Вебхуки
Сервис сообщает мерчанту о событиях — платёж замечен, платёж подтверждён, инвойс просрочен — запросом POST на адрес вебхуков из настроек проекта. Событие записывается в той же транзакции базы, что и изменение, о котором оно сообщает, и отправляется после того, как эта транзакция зафиксирована.
Главное правило
Не доверяйте телу вебхука. Получив вебхук, проверьте подпись, затем запросите актуальное состояние объекта у API и действуйте по ответу API. Тело вебхука — сигнал «сходи проверь», а не источник истины.
Причина: если секрет вебхуков утечёт или в проверке подписи окажется ошибка, злоумышленник сможет прислать поддельное «оплачено». Если товар выдаётся только по ответу API, подделка ничего не даст.
Каким запросом проверять событие:
| Событие | Запрос |
|---|---|
invoice.* |
GET /v1/invoices/{id} с data.invoice_id — см. получение инвойса |
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, — см. проверку перевода |
payout.* |
GET /v1/payouts/{id} с data.payout_id — см. получение выплаты |
События
| Событие | Когда приходит |
|---|---|
invoice.seen |
Статус инвойса стал seen: перевод, который оплачивает инвойс, попал в блок до дедлайна. Подтверждений ещё нет: товар не отдавать. После реорганизации и после invoice.partially_paid, когда пришёл следующий перевод, тот же инвойс может получить invoice.seen ещё раз — новым событием с новым id |
invoice.partially_paid |
Статус инвойса стал partially_paid: до дедлайна подтверждённые переводы покрыли только часть amount_expected, и ни один перевод не ждёт подтверждений. Товар не отдавать: покупатель может дослать остаток на тот же адрес до дедлайна — см. частичную оплату. Приходит при каждом переходе в этот статус, в том числе снова после 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. См. статусы выплат |
Статусы инвойса и переходы между ними — в описании инвойсов. События invoice.* приходят только при смене статуса инвойса: перевод, который статус не меняет, их не порождает, а о каждом подтверждённом платеже сообщает deposit.confirmed. У перехода в settled события нет: для мерчанта это то же, что confirmed. Возврат в created после реорганизации отдельного события invoice.* не имеет — об исчезнувшем переводе сообщает deposit.orphaned. У создания инвойса и у выбора токена покупателем событий тоже нет.
Пыль — перевод меньше минимального депозита своего актива, кроме остатка начатой оплаты инвойса (см. частичная оплата), — перевод другого актива и токен вне каталога сервиса событий не порождают.
О новых типах событий сообщает журнал изменений. На тип события, который обработчик не обрабатывает, отвечайте 2xx: любой другой ответ заставит сервис повторять доставку, пока не кончатся попытки.
Тело
{
"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 — и строка комиссии сети, как в инвойсе |
external_user_id |
string или null | Пользователь мерчанта или null |
В данных нет address, price, currencies, amount_remaining, checkout_url, expires_at и deposits: они есть в GET /v1/invoices/{id}.
Инвойс, токен которого выбирает покупатель (инвойсы), до выбора может получить только 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. В TON — транзакция, в которой деньги получил адрес приёма или его жетон-кошелёк, см. проверить перевод |
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 |
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:
{
"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 — как в выплате. У payout.confirmed network.charged равен network.amount, у payout.failed — нулю |
to_address |
Адрес получателя, целиком |
tx_hash |
Хэш транзакции. У payout.failed — последней транзакции выплаты, средств она не перевела; null, если сервер подписи ничего не подписал |
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",
"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.
Подпись
Каждый вебхук содержит заголовки:
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:
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:
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, и если она неудачна, доставка снова помечается недоставленной. Доставленное событие повторно не отправляется. Недоставленное событие может отправить заново и оператор.