Попробовать API
</>
Webhook в проде: HMAC-подпись, коды ошибок и retry
webhooksИнструкция
17 мин

Webhook в проде: HMAC-подпись, коды ошибок и retry

TL;DR: webhook-приёмник становится production-ready, когда закрыты три вещи — проверка HMAC-подписи в постоянном времени, правильный HTTP-код на каждый тип сбоя (401/400/5xx/200, а не «500 на всё» и не «200 на всё») и понимание retry-политики платформы (1m/15m/1h/24h + dead-letter). Без первой любой сервер пришлёт вам «оплата прошла»; без второй платформа теряет события молча; без третьей at-least-once delivery рано или поздно продублирует обработку. Разбираю все три опоры с кодом на Python, PHP и Node.

Коротко

  • Подпись. Webhook без HMAC — открытая дверь: URL публичен, подделать «нужное» событие может кто угодно. Проверяем HMAC-SHA256(secret, raw_body) и сравниваем через compare_digest/hash_equals/timingSafeEqual, а не == — иначе timing attack.
  • Коды. Четыре типа сбоя — четыре реакции: 401 невалидная подпись, 400 битый payload, 5xx внутренний сбой (его получает сама платформа), 200 всё остальное с асинхронной обработкой. Плюс единый контракт error response: code, message, details, request_id.
  • Retry. Ретраи живут на стороне отправителя (1m/15m/1h/24h → dead-letter) И на стороне получателя (очередь). Идемпотентность приёма обязательна — короткий кеш event_id в Redis или INSERT ... ON CONFLICT.

Webhook-механизм выглядит просто: платформа шлёт POST, вы принимаете. Между «шлёт» и «приняли» лежит длинный список того, что идёт не так — от подделанного запроса до упавшего на релизе pod'а. Прод-приёмник отличается от «как-нибудь работает» ровно тремя вещами: он проверяет, что запрос пришёл от платформы; отвечает осмысленным HTTP-кодом на каждый класс отказа; и переживает ретраи, не создавая дублей. Combo 03-09-10 — это как раз про эти три опоры. Ниже — каждая отдельным разделом, с кодом.

Подпись: HMAC-SHA256 и сравнение в постоянном времени

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"
}

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

HMAC (Hash-based Message Authentication Code) закрывает эту дверь. Платформа и клиент держат общий секрет — в BOTIX он выдаётся один раз при создании подписки (POST /public/v1/webhooks возвращает secret в ответе, показывается только один раз — сохраните его на своей стороне). Для каждого webhook'а платформа считает signature = HMAC-SHA256(secret, raw_request_body) и кладёт результат в заголовок X-BOTIX-Signature. Клиент повторяет ту же операцию с тем же секретом и тем же телом. Совпало — запрос от платформы. Не совпало — подделка, отклоняем. Злоумышленник не знает секрет и не может вычислить правильную подпись для своего тела.

Разработчик, увидев это в документации, часто пишет так:

python
expected = hmac.new(secret, body, sha256).hexdigest()
received = request.headers["X-BOTIX-Signature"]

if expected == received:
    process_event(body)

На первый взгляд работает: правильная подпись проходит, неправильная отваливается. Но == сравнивает строки байт за байтом и останавливается на первом несовпадении — время ответа зависит от того, сколько первых байт совпало. Разница в наносекундах, но злоумышленник может слать тысячи запросов и, измеряя время, подобрать подпись байт за байтом. Медленная, но рабочая атака — timing attack.

Правильный код использует сравнение в постоянном времени — оно всегда делает одинаковое число шагов независимо от места несовпадения:

python
def verify_signature(secret, raw_body, signature):
    expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature.replace("sha256=", ""))

@app.route("/webhook", methods=["POST"])
def webhook():
    raw = request.get_data()  # обязательно raw, не json
    if not verify_signature(SECRET, raw, request.headers.get("X-BOTIX-Signature", "")):
        return "", 401
    # ... обработка

Два критичных момента. Первый — compare_digest вместо ==. Аналоги: hash_equals($expected, $received) в PHP, crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) в Node, hmac.Equal(a, b) в Go. Второй — raw body, а не request.json: если распарсить и пересериализовать JSON, поменяются пробелы и порядок ключей, и HMAC не сойдётся. На уровне фреймворка сырое тело нужно получить специально — в Flask это request.get_data(), в Laravel $request->getContent(), в Express app.use(bodyParser.raw({type: 'application/json'})).

