Перейти к содержанию
STONS
Разделы документации

Страница оплаты

Кроме интеграции, в которой продукт сам показывает покупателю адрес и сумму, сервис умеет принимать платёж на своей странице. Продукт создаёт инвойс, отправляет покупателя по ссылке из ответа, страница показывает сумму, адрес и статус платежа, а когда у инвойса появляется исход — возвращает покупателя на сайт мерчанта подписанным редиректом. Вебхуки при этом приходят так же, как без страницы. Решение и его границы — ADR-0032.

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

Не отдавайте товар по редиректу. Получив покупателя на return_url, проверьте подпись, затем запросите инвойс GET /v1/invoices/{id} и отдавайте товар только по ответу API — так же, как после вебхука.

Подпись доказывает одно: адрес собрал сервис для этого инвойса в этом статусе. Она не мешает покупателю открыть ту же ссылку ещё раз, пока не истекло окно проверки, и не заменяет проверку статуса.

Как это работает

  1. Продукт создаёт инвойс: POST /v1/invoices. В ответе поле checkout_url — ссылка на страницу оплаты этого инвойса. Если оператор не настроил страницу, поле равно null — см. инвойсы.
  2. Продукт отправляет покупателя по checkout_url.
  3. Страница показывает сумму, адрес целиком, срок и подтверждения, и обновляет их, пока покупатель платит.
  4. Когда инвойс оплачен или срок вышел, страница отправляет покупателя на адрес возврата или отмены с параметрами и подписью.
  5. Продукт проверяет подпись и статус через API.

Страница для покупателя

Страница открывается по checkout_url без входа и без ключей. Вот что на ней видит покупатель:

  • название мерчанта;
  • сумму к оплате — amount_expected — с тикером и сетью, с кнопкой копирования;
  • если комиссию сервиса платит покупатель — разбивку «Цена · Комиссия сервиса · Итого»: цена мерчанта, комиссия со ставкой и та же сумма к оплате;
  • предупреждение отправлять ровно этот актив и в этой сети;
  • QR-код;
  • адрес целиком, с кнопкой копирования;
  • время до срока оплаты;
  • статус платежа.

QR-код содержит только адрес. В документации TRON нет формата платёжной ссылки, который передал бы кошельку ещё и сумму с токеном. Поэтому сумма показывается и копируется отдельно, а покупателя просят сверить адрес после вставки целиком. Адрес нигде не сокращается.

Страница сама обновляет статус, перезагружать её не нужно:

  • первый запрос — через 3 секунды, второй — через 5, дальше каждые 10;
  • после смены статуса отсчёт начинается заново;
  • пока вкладка скрыта — раз в 30 секунд;
  • при возвращении на вкладку страница спрашивает сразу.

Время до срока считается по часам сервера, а не телефона.

Что страница показывает по статусам:

Статус Что видит покупатель
created Сумму, адрес, QR-код и оставшееся время
seen «Платёж замечен, ждём подтверждений» и число подтверждений из нужных. Сумма и адрес остаются на экране
confirmed, settled «Оплата получена»
paid_late «Платёж пришёл после срока»: решение за магазином
underpaid «Сумма пришла не полностью» и просьбу не досылать остаток на этот адрес
expired «Срок оплаты истёк» и просьбу не отправлять деньги повторно, если перевод уже ушёл

После статуса с исходом (confirmed, settled, paid_late, underpaid, expired) страница перестаёт обновляться. Если у мерчанта есть адрес нужного вида, через 5 секунд страница переводит покупателя на сайт мерчанта. Перейти можно и сразу, кнопкой. Подписанный адрес страница берёт из запроса, сделанного прямо перед переходом, а не из ответа, полученного раньше. Без адреса возврата страница показывает исход и оставляет покупателя на месте.

expired и underpaid ещё могут смениться на paid_late, если деньги придут позже. Покупатель увидит это, открыв страницу снова, мерчант узнает из вебхука invoice.paid_late.

