Вебхуки¶
Страница для интегратора: как подписаться на события, как проверить подпись и что обязан уметь приёмник.
Подписка¶
Подписка заводится вашим ключом: 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 выдаёт новый секрет (снова один
раз) и на переходный период — сутки по умолчанию — подписывает обоими.
Порядок безопасной ротации: получить новый секрет → научить приёмник принимать
оба → дождаться конца переходного периода → убрать старый.