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

Вебхуки

Evidra отправляет событие на ваш адрес подписанным запросом. Опрос 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 секунд. Тяжёлую работу поставьте в очередь после ответа. Другой код или молчание считаются неудачей. Evidra повторит доставку ещё 5 раз. Пауза растёт: через минуту, 5 минут, полчаса, 2 часа, 6 часов, сутки.

После 10 неудачных доставок адрес выключается. Причина видна в кабинете. Включите адрес после починки. Пропущенные события повторно не отправляются.

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

Адрес публичный. Без проверки подписи на него может прийти запрос не от Evidra. Заголовок 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"
  }
}