Skip to content

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 tagged metadata.kind=wallet_topup is a wallet top-up and is routed first: it credits the workspace's wallet with amount_subtotal (only when payment_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: an unreconciled movement 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) credits amount_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_failed is 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's payment_intent against 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-gateway tag must still reach the mirror) and use a local relevance test instead, which makes no network call: the object's own tag, the tag in data.previous_attributes, the object id already being mirrored, or (for a price) its product being mirrored. An event that passes none is ignored (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 (processed or ignored). 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.sh in 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.