# Webhook EasyPay: проверка подписи

> **Этот документ описывает, как проверить, что webhook действительно отправлен EasyPay.** Структуру данных самого платежа см. в [Webhook EasyPay: структура данных платежей Stripe](https://docs.thenextgen.store/s/635a43c1-37fa-463b-bc7e-7ede7884fce2/doc/webhook-easypay-struktura-dannyh-platezhej-stripe-2aK80hyqzo).

## Общая информация

EasyPay подписывает каждый webhook, который отправляет на ваш сервер, — чтобы вы могли убедиться, что запрос действительно пришёл от EasyPay и не был изменён по дороге. Проверка занимает несколько строк кода (примеры для Node.js и Python — ниже).

> **Обратная совместимость.** Пока у вас не настроен секрет подписи, webhook'и отправляются **без подписи** (без заголовков подписи), ровно как раньше. Подпись включается для вашего endpoint в тот момент, когда у вас появляется секрет.

## Заголовки подписи

Тело JSON при этом **не меняется** — подпись передаётся только в заголовках:

| Заголовок | Значение |
|-----------|----------|
| `EasyPay-Webhook-Id` | Уникальный id события. Используйте для идемпотентной обработки (дедупликация повторных доставок). |
| `EasyPay-Timestamp` | Время подписи в Unix-секундах. |
| `EasyPay-Signature` | `v1,<hex>` — подпись HMAC-SHA256. В период ротации секрета может содержать **два** значения через пробел: `v1,<hex> v1,<hex>`. |

## Что подписывается

Подпись — это `HMAC-SHA256(секрет, "{id}.{timestamp}.{тело}")` в hex-кодировке, где:

* `id` — заголовок `EasyPay-Webhook-Id`
* `timestamp` — заголовок `EasyPay-Timestamp`
* `тело` — **точные байты** тела запроса

> ⚠️ Проверяйте подпись над **сырыми байтами тела**, как их получили. Не парсите и не пере-сериализуйте JSON перед проверкой — изменение порядка ключей или пробелов сломает подпись.

## Как получить секрет

Секрет выдаётся при регистрации вашего webhook endpoint:

* `register_partner_notifications_webhook` возвращает `registered[].signing_secret`. Ручка **идемпотентна (get-or-create)** и **всегда возвращает текущий секрет**: первый вызов минтит новый (`secret_status: "issued"`), повторный — возвращает тот же (`secret_status: "returned"`). Потеряли секрет — просто вызовите register ещё раз, он его вернёт.
* Хотите **сменить** секрет (утечка / плановая ротация)? Вызовите `rotate_partner_webhook_secret` (см. раздел «Ротация секрета») — она выдаёт новый секрет, старый живёт grace-период.

Секреты выглядят как `whsec_live_…` / `whsec_test_…` и **отдельны для каждой среды** — у test и live свои секреты.

## Как проверить подпись


1. Отклоните запрос, если `EasyPay-Timestamp` отличается от вашего времени более чем на **5 минут** (300 секунд). Это ограничивает replay-атаки.
2. Пересчитайте `HMAC-SHA256(секрет, "{id}.{timestamp}.{тело}")`.
3. Сравните — **в постоянном времени** — с каждым значением `v1,<hex>` в заголовке `EasyPay-Signature`. Примите запрос, если совпало хотя бы одно.
4. Дедуплицируйте по `EasyPay-Webhook-Id`, чтобы повторная доставка обработалась только один раз.

### Node.js

```js
const crypto = require("crypto");

function verify(rawBody, headers, secret) {
  const id = headers["easypay-webhook-id"];
  const ts = headers["easypay-timestamp"];
  // 1. окно времени (защита от replay)
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("stale timestamp");

  // 2. пересчитываем подпись над сырым телом
  const expected = crypto.createHmac("sha256", secret)
    .update(`${id}.${ts}.${rawBody}`)
    .digest("hex");

  // 3. сравниваем в постоянном времени с каждым v1-значением
  const ok = headers["easypay-signature"].split(" ")
    .map(p => p.split(",")[1])
    .some(sig => sig && sig.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig)));

  if (!ok) throw new Error("bad signature");
}
```

### Python

```python
import hmac, hashlib, time

def verify(raw_body: bytes, headers: dict, secret: str):
    wid = headers["EasyPay-Webhook-Id"]
    ts = headers["EasyPay-Timestamp"]
    # 1. окно времени (защита от replay)
    if abs(time.time() - int(ts)) > 300:
        raise ValueError("stale timestamp")

    # 2. пересчитываем подпись над сырым телом
    msg = f"{wid}.{ts}.".encode() + raw_body
    expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()

    # 3. сравниваем в постоянном времени с каждым v1-значением
    presented = [p.split(",")[1] for p in headers["EasyPay-Signature"].split(" ")]
    if not any(hmac.compare_digest(expected, s) for s in presented):
        raise ValueError("bad signature")
```

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

Вызовите `rotate_partner_webhook_secret` с параметрами `environment` (`test` или `live`) и `client_token`. Он возвращает новый `signing_secret` (показывается один раз) и метку времени `previous_valid_until`.

В течение grace-периода (до `previous_valid_until`) EasyPay подписывает каждый webhook **обоими** секретами — новым и предыдущим (в заголовке появляются два значения `v1,`), — чтобы вы могли перейти на новый секрет без потери событий:


1. Разверните новый секрет рядом со старым и принимайте webhook, если проходит **любой** из них. Сниппеты выше уже перебирают все значения `v1,`, поэтому достаточно прогнать проверку по каждому вашему секрету.
2. Когда новый секрет разъедется на все инстансы — уберите старый. Успейте до `previous_valid_until`.

Ротируйте секрет при подозрении на утечку или по расписанию. (Потеряли секрет — ротация не нужна: просто вызовите `register_partner_notifications_webhook` ещё раз, он вернёт текущий.)

## Примечание

Схема повторяет то, как подписывают webhook'и **Stripe** и **Slack** (HMAC-SHA256 над телом с временной меткой) — применимы та же логика и те же готовые библиотеки проверки.