Безопасный webhook-обработчик за 30 минут: HMAC, timestamp и идемпотентность
TL;DR: webhook без подписи и без защиты от повторов — это открытый эндпоинт, куда любой может прислать произвольный JSON и запустить вашу бизнес-логику. Соберём за 30 минут обработчик с HMAC-SHA256 от raw body, проверкой timestamp против replay и корректной реакцией на ретраи BOTIX. Код на Python + Flask, без сторонних библиотек кроме Flask и `requests` — только stdlib для крипты.
Коротко
- Подпись HMAC-SHA256 от сырого тела запроса + сравнение в постоянном времени (
compare_digest, не==) закрывают доступ к эндпоинту чужим и timing-атаку — но это только первый из трёх барьеров. - Одной подписи мало. Перехваченный валидно подписанный webhook можно переслать заново (replay). Нужны два дополнительных слоя: timestamp в подписи с окном свежести и идемпотентность приёма по
event_id. - Retry — часть контракта. BOTIX повторяет доставку по расписанию 1m/15m/1h/24h, после пятой попытки уходит в dead-letter. Практическое следствие: при временной проблеме отвечайте 503, а не 401, иначе события пропадут.
Что мы строим
Endpoint /webhooks/botix, который принимает события (контакт создан, сообщение получено, оплата прошла) и:
- Проверяет HMAC-подпись — чтобы быть уверенным, что запрос пришёл от BOTIX, а не от случайного злоумышленника.
- Проверяет свежесть timestamp — чтобы старый перехваченный webhook нельзя было переслать заново через час.
- Идемпотентно обрабатывает дубли — at-least-once delivery приведёт к повторам.
- Корректно отвечает на retry со стороны платформы — чтобы при временной поломке не пропустить события.
Никаких сторонних библиотек кроме Flask и requests — только stdlib для крипты. Полный код помещается в один файл. Каждая из трёх проверок закрывает отдельный класс атак, и в production они идут именно в этом порядке: дешёвая (timestamp) перед дорогой (HMAC), обе — перед обращением в кеш обработанных событий.
Отправная точка — endpoint, принимающий POST с JSON:
from flask import Flask, request, jsonify
import logging
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
@app.route("/webhooks/botix", methods=["POST"])
def webhook_handler():
payload = request.get_json()
logging.info(f"Received event: {payload.get('event')}")
return jsonify({"ok": True}), 200В таком виде endpoint — открытая дверь. Любой, кто узнает URL, отправит свой POST с "event": "payment.succeeded" и запустит бизнес-логику. URL утекает легко: через .env.example в открытом репозитории, access.log реверс-прокси, перебор поддоменов. Сценарий не теоретический — с него и начнём закрывать дыры.
Барьер 1. HMAC-подпись: почему без неё endpoint — открытая дверь
URL webhook-endpoint'а публичный по определению: он должен быть доступен из интернета, чтобы платформа могла достучаться. Значит, отправить POST на него может любой, кто знает адрес. После утечки адреса злоумышленник формирует фальшивый webhook с выгодными ему данными:
{
"event": "payment.succeeded",
"amount": 100000,
"currency": "RUB",
"order_id": "ORD-42",
"customer_email": "victim@example.com"
}Сервер принимает запрос, видит «нужное» событие, помечает заказ оплаченным, отгружает товар, начисляет бонусы. Через час злоумышленник получил товар, денег не приходило.
HMAC (Hash-based Message Authentication Code) доказывает, что отправитель знает общий секрет, не передавая сам секрет в запросе. Платформа и клиент имеют общий секрет (выдаётся один раз при создании подписки на webhook). Платформа считает:
signature = HMAC-SHA256(secret, timestamp + "." + raw_body)И кладёт в заголовки:
X-Botix-Signature: 7d4a8c9f...
X-Botix-Timestamp: 1716392460Клиент повторяет ту же операцию с тем же секретом. Совпало — настоящий, не совпало — отбрасываем 401. Злоумышленник не знает секрет и не может вычислить правильную подпись для своего тела.
Критичный момент: HMAC считается от raw body, не от распаршенного JSON. Если перепарсить body и сериализовать обратно, порядок ключей и пробелы могут измениться, подпись не сойдётся. В Flask raw body берётся через request.get_data():
import hmac
import hashlib
import os
WEBHOOK_SECRET = os.environ["BOTIX_WEBHOOK_SECRET"].encode()
def verify_signature(raw_body: bytes, timestamp: str, signature: str) -> bool:
signed_payload = timestamp.encode() + b"." + raw_body
expected = hmac.new(WEBHOOK_SECRET, signed_payload, hashlib.sha256).hexdigest()
# ВАЖНО: compare_digest, не ==
return hmac.compare_digest(expected, signature)Почему == — это уязвимость, а не мелочь
Видя описание HMAC в документации, разработчик пишет:
expected = hmac.new(secret, body, sha256).hexdigest()
received = request.headers["X-Botix-Signature"]
if expected == received:
process_event(body)На первый взгляд работает: правильная подпись проходит, неправильная отклоняется. Но оператор == сравнивает строки байт за байтом и останавливается на первом несовпадении. Если первые байты совпали — цикл идёт дольше; несовпадение сразу — короче. Разница в наносекундах, но злоумышленник отправляет тысячи запросов с разными подписями, измеряет время ответа и постепенно подбирает каждый байт, начиная с первого. Атака медленная, но рабочая на длинных горизонтах.
hmac.compare_digest сравнивает в постоянном времени — всегда одинаковое число шагов, независимо от того, где нашлось несовпадение. Аналоги в других языках: hash_equals в PHP, crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)) в Node, hmac.Equal в Go. Разработчик, пишущий проверку с нуля, легко пропускает этот момент — поэтому проверку стоит инкапсулировать в SDK.
Во всех трёх официальных 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();
}Внутри все три используют сравнение в постоянном времени — разработчику не нужно помнить про timing attack. И помните про raw body на уровне фреймворка: в Express — app.use(bodyParser.raw({type: 'application/json'})), в Laravel — $request->getContent(), в Flask — request.get_data().
Про ротацию секрета: если он утёк, простая замена приведёт к потере событий, пока клиент не обновил секрет у себя. Правильный механизм — окно ротации. В BOTIX при перевыпуске старый секрет сохраняется со статусом revoked_at = NOW() + INTERVAL 24 HOUR: в этом окне подпись проверяется и старым, и новым, по истечении 24 часов старый удаляется. Это даёт время на спокойное обновление. Подробнее про выбор между подписью и OAuth — в разборе хранения ключей и polling vs webhook.
Барьер 2. Replay: почему одной подписи недостаточно
HMAC отвечает на «настоящий ли запрос». Он не отвечает на «не подсунули ли старый перехваченный запрос заново». HMAC — математическая функция без понятия времени и без памяти: один и тот же вход всегда даёт один и тот же выход. Прокси, через который прошёл webhook, мог сохранить его в логах. Злоумышленник берёт сохранённый запрос с валидной подписью и отправляет повторно — HMAC сходится, бизнес-логика выполняется второй раз. Если вебхук обрабатывает оплату, бонусы или изменение прав — последствия серьёзные.
Защита от replay лежит не в HMAC, а в двух слоях рядом с ним: подписанный timestamp с окном свежести и проверка уникальности на стороне получателя. Оба нужны одновременно — каждый по отдельности дыру не закрывает.
Timestamp в подписи. Платформа кладёт в подписанную строку текущее время отправки. Клиент проверяет: если разница больше N секунд — отбрасываем.
import time
REPLAY_WINDOW_SEC = 300 # 5 минут
def is_fresh(timestamp_str: str) -> bool:
try:
ts = int(timestamp_str)
except (ValueError, TypeError):
return False
return abs(int(time.time()) - ts) <= REPLAY_WINDOW_SECОкно симметричное: timestamp из будущего так же опасен, как из прошлого (расхождение часов между серверами). В BOTIX окно — ±5 минут: этого хватает на сетевые задержки, retry-каскады и рассинхрон часов.
Важный нюанс: timestamp обязан быть в подписи. Если подписывать только тело, а timestamp передавать отдельным заголовком вне HMAC — злоумышленник подменит timestamp на свежий, HMAC сойдётся, окно сойдётся, атака пройдёт. В SDK BOTIX верификация делает обе проверки за один вызов:
from botix.webhooks import verify
is_valid = verify(
payload=raw_body,
signature=request.headers["X-Botix-Signature"],
timestamp=request.headers["X-Botix-Timestamp"],
secret=XXXXXXXX["BOTIX_WEBHOOK_SECRET"],
tolerance=300, # секунд
)Идемпотентность приёма: event_id как готовый nonce
Timestamp-окно отрезает replay через час. В пределах 5 минут он остаётся возможен. И отдельно — повторы приходят от самой платформы из-за at-least-once delivery (платформа не получила 2xx и ретраит). Здесь вступает второй слой replay-защиты: получатель должен помнить, какие события уже обрабатывал.
Nonce не нужно выдумывать — у любого осмысленного события уже есть уникальный идентификатор. В BOTIX каждое событие содержит поле event.id в формате evt_ + строка из 24 символов, уникальное во всей платформе и не повторяющееся при retry. Платформа также дублирует его в заголовок X-Event-Id. Достаточно хранить обработанные id за интервал в полтора-два раза больше окна свежести подписи — 10–15 минут. Это не журнал событий, а короткий кеш для отбрасывания дубликатов.
В Redis это атомарная операция через SET NX EX:
import redis
r = redis.Redis()
def already_processed(event_id: str, ttl_sec: int = 900) -> bool:
key = f"webhook:processed:{event_id}"
was_set = r.set(key, "1", ex=ttl_sec, nx=True)
return not was_setКоманда SET … NX EX 900 атомарна: конкурентные запросы с одним и тем же event.id гарантированно увидят, что кто-то уже занял ключ, и пройдут мимо. Без Redis тот же результат даёт БД:
INSERT INTO processed_events (event_id, processed_at)
VALUES (?, NOW())
ON DUPLICATE KEY UPDATE event_id = event_id;Если INSERT вставил строку — событие новое, обрабатывать. Если конфликт по event_id — дубль, игнорировать. Частая ошибка — проверять через SELECT, а потом INSERT без транзакции и без ON CONFLICT: при двух одновременных вебхуках сообщение обработается дважды. Атомарность обязательна.
Отдельно про терминологию: в платёжных API встречается Idempotency-Key, который генерирует сам клиент при запросе в сторону API. В webhook-направлении инициатор обратный — поставщик, и nonce приходит от него. Поэтому event.id в webhook и Idempotency-Key в Public API — два разных поля разной природы, но логика приёмки одинаковая: «уже обрабатывал — не делай повторно». Подробный тест обоих слоёв replay-защиты — в разборе симулятора и replay-атак.
Барьер 3. Retry со стороны BOTIX: 1m/15m/1h/24h и dead-letter
Платформа — отдельная половина контракта. Retry-логика живёт на обеих сторонах, но покрывает разные отказы. На стороне получателя: клиент принимает webhook, ставит в свою очередь и обрабатывает асинхронно — это правильно для финальной обработки, но не заменяет ретраи отправителя. Если клиент вообще не ответил 200 (timeout, упавший сервер, 5xx) — webhook не попал в его очередь, ретраить нечего. Ретраи отправителя покрывают ровно этот класс: «я физически не получил запрос».
Когда endpoint вернул не-2xx или ушёл в timeout, BOTIX повторяет по экспоненциальному backoff:
| Попытка | Задержка от первой | Зачем |
|---|---|---|
| 1 | 0 (сразу) | основная |
| 2 | +1 мин | временный сбой сети / перегрузка |
| 3 | +15 мин | короткий простой / рестарт |
| 4 | +1 ч | средний инцидент / выкатка релиза |
| 5 | +24 ч | длительная авария / выходные |
После пятой попытки webhook уходит в dead-letter. Общее окно — около 25 часов: этого достаточно, чтобы дежурный заметил проблему, починил и сервер начал принимать. Дольше держать в очереди обычно бессмысленно — событие за пределами суток теряет актуальность.
Что ретраится, а что нет
Успех — любой ответ 2xx (200, 201, 202, 204). Webhook больше не ретраится.
Ретраится (неуспех):
- Connect timeout (по умолчанию 10 сек).
- Read timeout (по умолчанию 30 сек).
- Любой 5xx (500, 502, 503, 504).
- 429 Too Many Requests с заголовком
Retry-After— следующая попытка берётся из заголовка, а не из таблицы.
Не ретраится: 4xx кроме 429 (400, 401, 403, 404). Клиент сам сказал «не приму», долбить бессмысленно — webhook сразу попадает в dead-letter.
Отсюда практическое следствие: если endpoint возвращает 401, потому что вы сменили секрет webhook и ещё не обновили его у себя, — платформа не ретраит, события пропадут. При временной смене секрета возвращайте 503, а не 401. Тогда платформа повторит через минуту.
Для обработчика заголовок X-Delivery-Attempt показывает номер попытки — полезно логировать повторы:
attempt = request.headers.get("X-Delivery-Attempt", "1")
if attempt != "1":
logging.info(f"Retry attempt {attempt} for event {event_id}")Помимо подписи webhook несёт набор заголовков:
X-Botix-Signature: 7d4a8c...
X-Event-Id: evt_01HQRX...
X-Event-Type: payment.succeeded
X-Delivery-Attempt: 2
X-Request-Id: req_01HQRX...Dead-letter и дашборд
Dead-letter — конечная точка для webhook'ов, которые не удалось доставить. Без неё events просто теряются, и о факте потери никто не узнаёт. В BOTIX каждая попытка доставки логируется в btx_api_webhook_deliveries: целевой URL, статус ответа, текст ошибки, время, номер попытки. После исчерпания ретраев или 4xx создаётся запись со статусом dead_letter.
В кабинете на странице /developers есть раздел Webhook Delivery — это и есть дашборд:
- Список всех попыток за последние 14 дней с фильтром по статусу.
- Каждая запись разворачивается: целевой URL, payload, HTTP-код ответа, тело ответа, request_id, время.
- Кнопка «Повторить отправку» — отправляет тот же webhook повторно (с новым request_id). Используется, когда сервер починили и нужно догнать пропущенные события.
- Кнопка «Перенаправить» — отправляет webhook на временный тестовый URL (например, на
webhook.botix.pro— встроенный инспектор), чтобы посмотреть тело в безопасном окружении, не дёргая прод.
Ещё пара правил контракта: sequence не гарантируется — при параллельной доставке порядок прихода может отличаться от порядка событий, полагайтесь на timestamp в теле, а не на порядок получения. Размер payload ограничен — в BOTIX 64 КБ на webhook; если данных больше, отправляется только id, и клиент запрашивает полный объект через API. Как строить backoff на клиенте по rate-limit-заголовкам — в смежном разборе rate-limit и выбора доставки.
Собираем endpoint целиком
Три проверки идут в правильном порядке: дешёвая (timestamp) перед дорогой (HMAC), обе — перед обращением в Redis.
from flask import Flask, request, jsonify
import hmac
import hashlib
import os
import time
import redis
import logging
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
r = redis.Redis()
WEBHOOK_SECRET = os.environ["BOTIX_WEBHOOK_SECRET"].encode()
REPLAY_WINDOW_SEC = 300
def verify_signature(raw_body, timestamp, signature):
signed_payload = timestamp.encode() + b"." + raw_body
expected = hmac.new(WEBHOOK_SECRET, signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
def is_fresh(timestamp_str):
try:
ts = int(timestamp_str)
except (ValueError, TypeError):
return False
return abs(int(time.time()) - ts) <= REPLAY_WINDOW_SEC
def already_processed(event_id):
key = f"webhook:processed:{event_id}"
return not r.set(key, "1", ex=900, nx=True)
@app.route("/webhooks/botix", methods=["POST"])
def webhook_handler():
raw_body = request.get_data()
timestamp = request.headers.get("X-Botix-Timestamp", "")
signature = request.headers.get("X-Botix-Signature", "")
event_id = request.headers.get("X-Event-Id", "")
# Дешёвая проверка раньше дорогой
if not is_fresh(timestamp):
return jsonify({"error": "stale"}), 401
if not verify_signature(raw_body, timestamp, signature):
return jsonify({"error": "invalid_signature"}), 401
if already_processed(event_id):
# 200 — чтобы платформа не ретраила
return jsonify({"ok": True, "duplicate": True}), 200
# Кладём в очередь и быстро отвечаем
payload = request.get_json()
enqueue_event(payload)
return jsonify({"ok": True}), 200Каждая проверка закрывает отдельный класс атак: timestamp — replay через час, HMAC от raw body с постоянным сравнением — подделку и timing-attack, event_id — повторную обработку из-за at-least-once delivery.
Подводные камни
Парсинг body до проверки подписи ломает её. Если фреймворк автоматически парсит JSON и сериализует обратно — raw body теряется. В Flask request.get_data() сохраняет байты; в Express нужно bodyParser.raw({type: 'application/json'}); в Laravel — $request->getContent(). Проверяйте, что фреймворк отдаёт исходные байты.
SDK закрывает HMAC и timestamp, но не идемпотентность. В BOTIX SDK для всех трёх языков есть верификация HMAC + timestamp за один вызов. Кеш event_id — это уже ваша часть, потому что только вы знаете, какое хранилище у вас есть.
Тяжёлая обработка внутри endpoint — путь к timeout. Платформа считает timeout от 30 секунд неудачей и ретраит. Очередь из 100 событий с обработкой по 10 секунд положит сервер. Кладите в очередь и отвечайте 200 сразу, обработка — асинхронно.
Не делайте «бесконечные ретраи». Запоздалый webhook «заказ принят», доставленный через три дня, чаще приносит вред, чем пользу, а размер очереди становится непредсказуемым. Конечное окно (24 часа) — правильный компромисс; что не доставилось за сутки, разбирается вручную через дашборд.
Итог
Безопасный webhook-обработчик — это не одна проверка HMAC, а три последовательные проверки: timestamp в окне свежести, валидная подпись от raw body в постоянном сравнении, уникальный event_id. Пропуск любой создаёт отдельный класс атак — replay через час, подделку через timing-attack, повторную обработку из-за at-least-once delivery. Retry-механика со стороны BOTIX (backoff с конечным окном, dead-letter, дашборд) — вторая половина контракта: она гарантирует доставку до тех пор, пока вы не вернули 2xx.
30 минут уходит на склейку, потому что каждая часть короткая. Файла на 80 строк хватает на production. Главное — не сокращать ни одну из трёх проверок и не ронять стек при разборе тела до проверки подписи.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатно