SaaS engineering
Stripe subscription webhooks that don’t double-bill: idempotency, ordering and scheduled downgrades
By Waleed Siddiqui, founder & lead engineer
Stripe makes taking a first payment easy. Keeping subscriptions correct for months — through retries, upgrades, downgrades, failed cards and cancellations — is where most SaaS billing goes wrong. The symptoms are familiar: a customer charged twice, a paying member locked out, a downgrade that happened a month early, or an account that stays on the premium plan for free.
Almost all of these come from a handful of webhook mistakes. This article covers the patterns we use on subscription platforms we build, including a membership platform with four paid tiers, trials, scheduled downgrades and payment-failure grace periods (case study). Every Stripe behaviour below is from Stripe's own documentation, linked at the end.
What Stripe does — and does not — guarantee
Three facts from Stripe's webhook documentation shape everything else:
| Behaviour | What Stripe says | What it means for your code |
|---|---|---|
| Duplicates | Endpoints "might occasionally receive the same event more than once" | Every handler must be safe to run twice |
| Ordering | Stripe "doesn’t guarantee the delivery of events in the order that they’re generated" | invoice.paid can arrive before customer.subscription.created |
| Retries | Failed deliveries are retried "for up to three days with an exponential back off" in live mode | A bug can replay three days of events at once when you fix it |
Stripe also asks you to return a 2xx quickly, before any slow work, or the delivery times out and is retried — which creates more duplicates.
1. De-duplicate events in the database, not in memory
The usual fix — "check if we've seen this event ID, then process it" — has a race: two deliveries of the same event arrive together, both pass the check, and both grant the subscription. The check and the claim have to be one atomic step. A unique constraint does that for you:
create table stripe_events (
id text primary key, -- evt_... from Stripe
type text not null,
received_at timestamptz not null default now(),
processed_at timestamptz
);// app/api/stripe/webhook/route.ts (Next.js App Router)
export async function POST(req: Request) {
const body = await req.text(); // raw body — required for signature checks
const event = stripe.webhooks.constructEvent(
body,
req.headers.get('stripe-signature')!,
process.env.STRIPE_WEBHOOK_SECRET!,
);
// Atomic claim: only one delivery of this event can insert the row.
const { rowCount } = await db.query(
'insert into stripe_events (id, type) values ($1, $2) on conflict (id) do nothing',
[event.id, event.type],
);
if (rowCount === 0) return new Response('duplicate', { status: 200 });
await queue.enqueue('stripe-event', { id: event.id }); // do the work async
return new Response('ok', { status: 200 });
}Two details matter. Verify the signature against the raw body — frameworks that parse JSON first break verification. And acknowledge fast, then process from a queue, so a slow CRM call never turns into a timeout and a retry.
2. Make Stripe the source of truth — then re-read it
Never grant access because the browser was redirected to a "success" page. The redirect can be faked, closed early or replayed. Access changes only when a verified webhook says so.
Because events arrive out of order, do not apply the event payload as if it were the latest state. Instead, treat the event as a signal and fetch the current object from Stripe before you write anything:
async function syncSubscription(subscriptionId: string) {
const sub = await stripe.subscriptions.retrieve(subscriptionId);
await db.query(
`update memberships
set status = $2, price_id = $3, current_period_end = to_timestamp($4)
where stripe_subscription_id = $1`,
[sub.id, sub.status, sub.items.data[0].price.id, sub.items.data[0].current_period_end],
);
}Every subscription-related event — created, updated, deleted, invoice.paid, invoice.payment_failed — ends in the same syncSubscription call. The handler becomes idempotent by design: running it twice, or in the wrong order, converges on the same row.
3. Enforce plan access where a UI bug cannot leak it
Once the database knows each customer's plan, use it everywhere — ideally in the database itself. On Postgres (including Supabase), row-level-security policies can check the member's tier before returning a premium row, so a page that forgets an if (isPremium) check still cannot show paid content. On the membership platform above, plan-tier access is enforced by 46 RLS policies for exactly this reason.
4. Schedule downgrades for the end of the period
Customers expect to keep what they paid for until the period ends. Updating the subscription's price immediately does the opposite, and with default proration settings it can also create credits you did not intend. Stripe's tool for "change this at renewal" is a subscription schedule:
- Create a schedule from the existing subscription with
from_subscription. Stripe creates one phase matching the current billing period. - Update the schedule with two phases: the current one (same price, same start and end dates) and a second phase with the lower price.
- Keep
end_behavior: 'release'so the subscription continues normally once the schedule finishes.
const schedule = await stripe.subscriptionSchedules.create({
from_subscription: subscriptionId,
});
const current = schedule.phases[0];
await stripe.subscriptionSchedules.update(schedule.id, {
end_behavior: 'release',
phases: [
{
items: current.items.map((i) => ({ price: i.price as string, quantity: i.quantity })),
start_date: current.start_date,
end_date: current.end_date,
},
{ items: [{ price: LOWER_TIER_PRICE_ID, quantity: 1 }], duration: { interval: 'month', interval_count: 1 } },
],
});Three traps we design around:
- Updates replace phases wholesale. Stripe unsets anything you omit, so pass every item, discount, tax rate and piece of metadata on the current phase — not just the price.
- Direct subscription edits can be overwritten. While a schedule is attached, change the subscription through the Subscription Schedules API, or the next phase transition may undo your edit. Store the schedule ID next to the subscription ID.
- Upgrades usually should not wait. A customer upgrading wants the feature now: update the subscription directly with proration, and cancel any pending downgrade schedule first (release it) so the two do not fight.
Finally, make the downgrade self-healing: a scheduled job that compares each customer's stored plan with Stripe's and repairs any drift catches the rare case where a phase-transition webhook was lost or failed, before the customer notices.
5. Give failed payments a grace period, not an instant lock-out
Most failed renewals are expired or replaced cards, not customers trying to leave. Locking them out on the first invoice.payment_failed turns a fixable card problem into churn. A better sequence:
| Stage | Subscription status | What the customer sees |
|---|---|---|
| First failure | past_due | Full access, a banner and an email with a link to update their card |
| During Stripe’s retries | past_due | Access continues; reminders from Stripe’s Smart Retries and your own emails |
| Grace period ends | unpaid or canceled (per your Billing settings) | Access drops to the free tier; data is kept so they can come back |
| Card updated and paid | active | Access restored automatically by the same sync handler |
Because access is derived from Stripe's status through one sync function, the whole sequence needs no special-case code — just a clear mapping from status to entitlement.
A pre-launch checklist for subscription billing
- Signature verified on the raw body; endpoint excluded from CSRF middleware.
- Event IDs claimed atomically with a unique constraint.
- Webhook returns
2xxbefore slow work; processing runs from a queue. - Handlers re-fetch the object from Stripe instead of trusting payload order.
- Access granted only from webhooks, never from the success redirect.
- Downgrades scheduled for period end; upgrades applied immediately.
- Grace period for failed payments; data retained after downgrade.
- Nightly reconciliation between your database and Stripe.
- Tested with
stripe triggerand by replaying the same event twice.
If you are building billing into a new product, it belongs in the MVP from day one — see what a SaaS MVP costs. If billing in an existing product is already misbehaving, our SaaS development team can audit and fix it.
