# Вебхуки

Сервис сообщает мерчанту о событиях — платёж замечен, платёж подтверждён, инвойс просрочен — запросом `POST` на адрес вебхуков из настроек проекта. Событие записывается в той же транзакции базы, что и изменение, о котором оно сообщает, и отправляется после того, как эта транзакция зафиксирована.

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

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

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

Каким запросом проверять событие:

| Событие | Запрос |
| --- | --- |
| `invoice.*` | `GET /v1/invoices/{id}` с `data.invoice_id` — см. [получение инвойса](https://stons.io/ru/docs/invoices.md#получить-инвойс) |
| `deposit.*` с `data.invoice_id` | `GET /v1/invoices/{id}` с `data.invoice_id`: подтверждённый перевод есть в `deposits` с тем же `tx_hash`. Сам перевод — в `GET /v1/deposits/{tx_hash}` с `data.tx_hash` |
| `deposit.*` с `data.invoice_id` = `null` | `GET /v1/deposits/{tx_hash}` с `data.tx_hash`: найдите перевод по `data.log_index` и зачисляйте пополнение, только если у него `status` = `confirmed` и `credited` = `true`, — см. [проверку перевода](https://stons.io/ru/docs/addresses.md#проверить-перевод) |
| `payout.*` | `GET /v1/payouts/{id}` с `data.payout_id` — см. [получение выплаты](https://stons.io/ru/docs/payouts.md#получить-выплату) |

## События

| Событие | Когда приходит |
| --- | --- |
| `invoice.seen` | Статус инвойса стал `seen`: перевод, который оплачивает инвойс, попал в блок до дедлайна. Подтверждений ещё нет: товар не отдавать. После реорганизации и после `invoice.partially_paid`, когда пришёл следующий перевод, тот же инвойс может получить `invoice.seen` ещё раз — новым событием с новым `id` |
| `invoice.partially_paid` | Статус инвойса стал `partially_paid`: до дедлайна подтверждённые переводы покрыли только часть `amount_expected`, и ни один перевод не ждёт подтверждений. Товар не отдавать: покупатель может дослать остаток на тот же адрес до дедлайна — см. [частичную оплату](https://stons.io/ru/docs/invoices.md#частичная-оплата). Приходит при каждом переходе в этот статус, в том числе снова после `invoice.seen` |
| `invoice.confirmed` | Подтверждённые переводы, сделанные до дедлайна — по времени их блока, — покрыли `amount_expected`. Отдать товар, проверив статус через API |
| `invoice.overpaid` | Вместе с `invoice.confirmed`, отдельным событием с теми же `data`, если в момент подтверждения `amount_received` больше `amount_expected`. Перевод на инвойс, который уже `confirmed`, порождает только `deposit.confirmed` |
| `invoice.underpaid` | Дедлайн прошёл; подтверждённые переводы, сделанные до него, покрыли только часть `amount_expected`, а подтверждённых переводов после дедлайна нет |
| `invoice.expired` | Дедлайн прошёл без подтверждённых переводов. Приходит и для инвойса, покупатель которого не выбрал токен до дедлайна |
| `invoice.paid_late` | Дедлайн прошёл, переводы до него не покрыли `amount_expected`, а перевод, сделанный после дедлайна, подтвердился. Обычно приходит после `invoice.expired` или `invoice.underpaid` |
| `deposit.confirmed` | Платёж набрал число подтверждений, которое требует его актив. Одно событие на перевод. В режиме `per_user` приходит и для пополнения без инвойса: тогда `invoice_id` равен `null` |
| `deposit.orphaned` | Замеченный и ещё не подтверждённый платёж исчез из цепи при реорганизации или его транзакция оказалась неуспешной. Перевод перестаёт засчитываться инвойсу. Если он снова попадёт в цепь, он снова засчитывается, а при подтверждении придёт `deposit.confirmed` |
| `payout.confirmed` | Выплата подтверждена сетью: средства ушли получателю. Окончательное состояние |
| `payout.failed` | Выплата перешла в `failed`: средства **не** ушли. Либо её транзакция попала в блок неуспешной, либо сервер подписи отказался её подписывать, либо исчерпал попытки, и ни одна его транзакция уже не может попасть в блок. Выплата не повторяется автоматически — новая попытка требует нового `reference`. См. [статусы выплат](https://stons.io/ru/docs/payouts.md#статусы) |

Статусы инвойса и переходы между ними — в [описании инвойсов](https://stons.io/ru/docs/invoices.md#статусы). События `invoice.*` приходят только при смене статуса инвойса: перевод, который статус не меняет, их не порождает, а о каждом подтверждённом платеже сообщает `deposit.confirmed`. У перехода в `settled` события нет: для мерчанта это то же, что `confirmed`. Возврат в `created` после реорганизации отдельного события `invoice.*` не имеет — об исчезнувшем переводе сообщает `deposit.orphaned`. У создания инвойса и у выбора токена покупателем событий тоже нет.

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

О новых типах событий сообщает [журнал изменений](https://stons.io/ru/docs/changelog.md). На тип события, который обработчик не обрабатывает, отвечайте `2xx`: любой другой ответ заставит сервис повторять доставку, пока не кончатся попытки.

## Тело

```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",
    "network": "tron",
    "amount_requested": "49.900000",
    "amount_expected": "49.900000",
    "amount_received": "49.900000",
    "amount_credited": "49.401000",
    "fees": {
      "service": { "payer": "merchant", "percent": "1.00", "fixed": "0.000000", "amount": "0.499000" },
      "network": { "payer": "merchant", "quote": null, "quote_rate": null, "actual": null, "status": "included" }
    },
    "external_user_id": null
  }
}
```

Примеры в этом документе отформатированы для чтения. Приходящее тело — тот же объект JSON, но порядок ключей и пробелы в нём могут отличаться: подпись проверяйте по полученным байтам, а поля читайте по именам.

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

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

| Поле | Тип | Описание |
| --- | --- | --- |
| `invoice_id` | string (UUID) | Идентификатор инвойса |
| `order_id` | string | Идентификатор заказа мерчанта |
| `status` | string | Статус инвойса после изменения: `seen`, `partially_paid`, `confirmed`, `underpaid`, `expired` или `paid_late`. У `invoice.overpaid` — `confirmed` |
| `asset` | string или null | Актив; `null` у инвойса, срок которого вышел без выбора токена |
| `network` | string или null | Сеть актива: идентификатор сети, например `tron`, как в `network` инвойса; `null` у инвойса, срок которого вышел без выбора токена |
| `amount_requested` | string или null | Цена мерчанта со всеми знаками актива |
| `amount_expected` | string или null | Сумма, которую должен заплатить покупатель, со всеми знаками актива: цена и комиссия сервиса, если её платит покупатель |
| `amount_received` | string или null | Сумма подтверждённых переводов — сделанных до дедлайна и после — на момент события |
| `amount_credited` | string или null | `amount_received` за вычетом `fees.service.amount` |
| `fees` | object | Комиссия сервиса — `payer`, `percent`, `fixed` и удержанное `amount` — и строка комиссии сети, как в [инвойсе](https://stons.io/ru/docs/invoices.md#комиссии) |
| `external_user_id` | string или null | Пользователь мерчанта или `null` |

В данных нет `address`, `price`, `currencies`, `amount_remaining`, `checkout_url`, `expires_at` и `deposits`: они есть в `GET /v1/invoices/{id}`.

Инвойс, токен которого выбирает покупатель ([инвойсы](https://stons.io/ru/docs/invoices.md#токен-выбирает-покупатель)), до выбора может получить только `invoice.expired`. В нём `asset`, `network`, `amount_requested`, `amount_expected`, `amount_received`, `amount_credited`, `fees.service.fixed` и `fees.service.amount` — `null`. Отдельного события о выборе токена нет: выбранный токен виден в `GET /v1/invoices/{id}` и в следующих событиях.

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

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

| Поле | Тип | Описание |
| --- | --- | --- |
| `tx_hash` | string | Хэш транзакции: в TRON и TON — 64 шестнадцатеричных символа, в EVM-сетях — `0x` и 64, и то и другое в нижнем регистре. **В Solana это первая подпись транзакции: 86–88 знаков base58, у которых регистр несёт смысл и сохраняется ровно таким, как его пишет сеть** — приведение к нижнему регистру уничтожает значение, см. [адреса в Solana](https://stons.io/ru/docs/addresses.md#адреса-в-solana). В TON — транзакция, в которой деньги получил адрес приёма или его жетон-кошелёк, см. [проверить перевод](https://stons.io/ru/docs/addresses.md#проверить-перевод) |
| `log_index` | integer | Номер перевода в транзакции: вместе с `tx_hash` и `network` однозначно определяет платёж. У перевода нативной монеты отрицательный: `-1` — сумма самой транзакции, а в TRON и EVM-сетях `-2`, `-3` и далее — переводы, сделанные контрактами внутри неё. В TON у токена `0`, у GRAM `-1`. Считайте пару `tx_hash` + `log_index` непрозрачным ключом: смысл числа различается от сети к сети, и в Solana перевод токена различается по токен-аккаунту, баланс которого изменился, а не по позиции в списке логов |
| `asset` | string | Актив |
| `network` | string | Сеть перевода, например `tron`. Одинаковый хэш транзакции может быть в двух сетях |
| `amount` | string | Сумма перевода со всеми знаками актива |
| `address` | string | Адрес приёма, целиком, в той же форме, что в `POST /v1/addresses` и в инвойсе: в TON — user-friendly без флага bounce, см. [адреса в TON](https://stons.io/ru/docs/addresses.md#адреса-в-ton) |
| `invoice_id` | string (UUID) или null | Инвойс, который оплачивает перевод, или `null` для пополнения в режиме `per_user` |
| `external_user_id` | string или null | Пользователь адреса `per_user`; у адреса `per_invoice` всегда `null` |
| `block_time` | string | Время блока с переводом, ISO 8601 в UTC |
| `confirmations` | integer | Сколько блоков набрано над блоком перевода: в `deposit.confirmed` — не меньше, чем требует актив; в `deposit.orphaned` — последняя глубина, которую видел сервис |

Полей комиссии у событий `deposit.*` нет: комиссия платежа по инвойсу — в событиях `invoice.*` и в инвойсе, а комиссия пополнения видна в балансе проекта. Те же поля — с текущим `confirmations` и с `kind`, `status`, `block_number` и `credited` — есть в ответе `GET /v1/deposits/{tx_hash}`.

Пример `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",
    "network": "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.percent`, `network.fixed`, `network.amount`, `network.charged` — как в [выплате](https://stons.io/ru/docs/payouts.md#комиссия-за-выплату). У `payout.confirmed` `network.charged` равен `network.amount`, у `payout.failed` — нулю |
| `to_address` | Адрес получателя, целиком |
| `tx_hash` | Хэш транзакции. У `payout.failed` — последней транзакции выплаты, средств она не перевела; `null`, если сервер подписи ничего не подписал |
| `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", "percent": "1.00", "fixed": "1.700000", "amount": "2.705000", "charged": "2.705000" } },
    "to_address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
    "tx_hash": "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0"
  }
}
```

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

## Подпись

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

```text
Content-Type: application/json
User-Agent: STONS webhooks
X-Timestamp: <время отправки этой попытки, секунды Unix>
X-Signature: sha256=<HMAC-SHA256 в hex строчными буквами>
```

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

Каждая попытка подписывается заново: у повторной доставки те же тело и `id`, но свои `X-Timestamp` и `X-Signature`.

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

- отклонять вебхук, если `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);
}
```

Проверка на Python:

```python
import hashlib
import hmac
import re
import time

TOLERANCE_SECONDS = 300


# raw_body — bytes с сырым телом запроса, до json.loads.
def verify_webhook(raw_body, signature_header, timestamp_header, secret, now=None):
    if timestamp_header is None or re.fullmatch(r"[0-9]{1,12}", timestamp_header) is None:
        return False
    current = time.time() if now is None else now
    if abs(current - int(timestamp_header)) > TOLERANCE_SECONDS:
        return False

    match = re.fullmatch(r"sha256=([0-9a-f]{64})", signature_header or "")
    if match is None:
        return False

    signed = timestamp_header.encode("ascii") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, match.group(1))
```

Пример для проверки своей реализации. С секретом `example-webhook-secret`, `X-Timestamp: 1789300000` и телом `{"id":"0b7e6f2a-8c1d-4e5f-9a3b-2c4d6e8f0a1b","type":"invoice.confirmed"}` без перевода строки в конце заголовок — `X-Signature: sha256=de0f803a28ef65326019f2500bd933b61882216dedb6e22ea2a03c32c9b272c0`. Этот пример проверяет тест сервиса.

## Доставка

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

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

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

Каждая попытка доставки записывается: код ответа, длительность и ошибка. Владелец проекта видит события счёта на его странице в кабинете. Доставку со статусом «Не доставлен» можно отправить ещё раз кнопкой «Отправить снова»: это ещё одна попытка с тем же телом и `id`, и если она неудачна, доставка снова помечается недоставленной. Доставленное событие повторно не отправляется. Недоставленное событие может отправить заново и оператор.
