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
| Symptom | Stripe | Your DB | Common cause |
|---|---|---|---|
| Revenue leak | active / trialing | has_paid_access = false | Handler returned 200 without updating the row, wrong customer lookup, dropped event branch |
| Wrongful access | canceled / unpaid / past_due | has_paid_access = true | Missing subscription.deleted handler |
| Plan drift | Active price price_Pro | plan = starter | Price ID map not updated after a pricing change |
| Orphan link | Active subscription | No matching user row | stripe_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.
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.