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) through provider_events. The table stores only the SHA-256 of the verified body.
  • Re-confirmation. The link is re-fetched with GetPaymentLink before 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_changed and billing.subscription.status_changed with outcome success and no actor. Metadata holds provider, provider_event_id, payment_id, provider_state, from and to. Audit event ids are derived from the svix-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