Попробовать API
</>
Как надёжно доставлять webhook: подпись, retry, симулятор
webhooksРазбор
14 мин

Как надёжно доставлять webhook: подпись, retry, симулятор

TL;DR: webhook — это не «POST на URL клиента», а подсистема со своими требованиями к надёжности. HMAC-подпись с timing-safe сравнением закрывает phishing, retry с шагом 1m/15m/1h/24h и dead-letter закрывают падения приёмника, идемпотентность на приёме закрывает неизбежные дубли, а webhook-симулятор ловит первый payload до подключения боевого канала. Разбираю доставку BOTIX целиком — по слоям, с кодом.

Коротко

  • Подпись (тезис 09). URL приёмника публичен, поэтому webhook без HMAC-SHA256 — открытая дверь: любой шлёт payment.succeeded и запускает бизнес-логику. Подпись закрывает дверь, а timing-safe сравнение (timingSafeEqual / hash_equals / compare_digest) — замок на ней. Сверять только по raw body.
  • Доставка (тезис 10). Retry живёт в двух местах — на отправителе (платформа повторяет при ≠2xx/timeout) и на приёмнике (клиент кладёт в свою очередь). Экспоненциальный backoff 1m/15m/1h/24h, окно ~25 часов, дальше — dead-letter с ручным повтором. At-least-once → дубли неизбежны → идемпотентность на приёме через event_id обязательна.
  • Отладка (тезис 11). Обработчик не привязан к каналу — ему нужен корректный HTTP-запрос, а не «подключённый бот». Webhook-инспектор webhook.botix.pro отдаёт payload через 30 секунд, кнопка «Тестовая отправка» в дашборде замыкает цикл. Каждый шаг отладки — независим.

Webhook-подсистема — это не один эндпоинт, а пайплайн из четырёх частей. Каждую часть видно по-отдельности, но работают они только в связке. Ниже — три тезиса (подпись, доставка, отладка), каждый разобран отдельным разделом с кодом.

Архитектура пайплайна: что происходит между событием и приёмником

   [ Событие в системе ]
         |
         v
  | Сериализация payload |    (event_id, type, timestamp, data) + X-BOTIX-*
         |
         v
  | HMAC-SHA256 (raw body) |   secret из btx_webhook_endpoints
         |
         v
  | Очередь delivery |         btx_api_webhook_deliveries (next_attempt_at)
         |
         v   <----- worker (cron + redis BRPOP)
  | HTTP POST на URL клиента | timeout connect=10s, read=30s
         |
   2xx OK  /  timeout, 5xx, 429
     |              |
  delivered   retry: +1m, +15m, +1h, +24h
                    |
              dead_letter (4xx кроме 429 или исчерпание)

Подписка на webhook заводится через публичный API. При создании секрет для HMAC возвращается ровно один раз — его надо сохранить у себя, повторно платформа его не покажет:

bash
curl -X POST 'https://api.botix.pro/public/v1/webhooks' \
  -H 'Authorization: Bearer XXXXXXXX…' \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "url": "https://example.com/botix-webhook",
    "events": ["message.received", "chat.created"]
  }'
# → 201 Created; data.secret показывается ОДИН РАЗ — сохраните его

Дальше события этих типов уходят POST-ом на ваш url, подписанные этим секретом. Разберём три слоя, которые делают эту доставку надёжной.

Тезис 1. HMAC-подпись: без неё endpoint — открытая дверь для phishing

URL webhook-эндпоинта публичен по определению — он должен быть доступен из интернета, чтобы платформа могла достучаться. Значит, отправить POST может любой, кто узнал адрес. А адрес утекает буднично: попадает в .env.example в открытом репозитории, в логи реверс-прокси, которые случайно проиндексировались, через подрядчика, или через перебор поддоменов (webhook.example.com/payment угадывается).

После этого злоумышленник формирует фальшивый webhook с выгодными ему данными:

json
{
  "event": "payment.succeeded",
  "amount": 100000,
  "currency": "RUB",
  "order_id": "ORD-42",
  "customer_email": "victim@example.com"
}

Сервер клиента принимает запрос, видит «нужное» событие, запускает бизнес-логику: помечает заказ оплаченным, отгружает товар, начисляет бонусы. Денег не приходило. Сценарий реалистичен для любого endpoint, который не проверяет подпись.

HMAC закрывает эту дверь. Платформа и клиент имеют общий секрет (тот самый, что показался один раз при создании подписки), и для каждого webhook платформа считает:

canonical_payload = timestamp + "." + raw_body
signature = HMAC-SHA256(secret, canonical_payload)

