stripewebhooksidempotencyinvoluntary churn

Stripe Webhook Bugs Are Quietly Creating Churn You're Blaming on Customers

A crashed webhook handler can lock out a paying customer or bill them twice — and your churn report will never tell the two apart.

XY
27 August 2026 · 8 min read

A customer pays. Stripe confirms the charge. And in your product, nothing happens — no access granted, no plan upgraded, because the process that was supposed to react to that payment crashed halfway through, or never ran at all. Three days later that customer is emailing support asking why they were charged for a tool they can't use, and by the end of the week they've disputed the charge or canceled outright. Your churn report logs it as "customer left." Nothing about that record says the actual cause was a webhook handler returning a 500.

Key stat
3 DAYS
How long Stripe keeps retrying a failed webhook delivery before it gives up and disables your endpoint
Source: Stripe webhook documentation, docs.stripe.com/webhooks

That's not a short window. It's long enough for a customer to notice they've been locked out, long enough for them to try logging in three or four times before giving up, and long enough for whatever goodwill your product had built up to evaporate before your integration even finishes retrying. Most billing post-mortems focus on payment failures — cards that decline, banks that block a charge. This is the other failure mode, and it's less understood because it isn't the customer's payment method that broke. It's your application's understanding of what happened to it.

Two sources of truth, one fragile bridge between them

Stripe knows the real state of a subscription — active, past due, canceled, whatever it is right now. Your application has its own copy of that state, usually a row in a database, because you need to answer "does this user have access" in milliseconds without calling Stripe's API on every page load. A webhook is the only thing keeping those two copies in sync. When a subscription changes in Stripe, it fires an event — invoice.paid, customer.subscription.updated, customer.subscription.deleted — and your endpoint is supposed to catch it and update your local copy to match.

That bridge is thinner than most teams treat it. Stripe's own documentation is explicit that delivery is at-least-once, not exactly-once: your endpoint can receive the same event more than once, and your handler is required to be idempotent as a result. If your endpoint returns anything other than a 2xx response, or simply times out, Stripe assumes the event didn't land and retries it with exponential backoff for up to three days in live mode. That's the correct, resilient design on Stripe's side. It only works if the code on your side treats every event as something that might arrive twice, arrive late, or arrive out of order — and a lot of production webhook handlers don't.

Four ways this shows up as "churn" that was never a decision

Failure modeWhat actually happenedWhat it looks like in your data
Access never grantedHandler crashed or timed out after the charge succeeded but before provisioning ranA paying customer with no access — usually reported as a support ticket, sometimes as a chargeback for "product not received"
Access never revokedThe subscription.deleted event failed silently and your local record still shows activePhantom retained revenue — you keep counting a customer who already left, and your churn number is understated until finance notices the mismatch
Duplicate provisioningThe same event processed twice because the handler wasn't idempotentDouble-sent receipts, a second charge attempt, or inventory/seats decremented twice — a real customer complaint that reads as a billing bug even though the underlying payment was correct
State reverts on out-of-order deliveryA stale subscription.updated event lands after a newer subscription.deleted event and overwrites itA customer who genuinely canceled shows as active again, or a customer who upgraded briefly shows downgraded, until the next event straightens it out

None of these four are edge cases invented for the sake of a checklist. They're the standard failure modes of any at-least-once messaging system, and Stripe's webhooks are exactly that — a messaging system, with all the ordering and duplication guarantees (and non-guarantees) that implies. The mistake most teams make isn't writing a webhook handler. It's writing one that quietly assumes each event arrives once, in order, and exactly when expected.

These aren't hypothetical — they're filed as public bug reports

Two examples, both in widely used open-source Stripe integrations, show exactly how ordinary this failure mode is. In the WooCommerce Stripe gateway, a reported issue described Stripe order notes appearing two or three times on the same order and inventory being decremented multiple times for a single sale — traced directly to webhook events arriving more than once against a handler that wasn't built to expect it. In the Rails pay gem, a separate issue reported customers receiving two or more receipt emails per successful charge, again because the same Stripe event fired the handler twice and nothing was deduplicating it.

Neither of these is an obscure or poorly maintained project — they're two of the most commonly used Stripe integration layers in their respective ecosystems, built by teams who clearly understood Stripe well enough to ship a working payment flow. The bug wasn't ignorance of Stripe's API. It was underestimating how often "at-least-once" actually means "more than once" in production, at real volume, over enough billing cycles.

Share of top API-first companies sending webhooks as part of their product
202383%
202485%

Source: Svix, State of Webhooks Report 2024 (100+ Forbes-ranked fintech, devtools, and AI companies)

That share only moves in one direction. More of the billing, provisioning, and access-control logic running in SaaS products today depends on a webhook arriving correctly than did two years ago, because webhooks are the standard way Stripe — and every other payment processor — tells your application what just happened. The surface area for this exact failure mode grows every time a team wires a new feature to a Stripe event instead of polling the API directly, which is almost always the right architectural call and also means more of your churn number is downstream of code you wrote once and rarely revisit.

Why it reads as voluntary churn, not a bug

This is the part that makes webhook bugs more expensive than they look. A failed card payment shows up cleanly in your dunning data — Stripe tells you exactly why the charge failed and gives you a retry mechanism built for it. A webhook failure shows up nowhere specific. If the bug revokes access that should still be active, the customer's experience is identical to being locked out for non-payment, so they react the way anyone does when software stops working after they paid for it: they get angry, they email support, and if support doesn't resolve it fast, they cancel or file a dispute. Our chargebacks and disputes piece covers what a "product not received" dispute actually costs once it's filed — this is one of the more preventable ways to end up facing one, because the product was there and the access layer just never got the memo.

