Webhooks API
Register an HTTPS endpoint, receive signed deliveries when a subscribed event happens, and read or redeliver the delivery history.
All routes live under /api/v1/webhooks and accept either credential type: if an X-API-Key header is present the request is authenticated as an API key, otherwise as a vendor session (see API Overview). Every route is scoped to the account that owns the webhook configuration.
The account check that follows is not the same for the two credentials. A vendor session must hold a membership on that account with at least the role the route names: viewer to read a configuration or its delivery history, member to create, update or delete one and to call test or redeliver. An API key carries no role at all — it is checked against the one account it was minted for and then against its scopes. Reads require webhooks:read; every mutation requires webhooks:write, including test and redeliver, because both cause an outbound send. A key holding only webhooks:read is refused with 403 on those two.
One event is emitted today
The delivery engine is real: an in-process dispatcher drains a webhook_deliveries outbox, signs every attempt, retries with backoff, and dead-letters what it cannot deliver. What feeds it is still narrow. Exactly one event type exists — connection.status.changed — and it fires from one code path, PATCH /api/v1/connections/:id. A connection created straight into error, or moved to error by a failed re-provision from the Admin Portal, does not emit yet. Nothing fires for SCIM errors, health checks, or account lifecycle. Do not design a receiver around events that are not in the table below.
Events
| Event | Emitted when | Not yet covered |
|---|---|---|
connection.status.changed | PATCH /api/v1/connections/:id leaves the connection's status different from what it was before the request — including the two moves the caller did not ask for: a failed re-provision forcing error, and a requested change the server declines. A rename emits nothing. | Create-time status (POST /connections/saml and /oidc landing in active or error) and status changes made by the Admin Portal's own configuration update. |
A webhook's events array names the events it wants. A configuration receives a delivery when it is enabled and its events array contains the event name — or the wildcard *, which means every event the server emits, including ones added after the configuration was written. The wildcard is the whole of the matching language: there are no prefixes and no globs, so connection.* matches nothing. Subscribing to a name that is not in the table above is accepted but never fires — the server writes no delivery row (and logs at debug level only when nothing on the account matched), so the failure mode is silence, not an error.
Payload
The request body is the event object itself, serialized once as JSON — there is no envelope:
{
"connectionId": "clx9f0a2b0001abcd",
"accountId": "clx8e1b3c0002efgh",
"previousStatus": "active",
"status": "error",
"changedAt": "2026-09-02T19:18:21.000Z"
}previousStatus and status are connection statuses (active, inactive, error, pending); changedAt is an ISO 8601 timestamp taken when the event was emitted. The body is a snapshot of what was true when the event happened — a retry re-sends the same bytes, not a fresh read of the connection. The test endpoint below sends a different shape ({ "event": "test", "timestamp": …, "data": { "message": … } }), so branch on the X-SSO-Plane-Event header rather than on body fields.
Request headers and signature
Every attempt — from the dispatcher and from the test endpoint alike — is an HTTP POST carrying:
| Header | Value |
|---|---|
| Content-Type | application/json |
| User-Agent | SSO-Plane-Webhook/1.0 |
| X-SSO-Plane-Event | The event name — connection.status.changed, or test |
| X-SSO-Plane-Delivery | The delivery id. Stable across every attempt of one delivery, including redeliveries — this is your idempotency key |
| X-SSO-Plane-Timestamp | Unix seconds at the moment of this attempt; differs on every retry |
| X-SSO-Plane-Signature | v1=<hex> — HMAC-SHA256 over the canonical string below, keyed with the webhook secret |
The canonical string is five fields joined by a single newline: the literal v1, the timestamp header, the delivery header, the event header, and the raw request body byte for byte. Compute HMAC-SHA256 over it with the secret exactly as issued (whsec_ prefix included), hex-encode, and compare in constant time against the v1= element of the signature header. Reject a timestamp further from your clock than your tolerance — 300 seconds is the recommended default. Verify the bytes you received, not a re-serialization of the parsed JSON: a round trip through your JSON library can reorder keys or change number formatting and produce a different digest that looks like an attack.
const { createHmac, timingSafeEqual } = require('crypto');
function verifyRealmSSOWebhook({ secret, rawBody, headers, toleranceSeconds = 300 }) {
const timestamp = Number(headers['x-sso-plane-timestamp']);
if (!Number.isInteger(timestamp)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
const presented = (headers['x-sso-plane-signature'] ?? '')
.split(',')
.map((part) => part.trim())
.find((part) => part.startsWith('v1='))
?.slice(3);
if (!presented) return false;
const canonical = [
'v1',
String(timestamp),
headers['x-sso-plane-delivery'],
headers['x-sso-plane-event'],
rawBody,
].join('\n');
const expected = createHmac('sha256', secret).update(canonical, 'utf8').digest('hex');
const a = Buffer.from(presented, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}When verification fails, return 400 and process nothing. Do not return 2xx: any non-2xx is a failed attempt and is retried, so a rejected forgery costs you nothing, while a 2xx on a bad signature tells the sender the forgery was accepted. The server repo carries the same recipe in Node and Python in docs/webhook-signature-verification.md, transcribed from the function its own test suite runs against real deliveries — if this page and that function ever disagree, the function is right.
The signing secret
The secret is generated server-side when the webhook is created — 32 bytes of CSPRNG entropy as hex behind a whsec_ prefix — and returned as data.secret in the 201 response. A caller-supplied secret in the create body is discarded. It stays readable from GET /api/v1/webhooks/:id for as long as the webhook exists: RealmSSO must hold the plaintext to compute an HMAC on every delivery, so unlike an API key it is not hashed at rest and is not "shown once". Anyone who can read the configuration can read the secret. There is no rotation endpoint; to replace a secret, delete the webhook and create it again.
How a delivery is made
When an event matches a configuration, one row per matching configuration is written to the webhook_deliveries outbox in state pending. An in-process poll loop in the server claims due rows every WEBHOOK_DISPATCH_INTERVAL_MS (default 5 seconds) and makes one attempt per claimed row per pass, with at most one in-flight attempt per webhook configuration so one slow receiver does not hold up another's deliveries. The row, not the process, is the record: a server restart does not lose a queued delivery. The variables that tune the loop are on the Configuration page.
| Property | What holds |
|---|---|
| Latency | Not immediate. A row becomes due the moment it is written and is picked up by the next pass, so expect the poll interval plus your own response time. Two events can arrive in either order — there is no ordering promise across events or configurations. |
| Retry | A non-2xx response, a network error, a redirect, or a timed-out attempt is a failed attempt. The next one is due after WEBHOOK_RETRY_BACKOFF_MS × 2^(attempt−1) — 5 s, then 10 s, on the defaults. After WEBHOOK_RETRY_MAX_ATTEMPTS (default 3) the row moves to dead_letter and no further attempt is made. |
| Per-attempt deadline | An attempt is aborted if your endpoint has not returned response headers within WEBHOOK_DELIVERY_TIMEOUT_MS (default 10 s), and recorded as failed. This bounds one attempt, not the whole delivery. Acknowledge fast and do your work afterwards. |
| Duplicates | Delivery is at-least-once. A crash between your 2xx and the server's bookkeeping write, or a claim lease that expires under a slow attempt, re-sends the same delivery. Key on X-SSO-Plane-Delivery; the timestamp and signature differ per attempt by design. |
| Redirects | Not followed. A 3xx is recorded as a failed attempt with a note saying so. Register the final URL. |
| Your response body | Never read and never stored. responseBody on a delivery row holds only text RealmSSO wrote about the attempt — a timeout, a refused redirect, a refused URL — so a diagnostic in your response will not surface in the delivery history. |
| Destination checks | The URL is validated against the SSRF guard when the webhook is created or updated and again immediately before every attempt. A URL the guard refuses at attempt time is not dialled; the refusal is recorded on the row with statusCode: null. |
| Disabling a webhook | Takes effect at once. A disabled configuration receives no new rows, its rows already queued are dead-lettered without being sent (the row says so in responseBody), and redelivery is refused while it stays disabled. |
| Exhaustion | Recorded, not announced. A dead_letter row is readable in the delivery history below; nothing emails or pages anyone. |
Create a webhook
/api/v1/webhooks| Field | Type | Notes |
|---|---|---|
| accountId | string | Required |
| url | string (URL) | Required — checked against the SSRF guard before the row is written |
| events | string[] | Required, at least one entry — event names from the table above, or ["*"] |
Returns 201 with the configuration, including the generated secret. enabled defaults to true. The dashboard creates webhooks with events: ["*"].
List, get, update, delete
/api/v1/webhooks?accountId=…/api/v1/webhooks/:id/api/v1/webhooks/:id/api/v1/webhooks/:idThe list requires accountId (400 without it) and is one of the unpaginated lists — a bare { data: [...] }, newest first, no meta. PATCH accepts any of url, events, and enabled; a new url is SSRF-checked. DELETE returns 204 and cascades the configuration's delivery history away with it.
Delivery history
/api/v1/webhooks/:id/deliveriesPaginated (page/limit, capped at 100), newest first. Each row is the outbox record, returned verbatim:
{
"data": [
{
"id": "clx9f0a2b0001abcd",
"webhookId": "clx7d2c4e0003ijkl",
"eventType": "connection.status.changed",
"payload": { "connectionId": "…", "accountId": "…", "previousStatus": "active", "status": "error", "changedAt": "…" },
"state": "dead_letter",
"attempt": 3,
"statusCode": 503,
"responseBody": null,
"success": false,
"nextRetryAt": null,
"completedAt": "2026-09-02T19:19:01.000Z",
"createdAt": "2026-09-02T19:18:21.000Z"
}
],
"meta": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
}| state | Meaning |
|---|---|
pending | Another attempt is owed. nextRetryAt is when it is due — or, while an attempt is running, when its claim lease expires. Null for a row the test endpoint is sending synchronously. |
delivered | Your endpoint answered 2xx. Terminal. |
dead_letter | Attempts exhausted, or the configuration was disabled before the row could be sent. Terminal until redelivered. |
attempt counts attempts made so far. statusCode is the last response status — 0 for a network error or a timed-out attempt, null when nothing was dialled. success and completedAt are kept for compatibility; state is what decides.
Redeliver
/api/v1/webhooks/:id/deliveries/:deliveryId/redeliverResets a terminal row (delivered or dead_letter) to pending with attempt: 0, due now. The next dispatch pass sends it through the ordinary path — same delivery id, so your idempotency key still holds; fresh timestamp and signature. Returns 202 with the reset row: the send has not happened yet, so read the outcome from the delivery history. Returns 404 when the configuration is disabled, when the delivery belongs to another configuration, or when it is still pending — a pending row is either due or mid-attempt, and resetting it would double-send. Redelivery reaches back exactly as far as retention: terminal rows older than WEBHOOK_DELIVERY_RETENTION_DAYS (default 90) are purged, and a purged row cannot be redelivered.
Send a test delivery
/api/v1/webhooks/:id/testSynchronous: sends a test event with the same signing, per-attempt deadline, and retry schedule, waits for the delivery to finish (up to 45 s on the defaults), and returns the outcome. The row it writes appears in the delivery history like any other.
{
"data": { "deliveryId": "clx9f0a2b0001abcd", "statusCode": 200, "success": true, "attempts": 1 }
}Not implemented — do not design around these
- Any event other than
connection.status.changed, and that event from any path other thanPATCH /api/v1/connections/:id. - Secret rotation. Delete and recreate the webhook.
- Ordering, a delivery-wide time budget, or per-configuration rate limiting finer than the attempt cap and backoff.
- Alerting on dead-lettered deliveries — read the delivery history.