Проверка подписи
Каждая доставка webhook содержит два заголовка, используемых вместе:
X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b
X-Timestamp: 1729536000Hex-строка после sha256= — это
HMAC-SHA256(secret, timestamp + "." + raw_body).
Точка — это буквальный байт; timestamp — это unix-секунды как ASCII.
Зачем проверять
URL’ы webhook’ов утекают. Они появляются в логах прокси, на
скриншотах, в истории браузера, в тикетах поддержки партнёров. Без
проверки подписи любой, кто узнаёт ваш URL, может сделать POST
фейкового события payment.settled и обмануть вас, заставив обработать
неоплаченные заказы. Проверка криптографически доказывает, что запрос
пришёл от InfraIO.
Включение timestamp внутрь подписанного payload’а также даёт вам защиту от replay: атакующий, захвативший доставку, не может переотправить её позже без того, чтобы подпись стала заметно устаревшей.
Алгоритм
signed_payload = timestamp + "." + raw_body
expected_header = "sha256=" + lowercase_hex( HMAC_SHA256(secret, signed_payload) )
constant_time_compare(expected_header, x_signature_header)Затем проверьте, что timestamp недавний (типичный допуск: ±5 минут).
Всегда передавайте сырые байты тела запроса. Фреймворки часто
парсят JSON до того, как запускается ваш обработчик; пересериализованная
версия может отличаться от того, что мы отправили (порядок ключей,
пробелы, форматирование чисел), и HMAC не совпадёт. В Next.js App
Router используйте await req.text() до JSON.parse. В Express
монтируйте express.raw({ type: 'application/json' }) только на
маршрут webhook.
Реализации
Node / TS
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyInfraIo({
body,
signature,
timestamp,
secret,
}: {
body: string; // сырой текст — НЕ распарсенный JSON
signature: string; // значение заголовка X-Signature
timestamp: string; // значение заголовка X-Timestamp (unix-секунды)
secret: string; // whsec_…
}): boolean {
const ts = Number.parseInt(timestamp, 10);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) {
return false; // слишком старый или слишком далеко в будущем
}
const expected = "sha256=" + createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Защита от replay
Timestamp внутри подписанного payload’а — это первая линия защиты — атакующий, захвативший доставку, не может переотправить её после истечения вашего окна допуска.
Belt-and-braces (рекомендуется для дорогих событий вроде
payment.settled):
- Дедуплицируйте по
X-Deliveryв таблице с unique-constraint. Replay’и внутри окна допуска становятся no-op — ваш обработчик возвращает 200, не выполняя работу дважды. Это та же идемпотентность, которую вы хотите для легитимных повторов. (X-Deliveryстабилен между каждым повтором доставки; payload не несёт поляevent_id.) - Используйте наименьший допуск, который позволяет ваш дрифт часов. ±5 минут — рекомендуемый дефолт, и большинство NTP-синхронизированных флотов это выдерживает. Жёстче нормально; ниже ±30 секунд вы начнёте отклонять легитимные доставки в сетях с медленным upstream NTP.
Ротация секрета
- Панель → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.
- Новый секрет генерируется и показывается ровно один раз. Скопируйте его до закрытия диалога.
- Обновите вашу env var и переразверните verifier в течение 24 часов.
Grace-окно (двойная подпись)
В течение 24 часов после ротации каждая доставка несёт две подписи:
X-Signature: sha256=<hmac(new_secret, ts + "." + body)>
X-Signature-Prev: sha256=<hmac(prev_secret, ts + "." + body)>
X-Timestamp: 1729536000Verifier, работающий с предыдущим секретом, совпадёт с
X-Signature-Prev; verifier, работающий с новым секретом,
совпадёт с X-Signature. Прохождения любого из заголовков достаточно
— ваш обработчик может принять доставку во время миграции, не
удерживая деплой.
После закрытия grace-окна отправляется только X-Signature.
Предыдущий секрет перестаёт приниматься, и любой verifier, всё ещё
сконфигурированный с ним, начнёт отклонять доставки — поэтому
завершите выкатку в пределах 24-часового бюджета.
Предлагаемый паттерн получателя
// Принимать любую подпись в течение grace-окна ротации.
const sig = req.headers["x-signature"] ?? "";
const sigPrev = req.headers["x-signature-prev"] ?? "";
const ok = verify(body, sig, ts, CURRENT_SECRET)
|| (PREV_SECRET && verify(body, sigPrev, ts, PREV_SECRET));Вы можете убрать ветку X-Signature-Prev, как только grace-окно на
вашем endpoint’е истекло и вы убрали PREV_SECRET из env.
Экстренный отзыв
Если секрет утёк публично и вам нужно инвалидировать предыдущий секрет немедленно — то есть вы не хотите, чтобы 24-часовое перекрытие держало известно-плохой ключ живым — ротируйте дважды. Первая ротация сдвигает утёкший секрет в prev-слот; вторая ротация выталкивает его из prev-слота (заменяя его всё ещё новым ключом), так что утёкшее значение больше не принимается.
Тестирование подключения
В панели откройте Developers → Webhooks и нажмите Send Test на endpoint’е, который хотите проверить. Мы подписываем и делаем POST синтетического конверта на URL синхронно, затем показываем HTTP-статус, latency и 512-байтный сниппет вашего ответа. Форма payload’а:
{
"event_id": "<uuid>",
"event_type": "webhook.test.ping",
"created_at": "2026-05-17T12:00:00Z",
"test": true,
"data": {
"merchant_id": "<your-merchant-id>",
"webhook_id": "<endpoint-id>",
"message": "Test ping from the merchant dashboard..."
}
}Test-ping использует ту же схему подписи, что и продакшен-доставки, поэтому зелёная галочка от этой кнопки подтверждает, что ваш verifier принимает и реальные события. Test-ping минует RMQ-pipeline повторов — если хотите упражнять повторы, запустите реальное событие через соответствующий API-поток.
Типичные сбои
| Симптом | Вероятная причина |
|---|---|
| Всегда возвращает false в dev | Тело было распарсено как JSON до HMAC. Сначала читайте сырые байты. |
| Работало вчера, не работает сегодня | Вы ротировали секрет, но env var на этом сервере всё ещё хранит старый. Переразверните с новым секретом. |
| Не работает для старых событий, работает для новых | Доставка была поставлена в очередь до ротации; подпись использует старый секрет, и ваш verifier его больше не принимает. Подождите, пока повтор уронит её, или проиграйте заново через панель. |
| Off-by-one на сравнении timestamp | Убедитесь, что сравниваете unix-секунды с unix-секундами. Date.now() в JS — это миллисекунды — делите на 1000. |
| Работает локально, не работает в проде | Прокси (Cloudflare, nginx) распаковывает, переэнкодит или срезает завершающий newline. Проинспектируйте байты, которые видит ваш обработчик. |
| Test-ping говорит 401 / signature mismatch | Ваш verifier подписывает только body (схема до 2026). Обновите подписание на timestamp + "." + body. |
| Заголовок полностью отсутствует | Endpoint зарегистрирован для другого окружения. Test-mode endpoint’ы получают только события environment=test. |