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:
| Field | Description |
|---|---|
event_id | Unique id of the event. The same id is sent again on every retry of the same delivery: dedupe on it. |
event_type | check.completed. |
environment | live or sandbox. |
created_at | When the event was created, an ISO 8601 timestamp. Identical on every retry. |
check | The 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.
| Field | Required | Description |
|---|---|---|
url | yes | The 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
- Node.js
- Python
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"}'
const response = await fetch('https://api.leeri.ai/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LEERI_API_KEY}`,
'Idempotency-Key': 'register-orders-endpoint',
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: 'https://your-service.example.com/webhooks/leeri' }),
});
const { data } = await response.json();
// data.secret is shown only in this response.
console.log(data.webhook_id, data.secret);
import os
import requests
response = requests.post(
"https://api.leeri.ai/v1/webhooks",
headers={
"Authorization": f"Bearer {os.environ['LEERI_API_KEY']}",
"Idempotency-Key": "register-orders-endpoint",
},
json={"url": "https://your-service.example.com/webhooks/leeri"},
timeout=30,
)
data = response.json()["data"]
# data["secret"] is shown only in this response.
print(data["webhook_id"], data["secret"])
The 201 response carries the endpoint:
| Field | Description |
|---|---|
webhook_id | Id of the endpoint, used to rotate or disable it. |
url | The registered URL. |
secret | The 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_last4 | The last four characters of the secret, to tell secrets apart. |
status | active, or disabled after you disable it. |
previous_secret_expires_at | null, or the end of the rotation window; see Rotate the secret. |
rotated_at, created_at, updated_at | Timestamps. |
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/webhookslists the endpoints of the key's environment, active and disabled, oldest first. Listing showssecret_last4and never the secret.POST /v1/webhooks/{webhook_id}/rotateissues 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, answers404.
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
tis the time the delivery was signed, in Unix milliseconds.v1is a hex-encoded HMAC-SHA256, 64 characters. There is onev1per currently valid secret, so the header carries two during a rotation. A match against anyv1is enough.- The signed message is the string
<t>.<raw body>: the value oft, a dot, and the request body exactly as it arrived. The key is your endpoint's secret.
To verify a delivery:
- 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.
- Parse
tand thev1values from the header. Reject the request iftor everyv1is missing or malformed. - Reject it if
tdiffers from your clock by more than 5 minutes, in either direction. This stops a captured delivery being replayed later. - Compute the HMAC-SHA256 of
<t>.<raw body>with your secret and compare it with eachv1using 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.
- Node.js
- Python
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.
import hashlib
import hmac
import re
import time
_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$")
# 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.
def verify_webhook(raw_body: bytes, header: str, secret: str, now_ms=None, max_skew_ms=300000) -> bool:
if not isinstance(header, str):
return False
if now_ms is None:
now_ms = int(time.time() * 1000)
t = None
signatures = []
for part in header.split(","):
key, sep, value = part.partition("=")
if not sep:
continue
if key == "t" and value.isascii() and value.isdigit():
t = int(value)
elif key == "v1" and _HEX64.match(value):
signatures.append(bytes.fromhex(value))
if t is None or not signatures:
return False
if abs(now_ms - t) > max_skew_ms:
return False
expected = hmac.new(secret.encode("utf-8"), str(t).encode("ascii") + b"." + raw_body, hashlib.sha256).digest()
# During a secret rotation the header carries one v1 per valid secret; any match is enough.
return any(hmac.compare_digest(signature, expected) for signature in signatures)
# 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.
- Node.js
- Python
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
});
import os
from flask import Flask, request
from verify_webhook import verify_webhook
app = Flask(__name__)
@app.post("/webhooks/leeri")
def leeri_webhook():
raw_body = request.get_data() # bytes, before any JSON parsing
if not verify_webhook(raw_body, request.headers.get("x-aml-signature", ""), os.environ["LEERI_WEBHOOK_SECRET"]):
return "", 400
enqueue(request.get_json()) # process later, skipping event_id values already handled
return "", 200
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
2xxwithin 10 seconds. The response body is not used: an empty204is fine, and an unusually large body counts as a failure. - Redirects are not followed. A
3xxanswer counts as a failed delivery, so register the final URL. - Retried failures: no answer or an answer slower than 10 seconds,
408,429and every5xx. A temporary DNS failure is retried too. - Not retried: any other
4xxand any3xx, 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
2xxwas lost. Keep theevent_idvalues 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.