# Вебхуки

Сервис сообщает мерчанту о событиях — платёж замечен, платёж подтверждён, инвойс просрочен — запросом `POST` на его URL. События формирует сканер блокчейна и записывает их в очередь в базе в той же транзакции, что и само изменение; отправляет их API. Устройство доставки — ADR-0011.

## Главное правило

> **Не доверяйте телу вебхука.** Получив вебхук, проверьте подпись, затем запросите актуальное состояние объекта у API и действуйте по ответу API. Тело вебхука — сигнал «сходи проверь», а не источник истины.

Причина: если секрет вебхуков утечёт или в проверке подписи окажется ошибка, злоумышленник сможет прислать поддельное «оплачено». Если товар выдаётся только по ответу API, подделка ничего не даст.

## События

| Событие | Когда приходит |
| --- | --- |
| `invoice.seen` | До дедлайна в блоке появился перевод на адрес инвойса. Подтверждений ещё нет: товар не отдавать |
| `invoice.confirmed` | Подтверждённые переводы, пришедшие до дедлайна, покрыли сумму инвойса. Отдать товар, проверив статус через API |
| `invoice.overpaid` | Вместе с `invoice.confirmed`, если подтверждено больше ожидаемого |
| `invoice.underpaid` | Дедлайн прошёл, подтверждена только часть суммы |
| `invoice.expired` | Дедлайн прошёл, подтверждённых переводов нет |
| `invoice.paid_late` | После `invoice.expired` или `invoice.underpaid` подтвердился перевод, пришедший после дедлайна |
| `deposit.confirmed` | Платёж набрал нужное число подтверждений. В режиме `per_user` приходит и для пополнения без инвойса: тогда `invoice_id` равен `null` |
| `deposit.orphaned` | Замеченный платёж исчез из цепи при реорганизации или его транзакция оказалась неуспешной. Отменяет ожидание, которое начал `invoice.seen` |
| `payout.confirmed` | Выплата подтверждена сетью: средства ушли получателю. Окончательное состояние |
| `payout.failed` | Транзакция выплаты попала в блок неуспешной: средства **не** ушли. Выплата не повторяется автоматически — новая попытка требует нового `reference` |