Разработчик, пишущий проверку с нуля, легко пропускает timing-safe. Поэтому в SDK BOTIX проверка инкапсулирована в helper Webhook::verify, и внутри все три реализации используют сравнение в постоянном времени:

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();
}

Ротация секрета. Если секрет утёк, его меняют. Но простая замена «старый → новый» ломает доставку: пока клиент не обновил секрет у себя, webhook'и со старой подписью будут отклоняться, события пропадут. Правильный механизм — окно ротации: платформа держит два активных секрета одновременно, подписывает новым, а клиент временно принимает оба. В BOTIX при перевыпуске старый секрет сохраняется со статусом revoked_at = NOW() + INTERVAL 24 HOUR — сутки на спокойное обновление, после чего он физически удаляется.

Чего HMAC не делает — три задачи, которые с ним часто путают. Не предотвращает replay: перехваченный настоящий webhook можно переслать повторно, подпись останется валидной — защита требует timestamp'а в подписи и nonce/event_id (об этом отдельно — replay-защита webhook: timestamp + nonce). Не шифрует тело: HMAC лишь подтверждает целостность и происхождение, само тело идёт открытым текстом (для большинства случаев достаточно HTTPS). Не подтверждает доставку: подпись не имеет отношения к надёжности — это отдельная задача retry и очередей, к ней вернёмся в третьем разделе.

Четыре типа сбоя и какой HTTP-код отвечать на каждый

Любой webhook-приёмник в проде регулярно попадает в один из четырёх сценариев отказа. Возвращать на все 500 неправильно — для одних сбоев retry имеет смысл, для других нет. Возвращать 200 на всё — катастрофа: платформа считает доставку успешной, а событие потеряно. Правильная реакция зависит от типа ошибки.

Тип 1. Невалидная подпись. Заголовок X-BOTIX-Signature есть, но HMAC не сходится: подпись подделана, тело изменено в полёте или у клиента старый секрет после ротации. Ответ — 401 Unauthorized. В BOTIX 401 от обработчика отправляет webhook в dead-letter без ретраев: 4xx означает «клиент сказал не принимать» (код проверки — в разделе выше).

Тип 2. Битый payload. Подпись валидна, но JSON не парсится либо нет ожидаемых полей. Ответ — 400 Bad Request, webhook сразу в dead-letter:

python
try:
    payload = json.loads(raw)
except json.JSONDecodeError:
    return {"error": "INVALID_JSON"}, 400
if "event" not in payload or "event_id" not in payload:
    return {"error": "MISSING_REQUIRED_FIELD"}, 400

Не пытаться «спасти» битый payload. Платформа зафиксирует факт, а владелец проекта найдёт в delivery dashboard конкретный битый webhook и разберёт причину.

Тип 3. Недоступный обработчик. Сервер не отвечает или отдаёт 5xx: идёт деплой, балансировщик уронил pod, истёк сертификат, упал реверс-прокси. Отвечать со стороны обработчика нечем — он уже не работает. Реагирует платформа: повторяет доставку по схеме 1m / 15m / 1h / 24h (детали — в третьем разделе).

Тип 4. Таймаут обработки. Обработчик принял запрос, начал работать, но не успел ответить за толерируемое окно (в BOTIX — 30 секунд): например, синхронно полез в стороннюю API и ждёт 45 секунд. Самый коварный сценарий. Платформа не получила 200 OK, считает попытку неуспешной и ретраит. А обработчик всё-таки дописал событие в БД и ответил 200 — но платформе это уже неинтересно, вторая попытка запущена. Событие записано дважды. Решение — асинхронная обработка с быстрым 200, разбор в третьем разделе.

Теперь — про сам контракт ошибки. 500 Internal Server Error с пустым телом превращает интеграцию в гадание по логам: что произошло, какое поле невалидно, чья сторона виновата, имеет ли смысл retry — ничего не понятно. {"error": "validation failed"} чуть лучше, но какое поле, какое значение, что менять — снова неизвестно. Error response — это часть публичного API, такой же контракт, как успешный ответ. Минимальный набор полей зрелого API:

