Webhook semantics
What each Stripe event does to a user's access, and the refunds and disputes that need a person. How the handler stays correct when Stripe redelivers and reorders events is in Webhook internals.
| Event | Effect |
|---|---|
checkout.session.completed (subscription) |
upsert plan/status/period for the user in the subscription's metadata.userId; skipped (skipped_no_user) when that is missing. client_reference_id is not read |
checkout.session.completed (payment) |
fires BILLING_PAYMENT_HOOK (see One-time payments) unless payment_status is unpaid |
checkout.session.async_payment_succeeded |
the money landed: fires BILLING_PAYMENT_HOOK |
checkout.session.async_payment_failed |
logged processed, grants nothing |
customer.subscription.created / updated |
upsert plan/status/period |
customer.subscription.deleted |
plan=free, status=canceled |
charge.refunded (full only) |
if the charge paid the row's subscription: cancel it at period end in Stripe (see Webhook internals). Any other charge leaves the subscription alone. Then onPaymentReversed. Partial refunds are ignored |
charge.dispute.funds_withdrawn |
recorded in billing_disputed_customers, then plan=free, status=disputed (locked), plus onPaymentReversed. Recorded even with no row yet, so one created later is born locked |
| anything else | logged skipped_unhandled, 200 |
invoice.payment_failed is deliberately unhandled: Stripe moves the
subscription itself to past_due and fires customer.subscription.updated
for the same transition; a second writer on the invoice stream only adds
ordering hazards.
Access policy. past_due keeps paid access (Stripe is retrying the
card; punishing a hiccup churns customers). Access ending when the
retries run out depends on the Stripe setting in
setup step 5. incomplete
never grants (first payment never confirmed). The effective plan is
free whenever the status doesn't grant paid access: a canceled pro row
never leaks pro.
Paid access follows the plan. A live status is not enough: the row's
plan must be pro, which is set from the subscription's product (the one
behind STRIPE_PRO_PRICE_ID). A live subscription on any other product of
the same Stripe account stores plan=free and grants nothing. If you add a
second paid plan, two things need work: extend this check in
extractEntitlementDtoFromRow (billing.schemas.ts) and the plan mapping
in subscriptionToFields (billing-status.ts), or its buyers get no
access; and give buyers a way to switch, because checkout refuses anyone
with a live subscription, whatever its product, and points them to the
portal (enable plan switching there, or build an upgrade path).
Refunds and disputes that need a person
Two refunds need a person:
Delivered before the customer's subscription row exists: skipped as
skipped_no_user. Cancel that subscription in the Stripe dashboard by hand.A charge with no PaymentIntent: invoice payments can only be looked up by PaymentIntent, and an invoice can be paid by a bare charge, so the handler cannot tell whether it paid the subscription. It logs a warning and records an
errorrow whose detail readscharge <id> unresolved, answered 200 because a retry would learn nothing new. Find these withSELECT event_id, detail, created_at FROM billing_webhook_events WHERE type = 'charge.refunded' AND outcome = 'error' AND detail LIKE '%unresolved%';then open the charge in the dashboard and cancel the subscription at period end yourself if the charge paid one of its invoices.
If you win the dispute (or want to forgive), unlock manually. Clear both
the row and the customer's billing_disputed_customers record, or the
next subscription event re-locks the row:
UPDATE billing_subscriptions
SET status = 'canceled' -- they can re-subscribe self-serve
WHERE user_id = '<id>' AND status = 'disputed';
DELETE FROM billing_disputed_customers
WHERE stripe_customer_id = '<cus_...>';