ProdVerdict
← Blog
BillingJune 29, 20265 min read

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.

stripe webhook reconciliationbilling driftstripe postgres syncsubscription database mismatchsaas revenue leak

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:

  1. Webhook event type never subscribed in the Stripe dashboard.
  2. Signature check runs on parsed JSON instead of the raw body (always fails).
  3. Handler looks up stripe_customer_id but your column is named customerId (updates zero rows, returns 200).
  4. 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

SymptomStripe saysDB saysWhat usually broke
Revenue leakactive / trialinghas_paid_access = falseDropped handler branch, wrong customer lookup, webhook returned 200 on error
Wrongful accesscanceled / unpaidhas_paid_access = trueMissing subscription.deleted handler
Plan driftPrice price_Proplan = starterPrice ID map not updated after a pricing change
Duplicate customerTwo rows share one stripe_customer_idBad 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.

typescript
// 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 demo

Expect FAIL: active Stripe subscription, has_paid_access false.

On your stack:

$
npx prodverdict setup
npx prodverdict check access

Read 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.

$fixture demo
npx prodverdict@latest check access --config examples/nextjs-stripe/prodverdict.yml --fixtures --fixtures-dir examples/nextjs-stripe/scenarios/fail-revenue-leak

Comments

Guest comments. No account required. Max 300 words.

Loading comments…

0/300 words