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

Этот документ описывает, как проверить, что webhook действительно отправлен EasyPay. Структуру данных самого платежа см. в Webhook EasyPay: структура данных платежей Stripe.

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

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

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

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 над телом с временной меткой) — применимы та же логика и те же готовые библиотеки проверки.