OperationsCredit Top-ups

Credit Top-ups

One-time top-ups let paid organizations buy extra non-expiring credits from the billing page.

Runtime Map

SurfaceFile
Package configconfig/supastarter-config.ts
Top-up listing endpointpackages/api/src/routes/payments/router.ts
Checkout link endpointPOST /api/payments/create-checkout-link
Stripe webhookpackages/payments/provider/stripe/index.ts
Credit grant helperpackages/database/drizzle/queries/credits.ts
Credit tablescreditLedger, creditBalances
Manual customer grantPOST /api/zoe/credits/topup in packages/api/src/routes/zoe/credits.ts

Package Defaults

PackageCreditsPriceEnv var
100 credits100$10STRIPE_TOPUP_100_CREDITS_PRICE_ID
500 credits500$39STRIPE_TOPUP_500_CREDITS_PRICE_ID
1,000 credits1,000$69STRIPE_TOPUP_1000_CREDITS_PRICE_ID
5,000 credits5,000$259STRIPE_TOPUP_5000_CREDITS_PRICE_ID
10,000 credits10,000$499STRIPE_TOPUP_10000_CREDITS_PRICE_ID
50,000 credits50,000$2,299STRIPE_TOPUP_50000_CREDITS_PRICE_ID
100,000+ creditsContact salesContact salesNo Stripe price ID

Prices are proposed defaults. Change the amounts or credits in config/supastarter-config.ts if pricing changes.

Stripe Price Metadata

Set metadata on the Stripe Price, not only the Product.

credits=<credit amount>
credit_pool_type=TOP_UP

Optional compatibility key:

is_credit_product=true

The webhook also recognizes legacy pool_type=TOP_UP, but new prices should use credit_pool_type=TOP_UP.

Configured top-up Price IDs in config/supastarter-config.ts are authoritative for credit amount and TOP_UP pool. Price metadata is still required for Stripe catalog hygiene, but the webhook falls back to app config when configured top-up metadata is missing or wrong.

Local Testing

Create Test Products

  1. Open Stripe Dashboard in test mode.
  2. Go to Products.
  3. Click Add product.
  4. Name it Medialyst Top-up 100 credits.
  5. Add a one-time USD price for $10.
  6. Open the created price, then add price metadata:
credits=100
credit_pool_type=TOP_UP

Repeat for:

ProductPriceMetadata
Medialyst Top-up 500 credits$39credits=500, credit_pool_type=TOP_UP
Medialyst Top-up 1,000 credits$69credits=1000, credit_pool_type=TOP_UP
Medialyst Top-up 5,000 credits$259credits=5000, credit_pool_type=TOP_UP
Medialyst Top-up 10,000 credits$499credits=10000, credit_pool_type=TOP_UP
Medialyst Top-up 50,000 credits$2,299credits=50000, credit_pool_type=TOP_UP

Copy each price_... ID into local env.

Local Env

Set these in the env file used by pnpm --filter=@repo/web dev:

STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
STRIPE_TOPUP_100_CREDITS_PRICE_ID="price_..."
STRIPE_TOPUP_500_CREDITS_PRICE_ID="price_..."
STRIPE_TOPUP_1000_CREDITS_PRICE_ID="price_..."
STRIPE_TOPUP_5000_CREDITS_PRICE_ID="price_..."
STRIPE_TOPUP_10000_CREDITS_PRICE_ID="price_..."
STRIPE_TOPUP_50000_CREDITS_PRICE_ID="price_..."

Forward Webhooks

Run the app:

pnpm --filter=@repo/web dev

Forward Stripe test events to the mounted Hono API:

stripe listen \
  --events checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.payment_succeeded \
  --forward-to localhost:3000/api/webhooks/payments

Copy the printed whsec_... into STRIPE_WEBHOOK_SECRET, then restart the dev server.

If the app logs No signatures found matching the expected signature for payload, the webhook reached the app but was rejected before any credit logic ran. Use the signing secret from the exact delivery source:

  • Stripe CLI forwarding: use the whsec_... printed by that stripe listen process, then restart pnpm --filter=@repo/web dev.
  • Stripe Dashboard event destination: use that destination's signing secret.

Do not mix the Dashboard endpoint secret with a Stripe CLI-forwarded request, or the CLI secret with a Dashboard-delivered request.

Stripe CLI references:

