AUTH4D-37: Stitch webhook verification and event normalization
@auth4/provider-stitch verifies Stitch webhooks and turns them into the contract
BillingWebhookEvent. It exposes no HTTP routes. The billing webhook route reads the body with
request.arrayBuffer() and passes those exact bytes in. It never passes re-serialized JSON.
Signature verification
verifyWebhook(rawBody, headers, secret, now, options?) follows the Svix scheme Stitch uses:
- Headers:
svix-id,svix-timestamp(integer seconds) andsvix-signature. Header lookup is case-insensitive. - Secret: the
STITCH_WEBHOOK_SECRETWorker secret, in the formwhsec_<base64>. The key is the base64-decoded part. - Signed content:
${svix-id}.${svix-timestamp}.followed by the raw body bytes, signed with WebCrypto HMAC-SHA256. svix-signaturemay hold several space-separatedv1,<base64>entries, which happens during secret rotation. The request is accepted if any entry matches. Entries with other version tags are ignored. Comparison is constant-time over the 32-byte MAC.- The timestamp must be within 300 seconds of
now, in either direction. SettoleranceSecondsto change this. Bodies over 256 KiB are rejected (maxBodyBytes).
The function returns { ok: true, messageId, timestamp, payload } or { ok: false, reason }. The
reason is one of missing_headers, malformed_headers, invalid_secret,
timestamp_out_of_tolerance, invalid_signature, body_too_large or malformed_body. Reasons
never include header values, the body or the secret. A suggested mapping is 401 for signature,
timestamp and header failures, 413 for an oversized body and 500 for invalid_secret, which is an
operator misconfiguration.
Normalization
Per the Stitch Express docs, the only webhook event is payment.paid. Its body is flat:
{
"amount": 5000,
"id": "payment-uuid",
"status": "PAID",
"type": "LINK",
"linkId": "link-uuid",
"consentId": null,
"subscriptionId": null,
"terminalSessionId": null
}
Cancelled and expired payment links send no webhook. Reconciliation (AUTH4D-42) observes those by polling the link status.
normalizeEvent(payload, { providerEventId, receivedAt, resolvePayment, eventId? }) does the
following:
- Consumes only
type: "LINK"events, because auth4 creates only payment links.linkIdis theproviderPaymentIdthat checkout stored. - Maps
PAIDtocompleted. It reusesSTITCH_PAYMENT_STATE_MAPfrompayments.ts. - Resolves our payment and its tenant only through
resolvePayment({ providerPaymentId }), which is a storage lookup on the globally unique link id. The body carries no merchant reference, and neither the payment id nor the tenant is read from the payload. - Takes
amountas integer ZAR cents. It rejects zero, negative and non-integer amounts. - Sets
occurredAttoreceivedAt, because the body has no event time. Setsproviderto"stitch"andproviderEventIdto the verifiedsvix-id, sobillingWebhookIdempotencyKeyisstitch:<svix-id>. - Validates the result against
BillingWebhookEventSchema.
The rejection reasons are as follows:
unsupported_event: a recognised Stitch body that auth4 does not consume, such as a card consent or subscription payment.unknown_state: a status other thanPAID.unknown_payment: the resolver found no payment for the link.malformed_event: anything else, including aLINKevent without a link id.
The route should acknowledge unsupported_event with 2xx and record it, so Svix stops retrying.
The successful result also returns providerPaymentId and providerState. The billing service
must dedupe on the idempotency key, confirm the status with Stitch before granting entitlement,
and apply state changes only through the billing state machine.
Source: docs/api-spec/auth4d-37.md