Playbook · Updated August 9, 2026

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

DecisionWhy it matters
Where the secret key livesServer-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 varSandbox → production must be config, not a code change.
Plan reference namingKeep the tier ↔ reference map in one place. These are recreated by hand in production, and a typo charges the wrong amount.
What identifies a customerexternalId = your tenant id. It must be stable forever; webhooks resolve through it.
Trial: yours or Throttle’sIf 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
  • externalId set 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: 0 charges 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_failed surfaces “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/validate before submit; POST /discounts/preview to 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.externalIdnot externalCustomerId
  • 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.

#ScenarioAssert
1Sign up → trialLocal subscription row exists with an end date
2Trial expiryStatus flips and access is actually blocked
3Checkout completesSubscription active, invoice paid, card vaulted, your DB updated
4Trial → paidNo second trial granted; charged the right amount
5UpgradePlan changed, prorated invoice paid
6Upgrade with no card402 handled with an actionable message
7DowngradeStaged for period end, not applied now
8Cancel at period endAccess retained, end date shown
9Cancel immediatelyAccess revoked
10Resubscribe after cancelWorks, no duplicate subscription
11Discount: valid, expired, limit reachedCorrect total, clear reason
12Discount on renewalVerify whether it carries — do not assume
13Add, replace, remove cardThrough the embed only
14Payment failureStatus → past due, merchant notified
15RenewalThe one you cannot trigger on demand
16Duplicate webhookNo 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:

  1. Give it a subscription row and record the baseline
  2. Create a Throttle customer whose externalId is that tenant’s real id
  3. Create or modify a subscription through the API
  4. Assert your database changed
  5. 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.