Diagnostic guide

canceled subscription still has access

A cancellation is the quietest kind of bug, because it looks like nothing happened. Nobody files a support ticket for access they still have. It only surfaces when someone else notices the pattern, and by then it has usually been running for a while.

most likely causes, in order

01customer.subscription.deleted is not in the events your webhook listens for+

how it looks

Every cancellation, going back to when the webhook was first set up, still shows as active in your app. Nothing about it looks broken from Stripe's side.

how to check

Stripe Dashboard → Developers → Webhooks → your endpoint. Look at the list of events it is subscribed to. If customer.subscription.deleted is not on it, Stripe never sends the event at all: there is nothing to retry.

the fix

Add the event, then handle it: look up the customer and revoke access. Past cancellations will not replay automatically, because the event itself was never sent; find and fix those by hand.

02The handler only reacts to cancel_at_period_end, not the actual end+

how it looks

Someone who canceled and finished out their paid month is still active weeks later. A subscription canceled immediately, with no remaining time, behaves correctly.

how to check

Read the handler for customer.subscription.updated. If it only checks cancel_at_period_end and does nothing when the subscription later actually ends, it is watching the schedule, not the result.

the fix

cancel_at_period_end just marks intent; the access change belongs on customer.subscription.deleted, which fires when the period actually ends (or immediately, if canceled without waiting). Revoke access there.

03The app's own cancel button updates the database before Stripe confirms it+

how it looks

Your cancel button appears to work (the screen updates), but the row flips back to active on the next unrelated write, or never matches what Stripe actually has.

how to check

Look at what the cancel button calls. If it writes your access column directly and only afterward calls Stripe's API, a failed or slow Stripe call leaves the two sides disagreeing with no error visible to the customer.

the fix

Cancel in Stripe first, and let the webhook be the only thing that changes the access column. Show a pending state until the webhook confirms it, rather than assuming success.

04The webhook runs but is blocked from writing (RLS / permissions)+

how it looks

The handler executes without throwing, logs look clean, and the row does not change.

how to check

In a Supabase build, confirm the webhook uses the service-role key. The anon key has no authenticated user in a webhook context, so a row-level security policy silently drops the update.

the fix

Use the service-role key server-side only, never in code that reaches the browser.

check it yourself

Access that has outlived its own recorded end date. Run this in your SQL editor, adjusting the table and column names to match your schema.

select email, stripe_customer_id, subscribed, subscription_end
from public.subscribers
where subscribed = true
  and subscription_end is not null
  and subscription_end < now();

what this does not tell you

This finds anyone whose stored end date has already passed. It cannot find someone who canceled immediately, with no remaining paid time and no end date ever recorded: that row looks identical to someone who never subscribed in the first place.

Read-only, both sides. Three findings free, no account.

related