Stripe subscription billing patterns I use in every SaaS
Six patterns from 12+ SaaS builds — webhook idempotency, plan transitions, dunning, trials, taxes, and the customer portal trap. With code.
Twelve SaaS builds in, I have a checklist for Stripe billing. Same six patterns, every time. Skip any one of them and you will have a 3 AM bug at month-end.
1. Idempotent webhook handling
The single most important thing.
async function handleWebhook(event: Stripe.Event) {
const exists = await db.query.webhookEvents.findFirst({
where: eq(webhookEvents.id, event.id),
});
if (exists) return; // Stripe retries — we no-op
await db.transaction(async (tx) => {
await tx.insert(webhookEvents).values({ id: event.id, type: event.type });
await routeEvent(event, tx);
});
}
event.id as primary key, transaction wrapping insert + business logic. Stripe retries up to 72 hours. You will get duplicates. Treat them as a normal case.
2. Plan changes via Stripe, not via your DB
It is tempting to flip a plan_id in your subscriptions table when a user clicks "upgrade". Don't. Always create a Stripe checkout session or subscription update, then let the customer.subscription.updated webhook update YOUR database.
This way Stripe stays the source of truth and your DB is a derived view. It also makes proration, coupons, and taxes Stripe's problem (they are good at it; you are not).
3. Trial logic at the entitlement layer
Trials should not be a column on the user. They should be a state of the subscription. Your entitlement check looks like this:
function canUseFeature(sub: Subscription, feature: string): boolean {
if (sub.status === "trialing" && trialFeatures.has(feature)) return true;
if (sub.status === "active") return planFeatures(sub.plan).has(feature);
if (sub.status === "past_due" && gracePeriod(sub)) return true;
return false;
}
Five states, one function, called from every protected route. Tested with table-driven unit tests.
4. Dunning + grace period
Cards fail. A lot. Default Stripe Smart Retries handles the technical retry. You handle the UX:
- Day 0: card fails — email "we'll try again", flip status to
past_duebut keep access. - Day 3: still failing — in-app banner with "update card" CTA.
- Day 7: still failing — partial restrictions (read-only).
- Day 14: full block, archive workspace.
The tunable here is the grace period length. B2B SaaS = 7-14 days. Consumer = 3-7 days.
5. Taxes — Stripe Tax or you'll cry
Stripe Tax is $0.40 per transaction and it does the EU VAT, US sales tax, India GST, etc. Just use it. Building this yourself is a 6-week project that never ends.
Edge case: if you sell to enterprise customers who require their VAT number on the invoice, capture it at checkout and pass it as tax_id on the customer. Stripe handles the reverse-charge logic automatically.
6. The customer portal trap
Stripe Customer Portal is a gift — pre-built UI for cancellations, plan changes, invoices. Use it.
But: customers can cancel from there without your app knowing in real-time. Your app finds out via the customer.subscription.deleted webhook. If you have a "save offer" flow you want to trigger on cancel, you have to either:
- Disable cancellation in the portal and build your own.
- OR react to the webhook with a "we noticed you cancelled — here is 30% off if you stay" email.
The second one is what I do for most clients. Less custom code, almost as effective.
Bonus: testing
Stripe CLI is great. stripe listen --forward-to localhost:3000/api/stripe/webhook saves a day of frustration. Pair it with stripe trigger customer.subscription.updated to fire fake events. Combine with a ?test=true query string on your endpoint that bypasses signature verification IN DEV ONLY (delete this code before prod, I have seen people forget).
The 10-minute checklist
When I scope a new SaaS, my Stripe-billing setup item is always 1.5-2 weeks. Here is the breakdown:
- [ ] Stripe account, products, prices created
- [ ] Checkout session endpoint
- [ ] Customer portal endpoint
- [ ] Webhook endpoint with signature verification
- [ ]
webhookEventstable for idempotency - [ ] Entitlement function
- [ ] Trial logic in entitlement
- [ ] Dunning email/banner flow
- [ ] Stripe Tax enabled
- [ ] Test mode → live mode switch documented
If any of these are missing on your current SaaS, you have a bug in your future. Happy to audit yours — usually a 30-minute call gets to the worst issue.
Author
Usama
I have spent the last 6+ years shipping production websites and apps from Pakistan for clients across 30+ countries. I work daily on Fiverr and Upwork, and partner directly with founders, agencies and local businesses on long-term builds.
More about Usama