API reference
Webhooks
Events we POST to your server, how to verify they came from us, and what happens when your endpoint is down.
A webhook tells your backend something happened without you polling for it. Register an endpoint in the console under Webhooks, choose the events you care about, and we POST JSON to it.
The signing secret is shown once, when you create the endpoint. Store it before you close the dialog.
The request
POST /your/endpoint HTTP/1.1
content-type: application/json
zevauth-signature: t=1767225660,v1=5257a8...
zevauth-event-type: user.created
zevauth-delivery-id: whd_8AYnhCe0z1C4
zevauth-attempt: 1
{
"type": "user.created",
"createdAt": "2026-01-01T12:00:00.000Z",
"environment": "env_live_...",
"data": { "id": "usr_...", "email": "ada@example.com" }
}
zevauth-attempt counts from 1 and increases on every retry, so you can
tell a retry from a new event without inspecting your own records.
Verifying the signature
Do this before you trust the body. Your endpoint is a public URL; the signature is the only thing that says a request came from us.
The header is t=<unix seconds>,v1=<hex>. The v1 value is an
HMAC-SHA256 over <timestamp>.<raw body>, keyed with your signing
secret.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=') as [string, string]),
);
// Reject anything older than five minutes. Without this a signature
// stays valid forever, and a captured request can be replayed back at
// you indefinitely.
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return false;
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? '');
// Constant time. A plain === leaks the correct prefix through timing.
return a.length === b.length && timingSafeEqual(a, b);
}
Events
| Event | When |
|---|---|
user.created | A new end user signed up. |
user.signed_in | An end user signed in successfully. |
user.signed_out | An end user signed out. |
user.locked | An account was locked after repeated failed attempts. |
user.deleted | An end user was deleted. |
session.revoked | A session was ended from the console or by an admin. |
user.identity.revoked | The user withdrew consent for a linked provider, for example removed your app inside ZevID. Their sessions in this environment are already ended; data.provider names the provider. |
delegated_access.connection.created | A user connected your app to a Zev product, or reconnected. Delegated access webhooks. |
delegated_access.connection.updated | A connection’s targets, permissions, suspension, pause or high-risk window changed. |
delegated_access.connection.revoked | A connection was removed. Stop using its tokens. |
delegated_access.app.updated | Your app was suspended or restored on a product, or a permission was paused for it. |
delegated_access.security.refresh_reuse | A refresh token was used twice; the connection’s refresh tokens are revoked. |
Failed sign-ins are deliberately absent. They belong in your auth log,
which the console shows, rather than as one webhook per mistyped
password. token_refresh is absent for the same reason.
Responding
Return any 2xx. We read the status and nothing else, so an empty 200
is a perfectly good answer.
Answer quickly and do the work afterwards. We give up on a request after 15 seconds and treat it as a failure, so an endpoint that finishes the job before replying will be retried while it is still working.
Retries
A non-2xx, a timeout, or an unreachable host is retried on a fixed schedule: immediately, then after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. Six attempts in total, then the delivery is marked failed and left in the console for you to inspect or resend.
Make your handler idempotent. A retry can arrive after your endpoint
already succeeded — a response lost in transit still looks like a
failure to us. zevauth-delivery-id is stable across every attempt at
one delivery, so recording it and ignoring repeats is enough.
An endpoint that fails 20 consecutive times is disabled automatically, and we notify you. Nothing is delivered while it is disabled; re-enable it in the console once the endpoint is healthy.
Ordering
Not guaranteed. Retries mean a delayed event can arrive after a later
one, so treat each event as a statement about its own createdAt rather
than as a position in a sequence. If order matters, read the current
state from the API rather than reconstructing it from the stream.
Testing
Development and production environments have separate endpoints, so you
can point development at a tunnel and leave production alone. The
environment field on every payload says which one sent it.
The console shows every delivery with the request and response of each attempt, and lets you resend one. A resend carries the original bytes, not a re-serialisation, so it is usable for debugging a signature mismatch.
Updated at, Wednesday, October 21, 2026