A customer canceled, and Stripe still says the subscription is active
September 21, 2026
A customer clicks “Cancel subscription” in your billing portal. You open Stripe and the subscription still says active. Nothing is broken, but this is the moment where most integrations make one of two opposite mistakes: they revoke access the second the customer clicks (and refund-worthy complaints follow), or they never revoke it at all, because the event that ends the subscription arrives weeks later and nothing was built to wait for it.
What actually happens when someone cancels
By default, canceling from Stripe’s customer portal or from your own UI with cancel_at_period_end: true does not end the subscription. It schedules the end. The customer already paid for the current period, so Stripe keeps the subscription running until that period runs out. In between, these fields tell you what is going on:
statusstaysactivethe whole time. This is the one your access check probably reads, and it does not change on click.cancel_at_period_endflips totrue. This is the only signal that the customer has asked to leave.canceled_atis set to the time of the click, even though nothing has been canceled yet. It records when cancellation was requested, not when it took effect.ended_atstays empty until the subscription really ends.
The customer can also change their mind. Turning cancel_at_period_end back to false before the period ends undoes the cancellation, with no new subscription and no new checkout.
The events, in order
- Click:
customer.subscription.updatedfires withprevious_attributes.cancel_at_period_end: false. Status is stillactive. - Period end: Stripe cancels the subscription. Status becomes
canceledandcustomer.subscription.deletedfires. This can be a month or a year after the click.
That gap is the whole problem. If your logic keys off customer.subscription.deleted, it is correct but late by design. If it keys off the click, it is early. Neither is a bug in Stripe; the mistake is treating “canceled” as a single moment when it is two.
Where access logic goes wrong
Revoking on the click
Some apps listen to customer.subscription.updated, see the cancellation flag, and lock the account immediately. The customer paid through the end of the month and loses access on the 3rd. Support tickets follow, and often a chargeback for a service that was paid for and not delivered.
Never revoking
The opposite failure: the app only checks status === 'active' when a page loads, or caches the result at login. When the period ends and Stripe finally cancels, nothing re-checks, and the user keeps access for as long as their cached state survives. If the deleted webhook was also missed (see why a 200 doesn’t mean it worked), there is no second chance.
Confusing this with a failed payment
A canceled-at-period-end subscription and a subscription that stopped paying both eventually end, but they are opposite situations. One is a customer who chose to leave and is fully paid up. The other is a ghost customer who never got the ending event. Lumping them together makes churn reporting and win-back emails wrong.
A check that gets it right
// Decide access from the subscription itself, not from "did they click cancel".
function hasAccess(sub, now = Date.now()) {
if (sub.status === 'active' || sub.status === 'trialing') {
// Scheduled cancellations (cancel_at_period_end or cancel_at) are still paid-up:
// access ends when Stripe actually moves the status, not when the customer clicked.
return true
}
return false
}
// Separate question for the UI, not for access:
function isEnding(sub) {
return sub.status === 'active' && (sub.cancel_at_period_end || sub.cancel_at !== null)
}Access follows status, and the cancellation flag drives only the UI: a “your plan ends on the 30th” banner, and a “resume subscription” button that flips cancel_at_period_end back. Use isEnding for that and nothing else.
active and look for ones with a scheduled cancellation. For a few of them, log into your app as that customer, or look at their row: is access still on, and does the UI tell them when it ends? Then find a subscription that ended last month and confirm access is actually gone.Testing it without waiting a month
Use a Stripe test clock: create a test customer with a subscription, cancel at period end, then advance the clock past the period end. You will see the deleted event fire on demand and can confirm your app revokes access when it does, which is the step teams almost never test because the real event is weeks away.
Catching the ones that slip through
Everything above assumes the ending event arrived and was handled. When it isn’t, the customer simply keeps access with nothing to warn you. The reliable safety net is a periodic comparison of what Stripe currently says against what your app grants, which is exactly what Venwai does once a day with a read-only key, alerting you in Slack, Discord, Telegram, or email when the two disagree. If you would rather start with a script, here is one you can run today.
Free while it’s in beta, no card required: venwai.com.