Злоумышленник не знает секрет и не может вычислить правильную подпись для своего фейкового тела. Проверка отвалит его до того, как бизнес-логика запустится.

Полный набор заголовков доставки:

X-BOTIX-Signature: 7d4a8c9f...
X-BOTIX-Timestamp: 1748960000
X-Event-Id: evt_01HQRX...
X-Event-Type: payment.succeeded
X-Delivery-Attempt: 2
X-Request-Id: req_01HQRX...

X-Delivery-Attempt сразу показывает клиенту, что это ретрай, а не первая попытка.

Проверка на стороне клиента: timing-safe сравнение

Стандартная ошибка — сравнивать подпись оператором === (или == в Python). Он сравнивает байт за байтом и останавливается на первом несовпадении: если первые байты совпали, цикл длится дольше; несовпадение сразу — короче. Разница в наносекундах, но злоумышленник шлёт тысячи запросов с разными подписями, измеряет время ответа и постепенно подбирает каждый байт правильной подписи, начиная с первого. Атака медленная, но рабочая на длинных горизонтах.

Правильно — сравнение в постоянном времени: функция всегда делает одинаковое число шагов независимо от того, где нашлось несовпадение. Код из Node-SDK (@botix/sdk v1.1.0):

typescript
// github.com/BOTIX-pro/sdk-node/blob/v1.1.0/src/webhook/verifier.ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(params) {
  const tolerance = params.toleranceSec ?? 300; // ±5 минут
  const now = Math.floor(Date.now() / 1000);
  const ts = parseInt(params.timestamp, 10);
  if (Number.isNaN(ts) || Math.abs(now - ts) > tolerance) return false;

  const body = typeof params.payload === 'string' ? params.payload : params.payload.toString('utf8');
  const canonical = `${params.timestamp}.${body}`;
  const expected = createHmac('sha256', params.secret).update(canonical).digest('hex');

  const expectedBuf = Buffer.from(expected, 'hex');
  const receivedBuf = Buffer.from(params.signature, 'hex');
  if (expectedBuf.length !== receivedBuf.length) return false;
  return timingSafeEqual(expectedBuf, receivedBuf);
}

В Express-обработчике критично получить raw body, не JSON: express.raw({ type: 'application/json' }). Если перепарсить body и сериализовать обратно, порядок ключей меняется, подпись не сойдётся — стандартная грабля при переходе с polling на webhook. Аналоги получения raw body: в Laravel — $request->getContent(), в Flask — request.get_data().

Готовый verifyWebhook в SDK — чтобы не помнить про timing attack

Разработчик, который пишет проверку с нуля, легко пропускает timing-safe сравнение. Поэтому во всех трёх официальных SDK BOTIX проверка инкапсулирована в helper, а внутри — сравнение в постоянном времени:

php
// PHP
use Botix\SDK\Webhook;

if (!Webhook::verify($secret, $rawBody, $signatureHeader)) {
    http_response_code(401);
    exit;
}
python
# Python
from botix import Webhook

if not Webhook.verify(secret, raw_body, signature_header):
    return Response(status=401)
javascript
// Node
const { Webhook } = require('@botix/sdk');

if (!Webhook.verify(secret, rawBody, signatureHeader)) {
  return res.status(401).end();
}

Если SDK у вас нет — проверьте, что в коде проверки стоит hash_equals (PHP) / hmac.compare_digest (Python) / crypto.timingSafeEqual (Node) / hmac.Equal (Go), а не обычный ==. Это одна строка, которая отличает работающую защиту от иллюзии защиты.

Ротация секрета: окно, а не резкая замена

Если секрет утёк, его нужно сменить. Но если просто заменить старый на новый — какое-то время (пока клиент не обновился) будут приходить webhook со старой подписью, они отклонятся, события пропадут. Правильный механизм — окно ротации: платформа держит одновременно два активных секрета, подписывает новым, а проверка на приёме принимает оба.

В BOTIX при перевыпуске старый секрет сохраняется в статусе revoked_at = NOW() + INTERVAL 24 HOUR. В этом окне клиент проверяет подпись и старым, и новым, по истечении 24 часов старый удаляется физически. Это даёт время на спокойное обновление.

Чего HMAC не делает