Статусы инвойса и переходы между ними — в [описании инвойсов](https://stons.io/docs/invoices.md#статусы). Переводы другого актива и пыль событий не порождают.

## Тело

```json
{
  "id": "0b7e6f2a-8c1d-4e5f-9a3b-2c4d6e8f0a1b",
  "type": "invoice.confirmed",
  "created_at": "2026-09-11T20:05:41.118Z",
  "data": {
    "invoice_id": "6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44",
    "order_id": "ord_1001",
    "status": "confirmed",
    "asset": "USDT_TRON",
    "amount_requested": "49.900000",
    "amount_expected": "49.900000",
    "amount_received": "49.900000",
    "amount_credited": "49.401000",
    "fees": {
      "service": { "payer": "merchant", "percent": "1.00", "amount": "0.499000" },
      "network": { "payer": "merchant", "quote": null, "quote_rate": null, "actual": null, "status": "included" }
    },
    "external_user_id": null
  }
}
```

| Поле | Описание |
| --- | --- |
| `id` | Идентификатор события. Повторная доставка приходит с тем же `id`: по нему обработчик отбрасывает дубли |
| `type` | Событие из таблицы выше |
| `created_at` | Когда событие произошло, ISO 8601 в UTC |
| `data` | Объект события, поля ниже |

Поля `data` у событий `invoice.*`:

| Поле | Описание |
| --- | --- |
| `invoice_id` | Идентификатор инвойса |
| `order_id` | Идентификатор заказа мерчанта |
| `status` | Статус инвойса после изменения |
| `asset` | Актив |
| `amount_requested` | Цена мерчанта |
| `amount_expected` | Сумма, которую должен заплатить покупатель, со всеми знаками актива: цена и комиссия сервиса, если её платит покупатель |
| `amount_received` | Подтверждённая сумма на момент события |
| `amount_credited` | Подтверждённая сумма за вычетом комиссий, удержанных на момент события |
| `fees` | Комиссия сервиса и строка комиссии сети, как в [инвойсе](https://stons.io/docs/invoices.md#комиссии) |
| `external_user_id` | Пользователь мерчанта или `null` |

Комиссия платежа проводится в той же транзакции, что его подтверждение, поэтому `invoice.confirmed` уже несёт удержанную комиссию и `amount_credited` после неё.

Поля `data` у событий `deposit.*`:

| Поле | Описание |
| --- | --- |
| `tx_hash` | Хэш транзакции |
| `log_index` | Номер перевода в транзакции: вместе с `tx_hash` однозначно определяет платёж |
| `asset` | Актив |
| `amount` | Сумма перевода со всеми знаками актива |
| `address` | Адрес приёма, целиком |
| `invoice_id` | Инвойс, который оплачивает перевод, или `null` для пополнения в режиме `per_user` |
| `external_user_id` | Пользователь мерчанта или `null` |
| `block_time` | Время блока с переводом, ISO 8601 в UTC |
| `confirmations` | Сколько блоков набрано над блоком перевода |

Пример `deposit.confirmed`:

```json
{
  "id": "5d2c9e41-7b3a-4c8f-a1e6-9f0b2d4c6e8a",
  "type": "deposit.confirmed",
  "created_at": "2026-09-11T20:05:41.118Z",
  "data": {
    "tx_hash": "50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c",
    "log_index": 0,
    "asset": "USDT_TRON",
    "amount": "49.900000",
    "address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
    "invoice_id": "6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44",
    "external_user_id": null,
    "block_time": "2026-09-11T20:04:38.000Z",
    "confirmations": 20
  }
}
```

Поля `data` у событий `payout.*`:

| Поле | Описание |
| --- | --- |
| `payout_id` | Идентификатор выплаты |
| `reference` | Идентификатор выплаты на стороне мерчанта |
| `status` | `confirmed` или `failed` |
| `asset` | Актив |
| `amount` | Сумма выплаты со всеми знаками актива |
| `fees` | Комиссия сети выплаты: `network.payer`, `network.amount`, `network.charged` — как в [выплате](https://stons.io/docs/payouts.md#комиссия-сети). У `payout.confirmed` `charged` равен `amount` комиссии, у `payout.failed` — нулю |
| `to_address` | Адрес получателя, целиком |
| `tx_hash` | Хэш транзакции |
| `reason` | Только у `payout.failed`: что именно произошло |

Пример `payout.confirmed`:

```json
{
  "id": "9c4b1a2e-7d3f-4a5b-8c6d-0e1f2a3b4c5d",
  "type": "payout.confirmed",
  "created_at": "2026-09-12T12:15:03.402Z",
  "data": {
    "payout_id": "4c2f1a7e-93d5-4c0b-8a2e-5f6b7c8d9e01",
    "reference": "wd_501",
    "status": "confirmed",
    "asset": "USDT_TRON",
    "amount": "100.500000",
    "fees": { "network": { "payer": "merchant", "amount": "1.700000", "charged": "1.700000" } },
    "to_address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
    "tx_hash": "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0"
  }
}
```

Из суммы выплаты ничего не вычитается: получатель получает ровно `amount` (ADR-0023). Комиссия сети списывается с баланса проекта в той же операции, что подтверждение, поэтому `payout.confirmed` уже несёт её в `fees.network.charged`.

## Подпись

Каждый вебхук содержит два заголовка:

```text
X-Timestamp: <время отправки, секунды Unix>
X-Signature: sha256=<HMAC-SHA256 в hex>
```

Подпись — это HMAC-SHA256 по строке `<X-Timestamp>.<сырое тело запроса>` с секретом вебхуков мерчанта в качестве ключа. Подписывается именно сырое тело: байты в том виде, в каком они пришли, до разбора JSON. Разобранный и заново сериализованный JSON может отличаться порядком ключей и пробелами, и подпись не сойдётся.

Получатель обязан:

- отклонять вебхук, если `X-Timestamp` отличается от текущего времени больше чем на 5 минут: так перехваченный вебхук нельзя отправить повторно позже;
- сравнивать подписи за постоянное время, а не обычным сравнением строк.

Проверка на Node.js:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

// rawBody — Buffer с сырым телом запроса, до JSON.parse.
export function verifyWebhook({ rawBody, signatureHeader, timestampHeader, secret, nowMs = Date.now() }) {
  if (typeof timestampHeader !== "string" || !/^\d{1,12}$/.test(timestampHeader)) return false;
  if (Math.abs(nowMs / 1000 - Number(timestampHeader)) > TOLERANCE_SECONDS) return false;

  const match = /^sha256=([0-9a-f]{64})$/.exec(signatureHeader ?? "");
  if (match === null) return false;

  const expected = createHmac("sha256", secret).update(`${timestampHeader}.`).update(rawBody).digest();
  return timingSafeEqual(Buffer.from(match[1], "hex"), expected);
}
```

## Доставка

- Вебхук считается доставленным, если URL мерчанта ответил любым кодом `2xx` за 10 секунд.
- Иначе отправка повторяется через 10 секунд, 1 минуту, 5 минут, 30 минут, 2 часа и 6 часов после предыдущей попытки. После последней неудачи вебхук помечается недоставленным, и оператор получает сигнал.
- Доставка «хотя бы один раз»: одно и то же событие может прийти несколько раз. Обработчик должен быть идемпотентным — повторная обработка события не должна повторно выдавать товар.
- Событие записывается в той же транзакции базы, что и изменение, о котором оно сообщает. Поэтому событие не теряется при сбое между изменением и отправкой, но и не гарантирует порядок доставки: `invoice.confirmed` может прийти раньше `invoice.seen`. Ориентируйтесь на состояние из API.
- Проекту без адреса вебхуков — например, заведённому в кабинете для счетов, выставляемых вручную, — события не записываются вовсе. Инвойсы при этом оплачиваются и зачисляются как обычно. События, случившиеся до того, как адрес задан, после его появления не досылаются: состояние берите из API. Заданный адрес можно заменить, но не удалить.

Отвечайте `2xx` сразу после проверки подписи, а долгую обработку выполняйте асинхронно: иначе таймаут в 10 секунд приведёт к лишним повторам.

Ответ `3xx` успехом не считается, и сервис по нему не переходит: подписанное тело не должно уходить на другой адрес. Меняете URL — сообщите оператору, он поменяет его в настройках мерчанта.

Каждая попытка доставки записывается: код ответа, длительность и ошибка. Оператор видит недоставленные события и может поставить их в очередь заново — см. застрявшие вебхуки.
