Вебхуки: подписанные данные и надёжные повторы

Backend2026-11-05TryQuickToolBox

Ваша интеграция работает на стейджинге: платёжный провайдер отправляет событие, ваш эндпоинт его получает, заказ переходит в статус paid. Но в продакшене клиент платит дважды, доставка бесследно исчезает во время деплоя, а кто-то повторяет перехваченный запрос, чтобы пометить заказ как возвращённый. Все три сбоя имеют одну корневую причину: к вебхуку отнеслись как к простому HTTP POST, а не как к критичному для безопасности каналу доставки с гарантией «хотя бы один раз».

Это руководство охватывает две половины правильной реализации вебхуков — проверку подлинности данных и обеспечение выживания доставки при повторных попытках — с кодом, который можно адаптировать под любой стек.

Что такое вебхук на самом деле

Вебхук — это HTTP-запрос между серверами, который одна система отправляет другой при возникновении события. Он инвертирует обычную модель опроса: вместо того чтобы ваше приложение каждую минуту спрашивало «есть что-нибудь новое?», производитель отправляет событие на URL, который вы зарегистрировали.

Из этого дизайна следуют два свойства, и оба важны:

Всё нижеследующее — следствие этих двух фактов.

Подписание данных с помощью HMAC

Стандартный подход — общий секрет плюс HMAC по сырому телу запроса. Отправитель вычисляет подпись и помещает её в заголовок; вы пересчитываете её и сравниваете. Если они совпадают, значит, данные пришли от того, кто знает секрет, и не были изменены при передаче.

Три детали отличают рабочую реализацию от сломанной:

  1. Подписывайте сырые байты. Разбирайте JSON только после проверки. Повторная сериализация тела меняет пробелы и порядок ключей, и подпись никогда не совпадёт.
  2. Используйте сравнение с постоянным временем. Наивное == раскрывает информацию о времени выполнения. Используйте встроенную в язык безопасную функцию сравнения.
  3. Включайте метку времени в подписываемую строку. Подпись timestamp + "." + body позволяет отклонять старые запросы, что блокирует повторные атаки.

Проверка подписи в Node.js

const crypto = require('crypto');

function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const signature = headers['x-signature'];   // e.g. "t=1712345678,v1=abc123..."
  const timestamp = headers['x-timestamp'];
  if (!signature || !timestamp) return false;

  // 1. Reject stale requests (replay protection)
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || age > toleranceSeconds) return false;

  // 2. Recompute over the exact bytes received
  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // 3. Constant-time compare
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

В Express для этого требуется express.raw({ type: 'application/json' }) на маршруте вебхука, чтобы тело приходило как Buffer. Если глобальный middleware express.json() выполнится первым, сырое тело будет потеряно и проверка не пройдёт — очень распространённая ошибка.

Проверка в Python

import hmac, hashlib, time

def verify(raw_body: bytes, timestamp: str, signature: str, secret: bytes, tolerance: int = 300) -> bool:
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Всегда возвращайте 401 при неудачной проверке и логируйте ID запроса — но никогда не логируйте секрет или полную подпись.

Проектирование повторных попыток, которые действительно работают

Повторные попытки полезны только в том случае, если ваш эндпоинт ведёт себя предсказуемо. Контракт прост: быстро верните 2xx, как только надёжно приняли событие, а всё остальное пусть будет повторено.

Ваш ответПоведение отправителяЧто делать вместо этого
200 / 204Пометить как доставленноеВерно — подтверждайте после постановки в очередь
400 / 401Обычно без повтораИсправьте обработку подписи или данных
429 / 503Повтор с задержкойВозвращайте при перегрузке, а не общий 500
500 / тайм-аутПовторПеренесите медленную работу в очередь

Два правила поддерживают здоровье системы:

Сделайте обработчики идемпотентными

Поскольку доставка гарантируется «хотя бы один раз», один и тот же ID события может прийти дважды — один раз после тайм-аута, когда ваш обработчик на самом деле выполнился успешно. Сохраняйте обработанные ID событий и пропускайте дубликаты:

INSERT INTO processed_events (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- If 0 rows inserted, this is a duplicate: return 200 and stop.

Где возможно, делайте идемпотентной саму бизнес-операцию — устанавливайте статус заказа в paid вместо инкремента счётчика и используйте ID события как ключ идемпотентности при вызове нижестоящих сервисов.

Чек-лист перед запуском в продакшен

  1. Проверяйте HMAC по сырым байтам с сравнением за постоянное время.
  2. Отклоняйте метки времени старше нескольких минут, чтобы остановить повторные атаки.
  3. Сохраните событие, затем верните 2xx в течение секунды-двух.
  4. Выполняйте дедупликацию по ID события перед любой работой.
  5. Логируйте ID события, тип и результат — никогда секрет.
  6. Предоставьте способ вручную повторно отправить события из панели управления или административного инструмента.
  7. Тестируйте с намеренно сломанными подписями, дублирующимися доставками и медленными обработчиками.

Когда вы отлаживаете неработающую интеграцию, сырое тело запроса — это то, на что вы будете смотреть чаще всего. Вставьте его в JSON-форматтер, чтобы сделать вложенные объекты событий читаемыми, не изменяя уже захваченные байты.

Часто задаваемые вопросы

Почему проверка подписи не проходит, хотя данные выглядят идентично?

Почти всегда из-за того, что тело было разобрано и повторно сериализовано до хеширования. Порядок ключей JSON, пробелы и экранирование Unicode меняются при parse/stringify, поэтому байты больше не совпадают с теми, что подписал отправитель. Захватывайте сырое тело и хешируйте именно его.

Возвращать 200 до или после обработки события?

После надёжного сохранения, но до выполнения тяжёлой работы. Если сначала обрабатывать, медленные вызовы нижестоящих сервисов приводят к тайм-аутам, отправитель повторяет попытку, и вы получаете дубликаты. Поставьте событие в очередь, ответьте немедленно и обрабатывайте асинхронно.

Достаточно ли HTTPS для защиты эндпоинта вебхука?

Нет. TLS защищает данные при передаче, но не доказывает, кто отправил запрос — любой может отправить POST на публичный URL. Подпись HMAC с общим секретом аутентифицирует отправителя, а подписанная метка времени предотвращает повторное использование перехваченных запросов.

Итог

Относитесь к вебхукам как к любым недоверенным входным данным: проверяйте подпись по сырым байтам, проверяйте метку времени, выполняйте дедупликацию по ID события и подтверждайте только после надёжного сохранения события. Делая так, вы перестанете получать дублирующиеся списания из-за повторов, и они станут тем, чем и должны быть — страховочной сетью.