Skip to main content

Why billing drift happens

Billing drift is when your payment provider and your app database disagree about who should have paid access.

Stripe says active. Your users table says has_paid_access = false. Or the reverse: cancelled in Stripe, still marked paid in Postgres.

You usually find out from a support ticket. Nothing in your test suite failed.

What Stripe guarantees (and what it doesn't)

From Stripe's webhook documentation:

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

Your database is a cache of Stripe state. Webhooks are the sync path. The sync is best-effort, not transactional.

Practitioners add a scheduled reconciliation job on top. See Alex Mayhew on SaaS billing architecture, Operational.co on payment reconciliation, and this DEV post on catching dropped customers.

Failure modes the Access contract catches

SymptomStripeYour DBCommon cause
Revenue leakactive / trialinghas_paid_access = falseHandler returned 200 without updating the row, wrong customer lookup, dropped event branch
Wrongful accesscanceled / unpaid / past_duehas_paid_access = trueMissing subscription.deleted handler
Plan driftActive price price_Proplan = starterPrice ID map not updated after a pricing change
Orphan linkActive subscriptionNo matching user rowstripe_customer_id never written at checkout

ProdVerdict compares live billing state to live database rows. Not mocked webhooks. Not static analysis of your handler file.

Column names

Many apps use is_pro, subscription_status, or plan instead of has_paid_access. Map your columns in prodverdict.yml under the Access contract. See Access contract configuration.

DIY nightly reconciliation

You can build the backstop yourself:

for await (const sub of stripe.subscriptions.list({ status: 'active', limit: 100 })) {
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) {
console.warn('DRIFT', { customer: sub.customer, subscription: sub.id });
}
}

That pattern works. Teams often delay shipping it until after the first billing support ticket. ProdVerdict packages the comparison, config, GitHub Action workflow, and Slack alerting.

Run ProdVerdict instead

1. Fixture demo (no credentials):

npx prodverdict demo

Expect FAIL: active subscription, access flag false.

2. Wire your repo:

npx prodverdict setup
npx prodverdict status

3. Live check (read-only Stripe key + read-only Postgres role):

npx prodverdict check access

4. Schedule drift detection:

npx prodverdict scheduled --frequency daily --install

See Scheduled vs PR for why Access runs on a schedule, not on every PR.

Further reading