A customer downgraded three months ago and still has every Pro feature
September 10, 2026
Upgrades and downgrades feel like the safe part of a Stripe integration — there’s no failed payment, no cancellation, no ambiguous state. The subscription stays active the entire time. Which is exactly why it’s such a common place for access control to quietly go wrong: most access logic is written to check one thing — is this subscription active — and a plan change never makes that check fail. The subscription that was upgraded or downgraded is still sitting there, still active, just pointed at a different price than your code thinks it is.
What actually happens when a plan changes
Stripe doesn’t cancel the old subscription and create a new one when a customer changes plans — it modifies the existing subscription object in place, swapping the price on its subscription item and (depending on your proration_behavior setting) generating a prorated invoice for the difference. The subscription ID never changes. The status never changes. The only thing that changed is a nested field — items.data[0].price.id — three levels deep in the object.
If your access logic was written against “does an active subscription exist for this customer,” a downgrade from Pro to Starter is completely invisible to it. The customer still has an active subscription. It’s just the wrong one.
The event that’s easy to half-implement
Stripe does fire customer.subscription.updated on every plan change, with a previous_attributes field showing exactly what changed. The problem isn’t that the event doesn’t exist — it’s that customer.subscription.updated also fires for things that have nothing to do with plan changes: a payment method update, a metadata edit, a trial ending, a pause and resume. A handler that was built quickly often subscribes to the event, does something generic with it (like refreshing a cached “last updated” timestamp), and never actually diffs previous_attributes.items against the current price ID to detect that a plan change specifically occurred.
customer.subscription.updated handler, are you comparing the incoming items.data[0].price.id against what you have stored for that customer — or are you just re-saving whatever Stripe sent without checking if the plan actually changed? If it’s the latter, a downgrade updates your timestamp and leaves your feature flags exactly where they were.The scheduled-downgrade version of the same bug, in reverse
Downgrades are often configured to take effect at the end of the current billing period rather than immediately — nobody wants to refund the unused portion of a plan the customer already paid for. Stripe supports this cleanly through subscription schedules, but it means the actual price change doesn’t happen at the moment the customer clicks “downgrade” — it happens days or weeks later, silently, at the next renewal. Teams that test the immediate-downgrade path and see it work correctly often never test what happens when the scheduled change actually fires at renewal, weeks after the code was written and reviewed. If that later event isn’t handled with the same rigor as the immediate case, the customer keeps Pro-tier access indefinitely, past the point where Stripe itself has already moved them to the cheaper plan.
Why this specific gap survives so long
Nobody files a support ticket over having more access than they paid for. A failed payment generates an angry email within the hour; a downgrade that didn’t actually downgrade anything generates silence, because from the customer’s side, everything is working better than expected. It usually surfaces months later, in a completely different context — someone doing a manual revenue audit notices a customer on the Starter plan pulling data through a Pro-only API endpoint, and has to work backward through months of billing history to figure out when the downgrade actually happened versus when access should have changed.
Two-minute check
Pick a handful of customers who downgraded in the last quarter (Stripe Dashboard → filter subscriptions by recent items changes, or check your own billing-event log if you keep one). For each, compare their current Stripe price ID against whatever your app is using right now to decide what they can access. Any mismatch is this bug, already live.
The fix
- In your
customer.subscription.updatedhandler, explicitly diffevent.data.previous_attributes.itemsagainst the current items — don’t treat the event as a generic “something changed, re-sync everything” signal without checking what. - Derive access from the subscription’s current price ID at the moment you check it, not from a plan name cached at signup or upgrade time that never gets invalidated.
- Test the scheduled-downgrade path specifically, not just the immediate-change path — advance a test clock past the renewal date and confirm access actually changes when the scheduled price change fires, not just when the customer clicks the button.
- Reconcile periodically: compare every active customer’s current Stripe price against what your app currently grants, independent of whether any single webhook fired correctly for that specific transition.
What we built this for
I built Venwai after running into this same category of gap from the other direction — a wrong MRR number, a ghost subscription that never fired the event it was supposed to. Venwai connects to Stripe with a read-only key, polls a single endpoint you expose on your own app once a day, and diffs what Stripe says a customer’s plan is against what your app actually grants them — catching plan-change drift like this one even when the specific webhook that should have caught it was never quite finished.
Free while it’s in beta, no card required: venwai.com.