РуководстваОпубликовано: 08.11.2026• 0 просмотров

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 и последний перевод строки меняют байтовую строку. Сжатие или изменение шлюзом тоже может произойти до или после проверки; нужно установить, какое представление покрывает подпись.

Обычно соблюдайте такой порядок:

  1. Считайте исходные bytes до первого разбора тела фреймворком и ограничьте размер согласно протоколу.
  2. Храните защищённую копию исходных байтов или зашифрованный/обезличенный digest для диагностики.
  3. Склейте метку времени, префикс версии или nonce по официальному протоколу, не угадывая разделитель.
  4. Сравнивайте digest постоянным по времени способом, а не обычным сравнением строк.
  5. Только после успешной проверки разбирайте 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_TIMESTAMPNTP, часовой пояс контейнера и задержку проксисобытие обязательно является атакой
При повторе подпись проходит, но эффект повторяется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 вычислите правильную подпись и проверьте:

  1. полностью неизменённое тело — должно пройти;
  2. изменение только пробелов JSON или последнего перевода строки — должно завершиться ошибкой;
  3. метка времени за пределами допуска — должна дать STALE_TIMESTAMP;
  4. два параллельных события с одним 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 в статье является учебным и не выдаётся за спецификацию какого-либо сервиса.

Не тестировались webhooks конкретных провайдеров, TLS, HTTP/2, серверная ротация подписей или биллинг. Локальные проверки подтверждают только описанный HMAC-протокол и границы приёмника.

Опубликовано: 8 ноября 2026 г.
最后更新: 08.11.2026

Похожие статьи

Руководства

Длинный ID из API изменился во фронтенде? Точность JavaScript и передача строк

Изменились последние цифры ID, поиск вернул 404 или номера в счёте слились? Проверьте текст JSON, разбор, хранение и повторную отправку; узнайте, почему String и BigInt иногда уже не помогут, и проверьте передачу строковых ID на локальных примерах.

Руководства

Как считать стабильность API: исходные запросы, повторы и конечная успешность

Успех после повтора нельзя считать новым независимым успехом. Статья разделяет request_id, попытки и конечные состояния, объясняет границы 429, 503, тайм-аутов и идемпотентности, а локальный скрипт считает конечную успешность и усиление повторами.

Руководства

API вернул 202 без результата: состояния асинхронных заданий, опрос и повторная отправка

Различайте приём задания, успешную проверку статуса и завершение работы. Сохраняйте ID, ограничивайте опрос и разбирайте 404, истечение срока результата и неизвестный исход отправки без повторного создания задания.

Отказ от ответственности

Данные о сервисах и ценах вводятся вручную; доступность сайтов проверяется автоматически с указанием времени последней проверки.Информация о провайдерах может меняться, поэтому перед оплатой рекомендуем посетить официальный сайт сервиса. Мы не гарантируем качество услуг сторонних провайдеров.

© 2026 Выбор API. All rights reserved.