Перейти к содержанию
STONS

Вебхуки

Сервис сообщает мерчанту о событиях — платёж замечен, платёж подтверждён, инвойс просрочен — запросом 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, и если она неудачна, доставка снова помечается недоставленной. Доставленное событие повторно не отправляется. Недоставленное событие может отправить заново и оператор.