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

# Webhooks — Обзор

Webhook'и — **авторитетный** сигнал. Браузерные callback'и
(`onSuccess`) и представления панели — удобство; webhook'и — истина.

## Гарантии доставки

- **Не менее одного раза.** Одно событие может быть доставлено до
  **6 раз**, если ваш сервер не возвращает 2xx в течение таймаута.
  Делайте обработчик идемпотентным — дедуплицируйте по `X-Delivery`
  (payload не имеет поля `event_id`; стабильный delivery UUID — это
  ключ идемпотентности).
- **Одно событие на HTTP-запрос.** Без батчинга.
- **Изоляция per-endpoint.** Если у вас несколько зарегистрированных
  endpoint'ов, у каждого свой трек доставки и повторов. Один медленный
  endpoint не задерживает остальные.
- **Подписано.** Каждый payload несёт заголовок `X-Signature` (а в
  течение 24-часового окна после ротации — также `X-Signature-Prev`).
  Проверяйте до того, как что-либо делать с телом.
  См. [Проверка подписи](https://docs.infraio.xyz/ru/webhooks/signature-verification).

## Подписываемые типы событий

| Событие | Срабатывает когда… |
| --- | --- |
| `payment.settled` | On-chain перевод набрал число подтверждений сети. **Используйте это, чтобы отметить заказы оплаченными.** |
| `payment.failed` | Фиатный платёж был отклонён платёжным провайдером. Не срабатывает для крипто-таймаутов — те всплывают как `checkout.expired`, а короткие крипто-платежи как `payment.underpaid`. |
| `payment.underpaid` | Средства пришли, но меньше общей суммы заказа (типично: комиссия за перевод стейблкоина вычтена из суммы). |
| `payment.overpaid` | Средства пришли в избытке от общей суммы заказа. Излишек записан, но не возвращается автоматически. |
| `order.created` | Открыт новый заказ — либо через ваш B2B API-вызов, либо через конверсию checkout-сессии. |
| `order.canceled` | Заказ перешёл в отменён. `data.reason` в payload'е отличает ручную отмену от `payment_timeout` (неоплаченный заказ истёк по времени). |
| `order.resolved` | Заказ `PARTIAL_PAID` был разрешён в `PAID` — мерчант принял недоплату. |
| `order.reopened` | Ранее авто-отменённый заказ (`canceled_reason=payment_timeout`) был переоткрыт мерчантом. |
| `checkout.created` | Покупатель открыл checkout для заказа. |
| `checkout.completed` | Поток на стороне покупателя завершён (не подразумевает on-chain расчёта — используйте `payment.settled` для этого). |
| `checkout.expired` | Покупатель забросил, и TTL сессии истёк. |
| `payment.refund.requested` | Создана запись возврата — либо из инициированного мерчантом API-вызова, либо из отправленной клиентом формы refund-request. |
| `payment.refund.approved` | Ожидающий возврат прошёл ваш workflow одобрения. |
| `payment.refund.rejected` | Ожидающий возврат отклонён. |
| `payment.refund.executed` | On-chain перевод возврата прошёл, и запись перешла в терминальное `executed`. |
| `refund_request.created` | Refund-request токен был выпущен. `data.source` равно `b2b` / `dashboard` / `renewal`. Подписка опциональна — полезно для аудиторских pipeline'ов, отслеживающих, какой токен сейчас активен per заказ. |
| `refund_request.renewal_requested` | Покупатель нажал «Запросить новую ссылку» после истечения токена. **Настоятельно рекомендуется подписаться** — это сигнал мерчанту, что в виджете обновлений появился новый элемент для действия. |
| `refund_request.renewed` | Обновление было одобрено, и новый токен заменил старый. `data.old_token` / `data.new_token` формируют аудиторскую цепочку. |
| `refund_request.canceled` | Мерчант перевёл токен в `CANCELED` из панели (например, отклонил запрос на обновление, убил живую ссылку). Идемпотентно — отправляется только при первом переходе. `data.reason` — опциональная заметка мерчанта. |

### Запланировано (скоро)

> **Note:**
>
> **Скоро.** Эти события относятся к регулярным счетам и подпискам, которые
> пока недоступны. Их **нет** в таблице подписываемых событий выше, и
> подписаться на них сегодня нельзя. См.
> [Регулярные счета](https://docs.infraio.xyz/ru/guides/recurring-invoices).

| Планируемое событие | Срабатывает, когда… |
| --- | --- |
| `subscription.created` | Создана подписка. |
| `invoice.created` | Создан счёт за расчётный период. |
| `invoice.paid` | Счёт оплачен. |
| `subscription.past_due` | Счёт не оплачен после срока оплаты. |
| `subscription.canceled` | Подписка отменена. |

Форма endpoint'а в панели перечисляет те же события. Подписка на
несуществующее событие отклоняется при сохранении endpoint'а.

> **Note:**
>
> **Тестовые события не подписываемы.** Кнопка **Send Test** per-endpoint
> в панели сразу, без повторов, отправляет событие `webhook.test.ping` на
> этот один endpoint. Его нет в каталоге выше: вы получаете его потому, что
> у вас есть зарегистрированный endpoint, а не потому, что подписались.

> **Note:**
>
> Подписывайтесь только на события, которые вы обрабатываете. У
> каждого endpoint'а свой фильтр событий; wildcard `"*"` означает
> «каждое событие, включая добавленные в будущем». Подписка на
> меньшее число событий упрощает ваш обработчик и означает меньше
> повторов, когда на вашем endpoint'е возникают ошибки.

## Payload + заголовки

**HTTP-тело — это напрямую объект данных конкретного события.** Нет
внешнего Stripe-стиля конверта — поля вроде типа события, delivery ID
и timestamp эмиссии живут в **заголовках**. Для `payment.settled` тело
выглядит так:

```json
{
  "receipt_id":        "rcp_…",
  "order_id":          "ord_…",
  "payment_intent_id": "pin_…",
  "checkout_session_id": "cst_…",
  "merchant_id":       "mer_…",
  "customer_id":       "cus_…",
  "total":             "49.00",
  "currency":          "USD",
  "payment_method":    "crypto",
  "token":             "USDC",
  "network":           "polygon",
  "tx_hash":           "0x…",
  "deposit_address":   "0x…",
  "treasury_address":  "0x…",
  "amount_received":   "49.00",
  "confirmations":     5,
  "metadata":          { /* per-event */ }
}
```

Другие события несут свои поля. Имена полей стабильны (нижний
snake_case); on-chain хеш транзакции всегда `tx_hash`.

`tx_hash` — идентификатор транзакции в собственном формате сети (`0x…` в EVM-сетях; нативный хеш или подпись в TRON, Solana и TON). `deposit_address` может отсутствовать для TRON, Solana и TON, поскольку там покупатели платят напрямую на ваш кошелёк Казначейства, а `confirmations` соответствует [Сети и активы](https://docs.infraio.xyz/ru/concepts/chains).

### Заголовки входящего запроса

```http
Content-Type:      application/json
X-Event:           payment.settled
X-Delivery:        7c9e6679-7425-40de-944b-e07fc1f90ae7
Idempotency-Key:   7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Timestamp:       1729536000
X-Signature:       sha256=9a8b7c…
X-Signature-Prev:  sha256=fa31b2…    (только в течение grace-окна ротации)
```

| Заголовок | Что это |
| --- | --- |
| `X-Event` | Тип события (например, `payment.settled`). Маршрутизируйте по нему на уровне прокси, если хотите пропустить парсинг JSON. |
| `X-Delivery` | UUID, идентифицирующий строку доставки. **Стабилен между всеми повторами** одной пары `(событие, endpoint)` — используйте его как ключ идемпотентности. |
| `Idempotency-Key` | Зеркалит `X-Delivery` (то же значение). Установлен на каждой доставке. |
| `X-Timestamp` | Unix-секунды отправки попытки. Подписан в payload, чтобы захваченную пару `(тело, X-Signature)` нельзя было replay'ить бесконечно — отклоняйте доставки, у которых timestamp вне вашего окна допуска. |
| `X-Signature` | `sha256=<hex>` от `HMAC-SHA256(secret, X-Timestamp + "." + raw_body)`. См. [Проверка подписи](https://docs.infraio.xyz/ru/webhooks/signature-verification). |
| `X-Signature-Prev` | Тот же алгоритм с **предыдущим** секретом. Присутствует только в 24-часовом окне после ротации — позволяет verifier'ам, запущенным с любым из ключей, продолжать принимать доставки во время cutover. После закрытия окна заголовок перестаёт отправляться. |

## Расписание повторов

Если ваш endpoint не возвращает `2xx` в течение таймаута, мы повторяем
по этому расписанию (timestamp'ы относительно первой попытки):

| Попытка | Задержка | Накопительно |
| --- | --- | --- |
| 1 | 0с | 0с |
| 2 | +1 мин | 1м |
| 3 | +5 мин | 6м |
| 4 | +15 мин | 21м |
| 5 | +1 час | 1ч 21м |
| 6 | +6 часов | 7ч 21м |

После того как попытка 6 не удалась, доставка переводится в
**Failed**, и email вашего аккаунта уведомляется.
Неудавшиеся события можно повторить из панели **Developers →
Webhooks → Delivery history**. Каждый replay — это новая доставка со
своим `X-Delivery`.

## Регистрация endpoint'а

Из [панели мерчанта](https://app.infraio.xyz):

1. **Developers → Webhooks** → **+ Add endpoint**
2. Вставьте ваш URL — только `https://…` (обычный HTTP отклоняется;
   форма создания также блокирует `localhost`, частные IP-диапазоны
   и URL с userinfo)
3. Выберите события для подписки (или `*` для всех)
4. Выберите окружение — **test** или **live** (у каждого свой секрет;
   они никогда не пересекаются)
5. Save → панель отображает секрет подписи (`whsec_…`) **один раз**.
   Сохраните его на сервере; он понадобится для следующих двух
   фич.

Вы можете зарегистрировать до **10 endpoint'ов на окружение на
мерчанта** (например, один для обработки в продакшене, один для
зеркалирования в staging, один для уведомлений в Slack). У каждого
своё состояние повторов и секрет.

## Действия жизненного цикла на каждом endpoint'е

Меню ⋮ на карточке каждого endpoint'а показывает:

- **Edit** — изменить URL, описание или список подписок. Новый URL
  пере-валидируется теми же правилами `https://`/SSRF, что и при
  создании.
- **Send Test** — синхронный POST конверта `webhook.test.ping`,
  подписанный вашим текущим секретом. Панель показывает HTTP-статус,
  latency и 512-байтный сниппет вашего ответа.
  Тестовые пинги не повторяются, поэтому ответ мгновенный.
- **Rotate Secret** — генерирует новый секрет. Предыдущий остаётся
  валидным **24 часа** (доставки несут и `X-Signature`, и
  `X-Signature-Prev` в окне, чтобы verifier'ы, работающие с любым из
  ключей, продолжали принимать события, пока вы переразворачиваете).
- **Reveal Secret** — повторно отображает существующий секрет.
  Защищён свежей 2FA-проверкой и записывается в журнал аудита;
  используйте, только когда вы потеряли копию и Rotate неприемлем.
- **Enable / Disable** — включает или отключает endpoint без потери истории
  доставки. Отключённые endpoint'ы остаются в панели, но не получают
  новых доставок.
- **Delete** — необратимо. Используйте Disable, если можете
  переключиться обратно позже.

## Советы для обработчиков

1. **Возвращайте 2xx быстро.** Подтверждайте `200 OK` до тяжёлой
   работы — переносите обработку в фоновую задачу. Таймаут per-попытка
   — **10 секунд**; удержание ответа дольше этого триггерит повтор.
   Таймаут на стороне платформы и не настраивается мерчантом — пишите
   в поддержку, если ваш обработчик действительно нуждается в большем
   времени.
2. **Дедуплицируйте по `X-Delivery`** (или `Idempotency-Key` — то же
   значение). Даже если вы возвращаете 2xx, upstream-прокси может
   уронить соединение и спровоцировать повтор; delivery ID стабилен
   между каждым повтором одной строки доставки, поэтому это правильный
   ключ.
3. **Толерируйте неизвестные типы событий.** Новые события могут
   появиться; возвращайте 200 и no-op, а не 4xx, иначе эти доставки будут
   повторяться снова и снова.
4. **Логируйте `X-Delivery` рядом с бизнес-логикой.** Когда что-то
   пошло не так, это ключ join между нашей стороной и вашей.

## Что дальше

- [Проверка подписи](https://docs.infraio.xyz/ru/webhooks/signature-verification) — точный
  алгоритм + паттерны защиты от replay.
- [Концепции → Сессии](https://docs.infraio.xyz/ru/concepts/sessions) — в каком состоянии
  сессия, когда срабатывает каждое событие.
