Credit Top-ups
One-time top-ups let paid organizations buy extra non-expiring credits from the billing page.
Runtime Map
| Surface | File |
|---|---|
| Package config | config/supastarter-config.ts |
| Top-up listing endpoint | packages/api/src/routes/payments/router.ts |
| Checkout link endpoint | POST /api/payments/create-checkout-link |
| Stripe webhook | packages/payments/provider/stripe/index.ts |
| Credit grant helper | packages/database/drizzle/queries/credits.ts |
| Credit tables | creditLedger, creditBalances |
| Manual customer grant | POST /api/zoe/credits/topup in packages/api/src/routes/zoe/credits.ts |
Package Defaults
| Package | Credits | Price | Env var |
|---|---|---|---|
| 100 credits | 100 | $10 | STRIPE_TOPUP_100_CREDITS_PRICE_ID |
| 500 credits | 500 | $39 | STRIPE_TOPUP_500_CREDITS_PRICE_ID |
| 1,000 credits | 1,000 | $69 | STRIPE_TOPUP_1000_CREDITS_PRICE_ID |
| 5,000 credits | 5,000 | $259 | STRIPE_TOPUP_5000_CREDITS_PRICE_ID |
| 10,000 credits | 10,000 | $499 | STRIPE_TOPUP_10000_CREDITS_PRICE_ID |
| 50,000 credits | 50,000 | $2,299 | STRIPE_TOPUP_50000_CREDITS_PRICE_ID |
| 100,000+ credits | Contact sales | Contact sales | No 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_UPOptional compatibility key:
is_credit_product=trueThe 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
- Open Stripe Dashboard in test mode.
- Go to Products.
- Click Add product.
- Name it
Medialyst Top-up 100 credits. - Add a one-time USD price for
$10. - Open the created price, then add price metadata:
credits=100
credit_pool_type=TOP_UPRepeat for:
| Product | Price | Metadata |
|---|---|---|
Medialyst Top-up 500 credits | $39 | credits=500, credit_pool_type=TOP_UP |
Medialyst Top-up 1,000 credits | $69 | credits=1000, credit_pool_type=TOP_UP |
Medialyst Top-up 5,000 credits | $259 | credits=5000, credit_pool_type=TOP_UP |
Medialyst Top-up 10,000 credits | $499 | credits=10000, credit_pool_type=TOP_UP |
Medialyst Top-up 50,000 credits | $2,299 | credits=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 devForward 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/paymentsCopy 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 thatstripe listenprocess, then restartpnpm --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
- Sign in locally.
- Use an organization on an active or trialing paid plan.
- Go to Settings -> Billing.
- In Buy more credits, choose a package.
- Use Stripe test card
4242 4242 4242 4242, any future expiry, any CVC.
Useful test cards:
| Card | Expected result |
|---|---|
4242 4242 4242 4242 | Successful payment |
4000 0000 0000 0002 | Declined payment |
Stripe card reference: Test card numbers.
Expected Stripe events:
checkout.session.completed
payment_intent.succeeded
charge.succeededOnly 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_UPCheck 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
grantCreditswith the samestripeEventId:<event_id>:TOP_UP. creditLedger.stripeEventIdis unique.- The second delivery does not create another ledger row or increase
creditBalances.
Production Setup
- Open Stripe Dashboard in live mode.
- Create the six products and one-time prices.
- Add price metadata exactly:
credits=<100|500|1000|5000|10000|50000>
credit_pool_type=TOP_UP- 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_..."- Confirm the live webhook endpoint is enabled for:
checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.payment_succeeded- Deploy.
- Buy a live $10 top-up with an internal paid org.
- Verify
creditLedgerandcreditBalances.
Rollback
Stop new purchases:
- Remove the six
STRIPE_TOPUP_*_CREDITS_PRICE_IDenv vars from Vercel or roll back the deploy. - Redeploy. In production, missing top-up price IDs are filtered out of
GET /api/payments/topups. - 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.completedhandler:packages/payments/provider/stripe/index.ts:803- Top-up grant call:
packages/payments/provider/stripe/index.ts:861 grantCreditsimplementation:packages/database/drizzle/queries/credits.ts:87- Idempotency key format:
<stripe_event_id>:<pool_type>, for exampleevt_123:TOP_UP - DB idempotency: unique index
credit_ledger_stripe_event_id_idxoncreditLedger.stripeEventIdatpackages/database/drizzle/schema/postgres.ts:691 - Manual grant endpoint:
packages/api/src/routes/zoe/credits.ts:38