Полезно знать границы — HMAC не решает три задачи, которые с ним путают:

  • Не предотвращает replay. Перехваченный настоящий webhook можно переслать ещё раз, подпись останется валидной. Защита от replay — уникальный event_id + хранение обработанных id (см. слой идемпотентности ниже) либо timestamp в подписи с проверкой свежести (toleranceSec в коде выше — как раз она).
  • Не шифрует тело. HMAC подтверждает целостность и авторство, но тело передаётся в открытом виде; для большинства случаев достаточно HTTPS.
  • Не подтверждает доставку. Корректно подписанный webhook может не дойти из-за сетевого сбоя — это отдельная задача (retry, очереди, dead-letter, следующий тезис).

Тезис 2. Retry на стороне сервера: 1m/15m/1h/24h и dead-letter

Между «отправляет» и «принимает» лежит длинный список отказов: сервер клиента упал, выкатился релиз и endpoint временно недоступен, истёк сертификат, реверс-прокси отдал 503, DNS не разрешается, connect timeout. Как платформа ведёт себя в каждом из этих случаев — и определяет, надёжна ли интеграция.

Где должна жить retry-логика

Retry нужен в двух местах, и одно не заменяет другое:

  • На отправителе (платформа): сохраняет каждый webhook в БД до подтверждения и повторяет при ответе ≠2xx или timeout. Это покрывает класс отказов «я физически не получил запрос».
  • На приёмнике (клиент): принял webhook, сразу ответил 200, положил событие в свою внутреннюю очередь и обрабатывает асинхронно с собственными ретраями. Это правильно для финальной обработки.

Если клиент вообще не ответил 200 (timeout, упавший сервер, 5xx) — webhook просто не попал в его внутреннюю очередь, и ретраить ему нечего. Поэтому платформа гарантирует доставку до 2xx, а после — ответственность переходит к клиенту.

Интервалы: экспоненциальный backoff с конечным окном

Слишком частые ретраи — атака на свой же лежащий endpoint: если он упал под нагрузкой, ещё пятьсот попыток в секунду его не поднимут. Слишком редкие — потерянные часы. Баланс — четыре ступени с окном ~25 часов:

ПопыткаЗадержка от первойПокрывает
10 (сразу)основная попытка
2+1 минутавременный сбой сети / перегрузка
3+15 минуткороткий простой / рестарт
4+1 чассредний инцидент / выкатка релиза
5+24 часадлительный простой / выходные

Мгновенный сбой (5xx при релоаде nginx) закроется в попытке №2, короткий релиз — в №3, часовой инцидент — в №4, длительная авария — в №5 или уйдёт в dead-letter. Общего окна ~25 часов хватает, чтобы дежурный заметил, починил и сервер начал принимать. Дольше держать webhook в очереди бессмысленно — событие за пределами суток теряет актуальность.

Что считается успехом, а что ретраится

ОтветДействие
2xx (200, 201, 202, 204)success — больше не ретраим
Connect timeout (10 сек) / Read timeout (30 сек)retry
5xx (500, 502, 503, 504)retry
429 Too Many Requestsretry с Retry-After из заголовка
4xx кроме 429 (400, 401, 403, 404)сразу dead_letter, не ретраим

Последняя строка важна: если endpoint возвращает 401 (клиент сменил токен авторизации webhook и не обновил у себя) — пинговать дальше бессмысленно, клиент сам сказал «не приму». Лучше сразу зафиксировать в dead-letter и дать владельцу увидеть проблему в дашборде. При 429 следующая попытка берётся не из таблицы, а из Retry-After.

Dead-letter и дашборд: чтобы события не терялись молча

Dead-letter — конечная точка для недоставленных webhook. Без неё события просто теряются, и о факте потери никто не узнаёт. Каждая попытка логируется в btx_api_webhook_deliveries: целевой URL, статус (pending / delivered / retrying / dead_letter), response_code, next_attempt_at, номер попытки, флаг is_test. После исчерпания ретраев или 4xx создаётся запись dead_letter.

В кабинете на app.botix.pro/developers есть раздел Webhook Delivery — это и есть дашборд:

  • Список всех попыток за последние 14 дней с фильтром по статусу.
  • Каждая запись разворачивается: целевой URL, payload, HTTP-код ответа, тело ответа, request_id, время.
  • «Повторить отправку» — шлёт тот же webhook повторно с новым request_id (когда сервер починили и нужно догнать пропущенные события).
  • «Перенаправить» — шлёт webhook на временный тестовый URL (например, на инспектор webhook.botix.pro), чтобы посмотреть тело в безопасном окружении, не дёргая прод.

Без дашборда dead-letter — просто строки в БД, которые никто не смотрит. С дашбордом — рабочий инструмент для разбора инцидентов.

Идемпотентность на приёме: at-least-once — это не баг

