> ## Documentation Index
> Fetch the complete documentation index at: https://oblodai.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Безопасность вебхуков за пределами подписи

Проверка подписи вебхука — необходимый минимум, но не полная защита. Эта инструкция про то, что кусает
уже в проде и стоит денег: защиту от повторного проигрывания (replay), перепроверку статуса перед
выдачей ценного, и оставшиеся острые углы приёма вебхуков.

Базовая проверка подписи описана в [Настройке вебхуков](/guides/webhooks-setup) и
[Объекте вебхука](/reference/webhook-object) — здесь мы идём глубже и не повторяем её.

***

## Что подпись НЕ гарантирует

Верная подпись доказывает две вещи: тело пришло от Oblodai и не изменено в пути. Она **не** гарантирует:

* что это уведомление **свежее**, а не перехваченное и проигранное заново позже (replay);
* что описанное в теле состояние **всё ещё актуально** к моменту, когда вы его обрабатываете;
* что вы не обрабатываете это событие **повторно** (доставка at-least-once).

Каждый пункт закрывается отдельно.

***

## 1. Replay-защита по timestamp

В каждой доставке есть заголовок `X-Webhook-Timestamp` — момент отправки в unix-секундах, и он **входит
в подписанную строку** (`hex(HMAC-SHA256(secret, "{timestamp}." + сырое_тело))`). Значит, timestamp
нельзя подменить, не сломав подпись.

Используйте это: **отклоняйте вебхуки со слишком старым timestamp**. Тогда даже валидно подписанное, но
перехваченное ранее уведомление нельзя будет проиграть спустя время.

<CodeGroup>
  ```python Python theme={null}
  import time, hmac, hashlib

  MAX_WEBHOOK_AGE = 300   # 5 минут — подберите под свою терпимость

  def verify_fresh(secret: bytes, ts_header: str, raw_body: bytes, sig_header: str) -> bool:
      # 1) подпись
      expected = hmac.new(secret, ts_header.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(expected, sig_header):
          return False
      # 2) свежесть
      try:
          age = abs(time.time() - int(ts_header))
      except ValueError:
          return False
      return age <= MAX_WEBHOOK_AGE
  ```

  ```js Node.js theme={null}
  import crypto from "node:crypto";
  const MAX_WEBHOOK_AGE = 300;

  function verifyFresh(secret, tsHeader, rawBody, sigHeader) {
    const expected = crypto.createHmac("sha256", secret).update(`${tsHeader}.${rawBody}`).digest("hex");
    const a = Buffer.from(expected), b = Buffer.from(sigHeader || "");
    // timingSafeEqual бросает исключение на разной длине — сверяем длину сами.
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
    const age = Math.abs(Date.now() / 1000 - Number(tsHeader));
    return Number.isFinite(age) && age <= MAX_WEBHOOK_AGE;
  }
  ```
</CodeGroup>

<Note>
  **Про окно и ретраи.** `X-Webhook-Timestamp` **обновляется на каждой попытке доставки** — при ретрае
  Oblodai переподписывает тело со свежей меткой времени. Поэтому окно `300` секунд **не** отбраковывает
  легитимные повторы (даже если сам ретрай случился через час): каждая доставка «свежая» на момент
  отправки. `300` c — разумная отправная точка; сужать окно ради «защиты от ретраев» не нужно.
</Note>

***

## 2. Дедупликация — защита от повторной обработки

Доставка **at-least-once**: одно событие может прийти несколько раз (в том числе после
[`/v1/payment/resend`](/reference/payment-resend) или ретраев). Дедуплицируйте по паре
`uuid` + `status` и обрабатывайте повтор как no-op.

<CodeGroup>
  ```python Python theme={null}
  def handle(event):
      key = (event["uuid"], event["status"])
      if seen(key):            # ваша БД/кэш
          return               # уже обработано — идемпотентный no-op
      process(event)
      mark_seen(key)
  ```

  ```js Node.js theme={null}
  function handle(event) {
    const key = `${event.uuid}:${event.status}`;
    if (seen(key)) {           // ваша БД/кэш
      return;                  // уже обработано — идемпотентный no-op
    }
    processEvent(event);
    markSeen(key);
  }
  ```
</CodeGroup>

