Вебхуки
Вебхук доставляє кожну завершену перевірку на ваш сервер, тож опитувати нічого не потрібно. Ви один раз реєструєте HTTPS-кінцеву точку, і відтоді кожна перевірка, що досягла завершального стану, надсилається на неї підписаним POST. Опитування GET /v1/checks/{check_id} залишається запасним варіантом.
Що доставляється
Є один тип події, check.completed. Її надсилають один раз для кожної перевірки, що досягла завершального стану, completed або failed, зокрема для кожного елемента пакета (одна доставка на елемент, а не на пакет). Перевірка в стані pending нічого не надсилає.
Подію отримує кожна активна кінцева точка середовища, і кожна кінцева точка отримує власну доставку. Кінцеві точки ключа sandbox отримують лише події sandbox, а кінцеві точки live-ключа лише події live; поле environment каже, яка з них.
Тіло — JSON:
| Поле | Опис |
|---|---|
event_id | Унікальний ідентифікатор події. Той самий ідентифікатор надсилається знову при кожній повторній спробі тієї самої доставки: дедуплікуйте за ним. |
event_type | check.completed. |
environment | live або sandbox. |
created_at | Коли подію створено, мітка часу ISO 8601. Однакова при кожній повторній спробі. |
check | Повна перевірка з тими самими полями, що й data у відповіді GET /v1/checks/{check_id}; див. Перевірки адрес. |
{
"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
}
}
У прикладі check скорочено до кількох полів; доставляється повний об'єкт перевірки. Сприймайте доставку як той самий запис, який ви отримали б, прочитавши перевірку: вердикт не змінюється після того, як став завершальним.
Зареєструйте кінцеву точку
POST /v1/webhooks зі scope webhooks:manage.
| Поле | Обов'язкове | Опис |
|---|---|---|
url | так | HTTPS-адреса, що отримує доставки, не більше 2048 символів. Невідомі поля відхиляються. |
URL має використовувати https. Його хост розв'язується під час реєстрації, а хост, що розв'язується в адресу з приватного чи іншого зарезервованого діапазону, відхиляється з 400. Та сама перевірка виконується перед кожною доставкою, бо DNS можуть переспрямувати після реєстрації. Якщо хост розв'язати не вдалося, запит повертає 503. Кожне середовище може мати до 20 активних кінцевих точок; 21-шу відхилено з 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"])
Відповідь 201 містить кінцеву точку:
| Поле | Опис |
|---|---|
webhook_id | Ідентифікатор кінцевої точки, потрібний, щоб ротувати чи вимкнути її. |
url | Зареєстрований URL. |
secret | Секрет підпису. Показується один раз, лише в цій відповіді. Збережіть його у своєму сховищі секретів зараз: він не зберігається у читабельному вигляді й більше не повертається, навіть коли той самий запит повторюють із тим самим Idempotency-Key. |
secret_last4 | Останні чотири символи секрету, щоб розрізняти секрети. |
status | active або disabled після вимкнення. |
previous_secret_expires_at | null або кінець вікна ротації; див. Ротація секрету. |
rotated_at, created_at, updated_at | Мітки часу. |
Якщо ви втратили секрет, зробіть ротацію кінцевої точки, щоб отримати новий.
Перегляд, ротація й вимкнення
Усі три дії потребують scope webhooks:manage і приймають необов'язковий Idempotency-Key там, де щось змінюють (див. Ідемпотентність).
GET /v1/webhooksпоказує кінцеві точки середовища ключа, активні й вимкнені, від найстаршої. Перегляд показуєsecret_last4і ніколи не показує секрет.POST /v1/webhooks/{webhook_id}/rotateвидає новий секрет; див. нижче.DELETE /v1/webhooks/{webhook_id}вимикає кінцеву точку. Відтоді вона нічого не отримує, зокрема й доставки, що ще чекали повторної спроби. Вимкнення вже вимкненої кінцевої точки не є помилкою, а вимкнена кінцева точка більше не враховується в ліміті 20. Невідомий ідентифікатор або ідентифікатор з іншого середовища повертає404.
Ротація секрету
Ротація замінює секрет без простою. Протягом 24 годин діють і новий, і попередній секрети: кожну доставку в цьому вікні підписано обома, тож отримувач, що вже перейшов на новий секрет, і той, що ще ні, обидва її перевіряють. previous_secret_expires_at показує, коли вікно закінчується. Новий секрет міститься у відповіді 200, один раз.
Розгорніть новий секрет у своєму отримувачі протягом вікна й після цього припиніть використовувати старий. Ваш код перевірки не змінюється: він уже приймає заголовок із кількома підписами.
Перевірте підпис
Будь-хто, хто знає ваш URL, може надіслати на нього запит. Кожна справжня доставка несе підпис у заголовку x-aml-signature, а запиту без дійсного підпису не можна довіряти й виконувати за ним дії.
x-aml-signature: t=1793121567192,v1=4abc621d…d9d2
t— час підписання доставки в мілісекундах Unix.v1— HMAC-SHA256 у шістнадцятковому записі, 64 символи. На кожен чинний секрет припадає одинv1, тож під час ротації заголовок містить два. Достатньо збігу з будь-якимv1.- Підписане повідомлення — рядок
<t>.<сире тіло>: значенняt, крапка й тіло запиту точно в тому вигляді, в якому воно надійшло. Ключ — секрет вашої кінцевої точки.
Щоб перевірити доставку:
- Прочитайте сире тіло до його розбору. Підпис охоплює передані байти, тож тіло, яке розібрали й серіалізували знову, уже не збігається.
- Розберіть
tі значенняv1із заголовка. Відхиліть запит, якщоtабо всіv1відсутні чи неправильні. - Відхиліть його, якщо
tвідрізняється від вашого годинника більш ніж на 5 хвилин в будь-який бік. Це не дає повторно відтворити перехоплену доставку пізніше. - Обчисліть HMAC-SHA256 від
<t>.<сире тіло>з вашим секретом і порівняйте з кожнимv1порівнянням за сталий час.
Наведені нижче приклади роблять саме це й ніколи не викидають виняток на неправильному заголовку. Їх перевірено на підписах, створених самою службою доставки: правильний підпис, змінене тіло, застаріла мітка часу, два підписи під час ротації та неправильні заголовки.
- 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.
Мінімальний отримувач
Підключіть функцію до маршруту, що зберігає сире тіло, відповідає швидко, а справжню роботу виконує згодом.
- 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
Обидва отримувачі припускають власну чергу (queue.add, enqueue). Секрет береться зі змінних середовища чи сховища секретів, ніколи не з вихідного коду.
Доставка й повторні спроби
- Підтверджуйте будь-яким
2xxпротягом 10 секунд. Тіло відповіді не використовується: порожній204підходить, а незвично велике тіло вважається збоєм. - Перенаправлення не виконуються. Відповідь
3xxвважається невдалою доставкою, тож реєструйте кінцевий URL. - Збої, що повторюються: відсутня відповідь або відповідь повільніша за 10 секунд,
408,429і кожен5xx. Тимчасова помилка DNS теж повторюється. - Не повторюються: будь-який інший
4xxі будь-який3xx, бо ваш сервер явно відмовився від доставки, а також URL, що тепер розв'язується в заблоковану адресу. Такі доставки завершуються як невдалі й більше не надсилаються. - Розклад повторних спроб: перша повторна спроба негайна, далі через 10 секунд, 1 хвилину й 5 хвилин, потім кожні 10 хвилин, доки доставці менше 24 годин, далі щогодини. Доставку, що досі невдала через 48 годин після створення, відкидають.
- Принаймні раз. Доставка може надійти більше одного разу, наприклад коли ваш сервер її обробив, але його
2xxзагубився. Зберігайте оброблені значенняevent_idй ігноруйте повтори. - Порядок не гарантується. Доставки різних перевірок і повторні спроби можуть надходити не по порядку. Спирайтеся в обробці на
check_id, а не на порядок надходження.
Невдала доставка ніколи не впливає на перевірку чи її списання. Якщо ваша кінцева точка не працювала довше, ніж триває наведений розклад, прочитайте пропущені перевірки через GET /v1/checks/{check_id}.
Перевірте на практиці
Зареєструйте кінцеву точку з ключем sandbox, виконайте перевірку в sandbox і переконайтеся, що ваш отримувач перевіряє підпис і відповідає 2xx. Кінцеві точки sandbox отримують лише події sandbox, тож на вашу live-кінцеву точку нічого не надійде; див. Sandbox і live.