Ретраи неизбежно приводят к доставке одного webhook несколько раз (клиент обработал событие, начал отвечать 200, но connection порвался до того, как платформа получила ответ — платформа считает доставку неуспешной и ретраит). At-least-once delivery — стандарт для webhook, защита от дублей лежит на стороне клиента через event_id:

sql
INSERT INTO processed_events (event_id, processed_at)
VALUES (?, NOW())
ON DUPLICATE KEY UPDATE event_id = event_id;

Вставил строку — событие новое, обрабатываем. Ничего не вставилось (конфликт по event_id) — дубль, игнорируем. Тот же подход защищает и от случая, когда два воркера платформы одновременно подхватили задачу из очереди.

Тезис 3. Webhook-симулятор: тестовый payload до подключения канала

Типичный сценарий: разработчик пишет обработчик, который должен валидировать подпись, разбирать JSON, класть событие в очередь и отвечать 200 за допустимое время. Чтобы это проверить, нужен реальный входящий запрос — и тут начинается лишняя работа: завести аккаунт у внешнего сервиса, получить токен, настроить бота, попросить кого-то отправить сообщение, дождаться доставки. Часы вместо минут.

При этом обработчик к каналу не привязан. Ему нужен лишь корректно сформированный HTTP-запрос с теми же headers, тем же payload и той же подписью, что отправил бы прод. Никакого состояния «канал подключён» внутри обработчика нет — значит, весь блок подключения канала на этапе разработки лишний.

Что делает инспектор

Инспектор выдаёт временный публичный URL и показывает всё, что в него приходит: метод, путь, query, headers, body, время. У BOTIX он сделан как отдельный поддомен webhook.botix.pro: «Create new URL» → https://webhook.botix.pro/r/8f3a1c9b, TTL по умолчанию 7 дней (потом bin и запросы удаляются автоматически). Подставляете этот URL в настройки источника, источник стучится — инспектор фиксирует request один в один. Дальше можно изучить структуру в браузере или скопировать как curl и воспроизводить на localhost сколько угодно.

Изоляция от платформы — намеренная

Архитектурно это маленький сервис: один nginx server-блок, два эндпоинта (создание bin и приём входящего), две таблицы (bins и bin_requests с CASCADE на удаление). Никакой связи с основной платформой по бизнес-логике — bin не знает, к какому проекту привязан, и не требует авторизации. Сделано намеренно, чтобы инспектор оставался изолированным песочничным инструментом и его доступность не зависела от состояния остальной платформы.

Плюс — работа внутри отечественной инфраструктуры: для компаний, у которых в требованиях запрет на передачу любых тестовых данных за границу, внешний инспектор (типа webhook.site) означает выпуск трафика наружу, а свой — остаётся внутри.

Тестовая отправка замыкает цикл отладки

Инспектор закрывает первую половину задачи — увидеть структуру запроса. Вторую половину (отладку самого кода обработчика) закрывает mock-режим: на app.botix.pro/developers в разделе Webhook Delivery есть кнопка «Тестовая отправка» (эндпоинт POST /public/v1/webhooks/{id}/test), которая шлёт payload известной структуры на указанный URL. Это детерминированный вход для интеграционных тестов.

В связке:

  1. Создаёте bin на webhook.botix.pro, получаете структуру реального события — headers и подпись.
  2. Пишете код обработчика и проверяете локально, отправляя тот же payload на localhost через curl.
  3. Когда обработчик готов — шлёте тестовый webhook через delivery dashboard и убеждаетесь, что вся цепочка от платформы до приёмника отрабатывает.

Главная ценность — снижение порога входа: каждый шаг разработчика запускается независимо. Можно проверять подписи, не подключая канал; писать обработчик, не имея источника; гонять интеграционные тесты, не дёргая внешние сервисы. Payload приходит через 30 секунд после старта, а не через несколько часов чужих действий.

Что нельзя делать

  • Не полагаться на порядок (sequence). Параллельные доставки могут прийти не в порядке событий — клиент опирается на timestamp в теле, а не на порядок получения.
  • Не делать «бесконечные ретраи». Размер очереди становится непредсказуемым (один долго лежащий клиент оставит в БД миллионы записей), а запоздалый webhook «заказ принят» через три дня приносит вред, не пользу. Окно конечно — что не доставилось за сутки, разбирается вручную.
  • Не слать payload больше 64 КБ. Если данных больше — отправляется только id, клиент забирает объект через API. Это защищает обе стороны от перегруза.
  • Тип события — обязательное поле. Без event (например contact.created, payment.succeeded, subscription.cancelled) клиент не может маршрутизировать. Полный список — в документации.