Your Stripe webhook returns 200. That doesn’t mean it worked.
September 3, 2026
A 200 response tells Stripe exactly one thing: your endpoint received the request. It says nothing about what your code did after that. If the handler function is empty, half-written, or silently swallows an error, Stripe still sees 200, marks the event delivered, and moves on. It will never retry, because as far as Stripe is concerned, nothing went wrong.
This is the single most common way billing state quietly drifts from reality — not because a webhook failed to arrive, but because it arrived, got acknowledged, and did nothing.
What this actually looks like
It’s rarely a crash. It’s usually one of these:
- A handler generated by an AI tool (Cursor, Lovable, Bolt, or similar) that has the right
switchstatement and event names, but an empty orconsole.log-only body for the cases that matter. - A
try/catchthat swallows the actual database update error and returns 200 anyway, because the handler was written to satisfy Stripe’s retry policy, not to surface failures. - Logic that only handles
customer.subscription.deleted, while a failedinvoice.payment_failedafter a card decline never revokes access at all — the subscription just sits active on a dead card indefinitely.
In every case, your logs show a clean 200. Your Stripe dashboard shows the event as delivered. Nothing in either system tells you that the customer’s access in your own database never changed.
Why it survives for months, not days
This is the part that makes it dangerous rather than just annoying: growth hides it. A handful of customers stuck in the wrong access state is a rounding error against new signups. Your MRR chart keeps climbing. Nothing crosses an anomaly threshold, because nothing you’re graphing is built to catch “Stripe says X, my database says Y.” That’s a comparison between two systems, and neither system reports on it by itself.
It surfaces the same way almost every time: someone manually cross-references a list of failed charges against who still has access, months later, and finds a number that’s been quietly accumulating the whole time.
How to check if this is happening to you right now
Pull two lists and compare them by hand, once:
- Every Stripe customer with a
past_due,unpaid, orcanceledsubscription in the last 90 days. - Every one of those customer IDs that still has active access in your own database.
If that intersection isn’t empty, you’ve found the leak. If you can’t easily produce list #2 at all — no query that maps a Stripe customer ID straight to an access flag — that’s the same finding stated differently.
Fixing the handler is necessary. It isn’t sufficient.
Writing the missing 15 lines inside the empty case block fixes new events going forward. It doesn’t fix the ones that already fired and were mishandled, and it doesn’t protect against the next category of failure: Stripe never firing the event at all. Dunning settings that leave a subscription unpaid instead of moving it to canceled mean the delete event you were watching for never arrives. A retried webhook that eventually gives up after Stripe’s retry window closes leaves the same gap.
The handler is a trigger. It shouldn’t be the only source of truth for whether your app’s state matches Stripe’s. The fix that actually closes the gap is a periodic reconciliation check — something that asks, independently of any webhook firing correctly, “for every customer, does what Stripe says match what my database says?” on a schedule, not on faith.
What we built this for
I built Venwai after running into this exact gap. It connects to Stripe with a read-only key, polls a single endpoint you expose on your own app once a day, and diffs the two — then alerts you in Slack, Discord, Telegram, or email the moment they disagree. It never writes to Stripe or touches your database directly; it just does the cross-reference that’s otherwise easy to keep meaning to write and never getting to.
Free while it’s in beta, no card required: venwai.com.