Skip to content

Webhooks

Receive identity events on your own server. This guide covers endpoint registration, signature verification, retries, and the delivery behavior you should design around.

Registering an endpoint

An endpoint is a URL, a set of subscribed event types, and a signing secret we mint for you. Manage them in the dashboard under DevelopersWebhooks, or through the management API:

MethodPathScope
GET/api/identity/{tenantId}/webhooksidentity:webhooks:read
POST/api/identity/{tenantId}/webhooksidentity:webhooks:write
GET/api/identity/{tenantId}/webhooks/event-typesidentity:webhooks:read
GET/api/identity/{tenantId}/webhooks/{id}identity:webhooks:read
PATCH/api/identity/{tenantId}/webhooks/{id}identity:webhooks:write
DELETE/api/identity/{tenantId}/webhooks/{id}identity:webhooks:write
GET/api/identity/{tenantId}/webhooks/{id}/secretidentity:webhooks:secret
POST/api/identity/{tenantId}/webhooks/{id}/secret/rotateidentity:webhooks:secret
POST/api/identity/{tenantId}/webhooks/{id}/testidentity:webhooks:write
GET/api/identity/{tenantId}/webhooks/{id}/deliveriesidentity:webhooks:read
GET/api/identity/{tenantId}/webhooks/{id}/deliveries/{deliveryId}identity:webhooks:read
POST/api/identity/{tenantId}/webhooks/{id}/deliveries/{deliveryId}/replayidentity:webhooks:write
POST/api/identity/{tenantId}/webhooks/{id}/deliveries/{deliveryId}/redriveidentity:webhooks:write

{tenantId} is the tenant whose endpoints you are managing — the same id that appears in your dashboard URL. Every path above is authorized against that tenant rather than against whichever host you called: your token needs the scope in the table and administrative authority over the tenant you named. A token that holds nothing over it is answered exactly as one naming a tenant that does not exist, so the URL never tells you whether a tenant is real.

These addresses changed on 2026-08-06, and nothing else did. The paths above previously carried no tenant (/api/identity/webhooks…) and resolved one from the request's host; the tenant now sits in the path. No capability, scope, or guarantee changed — only the address. The same scopes gate the same operations, the event catalog is the same catalog, and everything below this section — the destination rules, the once-only secret, signing, the retry schedule, dead-lettering, and the delivery log — is untouched. Your existing endpoints, signing secrets, and delivery history were not modified by the move. The old paths no longer resolve; they were reachable only on Udibo's own host and only with a Udibo staff seat, so no customer request is affected.

Administrator credentials required. These management calls use the credential associated with an authorized Udibo dashboard session. A machine credential issued to an application in your own tenant is not accepted on /api/identity/…. Use the dashboard or an approved administrator workflow; keep administrator credentials out of your application integration. Receiving deliveries is unaffected: that is your own server, authenticating nothing of ours.

Registering an endpoint returns the signing secret in the response, and that is the one time it is handed to you unprompted:

TypeScript
interface CreatedEndpoint {
  id: string;
  url: string;
  eventTypes: string[];
  secret: string;
}

