3
2 Comments

The Stripe webhook bug that quietly creates duplicate subscriptions

Stripe webhooks feel simple until you handle them at scale: receive event, update database, done. Except Stripe explicitly documents that webhooks can be delivered more than once for the same event — network retries, timeouts on your end, Stripe's own retry logic. If your handler isn't idempotent, "delivered twice" quietly becomes "charged twice" or "subscription created twice."

A few failure modes that are easy to miss even when your webhook "works" in testing:

The same checkout.session.completed event processed twice — if you're creating a subscription record on receipt without checking whether that event ID was already handled, a retried webhook creates a duplicate row, not just a duplicate log line
Out-of-order delivery — Stripe doesn't guarantee events arrive in the order they occurred. A subscription.updated event can arrive before the subscription.created event it depends on, if you're not careful about how you handle missing state
Partial failure between steps — if your handler updates the database but then fails before marking the webhook as processed, the retry reprocesses the whole thing from scratch, including any side effects (emails, credits granted, usage resets) that already happened once

The fix isn't complicated, but it's easy to skip under deadline pressure: store the Stripe event ID, check it before processing, and make every side effect (not just the database write) safe to run twice. That last part is the one people usually miss — idempotent database writes don't help if the same webhook also fires a "welcome to Pro" email twice.

This is one of the things I test explicitly when I look at someone's billing integration — event replay, out-of-order delivery, and partial-failure recovery, not just "does the happy path work."

If you're dealing with this in your own Stripe integration and want a second pair of eyes, I do quick audits for exactly this kind of thing. I also built the full pattern (webhook idempotency + subscription state sync + entitlements) into a foundation I use for B2B SaaS backends, if that's useful as a reference.

Curious what edge cases others have run into with Stripe webhooks — anything I'm missing here?

on September 17, 2026
  1. 1

    The partial-failure case is the one that catches people, I reckon. Everyone remembers to dedupe the Stripe event eventually, but fewer people make the whole business action resumable.

    One pattern I like is treating the webhook as a signal to sync state from Stripe, not as the only source of truth. Store the event, lock the customer or subscription, fetch the current Stripe object, then update local entitlements from that state. It makes out-of-order delivery much less scary.

    I’d also add a reconciliation job to the checklist. Even with good webhook handling, billing deserves a boring daily sweep: Stripe says this customer has X, your app says Y, and any mismatch gets surfaced before a customer notices.

    1. 1

      That's a better mental model than mine, honestly — the webhook is now the record. Treating it as "something changed, go check Stripe's actual state" instead of "this payload is truth" sidesteps the ordering problem almost entirely, rather than trying to handle every possible arrival order.

      The reconciliation job is the piece I think most people (including me) treat as optional when it really isn't. Webhook delivery is good, not perfect, and a boring daily sweep catching the rare miss is a lot cheaper than a customer noticing their access is wrong. Adding this to how I think about the pattern going forward — appreciate the pushback.