Skip to content
← Documentation

Webhooks API

Register an endpoint URL, receive signed deliveries when a subscribed event happens, and read or redeliver the delivery history.

All routes live under /api/v1/webhooks. A request with an X-API-Key header is an API key request. Any other request must carry a dashboard session. See the API Overview. Every route is limited to the account that owns the webhook.

The account check differs for the two credentials. A dashboard session must hold a role on the account that is at least the role the route names. The role is viewer to read a webhook or its delivery history. The role is member to create, update or delete a webhook, and to call test or redeliver.

An API key has no role. RealmSSO checks the key against the one account it was made for, and then against its scopes. Reads need webhooks:read. Each write needs webhooks:write, including test and redeliver, because both send a request out. A key that holds only webhooks:read gets 403 on those two.

One event is emitted today

The delivery engine works. A dispatcher takes deliveries from a durable queue and signs every attempt. It retries with backoff, and it marks a delivery dead-letter when it cannot send it. Few things feed it today.

Exactly one event type exists: connection.status.changed. It fires from five paths. Three paths are the connections PATCH and the two connection create paths (SAML and OIDC). Two paths are the Admin Portal's own SAML and OIDC update calls. Nothing fires for SCIM errors, health checks or account lifecycle. Do not build a receiver for events that are not in the table below.

Events

EventEmitted whenNot yet covered
connection.status.changedAny of five paths leaves the connection's status different from what it was before the request. The paths are listed below.
  • PATCH /api/v1/connections/:id. This includes two moves that the caller did not ask for: a failed re-provision that forces error, and a requested change that the server declines.
  • POST /api/v1/connections/saml and /oidc at create time. This is when provisioning leaves the new connection active or error. The Admin Portal's own create uses the same code.
  • The Admin Portal's PUT updates for SAML and OIDC. A re-provision clears a stale error or forces a new one.
Every path compares the row before and after. It emits nothing when they match. A rename emits nothing. A create that leaves the row pending emits nothing.
Nothing: every path that moves status emits. Other event types are not covered. See the not-implemented list at the end of this page.

A webhook's events array names the events it wants. A webhook receives a delivery when it is enabled and events holds the event name or the wildcard *. The wildcard means every event the server emits, including events added after you wrote the configuration. The wildcard is the whole matching language. There are no prefixes and no globs, so connection.* matches nothing.

A misspelled event name fails silently

The API accepts any string in events. It does not check the names against the table above. RealmSSO stores a misspelled name, and the name never fires. The server writes no delivery row and returns no error. Check each name against the table.

Payload

The request body is the event object itself, serialized as JSON. There is no envelope. Do not rely on the key order.