async function registerEndpoint(
  tenantId: string,
  token: string,
  url: string,
  eventTypes: string[],
): Promise<CreatedEndpoint> {
  const response = await fetch(
    `https://www.udibo.com/api/identity/${tenantId}/webhooks`,
    {
      method: "POST",
      headers: {
        authorization: `Bearer ${token}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({ url, eventTypes }),
    },
  );
  if (!response.ok) {
    throw new Error(`could not register endpoint: ${response.status}`);
  }
  return await response.json() as CreatedEndpoint;
}

Destinations must be public. We require https and refuse to deliver to loopback, private, link-local, CGNAT, and cloud-metadata addresses. The check runs when you register the URL and again before each delivery, where we also resolve the hostname and refuse it if any answer is a private address.

That second check pins the connection: we resolve your hostname once, refuse the delivery if any answer is a private address, and then open the socket to one of the addresses we just approved rather than looking the name up again. A hostname whose DNS answer flips between the check and the connection — a deliberate rebinding attack with a very low TTL — has nothing to flip to, because there is no second lookup. TLS still completes against your hostname, so SNI and certificate verification are unchanged.

A hostname we cannot resolve at all is refused (and retried) rather than attempted. Redirects are never followed, so a 3xx toward an internal address is recorded as a failed attempt and nothing more. Each attempt opens its own connection and closes it afterwards; we never reuse a socket across the check that authorized it.

Outside production, plain http and local addresses are permitted so you can point a development endpoint at your own machine; everything else about delivery — signing, retries, dead-lettering — is identical. The environments that relax this are an explicit allowlist, so a misconfigured deployment fails closed into production behavior rather than silently opening up.

The signing secret is shown once, at creation and after each rotation. It is sealed (AES-256-GCM) at rest rather than hashed, because both we and you need the value, so you can also reveal it later from the endpoint page or GET …/secret. Every reveal and rotation is written to your audit log. Secrets never appear in list or read responses — those carry hasSigningSecret instead.

Subscriptions

Subscribe with exact event names (auth.sign_in.succeeded), a prefix wildcard, or * for everything. A wildcard entry must end in .* and matches everything beneath that prefix at any depth — auth.* and the narrower auth.sign_in.* are both valid. A wildcard anywhere else in the string is not: auth.*.succeeded is refused, not ignored. So is a prefix that matches nothing in the catalog, which is what catches a typo at registration rather than six months later when you notice the silence.

An endpoint's eventTypes is replaced wholesale, never merged. It is an array, so a PATCH that sends it sends the complete new subscription list — patching in "one more event" with a single-element array unsubscribes everything else. Read the endpoint, append, and send the whole list back. Omit the field entirely and the existing subscriptions are left alone. An endpoint must keep at least one.

The catalog is the identity event taxonomy — user and session lifecycle, auth security events, admin mutations, and the usage.* family — and is served live from GET /api/identity/{tenantId}/webhooks/event-types rather than pinned in this document, because it grows.

usage.* is the automation hook for your usage limits. Every step of the alert ladder on the billing page — usage.halfway at half of a monthly limit or allowance (the day window has no halfway step), usage.alert_threshold at your alert threshold (80% by default, and sent even while no hard limit applies), and usage.limit_reached when a hard limit stops email or the retained-user allowance is passed — is recorded at most once per meter per window per line (a limit raised and reached again is recorded again), at the same moment the alert email goes out, and whether or not anyone is emailed. The delivery carries the event's type, the tenantId, and a data.target whose type names the meter and window — emails:month, emails:day, own_sender_emails:month, own_sender_emails:day or users:month — with the tenant as its id; it carries no metadata. The count (used), the bound it was measured against and the alert line live on the audit event's metadata, which the audit log API returns for the delivery's id. Subscribe to usage.limit_reached to page someone, or to usage.* to feed a dashboard. The catalog is the same for every tenant; it is addressed by tenant so that one base URL serves the whole webhook surface, and it answers to anyone who administers the tenant.

Payload

Deliveries are POSTed as application/json:

JSON
{
  "id": "0197f1f0-…",
  "type": "auth.sign_in.succeeded",
  "occurredAt": "2026-07-26T12:00:00.000Z",
  "tenantId": "0197f1f0-…",
  "data": {
    "actor": { "type": "user", "id": "0197f1f0-…" },
    "target": { "type": "user", "id": "0197f1f0-…" }
  }
}

Deduplicate on the udibo-webhook-id header, not on the payload's id. The header identifies one delivery: every retry of it carries the same value, so dropping a repeat is exactly right. The payload's id identifies the originating event, and is deliberately reused when you replay an event — so deduplicating on it would make every replay a silent no-op. Use id to correlate a delivery back to the event (and to your audit log), and the header to decide whether you have already processed this delivery.

id, and both the actor and target, are nullable. The test send from Send test event or POST …/test is delivered through the ordinary pipeline with the ordinary signature, but it has no originating event and no parties, so it arrives as "type": "webhook.test" with "id": null and an actor and target of { "type": null, "id": null }. Type id as nullable or your consumer breaks on the first test you send — which is exactly the delivery you were using to prove it works. webhook.test is not in the catalog and cannot be subscribed to; it reaches an endpoint only when you ask for it by id.

The payload deliberately carries identifiers and nothing else: our internal capture records detail (failure reasons, IP, user agent) that would make a webhook an account-enumeration oracle, so it is never forwarded. Read the rest through the audit log API. Adding fields here is additive and will be announced.

Signature verification

Every delivery carries three headers:

HeaderValue
udibo-webhook-idDelivery id — same across retries, new per replay; dedupe on it
udibo-webhook-timestampUnix seconds at signing time
udibo-webhook-signatureSpace-separated v1,<base64url> entries

The signature is HMAC-SHA256, keyed by the 32 raw bytes your secret encodes (everything after the whsec_ prefix, base64url-decoded), over the exact string:

{udibo-webhook-id}.{udibo-webhook-timestamp}.{raw request body}

Sign the raw body bytes, before any JSON parsing or re-serialization. The header is a list by construction, so accept the delivery if any entry matches rather than assuming there is exactly one — that is what keeps your verifier working if a delivery ever carries two.

Rotating a signing secret overlaps the old and new secrets for 24 hours. When you rotate, the endpoint keeps the secret it replaced. For the next 24 hours every delivery carries two entries in udibo-webhook-signature, one per secret, so your consumer verifies whether or not it has picked up the new value yet. After 24 hours the previous secret stops signing and deliveries carry the new entry alone. Deploy the new secret to your consumer any time inside that window. Each attempt is signed when it is sent, so a retry queued before the rotation goes out signed with whichever secrets are live at that moment.

Rotating again inside the window keeps only the secret that rotation replaced. The oldest secret stops signing at once, and the 24 hours start again from the second rotation.

If you are rotating because a secret leaked, what protects you is your consumer no longer accepting it. Remove the old secret from your verifier as soon as the new one is deployed. Signing with it during the window gives nobody anything new — forging a delivery takes the secret itself, not our signatures. There is no separate action to end the window early; rotating a second time takes the leaked secret out of signing immediately, because only the secret that rotation replaces is kept.

The previous secret is sealed at rest exactly like the current one and is never returned: rotation answers with the new secret, and reveal answers with the current secret only.

Reject deliveries whose timestamp is more than 5 minutes from your clock, and compare signatures in constant time. Binding the id and timestamp into the signed string is what stops a captured request from being replayed later or against a different endpoint.

TypeScript
import { Buffer } from "node:buffer";
import { timingSafeEqual } from "node:crypto";

async function verify(request: Request, secret: string): Promise<unknown> {
  const body = await request.text();
  const id = request.headers.get("udibo-webhook-id");
  const timestamp = Number(request.headers.get("udibo-webhook-timestamp"));
  const header = request.headers.get("udibo-webhook-signature");
  if (!id || !header || !Number.isInteger(timestamp)) return null;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return null;

  const raw = Uint8Array.from(
    atob(secret.slice("whsec_".length).replace(/-/g, "+").replace(/_/g, "/")),
    (c) => c.charCodeAt(0),
  );
  const key = await crypto.subtle.importKey(
    "raw",
    raw,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const mac = new Uint8Array(
    await crypto.subtle.sign(
      "HMAC",
      key,
      new TextEncoder().encode(`${id}.${timestamp}.${body}`),
    ),
  );
  const expected = `v1,${
    btoa(String.fromCharCode(...mac)).replace(/\+/g, "-").replace(/\//g, "_")
      .replace(/=+$/, "")
  }`;

  const ok = header.split(" ").some((entry) =>
    entry.length === expected.length &&
    timingSafeEqual(Buffer.from(entry), Buffer.from(expected))
  );
  return ok ? JSON.parse(body) : null;
}

Respond 2xx to acknowledge. Anything else — or no response within 10 seconds — is a failed attempt and is retried.

Only your status code is read. We discard the response body without parsing it, so there is no way to tell us anything except by the status. In particular Retry-After is ignored: a 429 backs off on the schedule below, not on the interval you name, so shedding load is a matter of failing fast rather than asking us to wait. Every request arrives with user-agent: Udibo-Webhooks/1.0 if you need something to allowlist on.

Delivery guarantees

At-least-once. A delivery row is written in the same database transaction as the event that produced it, so ordinarily the two commit together or not at all. An attempt then leases that row rather than removing it, so a worker that dies mid-attempt has the delivery reclaimed and retried. Design for duplicates: deduplicate on udibo-webhook-id.

One case breaks that pairing, and it favours the audit log. If writing the deliveries fails after the event itself was written, the event is re-recorded on its own, without them. The audit log usually keeps the event; the fan-out is dropped, and because no delivery row was ever created there is nothing in the delivery log and nothing to redrive. It is rare, but it means the audit log outlives the webhook stream — reconcile against the audit log if you need certainty rather than treating deliveries as complete.

Capture is best-effort by design, though, so the audit log is the better record rather than a perfect one: if that second write also fails the event is lost, because recording is never allowed to fail the operation that produced it. A sign-in succeeds even when nothing could be written about it.

Not ordered. Retries mean a later event can arrive before an earlier one. Use occurredAt if you need ordering.

Queued, then drained by a worker on a schedule. The originating request never waits on your server. The worker drains continuously within a run rather than stopping after a fixed batch, and each round takes a bounded number of deliveries per tenant, so another customer's traffic spike cannot push your events behind theirs. If your own volume outruns delivery for a sustained period the queue lengthens — it is a queue, not a guarantee of constant latency — but nothing is dropped and the retry schedule below still applies.

Private-beta delivery cadence: every 15 minutes. Budget up to about 15 minutes before the first attempt. The retry schedule and signature requirements below still apply; a failed attempt waits until a later worker run after its retry becomes due.

Up to ten of one tenant's deliveries are attempted at once, and they can be for the same endpoint. Your consumer needs to be concurrency-safe, not merely idempotent.

Retry schedule — 8 attempts over about 45 hours:

AttemptWaits after the previous attemptElapsed since the event
10
21 minute~1 minute
35 minutes~6 minutes
430 minutes~36 minutes
52 hours~2.6 hours
66 hours~8.6 hours
712 hours~20.6 hours
824 hours~44.6 hours

Dead letter, never discard. A delivery that uses up all 8 attempts moves to exhausted and stays in your delivery log. It is not dropped, and we do not silently give up: redrive it from the endpoint page or POST …/deliveries/{id}/redrive and it re-enters the queue with a full retry budget. That is the recovery path after an outage longer than the retry window. Redrive reuses the same row rather than adding one, so the log shows one delivery that was re-driven rather than two, and it accepts only an exhausted delivery — anything else answers 404.

Replay (POST …/deliveries/{id}/replay) is the other half — it queues a fresh delivery of an already-delivered event without disturbing the original log entry.

Draining the dead-letter queue after your consumer is healthy again:

TypeScript
interface Delivery {
  id: string;
  status: string;
  attempts: number;
  error: string | null;
}

async function redriveExhausted(
  tenantId: string,
  token: string,
  endpointId: string,
): Promise<number> {
  const base =
    `https://www.udibo.com/api/identity/${tenantId}/webhooks/${endpointId}`;
  const authorization = `Bearer ${token}`;
  let redriven = 0;
  let cursor: string | null = null;
  do {
    const url = new URL(`${base}/deliveries`);
    url.searchParams.set("status", "exhausted");
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);
    const listed = await fetch(url, { headers: { authorization } });
    if (!listed.ok) throw new Error(`list failed: ${listed.status}`);
    const page = await listed.json() as {
      data: Delivery[];
      cursors: { next: string | null };
    };
    for (const delivery of page.data) {
      const response = await fetch(
        `${base}/deliveries/${delivery.id}/redrive`,
        { method: "POST", headers: { authorization } },
      );
      if (response.ok) redriven += 1;
    }
    cursor = page.cursors.next;
  } while (cursor);
  return redriven;
}

Redriving moves a delivery out of exhausted, so re-listing the same filter after a pass returns what is still stuck rather than what you just queued.

Non-production environments retry on exactly the production schedule. The schedule above is a single constant with no environment branch, so a staging consumer fails the same way production would instead of hiding the problem until launch.

A delivery stops early when retrying it cannot help. Three things do that, and each lands in exhausted immediately with the reason recorded:

  • the endpoint was disabled or deleted — including deliveries already queued for it, which exhaust rather than draining first;

  • its URL is no longer a permitted destination — it stopped parsing, it carries embedded credentials, or it now resolves somewhere we refuse;

  • its signing secret could not be opened on our side. This one is ours, not yours: the endpoint is healthy and the URL is fine, and your events dead-letter anyway. Watch your delivery log for exhausted rows whose error says so, and raise it with us — redriving will not help until we have fixed it.

A host that merely fails to resolve is not in that set: it is a normal failure and is retried on the schedule above.

Delivery log

Every attempt-set is visible per endpoint with its status, attempt count, last response code, last error, and timestamps — in the dashboard and through GET …/deliveries (cursor-paginated, newest first, 20 per page by default and at most 100).

A delivery's status is one of pending, delivering, succeeded or exhausted; ?status=exhausted is the dead-letter view, and a value outside that set is a 400 rather than a filter we ignore. The recorded error is truncated at 300 characters, so treat it as a lead rather than the whole message.

Delivery records are not purged today. There is no retention window on them and no job that removes them, so your delivery log keeps growing and old records stay readable. That is a gap rather than a promise: when a retention schedule does arrive it will be announced, so do not build on records being there forever.

Last verified 2026-09-11.