Попробовать API
</>
Безопасный webhook-обработчик за 30 минут: HMAC, timestamp и идемпотентность
webhooksИнструкция
17 мин

Безопасный 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, который принимает события (контакт создан, сообщение получено, оплата прошла) и:

  1. Проверяет HMAC-подпись — чтобы быть уверенным, что запрос пришёл от BOTIX, а не от случайного злоумышленника.
  2. Проверяет свежесть timestamp — чтобы старый перехваченный webhook нельзя было переслать заново через час.
  3. Идемпотентно обрабатывает дубли — at-least-once delivery приведёт к повторам.
  4. Корректно отвечает на retry со стороны платформы — чтобы при временной поломке не пропустить события.

Никаких сторонних библиотек кроме Flask и requests — только stdlib для крипты. Полный код помещается в один файл. Каждая из трёх проверок закрывает отдельный класс атак, и в production они идут именно в этом порядке: дешёвая (timestamp) перед дорогой (HMAC), обе — перед обращением в кеш обработанных событий.

Отправная точка — endpoint, принимающий POST с JSON:

python
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 с выгодными ему данными:

json
{
  "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():

python
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 в документации, разработчик пишет:

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

Внутри все три используют сравнение в постоянном времени — разработчику не нужно помнить про 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 секунд — отбрасываем.

python
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 верификация делает обе проверки за один вызов:

python
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:

python
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 тот же результат даёт БД:

sql
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:

ПопыткаЗадержка от первойЗачем
10 (сразу)основная
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 показывает номер попытки — полезно логировать повторы:

python
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.

python
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. Главное — не сокращать ни одну из трёх проверок и не ронять стек при разборе тела до проверки подписи.