Integrating Stripe's happy path takes a day: checkout session, success page, done. Then real customers arrive with expired cards, mid cycle upgrades, disputed charges and company cards that require 3-D Secure, and you discover the happy path was maybe 20 percent of the work.
I have owned Stripe billing on several production SaaS products. This is the architecture that holds up, including the database shape, the webhook rules and the miserable paths most tutorials skip.
Rule 1: Stripe is the source of truth, your DB is a cache
The moment you try to compute subscription state yourself, you will drift from reality. Instead:
- Store
stripeCustomerIdandstripeSubscriptionIdon your user or organization. - Mirror only the fields your product logic reads:
status,priceId,currentPeriodEnd,cancelAtPeriodEnd. - Update that mirror only from webhooks, never from your own optimistic writes after a checkout redirect.
The success URL is a UX hint, not a fact. The user can close the tab before it loads, and the payment still happened. The webhook is the fact.
A minimal mirror table looks like this:
ALTER TABLE organizations ADD COLUMN stripe_customer_id text UNIQUE;
ALTER TABLE organizations ADD COLUMN stripe_subscription_id text;
ALTER TABLE organizations ADD COLUMN plan_status text DEFAULT 'none';
ALTER TABLE organizations ADD COLUMN price_id text;
ALTER TABLE organizations ADD COLUMN current_period_end timestamptz;
ALTER TABLE organizations ADD COLUMN cancel_at_period_end boolean DEFAULT false;Six columns. Everything else lives in Stripe where it belongs.
Rule 2: Treat webhooks as hostile input
Stripe will retry webhooks, deliver them out of order, and occasionally deliver them twice. Your handler needs three properties:
- Verify the signature. Always, no exceptions, before parsing anything.
- Idempotency. Store processed
event.idvalues in a table with a unique constraint and skip duplicates. This one column has prevented more 2am incidents than any monitoring I have set up. - Fetch, don't trust. For anything important, use the event as a trigger and fetch the current object from Stripe's API. Then an out of order update cannot poison your state, because you always write the freshest truth.
Process the event in a background job if it does anything heavier than a row update. Stripe times out slow endpoints and retries, and without idempotency those retries multiply your bugs.
Rule 3: Four events carry the whole system
You can ship a robust billing system by handling a surprisingly small set well:
checkout.session.completed: link the customer and provision access.customer.subscription.updated: plan changes, scheduled cancellations, trials converting. Just re-sync the mirror.customer.subscription.deleted: remove access, with a grace period rather than a trapdoor.invoice.payment_failed: begin dunning.
Everything else is either informational or already covered by re-syncing on update.
Rule 4: Dunning is where SaaS quietly leaks revenue
A failed renewal is not a cancellation. It is usually an expired card on a customer who wants to keep paying you. Treat it that way:
- Day 0: payment fails. Email the customer, show a banner in the app. Do not cut access.
- Days 3 to 7: Stripe Smart Retries runs its schedule. A large share of failures recover on their own if you let them.
- Day 10 or so: a firmer email, access moves to read only.
- Day 14+: subscription cancels, access ends, data is retained for a window you state clearly.
Teams that hard cut access at the first failure turn recoverable revenue into churn and support tickets into escalations. The sequence above is boring and it works.
Rule 5: Let Stripe's own UI do the heavy lifting
The hosted Customer Portal handles card updates, plan switches, invoices and cancellation flows. Every hour you spend rebuilding it is an hour not spent on your product, and Stripe's version handles the compliance edge cases you have not imagined yet. Deep link to it and move on. Your billing settings page becomes one button.
Rule 6: Test the miserable paths with test clocks
Stripe's test clocks let you fast forward a simulated customer through months of billing in minutes: trial expiry, failed renewals, upgrades mid cycle. Before shipping, walk each path and check three things: what does the user see, what email goes out, and what does your database say. If any of the three surprises you, production would have surprised you harder.
The specific scenarios I always run: card declines at renewal, upgrade halfway through a period, cancellation that reverses before period end, and a 3-D Secure challenge at checkout.
The checklist
Signature verification, event idempotency, fetch don't trust, background processing, a six column mirror synced only from webhooks, the customer portal, a real dunning sequence, and test clock rehearsals. Boring, yes. That is the point. Billing should be the most boring, reliable part of your product, because nothing erodes trust faster than money behaving strangely.
Want your billing flow reviewed before it costs you revenue? Let's talk.
Need this built?
I offer these as services. Happy to help with yours.