Playbook · Updated August 9, 2026

Billing your SaaS app with Throttle

Putting subscription billing behind a multi-tenant app: the decisions that are hard to reverse, the integration in order, and the cutover.

This is the playbook for wiring Throttle billing into a SaaS product — a multi-tenant app where each account has a plan, a trial, and a bill. It is written in the order you should build, and the decisions come first because several of them are expensive to change later.

The companion checklist is what you tick through while doing it. This page explains why each item is there.

Four decisions before any code

Where the secret key lives. Server-side, in exactly one place. If your frontend also uses a React SDK’s proxy handler, that is a second copy of the key in another host’s environment — two places to rotate and two places to leak. Route everything through your own backend, or use the SDK proxy, but pick one.

The base URL is configuration, not a constant. Moving from sandbox to production has to be an environment variable change. If it is a code change, someone will ship the wrong one.

What identifies a tenant. Set externalId on the Throttle customer to your own tenant id. It has to be stable forever, because every webhook resolves through it. Pick the id that never changes — usually the organization or account row’s primary key, never an email or a slug.

Whose trial is it. If your signup already grants a trial, Throttle should not grant a second one. Pass the remaining days at checkout, not a constant, and pass 0 for an expired trial. Note that trialDays: 0 means the customer is charged today — make sure the button does not say “Start free trial”.

Model the plans

Keep the map from your tier names to Throttle plan references in one place, overridable per environment. You will recreate these by hand in production, and a typo charges the wrong amount to real customers.

Amounts are integers in minor units everywhere — 4900 is $49.00.

Subscribe

Create a checkout session and redirect to the hosted URL. The customer should already exist, or be created as part of the session, with externalId set.

Two paths bring state back to you, and you want both:

  • The return URL, for the user-present path. Sync immediately rather than waiting for a webhook, so the buyer sees their new plan the moment they land back.
  • The webhook, for everything else — renewals, dunning, cancellations that happen with nobody watching.

Relying on only the return URL means renewals never register. Relying on only the webhook means a visible lag right after payment, which reads as a broken checkout.

Upgrades and downgrades

Use POST /subscriptions/{id}/change-plan. Do not send an existing subscriber back through checkout — checkout always creates a new recurring subscription, so you end up with two live subscriptions and a double bill.

The effective field decides what happens to money:

DirectionSendBehaviour
Upgradeeffective: 'now'Prorates and charges the stored card immediately
Downgradeeffective: 'period_end'Stages the change, no charge — they paid for this period

Two responses to handle deliberately:

  • 402 payment_failed — the stored card was declined or there isn’t one. Surface “add a payment method”, not a generic failure. This is the most common real-world outcome of an upgrade attempt.
  • No live subscription — fall back to checkout. A lapsed customer upgrading is really a new subscription.

A staged downgrade lands in pendingPlanReference. Show it: a customer who downgraded and sees no change assumes it failed and does it again.

Cancellation

Decide your default. Period-end keeps access the customer already paid for; immediate revokes now. Most SaaS products want period-end.

They emit different events, which is the trap:

Called withEventSubscription looks like
atPeriodEnd: falsesubscription.cancelledstatus: cancelled
atPeriodEnd: truesubscription.updatedstatus: active, cancelAtPeriodEnd: true

Handle only the first and a scheduled cancellation is invisible to you — no confirmation, and your Cancel button stays live because the subscription is still active.

Always tell the merchant when access ends, and make resubscribing work.

Payment methods

Card entry goes through the hosted embed, via @usethrottle/payment-methods or hosted checkout. Never build your own card form.

This is not only a PCI question, though it is that too — the provider-hosted iframe means a PAN never touches your DOM or your servers. It is also correctness: a card attached by any other route saves fine, lists fine, and then fails at charge time. You find out at the first renewal.

Hide the wallet UI until the tenant actually has a Throttle customer, or you will render an empty component to every new signup.

Webhooks

Budget more time here than you expect. This is where integrations break silently.

  • Verify the signature, enforce a replay window, and fail closed if the signing secret is missing. A missing secret should stop the process, not skip verification.
  • Resolve your tenant from data.customer.externalId. Not externalCustomerId — that is a per-connection mapping and is null for direct integrations, so reading it silently returns nothing forever.
  • Return 200 for events you do not handle, but log them loudly.
  • Be idempotent. You will receive duplicates.
  • Confirm you are actually subscribed to every event you handle. A perfect handler for an unsubscribed event never runs and never complains — the coverage endpoint exists to catch exactly this.

Entitlements

Two things fail here, both silently.

Every tenant-creation path needs a subscription row. Count them: signup, invite, admin creation, seed, import. Any path that skips it produces an account with permanent free access that is never asked to pay.

The gate has to actually execute. A check registered before tenant context exists, or on a router that does not cover your API, returns early on every request — and both expiry lockout and plan gating become dead code with no symptom. Prove it by putting a tenant in a blocked state and confirming you get a 403. Reading the code is not evidence.

And keep billing routes reachable when a tenant is locked out. Otherwise they cannot pay to recover, which turns a dunning problem into a support ticket.

Test it properly

The full matrix is in the checklist. The habit that matters: assert your own database changed. Not the API response, not the absence of an exception.

Everything except a renewal can be driven headlessly through the API — a browser is only needed for card entry. Renewal is the one path you cannot trigger on demand, so diarise the first one.

Before go-live, prove the webhook path end to end with a dedicated test tenant: create a Throttle customer whose externalId is that tenant’s real id, drive a subscription change through the API, and assert your database moved. A broken tenant lookup returns 200 on every delivery and looks healthy from both sides, so this is the only test that catches it.

Cutover

Switching environments invalidates more than the API key — plan references, the webhook signing secret, and every stored subscription id. The full sequence, and what each switch breaks, is in Going live.

Two things worth repeating here: enable plan gating last, once nobody can be stranded in a blocked state; and confirm the deploy actually shipped, because a failed build leaves the previous image serving happily while you debug code that was never released.

Where to look when something is wrong

Start with the delivery log: GET /api/v1/webhook-deliveries?endpointId=. It shows the exact payload sent and the status your endpoint returned, which separates “never sent” from “sent and swallowed” — completely different bugs with identical symptoms.

Then read Failures with no error, which collects the mistakes that produce no exception anywhere.