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) and svix-signature. Header lookup is case-insensitive.
  • Secret: the STITCH_WEBHOOK_SECRET Worker secret, in the form whsec_<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-signature may hold several space-separated v1,<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. Set toleranceSeconds to 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. linkId is the providerPaymentId that checkout stored.
  • Maps PAID to completed. It reuses STITCH_PAYMENT_STATE_MAP from payments.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 amount as integer ZAR cents. It rejects zero, negative and non-integer amounts.
  • Sets occurredAt to receivedAt, because the body has no event time. Sets provider to "stitch" and providerEventId to the verified svix-id, so billingWebhookIdempotencyKey is stitch:<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 than PAID.
  • unknown_payment: the resolver found no payment for the link.
  • malformed_event: anything else, including a LINK event 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