Complete Checkout

  1. Sign in locally.
  2. Use an organization on an active or trialing paid plan.
  3. Go to Settings -> Billing.
  4. In Buy more credits, choose a package.
  5. Use Stripe test card 4242 4242 4242 4242, any future expiry, any CVC.

Useful test cards:

CardExpected result
4242 4242 4242 4242Successful payment
4000 0000 0000 0002Declined payment

Stripe card reference: Test card numbers.

Expected Stripe events:

checkout.session.completed
payment_intent.succeeded
charge.succeeded

Only checkout.session.completed grants credits.

Verify Credits

Find the organization ID, then query:

select
  "createdAt",
  "eventType",
  "poolType",
  "amount",
  "relatedId",
  "stripeEventId",
  "metadata"
from "creditLedger"
where "subjectType" = 'ORG'
  and "subjectId" = '<ORG_ID>'
order by "createdAt" desc
limit 20;

Expected ledger row:

eventType=GRANT
poolType=TOP_UP
amount=<package credits>
metadata.checkoutSessionId=<cs_...>
stripeEventId=<evt_...>:TOP_UP

Check the balance:

select
  "subjectType",
  "subjectId",
  "poolType",
  "available",
  "held",
  "updatedAt"
from "creditBalances"
where "subjectType" = 'ORG'
  and "subjectId" = '<ORG_ID>'
  and "poolType" = 'TOP_UP';

Verify Idempotency

Resend the same event:

stripe events resend evt_... --webhook-endpoint=we_...

Expected result:

  • The webhook calls grantCredits with the same stripeEventId: <event_id>:TOP_UP.
  • creditLedger.stripeEventId is unique.
  • The second delivery does not create another ledger row or increase creditBalances.

Production Setup

  1. Open Stripe Dashboard in live mode.
  2. Create the six products and one-time prices.
  3. Add price metadata exactly:
credits=<100|500|1000|5000|10000|50000>
credit_pool_type=TOP_UP
  1. Set Vercel env vars:
STRIPE_TOPUP_100_CREDITS_PRICE_ID="price_..."
STRIPE_TOPUP_500_CREDITS_PRICE_ID="price_..."
STRIPE_TOPUP_1000_CREDITS_PRICE_ID="price_..."
STRIPE_TOPUP_5000_CREDITS_PRICE_ID="price_..."
STRIPE_TOPUP_10000_CREDITS_PRICE_ID="price_..."
STRIPE_TOPUP_50000_CREDITS_PRICE_ID="price_..."
  1. Confirm the live webhook endpoint is enabled for:
checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.payment_succeeded
  1. Deploy.
  2. Buy a live $10 top-up with an internal paid org.
  3. Verify creditLedger and creditBalances.

Rollback

Stop new purchases:

  1. Remove the six STRIPE_TOPUP_*_CREDITS_PRICE_ID env vars from Vercel or roll back the deploy.
  2. Redeploy. In production, missing top-up price IDs are filtered out of GET /api/payments/topups.
  3. In Stripe, archive the top-up prices if they should no longer be sold.

Fix an under-grant:

curl -X POST "https://<host>/api/zoe/credits/topup" \
  -H "X-Admin-Key: $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "organizationId": "<ORG_ID>",
    "amount": 1000,
    "reason": "Manual correction for Stripe top-up <cs_...>"
  }'

Fix an over-grant:

POST /api/zoe/credits/topup only grants positive credits. Use the admin consume endpoint for negative corrections:

curl -X POST "https://<host>/api/admin/credits/consume" \
  -H "Cookie: <admin_session_cookie>" \
  -H "Content-Type: application/json" \
  -d '{
    "subjectType": "ORG",
    "subjectId": "<ORG_ID>",
    "amount": 1000,
    "reason": "Reverse mistaken Stripe top-up <cs_...>"
  }'

Trace Points

  • checkout.session.completed handler: packages/payments/provider/stripe/index.ts:803
  • Top-up grant call: packages/payments/provider/stripe/index.ts:861
  • grantCredits implementation: packages/database/drizzle/queries/credits.ts:87
  • Idempotency key format: <stripe_event_id>:<pool_type>, for example evt_123:TOP_UP
  • DB idempotency: unique index credit_ledger_stripe_event_id_idx on creditLedger.stripeEventId at packages/database/drizzle/schema/postgres.ts:691
  • Manual grant endpoint: packages/api/src/routes/zoe/credits.ts:38