Stripe handles the card network, the vaulting, and the PCI envelope. Everything that sits between Stripe and your database — webhook delivery, idempotency, subscription state, and failed-payment recovery — is still your problem. Teams that treat billing as a simple SDK integration are the same teams that, six months in, discover silent double-charges, orphaned subscriptions, and a dunning flow that quietly loses 20% of renewals. The patterns below are what production SaaS billing actually requires: raw-body signature verification, idempotent event handling, smart retries, reconciliation, and a dunning sequence that behaves like a system rather than a hope.
Webhook signature verification: raw body or nothing
Stripe signs every webhook event with a timestamp and HMAC over the exact request body. If any middleware parses, re-serialises, or mutates that body before your verifier sees it, the signature check fails and you're left with a blind endpoint. The Express pattern most teams start with — app.use(express.json()) — silently breaks Stripe verification because it replaces the raw buffer with a parsed object.
// Correct raw-body Stripe webhook route in Express
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }), // raw bytes, not JSON
async (req, res) => {
const sig = req.headers["stripe-signature"] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
return res.status(400).send("signature verification failed");
}
// 1. Acknowledge fast (Stripe times out at ~10s and retries).
res.status(200).send("ok");
// 2. Hand off to a queue for idempotent processing.
await queue.publish("stripe.events", { id: event.id, payload: event });
},
);Three things break signature verification in production: a global JSON middleware mounted before the webhook route, a reverse proxy that rewrites content-encoding, and a logging middleware that reads the request stream. Mount the raw-body parser only on the webhook route, and test the signature path in CI with a recorded Stripe payload.
Events you actually need to handle
Stripe emits around 200 event types. Most SaaS products need a consistent response to roughly a dozen of them. The rest can be logged and ignored until a feature depends on them. Here's the minimum set for a subscription product with a self-serve billing portal.
| Event | When it fires | What your handler does |
|---|---|---|
| checkout.session.completed | New subscription created via Checkout | Provision the account, link customer ID, send welcome email |
| customer.subscription.created | Subscription begins (including trials) | Record status, plan, trial end, seats |
| customer.subscription.updated | Plan change, seat change, status flip | Sync plan, seat count, and status to your DB |
| customer.subscription.deleted | Subscription cancelled at period end | Downgrade or disable the account on period end |
| invoice.paid | Successful payment, including renewals | Extend access, emit receipt, reset past-due state |
| invoice.payment_failed | Charge failed on renewal or trial end | Mark past-due, start dunning sequence |
| invoice.payment_action_required | 3DS or SCA step required | Email the customer a portal link to authenticate |
| customer.subscription.trial_will_end | 3 days before trial ends | Nudge the customer to add a payment method |
| charge.refunded | Refund issued | Revoke access if applicable, update ledger |
| charge.dispute.created | Chargeback opened | Freeze account, notify finance, gather evidence |
Idempotency: the double-charge trap
Stripe delivers webhooks at-least-once. Your endpoint will, at some point, receive the same event twice. If your handler is not idempotent, the second delivery can provision the account again, email the customer again, or extend their subscription by another month. The fix is not clever — it's discipline: every side effect is keyed to the event ID, and every write checks whether that event ID has already been processed.
// Idempotent webhook consumer using a processed-events table
async function handleStripeEvent(event: Stripe.Event) {
const alreadySeen = await db.processedEvents.findUnique({
where: { id: event.id },
});
if (alreadySeen) return; // duplicate delivery, nothing to do
await db.$transaction(async (tx) => {
await tx.processedEvents.create({
data: { id: event.id, type: event.type, receivedAt: new Date() },
});
switch (event.type) {
case "invoice.paid":
await extendAccess(tx, event.data.object as Stripe.Invoice);
break;
case "invoice.payment_failed":
await startDunning(tx, event.data.object as Stripe.Invoice);
break;
// ...
}
});
}The same discipline applies on the outbound side. Every Stripe API call that creates money — subscription creation, one-off charge, refund — needs an Idempotency-Key header. Stripe deduplicates for 24 hours per key, which means a retry from your job queue can't silently create two subscriptions.
Don't derive idempotency keys from request inputs alone (for example, customerId+planId). If a customer legitimately subscribes, cancels, and subscribes again, the two requests share inputs but are distinct intents. Use a per-request UUID and persist it with the job that triggered the call.
Failed payments: smart retries and dunning
Between 5% and 15% of subscription renewals fail on the first attempt. The gap between teams that recover 50% of that revenue and teams that recover 10% comes down to retry strategy and how the customer hears about it. Stripe's Smart Retries uses machine learning against billions of transactions to pick retry times and materially outperforms fixed schedules. Turn it on unless you have a specific reason not to.
The dunning sequence that actually works
- Day 0 — payment fails. Mark the subscription past-due. Do not lock the account yet. Send a calm, specific email: which card, which amount, and a one-click link to the Stripe Billing Portal to update it.
- Day 3 — second retry. If still failing, send a second email and trigger an in-app banner for users on that account. Avoid scary language; you're reminding, not threatening.
- Day 7 — third retry plus a last-chance email. Offer a concrete downgrade path for customers who want to stay on a smaller plan rather than churn.
- Day 14 — cancel or pause. Stop the subscription per your policy. Keep data in place for a defined retention window so win-backs can happen.
The single biggest recovery lever is using the Stripe Customer Portal for card updates instead of rolling your own form. It handles SCA, 3DS, and regional payment methods correctly, and every update triggers a retry automatically. You inherit Stripe's work without shipping card UI.
Subscription lifecycle state — source of truth
A common bug: the app shows the customer as active while Stripe has them as past_due, or the app denies access while Stripe shows paid. This happens when the app derives state from its own writes rather than from the webhook stream. The rule to keep this quiet: Stripe is the source of truth for subscription state. Your database mirrors it. Every subscription write in your app is a reaction to an event, not a primary action.
- Store the Stripe customer ID, subscription ID, and current status on the account row.
- Persist the current_period_end timestamp — this is what gates access, not a local boolean.
- Record the last processed event ID on the subscription so you can detect stale writes.
- Never update subscription state from a button click alone; trigger the Stripe API, wait for the webhook, and let the webhook write the state.
Reconciliation: the nightly job that saves weekends
Webhooks are reliable but not complete. Networks drop events, your endpoint has bad days, and Stripe retries for three days and then stops. A nightly reconciliation job closes the gap: pull subscriptions updated in the last 36 hours from the Stripe API, compare status and period end against your DB, and reconcile any drift. The job usually finds nothing; on the day it does, it saves a support escalation.
// Nightly reconciliation — run via cron at 02:00 UTC
async function reconcileSubscriptions() {
const since = Math.floor(Date.now() / 1000) - 36 * 60 * 60;
for await (const sub of stripe.subscriptions.list({
created: { gte: since },
status: "all",
limit: 100,
})) {
const local = await db.subscription.findUnique({
where: { stripeId: sub.id },
});
if (!local) {
await logMissingSubscription(sub);
continue;
}
if (
local.status !== sub.status ||
local.currentPeriodEnd.getTime() / 1000 !== sub.current_period_end
) {
await db.subscription.update({
where: { stripeId: sub.id },
data: {
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
reconciledAt: new Date(),
},
});
}
}
}Testing Stripe without breaking things
The Stripe CLI forwards test-mode webhooks to localhost and lets you trigger specific events on demand. Combine it with test clocks for subscription flows — you can simulate a trial ending, a renewal, and a failed payment in seconds instead of waiting a month. Every webhook handler in your codebase should have a unit test that feeds it a real recorded Stripe payload and asserts the side effect.
- stripe listen --forward-to localhost:3000/webhooks/stripe during development.
- stripe trigger invoice.payment_failed to rehearse the dunning path.
- Test clocks to advance subscriptions through trials, renewals, and cancellations in minutes.
- Record representative payloads in a fixtures directory and replay them in CI — this catches breakage from Stripe API version bumps.
Key takeaways
- Verify every webhook on the raw body with the exact stripe-signature header — any middleware that parses the body first will break you.
- Every webhook side effect is idempotent, keyed on the Stripe event ID, and persisted in a processed-events table inside the same transaction.
- Every Stripe API call that moves money carries an Idempotency-Key so retries from your queue can't double up.
- Treat Stripe as the source of truth for subscription state. Your database mirrors it via the webhook stream, never the other way around.
- Turn on Smart Retries, route card updates through the Stripe Customer Portal, and instrument the dunning emails — recovery rates of 40–55% are achievable.
- Run a nightly reconciliation job against the Stripe API. The day it finds drift is the day it pays for itself ten times over.