DEV Community

Amer tech
Amer tech

Posted on

Stripe webhooks can work and your app access can still be wrong

Most Stripe billing bugs get described as webhook bugs.

Did the event arrive? Did the signature verify? Did the handler return 200? Is the handler idempotent? Can we replay failed events?

Those are good questions. But they miss another one:

Does the access state in your app match the billing state in Stripe right now?

That check is final-state reconciliation.

The failure mode

A common SaaS setup looks like this:

  • Stripe is the billing source of truth.
  • Your database decides who gets access.
  • Webhooks and custom code keep the two in sync.

That works until it doesn't.

A few normal ways it breaks:

  • customer.subscription.deleted arrives during a deploy and the handler fails.
  • The webhook handler returns 200, but the database write rolls back.
  • Support manually enables or disables access in the admin panel.
  • A migration changes plan/status fields and misses old rows.
  • A customer cancels and later resubscribes, leaving multiple subscription records.
  • Lazy sync only runs when someone opens the billing page, but the user keeps hitting API endpoints.

Now Stripe says one thing and your app says another.

There are two different problems here:

  1. Unpaid but active

    Stripe says canceled, unpaid, or past due, but the app still grants access. This is usually silent. Nobody opens a support ticket to say they are still getting free compute.

  2. Paid but blocked

    Stripe says active or paid, but the app blocks or downgrades the customer. This is urgent because the customer will probably notice before your cron does.

Those two cases should not be handled the same way.

Webhook reliability is not the same check

A reliable webhook pipeline asks:

  • Did we receive the event?
  • Did we process it once?
  • Can we retry failed deliveries?
  • Can we inspect what happened?

Final-state reconciliation asks:

  • What does Stripe say now?
  • What does the app grant now?
  • Do those states agree?
  • If not, which side needs review?

You probably need both.

Webhook infrastructure prevents a lot of failures. Reconciliation catches the ones that still escape. It also catches problems that never went through the webhook path: admin overrides, migrations, backfills, legacy status fields, and access logic that drifted over time.

What a small reconciliation check needs

You do not need a huge system to start.

From Stripe, export or query:

  • customer ID
  • subscription ID
  • subscription status
  • product or plan
  • amount/MRR, if you want exposure estimates
  • current period end / cancel-at-period-end, if relevant

From your app, export:

  • internal user or workspace ID
  • Stripe customer ID
  • access flag or entitlement status
  • plan/tier, if your app stores it
  • any field your request path actually reads

That last part matters.

If your middleware checks access_enabled, your rate limiter checks plan_tier, and your billing page checks subscription_status, your first drift problem might be inside your own database.

Start with the fields that actually grant or deny access.

Do not auto-fix on day one

It is tempting to auto-suspend every unpaid-but-active account.

I would not start there.

A first reconciliation job should usually flag, not fix. There are too many legitimate edge cases: trials, grace periods, dunning windows, enterprise comps, test accounts, manual support exceptions, and custom contracts.

A safer first workflow:

  1. Run the comparison nightly or weekly.
  2. Split findings by direction.
  3. Treat paid-but-blocked as urgent.
  4. Put unpaid-but-active and ambiguous cases into review.
  5. Add notes for known exceptions.
  6. Only automate actions after you trust the classification.

The first version should help you see drift, not create a new production incident.

I built a small local-first prototype

I built EntitleGuard to test this workflow as a free local audit.

It compares:

  • a Stripe CSV export
  • a minimal app users/workspaces CSV export

The comparison runs in the browser.

No Stripe API key. No database credentials. No account. No upload.

It flags:

  • unpaid-but-active
  • paid-but-blocked
  • missing billing links
  • orphaned Stripe subscriptions
  • ambiguous cases that need review

The source is public, so the local-only claim is easy to inspect.

Live audit:

https://entitleguard.amertech.online/audit

Source:

https://github.com/impara/EntitleGuard

The product question I am testing now is whether this should stay as a one-time diagnostic or become recurring monitoring: nightly diff, alerting, review history, and an evidence trail for each mismatch.

My guess is that most teams only care about this after they have seen drift once.

If you run a Stripe SaaS

A practical first check:

  • Export active and non-active Stripe subscriptions.
  • Export the app table that controls access.
  • Join on stripe_customer_id if you store it.
  • Treat customer ID as more stable than subscription ID for access-level reconciliation.
  • If a customer can have multiple subscriptions, rank by status instead of assuming one row.
  • Keep the first version read-only.
  • Review both directions separately.

This is not a replacement for correct webhook handling.

It is a backstop for the final state your users actually experience.

If Stripe and your app disagree, the user does not care that the webhook pipeline looked healthy.

Top comments (10)

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

This is the trap: webhooks are deltas, but access is state. You're reconstructing current state from a stream that can arrive out of order, drop an event, or replay one. Even with a perfectly idempotent handler, one missed event means your DB's access flag diverges from Stripe forever, because nothing ever re-checks.

What finally stopped the drift for us was to stop treating the webhook as the source of truth. Treat it as a "go look" trigger: on any subscription event, re-fetch the subscription from the API and reconcile access against its real current status, instead of mutating access straight from the event payload. Then run a scheduled reconciliation (nightly, or hourly for higher stakes) that pulls active subscriptions and heals whatever slipped through.

Webhooks tell you "something changed." They're a bad place to store what the state now is. The final-state check you're describing is exactly what makes the system self-correcting.

Collapse
 
amer_tech profile image
Amer tech

That “webhooks are deltas, access is state” framing is exactly it.

I like the “go look” trigger pattern too. It avoids treating an event payload as the final answer, especially when events arrive late or out of order.

The piece I’m trying to validate is the scheduled backstop around it: not just re-fetching after events, but periodically asking “does Stripe’s current state still match what the app is granting?” That’s where missed events, manual overrides, migrations, and old access flags show up.

