Webhooks: подписанные данные и надёжные повторы
Вы внедряете платёжную интеграцию, провайдер отправляет вебхук, ваш эндпоинт возвращает 200 — но через три часа поддержка сообщает о пропавших заказах. Или хуже: одно и то же событие приходит четыре раза, и с клиента списывают деньги дважды. Вебхуки выглядят просто (HTTP POST на ваш URL), но сложные части — это доверие и гарантии доставки.
Это руководство охватывает оба аспекта: как проверить, что данные действительно пришли от отправителя, используя подписанные данные, и как построить модель повторных попыток, которая выдерживает простои, тайм-ауты и дублирование доставки.
Что на самом деле представляет собой вебхук
Вебхук — это обратный вызов API. Вместо того чтобы ваш сервер опрашивал GET /events каждую минуту, провайдер отправляет HTTP-запрос на URL, который вы зарегистрировали, когда что-то происходит. Данные обычно в формате JSON, метод почти всегда POST, а ответ, который вы возвращаете, — это сигнал: 2xx означает «получено», всё остальное — «попробуйте позже».
Это последнее предложение и есть весь контракт. Всё остальное — подписи, повторы, порядок — существует, чтобы сделать этот контракт безопасным в ненадёжной сети.
Почему неподписанные вебхуки опасны
Ваш эндпоинт вебхука — это публичный URL. Любой, кто его обнаружит, может отправить POST с поддельными данными, утверждающими, что заказ оплачен. Если вы доверяете телу запроса без проверки, вы создали открытую дверь в вашу логику выставления счетов.
Белые списки IP-адресов помогают лишь немного, но ломаются за CDN, при исходящем трафике serverless и при смене IP-адресов провайдера. Надёжное решение — общий секрет плюс подпись HMAC по необработанному телу запроса.
Как работают подписанные данные
Провайдер и вы используете общий секретный ключ. При каждой доставке провайдер вычисляет HMAC (обычно SHA-256) по необработанному телу — иногда по метке времени плюс тело — и отправляет шестнадцатеричный дайджест в заголовке, например X-Signature. Вы пересчитываете тот же дайджест с вашей копией секрета и сравниваете.
Две детали важнее, чем многие ожидают:
- Подписывайте необработанные байты. Если ваш фреймворк парсит JSON и повторно сериализует его, порядок ключей и пробелы меняются, и дайджест не совпадёт. Сначала прочитайте необработанное тело.
- Сравнивайте за постоянное время. Наивное
==раскрывает информацию о времени выполнения. Используйте функцию сравнения за постоянное время из криптобиблиотеки вашего языка.
Минимальный пример проверки (Node.js)
const crypto = require('crypto');
function verifySignature(rawBody, headerSig, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // raw Buffer or string, not parsed JSON
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(headerSig || '', 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
Тот же шаблон работает в Python с hmac.compare_digest, в Go с hmac.Equal и в Java с MessageDigest.isEqual. Принцип везде одинаков: HMAC по необработанным байтам, сравнение за постоянное время.
Защита от повторного воспроизведения с помощью меток времени
Действительная подпись доказывает, что данные пришли от кого-то, владеющего секретом, — но перехваченный запрос можно воспроизводить бесконечно. Провайдеры, которые заботятся об этом, включают метку времени в подписываемую строку, например t=1712345678,v1=<hex>. С вашей стороны:
- Извлеките метку времени из заголовка.
- Отклоните запрос, если он старше допустимого окна (часто 5 минут).
- Пересчитайте HMAC по
timestamp + "." + rawBody.
Это ограничивает ущерб от утечки запроса несколькими минутами вместо бесконечности.
Разработка повторных попыток, которые действительно доставляют
Сети отказывают, ваш сервис перезапускается, при деплое пересоздаются поды. Серьёзный провайдер повторяет неудачные доставки по расписанию. Серьёзный получатель должен исходить из того, что каждое событие может прийти более одного раза.
Правила повторных попыток на стороне отправителя
- Считайте любой ответ не-2xx, ошибку соединения или тайм-аут неудачей.
- Повторяйте с экспоненциальной задержкой и джиттером — например, 1 с, 5 с, 30 с, 5 мин, 30 мин, 2 ч — чтобы простой провайдера не вызвал лавину запросов при восстановлении.
- Ограничьте количество попыток (обычно 5–10) и записывайте окончательную неудачу, чтобы пользователь или оператор могли её увидеть.
- Установите короткий тайм-аут клиента (5–10 секунд). Обработчик вебхука должен быстро подтверждать получение и выполнять работу асинхронно.
Правила на стороне получателя
- Сначала проверка, потом парсинг. Отклоняйте неверные подписи с кодом 401, прежде чем обращаться к базе данных.
- Сохраните событие, затем верните 200. Запишите необработанные данные и ID события в очередь или таблицу, сразу ответьте и обработайте в воркере. Если обработка не удалась, повторите внутри системы.
- Сделайте обработчики идемпотентными. Используйте ID события от провайдера как уникальный ключ. Если он уже существует, верните 200, не выполняя побочные эффекты повторно.
- Возвращайте ошибки честно. Если ваша база данных недоступна, верните 500, чтобы провайдер повторил попытку, — не возвращайте 200 и не отбрасывайте событие молча.
| Ответ | Значение для отправителя | Что происходит дальше |
|---|---|---|
| 200 / 204 | Доставлено | Без повтора |
| 400 | Некорректный или отклонён | Обычно без повтора; логируйте для отладки |
| 401 | Неверная подпись | Без повтора; проверьте секрет |
| 429 | Превышен лимит запросов | Повтор с задержкой |
| 5xx / тайм-аут | Временная ошибка | Повтор с задержкой |
Идемпотентность на практике
Идемпотентность здесь не модное слово — это единственное, что стоит между повторной попыткой и двойным списанием. Шаблон:
INSERT INTO webhook_events (event_id, payload, received_at)
VALUES ($1, $2, now())
ON CONFLICT (event_id) DO NOTHING;
Если вставка не затронула ни одной строки, вы уже видели это событие; подтвердите и продолжайте. Это работает даже при одновременных доставках, потому что уникальное ограничение сериализует их на уровне базы данных.
Для побочных эффектов, которые должны произойти ровно один раз (отправка письма, списание с карты), передайте свой собственный ключ идемпотентности дальше — большинство платёжных и почтовых API принимают такой ключ.
Порядок, отладка и наблюдаемость
Не предполагайте, что события приходят по порядку. Повторы могут изменить порядок доставки, как и параллельные воркеры. Если порядок важен, включите в данные порядковый номер или метку времени и согласовывайте состояние, а не применяйте изменения слепо.
Логируйте каждую попытку доставки с ID события, результатом проверки подписи, кодом ответа и временем обработки. Когда клиент говорит «мой заказ так и не пришёл», вы хотите ответить за секунды: был ли вебхук получен, была ли подпись действительной, успешна ли обработка? Одной структурированной строки лога на событие достаточно.
Для эндпоинтов с высоким трафиком следите за штормами повторов после деплоя. Быстрый способ их заметить — посмотреть на шаблоны запросов в логах доступа: всплеск одинаковых путей с одного IP с ответами 5xx — классический цикл повторов. Инструменты вроде анализатора логов Nginx делают этот паттерн очевидным без написания запроса вручную.
Распространённые ошибки, которых следует избегать
- Парсинг JSON до проверки подписи.
- Использование одного и того же секрета в разных средах или отсутствие его ротации.
- Выполнение тяжёлой работы (генерация PDF, вызовы сторонних сервисов) внутри обработчика запроса.
- Возврат 200 при ошибках обработки, из-за чего провайдер никогда не повторит попытку.
- Доверие сумме или статусу из данных без повторного запроса ресурса из API провайдера, когда это важно.
Часто задаваемые вопросы
Как проверить подпись вебхука, если тело в формате JSON?
Прочитайте необработанное тело запроса как байты до любого парсинга JSON, вычислите HMAC по этим байтам и сравните с заголовком с помощью функции сравнения за постоянное время. Повторно сериализованный JSON даст другой дайджест.
Сколько раз следует повторять вебхук?
Универсального числа нет, но обычно это 5–10 попыток в течение нескольких часов с экспоненциальной задержкой и джиттером. Важно то, что получатели идемпотентны, поэтому повторы безопасны, даже если они частые.
Какой код состояния должен возвращать мой эндпоинт вебхука?
Возвращайте 2xx только после того, как вы надёжно сохранили событие. Возвращайте 401 при неверных подписях, 400 при некорректных данных и 5xx или 429, когда вы хотите, чтобы отправитель повторил попытку.
Если вы тестируете данные вебхуков или логируете получаемый JSON, форматтер JSON — быстрый способ отформатировать и проверить перехваченное событие, прежде чем строить вокруг него логику парсинга.