Webhook в проде: HMAC, симулятор и выбор с polling
TL;DR: приёмник webhook — это три независимые задачи, которые обычно сваливают в одну и на этом застревают. Нужно закрыть дверь (HMAC-подпись + сравнение в постоянном времени), увидеть реальный payload без подключения канала (webhook-инспектор `webhook.botix.pro`) и осознанно выбрать между polling и webhook, а не уйти в polling «временно» навсегда. Разбираю все три на реальном API BOTIX: код, эндпоинты, таблицы, retry-расписание.
Коротко
- Webhook без HMAC-подписи — открытая дверь. URL публичный, любой может прислать
payment.succeeded. Закрывает HMAC-SHA256 от raw body + сравнение в постоянном времени (hash_equals/compare_digest/timingSafeEqual), а не==— иначе timing attack. - Симулятор снимает барьер к тестам.
webhook.botix.proдаёт временный URL и показывает реальный запрос за 30 секунд,POST /public/v1/webhooks/{id}/testшлёт валидный payload на ваш обработчик. Канал-источник подключать не нужно. - Polling vs webhook — не вкус, а условия. Есть публичный endpoint и нужна задержка <5 с — webhook. Cron раз в час или локалка без туннеля — polling через
cursor. В проде под нагрузкой polling ломается первым об rate-limit.
Почему приёмник webhook — это три отдельные задачи
Типовой сценарий разработки webhook-обработчика:
- Прочитать документацию платформы. Понять формат payload, headers, подписи.
- Написать обработчик: проверка HMAC, разбор JSON, постановка в очередь, ответ 200.
- Проверить, что обработчик работает.
Третий шаг — место, где ломается половина команд. Чтобы прислать в обработчик реальный webhook, нужно завести аккаунт, получить токен, подключить канал-источник, настроить webhook на URL обработчика, попросить кого-то отправить сообщение, дождаться доставки. Часы вместо минут. Локальная разработка усложняется тем, что обработчик работает на localhost, а платформа не может стукнуться на 127.0.0.1.
В итоге команда либо тратит лишнее время на каждый тест, либо переключается на polling «временно, чтобы быстрее запуститься», и polling остаётся в production навсегда. Дальше — три задачи, каждая со своим решением: закрыть дверь подписью (тезис 1), увидеть payload без канала (тезис 2), выбрать транспорт под нагрузку (тезис 3).
Тезис 1. Webhook без HMAC-подписи — открытая дверь
Когда платформа отправляет webhook о событии — оплата прошла, контакт создан, заказ изменил статус — это обычный HTTP POST на URL, который указал клиент. На стороне клиента запрос принимает endpoint, вызывающий бизнес-логику: записывает оплату в БД, отправляет SMS, обновляет статус заказа, начисляет бонусы. Вопрос, который часто игнорируют на старте: как клиентский сервер убеждается, что запрос пришёл от платформы, а не от того, кто узнал URL?
Что идёт не так без подписи. URL webhook-endpoint'а публичный по определению — он должен быть доступен из интернета. Значит, отправить POST может любой, кто знает адрес. А адрес утекает: через .env.example в открытом репозитории, через логи реверс-прокси, через подрядчика, через перебор поддоменов (webhook.example.com/payment угадать несложно). После этого злоумышленник формирует фальшивый webhook с выгодными ему данными:
{
"event": "payment.succeeded",
"amount": 100000,
"currency": "RUB",
"order_id": "ORD-42",
"customer_email": "victim@example.com"
}Шлёт POST на endpoint. Сервер видит «нужное» событие, запускает логику: помечает заказ оплаченным, отгружает товар, начисляет бонусы. Через час злоумышленник получает товар, денег не приходило. Сценарий реалистичен для любого endpoint'а, который не проверяет подпись.
Что делает HMAC. HMAC (Hash-based Message Authentication Code) доказывает, что отправитель знает общий секрет, не передавая сам секрет в запросе. Платформа и клиент имеют общий секрет (выдаётся один раз при создании подписки). Для каждого webhook'а платформа вычисляет:
signature = HMAC-SHA256(secret, raw_request_body)и кладёт результат в заголовок X-BOTIX-Signature. Клиент повторяет ту же операцию с тем же секретом и тем же телом. Совпало — запрос от платформы; не совпало — подделка, отклоняем. Злоумышленник не знает секрет и не может вычислить правильную подпись для своего тела — проверка отвалит его до запуска бизнес-логики.
Timing-safe проверка: Verifier и unit-тесты
Уязвимость, которую часто не замечают. Видя описание HMAC, разработчик пишет такой код:
# АНТИПАТТЕРН — не делайте так.
expected = hmac.new(secret, body, sha256).hexdigest()
received = request.headers["X-BOTIX-Signature"]
if expected == received:
process_event(body)На первый взгляд работает: правильная подпись проходит, неправильная отклоняется. Но здесь есть дверь к timing attack. Оператор == в Python (и аналогичный в большинстве языков) сравнивает строки байт за байтом и останавливается на первом несовпадении. Совпали первые байты — цикл дольше; несовпадение сразу — короче. Разница в наносекундах. Но злоумышленник, отправляя тысячи запросов с разными подписями и измеряя время ответа, подбирает каждый байт правильной подписи. Атака медленная, но рабочая.
Правильный код использует сравнение в постоянном времени — функцию, которая всегда работает одинаковое число шагов независимо от того, где нашлось несовпадение:
import hmac
expected = hmac.new(secret, body, sha256).hexdigest()
received = request.headers["X-BOTIX-Signature"]
if hmac.compare_digest(expected, received):
process_event(body)Аналоги:
- PHP —
hash_equals($expected, $received) - Node.js —
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)) - Go —
hmac.Equal(expected, received)
Verifier в SDK. Разработчик, пишущий проверку с нуля, легко пропустит этот момент. Поэтому проверка должна быть инкапсулирована в SDK. Verifier во всех трёх SDK BOTIX — 12 строк:
// github.com/BOTIX-pro/sdk-php/blob/v1.1.0/src/Webhook/Verifier.php
namespace Botix\SDK\Webhook;
final class Verifier
{
/**
* Проверка HMAC-SHA256 в постоянном времени.
* $payload — raw bytes тела запроса, без перепарсинга.
*/
public static function verify(
string $payload,
string $signatureHeader,
string $secret
): bool {
$expected = hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signatureHeader);
}
}Использование на стороне обработчика — три строки:
use Botix\SDK\Webhook\Verifier;
$rawBody = $request->getContent(); // СЫРЫЕ байты, не парсим заранее
$signature = $request->header('X-BOTIX-Signature');
if (!Verifier::verify($rawBody, $signature, $secret)) {
return response()->json(['error' => 'invalid_signature'], 401);
}Аналоги в Python и Node:
# github.com/BOTIX-pro/sdk-python/blob/v1.1.0/botix/webhook.py
from botix.webhook import Webhook
if not Webhook.verify(secret, raw_body, signature_header):
return Response(status=401)// github.com/BOTIX-pro/sdk-node/blob/v1.1.0/src/webhook.ts
import { Webhook } from '@botix/sdk';
if (!Webhook.verify(secret, rawBody, signatureHeader)) {
return res.status(401).end();
}Важная деталь: helper принимает сырой body запроса (raw bytes), а не распаршенный JSON. Если перепарсить body и сериализовать обратно, порядок ключей и форматирование могут измениться, и подпись не сойдётся. На уровне фреймворка нужно специально получить raw body — в Express это app.use(bodyParser.raw({type: 'application/json'})), в Laravel — $request->getContent(), в Flask — request.get_data().
Unit-тесты на Verifier. Verifier — чистая функция. Её можно тестировать без сети, без БД, без mock'ов. Полный набор на Python:
# tests/test_webhook_verifier.py — pytest, без I/O
import hmac
from hashlib import sha256
from botix.webhook import Webhook
SECRET = "XXXXXXXX"
PAYLOAD = b'{"event":"contact.created","id":42}'
def _sign(payload: bytes, secret: str) -> str:
return hmac.new(secret.encode(), payload, sha256).hexdigest()
def test_valid_signature_passes():
signature = _sign(PAYLOAD, SECRET)
assert Webhook.verify(SECRET, PAYLOAD, signature) is True
def test_wrong_signature_fails():
signature = _sign(PAYLOAD, "different_secret")
assert Webhook.verify(SECRET, PAYLOAD, signature) is False
def test_tampered_payload_fails():
"""Подписали один payload, прислали другой."""
signature = _sign(PAYLOAD, SECRET)
tampered = b'{"event":"contact.created","id":99}'
assert Webhook.verify(SECRET, tampered, signature) is False
def test_empty_signature_fails():
assert Webhook.verify(SECRET, PAYLOAD, "") is False
def test_malformed_signature_fails():
"""Несимметричная длина или невалидный hex — ровно False, не исключение."""
assert Webhook.verify(SECRET, PAYLOAD, "not_a_hex_string") is FalseАналогичные тесты на PHPUnit пишутся в 10 строк: $signature = hash_hmac('sha256', $payload, $secret); $this->assertTrue(Verifier::verify($payload, $signature, $secret));. Прогоняются за миллисекунды, без сети, без mock'ов.
Ротация секрета. Если секрет утёк, его меняют — но при простой замене webhook'и с новой подписью полетят на сервер, где ещё стоит старый, и будут отклонены. Правильно — окно ротации: два активных секрета одновременно, новые webhook'и подписываются новым, проверка клиента принимает оба, через 24 ч старый деактивируется. В BOTIX при перевыпуске старый получает revoked_at = NOW() + INTERVAL 24 HOUR. Обработчик принимает оба — проверяет в цикле по списку активных секретов.
Чего HMAC не делает. Полезно понимать границы:
- Не защищает от replay. Перехваченный настоящий webhook можно переслать ещё раз — подпись валидна. Нужен уникальный
event_idв теле + хранение обработанных id, либо timestamp в подписи с проверкой свежести (см. replay-защиту webhook). - Не шифрует тело. HMAC подтверждает, что тело не изменилось и пришло от знающего секрет. Само тело едет в открытом виде; для чувствительного содержимого достаточно HTTPS.
- Не подтверждает доставку. Корректно подписанный webhook может не дойти из-за сетевого сбоя. Надёжность доставки — отдельная задача (retry, очереди, dead-letter — см. retry-политику и dead-letter).
Тезис 2. Webhook-симулятор: payload до подключения канала
Сам обработчик к каналу не привязан. Ему нужен лишь корректно сформированный HTTP-запрос с теми же headers, тем же payload и той же подписью, что отправил бы прод. Никакого состояния «канал подключён» внутри обработчика нет — значит, весь блок подключения канала на этапе разработки лишний. Решает это webhook-инспектор.
Что делает инспектор. Сервис выдаёт временный публичный URL и показывает всё, что в него приходит: метод, путь, query, headers, body, время. Разработчик подставляет URL в настройки источника, источник стучится — инспектор фиксирует request один в один. Дальше либо изучаем структуру в браузере, либо копируем как curl и воспроизводим на localhost.
В BOTIX инспектор реализован как отдельный поддомен webhook.botix.pro. Заходим, жмём «Create new URL», получаем https://webhook.botix.pro/r/8f3a1c9b. TTL по умолчанию — 7 дней, после чего bin и все накопленные запросы удаляются автоматически. На странице bin'а — список входящих запросов с возможностью раскрыть любой.
Архитектурно это маленький сервис: один nginx server-блок, два эндпоинта (создание bin + приём запроса), две таблицы (webhook_bins + webhook_bin_requests с CASCADE на удаление). Никакой связи с основной платформой по бизнес-логике — bin не знает project_id, не требует авторизации. Это намеренная изоляция: доступность инспектора не зависит от состояния остальной платформы. Для компаний с требованием «данные не выходят за периметр» это важно: свой инспектор остаётся внутри инфраструктуры, в отличие от внешних webhook.site и аналогов.
Test endpoint: детерминированный вход. Инспектор закрывает первую половину задачи — увидеть структуру запроса. Вторую половину, отладку самого обработчика, закрывает test endpoint. В BOTIX это POST /public/v1/webhooks/{id}/test:
curl -X POST 'https://api.botix.pro/public/v1/webhooks/12345/test' \
-H 'Authorization: Bearer XXXXXXXX…' \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)"Платформа отправляет webhook с известным валидным payload и валидной подписью на URL подписки. Та же кнопка есть в кабинете на app.botix.pro/developers (webhook delivery dashboard). Это закрывает интеграционные тесты: подпись валидна → обработчик принял → залогировал → ответил 200 — без подключения боевого канала-источника.
Связка двух инструментов даёт полный цикл:
- Создаём bin на
webhook.botix.pro, указываем его какurlподписки, дёргаемPOST /webhooks/{id}/test. - На bin'е сразу появляется реальный payload реального типа события — изучаем headers и подпись.
- Копируем запрос как curl, воспроизводим на localhost сколько угодно раз, доводим обработчик.
- Когда обработчик готов — снова
testуже на боевой URL, убеждаемся, что вся цепочка от платформы до приёмника отрабатывает.
Главная ценность — снижение порога входа. Подключение канала и тестирование обработчика — две разные задачи; их совмещение в «подключи канал и попроси кого-нибудь написать» вставляет несколько часов чужих действий между «открыл редактор» и «увидел payload». Инспектор разрывает эту последовательность: payload через 30 секунд после старта. Это признак зрелого DX — каждый шаг разработчика запускается независимо от других.
Чего симулятор не закрывает (честно): mock-payload в test endpoint фиксированный — можно отправить contact.created, но нельзя задать значения полей. Для большинства тестов хватает; для edge cases — кастомный curl с подменой тела, а Verifier из тезиса 1 валидирует подпись.
Тезис 3. Polling или webhook: критерии выбора
Между двумя способами получать события нет «правильного» и «неправильного» — есть условия, при которых каждый становится единственно разумным.
Что такое polling. Запрос вида «дай всё, что появилось после такой-то отметки»: GET с курсором, отвечающий списком новых событий. Клиент запоминает позицию последнего обработанного, ждёт N секунд, повторяет. Достоинство — простота: работает без публичного endpoint'а, без HTTPS-сертификата, без обработки повторных доставок. Воркер — маленький while-loop с requests.get и cursor'ом, запустится хоть на localhost, хоть в Lambda. Недостаток — обратная сторона простоты: период опроса задаёт лаг. Раз в секунду — лаг до 1 с, но 86 400 запросов в сутки на ключ. Раз в минуту — лаг до 60 с, для интерактивного бота неприемлемо.
Что такое webhook. HTTP-запрос, который платформа шлёт на endpoint клиента в момент события. Задержка минимальная — сетевой round-trip плюс обработка на платформе, обычно десятки-сотни миллисекунд, без пустых запросов. Цена — инфраструктура: публичный endpoint, HTTPS с валидным сертификатом, обработка повторных доставок (платформа ретраит при таймауте), проверка подписи на каждом запросе, ответ в пределах нескольких секунд.
Критерии выбора:
- Низкая нагрузка + нет постоянного процесса (только cron раз в час) → polling: сходил, забрал накопленное, забыл. Webhook здесь негде принять.
- Высокая нагрузка (тысячи в час) + критична задержка → webhook: polling требовал бы десятков запросов в секунду или long-polling, что по сложности сравнимо с webhook, а по эффективности хуже.
- Промежуточный режим (несколько событий в час, лаг до минуты допустим) → решает второй критерий: готова ли инфраструктура клиента. Есть стабильный публичный HTTPS-сервис — webhook проще в эксплуатации; развёртывание сложное — polling обходится без публичного endpoint'а.
Что в BOTIX. Webhook — основной паттерн. Получатель регистрируется через POST /public/v1/webhooks либо в кабинете app.botix.pro/developers:
curl -X POST 'https://api.botix.pro/public/v1/webhooks' \
-H 'Authorization: Bearer XXXXXXXX…' \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/botix-webhook","events":["message.received","chat.opened"]}'В ответе 201 поле data.secret возвращается ровно один раз — это секрет для проверки X-Botix-Signature (HMAC-SHA256). Сохраните его на своей стороне; повторно платформа его не покажет. Поддерживаемые типы событий (enum WebhookEvent): contact.created, contact.updated, contact.deleted, contact.tagged, message.received, message.sent, chat.opened, chat.closed, chat.assigned, scenario.started, scenario.completed, scenario.failed. Каждая доставка несёт заголовки X-Botix-Event, X-Botix-Signature, X-Botix-Request-Id.
Доставка идёт через очередь в БД (btx_api_webhook_deliveries, статусы pending / delivered / failed) и cron-диспетчер раз в минуту. Retry-расписание: 30 секунд / 5 минут / 30 минут / 2 часа / 6 часов — 5 попыток, после чего доставка помечается failed и видна в delivery dashboard. Важное ограничение: критические бизнес-операции нельзя завязывать на webhook без страховки на стороне клиента — после 5 неудачных попыток за 6 часов событие может потеряться.
Polling доступен как fallback через cursor-пагинацию: GET /public/v1/chats, GET /public/v1/messages, GET /public/v1/contacts принимают query-параметр cursor и возвращают meta.next_cursor (+ meta.has_more, v1.1+). Клиент сохраняет последний cursor, опрашивает периодически, идёт вперёд до пустого next_cursor. Это покрывает случаи, где webhook поднять нельзя (локальная разработка без туннеля, jobs из CRM по своему расписанию). В чистом виде polling в документации developers.botix.pro/best-practices отмечен как fallback: при росте числа клиентов один опрашивающий воркер быстро упрётся в rate-limit (X-RateLimit-Limit per minute зависит от тарифа), а растягивать период опроса нельзя без потери качества.
Гибрид (reconciliation). В зрелых интеграциях основной поток — webhook (задержка важна), резервный — periodic reconciliation: раз в час клиент опрашивает API через cursor и сверяет локальное состояние с платформой. Webhook закрывает 99% случаев, реконсиляция доезжает потерянный процент. Полезно, когда пропущенное событие дорого стоит (не доставили триггер по платёжному статусу).
Что выбрать на старте. Есть стабильный публичный endpoint и нужна задержка <5 с — webhook, без раздумий. Разработка на локальной машине без публичного endpoint'а — polling через cursor с периодом 30-60 с на время разработки, в прод — webhook. Cron раз в час — polling. И отдельно: никогда не сравнивайте polling с webhook по «удобству на старте», не замерив ожидаемый трафик и допустимую задержку. Polling кажется проще на MVP, но при масштабировании ломается первым. Смежный критерий — как клиент должен реагировать на X-RateLimit-* — разобран в rate-limit и backoff.
Обратный переход polling → webhook без даунтайма
Сценарий: интеграция год работает на polling, нужно перейти на webhook. Простое переключение рискованно — между «выключили polling» и «webhook заработал корректно» может произойти инцидент. Стандартная техника blue-green:
- Включить webhook на тестовый URL (
webhook.botix.probin). Убедиться, что события идут, проверить пару штук руками. - Поднять webhook на проде в режиме «принимаем, но обработчик — заглушка, отвечаем 200 и логируем».
- Включить webhook в боевой URL. Polling всё ещё работает — оба источника активны.
- Сверять данные несколько часов: события через polling должны совпадать с событиями через webhook (id, content, timestamp).
- Переключить обработчик с заглушки на реальную логику. Идемпотентность по
event_idгарантирует, что дубль от перекрытия не создаст проблем. - Выключить polling. Опционально оставить periodic reconciliation раз в час как страховку.
Процесс безопасен в обе стороны. Обнаружили расхождение на шаге 4 — откатились к polling. На шаге 6 — тоже.
Итог
Приёмник webhook — три отдельные задачи, каждая со своим инструментом:
- Закрыть дверь → HMAC-SHA256 от raw body + сравнение в постоянном времени. Verifier в SDK снимает с разработчика обязанность помнить про timing attack. Нет SDK — проверьте, что в коде
hash_equals/compare_digest/timingSafeEqual, а не==. - Увидеть payload без канала → webhook-инспектор (
webhook.botix.pro, TTL 7 дней) +POST /public/v1/webhooks/{id}/test. Формат за 30 секунд, интеграционный тест без боевого источника. - Выбрать транспорт → webhook при публичном endpoint и задержке <5 с, polling через
cursorдля cron/локалки, гибрид с reconciliation где пропуск дорог.
Если хоть один инструмент отсутствует, разработчик уходит в polling «временно», и через год у вас в production polling, который упирается в rate-limit. Лечится не уговорами переключиться, а снятием барьеров к тестированию webhook на старте.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Как надёжно доставлять webhook: подпись, retry, симулятор
Тест безопасности webhook: симулятор и репетиция replay
Webhook и polling: retry-политика, dead-letter и backoff по 429
Multi-tenant, polling vs webhook и OAuth для агентства
