Skip to main content

Webhooks

A webhook delivers each finished check to your server, so you never have to poll. You register an HTTPS endpoint once; from then on every check that reaches a terminal state is sent to it as a signed POST. Polling GET /v1/checks/{check_id} stays available as the fallback.

What is delivered​

There is one event type, check.completed. It is sent once for every check that reaches a terminal state, completed or failed, including each item of a batch (one delivery per item, not per batch). A check that is still pending sends nothing.

Every active endpoint of the environment receives the event, and each endpoint gets its own delivery. A sandbox key's endpoints receive only sandbox events and a live key's endpoints only live events; the environment field says which.

The body is JSON:

FieldDescription
event_idUnique id of the event. The same id is sent again on every retry of the same delivery: dedupe on it.
event_typecheck.completed.
environmentlive or sandbox.
created_atWhen the event was created, an ISO 8601 timestamp. Identical on every retry.
checkThe full check, with the same fields as data in the response of GET /v1/checks/{check_id}; see Address checks.
{
"event_id": "9d2f6c1a-3b7e-4f08-a5d4-1c8e0b7a6f23",
"event_type": "check.completed",
"environment": "live",
"created_at": "2026-10-27T09:14:04.931Z",
"check": {
"check_id": "5b0c3f64-1a52-4c8e-9f3e-2f6c1d7a9e10",
"status": "completed",
"band": "high",
"score": 74
}
}

The example trims check to a few fields; the delivered object is the complete check. Treat the delivery as the same record you would get by reading the check: a verdict does not change after it is terminal.

Register an endpoint​

POST /v1/webhooks with the webhooks:manage scope.

FieldRequiredDescription
urlyesThe HTTPS address that receives deliveries, at most 2048 characters. Unknown fields are rejected.

The URL must use https. Its host is resolved when you register it, and a host that resolves to an address inside a private or otherwise reserved range is refused with 400. The same check runs again before every delivery, because DNS can be re-pointed after registration. If the host cannot be resolved the request answers 503. Each environment can have up to 20 active endpoints; the 21st is refused with 400.

curl -s -X POST https://api.leeri.ai/v1/webhooks \
-H "Authorization: Bearer $LEERI_API_KEY" \
-H "Idempotency-Key: register-orders-endpoint" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-service.example.com/webhooks/leeri"}'

The 201 response carries the endpoint:

FieldDescription
webhook_idId of the endpoint, used to rotate or disable it.
urlThe registered URL.
secretThe signing secret. Shown once, in this response only. Store it in your secret manager now: it is not stored in a readable form and is never returned again, not even when the same request is replayed with the same Idempotency-Key.
secret_last4The last four characters of the secret, to tell secrets apart.
statusactive, or disabled after you disable it.
previous_secret_expires_atnull, or the end of the rotation window; see Rotate the secret.
rotated_at, created_at, updated_atTimestamps.

If you lost the secret, rotate the endpoint to get a new one.

List, rotate and disable​

All three need the webhooks:manage scope and accept an optional Idempotency-Key where they change something (see Idempotency).

  • GET /v1/webhooks lists the endpoints of the key's environment, active and disabled, oldest first. Listing shows secret_last4 and never the secret.
  • POST /v1/webhooks/{webhook_id}/rotate issues a new secret; see below.
  • DELETE /v1/webhooks/{webhook_id} disables the endpoint. It receives nothing from then on, including deliveries that were still waiting to be retried. Disabling a disabled endpoint is not an error, and a disabled endpoint no longer counts toward the limit of 20. An unknown id, or one from the other environment, answers 404.

Rotate the secret​

A rotation replaces the secret without an outage. For 24 hours both the new and the previous secret are valid: every delivery in that window is signed with both, so a receiver that has switched to the new secret and one that has not yet both verify it. previous_secret_expires_at shows when the window ends. The new secret is in the 200 response, once.

Deploy the new secret to your receiver within the window, then stop using the old one. Your verification code does not change: it already accepts a header with several signatures.

Verify the signature​

Anyone who knows your URL can send it a request. Every genuine delivery carries a signature in the x-aml-signature header, and a request without a valid one must not be trusted or acted on.

