AUTH4D-157 Webhooks and audit-log export

A project can register HTTPS endpoints that receive the AUTH4D-158 Flows events (user.created, user.signed_in, guest.upgraded, session.revoked and member.changed) as signed POST requests. Deliveries go through a Cloudflare Queue, are retried with backoff and are recorded in a per-endpoint delivery log. The project’s audit log can also be downloaded as NDJSON.

Schemas are in packages/contracts (WebhookEndpointSchema, CreateWebhookEndpointRequestSchema, UpdateWebhookEndpointRequestSchema, WebhookDeliverySchema, WebhookTestEventSchema, WebhookDeliveryMessageSchema). The routes and the queue consumers are in apps/api/src/modules/webhooks. Storage is migration 0023_webhooks.sql.

Management routes

All routes need a management token. The console reaches them through its BFF (/api/management/…), which adds the token and checks the origin on writes. Every member can read; owners and admins can change endpoints. Viewer attempts are refused with 403 insufficient_role and audited as denied. Writes on a suspended project return 409 project_suspended.

Method Path Scope Result
GET /management/tenants/{tenantId}/webhooks tenant:read { endpoints, canManage }
POST /management/tenants/{tenantId}/webhooks tenant:write 201 { endpoint, signingSecret }
PATCH /management/tenants/{tenantId}/webhooks/{endpointId} tenant:write { endpoint }
DELETE /management/tenants/{tenantId}/webhooks/{endpointId} tenant:write { endpoint } (body {})
POST /management/tenants/{tenantId}/webhooks/{endpointId}/rotate-secret tenant:write { endpoint, signingSecret } (body {})
POST /management/tenants/{tenantId}/webhooks/{endpointId}/test tenant:write 202 { delivery } (body {})
GET /management/tenants/{tenantId}/webhooks/{endpointId}/deliveries tenant:read { deliveries }, newest first, ?limit=1…100
GET /management/tenants/{tenantId}/audit-events/export audit:read NDJSON download

Create body:

{
  "url": "https://game.example.com/hooks/auth4",
  "description": "Game backend",
  "eventTypes": ["user.created", "user.signed_in"]
}

PATCH takes any of url, description (null clears it), eventTypes and enabled. An endpoint looks like this; the signing secret is never part of it:

{
  "endpointId": "whe_0f6c…",
  "url": "https://game.example.com/hooks/auth4",
  "description": "Game backend",
  "eventTypes": ["user.created", "user.signed_in"],
  "enabled": true,
  "createdAt": "2026-10-06T20:00:00.000Z",
  "updatedAt": "2026-10-06T20:00:00.000Z"
}

Errors use the management error body. Codes specific to these routes:

Status Code When
400 invalid_webhook_url The URL breaks the rules below (details[0].issue says which).
400 validation_failed Unknown fields, no or repeated event types, bad description.
409 webhook_limit The project already has 10 endpoints.
409 webhook_disabled A test send to a disabled endpoint.
429 rate_limited More than 10 test sends per endpoint, or 10 exports per developer, per hour.
503 not_ready No encryption key ring or delivery queue on this deployment.

URL rules

Checked on create and update and again before every attempt (modules/webhooks/url.ts):

  • https:// only, on the default port, with no user name, password or fragment; at most 2,048 characters.
  • The host must be a public DNS name with at least two labels. IP addresses in any notation (including decimal, hex and IPv6), localhost and special-use or private suffixes (.local, .internal, .arpa, .test, .example, .invalid, .onion, .lan, .home, .corp and similar) are refused.
  • auth4.dev hosts are refused, so deliveries can’t loop back into the platform.
  • Redirects are never followed: a 3xx answer is a failed attempt. Workers subrequests only reach public internet addresses.

Signing

The signing secret is whsec_ plus 32 random bytes in base64. It is returned once by create and by rotate, and stored only as an AES-GCM envelope (AUTH_ENCRYPTION_KEY_RING) bound to the project and endpoint. Rotating takes effect at once, including for queued retries.