Did you end up keeping a review queue/history for mismatches, or did the scheduled job just heal known cases automatically?

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

Very late, sorry. You asked something specific so it deserves a real answer.

Both, but split by direction, because the two mismatch types have completely different blast radius.

Grant direction, where Stripe says active and the app is not granting, heals automatically. The worst case is giving access to someone who has already paid for it, which is the safe way to be wrong.

Revoke direction, where Stripe says cancelled and the app is still granting, does not silently auto-heal. That is the one where a bug in the reconciler or a bad API response locks out paying customers in bulk. Require several consecutive agreeing runs before revoking, and send anything that does not agree to a queue instead.

Keep the history either way, and this is the part I would push hardest on. The value is not the healing, it is the rate. A mismatch count trending upward is what tells you a webhook path has quietly broken. A job that silently heals destroys exactly that signal, and you end up with a system that hides its own bugs while looking healthy.

One practical thing, since you mentioned manual overrides: mark them explicitly. If a comped or hand-granted account carries no flag saying a human did this, reconciliation will keep reverting it and someone will keep re-granting it, and each side will assume the other is broken.

Thread Thread
 
amer_tech profile image
Amer tech

I appreciate your answer, thank you.

I particularly like your point that the trend is often more valuable than the automatic healing. My current direction is actually to keep reconciliation read-only by default and treat it primarily as an observability layer rather than an auto-remediation system.

I also agree on separating grant and revoke handling. Grant drift has a much smaller blast radius than revoke drift, so treating them differently makes sense.

The manual override point is also something I’ve been thinking about. Rather than repeatedly “correcting” intentional exceptions, they need to be treated as first-class state with explicit provenance, otherwise the reconciler becomes part of the problem instead of the solution.

Really appreciate you taking the time to write such a thoughtful response.

Thread Thread
 
mihirkanzariya profile image
Mihir kanzariya

Read-only by default is the right call. One trap inside it: an observability layer nobody looks at is worse than no layer, because it feels like coverage. Worth making the mismatch count alert on a threshold rather than living on a dashboard, so it has to be acknowledged rather than admired.

And yes on overrides as first-class state. Once they carry provenance you also get the more useful version of the question, which is not whether this account is correct but who decided it should be.

Thread Thread
 
amer_tech profile image
Amer tech

That is a very useful distinction: acknowledged rather than admired. for thresholding, would you prefer an absolute count, a mismatch-rate increase from baseline, or both? My instinct is both, an immediate alert for severe paid-but-blocked cases, and rate-based alerts when the overall drift pattern starts moving.

Thread Thread
 
mihirkanzariya profile image
Mihir kanzariya

Both, and I would split them the way you have, but with different thresholds rather than one shared number.

Absolute count for the severe revoke case, paid but blocked, threshold of one. A single paying customer locked out is a page rather than a trend, and the volume is far too small for a rate to say anything useful about it.

Rate against baseline for the general drift, since a broken webhook path shows up as a slope change rather than a spike.

One trap in the rate half though: your baseline drifts with you. If a path degrades slowly, the rate rises slowly, a trailing average absorbs it, and the alarm quietly retunes itself to the broken state. Compare against a fixed reference window instead, the same weekday a month back, rather than a rolling mean.

Worth alerting on queue AGE alongside count too. Three mismatches that are forty days old is a worse signal than fifty from this morning, and age is what catches nobody triaging, which is the real failure mode of a read-only layer.

Thread Thread
 
amer_tech profile image
Amer tech

That distinction is excellent, I think I now have enough to define the monitoring model: page at one paid-but-blocked customer, fixed-reference drift alerts for the broader mismatch rate, queue-age alerts, acknowledgement, history, and explicit override provenance.

One commercial question, since you clearly understand the operational side: would your team realistically buy a read-only tool like that rather than build and maintain it internally?

I’m considering a small design-partner beta at $79/month. Would that be compelling enough to trial against real production data, or would this always remain an internal system for you?

Thread Thread
 
mihirkanzariya profile image
Mihir kanzariya

Straight answer: we would build it, and I do not think that reflects badly on the idea.

Our entitlement rules live in our own schema, so the hard part is not detecting drift, it is stating what should have been true in the first place. Any external tool has to be taught that, and the teaching is most of the work. At $79 the price is not the objection, the integration week is.

The buyer you want is not someone my size. It is a team where paid-but-blocked already lands in a support queue and costs somebody a morning. They are not weighing $79 against a build, they are weighing it against ticket volume, and they will pay for the parts that are genuinely unpleasant to own: the alert history and the override provenance, plus keeping the thing alive after whoever wrote it moves teams.

One repositioning I would suggest. Do not lead with reconciliation. Lead with the single alert that fires when a paying customer cannot get in. Reconciliation sounds like an accounting chore you could postpone forever. That alert sounds like the thing you would pay to never miss again, and it is the same product.

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

Answering your thresholding question up here, the nested reply box would not take it any deeper.

Both, and I would split them the way you have, but with different thresholds rather than one shared number.

Absolute count for the severe revoke case, paid but blocked, threshold of one. A single paying customer locked out is a page rather than a trend, and the volume is far too small for a rate to say anything useful about it.

Rate against baseline for the general drift, since a broken webhook path shows up as a slope change rather than a spike.

One trap in the rate half though: your baseline drifts with you. If a path degrades slowly, the rate rises slowly, a trailing average absorbs it, and the alarm quietly retunes itself to the broken state. Compare against a fixed reference window instead, the same weekday a month back, rather than a rolling mean.

Worth alerting on queue AGE alongside count too. Three mismatches that are forty days old is a worse signal than fifty from this morning, and age is what catches nobody triaging, which is the real failure mode of a read-only layer.