Вебхуки: подписанные данные и надёжные повторы
Ваша интеграция работает на стейджинге: платёжный провайдер отправляет событие, ваш эндпоинт его получает, заказ переходит в статус paid. Но в продакшене клиент платит дважды, доставка бесследно исчезает во время деплоя, а кто-то повторяет перехваченный запрос, чтобы пометить заказ как возвращённый. Все три сбоя имеют одну корневую причину: к вебхуку отнеслись как к простому HTTP POST, а не как к критичному для безопасности каналу доставки с гарантией «хотя бы один раз».
Это руководство охватывает две половины правильной реализации вебхуков — проверку подлинности данных и обеспечение выживания доставки при повторных попытках — с кодом, который можно адаптировать под любой стек.
Что такое вебхук на самом деле
Вебхук — это HTTP-запрос между серверами, который одна система отправляет другой при возникновении события. Он инвертирует обычную модель опроса: вместо того чтобы ваше приложение каждую минуту спрашивало «есть что-нибудь новое?», производитель отправляет событие на URL, который вы зарегистрировали.
Из этого дизайна следуют два свойства, и оба важны:
- Ваш эндпоинт публичен. Любой в интернете может отправить на него POST, поэтому каждый запрос должен быть аутентифицирован.
- Доставка гарантируется «хотя бы один раз». Сети отказывают, серверы перезагружаются, а тайм-ауты неоднозначны — отправитель не может знать, выполнился ли ваш обработчик. Поэтому он повторяет попытки, и вы можете увидеть одно и то же событие более одного раза.
Всё нижеследующее — следствие этих двух фактов.
Подписание данных с помощью HMAC
Стандартный подход — общий секрет плюс HMAC по сырому телу запроса. Отправитель вычисляет подпись и помещает её в заголовок; вы пересчитываете её и сравниваете. Если они совпадают, значит, данные пришли от того, кто знает секрет, и не были изменены при передаче.
Три детали отличают рабочую реализацию от сломанной:
- Подписывайте сырые байты. Разбирайте JSON только после проверки. Повторная сериализация тела меняет пробелы и порядок ключей, и подпись никогда не совпадёт.
- Используйте сравнение с постоянным временем. Наивное
==раскрывает информацию о времени выполнения. Используйте встроенную в язык безопасную функцию сравнения. - Включайте метку времени в подписываемую строку. Подпись
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 / тайм-аут | Повтор | Перенесите медленную работу в очередь |
Два правила поддерживают здоровье системы:
- Подтверждайте быстро. Проверьте, сохраните событие в очередь или таблицу, ответьте
200. Выполняйте реальную работу асинхронно. Обработчик, который вызывает три API перед ответом, вызовет тайм-аут и спровоцирует дублирующиеся доставки. - Используйте экспоненциальную задержку с джиттером. Если отправитель повторяет попытку немедленно, кратковременный сбой превращается в лавину запросов. Расписание вроде 10 с, 30 с, 2 мин, 10 мин, 1 ч со случайным джиттером распределяет нагрузку.
Сделайте обработчики идемпотентными
Поскольку доставка гарантируется «хотя бы один раз», один и тот же 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 события как ключ идемпотентности при вызове нижестоящих сервисов.
Чек-лист перед запуском в продакшен
- Проверяйте HMAC по сырым байтам с сравнением за постоянное время.
- Отклоняйте метки времени старше нескольких минут, чтобы остановить повторные атаки.
- Сохраните событие, затем верните
2xxв течение секунды-двух. - Выполняйте дедупликацию по ID события перед любой работой.
- Логируйте ID события, тип и результат — никогда секрет.
- Предоставьте способ вручную повторно отправить события из панели управления или административного инструмента.
- Тестируйте с намеренно сломанными подписями, дублирующимися доставками и медленными обработчиками.
Когда вы отлаживаете неработающую интеграцию, сырое тело запроса — это то, на что вы будете смотреть чаще всего. Вставьте его в JSON-форматтер, чтобы сделать вложенные объекты событий читаемыми, не изменяя уже захваченные байты.
Часто задаваемые вопросы
Почему проверка подписи не проходит, хотя данные выглядят идентично?
Почти всегда из-за того, что тело было разобрано и повторно сериализовано до хеширования. Порядок ключей JSON, пробелы и экранирование Unicode меняются при parse/stringify, поэтому байты больше не совпадают с теми, что подписал отправитель. Захватывайте сырое тело и хешируйте именно его.
Возвращать 200 до или после обработки события?
После надёжного сохранения, но до выполнения тяжёлой работы. Если сначала обрабатывать, медленные вызовы нижестоящих сервисов приводят к тайм-аутам, отправитель повторяет попытку, и вы получаете дубликаты. Поставьте событие в очередь, ответьте немедленно и обрабатывайте асинхронно.
Достаточно ли HTTPS для защиты эндпоинта вебхука?
Нет. TLS защищает данные при передаче, но не доказывает, кто отправил запрос — любой может отправить POST на публичный URL. Подпись HMAC с общим секретом аутентифицирует отправителя, а подписанная метка времени предотвращает повторное использование перехваченных запросов.
Итог
Относитесь к вебхукам как к любым недоверенным входным данным: проверяйте подпись по сырым байтам, проверяйте метку времени, выполняйте дедупликацию по ID события и подтверждайте только после надёжного сохранения события. Делая так, вы перестанете получать дублирующиеся списания из-за повторов, и они станут тем, чем и должны быть — страховочной сетью.