SaaS billing build and test checklist
Every box to tick before billing real customers — build, test matrix, and cutover — with the assertion that proves each one actually works.
Keep this open in a tab while you build. It is the companion to Billing your SaaS app, which explains the decisions behind each item.
One rule governs the whole list: assert that state changed, not that nothing threw. Almost every billing bug worth finding produces no error — see Failures with no error.
Before you write code
| Decision | Why it matters |
|---|---|
| Where the secret key lives | Server-side, one place. If you also use a React SDK’s proxy handler, that is a second copy of the key in another host’s environment. Pick one. |
| Base URL as an env var | Sandbox → production must be config, not a code change. |
| Plan reference naming | Keep the tier ↔ reference map in one place. These are recreated by hand in production, and a typo charges the wrong amount. |
| What identifies a customer | externalId = your tenant id. It must be stable forever; webhooks resolve through it. |
| Trial: yours or Throttle’s | If your signup already grants a trial, pass the remaining days, not a constant. |
Build
Checkout
- Create a checkout session and redirect to the hosted URL
-
externalIdset to your tenant id on the customer - Return URL triggers a sync — do not rely on the webhook alone for the user-present path
- Cancel URL goes somewhere sensible
Trials
- Decided: local trial or Throttle-managed
- If local, pass remaining days; an expired trial passes
0 -
trialDays: 0charges the full amount today — make sure the button does not say “Start free trial” - Something enforces expiry, not just records it
- The merchant is told the trial ended
Plan changes
- Upgrades and downgrades use
POST /subscriptions/{id}/change-plan, never a second checkout - Upgrade sends
effective: 'now'— prorates and charges immediately - Downgrade sends
effective: 'period_end'— stages the change, no charge -
402 payment_failedsurfaces “add a payment method”, not a generic error - Falls back to checkout when there is no live subscription to change
- A scheduled downgrade is visible in your UI (
pendingPlanReference)
Cancellation
- Decided: period-end or immediate as the default
- The merchant is shown when access ends
- Both events handled — the two paths do not emit the same one
- Resubscribe works after cancelling
Discounts
- Codes created with the limits you need (
usageLimit,maxRedemptionsPerCustomer,startsAt/endsAt) -
POST /discounts/validatebefore submit;POST /discounts/previewto show the discounted total - The real rejection reason reaches the buyer — “expired” and “spend $20 more” are different problems
Payment methods
- Card entry goes through the embed. Never build your own card form
- The wallet is hidden until the tenant has a Throttle customer
Webhooks — budget the most time here
- Endpoint registered, signature verified, replay window enforced
- Fails closed when the signing secret is missing
- Tenant resolved from
data.customer.externalId— notexternalCustomerId - Unknown events return 200 but log loudly
- Subscribed to every event you handle — verify with the coverage endpoint
- Idempotent. You will receive duplicates
Entitlements
- Every tenant-creation path creates a subscription row
- The gate that enforces plan and expiry genuinely executes
- Billing routes stay reachable when a tenant is locked out, so they can pay to recover
Test matrix
Run against a sandbox environment. After each one, assert on your own database.
| # | Scenario | Assert |
|---|---|---|
| 1 | Sign up → trial | Local subscription row exists with an end date |
| 2 | Trial expiry | Status flips and access is actually blocked |
| 3 | Checkout completes | Subscription active, invoice paid, card vaulted, your DB updated |
| 4 | Trial → paid | No second trial granted; charged the right amount |
| 5 | Upgrade | Plan changed, prorated invoice paid |
| 6 | Upgrade with no card | 402 handled with an actionable message |
| 7 | Downgrade | Staged for period end, not applied now |
| 8 | Cancel at period end | Access retained, end date shown |
| 9 | Cancel immediately | Access revoked |
| 10 | Resubscribe after cancel | Works, no duplicate subscription |
| 11 | Discount: valid, expired, limit reached | Correct total, clear reason |
| 12 | Discount on renewal | Verify whether it carries — do not assume |
| 13 | Add, replace, remove card | Through the embed only |
| 14 | Payment failure | Status → past due, merchant notified |
| 15 | Renewal | The one you cannot trigger on demand |
| 16 | Duplicate webhook | No double-processing |
Everything except renewal can be driven headlessly through the API. A browser is only needed for card entry.
Prove webhooks before go-live
Do not wait for a real customer to find out. Use a dedicated test tenant:
- Give it a subscription row and record the baseline
- Create a Throttle customer whose
externalIdis that tenant’s real id - Create or modify a subscription through the API
- Assert your database changed
- Restore the tenant
This is the single highest-value test in the list, because a broken tenant lookup returns 200 on every delivery and looks perfectly healthy from both sides.
Cutover
- Live credentials set — API key, application id, webhook signing secret
- Base URL updated if the host differs
- Plan references recreated in production, amounts verified
- Webhook endpoint registered and subscribed to every event you handle
- Coverage endpoint reports nothing unsubscribed
- Your verification script confirms it is pointed at production, not sandbox
- One small real transaction, watched into your database
- Existing accounts converted
- Stored subscription ids relinked — sandbox ids are meaningless in production
- Plan gating enabled last, once nobody is stranded in a blocked state
- Confirm the deploy actually shipped — a failed build leaves the old image serving happily
- Diarise the first renewal, ~30 days out. It is the only path you could not test
See Going live for what each environment switch invalidates.