Skip to Content
WebhooksПроверка подписи

Проверка подписи

Каждая доставка webhook содержит два заголовка, используемых вместе:

X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b X-Timestamp: 1729536000

Hex-строка после 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.

Реализации

lib/verify-infraio.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):

  1. Дедуплицируйте по X-Delivery в таблице с unique-constraint. Replay’и внутри окна допуска становятся no-op — ваш обработчик возвращает 200, не выполняя работу дважды. Это та же идемпотентность, которую вы хотите для легитимных повторов. (X-Delivery стабилен между каждым повтором доставки; payload не несёт поля event_id.)
  2. Используйте наименьший допуск, который позволяет ваш дрифт часов. ±5 минут — рекомендуемый дефолт, и большинство NTP-синхронизированных флотов это выдерживает. Жёстче нормально; ниже ±30 секунд вы начнёте отклонять легитимные доставки в сетях с медленным upstream NTP.

Ротация секрета

  1. Панель → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.
  2. Новый секрет генерируется и показывается ровно один раз. Скопируйте его до закрытия диалога.
  3. Обновите вашу 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: 1729536000

Verifier, работающий с предыдущим секретом, совпадёт с 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.