Вебхуки
Сервис сообщает мерчанту о событиях — платёж замечен, платёж подтверждён, инвойс просрочен — запросом 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 |
Статусы инвойса и переходы между ними — в описании инвойсов. Переводы другого актива и пыль событий не порождают.
Тело
{
"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": "pending" }
},
"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 |
Комиссия сервиса и комиссия сети, как в инвойсе |
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:
{
"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 |
Сумма выплаты со всеми знаками актива |
to_address |
Адрес получателя, целиком |
tx_hash |
Хэш транзакции |
reason |
Только у payout.failed: что именно произошло |
Пример payout.confirmed:
{
"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",
"to_address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
"tx_hash": "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0"
}
}
Комиссию сети платит горячий кошелёк сервиса, и она не вычитается из суммы: получатель получает ровно amount (ADR-0023).
Подпись
Каждый вебхук содержит два заголовка:
X-Timestamp: <время отправки, секунды Unix>
X-Signature: sha256=<HMAC-SHA256 в hex>
Подпись — это HMAC-SHA256 по строке <X-Timestamp>.<сырое тело запроса> с секретом вебхуков мерчанта в качестве ключа. Подписывается именно сырое тело: байты в том виде, в каком они пришли, до разбора JSON. Разобранный и заново сериализованный JSON может отличаться порядком ключей и пробелами, и подпись не сойдётся.
Получатель обязан:
- отклонять вебхук, если
X-Timestampотличается от текущего времени больше чем на 5 минут: так перехваченный вебхук нельзя отправить повторно позже; - сравнивать подписи за постоянное время, а не обычным сравнением строк.
Проверка на Node.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 — сообщите оператору, он поменяет его в настройках мерчанта.
Каждая попытка доставки записывается: код ответа, длительность и ошибка. Оператор видит недоставленные события и может поставить их в очередь заново — см. застрявшие вебхуки.