A User Canceled but Still Got Charged (or Still Has Access), Fixing Subscription State
6 min read
Your user canceled and then either got charged again or still has full access, and now you are staring at Stripe wondering which side is broken. Short answer: both behaviours are usually your app not syncing with Stripe, not Stripe misbehaving. A Stripe cancellation normally means "cancel at period end," so access is meant to continue until the paid period runs out, and it only ends if your app hears the event and updates its own record. If nothing is listening, your app keeps its old idea of who is a paying customer.
Why this happens
An AI agent building "let users subscribe with Stripe" will wire up the checkout,
flip a is_pro flag to true on success, and stop. It works in the demo, so it
looks done. But a subscription has a life after checkout: it renews, it fails, it
gets canceled, it lapses. Each of those is an event Stripe sends to a webhook,
and if you never asked for a webhook, the agent never built one. So your database
knows one thing forever: "this person paid once." Stripe knows the truth. The two
drift apart the moment anything changes.
The "still charged" case is the flip side. In Stripe, canceling usually sets
cancel_at_period_end = true. The subscription stays active and bills once more
if the cancel happened after the renewal, or the user actually hit "cancel" in a
place that didn't call Stripe at all. Either way the charge is Stripe doing exactly
what its current state says.
How to check
- Open the Stripe Dashboard, find the customer, and look at their
subscription. Note its status:
active,canceled,past_due, and whether "Cancels at period end" is shown. - Compare that to your own database row for the same user (the
is_pro,plan, orsubscription_statusfield your app reads). - If Stripe says canceled and your app still grants access, you have a sync gap: no webhook, or a webhook that isn't updating the record.
- In Stripe, go to Developers → Webhooks. If there is no endpoint, or the recent deliveries show errors (red, non-200), that is your cause.
The fix
- Add a webhook endpoint in Stripe pointing at a route in your app, for
example
/api/stripe/webhook. Subscribe to at leastcustomer.subscription.updated,customer.subscription.deleted, andinvoice.payment_failed. - Handle the events by writing subscription state to your database. On
customer.subscription.updated, store the realstatusandcurrent_period_end. Oncustomer.subscription.deleted, mark the user as no longer entitled. - Gate access on the stored status, not a one-time boolean. A user is "pro"
when their status is
activeortrialingandcurrent_period_endis in the future. When the period ends, access ends on its own. - Verify the webhook signature with your signing secret so nobody can forge these calls. Stripe's SDK does this in one line; do not skip it.
- Replay to test. In Stripe, cancel a test subscription and watch the webhook delivery return 200 and your database flip. Then confirm the user loses access at period end, not before and not never.
The trap to avoid
Do not "fix" this by revoking access the instant someone clicks cancel. That usually breaks the deal your user paid for: they bought the month, they keep the month. And do not refund or hand-edit records one by one as complaints arrive. That treats each symptom while the real cause, a missing webhook, keeps producing new ones. Sync the state once and every future cancellation handles itself.
Where this fits
Payment state is one of the quietest ways a beta loses trust: a charged user who canceled, or a freeloader who never lost access, both find out before you do. The free Readiness Report checks whether your app actually listens for Stripe's subscription events instead of trusting a one-time flag, and the Finishing Pass hands you the webhook and access-gating fixes in order. It sits alongside the same class of "the key works in the demo" gaps we cover in what is the anon key: the happy path passed, the lifecycle was never wired up.