1
2 Comments

I checked a founder's Stripe webhook handler live on a call. He went quiet for 30 seconds when he saw what was inside it.

He had been running his SaaS for 8 months.

$6k MRR. Growing steadily. No obvious problems.

I asked him one question.

"What happens in your app when invoice.payment_failed fires?"

He said "we handle it."

I asked him to open his webhook handler and find that case while we were on the call.

He went quiet for about 30 seconds.

Then he said "it logs the event and returns 200."

That was it.

No access revoked. No database updated. No user notified.
Just a log statement and a 200 response.

Stripe saw the 200 and considered the event handled. His app had no idea the payment failed. The user kept full access.

We pulled his Stripe event history right there on the call.
invoice.payment_failed had fired 31 times in the last 90 days.

In 26 of those cases — the downstream subscription showed no corresponding state change within 48 hours.

26 users whose payments failed. Still active. Still using the product.
We estimated the monthly impact at $480.

8 months of compounding.

He had not changed that handler since he shipped.

The part that hit him hardest was not the dollar amount.

It was that everything looked fine the entire time.

MRR was growing. Dashboard looked normal. Stripe showed payments coming in from other customers.

There was no signal. No alert. No threshold crossed.

The leak survived because it never looked like a leak.
It looked like normal.

Here is the thing I keep coming back to after running these audits.

The bug is almost never the hard part to fix.

18 lines of code inside that case statement. An hour of work.

The hard part is knowing it exists.

And you cannot know it exists by looking at your growth dashboard. Growth dashboards show you what is going right. They have no visibility into the intersection of who failed to pay in Stripe and who still has active access in your database.

That intersection does not have a dashboard.

You have to look for it deliberately.

If you shipped a paid product in the last year and have never specifically tested what happens when invoice.payment_failed fires —
Open your webhook handler right now. Find that case. Look at what is actually inside it.

If it is logging and returning 200 without touching your database or your user's access state — you have this bug running right now.

Comment "checklist" below and I will DM you the 7-event audit checklist I use for every manual audit.

Free. No pitch. Just the checklist.

on April 21, 2026
  1. 1

    Returning 200 while the handler only logs is how past_due customers keep full access and never get a card-update nudge.

    I’ve watched the same pattern: invoice.payment_failed fires, entitlement never flips, dunning never starts, and the founder thinks retries are “on.” The clock still ages the invoice. Median time-to-first-useful action usually matters more than how many retries you scheduled:

    https://retryfix.com/blog/failed-payment-recovery-delay-cost

  2. 1

    Full disclosure: I'm Avery Lin (avrlin). I've been packaging a small Stripe Dunning Email Pack (failure-reason matrix → day-1 copy) with AI assistance, so take this as adjacent interest, not neutral advice.

    Your call story is the exact failure mode: invoice.payment_failed returns 200, Stripe thinks it's handled, and access stays open. Logging isn't handling. Minimum bar is: update subscription/entitlement state, notify the user once, and branch copy by decline family (retry-able vs update-card vs do_not_honor vs fraud silence). One generic "payment failed" email burns the easy recoveries and feels cold on the hard ones.

    Curious — after you showed him the 31 events, did he ship a real payment_failed path that week, or did it stay on the backlog behind features?