json
{
    "code": "VALIDATION_ERROR",
    "message": "One or more fields failed validation",
    "details": {
        "fields": {
            "email": "must be a valid email address",
            "phone": "must start with +"
        }
    },
    "request_id": "req_01HXY8Z2K5VQGN3PMAB7DEFGHJ"
}
  • code — машиночитаемый идентификатор, уникальный в рамках API. То, на что код интегратора полагается в if (error.code === 'CONTACT_LIMIT_EXCEEDED'). Без него единственный способ ветвить логику — парсить message, что ломается при первой правке текста. У BOTIX зафиксирован публичный список из 16 кодов на developers.botix.pro/error-codes, каждый с описанием «когда возникает / что делать / имеет ли смысл retry». Список — часть контракта; удаление или переименование кода — breaking change.
  • message — человекочитаемое описание для логов. Не локализуется на язык конечного пользователя: это сервисное сообщение для разработчика, в его лог-агрегаторе единый язык удобнее.
  • details — структурированные данные, схема которых зависит от code. Для VALIDATION_ERROR — карта {поле: причина}; для RATE_LIMIT_EXCEEDEDretry_after_seconds и limit; для RESOURCE_NOT_FOUNDresource_type и resource_id.
  • request_id — уникальный идентификатор запроса, видимый и клиенту, и серверу. Пишете в поддержку — единственное, что ей нужно, это request_id: по нему за секунды поднимается полная трассировка. У BOTIX он возвращается во всех ответах, успешных и ошибочных: в поле request_id и в заголовке X-Request-ID.

Чего в error response быть не должно: стектрейсов и имён внутренних классов (NullPointerException at com.example… — утечка деталей реализации и вектор атаки); SQL-запросов и имён колонок (column users.password_hash does not exist — катастрофа с точки зрения безопасности); «вежливых» расплывчатостей Something went wrong, please try again later (для честного внутреннего сбоя — code: INTERNAL_ERROR, message: Internal server error, request_id, и всё); переменного формата (когда разные эндпоинты отдают то {"error"}, то {"errors":[{"detail"}]} — это три парсера вместо одного); и 200 OK с полем error в теле — это ломает generic-обработку в axios/requests/Guzzle, которые считают 200 успехом и не зовут error-handler.

Минимум по HTTP-статусам, который стоит зафиксировать:

КодКогдаRetry
400невалидное тело/параметрынет
401нет или неверная аутентификациянет
403аутентифицирован, но нет правнет
404ресурс не существует или скрытнет
409состояние ресурса противоречит операциинет
422синтаксически валиден, семантически невозможеннет
429превышен rate limitда, по Retry-After
500ошибка серверада
502/503/504проблема с upstreamда, с backoff

Различие 400 vs 422 смущает многих; эмпирика: 400 — запрос непонятен серверу, 422 — понятен, но невыполним из-за семантики. Итог по кодам: 401 для невалидной подписи, 400 для битого payload, 5xx для внутренних сбоев (их получит сама платформа от вашего сервера), 200 для всего остального — с асинхронной обработкой, чтобы не словить таймаут из Типа 4.

Retry-политика: 1m/15m/1h/24h, dead-letter и идемпотентный приём

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

Интервалы. Слишком частые ретраи — атака на собственный лежащий эндпоинт клиента; слишком редкие — потерянные часы. В BOTIX — экспоненциальный backoff с четырьмя ступенями поверх первой попытки:

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

После пятой попытки webhook попадает в dead-letter. Окно ~25 часов покрывает большинство реальных аварий: за сутки дежурный обычно успевает починить. Что считается неуспехом и ретраится: connect timeout (по умолчанию 10 сек), read timeout (30 сек), любой 5xx, 429 с Retry-After (следующая попытка берётся из заголовка, а не из таблицы). Что не ретраится: любой 4xx кроме 429 — клиент сказал «не приму», webhook сразу в dead-letter. Пример: эндпоинт отдаёт 401, потому что клиент сменил токен и не обновил — пинговать его дальше бессмысленно, лучше зафиксировать в dead-letter и показать в дашборде.

Теперь — решение Типа 4 (таймаута). Асинхронная обработка с быстрым 200:

python
import redis
import json

r = redis.Redis()

@app.route("/webhook", methods=["POST"])
def webhook():
    raw = request.get_data()
    if not verify_signature(SECRET, raw, request.headers.get("X-BOTIX-Signature", "")):
        return "", 401

    try:
        payload = json.loads(raw)
    except json.JSONDecodeError:
        return {"error": "INVALID_JSON"}, 400

    # быстро кладём в очередь, отвечаем сразу
    r.lpush("webhook:incoming", raw)
    return "", 200

# отдельный воркер
def worker():
    while True:
        _, raw = r.brpop("webhook:incoming")
        payload = json.loads(raw)
        process_event(payload)

