Документация

Вебхуки

Мы сами постучимся на ваш адрес, когда что-то произойдёт, — опрашивать API по расписанию не нужно. Каждый запрос подписан, поэтому приёмник может убедиться, что событие пришло от нас.

Подключение

Адрес приёмника задаётся в кабинете: Разработчикам → Вебхуки. При добавлении выбираются события и выдаётся секрет подписи — он показывается один раз, как и ключ API. На организацию можно завести до 5 адресов, например прод и тест.

Требования к адресу: только https и публичный хост. На localhost и адреса внутренних сетей мы не ходим — для локальной отладки используйте туннель вида ngrok.

Как выглядит запрос

POST с телом application/json. Конверт одинаковый для всех событий, различается только data:

application/json
{
  "event": "cascade.recipient.finished",
  "createdAt": "2026-08-30T18:20:11.000Z",
  "organizationId": "clx…",
  "data": { … }
}

Заголовки, которые приходят с каждым событием:

ЗаголовокНазначение
X-Evidra-EventКлюч события — тот же, что в теле.
X-Evidra-DeliveryИдентификатор доставки. Повторы приходят с тем же значением — используйте для дедупликации.
X-Evidra-TimestampUnix-время подписи в секундах. Входит в подписываемую строку.
X-Evidra-SignatureПодпись v1=<HMAC-SHA256>, см. ниже.

Ответ и повторы

Отвечайте любым кодом 2xx в течение 10 секунд. Сначала ответьте, потом обрабатывайте: тяжёлую работу — в очередь или фон. Любой другой код или молчание считается неудачей, и мы повторим доставку ещё 5 раз с растущей паузой — через минуту, 5 минут, полчаса, 2 часа, 6 часов, сутки.

Если 10 доставок подряд исчерпали все повторы, эндпоинт выключается автоматически — вы увидите это в кабинете вместе с последней ошибкой. После починки включите его обратно; события, накопившиеся за время простоя, повторно не отправляются.

Проверка подписи

Адрес приёмника публичный, и без проверки в него сможет написать кто угодно. Заголовок X-Evidra-Signature содержит v1=<hex>, где hex — HMAC-SHA256 от строки {X-Evidra-Timestamp}.{сырое тело запроса} на секрете эндпоинта.

Подписывается сырое тело — байты ровно в том виде, в каком они пришли. Разобранный и заново сериализованный JSON даст другие байты, и подпись не сойдётся. Во всех примерах ниже это главное место.
Node.js
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 — не нашлось пригодного контакта.

application/json
{
  "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

Шлюз подтвердил доставку сообщения получателю. Приходит по данным площадки доставки, поэтому может отставать от факта отправки на несколько минут.

application/json
{
  "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.

application/json
{
  "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"
  }
}