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:

BehaviourWhat Stripe saysWhat it means for your code
DuplicatesEndpoints "might occasionally receive the same event more than once"Every handler must be safe to run twice
OrderingStripe "doesn’t guarantee the delivery of events in the order that they’re generated"invoice.paid can arrive before customer.subscription.created
RetriesFailed deliveries are retried "for up to three days with an exponential back off" in live modeA 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:

  1. Create a schedule from the existing subscription with from_subscription. Stripe creates one phase matching the current billing period.
  2. Update the schedule with two phases: the current one (same price, same start and end dates) and a second phase with the lower price.
  3. 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:

StageSubscription statusWhat the customer sees
First failurepast_dueFull access, a banner and an email with a link to update their card
During Stripe’s retriespast_dueAccess continues; reminders from Stripe’s Smart Retries and your own emails
Grace period endsunpaid or canceled (per your Billing settings)Access drops to the free tier; data is kept so they can come back
Card updated and paidactiveAccess 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 2xx before 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 trigger and 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.

Sources

  1. Stripe Docs — Receive Stripe events in your webhook endpoint (duplicates, ordering, retries, signatures), accessed 2026-10-24
  2. Stripe Docs — Subscription schedules (phases, end_behavior, scheduling a downgrade), accessed 2026-10-24
  3. Stripe Docs — Change the price of existing subscriptions

Questions we get about this

More from the blog

Tell us what is going wrong with your billing

Tell us what you are building or what needs fixing. You will hear back from an engineer — not a sales rep — within one business day, usually with questions or a rough approach.

No newsletter, no sales sequence. We use your details only to reply.

DablewDevs

Your enquiry goes straight to our engineering team — the people who will scope and build your project, not a sales desk.

© 2026 DablewDevs. All rights reserved.