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, or through the management API:
| 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.
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:
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:
{
"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,<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.
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:
| 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 |
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:
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
exhaustedrows 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.