Ключ должен жить в **надёжном** хранилище (БД), а не в памяти процесса — иначе после рестарта дедуп
сбросится. Порядок доставок не гарантирован: опирайтесь на `status`/`is_final`, а не на очерёдность.

***

## 3. Перепроверка статуса перед выдачей ценного

Вебхук — сигнал «сходи и проверь», а для дорогих действий (отгрузка товара, крупное зачисление,
разблокировка услуги) — не единственный источник правды. Перед необратимой выдачей **перепроверьте
состояние авторитетным запросом**:

<CodeGroup>
  ```python Python theme={null}
  def on_paid_webhook(event):
      # не доверяем телу вебхука на 100% для дорогой операции —
      # спрашиваем актуальный статус у API
      info = call("/v1/payment/info", {"uuid": event["uuid"]})["result"]
      if info["payment_status"] in ("paid", "paid_over"):
          fulfil_order(info["order_id"])   # идемпотентно
  ```

  ```js Node.js theme={null}
  async function onPaidWebhook(event) {
    // не доверяем телу вебхука на 100% для дорогой операции —
    // спрашиваем актуальный статус у API
    const info = (await call("/v1/payment/info", { uuid: event.uuid })).result;
    if (["paid", "paid_over"].includes(info.payment_status)) {
      await fulfilOrder(info.order_id);   // идемпотентно
    }
  }
  ```
</CodeGroup>

Это закрывает случаи, когда состояние изменилось между отправкой вебхука и его обработкой у вас, и
добавляет второй независимый барьер к проверке подписи. Для мелких/некритичных событий такой шаг можно
опустить — решайте по цене ошибки.

***

## 4. Приёмник: острые углы

* **Только HTTPS, публичный адрес.** При регистрации URL проходит SSRF-проверку (приватные/локальные
  адреса запрещены). → [`/v1/webhooks`](/reference/webhooks-register)
* **Сырое тело до парсинга.** Подпись считается по байтам; фреймворк, который пересериализует JSON,
  сломает проверку (`express.json()` на роуте вебхука — частая ошибка).
* **Секрет вебхука — это секрет.** Храните его как ключ API. Помните: повторный вызов
  [`/v1/webhooks`](/reference/webhooks-register) **меняет** URL и выдаёт **новый** секрет —
  старый перестанет подходить.
* **Пробные тела не подписаны** (`is_test: true`). Ваш код должен их отличать и не проверять подписью
  — но и не выполнять по ним реальную выдачу. → [Отладка вебхуков](/guides/webhooks-debug)
* **`2xx` только после успешной обработки.** Иначе Oblodai повторит доставку (это фича, не баг).
* **Отвечайте быстро, обрабатывайте асинхронно.** Тяжёлую работу выносите в очередь, а вебхуку
  отвечайте `2xx` сразу после того, как надёжно приняли событие — так вы не упрётесь в таймауты
  доставки и ретраи.

***

## Мини-чек-лист безопасности вебхуков

* Подпись проверяется по **сырому телу** правильным алгоритмом (timestamp + `.` + тело).
* Секрет — из [`/v1/webhooks`](/reference/webhooks-register), не ключ API.
* Проверяется **свежесть** по `X-Webhook-Timestamp` (replay-защита).
* Дедуп по `uuid` + `status` в надёжном хранилище (не в памяти).
* Для дорогих операций — перепроверка статуса через `*/info`.
* Пробные тела (`is_test`) отсекаются от реальной выдачи.
* `2xx` возвращается только после успешной обработки.
* Endpoint только HTTPS, тяжёлая работа — асинхронно.

***

## Связанные страницы

<CardGroup cols={2}>
  <Card title="Настройка вебхуков" href="/guides/webhooks-setup" icon="arrow-right" horizontal />

  <Card title="Объект вебхука" href="/reference/webhook-object" icon="arrow-right" horizontal />

  <Card title="Отладка вебхуков" href="/guides/webhooks-debug" icon="arrow-right" horizontal />

  <Card title="Рецепт устойчивого клиента" href="/guides/resilient-client" icon="arrow-right" horizontal />

  <Card title="Безопасность в проде" href="/guides/production-security" icon="arrow-right" horizontal />
</CardGroup>
