Перейти к содержанию
Версия контракта: 0.3.3 — спека

Вебхуки

Страница для интегратора: как подписаться на события, как проверить подпись и что обязан уметь приёмник.

Подписка

Подписка заводится вашим ключом: POST /v1/webhook_endpoints с адресом и, если нужно, перечнем типов событий. В ответ приходит секрет подписи — он показывается один раз. Повторить показ невозможно: в базе лежит только зашифрованное значение, и «покажите ещё раз» решается ротацией.

Требования к адресу проверяются дважды: при записи подписки (отказ — 422 webhook_url_invalid с param: url и причиной в message) и снова на каждом соединении при доставке — имя могло начать разрешаться в другой адрес:

  • только https — иначе секрет и тело события уедут открытым текстом;
  • без учётных данных в адресе (https://user:pass@…) и не длиннее 1024 символов;
  • публичный адрес: имена, разрешающиеся в частные и служебные сети, отвергаются — 10/8, 172.16/12, 192.168/16, 100.64/10, 198.18/15, 169.254/16, localhost, multicast и незаданный адрес, а для IPv6 — частные и link-local. Хаб не должен становиться способом сходить туда, куда чужому коду хода нет;
  • переадресации не выполняются: ответ 3xx считается отказом доставки. Переехавший приёмник — это новый адрес подписки, а не редирект со старого.

Ручки подписки: GET /v1/webhook_endpoints (список), GET|DELETE /v1/webhook_endpoints/{id}, POST …/rotate_secret, POST …/test (пробная доставка), POST …/enable (включить обратно), GET …/deliveries (журнал попыток).

Подпись

Заголовок:

Hub-Signature: t=<unix_ts>,v1=<hex(hmac_sha256(secret, t + "." + body))>

body — сырые байты тела, до какого-либо разбора. Пересобирать тело из разобранного JSON нельзя: порядок ключей и пробелы не обещаны, и подпись перестанет сходиться.

Окно допуска — ±5 минут. Подпись с временем вне окна обязана отвергаться: без этого перехваченный вчерашний батч принимается сегодня как новый.

В период ротации заголовок несёт две подписи через запятую (v1=…,v1=…) — достаточно совпадения любой. Так старый секрет продолжает работать, пока вы меняете его у себя.

function verify(string $header, string $body, string $secret): bool {
    if (!preg_match('/t=(\d+)/', $header, $m)) {
        return false;
    }
    $ts = (int) $m[1];
    if (abs(time() - $ts) > 300) {
        return false; // окно допуска: повтор старого вебхука
    }
    $expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
    foreach (explode(',', $header) as $part) {
        if (str_starts_with(trim($part), 'v1=')
            && hash_equals($expected, substr(trim($part), 3))) {
            return true;
        }
    }
    return false;
}

Сравнивать подписи только hash_equals (или аналогом с постоянным временем): обычное === подсказывает подбирающему, где он ошибся.

Что приезжает

Тело — всегда массив событий, даже когда событие одно. Приёмник, написанный под одиночный объект, сломается на первом же батче. В батче до 100 событий; батч закрывается размером или секундой ожидания — что наступит раньше.

Заголовок Hub-Event-Id — идентификатор батча (evb_…). Он стабилен между повторами: по нему батч отбрасывается целиком, не разбирая тело. Внутри батча дедупликация — по event.id (evt_…).

Порядок не гарантируется. message.delivered может опередить message.sent: батчи уходят параллельно и повторяются независимо. Опираться следует на created_at и статусную модель, а не на очерёдность приезда.

Доставка at-least-once: одно и то же событие может приехать дважды, в том числе в разных батчах. Идемпотентность по event.id обязательна.

Что обязан уметь приёмник

  • ответить 2xx за 10 секунд. Любой другой код, таймаут или обрыв — батч считается недоставленным;
  • обрабатывать асинхронно: разбирать батч в фоне, а отвечать сразу. Тело ответа не читается — отвечать телом бессмысленно, кодом обязательно;
  • переживать повторы: см. дедупликацию выше.

Повторы и отключение

Недоставленный батч повторяется с нарастающим интервалом (от минуты до часа между попытками) до 24 часов от постановки батча в доставку (для событий это почти их момент: батч собирается за секунду). Каждая попытка видна в GET /v1/webhook_endpoints/{id}/deliveries вместе с кодом ответа, длительностью и временем следующей попытки.

Интервал не заставляет ждать ожившего приёмника: как только очередной батч доставлен после серии отказов, остальные недоставленные батчи этой подписки уходят сразу, а не в назначенное им время. Приёмник, поднявшийся после простоя, получает накопленное без ожидания — порциями и в порядке событий.

Подписка отключается автоматикой (статус disabled_by_failures), когда приёмник лежит и долго, и подряд: не меньше 20 отказов подряд и не меньше часа от первого из них. Короткий простой — выкат, перезапуск, минутная авария — покрывается повторами, и включать ничего не нужно. Об отключении приходит событие webhook.endpoint_disabled — на остальные подписки аккаунта (в отключённую его слать незачем); оно же лежит в GET /v1/events.

Что происходит с событиями, пока подписка отключена

Они не теряются и не пропускаются. Батч, на котором подписка отключилась, и все события, случившиеся после, копятся у хаба и ждут включения. На отключённый адрес хаб при этом не ходит.

Включение — вручную, после того как приёмник починен:

POST /v1/webhook_endpoints/{id}/enable

Сразу после включения хаб доставляет накопленное: сначала батч, на котором подписка отключилась, затем всё, что случилось за время простоя, — теми же батчами и с теми же Hub-Event-Id, что и при обычных повторах. Приёмник должен быть готов принять поток за весь простой; не справляется — отвечайте отказом, батчи вернутся в повторы, а не пропадут.

Окно — те же 24 часа от постановки батча в доставку. Событие, пролежавшее у отключённой подписки дольше, хаб больше не доставляет: в журнале попыток у его батча появляется строка со статусом failed, пустым next_attempt_at и причиной «окно повторов истекло, пока подписка была отключена». Такие события — и вообще всё, что вы не получили, — забираются из журнала:

GET /v1/events?starting_after=evt_…

где evt_… — последнее событие, которое вы точно обработали. Журнал отдаёт события по порядку записи и не пропускает ни одного; дубли с тем, что успело приехать вебхуком, отбрасываются по event.id, как и при повторах. Если событий много и догонять неудобно — попросите оператора хаба повторить доставку за период.

Ротация секрета

POST /v1/webhook_endpoints/{id}/rotate_secret выдаёт новый секрет (снова один раз) и на переходный период — сутки по умолчанию — подписывает обоими. Порядок безопасной ротации: получить новый секрет → научить приёмник принимать оба → дождаться конца переходного периода → убрать старый.