Stripe and Postgres drift apart. Here's the proof.
Webhooks are not enough. Stripe docs, practitioner writeups, and reconciliation patterns for when billing state and your database disagree.
Alice opens a support ticket: "I paid yesterday. Still on the free plan."
You check Stripe. Subscription is active. You check Postgres. has_paid_access is false.
That is billing drift. Stripe and your app disagree about who paid. Nobody alerted you. You only found out because Alice complained.
I'm running a public study on this (5 indie Stripe + Postgres setups, fully redacted). Before any concierge runs finish, here's the evidence that the problem is structural, not edge-case.
Stripe does not guarantee your handler sees every event in order
From Stripe's webhook docs:
- Events are not guaranteed to arrive in order.
- Failed deliveries retry for up to three days in live mode.
- Your endpoint must return 2xx quickly or Stripe retries.
Translation: your database is a cache of Stripe state. Webhooks are the sync mechanism. The sync is best-effort.
FlowVerify's writeup on delivery guarantees puts it plainly: production webhook systems use at-least-once delivery. Duplicates and gaps are normal. Idempotency is required, not optional.
Everyone who has shipped billing recommends reconciliation anyway
Even with a correct webhook handler, practitioners add a scheduled backstop.
Alex Mayhew on SaaS billing architecture: billing bugs can cost $10K–$100K before detection. His fix includes a daily job that compares Stripe subscription state to your local records.
Operational.co on payment reconciliation: webhooks "fail all the time." They run a daily cron that walks paid users in their DB and checks each against Stripe.
This DEV post on catching dropped customers: after Stripe exhausts retries, state stays wrong unless you reconcile. Pattern 4 is a daily cron against the payment provider's API.
Amplified Creations on subscription webhooks: out-of-order customer.subscription.updated events can make a paying customer lose access. Fix: refetch the live subscription from Stripe, don't trust event order.
The pattern is consistent: webhooks + reconciliation. Not webhooks alone.
What "paid but locked out" actually looks like
Base44Devs documents four silent failure links between Stripe payment success and app access:
- Webhook event type never subscribed in the Stripe dashboard.
- Signature check runs on parsed JSON instead of the raw body (always fails).
- Handler looks up
stripe_customer_idbut your column is namedcustomerId(updates zero rows, returns 200). - DB row is correct but the user's session still has
role: free.
None of these throw a user-visible error. Support tickets are the alert.
Failure modes worth checking on a schedule
| Symptom | Stripe says | DB says | What usually broke |
|---|---|---|---|
| Revenue leak | active / trialing | has_paid_access = false | Dropped handler branch, wrong customer lookup, webhook returned 200 on error |
| Wrongful access | canceled / unpaid | has_paid_access = true | Missing subscription.deleted handler |
| Plan drift | Price price_Pro | plan = starter | Price ID map not updated after a pricing change |
| Duplicate customer | Two rows share one stripe_customer_id | — | Bad backfill or missing unique constraint |
ProdVerdict's Access contract compares live Stripe (or Paddle) to live Postgres on a schedule and emits PASS / WARN / FAIL with fix hints. No LLM in the evaluation path.
If AI agents rewrote your webhook handler recently, see the revenue leak post for how that shows up in practice.
How big is the dollar impact?
Broad billing leakage estimates exist, but they're wider than access drift alone.
Lago cites MGI Research: 1–5% of ARR lost to billing-related gaps. That includes failed payment recovery, coupon mistakes, and pricing errors, not just has_paid_access drift.
For a solo SaaS at $3k MRR, one locked-out $49/mo subscriber for two weeks is ~$25 and a likely churn. The bug is small until it isn't. The check is cheap either way.
I'm recruiting 5 founders for a redacted public study to put real numbers on indie setups. Volunteer here.
DIY: the 30-line cron everyone recommends
Honest take: you can build this yourself.
// Nightly: Stripe active subs vs your users table
for await (const sub of stripe.subscriptions.list({ status: 'active' })) {
const { rows } = await db.query(
`SELECT has_paid_access FROM users WHERE stripe_customer_id = $1`,
[sub.customer]
);
if (rows.length === 0 || !rows[0].has_paid_access) {
await alert(`DRIFT: ${sub.customer} active in Stripe, not paid in DB`);
}
}That works. Most teams don't ship it until after the first support ticket. The hard parts are mapping your schema (is_pro vs has_paid_access), handling Paddle, alerting in Slack, and keeping it running in CI.
ProdVerdict packages the comparison, config in prodverdict.yml, GitHub Action workflow, and npx prodverdict scheduled --install. Same idea, less glue.
Try it without API keys
npx prodverdict demoExpect FAIL: active Stripe subscription, has_paid_access false.
On your stack:
npx prodverdict setup
npx prodverdict check accessRead the full dev guide: Why billing drift happens.
What I'm doing next
I'm collecting 5 indie Stripe + Postgres setups for a public redacted study. You run the CLI on a Zoom call, keep all secrets, I publish aggregate counts only.
If you already run a nightly reconcile cron, you probably don't need ProdVerdict. If you've been meaning to write one for six months, this is the shortcut.
— mattbaconz
Try it in 60 seconds
Run the fixture demo — no Stripe key or database required.
npx prodverdict@latest check access --config examples/nextjs-stripe/prodverdict.yml --fixtures --fixtures-dir examples/nextjs-stripe/scenarios/fail-revenue-leakRelated posts
The Revenue Leak Your AI Agent Just Introduced
Active Stripe subscription, has_paid_access false — how billing drift happens when AI rewrites webhooks, and how to catch it in CI.
June 21, 2026
Scheduled Stripe–Postgres reconciliation beats PR-only billing tests
Why Access checks belong on a cron, not every pull request — and how to install nightly billing drift detection in GitHub Actions.
July 1, 2026
Stripe Entitlements migration checklist (before you cut over)
Moving from Postgres paid flags to Stripe Entitlements? Pitfalls, dual-write traps, and how to verify billing vs DB before you flip the switch.
June 30, 2026
Comments
Guest comments. No account required. Max 300 words.
Loading comments…