{
  "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. RealmSSO sets it when it emits the event. The body is a snapshot of what was true when the event happened. A retry sends the same bytes. It does not read the connection again.

The test route below sends a different shape: { "event": "test", "timestamp": …, "data": { "message": … } }. Branch on the X-SSO-Plane-Event header, not on body fields.

Request headers and signature

Every attempt is an HTTP POST with these headers. This holds for the dispatcher and for the test route.

HeaderValue
Content-Typeapplication/json
User-AgentSSO-Plane-Webhook/1.0
X-SSO-Plane-EventThe event name: connection.status.changed, or test
X-SSO-Plane-DeliveryThe delivery id. It is the same on every attempt of one delivery, including redeliveries. This is your idempotency key.
X-SSO-Plane-TimestampUnix seconds when this attempt was made. It differs on every retry.
X-SSO-Plane-Signaturev1=<hex>: HMAC-SHA256 over the canonical string below, keyed with the webhook secret

The canonical string has five fields joined by single newlines. These are the literal v1, the timestamp header, the delivery header and the event header. The last field is the raw request body, byte for byte. Compute HMAC-SHA256 over it with the secret exactly as issued, whsec_ prefix included. Hex-encode the result. Compare it in constant time with the v1= element of the signature header.

Reject a timestamp that is 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 a JSON library can reorder keys or change number formats. The digest then differs, and it 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. RealmSSO treats any non-2xx response as a failed attempt and retries it, so a rejected forgery costs you nothing. A 2xx on a bad signature tells the sender that the forgery worked. To check your verifier, use the test delivery below. RealmSSO signs it the same way as a real delivery.

The signing secret

RealmSSO generates the secret when you create the webhook. It is 32 random bytes as hex, behind a whsec_ prefix. The 201 response returns it as data.secret. RealmSSO discards a secret that you send in the create body. There is no rotation endpoint. To replace a secret, delete the webhook and create it again.

The signing secret is stored readable

RealmSSO needs the secret itself to sign each delivery. So RealmSSO stores it readable. It is not hashed and not encrypted.

Keep it in a secrets store or an environment variable. Never put it in code, logs or a ticket. Your verifier needs it exactly as issued, so do not hash it.

How a delivery is made

When an event matches a webhook, RealmSSO writes one delivery row for each matching webhook. The row starts in state pending. The dispatcher claims due rows in a pass every 5 seconds. It makes one attempt for each claimed row in a pass.

In one pass, the dispatcher claims at most one row for each webhook. A slow receiver holds up only its own attempt in that pass, for up to 10 seconds. The next pass starts only after the current pass ends. The row is the record, not the process, so a server restart does not lose a queued delivery. Measured on 2026-09-14: the dispatcher was running on production, with a pass every 5 seconds.

PropertyWhat holds
LatencyNot immediate. A row is due when it is written. A pass takes at most one row for each webhook, so more rows for one webhook wait for later passes. Expect the 5-second pass interval plus your own response time. There is no ordering promise across events or webhooks. Two events can arrive in either order.
RetryA non-2xx response, a network error, a redirect or a timed-out attempt is a failed attempt. The next attempt is due 5 seconds after the first failure and 10 seconds after the second. After 3 failed attempts the row moves to dead_letter. RealmSSO makes no more attempts.
Per-attempt deadlineRealmSSO aborts an attempt if your endpoint has not returned response headers within 10 seconds. It records that attempt as failed. The limit applies to one attempt, not to the whole delivery. Acknowledge fast, and do your work afterwards.
DuplicatesDelivery is at-least-once. A crash between your 2xx and RealmSSO's record of it can send the same delivery again. A slow attempt that outlasts its claim on the row can do the same. Key on X-SSO-Plane-Delivery. The timestamp and signature differ on each attempt by design.
RedirectsNot followed. RealmSSO records a 3xx as a failed attempt, with a note that says so. Register the final URL.
Your response bodyRealmSSO never reads it and never stores it. responseBody on a delivery row holds only text that RealmSSO wrote about the attempt. Examples are a timeout, a refused redirect and a refused URL. A diagnostic in your response does not appear in the delivery history.
Destination checksRealmSSO checks that the URL points to a public address when you create or update the webhook. It checks again immediately before every attempt. It does not dial a URL that fails the check. It records the refusal on the row with statusCode: null. The check resolves the name, and the connection then resolves it again. A DNS answer that changes between the two lookups is not caught.
Disabling a webhookA disabled webhook receives no new rows. When a pass reaches one of its queued rows, RealmSSO dead-letters the row without sending it. The row says so in responseBody. An attempt that has already started is not stopped. Redelivery is refused while the webhook stays disabled.
RetentionRealmSSO keeps delivery rows for 90 days. A delivery can be redelivered for as long as its row exists.
ExhaustionRecorded, not announced. A dead_letter row is readable in the delivery history below. Nothing emails or pages anyone.

Create a webhook

POST/api/v1/webhooks
FieldTypeNotes
accountIdstringRequired
urlstring (URL)Required. RealmSSO checks that it points to a public address before it writes the row.
eventsstring[]Required, at least one entry. Use event names from the table above, or ["*"]. The API does not check the names.

Returns 201 with the webhook, including the generated secret. enabled defaults to true. The dashboard creates webhooks with events: ["*"].

List, get, update, delete

GET/api/v1/webhooks?accountId=…
GET/api/v1/webhooks/:id
PATCH/api/v1/webhooks/:id
DELETE/api/v1/webhooks/:id

The list requires accountId, and answers 400 without it. It is one of the unpaginated lists: a bare { data: [...] }, newest first, with no meta. PATCH accepts any of url, events and enabled. RealmSSO checks a new url the same way as at create time. DELETE returns 204 and removes the webhook's delivery history with it.

Delivery history

GET/api/v1/webhooks/:id/deliveries

The list is paginated (page and limit, capped at 100), newest first. Each row is the queue record, returned as stored. The dashboard shows the same history.

{
  "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 }
}
stateMeaning
pendingAnother attempt is owed. nextRetryAt is when it is due. While an attempt is running, it is when the claim on the row expires. It is null for a row that the test route is sending now.
deliveredYour endpoint answered 2xx. Final.
dead_letterThe attempts ran out, or the webhook was disabled before the row could be sent. Final until you redeliver it.

attempt counts the attempts made so far. statusCode is the last response status. It is 0 for a network error or a timed-out attempt, and null when RealmSSO dialled nothing. success and completedAt are kept for compatibility. state decides.

Redeliver

POST/api/v1/webhooks/:id/deliveries/:deliveryId/redeliver

Resets a final row (delivered or dead_letter) to pending with attempt: 0, due now. The next pass sends it the normal way. The delivery id is the same, so your idempotency key still holds. The timestamp and signature are new. The dashboard can also redeliver a delivery.

Returns 202 with the reset row. The send has not happened yet, so read the result from the delivery history. Returns 404 in three cases. The webhook is disabled, the delivery belongs to another webhook, or the row is still pending. A pending row is due or in an attempt, and a reset would send it twice. Redelivery reaches back as far as retention. RealmSSO purges final rows after 90 days, and a purged row cannot be redelivered.

Send a test delivery

POST/api/v1/webhooks/:id/test

Synchronous. It sends a test event. The signing, the per-attempt deadline and the retry schedule are the same as for a real delivery. It waits for the delivery to finish, which can take about 45 seconds, and returns the result. The row it writes appears in the delivery history like any other.

{
  "data": { "deliveryId": "clx9f0a2b0001abcd", "statusCode": 200, "success": true, "attempts": 1 }
}

The test route is rate limited

The route allows 5 calls in 60 seconds for each webhook, counted for each client address. The next call in that window gets 429. A refused call sends nothing and writes no delivery row. The dashboard's Send test button uses this route, so the same limit applies.

Not implemented — do not design around these

  • Any event other than connection.status.changed. That event fires from all five paths that move a connection's status, listed in the table above.
  • Secret rotation. Delete and recreate the webhook.
  • A check on event names. RealmSSO stores a misspelled name, and the name never fires.
  • Ordering, and a time limit for a whole delivery. The limits that apply are the attempt count, the backoff, and one row for each webhook in each pass.
  • Alerts on dead-lettered deliveries. Read the delivery history.