Stripe billing for SaaS: the parts the docs quietly skip
Proration, failed payments, plan migrations, and the webhooks you actually need. The billing edge cases that bite in month three.
Stripe's happy path is a joy: drop in Checkout, collect a card, create a subscription. You'll have billing "working" in an afternoon. Then month three arrives — a card expires, someone upgrades mid-cycle, a customer churns and comes back — and you discover that the happy path was about 30% of the job. Here's the other 70%.
Your database is the source of truth, not Stripe's API
The most common mistake I see is treating Stripe as the place you read subscription state from on every request. Don't. Stripe is the system of record for payments; your database is the system of record for access. Mirror the subscription status locally and update it from webhooks. Then "can this user do X?" is a fast local read, not a network call that can rate-limit or time out at the worst moment.
Webhooks are the real integration
Checkout is the front door; webhooks are the plumbing that keeps your local state honest after the customer walks away. These are the events that matter, and the ones the quickstart never makes you handle:
// stripe/webhook.ts — the events that actually keep billing honest.
export async function handleStripeEvent(event: Stripe.Event) {
switch (event.type) {
case "invoice.payment_failed":
// Don't cut access yet — Stripe will retry. Flag and notify.
await markPastDue(event.data.object.customer);
break;
case "customer.subscription.deleted":
await revokeAccess(event.data.object.customer);
break;
case "customer.subscription.updated":
// Plan changes, proration, cancel-at-period-end all land here.
await syncSubscription(event.data.object);
break;
}
}subscription.deleted that runs revoke twice is harmless; a double-processed credit grant is a refund waiting to happen.The edge cases that actually bite
- Failed payments aren't churn. A
payment_failedis the start of a retry sequence, not the end of a subscription. Flag the account as past-due, email the customer, and let Stripe's dunning run before you cut access. Killing access on the first failure infuriates good customers whose card just expired. - Proration is a feature, not a glitch. When someone upgrades mid-cycle, Stripe prorates by default and the next invoice looks weird to anyone not expecting it. Decide your policy — prorate, or charge full at next cycle — and surface it in the UI before they're surprised by a charge.
- Plan migrations need a plan. When you reprice, existing customers shouldn't silently jump tiers. Grandfather them explicitly, or migrate on a date you communicate. Price IDs are forever; treat changing one as a migration, not an edit.
- Cancel ≠ delete. Cancel-at-period-end keeps access until the paid period ends. Don't revoke on the cancel event — revoke when the subscription actually ends. Two different webhooks, two different moments.
Reconcile, don't trust
Webhooks get dropped. Networks fail. At some point your local state and Stripe's will disagree, and you won't notice until a customer emails. Run a nightly reconciliation job that pulls active subscriptions from Stripe and compares them to your database. It's fifty lines that turn "silent billing drift" into "a log line you can act on."
Billing isn't a feature you build once. It's a system you operate — and the docs only ever cover the build.