If the bug instead fails to revoke access on a real cancellation, the damage is quieter but just as real for your metrics: you keep counting a customer as retained who has already mentally and financially left, which understates churn until someone notices the gap between billed revenue and actual active accounts. And when events arrive out of order — a late-arriving update event overwriting a more recent cancellation — the account can flicker between states in a way that shows up in your involuntary churn numbers with no clean explanation, because the customer didn't do anything different. Your system just told two different stories about them within the same hour.

Why this rarely gets caught before production

Part of what makes this failure mode so persistent is that it's genuinely hard to reproduce locally. A single test charge in a sandbox account fires each event once, in order, against a handler running on your laptop with no real latency — none of the conditions that actually trigger duplication or reordering at scale. Stripe's own sandbox retry behavior is also far gentler than live mode: a handful of retries over a few hours instead of the three-day live-mode window, so a bug that only surfaces on the fourth or fifth retry attempt simply never gets the chance to appear in testing.

The Stripe CLI closes some of that gap — stripe listen --forward-to lets you replay real event payloads against a local handler, and stripe events resend can manually re-fire a specific event to test your deduplication logic directly, rather than waiting for organic traffic to expose it. Running your idempotency check against an event you've deliberately resent twice is a five-minute test that catches the exact bug the WooCommerce and Rails issues above shipped without.

Fixing the sync, not just the symptom

None of this requires rebuilding your integration. It requires treating the webhook layer with the same rigor most teams already apply to the payment layer.

  • Deduplicate on the event ID, not on your own logic. Every Stripe event has a stable evt_... ID that doesn't change on redelivery. Store processed event IDs and check against that store before doing anything with a new event — a unique constraint in your database is enough for most teams, no separate deduplication service required.
  • Never trust event order. Compare the event's timestamp (or the subscription object's own current_period_end and status fields inside the payload) against what you already have stored, and discard anything older than your current record. A subscription.updated event that's older than the cancellation you already processed should be ignored, not applied.
  • Reconcile on a schedule, independent of webhooks. A nightly job that pulls active subscriptions directly from the Stripe API and diffs them against your local database catches every drift a webhook bug ever causes, usually before a customer notices. This is the cheapest insurance available and most teams skip it entirely until the first incident forces it.
  • Watch your endpoint, not just your error tracker. Stripe's Workbench added API and webhook observability in August 2026 — traffic, failure rates, and integration health visible directly in the dashboard rather than inferred from support tickets. If your current signal that something's wrong is a customer complaint, that's the gap this closes.
  • Return a 2xx fast, then do the work. Acknowledge receipt immediately and process the event asynchronously if provisioning takes any meaningful time. A slow handler that times out looks identical to a broken one from Stripe's side, and it needlessly triggers the entire retry cycle for an event you actually received just fine.

The uncomfortable part of all this is that webhook bugs are invisible in exactly the reporting most SaaS teams rely on to understand churn. A cancel-reason survey only captures customers who made it to your cancellation flow and told you why — and a customer locked out by a sync bug never gets that far, because as far as they're concerned there was nothing to cancel, just a product that stopped working. If you want a sense of how much of your reported churn might actually be this rather than a genuine decision, cross-referencing account status against Stripe directly is worth doing before you run the numbers through a churn calculator — the input matters as much as the formula. And for the churn that is real, once a customer does make it to a cancel screen instead of just disappearing angry, that's exactly the moment CancelFlow is built to catch — but it can only do that job for customers whose access was actually working in the first place.

Frequently asked questions

What does it mean for a Stripe webhook handler to be idempotent?+

It means processing the same event twice produces the same result as processing it once — no duplicate charge, no second confirmation email, no double-decremented inventory. Stripe guarantees at-least-once delivery, not exactly-once, so your handler will receive some events more than once. The standard fix is to store each processed event's ID (the evt_... value, which never changes on redelivery) and skip any event you've already handled before doing anything else.

Why does Stripe send the same webhook event more than once?+

Two reasons, both by design. First, Stripe's delivery model is at-least-once: if your endpoint times out, errors, or returns a non-2xx response, Stripe assumes the event wasn't received and retries it. Second, network conditions between Stripe and your server can occasionally cause your acknowledgment to get lost even after your handler succeeded, which looks identical to a failure from Stripe's side and also triggers a retry.

What happens if my webhook endpoint is down for more than 3 days?+

In live mode, Stripe retries a failed event delivery with exponential backoff for up to 3 days. If your endpoint is still failing after that window, Stripe stops retrying that event and, after continuous failures, disables the endpoint entirely and emails you a notification. Any events that failed permanently have to be recovered manually — Stripe's dashboard lets you list and resend events from an endpoint's event log, but only if you catch it before you assume the gap was customer behavior instead of a dead endpoint.

How do I know if webhook bugs are causing churn in my product?+

Pull every subscription your database marked canceled or downgraded in the last 90 days and cross-check its current status directly against the Stripe API rather than your local copy. Any mismatch — active in Stripe but canceled in your app, or the reverse — is a sync bug, not a customer decision, and it won't show up in a cancel-reason survey because the customer never went through your cancellation flow at all.

Try CancelFlow

Stop losing subscribers today

One script tag. One function call. A live cancellation flow in under 10 minutes.

Start free trial →
← All postsHome