DEV Community

Cover image for Cancelled customers who keep access: the Stripe webhook bug nobody reports
Uudg
Uudg

Posted on Originally published at entitled.dev

Cancelled customers who keep access: the Stripe webhook bug nobody reports

If you take subscriptions through Stripe, you keep two copies of "who is paying": Stripe's, and a column in your own database (subscribed, is_pro, plan...) that your app actually checks. A webhook keeps them in sync.

When the webhook misses an event, the two copies disagree. It fails in two directions:

  • Paid, no access. Someone pays and stays locked out. They email you within the hour.
  • Cancelled, still has access. Someone cancels, refunds or stops paying, and keeps everything. Nobody emails you about free access.

This post is about the second one, because it's the one you only find by looking.

Why it happens

1. The webhook only handles the purchase

Generated and tutorial webhooks often subscribe to exactly one event, checkout.session.completed, which grants access. Nothing ever takes it away. You need, at minimum:

  • customer.subscription.updated: status changes (past_due, unpaid, canceled) and plan changes
  • customer.subscription.deleted: the subscription has actually ended

Check in the Stripe dashboard: open your webhook endpoint and look at which events it's subscribed to. If customer.subscription.deleted isn't there, Stripe never sends it and there's nothing to retry.

2. Reacting to cancel_at_period_end instead of the actual end

When a customer cancels "at period end", Stripe sends customer.subscription.updated with cancel_at_period_end: true. They've paid until the period ends, so don't revoke access then. Revoke on customer.subscription.deleted, which fires when the period actually ends. Handlers that only look at the flag either cut paying customers off early or never cut anyone off.

3. Access granted by the success page

If the checkout success page sets subscribed = true, anyone who opens that URL gets access, paid or not, and a cancellation webhook can't fix what the webhook never granted. Access should only ever be granted by the webhook, after Stripe confirms payment.

4. The webhook is failing entirely

Look at recent deliveries on the endpoint:

  • 404: the route moved or was renamed in a deploy.
  • 400: signature verification failed. Stripe signs the raw body: read it as text before parsing (await req.text() in Next.js route handlers and Deno), and check the signing secret belongs to this endpoint and this mode (test and live are different).
  • 500: the handler threw; your function logs say why.

Once fixed, replay the failed events from the dashboard. Don't wait long: in live mode Stripe retries a failed delivery for about three days and then gives up, so an endpoint that was down over a long weekend can lose events for good. This also happens with correct code, for example when a deploy takes the endpoint down at the wrong moment.

5. Refunds and disputes

A refund or a chargeback doesn't cancel the subscription by itself. If they should end access, handle charge.refunded and charge.dispute.created (the dispute event carries a charge id; fetch the charge to find the customer). Decide the policy first: a goodwill refund might keep access; a chargeback usually shouldn't.

How to find the customers already affected

  1. In Stripe, go to Billing → Subscriptions and filter by Canceled.
  2. Look each customer up in your access table. Anyone who still has access is affected.
  3. Do the reverse for Active: every active subscriber should have access.

Match on the Stripe customer id if you store it (store it, if you don't); email matching breaks when people pay with a different address than they signed up with.

Automating the check

Doing this by hand once is fine. Doing it every week is where it slips. I built Entitled to do this comparison on a schedule: it reads Stripe with a restricted read-only key and your app through a read-only Postgres role or a small read-only endpoint, and lists every customer the two sides disagree about, with a suggested fix. The first scan is free.

Whether or not you use it, check your webhook's event list today. It takes two minutes.

Top comments (0)