Webhook internals
How the webhook handler stays correct when Stripe redelivers, reorders and races its events. What each event does to access is in Webhook semantics.
Subscription state
Idempotency. Subscription-stream writes go through a guarded upsert
(BillingSubscriptionStore): the UPDATE only lands if the row isn't
disputed, the exact event id hasn't been applied, and event.created is
strictly newer than the last applied event. First-delivery races resolve
via INSERT … ON CONFLICT DO NOTHING, then one more guarded pass (so a
strictly-newer event that lost the insert race still applies). Because
event.created is second-resolution, two distinct events can tie: the
store then fetches live subscription state from Stripe and writes that
(truth is order-independent), but only when both events name the
subscription the row already holds. A tie naming a different subscription
is stale, because the row is the newer one and fetching the rejected
event's subscription would overwrite a paying customer with the one that
lost.
The row holds one subscription per user, so the same rule applies to a
strictly newer event: a non-live event (incomplete, canceled) about a
different subscription than the row holds is skipped as stale while the
row's subscription is live. Otherwise an abandoned second checkout's
incomplete_expired, about a day after it was opened, would revoke access
from the subscription the customer is still paying for. A live event
replaces any row, and every event for the subscription the row holds
applies.
One Stripe customer per user. The first checkout creates the user's
Stripe customer (email, metadata.userId) and stores it in
billing_customers before the session is created, and every later
session passes that customer. A user whose subscription row already names
a customer gets that one stored instead. Two first checkouts in flight
together get the same customer: the create carries an idempotency key per
user, and the insert is ON CONFLICT (user_id) DO NOTHING followed by a
read, so the id stored first is the one both sessions use. If Checkout
refuses the stored customer as missing (deleted in the Dashboard, or a
test-mode id under a live key), it is replaced with a new one, swapped in
only while the row still holds the missing id, and the session is retried
once.
Two paid subscriptions. Checkout refuses only a user whose row is
already live, so two sessions opened while the user was free can both be
paid, both under the user's one customer. The row holds the newer one.
When a non-live event is about to end the subscription a live row holds,
the webhook first lists the subscriptions of the ending one's customer
(subscriptions.list({ customer }), every page, which leaves out
canceled ones). The first
other one live on the Pro product is written instead, the way the
same-second path writes a refetch: under the event's cursor, also on a
tied second, never over refunded, and locked if that customer charged
back. The user keeps Pro while it bills. If the row refuses that write,
the event's own fields apply as usual. A live event for a second
subscription while the row holds a live one logs a warning naming both:
nothing refunds automatically, so refund and cancel one in the dashboard.
The list is consistent, so a subscription created a moment ago is in it. A connection, rate-limit or Stripe-side error fails the delivery so Stripe retries. Any other error (a customer Stripe no longer has, a key without permission) will not succeed on retry, so it logs a warning naming the user and subscription and applies the event as it stands, which drops the user to free until the other subscription's next event.
One known window: the decision to list reads the row before the guarded write, outside its WHERE, so a delivery for the same user landing between the two can make one event skip a list it needed, or run one it did not.
Every delivery, applied, skipped (with the precise reason), or errored,
gets its own row in billing_webhook_events. One row per DELIVERY, not
per event id: Stripe redelivers, and a first attempt that errored
followed by a retry that processed is two rows, in order. error usually
means the handler threw and Stripe will retry; a refund it could not
resolve is also error, answered 200 and not retried (see
Webhook semantics).
Read one event's full history with:
SELECT outcome, detail, created_at
FROM billing_webhook_events
WHERE event_id = '<evt_...>'
ORDER BY created_at;
That table deduplicates nothing. Deduplication lives where the work is:
the event cursor on billing_subscriptions and the claim rows in
billing_payment_hook_claims.
Refunds. A full refund of a subscription charge calls
subscriptions.update(id, { cancel_at_period_end: true }) in Stripe and
writes nothing to billing_subscriptions. The row follows from the
customer.subscription.updated that comes next and, at period end,
customer.subscription.deleted, so the customer keeps access until the
period they paid for runs out. Revoking at once is the dispute path's job.
A Charge carries no invoice reference on this API version, so the
handler walks every invoice payment recorded against the charge's
PaymentIntent (one PaymentIntent can pay several invoices), keeps the
paid ones, and reads each invoice's parent.subscription_details. It
acts only when one of them is the subscription the customer's row holds.
It does nothing (skipped_unhandled) when that subscription is already
canceled, set to cancel at period end, has a cancel_at on or before
the current period end, or no longer exists in Stripe. A cancel_at
further out still gets cancelled, since the subscription would renew
before it. A refund of a subscription the row no longer holds (the
customer re-subscribed) is skipped_stale.
The cancellation happens once per charge. The update carries the
idempotency key refund-cancel:<charge id>, and only after Stripe
accepts it does the handler record that key in
billing_payment_hook_claims. A redelivery finds the record first and
is skipped_duplicate, so it cannot cancel again a subscription the
customer or an operator reactivated afterwards. A Stripe error records
nothing and throws, so Stripe redelivers. A crash between the update and
the record, or two concurrent deliveries, send the same key, and Stripe
answers the second with the first result instead of cancelling again.
The key has two costs. Stripe also replays a stored error for that key,
so a transient Stripe failure delays the cancellation through up to 24
hours of redeliveries. And Stripe keeps keys for 24 hours, so a response
lost after Stripe applied the cancel, then a reactivation, then a
redelivery more than 24 hours later, cancels again.
Status refunded is a legacy value nothing writes. A row may still hold
it; it grants no access, and the next subscription event overwrites it.
The chargeback write is the one deliberate exception. Funds have already left the account, so it lands whatever the cursor says: cursor-guarding it would let a dispute that opened before the last subscription event leave access switched on. For the same reason, the chargeback lock is written before the reversal hook runs, so a failing hook or a Stripe blip cannot leave paid access switched on for someone who took their money back.
Disputes. When chargeback funds actually leave the account, the row
locks: Stripe keeps reporting the subscription as active during a
dispute, so without the lock the next subscription.updated would hand
access back to someone doing a chargeback. The lock is one-way.
The chargeback is also recorded in billing_disputed_customers, keyed on
the Stripe customer, and that record is what makes the lock survive
delivery order. A dispute can arrive before the subscription row exists -
the subscription delivery failed once and is in Stripe's retry backoff,
or the endpoint was briefly down and Stripe is working through a backlog.
The lock is an UPDATE, so it would match nothing, and the 200 it returns
means Stripe never sends the dispute again. The insert path reads this
table, so a row created after a chargeback is born disputed instead of
pro/active.
Unlocking a row by hand is in Webhook semantics.
Rows in billing_disputed_customers are never swept: a customer who
charged back once is worth knowing about when the same card comes back.
The one-time payment hook
Delivery semantics. Stripe delivers webhooks at-least-once: the same
event id can arrive twice (a retry after a slow 200, or - rarely - two
genuinely concurrent deliveries). BillingWebhookService claims the
event id in billing_payment_hook_claims (INSERT … ON CONFLICT DO NOTHING) before invoking the hook, not after, so a redelivery sees the
claim and skips instead of re-invoking. The hook runs at most once per
event id. If the hook throws, the claim is released so Stripe's retry can
claim and run it again.
One gap remains, inherent to claiming before running without a lease or TTL: if the process crashes between the claim succeeding and the hook finishing - an actual process death, not a thrown error - the claim row is never released and Stripe's retry is silently skipped. The failure mode is a lost grant, meaning the customer paid and the hook never ran, so monitor for stuck claim rows if your grant is irreversible. It is also why the belt-and-braces advice in One-time payments still stands.
Asynchronous methods pay late. Pix, UPI and some bank debits complete
the Checkout Session while the funds are still moving, arriving as
checkout.session.completed with payment_status: 'unpaid'. The hook is
skipped for those and fires on the later
checkout.session.async_payment_succeeded instead, so a grant is never
handed out before the money lands.
checkout.session.async_payment_failed is recorded and grants nothing.
The two deliveries carry different event ids, so the dedupe claim is
taken only when the hook actually runs.
Which methods a session offers is the Stripe account's configuration, not this code's, and Managed Payments picks the set for you - so an async method can appear without a code change. That is why this is guarded even if none of your enabled methods is delayed-notification today.