AUTH4D-41 Stitch webhook endpoint
apps/api/src/modules/billing/webhook.ts (registered by the billing feature,
apps/api/src/runtime/features/billing.ts) serves:
POST /webhooks/stitch
This public endpoint receives Stitch Express payment.paid webhooks, which Svix delivers. It is not under
/management, /oauth or /t, so the API boundary applies no CORS, bearer-token, CSRF, session or
JSON media-type rules to it. The Svix signature is the only credential. Configure this URL as the
Stitch webhook endpoint. Its signing secret is the Worker secret STITCH_WEBHOOK_SECRET.
Checks, in order
| Step | Result |
|---|---|
| Per-source-IP budget (300 per 60 s, control-plane scope) | 429 rate_limited |
BILLING_ENABLED=true, complete Stitch config, D1 bound |
503 billing_unavailable |
| Body larger than 256 KiB | 413 too_large |
Svix signature over the raw body bytes (provider-stitch) |
401 unauthorized, with no DB writes |
| Signed body that is not JSON or is malformed | 200 rejected, logged at error level |
| Event auth4 does not consume (for example a subscription) | 200 ignored |
A status other than PAID |
200 rejected, logged |
| Link id does not resolve to a stored payment | 200 unknown_payment, logged, with no DB writes |
| Payment exists but checkout has not attached its Stitch id | 503 retry |
svix-id already processed or ignored |
200 duplicate, with no side effects |
| Stitch re-confirms the link status | 503 retry while unconfirmed or Stitch is unavailable |
| Confirmed link is unknown or names another payment | 200 rejected, audited as billing.webhook.rejected |
| Amount or currency differs from the stored payment | 200 rejected, audited as billing.webhook.rejected |
| Transition the state machine does not allow | 200 rejected, audited as billing.webhook.rejected |
| Concurrent update conflict | 503 retry |
| Applied | 200 processed |
A 2xx answer tells Svix not to redeliver. Transient conditions answer 503, so Svix retries with the
same svix-id.
Tenant resolution
Neither the tenant nor our payment id comes from the payload. The webhook names only Stitch’s
payment link id, which must match payments.provider_payment_id (unique). A link that checkout
has recorded in billing_checkout_requests but not yet attached answers 503 so Svix retries. An
unknown link is answered as unknown_payment. The re-confirmation below then checks that the
link’s merchantReference names the same payment.
Processing
- Idempotency. Processing is keyed on
(provider, svix-id)throughprovider_events. The table stores only the SHA-256 of the verified body. - Re-confirmation. The link is re-fetched with
GetPaymentLinkbefore anything is applied. The status Stitch reports then is the one applied, so a payload alone never grants entitlement. Stitch Express sends no webhook for cancelled or expired links; reconciliation (AUTH4D-42) applies those. - Applying the outcome.
applyPaymentOutcome(@auth4/billing) records the event, then commits the payment, invoice, subscription and event status updates in one guarded D1 batch through the billing state machine. A completed payment activates the pending subscription for one billing interval. - Audit. Every transition writes
billing.payment.status_changed,billing.invoice.status_changedandbilling.subscription.status_changedwith outcomesuccessand no actor. Metadata holdsprovider,provider_event_id,payment_id,provider_state,fromandto. Audit event ids are derived from thesvix-id, so a redelivery cannot duplicate them.
Response
{ "received": true, "outcome": "processed", "request_id": "req_…" }
Errors use { "error": { "code": "<outcome>", "request_id": "req_…" } }. Responses never say which
signature check failed.
Logs
The handler writes one JSON line per request with event: "billing.webhook". Fields are outcome,
statusCode, durationMs, reason, providerEventId, tenantId, paymentId, paymentStatus
and providerState. Logs never include secrets, signatures, bodies or provider messages.
Source: docs/api-spec/auth4d-41.md