x-aml-signature: t=1793121567192,v1=4abc621d…d9d2
  • t is the time the delivery was signed, in Unix milliseconds.
  • v1 is a hex-encoded HMAC-SHA256, 64 characters. There is one v1 per currently valid secret, so the header carries two during a rotation. A match against any v1 is enough.
  • The signed message is the string <t>.<raw body>: the value of t, a dot, and the request body exactly as it arrived. The key is your endpoint's secret.

To verify a delivery:

  1. Read the raw body before parsing it. The signature covers the transmitted bytes, so a body that was parsed and serialised again no longer matches.
  2. Parse t and the v1 values from the header. Reject the request if t or every v1 is missing or malformed.
  3. Reject it if t differs from your clock by more than 5 minutes, in either direction. This stops a captured delivery being replayed later.
  4. Compute the HMAC-SHA256 of <t>.<raw body> with your secret and compare it with each v1 using a constant-time comparison.

The samples below do exactly this and never throw on a malformed header. They are tested against signatures produced by the delivery service itself: a correct signature, a changed body, a stale timestamp, two signatures during a rotation and malformed headers.

import crypto from 'node:crypto';

// Pass the raw request body exactly as received, before any JSON parsing: the signature covers
// the transmitted bytes, and re-serialising the parsed object changes them.
export function verifyWebhook(rawBody, header, secret, nowMs = Date.now(), maxSkewMs = 300000) {
if (typeof header !== 'string') return false;

let t = null;
const signatures = [];

for (const part of header.split(',')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
const key = part.slice(0, eq);
const value = part.slice(eq + 1);

if (key === 't' && /^\d+$/.test(value)) t = Number(value);
else if (key === 'v1' && /^[0-9a-fA-F]{64}$/.test(value)) signatures.push(Buffer.from(value, 'hex'));
}

if (t === null || signatures.length === 0) return false;
if (Math.abs(nowMs - t) > maxSkewMs) return false;

const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();

// During a secret rotation the header carries one v1 per valid secret; any match is enough.
return signatures.some((signature) => crypto.timingSafeEqual(signature, expected));
}

// Respond 2xx quickly and process asynchronously. Deliveries are at-least-once, so dedupe on
// the event_id in the payload before acting on it.

A minimal receiver​

Wire the function into a route that keeps the raw body, answers quickly and does the real work afterwards.

import express from 'express';
import { verifyWebhook } from './verify-webhook.mjs';

const app = express();

// express.raw keeps the body as bytes; express.json would parse it first and lose them.
app.post('/webhooks/leeri', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(req.body, req.get('x-aml-signature'), process.env.LEERI_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}

res.sendStatus(200);

const event = JSON.parse(req.body.toString('utf8'));
queue.add(event); // process later, skipping event.event_id values already handled
});

Both receivers assume a queue of your own (queue.add, enqueue). The secret comes from your environment or secret manager, never from source code.

Delivery and retries​

  • Acknowledge with any 2xx within 10 seconds. The response body is not used: an empty 204 is fine, and an unusually large body counts as a failure.
  • Redirects are not followed. A 3xx answer counts as a failed delivery, so register the final URL.
  • Retried failures: no answer or an answer slower than 10 seconds, 408, 429 and every 5xx. A temporary DNS failure is retried too.
  • Not retried: any other 4xx and any 3xx, because your server has explicitly refused the delivery, and a URL that now resolves to a blocked address. Those deliveries end as failed and are not sent again.
  • Retry schedule: the first retry is immediate, then after 10 seconds, 1 minute and 5 minutes, then every 10 minutes while the delivery is less than 24 hours old, then hourly. A delivery that is still failing 48 hours after it was created is given up on.
  • At least once. A delivery can arrive more than once, for example when your server processed it but its 2xx was lost. Keep the event_id values you have handled and ignore repeats.
  • Order is not guaranteed. Deliveries of different checks, and retries, can arrive out of order. Key your handling on the check_id, not on arrival order.

A failed delivery never affects the check or its charge. If your endpoint was down for longer than the schedule above, read the checks you missed with GET /v1/checks/{check_id}.

Test it​

Register an endpoint with a sandbox key, run a sandbox check, and confirm that your receiver verifies the signature and answers 2xx. Sandbox endpoints receive only sandbox events, so nothing reaches your live endpoint; see Sandbox and live.