Обработчик валидирует подпись, кладёт raw body в очередь и сразу отвечает 200. Реальная обработка — в отдельном воркере с собственными ретраями. Платформа считает доставку успешной за миллисекунды.

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

python
def process_event(payload):
    event_id = payload["event_id"]
    key = f"webhook:processed:{event_id}"

    # SET NX EX — атомарно занимаем ключ
    if not r.set(key, "1", ex=900, nx=True):
        return  # уже обрабатывали

    # реальная бизнес-логика
    save_to_db(payload)

Команда SET ... NX EX атомарна: конкурентные запросы с одним event_id гарантированно увидят занятый ключ и пройдут мимо. TTL 900 секунд — втрое больше окна свежести webhook'а. В SQL тот же паттерн:

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

Вставилась строка — событие новое, обрабатываем. Конфликт по event_id — дубль, игнорируем. Главное — не делать SELECT, потом INSERT без транзакции: при двух одновременных webhook'ах оба пройдут проверку. Выбор между polling и webhook под конкретную интеграцию мы разбираем отдельно — polling vs webhook: критерии выбора.

Дашборд, заголовки и что не делать

Весь жизненный цикл webhook в BOTIX лежит в кабинете на app.botix.pro/developers, раздел Webhook Delivery. Каждая попытка доставки логируется в таблицу btx_api_webhook_deliveries; для каждой записи видны event-type, endpoint, HTTP-код ответа, длительность, тело ответа (первые 4 KB), номер попытки, время следующего retry, request_id. Кнопка «Повторить отправку» догоняет пропущенное после починки (с новым request_id). Кнопка «Перенаправить» отправляет webhook на тестовый URL — например, встроенный инспектор webhook.botix.pro, чтобы посмотреть тело в безопасном окружении, не дёргая прод. Каждая запись хранится 14 дней. Без дашборда dead-letter — это строки в БД, которые никто не смотрит; с дашбордом — рабочий инструмент разбора инцидентов.

Заголовки, которые несёт webhook BOTIX:

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

X-BOTIX-Signature — HMAC-SHA256 от raw body; X-BOTIX-Timestamp — защита от replay, окно ±5 минут; X-Event-Id — готовый nonce для idempotency; X-Event-Type — тип события (contact.created, payment.succeeded, subscription.cancelled); X-Delivery-Attempt — номер попытки, сразу видно, retry это или первая отправка; X-Request-Id — для тикета в поддержку. Пара дополнительных правил доставки: sequence не гарантируется (при параллельных доставках порядок прихода может отличаться от порядка событий — полагайтесь на timestamp в теле, не на порядок); размер payload ограничен (в BOTIX — 64 КБ; если данных больше, приходит только id, полный объект запрашивается через API).

Чего не делать. Не отвечать 200 на всё подряд — это уничтожает defense-in-depth. Не ретраить из обработчика синхронно — зависший на 30 секунд внешний вызов приведёт к тому, что платформа запустит второй webhook поверх первого, гарантированный дубль. Не игнорировать X-Delivery-Attempt — частые ретраи означают, что обработчик медленный или валидация нестабильна. Не хранить event_id вечно — кеша на 15 минут достаточно, лог обработанных событий — отдельная история. И не реализовывать «бесконечные ретраи с разумным backoff»: очередь становится непредсказуемой (один лежащий клиент оставит миллионы записей), а webhook о «заказе принят», доставленный через три дня, приносит вред, а не пользу. Конечное окно в 24 часа — правильный компромисс: что не доставилось за сутки, разбирается вручную.

Итог

Три опоры production-webhook, каждая из которых закрывает свой класс отказа. Подпись: HMAC-SHA256(secret, raw_body) со сравнением в постоянном времени — первая закрывает дверь, вторая закрывает замок на ней. Коды: четыре типа сбоя — четыре ответа (401/400/5xx/200) поверх единого контракта error response с code, message, details, request_id. Retry: backoff с конечным окном (1m/15m/1h/24h → dead-letter), дашборд с ручным повтором и обязательная идемпотентность приёма, потому что at-least-once delivery всегда пришлёт дубль.

Всё вместе умещается примерно в 50 строк кода на любом из языков. Без них webhook-приёмник работает «как-нибудь» — до первой серии ретраев, после которой выясняется, что половина событий обработана дважды, четверть потеряна и следов в логах нет. С ними — предсказуемая система, где каждый отказ имеет свой код и свою судьбу.