Несуществующий инвойс и инвойс отключённого мерчанта дают одинаковый ответ 404 без подробностей. Запросы одного покупателя ограничены числом в минуту: страница отвечает «Слишком много запросов». Как это настраивается — в эксплуатации страницы оплаты.

Адреса возврата

Какой адрес получает покупатель, зависит от статуса инвойса:

Статус Куда Почему
created, seen никуда Инвойс ещё можно оплатить
confirmed, settled адрес возврата Инвойс оплачен
paid_late адрес возврата Платёж пришёл после срока, но пришёл: решение за мерчантом, и принимать его должен сайт мерчанта
expired, underpaid адрес отмены Срок вышел без полной суммы

Адреса мерчанта по умолчанию задаёт оператор — см. мерчанты. Без адреса нужного вида редиректа нет: страница показывает исход и оставляет покупателя на месте.

Мерчант может переопределить адрес для отдельного инвойса полями return_url и cancel_url в POST /v1/invoices. Переопределение допускается, только если у мерчанта задан адрес того же вида, и только с тем же origin — той же схемой, хостом и портом. Например, при адресе мерчанта https://shop.example/thanks допустимо https://shop.example/orders/1001/paid, а https://pay.shop.example/… и https://shop.example:8443/… — нет. Иначе ответ 422 redirect_url_not_allowed.

Требования к любому адресу возврата:

  • только https;
  • без символа @ и без пробелов — так в адрес не попадают логин и пароль;
  • не длиннее 2000 символов;
  • без параметров invoice_id, order_id, status, ts и signature: их добавляет сервис.

Остальные параметры и фрагмент адреса сохраняются как есть.

Итоговый адрес — переопределение инвойса, если оно есть, иначе адрес мерчанта на момент редиректа. Если оператор сменит адрес мерчанта, это сразу коснётся инвойсов без переопределения.

Параметры редиректа

Параметр Значение
invoice_id Идентификатор инвойса
order_id Идентификатор заказа мерчанта
status Статус инвойса в момент, когда страница собрала адрес
ts Время сборки адреса, секунды Unix
signature Подпись, 64 шестнадцатеричных символа в нижнем регистре

Значения закодированы по правилам query-строки: читайте их через URLSearchParams или аналог, а не разбором строки вручную.

Подпись

Подпись — HMAC-SHA256 в hex с секретом вебхуков мерчанта в качестве ключа. Подписывается строка из пяти строк, соединённых символом перевода строки \n (байт 0x0A), без перевода строки в конце:

redirect
<ts>
<invoice_id>
<status>
<order_id>

Разделитель — перевод строки, а не точка, потому что order_id может содержать точки, а перевод строки не может содержать ни одно поле. Первая строка redirect не даёт подписи редиректа сойти за подпись вебхука тем же секретом.

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

  • отклонять редирект, если ts отличается от текущего времени больше чем на 5 минут;
  • сравнивать подписи за постоянное время;
  • проверять, что invoice_id — это инвойс заказа order_id, и брать статус из API, а не из параметра.

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

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

const TOLERANCE_SECONDS = 300;

// query — URLSearchParams запроса, пришедшего на return_url или cancel_url.
export function verifyRedirect({ query, secret, nowMs = Date.now() }) {
  const fields = ["ts", "invoice_id", "status", "order_id"].map((name) => query.get(name));
  const signature = query.get("signature");
  if (fields.some((value) => value === null || value.includes("\n")) || signature === null) return false;
  const [ts, invoiceId, status, orderId] = fields;
  if (!/^\d{1,12}$/.test(ts) || !/^[0-9a-f]{64}$/.test(signature)) return false;
  if (Math.abs(nowMs / 1000 - Number(ts)) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret)
    .update(["redirect", ts, invoiceId, status, orderId].join("\n"))
    .digest();
  return timingSafeEqual(expected, Buffer.from(signature, "hex"));
}

Секрет вебхуков выдаётся при создании мерчанта и хранится в менеджере секретов — см. вебхуки.

