Webhook: ошибка подписи или повторная обработка — проверяем исходное тело, время и идемпотентность
Разберите ошибки API-callback по четырём слоям: исходное тело, склейка HMAC, допуск времени и защита от повторов. Локальный пример показывает, почему 2xx и успешная подпись ещё не гарантируют однократный бизнес-эффект.
Webhook уже вернул 2xx, но приёмник сообщает об «ошибке подписи»; либо после повторной доставки одно событие дважды изменяет бизнес-данные. Эти случаи часто смешивают. Проверка подписи отвечает только на вопрос, сформированы ли байты держателем секрета; она не заменяет защиту от повторной доставки, идемпотентность и проверку бизнес-состояния.
Ниже разобран явно обозначенный учебный протокол HMAC-SHA256: подпись покрывает Unix-временную метку, точку и исходное тело запроса, а в заголовке хранится шестнадцатеричный digest. Это соглашение только для примера, а не универсальный протокол провайдера. При реальном подключении руководствуйтесь его официальной документацией. Примеры используют локально смоделированные запросы и не обращаются к платному API.
1. Разделяйте «запрос дошёл» и «запросу можно доверять»
Получение HTTP-запроса, прохождение TLS, ответ 2xx, успешная проверка подписи и запись бизнес-события — пять разных этапов. Прокси может доставить запрос приложению, но приложение всё равно должно проверить подпись до разбора JSON. При ошибке не записывайте в лог тело, секрет или полные заголовки; сохраняйте идентификатор запроса, результат проверки окна времени и обезличенный digest.
| Этап | На какой вопрос отвечает | Что проверять при сбое |
|---|---|---|
| Передача | Полностью ли дошло HTTP-сообщение | статус, тайм-ауты, прокси и длину |
| Получение оригинала | Не изменились ли байты, покрытые подписью | сжатие, кодировку, переводы строк и middleware |
| Проверка подписи | Совпадают ли алгоритм, секрет и порядок склейки | официальный протокол, версию и область секрета |
| Защита от повтора | Не обработано ли событие ранее | часы, nonce, ID события и кэш |
| Бизнес-обработка | Возникает ли эффект ровно один раз | уникальное ограничение и автомат состояний |
Не считайте 200 от отправителя доказательством завершения операции. Приёмник может сначала проверить подпись и сохранить событие, а затем выполнить работу асинхронно; 2xx тогда говорит только о результате приёма.
2. Для проверки подписи нужны исходные байты тела
Частая ошибка — сначала разобрать JSON в объект, а затем заново сериализовать его перед проверкой. Пробелы, порядок ключей, экранирование, нормализация Unicode и последний перевод строки меняют байтовую строку. Сжатие или изменение шлюзом тоже может произойти до или после проверки; нужно установить, какое представление покрывает подпись.
Обычно соблюдайте такой порядок:
- Считайте исходные bytes до первого разбора тела фреймворком и ограничьте размер согласно протоколу.
- Храните защищённую копию исходных байтов или зашифрованный/обезличенный digest для диагностики.
- Склейте метку времени, префикс версии или nonce по официальному протоколу, не угадывая разделитель.
- Сравнивайте digest постоянным по времени способом, а не обычным сравнением строк.
- Только после успешной проверки разбирайте JSON и проверяйте тип события, ID события и версию полей.
Функция ниже использует два учебных заголовка — X-Webhook-Timestamp и X-Webhook-Signature. Строка подписи — timestamp + "." + raw_body, digest — исходные байты HMAC-SHA256 в шестнадцатеричном виде. У реального сервиса могут быть несколько подписей, префиксы или другая кодировка; замените пример точной спецификацией.
import hashlib
import hmac
import time
from collections.abc import Mapping
class WebhookVerificationError(ValueError):
pass
def verify_webhook(
raw_body: bytes,
headers: Mapping[str, str],
secret: bytes,
*,
now: int | None = None,
tolerance: int = 300,
) -> int:
if not isinstance(raw_body, bytes):
raise TypeError("raw_body must be bytes")
if not isinstance(secret, bytes) or not secret:
raise ValueError("secret must be non-empty bytes")
if type(tolerance) is not int or tolerance < 0:
raise ValueError("tolerance must be a non-negative integer")
timestamp_text = headers.get("X-Webhook-Timestamp", "").strip()
signature_text = headers.get("X-Webhook-Signature", "").strip()
try:
timestamp = int(timestamp_text)
except ValueError:
raise WebhookVerificationError("INVALID_TIMESTAMP") from None
if timestamp < 0:
raise WebhookVerificationError("INVALID_TIMESTAMP")
current = int(time.time()) if now is None else now
if type(current) is not int:
raise TypeError("now must be an integer timestamp")
if abs(current - timestamp) > tolerance:
raise WebhookVerificationError("STALE_TIMESTAMP")
try:
provided = bytes.fromhex(signature_text)
except ValueError:
raise WebhookVerificationError("INVALID_SIGNATURE_FORMAT") from None
signed = str(timestamp).encode("ascii") + b"." + raw_body
expected = hmac.new(secret, signed, hashlib.sha256).digest()
if not hmac.compare_digest(provided, expected):
raise WebhookVerificationError("BAD_SIGNATURE")
return timestamp
Здесь tolerance=300 означает отказ при расхождении часов более 300 секунд. В production это значение выбирают по окну повторных доставок, сетевой задержке и синхронизации часов; бездумно увеличивать его нельзя.
3. Ищите этап ошибки, а не сразу меняйте секрет
| Наблюдение | Сначала проверьте | Какого вывода делать не следует |
|---|---|---|
| Все события заканчиваются BAD_SIGNATURE | секрет в окружении, место чтения оригинала, формат склейки и преобразование hex/текста | провайдер обязательно изменил протокол |
| Ошибки только у китайского текста или переводов строк | не декодировалось ли и не нормализовалось ли тело | UTF-8 сам по себе не поддерживает китайский |
| Иногда получается STALE_TIMESTAMP | NTP, часовой пояс контейнера и задержку прокси | событие обязательно является атакой |
| При повторе подпись проходит, но эффект повторяется | ID события, уникальный ключ и граница транзакции | функция подписи не работает |
| После 2xx отправитель продолжает повторы | требуемое время ответа, диапазон статусов и доступность сети | бизнес-обработку нужно просто ускорить |
| После reverse proxy всё ломается | не изменяет ли прокси тело, сжатие или регистр заголовка | достаточно отключить проверку подписи |
Разделяйте четыре вида доказательств: обезличенные длину и хэш исходных байтов, разбор заголовка подписи, идентификатор версии секрета, время приёма и разницу с временем отправки. Не помещайте в обычный лог HMAC-секрет, Authorization, Cookie или полный payload. При прокси-клиенте полезно руководство по переменным прокси HTTPX; для обращения к платформе — шаблон минимального воспроизведения ошибки API.
4. Временная метка лишь сокращает окно повтора
Даже правильно подписанное сообщение может быть копией старого. Окно времени блокирует просроченные сообщения, но не две отправки одного события внутри этого окна. Поэтому одновременно нужны:
- ID события или nonce: после подписи и до бизнес-операции проверить, не встречался ли он раньше;
- краткоживущая дедупликация: хранить ключ provider + event_id с TTL, покрывающим максимальное окно повторной доставки;
- атомарная запись: уникальное ограничение БД или операция «записать только один раз», чтобы параллельные запросы не прошли одновременно;
- синхронизация часов: надёжная синхронизация контейнера и хоста, запись UTC и локального времени отображения;
- ограниченные повторы: при ошибке бизнес-операции вернуть статус, предусмотренный протоколом, чтобы отправитель повторил попытку; не выполнять необратимую операцию заново только из-за дубля.
Состояния «получено» и «завершено» различаются. Можно вставить запись received, вернуть 2xx, а затем очередью перевести её в processed, failed или dead_letter. Потребитель должен в транзакции проверить состояние; повторная доставка возвращает уже сохранённый результат. Для платежей, пополнений и изменения квоты сверяйте ID запроса с биллингом по руководству по счетам API.
5. Постройте четыре теста на локальном компьютере
Не обращаясь к реальному сервису, можно проверить границы приёмника. Для одних и тех же исходных bytes вычислите правильную подпись и проверьте:
- полностью неизменённое тело — должно пройти;
- изменение только пробелов JSON или последнего перевода строки — должно завершиться ошибкой;
- метка времени за пределами допуска — должна дать STALE_TIMESTAMP;
- два параллельных события с одним ID — подпись у обоих может пройти, но побочный эффект должен возникнуть один раз.
Используйте фиксированный тестовый секрет и искусственное время, не помещайте production-секрет в репозиторий. Помимо unit-тестов добавьте интеграционную проверку реального фреймворка на чтение исходного тела, чтобы body parser не потребил или не изменил его заранее. При gzip отдельно фиксируйте, подписывается ли представление до или после сжатия; Content-Encoding сам по себе этого не доказывает. Границы сжатия разобраны в руководстве по gzip-ответам.
6. Чек-лист перед production
- Перепишите из официальной документации заголовки подписи, версию, кодировку, порядок склейки и правила ротации секрета.
- Ясно определите место чтения исходного body и максимальный размер; слишком большие запросы отклоняйте.
- Используйте сравнение постоянного времени; ответ об ошибке не должен раскрывать, какая часть не совпала.
- Введите мониторинг окна времени, синхронизации часов и дедупликации ID событий.
- Установите уникальное ограничение на ID события или бизнес-ключ идемпотентности и определите ответ на повтор.
- Записывайте обезличенные поля: ID события, ID запроса, версию, разницу времени, длину и хэш body.
- При ротации допускайте короткое перекрытие старого и нового секрета, затем отзывайте старый.
- Локальными примерами проверьте изменение body, повторы, параллелизм и сбой downstream, не отправляя платный запрос.
Проверка подписи решает задачу «соответствует ли сообщение протоколу», идемпотентность — «даёт ли повтор одно следствие», а автомат состояний — «завершилось ли следствие». Только после прохождения всех трёх уровней webhook можно считать пригодным для работы.
7. Первичные источники и пределы применимости
Материалы проверены 2026-11-08. Формат склейки HMAC в статье является учебным и не выдаётся за спецификацию какого-либо сервиса.
- [1] RFC 2104: HMAC: структура HMAC и использование секрета.
- [2] Документация Python hmac: compare_digest и API HMAC.
- [3] Документация Python hashlib: интерфейс алгоритмов хеширования и байты digest.
- [4] OWASP Webhook Security: общие проверки временной метки, защиты от повторов, исходного тела и идемпотентности.
Не тестировались webhooks конкретных провайдеров, TLS, HTTP/2, серверная ротация подписей или биллинг. Локальные проверки подтверждают только описанный HMAC-протокол и границы приёмника.