ZevAuth
Sign up

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

EventWhen
user.createdA new end user signed up.
user.signed_inAn end user signed in successfully.
user.signed_outAn end user signed out.
user.lockedAn account was locked after repeated failed attempts.
user.deletedAn end user was deleted.
session.revokedA session was ended from the console or by an admin.
user.identity.revokedThe 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.createdA user connected your app to a Zev product, or reconnected. Delegated access webhooks.
delegated_access.connection.updatedA connection’s targets, permissions, suspension, pause or high-risk window changed.
delegated_access.connection.revokedA connection was removed. Stop using its tokens.
delegated_access.app.updatedYour app was suspended or restored on a product, or a permission was paused for it.
delegated_access.security.refresh_reuseA 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