Skip to content
Blog

Stripe says they downgraded and the app still grants the old plan

A customer downgraded three months ago. Stripe has them on the cheaper plan and has invoiced them for it every month since. They still have the seat ceiling and the SSO of the plan they left, because your application is reading a row that was written the day they signed up.

Nobody is wrong here. Stripe charged what the subscription says. Your app granted what its own table says. The two have not agreed since March.

Where the two records separate

Stripe holds the subscription. Its status, and its items, each carrying a price and a quantity. That is the whole of what Stripe knows about what the customer bought.

What a plan means is yours. It lives in a mapping table, or a cached column on the account row, or a flag service, or a row somebody added by hand the week a deal closed. Between the subscription and the access a request actually gets there is code you wrote, and nothing verifies that it still agrees.

The drift arrives in ordinary ways.

A change was scheduled rather than applied. A downgrade taking effect at period end lives on a subscription schedule as a second phase, and the subscription keeps the old price until that phase starts. Stripe creates one of those schedules automatically when a customer downgrades through the Customer Portal. Code reading the subscription sees nothing, code reading the schedule sees the change, and only one of them runs.

An event was missed or arrived late. Stripe does not guarantee that events reach your endpoint in the order they were generated, and the same event can be delivered more than once. A handler that writes entitlements straight from the event body can write the older state last and leave it there.

A trial ended without anyone paying. A subscription in a trial has status trialing and moves to active when the trial is over, or to past_due if payment is required and cannot be taken. An app that treats anything other than canceled as entitled keeps the access on.

A cancellation is pending. cancel_at_period_end describes a subscription that will cancel at the end of the current period, and its status stays active while that is true.

A contract granted something no plan includes. Somebody set a flag for an enterprise account during onboarding. It is not in Stripe at all, and the only record of why is a thread in Slack.

Murphy does not catch this

Murphy checks what Stripe did against what it should have done. Everything it reads comes from Stripe.

This failure is not in Stripe. It is in your own database, which Murphy does not read and is not built to read. If your entitlements have drifted from your subscriptions, nothing here will tell you. It is worth catching anyway, and you can do it without us.

Reconciling it yourself

Pull the subscriptions with GET /v1/subscriptions, which returns everything that has not been canceled unless you pass status=all, up to 100 per page. Each subscription carries its items and each item carries its price, so that alone gives you the priced plan per customer.

Then read your own grants for the same accounts, out of whatever gates a request at runtime. Not the table you believe is authoritative. The one the authorization check reads.

Compare, and report the two directions separately. Accounts with access nobody is paying for are margin walking out. Accounts paying for access they do not have are a support ticket that has not been filed yet.

Run it on a schedule rather than on an event. Nightly is enough for most accounts. The whole shape of this failure is that the event is the thing you missed, so a reconciliation triggered by events inherits the bug it is meant to find.

What makes it hard

The mapping is not a clean function. Bespoke grants are legitimate and show up as differences forever unless you record them as deliberate. Give a hand-made grant a row naming who made it and why, and treat everything without one as reconcilable.

Timing produces false positives. A downgrade scheduled for period end is a difference for the rest of the period, and it is correct. Compare against the phase in effect at the moment you run, not the one that lands later.

And the history goes away. /v1/events reaches back 30 days, so a drift older than that cannot be traced to the event that caused it. Reconciliation tells you the state is wrong without telling you when it went wrong.

The failures we write about are the ones that happen inside Stripe, where the invoice itself is wrong and nothing errors. This one is not that, which is why the middle of this post tells you how to catch it yourself.