Appearance
Webhooks
POST /api/stripe/webhook (receiveStripeWebhook in src/stripe_webhook.ts, feature F11) is the only place where Stripe changes portal state. It verifies the signature, makes processing idempotent, archives the raw event to R2, and turns five event types into changes in the subscriptions, connections and inmates tables.
Not yet verified against Stripe
This path is covered by test/stripe_webhook.test.ts with locally signed events and no Stripe key. The branches that call Stripe from inside the webhook (re-fetching the subscription, cancelling a duplicate) run only when STRIPE_SECRET_KEY is set and have never run against real Stripe.
Request handling, in order
| Step | What happens | Failure |
|---|---|---|
| 1 | STRIPE_WEBHOOK_SECRET must be set | 503 billing_not_configured |
| 2 | The raw body is read as text (the signature is computed over exactly these bytes) | |
| 3 | Stripe-Signature is verified (below) | 400 invalid_signature |
| 4 | The body is parsed as JSON | 400 invalid_event |
| 5 | id and type must be strings | 400 invalid_event |
| 6 | The event id is reserved in D1 (Idempotency) | duplicate: 200 {"received":true,"duplicate":true} and stop |
| 7 | The raw body is archived to R2 (R2 archive) | on error: reservation deleted, error returned |
| 8 | The event is handled (Events) | on error: reservation deleted, error returned |
| 9 | 200 {"received":true} |
Only POST is routed; any other method on this path is answered with the usual method-not-allowed response.
Signature scheme
Stripe sends Stripe-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>...]. The Worker, with Web Crypto and no SDK:
- Splits the header on
,and each part on=, and takestand everyv1. - Rejects the request if
tis missing or not a run of digits, or if there is nov1value. - Rejects it if
|now − t|is more than 300 seconds (the tolerance, in both directions: replays of captured requests expire, and so does a skewed clock). - Computes
HMAC-SHA256(key = STRIPE_WEBHOOK_SECRET as UTF-8, message = "<t>.<raw body>"). - Accepts when any
v1value, decoded from hex, equals the digest in a constant-time comparison (constantTimeEqualwalks the longer of the two arrays, so length differences do not short-circuit). Av1that is not valid hex never matches.
Every failure above is the same 400 invalid_signature ("Stripe signature is invalid or expired."), so the response does not reveal which check failed. Multiple v1 values occur when Stripe is rolling a signing secret.
Consequences: do not put a proxy between Stripe and the Worker that re-serialises the body; the signing secret of stripe listen and that of a Dashboard endpoint differ, so the secret must match the source of the events (Testing).
Idempotency
Stripe may deliver the same event more than once. The Worker keeps an idempotency ledger in D1:
sql
INSERT OR IGNORE INTO stripe_events (event_id, event_type, received_at) VALUES (?, ?, ?)- If no row is inserted (
meta.changes = 0) the event id was seen before: the response is200 {"received":true,"duplicate":true}and nothing else happens (no archive, no state change). - If archiving or handling throws, the Worker deletes the reservation row and rethrows. The failed event is therefore not recorded as processed and Stripe's retry runs it again from scratch.
- The event id is the key, not the subscription: two different events about the same subscription are both processed, and the handlers are written so that order and repetition do not corrupt state (Latest state).
R2 archive
Every verified, non-duplicate event is written to the FILES bucket before it is handled:
| Key | Content | Written when |
|---|---|---|
stripe/events/<event_id>.json | the raw request body, content type application/json | every verified new event, including event types the Worker ignores |
stripe/duplicates/<subscription_id>.json | JSON record of a duplicate subscription (below) | when a duplicate or stale subscription is detected |
The archive makes it possible to replay or audit an event without asking Stripe. Duplicate records (the second key) are what you use for refunds (Going live).
Events and state changes
Five event types change state. Anything else is archived and acknowledged.
| Event | State change |
|---|---|
checkout.session.completed | See below. Stores customer id and subscription id, clears the open-session id. Never touches status, period, connections or inmates, and never calls Stripe unless it records a duplicate |
checkout.session.expired | Clears users.open_checkout_session_id when it equals this session's id. Nothing else |
customer.subscription.created | Subscription sync: resolve user, re-fetch, upsert the row, update the connection |
customer.subscription.updated | Same as created: renewals, plan switches, past_due, cancel_at_period_end, recovery |
customer.subscription.deleted | Same sync; the stored status becomes canceled and the connection not_connected |
An event with no data.object, or a handled type for a user the Worker cannot identify, does nothing and still returns 200.
checkout.session.completed
- The user id is
client_reference_id, elsemetadata.user_id. With none, nothing happens. - The user's stored open Checkout Session id is cleared when it equals this session's id.
- If the session carries a subscription id, the account's stored row has a different
stripe_subscription_id, and that stored status is live (active,trialing,past_due): the new subscription is a duplicate; it is recorded and cancelled (Duplicate subscriptions) and nothing else changes. - With no customer id on the session, nothing else happens.
- Otherwise the user's
subscriptionsrow is inserted or updated:- new row: status
incomplete, no period,cancel_at_period_end = 0; - existing row:
inmate_idandstripe_customer_idare set,stripe_subscription_idis set when the session has one (an existing value is kept otherwise), status and period are not touched, andplan_idis kept when the current status isactive,trialingorpast_dueso a late completion cannot overwrite a live plan; - the session has no subscription items, so the plan comes from
metadata.plan_id, then the existing row's plan, thenmonthly.
- new row: status
The row stays incomplete (not live) until a customer.subscription.* event arrives with the real status; this is what keeps a completed-but-not-yet-synced checkout from granting access.
Subscription events
For customer.subscription.created|updated|deleted:
- Find the user, in order:
metadata.user_idon the subscription object; thesubscriptionsrow with the samestripe_subscription_id; the row with the samestripe_customer_id. If none matches, the event is ignored. (Checkout putsuser_idandplan_idinsubscription_data[metadata], so subscriptions created through the portal carry them; see Checkout.) - Subscription id is the object's
id; if the object is not a subscription, itssubscriptionfield. With neither, the event is ignored. - Same-account stale/duplicate check (below).
- Re-fetch the subscription from Stripe (below).
- Cross-account duplicate check against the inmate.
- Upsert the
subscriptionsrow (field mapping). - Update the connection (connection mapping).
Latest state: the Stripe re-fetch
Stripe does not guarantee event order. When STRIPE_SECRET_KEY is set, the Worker therefore does not trust the event payload for subscription events. After resolving the user it calls GET https://api.stripe.com/v1/subscriptions/<id> and builds the row from the retrieved subscription. Values missing from the response fall back to the event's (status, current_period_start, current_period_end, and subscription items are merged per index). Processing an old event after a newer one therefore writes Stripe's current state, not the old event's.
- A non-2xx answer or an invalid body:
502 stripe_error, the reservation is released, and Stripe's retry reprocesses the event. - Without
STRIPE_SECRET_KEY(the test suite,wrangler devbefore secrets are set) no call is made and the payload is applied as-is, so event order matters and an old event can overwrite newer state. Also without a re-fetch,customer.subscription.deletedforces the stored status tocanceled.
How fields are stored
subscriptions column | Source |
|---|---|
status | Status of the re-fetched subscription (the event's without a re-fetch). active, trialing, past_due, incomplete, canceled pass through; unpaid and paused become past_due; anything else (for example incomplete_expired) becomes incomplete. For customer.subscription.deleted without a re-fetch the status is forced to canceled |
current_period_start, current_period_end | Each read separately: the object's top-level Unix-seconds value if finite, otherwise the same key on items.data[0] (newer Stripe API versions report periods on the item), converted to ISO-8601; NULL if neither exists |
cancel_at_period_end | 1 when the object's cancel_at_period_end is true, else 0 |
plan_id | Price id of items.data[0] compared to STRIPE_PRICE_MONTHLY, STRIPE_PRICE_QUARTERLY, STRIPE_PRICE_ANNUAL; else metadata.plan_id if it is one of the three plan ids; else the existing row's plan; else monthly. The price actually charged therefore wins over stale metadata |
stripe_customer_id | From the event (kept if the event has none) |
stripe_subscription_id | The subscription id |
inmate_id | The account's current inmate, or NULL |
The stored period feeds the monthly allowance window (period.ts, F12): period_start/period_end returned by /api/usage and to Hermes are the one-month slice of the Stripe period that contains now.
Connection state
If the account has an inmate, the connection is updated after the row is saved:
| Subscription status | New connection status |
|---|---|
active or trialing, and the subscription's latest connection for the same inmate is already connected | connected (renewal or plan change; detail kept) |
active or trialing, any other case: first payment, previous connection pending, failed or not_connected, or a changed inmate | pending |
anything else (past_due, canceled, incomplete) | not_connected |
The latest connections row of the subscription is updated in place (status, period start and end, and detail cleared unless it stays connected), or one is inserted if none exists. inmates.connection_status is set to the same value, with connection_detail cleared unless it stays connected.
pending connections whose subscription is active or trialing are exactly what GET /api/hermes/connections?status=pending returns, so the Hermes bridge picks them up on its next run and acknowledges connected or failed.
Renewals keep the inmate connected
A renewal (customer.subscription.updated with status active) of an already connected inmate does not re-queue the connection. It goes back to pending only after leaving active/trialing: a failed payment makes the subscription past_due (connection not_connected), and the recovery event re-queues it. The bridge treats the already-added Securus contact as connected without a second welcome.
If a subscription event arrives before the family has added an inmate, only the subscription row is written (no connection). The connection is created by the next subscription event after the inmate exists.
Cancellation
Cancellation arrives in two stages when the family cancels at the end of the period:
customer.subscription.updatedwithcancel_at_period_end = true: the status staysactive; the portal storescancel_at_period_end = 1, and the connection staysconnected.- At period end,
customer.subscription.deleted: after the re-fetch the status iscanceled(without a re-fetch it is forced), the connection becomesnot_connected, and the Hermes bridge revokes the contact on its next run.
An immediate cancellation skips stage 1.
Duplicate subscriptions
A subscription event or checkout completion must never overwrite the account's live subscription with a different Stripe subscription. Two situations count as duplicates (or stale subscriptions):
- Same account. The account's row holds a stripe subscription id that is different from the event's and whose status is live (
active,trialing,past_due). This happens when two Checkout Sessions were paid, and also when a late event about an older, replaced subscription arrives. It applies tocheckout.session.completedand to everycustomer.subscription.*event. - Cross-account. The subscription (after the re-fetch) would be live for an inmate whose
inmate_numberalready has a live subscription on a different account. This closes the race in which two families check out for the same inmate before either subscription exists;409 inmate_takenat checkout is the same rule applied earlier.
A customer.subscription.deleted event for a subscription id that already has a stripe/duplicates/<id>.json record is also treated as a duplicate, so a late deletion of a duplicate can never cancel the live row. Once the stored subscription is canceled or incomplete, a new subscription id replaces it normally.
What the Worker does with a duplicate (recordDuplicateSubscription):
- Record it at
stripe/duplicates/<subscription_id>.json:user_id,stripe_subscription_id,event_type,customer_id,subscription(the Stripe object),recorded_at,cancellation_requested,canceled, and, once a cancel was attempted,cancellation_statusandcancellation_response. An existing record is merged, not replaced: earlier fields are kept,cancellation_requestedandcancelednever go from true to false, and the stored subscription object is overlaid rather than replaced by older data. - Cancel it through Stripe, only when all of these hold:
STRIPE_SECRET_KEYis set, the event type is notcustomer.subscription.deleted, the record is not alreadycanceled, and the event does not already showstatus = canceled. The Worker first re-fetches the subscription and skips cancelling if Stripe already reportscanceled; otherwise it sendsDELETE https://api.stripe.com/v1/subscriptions/<id>and stores the HTTP status, response body andcanceledin the record. If theDELETEis not OK it re-fetches once more and still counts the subscription as cancelled when Stripe now sayscanceled. - Fail loudly when it could not be cancelled:
502 stripe_error, reservation released, record saved withcanceled = false, Stripe retries the event.
A customer.subscription.deleted event for a duplicate only updates the record (canceled = true when the object's status is canceled); it never calls Stripe.
Without STRIPE_SECRET_KEY the duplicate is only recorded and the family could be billed twice until someone cancels it by hand. Look in stripe/duplicates/ after any double payment, and set the key in every environment that receives real webhooks.
Cancelling the duplicate stops future charges. It does not refund a charge already taken; see Going live.
Retries and error responses
| Situation | Response | Reservation |
|---|---|---|
Missing STRIPE_WEBHOOK_SECRET | 503 billing_not_configured | not reached |
| Bad, stale or missing signature | 400 invalid_signature | not reached |
Bad JSON or missing id/type | 400 invalid_event | not reached |
| Replay of an event already processed | 200 {"received":true,"duplicate":true} | kept |
| Stripe API call failed during handling (re-fetch, cancel duplicate) | 502 stripe_error | released |
| Unexpected exception (for example a D1 or R2 error) | 500 internal_error | released |
| Success, including ignored event types | 200 {"received":true} | kept |
Stripe treats any non-2xx answer as a failed delivery and retries it on a schedule (general Stripe knowledge: live mode retries with exponential backoff for up to about three days; sandbox mode retries for a shorter period). Because the reservation is released on failure and the handlers re-read the current Stripe state, a retried event is safe to apply once the underlying problem (Stripe outage, wrong key, D1 error) is fixed. A permanent 400 cannot be fixed by a retry; check the signing secret instead. Manual replay of an event (and of the corresponding DELETE FROM stripe_events) is described in the runbook.