Requests use the Standard Webhooks format, which Svix libraries also verify:

Header Value
webhook-id The event’s event_id. The same on every retry, so deduplicate on it.
webhook-timestamp Unix seconds when this attempt was signed.
webhook-signature v1, + base64(HMAC-SHA256(secret bytes, {id}.{timestamp}.{raw body})).
Content-Type application/json
User-Agent auth4.dev-webhooks/1

The body is the Flows event exactly as described in auth4d-158.md. A test send has its own type and no user:

{
  "event_id": "evt_…",
  "version": 1,
  "type": "webhook.test",
  "tenant_id": "ten_…",
  "occurred_at": "2026-10-06T20:00:00.000Z",
  "data": { "endpoint_id": "whe_…" }
}

Delivery

Two queues per environment, listed in infra/cloudflare/environments.mjs:

  • auth4-flow-events-{env} (FLOW_EVENTS): the consumer stores one delivery row per enabled endpoint subscribed to the event type, keyed by project, endpoint and event_id. A redelivered event finds the existing row, so it is never delivered twice.
  • auth4-webhook-deliveries-{env} (WEBHOOK_DELIVERIES): each message names a delivery row. The consumer claims the row with a 60-second lease, signs the stored body and POSTs it with a 10-second timeout.

An attempt that is interrupted before it records a result (for example when the Worker is stopped) leaves the row delivering until its lease ends. A message that finds a live lease is retried just after the lease expires, then claims the row again and sends it, so the event is still delivered and never sent twice at the same time (AUTH4D-254).

Both dead-letter to auth4-webhooks-dlq-{env}. Any 2xx answer succeeds. Anything else (other status, redirect, timeout, connection error) is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes and 2 hours: six attempts in about 2 hours 45 minutes, then the delivery is failed. A disabled or deleted endpoint ends its pending deliveries without sending. The receiver’s response body is never read, stored or logged; the log keeps the status code and a fixed error code (http_status, redirect, timeout, network, invalid_url, endpoint_disabled, endpoint_deleted or secret_unavailable).

{
  "deliveryId": "whd_…",
  "endpointId": "whe_…",
  "eventId": "evt_…",
  "eventType": "user.created",
  "status": "pending",
  "attempts": 1,
  "lastResponseStatus": 500,
  "lastError": "http_status",
  "nextAttemptAt": "2026-10-06T20:00:30.000Z",
  "createdAt": "2026-10-06T20:00:00.000Z",
  "updatedAt": "2026-10-06T20:00:00.000Z"
}

status is pending, delivering, succeeded or failed. Delivery rows older than 30 days are removed as new events arrive for the project, and all of them are removed with the endpoint.

Audit-log export

GET /management/tenants/{tenantId}/audit-events/export streams the project’s retained audit events (30 days) as application/x-ndjson, oldest first, with Content-Disposition: attachment; filename="auth4-audit-{tenantId}-{yyyymmddThhmmssZ}.ndjson". It takes the audit list’s filters (since, until, event, outcome, actor_id, target_type, target_id), at most once each; anything else is 400 invalid_request. Each line has the list’s fields plus tenant_id:

{
  "tenant_id": "ten_…",
  "id": "evt_…",
  "request_id": "req_…",
  "event": "management.webhook.created",
  "outcome": "success",
  "actor_id": "usr_…",
  "target_type": "webhook_endpoint",
  "target_id": "whe_…",
  "occurred_at": "2026-10-06T20:00:00.000Z",
  "metadata": {
    "actor_role": "owner",
    "host": "game.example.com",
    "event_types": "user.created user.signed_in"
  }
}

An export is capped at 100,000 events and is itself recorded as management.audit.exported, with the date range and the names (not values) of the other filters.

Audit events

management.webhook.created, .updated, .deleted, .secret_rotated and .test_sent (target webhook_endpoint), and management.audit.exported (target tenant). Metadata holds the actor’s role, the endpoint host, event types and changed field names; never a secret or a full URL.

Source: docs/api-spec/auth4d-157.md