← Back to blog

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:

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

  1. Click: customer.subscription.updated fires with previous_attributes.cancel_at_period_end: false. Status is still active.
  2. Period end: Stripe cancels the subscription. Status becomes canceled and customer.subscription.deleted fires. 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.

Two-minute check: in Stripe, filter subscriptions by status 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.