Документация
Вебхуки
Мы сами постучимся на ваш адрес, когда что-то произойдёт, — опрашивать API по расписанию не нужно. Каждый запрос подписан, поэтому приёмник может убедиться, что событие пришло от нас.
Подключение
Адрес приёмника задаётся в кабинете: Разработчикам → Вебхуки. При добавлении выбираются события и выдаётся секрет подписи — он показывается один раз, как и ключ API. На организацию можно завести до 5 адресов, например прод и тест.
Требования к адресу: только https и публичный хост. На localhost и адреса внутренних сетей мы не ходим — для локальной отладки используйте туннель вида ngrok.
Как выглядит запрос
POST с телом application/json. Конверт одинаковый для всех событий, различается только data:
{
"event": "cascade.recipient.finished",
"createdAt": "2026-08-30T18:20:11.000Z",
"organizationId": "clx…",
"data": { … }
}Заголовки, которые приходят с каждым событием:
| Заголовок | Назначение |
|---|---|
X-Evidra-Event | Ключ события — тот же, что в теле. |
X-Evidra-Delivery | Идентификатор доставки. Повторы приходят с тем же значением — используйте для дедупликации. |
X-Evidra-Timestamp | Unix-время подписи в секундах. Входит в подписываемую строку. |
X-Evidra-Signature | Подпись v1=<HMAC-SHA256>, см. ниже. |
Ответ и повторы
Отвечайте любым кодом 2xx в течение 10 секунд. Сначала ответьте, потом обрабатывайте: тяжёлую работу — в очередь или фон. Любой другой код или молчание считается неудачей, и мы повторим доставку ещё 5 раз с растущей паузой — через минуту, 5 минут, полчаса, 2 часа, 6 часов, сутки.
Если 10 доставок подряд исчерпали все повторы, эндпоинт выключается автоматически — вы увидите это в кабинете вместе с последней ошибкой. После починки включите его обратно; события, накопившиеся за время простоя, повторно не отправляются.
Проверка подписи
Адрес приёмника публичный, и без проверки в него сможет написать кто угодно. Заголовок X-Evidra-Signature содержит v1=<hex>, где hex — HMAC-SHA256 от строки {X-Evidra-Timestamp}.{сырое тело запроса} на секрете эндпоинта.
import crypto from "node:crypto";
// express.raw, а не express.json: подпись считается по сырому телу.
// Пересобранный из объекта JSON даст другие байты, и проверка не сойдётся.
app.post("/hooks/evidra", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Evidra-Timestamp") ?? "";
const signature = req.get("X-Evidra-Signature") ?? "";
const body = req.body.toString("utf8");
const expected =
"v1=" +
crypto
.createHmac("sha256", process.env.EVIDRA_WEBHOOK_SECRET)
.update(`${timestamp}.${body}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
// Отвечаем сразу, разбор — в фон: дольше 10 секунд мы не ждём.
res.sendStatus(200);
handleEvent(JSON.parse(body));
});Два правила сверх самой подписи:
- Отклоняйте запрос, если
X-Evidra-Timestampстарше 5 минут. Подпись остаётся верной вечно, и перехваченный запрос иначе можно переиграть. - Считайте события повторяемыми: при повторе приходит тот же
X-Evidra-Delivery. Храните обработанные идентификаторы и отсекайте дубли по нему.
Секрет можно перевыпустить в кабинете. Старая подпись перестаёт проходить сразу, поэтому сначала положите новый секрет в приёмник, затем перевыпускайте.
События
Подписка настраивается по событиям — приёмник получает только выбранные. Ниже — что означает каждое и пример тела целиком.
Каскад по получателю завершёнcascade.recipient.finished
Каскад для конкретного клиента остановлен. Смотрите finishReason: RESPONDED — клиент поставил оценку (в rating), COMPLETED — шаги кончились без ответа, SUPPRESSED — клиент отписан, NO_CONTACT — не нашлось пригодного контакта.
{
"event": "cascade.recipient.finished",
"createdAt": "2026-08-30T18:20:11.000Z",
"organizationId": "clx2222222222222222222222",
"data": {
"cascadeId": "clx0000000000000000000000",
"externalId": "order-10423",
"locationId": "clx1111111111111111111111",
"recipient": {
"name": "Анна",
"email": "anna@example.com",
"phone": "79123456789"
},
"finishReason": "RESPONDED",
"rating": 5,
"respondedAt": "2026-08-30T18:20:11.000Z",
"attempts": [
{
"step": 1,
"channel": "EMAIL",
"status": "SENT",
"at": "2026-08-29T09:00:00.000Z"
}
]
}
}Сообщение доставленоmessage.delivered
Шлюз подтвердил доставку сообщения получателю. Приходит по данным площадки доставки, поэтому может отставать от факта отправки на несколько минут.
{
"event": "message.delivered",
"createdAt": "2026-08-30T18:20:11.000Z",
"organizationId": "clx2222222222222222222222",
"data": {
"cascadeId": "clx0000000000000000000000",
"externalId": "order-10423",
"recipient": {
"name": "Анна",
"email": "anna@example.com",
"phone": "79123456789"
},
"channel": "EMAIL",
"providerMessageId": "184623901",
"at": "2026-08-29T09:00:42.000Z"
}
}Сообщение не доставленоmessage.failed
Шаг каскада не ушёл или был отбит получателем/шлюзом. Это НЕ конец каскада: следующий шаг по другому каналу всё ещё может сработать — окончание смотрите по cascade.recipient.finished.
{
"event": "message.failed",
"createdAt": "2026-08-30T18:20:11.000Z",
"organizationId": "clx2222222222222222222222",
"data": {
"cascadeId": "clx0000000000000000000000",
"externalId": "order-10423",
"recipient": {
"name": "Анна",
"email": "anna@example.com",
"phone": "79123456789"
},
"channel": "EMAIL",
"reason": "BOUNCED",
"error": "mailbox not found",
"at": "2026-08-29T09:01:03.000Z"
}
}