Stripe webhook receiver
POST /hooks/stripe on the public inference host (https://ai-api.myra.eu) is the
inbound receiver for Stripe webhook events that drive
self-serve sign-up provisioning and subscription lifecycle (payment success/
failure, plan changes, cancellation, disputes, refunds). It is not a customer-
facing API — only Stripe's own webhook delivery infrastructure calls it — and is
documented here because it is a genuine untrusted-input trust boundary (principle
11): every byte of the request body and every header is hostile until verified.
Trust boundary: signature verification
Stripe authenticates via a body signature, not a bearer token. The
Stripe-Signature request header carries t=<unix-timestamp>,v1=<hex>[,v1=<hex>...]
(multiple v1 values appear during a webhook-secret rotation window — any one
matching is accepted). The expected signature is HMAC-SHA256(<the configured Stripe webhook secret>,
"<t>.<raw body>"), compared constant-time against every v1 candidate.
Signature verification happens before any JSON parsing, and regardless of the self-serve kill-switch — the kill-switch stops new checkouts only; already-paid customers must never be refused service by a config flag.
| Rejected input | Response |
|---|---|
Missing/empty Stripe-Signature header |
400 |
Malformed header (no t=, no v1=) |
400 |
| Timestamp more than ±300s from server time | 400 |
No v1 candidate matches the computed HMAC |
400 |
| Stripe webhook secret not configured (unset or whitespace-only) | 400 (fails closed, never crashes) — reachable only when self-serve is off; with self-serve on a boot-time gate refuses to start on a missing/blank secret, so a self-serve gateway always has a usable one |
| Body exceeds 512 KB | 413 |
An unsigned or otherwise unverifiable request is rejected here, at the signature check, and never consumes any rate-limit budget — the endpoint is not rate-limited on the caller's address (behind the edge every external request shares one origin peer address, so a per-address limit would be a single shared bucket that any unsigned flood could pin, starving genuine signed deliveries).
Once the signature verifies, the body is decoded and shape-checked:
| Rejected input | Response |
|---|---|
| Non-JSON body | 400 |
| JSON body that isn't an object | 400 |
Missing id or type |
400 |
Missing data or data.object |
400 |
| More than 6000 verified events/minute across the endpoint | 429 (a flat per-endpoint safety cap applied only after the signature verifies — so only genuine Stripe traffic can reach it; Stripe retries a 429 with backoff) |
Accepted event types
Only these fourteen are processed (eight billing events + six catalog events); anything
else is acknowledged (200) and marked ignored — Stripe stops retrying an event the
moment it gets any 2xx, so an unrecognized-but-harmless event type must never be met
with a 500 (a 500-retry storm would get the endpoint auto-disabled by Stripe, silently
killing provisioning for real customers).
checkout.session.completed— provisions a new tenant (pay-first: no tenant exists until this fires), or reactivates a cancelled tenant. A payment-mode session taggedmetadata.kind=wallet_topupis a wallet top-up and is routed first: it credits the workspace's wallet withamount_subtotal(only whenpayment_status=paid). A money field that is not a non-negative integer, a currency the platform cannot convert, or a workspace that no longer exists is terminal: anunreconciledmovement is recorded, ops is alerted once, and the event is acknowledged (200) — never a retry storm that can never succeed. A database fault is transient (500, redelivered).payment_intent.succeeded— our own off-session automatic top-up (metadata.kind=wallet_auto_topup) creditsamount_received; the PaymentIntent behind a manual top-up (metadata.kind=wallet_topup) only saves the card it was paid with. Any other PaymentIntent is ignored. Credits are idempotent per PaymentIntent (the ledger's unique key), so the two events Stripe fires for one payment credit once.payment_intent.payment_failedis deliberately not subscribed: the automatic top-up learns every decline synchronously from the charge itself.invoice.paid— renewal/initial-payment confirmation; resets the usage allowance on a genuine new billing cycle (never on a portal tier-switch's proration invoice — that would let a customer farm free allowance resets by toggling tiers). Marks the subscription active and ends any dunning grace only on those same billing events (subscription_create/subscription_cycle); a proration invoice — paid or net-credit — changes nothing about status or grace (the subscription's own status event does). A no-op on a subscription that is already terminal (a late renewal invoice after a cancellation never resurrects the workspace).invoice.payment_failed— grace period + dunning email on a renewal failure; immediate deactivation (no grace) on a first-invoice failure. A no-op — no dunning email either — on a subscription that is already terminal.customer.subscription.updated— syncs status/period bounds; detects a plan/interval switch by the subscription's price id, never by metadata (a portal-initiated price change never touches subscription metadata). Stripe's subscription status is the dunning authority: a status that maps to active also ends a grace window (the second way out of dunning, for a proration whose failed charge started it). The period and the cancel flag are written only when the event carries a well-formed value (see below); a non-terminal update never touches a subscription that is already terminal.-
customer.subscription.deleted— deactivates the tenant; starts the retention clock toward eventual data purge. -
charge.dispute.created— locks the account (no self-service reactivation) and alerts ops for manual review. A dispute on a wallet charge (matched by the charge'spayment_intentagainst the wallet ledger) also debits the wallet and flags it; the subscription lock still applies when one exists. charge.refunded— deactivates the account. A refund of a wallet charge instead debits the wallet by the refunded share of what was credited (cumulative across partial refunds) and leaves the subscription untouched. If the refund arrives before the top-up's own credit has been processed, the event is held (500, retried) rather than consumed. A charge that is neither a wallet charge nor known to be ours follows the subscription path.
Catalog events
price.created, price.updated, price.deleted, product.created, product.updated,
product.deleted keep the product catalog's Stripe mirror
equal to what is configured in Stripe.
- They bypass the metadata product filter above (an object that just lost its
metadata.product=ai-gatewaytag must still reach the mirror) and use a local relevance test instead, which makes no network call: the object's own tag, the tag indata.previous_attributes, the object id already being mirrored, or (for a price) its product being mirrored. An event that passes none isignored(200) with zero Stripe calls. - A relevant event triggers one refetch of that object from Stripe (5 s timeout) in
the client's pinned API version; the event body is never trusted beyond its validated id
(
^[A-Za-z0-9_-]{1,128}$). So delivery order, duplicates, and the endpoint's own API version do not matter. - Catalog events are always answered
200(processedorignored). A transient failure (Stripe unreachable, a database fault, a mirror lookup error, even an unexpected error in the handler) only marks the mirror dirty; the next full sync reconciles it. The webhook path never deletes a mirror row — only the full sync does, under its own safety rules. A burst of events for one object within 5 s refetches once and marks the mirror dirty for the rest. - Each environment's endpoint must subscribe to these six events (in addition to the eight
billing events) — see
scripts/stripe/ensure_webhook_events.shin the billing runbook.
Which event writes which column
Stripe delivers a renewal's, a plan change's or a dunning cycle's events in parallel with no ordering guarantee. Each handler therefore writes only the columns its event owns — never a value it read from the row moments earlier (a parallel delivery whose write landed in between would otherwise be reverted: a scheduled cancellation silently un-flagged by the renewal invoice, a stale 7-day grace deadline left on a recovered workspace, a period roll reverted for the dunning duration). A column the event does not own is left untouched.
| Event | status | Stripe status | grace deadline | period | cancel-at-period-end | dispute flag |
|---|---|---|---|---|---|---|
invoice.paid (subscription_create / subscription_cycle) |
active | — | cleared | the invoice line's service period (when readable) | — | — |
invoice.paid (subscription_update) |
— | — | — | only when the line period extends the stored one (an interval switch) | — | — |
invoice.paid (any other reason) |
— | — | — | — | — | — |
invoice.payment_failed |
grace / inactive | past_due |
the new deadline / cleared | — | — | — |
customer.subscription.updated |
mapped from Stripe's status | Stripe's (when recognized) | cleared when the status maps to active (active, trialing) and on a terminal status; otherwise — |
the item period (when valid) | Stripe's flag (when a boolean; not on a terminal status) | — |
customer.subscription.deleted |
inactive | canceled |
cleared | — | set | — |
charge.dispute.created |
inactive (locked) | — | cleared | — | — | set |
charge.refunded |
inactive | — | cleared | — | — | — |
Every status-0 write by invoice.payment_failed, customer.subscription.deleted,
charge.dispute.created and charge.refunded clears the grace deadline, so a
cancellation, a dispute or a refund blocks inference immediately — never "after
the grace window a failed payment had opened"; customer.subscription.updated
clears it only on a terminal status (an incomplete / paused / unknown status
maps the workspace inactive but leaves a running grace as it is).
Accepted shapes and what is rejected (the body is signature-verified first;
each field is then read on its own): cancel_at_period_end is written only when
it is a JSON boolean — absent, null, "true" or 1 leave the stored flag as it
is (writing 0 would silently un-schedule a cancellation) and log a warning;
status is written only when it is a string Stripe's vocabulary recognizes — an
unknown string maps the workspace inactive (fail closed) but the raw string is
not stored, and a non-string leaves both status columns as they are; a period is
written only when both bounds are finite positive timestamps with the end after
the start and both before the year 10000 — anything else (one bound, an inverted or zero-length pair, "1e400")
leaves the stored period as it is; billing_reason on an invoice must be a
string — a non-string invoice changes nothing. None of these answer an error:
Stripe would retry a 4xx/5xx for days, and a malformed-but-signed body is
never worth a retry storm.
Product filtering (shared Stripe account)
This endpoint receives every event on the Stripe account, including events for
an unrelated product sharing the same account. Checkout and subscription events
positively match metadata.product == "ai-gateway" before acting. Charge events
(dispute/refund) and invoice events (invoice.paid / invoice.payment_failed)
carry no reliable product tag on the object itself — a real invoice's metadata
is empty and the tag lives only on its line items — so these are matched instead
by looking up the object's customer id against our own subscription table.
An event that doesn't match is ignored (200), never 500 — same reasoning as
the unrecognized-event-type case above. A customer that is not (yet) in our table
is treated as foreign and ignored; a genuine database error during the lookup
returns 500 so Stripe retries.
Idempotency
Every event is durably recorded (stripe_event_id, unique) before processing, so a
Stripe redelivery of an already-fully-processed event is a fast no-op (200), and
a redelivery that arrives while the first delivery is still being processed gets
500 (never 200 — a 200 would make Stripe consider the event delivered and
stop retrying, permanently losing it if the in-flight attempt then fails).
What this endpoint never returns
There is no response body contract to integrate against — Stripe only inspects the HTTP status code. Responses are plain text, not JSON.