Ghost customers: when a Stripe subscription goes bad without ever firing subscription.deleted
September 3, 2026
The most common access-control bug in a Stripe integration isn’t a bug in the handler at all. It’s a wrong assumption about which event actually means “this customer should lose access.” Most homegrown integrations listen for customer.subscription.deleted and revoke access there. That event is the last thing to fire, not the first — and depending on how your Stripe account is configured, it might not fire for weeks after the customer effectively stopped paying.
What actually happens when a card gets declined
A subscription doesn’t go straight from “paid” to “canceled.” It goes:
active→ a renewal payment fails → Stripe firesinvoice.payment_failedand moves the subscription topast_due.- Stripe retries the card on its own schedule (Smart Retries, or whatever your dunning settings specify) — this can run for a week or more.
- Only after retries are exhausted does Stripe move the subscription to its final state — and that final state is a setting you chose, not a fixed behavior.
The setting that quietly decides whether you ever get the event
In Stripe’s dashboard, under subscription settings for failed payments, you choose what happens after retries are exhausted: cancel the subscription, or mark it unpaid and leave it alone. A lot of accounts are left on the default without anyone deciding it on purpose.
If it’s set to leave the subscription unpaid instead of canceling it, customer.subscription.deleted never fires. The subscription just sits in unpaid indefinitely. If your access logic was written to key off the delete event — which is the obvious, well-documented one to reach for — the customer keeps full access on a card that hasn’t successfully charged in weeks.
Why this specific one is expensive, not just annoying
Ghost customers are worse than the average bug because there’s no error anywhere. Nothing crashes. No log line looks wrong. The customer is happily using a product they stopped paying for, your churn number doesn’t move because they never technically canceled, and the only signal is a slow, quiet gap between MRR-as-reported-by-Stripe and MRR-as-actually-collected. Most teams find it by accident, months in, when someone happens to sort subscriptions by status and notices how many say unpaid.
How to check your own account in two minutes
- Stripe Dashboard → Subscriptions → filter by status
unpaidandpast_due. - For each one, check whether that customer still has access in your app. If your access check only looks for “subscription exists,” not “subscription status is
activeortrialing,” the answer is almost always yes.
The fix
Two changes, in order of how much they cost to skip:
- Check
subscription.statusdirectly wherever you gate access, instead of inferring status from whether a delete event ever arrived.activeandtrialinggrant access; everything else doesn’t. - Also handle
customer.subscription.updated, not just.deleted— that’s the event that actually fires when a subscription moves intopast_dueorunpaid. - Run a periodic reconciliation that doesn’t depend on any single event having fired correctly — one that just asks “for every customer with access right now, what does Stripe currently say their subscription status is?” on a schedule.
The first two close the gap going forward. The third is the only one that catches customers who are already stuck wrong today, and keeps catching the next edge case you haven’t thought of yet — proration timing, a webhook that silently stopped delivering, a dunning setting someone changes six months from now and forgets about.
What we built this for
I built Venwai after running into this exact gap. It connects to Stripe with a read-only key, polls a single endpoint you expose on your own app once a day, and diffs the two — then alerts you in Slack, Discord, Telegram, or email the moment they disagree. It never writes to Stripe or touches your database directly.
Free while it’s in beta, no card required: venwai.com.