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.createdcustomer.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.
as any. jonas_k · edited
planandstripe_customer_idon profiles hugo_l · edited