# 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 **Developers** → **Webhooks**. **Dashboard access.** You need **View webhooks** to open endpoint and delivery-log pages, and **Manage webhooks** to add, edit or delete endpoints, send test events, replay or redrive deliveries. Revealing or rotating an existing signing secret separately needs **Reveal webhook signing secrets**. Creating an endpoint displays its initial secret with **Manage webhooks**; saving that displayed secret does not need the separate reveal permission. **Owner** and **Admin** include all three permissions. **Support** alone can inspect endpoints and delivery logs, but cannot perform management or secret actions. See [choose an access level](https://www.udibo.com/docs/identity/team-access#choose-an-access-level) for dashboard access. ### Add an endpoint in the dashboard 1. Prepare a publicly reachable HTTPS receiver. It must [verify the signature](https://www.udibo.com/docs/identity/webhooks#signature-verification) over the raw request body before processing an event, handle duplicates, and return a successful response after accepting the work. 2. Open **Developers → Webhooks** in your tenant's dashboard and choose **Add endpoint**. 3. Enter the **Endpoint URL** and an optional **Description**. 4. In **Subscribed events**, keep **All events** or choose **Only the events I choose** and tick whole families (`auth.*`, which includes event types added to the family later) or single events. See [subscriptions](https://www.udibo.com/docs/identity/webhooks#subscriptions) before choosing a broad pattern. 5. Leave **Enabled** selected when the receiver is ready, then choose **Add endpoint**. 6. Save the displayed **signing secret** in your server's secret manager. Configure your receiver with it and choose **Go to endpoint**. ### Test your receiver On the endpoint page, choose **Send test event**. This queues a delivery; the result message does not mean your receiver accepted it. The delivery worker runs every minute, so a queued test may wait up to a minute for its next run; see [delivery guarantees](https://www.udibo.com/docs/identity/webhooks#delivery-guarantees). Reload the endpoint's **Delivery log** to inspect the result: | Status | What to check | | ------------- | ----------------------------------------------------------------------- | | Queued | Waiting for an initial attempt or a retry; inspect Attempts | | Delivering | An attempt is in progress | | Delivered | Check your receiver's logs and stored event, not just the HTTP response | | Dead-lettered | Review the response and last error; fix the receiver before redriving | Check **Attempts**, **Response**, **Last attempt**, and **Last error** when a test fails. **Response** shows the status code, how long the attempt took, and the start of your receiver's response body. After fixing a dead-lettered receiver, choose **Redrive** to return that delivery to the queue. **Replay** creates a fresh delivery of an event; keep your duplicate handling in place. For a successful test, confirm your receiver verified the signature and accepted the event into your own processing workflow before relying on it for customer data. ### Management API reference The management API operations below are listed for reference. Configure customer endpoints through the dashboard today; the API credential boundary is explained below. | Method | Path | Scope | | -------- | ------------------------------------------------------------------------ | -------------------------- | | `GET` | `/api/identity/{tenantId}/webhooks` | `identity:webhooks:read` | | `POST` | `/api/identity/{tenantId}/webhooks` | `identity:webhooks:write` | | `GET` | `/api/identity/{tenantId}/webhooks/event-types` | `identity: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}/secret` | `identity:webhooks:secret` | | `POST` | `/api/identity/{tenantId}/webhooks/{id}/secret/rotate` | `identity:webhooks:secret` | | `POST` | `/api/identity/{tenantId}/webhooks/{id}/test` | `identity:webhooks:write` | | `GET` | `/api/identity/{tenantId}/webhooks/{id}/deliveries` | `identity:webhooks:read` | | `GET` | `/api/identity/{tenantId}/webhooks/{id}/deliveries/{deliveryId}` | `identity:webhooks:read` | | `POST` | `/api/identity/{tenantId}/webhooks/{id}/deliveries/{deliveryId}/replay` | `identity:webhooks:write` | | `POST` | `/api/identity/{tenantId}/webhooks/{id}/deliveries/{deliveryId}/redrive` | `identity: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. **Dashboard only today.** These management API operations are listed for reference: they accept only the dashboard's own session or a token from Udibo's own sign-in, which you cannot get outside the dashboard. A token your tenant issues, including a client-credentials token, is refused with `401`; see [the management API](https://www.udibo.com/docs/identity/apis#the-management-api). Your receiver verifies the webhook signing secret and needs no management API credential to receive deliveries. Registering an endpoint returns the signing secret in the response, and that is the one time it is handed to you unprompted: _Reference only — dashboard today._ ```ts interface CreatedEndpoint { id: string; url: string; eventTypes: string[]; secret: string; } async function registerEndpoint( tenantId: string, token: string, url: string, eventTypes: string[], ): Promise { 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. **Hosted customers still need a public HTTPS receiver when testing locally.** A customer application's development or staging mode does not relax destination checks. Plain `http` and local addresses are permitted only when the Udibo server itself runs with `NODE_ENV=development` or `NODE_ENV=test`; every other value, and an unset one, keeps the public-destination checks. This exception is for development of the service, not a setting on your identity tenant. **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 dashboard's picker offers `*`, whole families such as `auth.*`, and single events. When you edit an endpoint that also has a narrower prefix set through the API, such as `auth.mfa.*`, the edit page lists it read-only under **Also subscribed through the API** and saving keeps it exactly as stored. Change or remove it through the API. A stored name that no longer matches any event in the catalog is listed under **No longer in the catalogue** and is removed when you save, because the API would refuse it. 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](https://www.udibo.com/docs/identity/billing#usage-alerts) — `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](https://www.udibo.com/docs/identity/audit-log) 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. **`application.secret.expiring` warns before a client secret runs out.** It is recorded 30 days and again 7 days before an application's last current secret expires, once per threshold per secret per expiry date, when the daily warning run first finds it; the warning email to your secret managers and owners follows in that run's digest ([applications](https://www.udibo.com/docs/identity/applications#expiry-warnings)). The delivery's `data.target` is the application (`type` `application`, its id as `id`); the secret's id, its expiry and the threshold in days live on the audit event's `metadata`. ## Payload Deliveries are `POST`ed 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: | Header | Value | | ------------------------- | --------------------------------------------------------------- | | `udibo-webhook-id` | Delivery id — same across retries, new per replay; dedupe on it | | `udibo-webhook-timestamp` | Unix seconds at signing time | | `udibo-webhook-signature` | Space-separated `v1,` 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. ```ts import { Buffer } from "node:buffer"; import { timingSafeEqual } from "node:crypto"; async function verify(request: Request, secret: string): Promise { 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 expectedBytes = Buffer.from(expected); const ok = header.split(" ").some((entry) => { const candidateBytes = Buffer.from(entry); return candidateBytes.byteLength === expectedBytes.byteLength && timingSafeEqual(candidateBytes, expectedBytes); }); 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 decides the outcome.** We never parse the response body: its first 4 KiB is kept as text in your [delivery log](https://www.udibo.com/docs/identity/webhooks#delivery-log) so you can see what your receiver said, but nothing in it changes whether we retry. 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](https://www.udibo.com/docs/identity/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. **Current delivery cadence: every minute.** Budget up to about a minute 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:** | Attempt | Waits after the previous attempt | Elapsed since the event | | ------- | -------------------------------- | ----------------------- | | 1 | — | 0 | | 2 | 1 minute | ~1 minute | | 3 | 5 minutes | ~6 minutes | | 4 | 30 minutes | ~36 minutes | | 5 | 2 hours | ~2.6 hours | | 6 | 6 hours | ~8.6 hours | | 7 | 12 hours | ~20.6 hours | | 8 | 24 hours | ~44.6 hours | **Each wait counts from when the previous attempt started**, the instant its `udibo-webhook-timestamp` header records, not from when your endpoint answered or timed out. The next attempt goes out on a later worker run once that wait has passed. Measured on our clock, no attempt goes out earlier than the table says. Your receiver's clock may differ from ours by the usual skew between hosts. **Dead letter, redrive within 30 days.** A delivery that uses up all 8 attempts moves to `exhausted` and stays in your delivery log for [30 days](https://www.udibo.com/docs/identity/webhooks#how-long-deliveries-are-kept) after its last attempt, then it is deleted. We do not silently give up on it in that time: **redrive** it from the endpoint page or `POST …/deliveries/{id}/redrive` within those 30 days 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: _Reference only — dashboard today._ ```ts interface Delivery { id: string; status: string; attempts: number; error: string | null; } async function redriveExhausted( tenantId: string, token: string, endpointId: string, ): Promise { 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 response body, how long the last attempt took (`durationMs`), last error, and timestamps — in the dashboard and through `GET …/deliveries` (cursor-paginated, newest first, 20 per page by default and at most 100). `responseBody` is the start of what your receiver answered on the last attempt, whatever its status: at most **4 KiB** of UTF-8 text, with bytes that are not valid UTF-8 shown as `�`, and `responseBodyTruncated: true` when the stored text is not the whole body (your receiver sent more, or replacement characters widened it past the cap). Anything shaped like a signing secret (`whsec_…`) is replaced with `whsec_[redacted]`. A compressed body is not kept, and an attempt with no response has `responseBody: null`. The dashboard shows the body as plain text, never as a rendered page. Each attempt replaces the last one's body, and it is kept as long as the delivery record. 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. ### How long deliveries are kept **A finished delivery is kept for 30 days after its last attempt, then deleted.** Finished means `succeeded` or `exhausted`. A purge runs once a day, so a record disappears from the log and from `GET …/deliveries` usually within a day after its 30 days are up. - **A delivery still in flight is never deleted.** A `pending` or `delivering` delivery stays until it finishes, however long that takes, and its 30 days start from the attempt that finishes it. - **The clock is the last attempt, not the event.** Redriving an `exhausted` delivery starts its 30 days again from the redriven attempt. - **Redrive a dead letter within 30 days.** An `exhausted` delivery you have not redriven by then is deleted with the rest, and so is its response body. - **Keep your own copy if you need longer.** An identity event stays in your audit log for that log's [retention period](https://www.udibo.com/docs/identity/audit-log#retention); the delivery log records our attempts to send it, not what happened. _Last verified 2026-10-30._