Demo, all content is generated
Question

Users who cancel in Stripe still have Pro in my app

Solved · 4205 views · asked by hugo_l · edited

I have a Next.js + Supabase app with a Pro plan. Claude Code set up a webhook for checkout.session.completed that sets profiles.plan = 'pro'. Works great.

Problem: people cancel through the customer portal (or their card fails) and they keep Pro forever. Also I noticed one guy renewed and nothing happened in my DB, which is fine by accident, but I clearly don't understand the flow.

Which events do I actually need? The Stripe docs list like 200 events.

What I’ve tried

Asked Claude to "handle cancellations" and it added customer.subscription.deleted but the cancelled users still have pro because the portal cancels at period end I think?

Comment
Do you store the stripe subscription id anywhere, or only the plan string? mira_dev · edited
only plan and stripe_customer_id on profiles hugo_l · edited
Good question, this is the #1 billing bug in apps I review. rafa_dev · edited

3 answers

Marked as helpful by the asker
tobiasw · edited

checkout.session.completed fires once, at the start. For ongoing state you want the subscription events. The pattern that doesn't break:

Listen to:

  • customer.subscription.created
  • customer.subscription.updated (plan change, cancel scheduled, past_due, renewal period moves)
  • customer.subscription.deleted (actually ended)
  • invoice.payment_failed (optional, for sending a "fix your card" email)

In every one of them, do the same thing: fetch the subscription and write its current state. Don't try to compute state from the event type.

case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
  const sub = event.data.object as Stripe.Subscription;
  await supabaseAdmin.from("subscriptions").upsert({
    id: sub.id,
    customer_id: sub.customer as string,
    status: sub.status,              // active, trialing, past_due, canceled, unpaid...
    price_id: sub.items.data[0].price.id,
    cancel_at_period_end: sub.cancel_at_period_end,
    current_period_end: new Date(sub.items.data[0].current_period_end * 1000).toISOString(),
  });
  break;
}

Then "is this user Pro" becomes: status in ('active','trialing') (decide yourself if past_due still gets access during retries).

Why your cancel didn't work: the portal by default cancels at period end. That sends customer.subscription.updated with cancel_at_period_end: true, and only when the period is over does customer.subscription.deleted arrive. So those users will lose Pro, just a month later. That's correct behavior, they paid for the month.

Note: on API versions from 2025-03-31 on, current_period_end lives on the subscription item, not the subscription. Older tutorials read sub.current_period_end, which is undefined on new accounts.

Comment
This is so much clearer than anything I read. The period end thing explains it, I have 3 people with cancel_at_period_end true. hugo_l · edited
saving this. my app has the exact same bug sam_builds · edited
The current_period_end move caught me too, TypeScript flagged it but Claude kept "fixing" it with as any. jonas_k · edited
Good answer. Would add: make the handler idempotent, Stripe can deliver the same event twice and out of order. Upserting full state (like here) handles that nicely. felix_codes · edited
Bookmarking. The "write the full state every time" idea is what made it click for me. kofi_mensah · edited
mira_dev · edited

Supabase side: put the subscription table behind RLS where users can only select their own row (join via customer id), and only the webhook (service role) writes. Otherwise a user can update their own plan column from the browser with the anon key.

alter table subscriptions enable row level security;
create policy "own subscription" on subscriptions for select
  using (customer_id = (select stripe_customer_id from profiles where id = auth.uid()));
Comment
oh no. i just checked and profiles has an update policy for own row. so yes someone could set plan=pro. fixing now hugo_l · edited
ingrid_h · edited

Easy way to test all of this: stripe trigger customer.subscription.updated won't have your customer, so instead create a test subscription, cancel it in the portal, then use a test clock to jump past the period end and watch the events arrive.

Comment
test clocks, never heard of them. trying hugo_l · edited