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 for dashboard access.
Add an endpoint in the dashboard
Prepare a publicly reachable HTTPS receiver. It must verify the signature over the raw request body before processing an event, handle duplicates, and return a successful response after accepting the work.
Open Developers → Webhooks in your tenant's dashboard and choose Add endpoint.
Enter the Endpoint URL and an optional Description.
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 before choosing a broad pattern.Leave Enabled selected when the receiver is ready, then choose Add endpoint.
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.
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.
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.
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.
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 —
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.
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).
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 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 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 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 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 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.
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 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
pendingordeliveringdelivery 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
exhausteddelivery starts its 30 days again from the redriven attempt.Redrive a dead letter within 30 days. An
exhausteddelivery 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; the delivery log records our attempts to send it, not what happened.
Last verified 2026-10-30.

