Skip to content

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 ​

StepWhat happensFailure
1STRIPE_WEBHOOK_SECRET must be set503 billing_not_configured
2The raw body is read as text (the signature is computed over exactly these bytes)
3Stripe-Signature is verified (below)400 invalid_signature
4The body is parsed as JSON400 invalid_event
5id and type must be strings400 invalid_event
6The event id is reserved in D1 (Idempotency)duplicate: 200 {"received":true,"duplicate":true} and stop
7The raw body is archived to R2 (R2 archive)on error: reservation deleted, error returned
8The event is handled (Events)on error: reservation deleted, error returned
9200 {"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:

  1. Splits the header on , and each part on =, and takes t and every v1.
  2. Rejects the request if t is missing or not a run of digits, or if there is no v1 value.
  3. 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).
  4. Computes HMAC-SHA256(key = STRIPE_WEBHOOK_SECRET as UTF-8, message = "<t>.<raw body>").
  5. Accepts when any v1 value, decoded from hex, equals the digest in a constant-time comparison (constantTimeEqual walks the longer of the two arrays, so length differences do not short-circuit). A v1 that 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 is 200 {"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:

KeyContentWritten when
stripe/events/<event_id>.jsonthe raw request body, content type application/jsonevery verified new event, including event types the Worker ignores
stripe/duplicates/<subscription_id>.jsonJSON 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.

EventState change
checkout.session.completedSee 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.expiredClears users.open_checkout_session_id when it equals this session's id. Nothing else
customer.subscription.createdSubscription sync: resolve user, re-fetch, upsert the row, update the connection
customer.subscription.updatedSame as created: renewals, plan switches, past_due, cancel_at_period_end, recovery
customer.subscription.deletedSame 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 ​

  1. The user id is client_reference_id, else metadata.user_id. With none, nothing happens.
  2. The user's stored open Checkout Session id is cleared when it equals this session's id.
  3. 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.
  4. With no customer id on the session, nothing else happens.
  5. Otherwise the user's subscriptions row is inserted or updated:
    • new row: status incomplete, no period, cancel_at_period_end = 0;
    • existing row: inmate_id and stripe_customer_id are set, stripe_subscription_id is set when the session has one (an existing value is kept otherwise), status and period are not touched, and plan_id is kept when the current status is active, trialing or past_due so 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, then monthly.

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:

  1. Find the user, in order: metadata.user_id on the subscription object; the subscriptions row with the same stripe_subscription_id; the row with the same stripe_customer_id. If none matches, the event is ignored. (Checkout puts user_id and plan_id in subscription_data[metadata], so subscriptions created through the portal carry them; see Checkout.)
  2. Subscription id is the object's id; if the object is not a subscription, its subscription field. With neither, the event is ignored.
  3. Same-account stale/duplicate check (below).
  4. Re-fetch the subscription from Stripe (below).
  5. Cross-account duplicate check against the inmate.
  6. Upsert the subscriptions row (field mapping).
  7. 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 dev before 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.deleted forces the stored status to canceled.

How fields are stored ​

subscriptions columnSource
statusStatus 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_endEach 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_end1 when the object's cancel_at_period_end is true, else 0
plan_idPrice 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_idFrom the event (kept if the event has none)
stripe_subscription_idThe subscription id
inmate_idThe 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 statusNew connection status
active or trialing, and the subscription's latest connection for the same inmate is already connectedconnected (renewal or plan change; detail kept)
active or trialing, any other case: first payment, previous connection pending, failed or not_connected, or a changed inmatepending
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:

  1. customer.subscription.updated with cancel_at_period_end = true: the status stays active; the portal stores cancel_at_period_end = 1, and the connection stays connected.
  2. At period end, customer.subscription.deleted: after the re-fetch the status is canceled (without a re-fetch it is forced), the connection becomes not_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 to checkout.session.completed and to every customer.subscription.* event.
  • Cross-account. The subscription (after the re-fetch) would be live for an inmate whose inmate_number already 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_taken at 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):

  1. 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_status and cancellation_response. An existing record is merged, not replaced: earlier fields are kept, cancellation_requested and canceled never go from true to false, and the stored subscription object is overlaid rather than replaced by older data.
  2. Cancel it through Stripe, only when all of these hold: STRIPE_SECRET_KEY is set, the event type is not customer.subscription.deleted, the record is not already canceled, and the event does not already show status = canceled. The Worker first re-fetches the subscription and skips cancelling if Stripe already reports canceled; otherwise it sends DELETE https://api.stripe.com/v1/subscriptions/<id> and stores the HTTP status, response body and canceled in the record. If the DELETE is not OK it re-fetches once more and still counts the subscription as cancelled when Stripe now says canceled.
  3. Fail loudly when it could not be cancelled: 502 stripe_error, reservation released, record saved with canceled = 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 ​

SituationResponseReservation
Missing STRIPE_WEBHOOK_SECRET503 billing_not_configurednot reached
Bad, stale or missing signature400 invalid_signaturenot reached
Bad JSON or missing id/type400 invalid_eventnot reached
Replay of an event already processed200 {"received":true,"duplicate":true}kept
Stripe API call failed during handling (re-fetch, cancel duplicate)502 stripe_errorreleased
Unexpected exception (for example a D1 or R2 error)500 internal_errorreleased
Success, including ignored event types200 {"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.