Пример для проверки своей реализации. При секрете example-webhook-secret и параметрах ts=1789300000, invoice_id=6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44, status=confirmed, order_id=ord_1001 подпись равна 2af98109e8fad432cc8e511f5e73f542ed3be8913b8d2863fe360f5f31f8e651. Этот пример проверяется тестом сервиса.

Маршрут страницы оплаты

GET /checkout/v1/invoices/{id} — не маршрут для мерчантов. Его вызывает сервер страницы оплаты по приватной сети: API сервиса не публичное, и покупатель из интернета до него не достаёт. Ключ API не нужен: страницу открывает покупатель, у которого ключа нет, а инвойс открывается его идентификатором — случайным UUID, который мерчант передаёт покупателю вместе со ссылкой. В OpenAPI мерчантов маршрута нет.

Ответ не кэшируется (Cache-Control: no-store). Число запросов в минуту с одного IP-адреса ограничено тем же API_RATE_LIMIT_PER_IP, что и у /v1, но отдельным счётчиком. Все запросы страницы приходят с одного адреса её сервера, поэтому этот лимит защищает API, но не разделяет покупателей: ограничивать отдельного покупателя должна сама страница.

Инвойс отключённого мерчанта для этого маршрута не существует: страница не должна продолжать собирать деньги мерчанту, которого остановил оператор.

Поле Тип Описание
invoice_id string (UUID) Идентификатор инвойса
merchant_name string Название мерчанта, которое видит покупатель
status string Статус инвойса, см. инвойсы
asset string Актив, например USDT_TRON
symbol string Тикер актива для показа: USDT
network string Сеть актива: tron
address string Адрес для платежа, целиком
amount_requested string Цена мерчанта
amount_expected string Сумма к оплате со всеми знаками актива: цена и комиссия сервиса, если её платит покупатель
amount_received string Сумма подтверждённых платежей
fees object Комиссии инвойса, как в инвойсе. Разбивку страница показывает при fees.service.payer = customer: комиссия сервиса — amount_expected минус amount_requested
expires_at string Срок оплаты, ISO 8601 в UTC
confirmations_required integer Сколько подтверждений нужно платежу в этом активе — из таблицы assets
deposits array Платежи по инвойсу: tx_hash, amount, confirmations, status (seen или confirmed)
redirect object или null Для статусов с исходом: kindreturn или cancel, url — подписанный адрес. null, пока инвойс можно оплатить или когда у мерчанта нет адреса нужного вида

В ответ не попадают идентификатор мерчанта, его секреты, external_user_id и order_id отдельным полем: номер заказа доходит до покупателя только внутри подписанного адреса, где он нужен мерчанту. Нет и amount_credited: сколько зачислено мерчанту после комиссий, покупателя не касается.

Подписанный адрес собирается в момент запроса и действует 5 минут. Страница запрашивает инвойс заново непосредственно перед переходом, а не хранит адрес.

HTTP error.code Когда
400 validation_failed Идентификатор не в формате UUID
404 invoice_not_found Инвойса нет или мерчант отключён
429 rate_limited Превышен лимит запросов с IP-адреса
{
  "invoice_id": "6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44",
  "merchant_name": "Shop",
  "status": "confirmed",
  "asset": "USDT_TRON",
  "symbol": "USDT",
  "network": "tron",
  "address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
  "amount_requested": "49.900000",
  "amount_expected": "49.900000",
  "amount_received": "49.900000",
  "fees": {
    "service": { "payer": "merchant", "percent": "1.00", "amount": "0.499000" },
    "network": { "payer": "merchant", "quote": null, "quote_rate": null, "actual": null, "status": "pending" }
  },
  "expires_at": "2026-09-11T21:03:05.531Z",
  "confirmations_required": 20,
  "deposits": [
    {
      "tx_hash": "50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c",
      "amount": "49.900000",
      "confirmations": 20,
      "status": "confirmed"
    }
  ],
  "redirect": {
    "kind": "return",
    "url": "https://shop.example/thanks?invoice_id=6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44&order_id=ord_1001&status=confirmed&ts=1789300000&signature=2af98109e8fad432cc8e511f5e73f542ed3be8913b8d2863fe360f5f31f8e651"
  }
}