<!-- Source: https://docs.infraio.xyz/ru/webhooks/signature-verification -->
<!-- Last updated: 2026-10-04 -->

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

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

```http
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 Pay.

Включение 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 минут).

> **Warning:**
>
> Всегда передавайте **сырые** байты тела запроса. Фреймворки часто
> парсят JSON до того, как запускается ваш обработчик; пересериализованная
> версия может отличаться от того, что мы отправили (порядок ключей,
> пробелы, форматирование чисел), и HMAC не совпадёт. В Next.js App
> Router используйте `await req.text()` *до* `JSON.parse`. В Express
> монтируйте `express.raw({ type: 'application/json' })` только на
> маршрут webhook.

## Реализации

**Node / TS**

```ts filename="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);
}
```

**Go**

```go
package infraio

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "strconv"
    "time"
)

const toleranceSeconds = 5 * 60

// VerifyWebhook возвращает true только если sig совпадает с
// HMAC-SHA256(secret, timestamp + "." + body) И timestamp
// в окне допуска.
func VerifyWebhook(body []byte, sig, timestamp, secret string) bool {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false
    }
    skew := time.Now().Unix() - ts
    if skew < 0 {
        skew = -skew
    }
    if skew > toleranceSeconds {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp))
    mac.Write([]byte("."))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) == 1
}
```

**Python**

```python
import hmac, hashlib, time

TOLERANCE_SECONDS = 5 * 60

def verify_infraio(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    """body: сырые байты. signature: 'sha256=<hex>'. timestamp: unix-секунды."""
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
        return False

    signed = timestamp.encode() + b"." + body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)
```

**Ruby**

```ruby
require 'openssl'

TOLERANCE_SECONDS = 5 * 60

def verify_infraio(body, signature, timestamp, secret)
  ts = Integer(timestamp) rescue (return false)
  return false if (Time.now.to_i - ts).abs > TOLERANCE_SECONDS

  signed   = "#{timestamp}.#{body}"
  expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
  Rack::Utils.secure_compare(signature.to_s, expected)
end
```

## Защита от replay

Timestamp внутри подписанного payload'а — это **первая** линия защиты
— атакующий, захвативший доставку, не может переотправить её после
истечения вашего окна допуска.

Для дополнительной надёжности (рекомендуется для дорогих событий вроде
`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 часов** после ротации каждая доставка несёт две
подписи:

```http
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-часового бюджета.

### Предлагаемый паттерн получателя

```ts
// Принимать любую подпись в течение 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'а:

```json
{
  "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 не повторяется.
Чтобы проверить повторы, запустите реальное событие через
соответствующий 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`. Подписывайте `timestamp + "." + body`. |
| Заголовок полностью отсутствует | Endpoint зарегистрирован для другого окружения. Test-mode endpoint'ы получают только события `environment=test`. |
