---
title: Webhooks
description: 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

```http
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
```

```json
{
  "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.

```ts
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);
}
```

> **Warning:**
> Sign the **raw request body**, exactly as it arrived. If your framework
> parses JSON and you re-serialise it, key order or whitespace can differ
> and every signature will fail. In Express, use
> `express.raw({ type: 'application/json' })` on this route. In NestJS,
> enable `rawBody: true` and read `req.rawBody`.

## 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/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.
