Как надёжно доставлять 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 возвращается ровно один раз — его надо сохранить у себя, повторно платформа его не покажет:
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 с выгодными ему данными:
{
"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):
// 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
use Botix\SDK\Webhook;
if (!Webhook::verify($secret, $rawBody, $signatureHeader)) {
http_response_code(401);
exit;
}# Python
from botix import Webhook
if not Webhook.verify(secret, raw_body, signature_header):
return Response(status=401)// 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 часов:
| Попытка | Задержка от первой | Покрывает |
|---|---|---|
| 1 | 0 (сразу) | основная попытка |
| 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 Requests | retry с 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:
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. Это детерминированный вход для интеграционных тестов.
В связке:
- Создаёте bin на
webhook.botix.pro, получаете структуру реального события — headers и подпись. - Пишете код обработчика и проверяете локально, отправляя тот же payload на localhost через curl.
- Когда обработчик готов — шлёте тестовый webhook через delivery dashboard и убеждаетесь, что вся цепочка от платформы до приёмника отрабатывает.
Главная ценность — снижение порога входа: каждый шаг разработчика запускается независимо. Можно проверять подписи, не подключая канал; писать обработчик, не имея источника; гонять интеграционные тесты, не дёргая внешние сервисы. Payload приходит через 30 секунд после старта, а не через несколько часов чужих действий.
Что нельзя делать
- Не полагаться на порядок (sequence). Параллельные доставки могут прийти не в порядке событий — клиент опирается на
timestampв теле, а не на порядок получения. - Не делать «бесконечные ретраи». Размер очереди становится непредсказуемым (один долго лежащий клиент оставит в БД миллионы записей), а запоздалый webhook «заказ принят» через три дня приносит вред, не пользу. Окно конечно — что не доставилось за сутки, разбирается вручную.
- Не слать payload больше 64 КБ. Если данных больше — отправляется только
id, клиент забирает объект через API. Это защищает обе стороны от перегруза. - Тип события — обязательное поле. Без
event(напримерcontact.created,payment.succeeded,subscription.cancelled) клиент не может маршрутизировать. Полный список — в документации.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Безопасный webhook-обработчик за 30 минут: HMAC, timestamp и идемпотентность
Webhook, polling или симулятор: какой инструмент на каком этапе разработки
Тест webhook-интеграции: симулятор, polling и backoff
Multi-tenant, polling vs webhook и OAuth для агентства
HMAC-подписи, X-RateLimit и защита от replay: три уровня защиты API и edge cases из прода
