Страница оплаты
Кроме интеграции, в которой продукт сам показывает покупателю адрес и сумму, сервис умеет принимать платёж на своей странице. Продукт создаёт инвойс, отправляет покупателя по ссылке из ответа, страница показывает сумму, адрес и статус платежа, а когда у инвойса появляется исход — возвращает покупателя на сайт мерчанта подписанным редиректом. Вебхуки при этом приходят так же, как без страницы. Решение и его границы — ADR-0032.
Главное правило
Не отдавайте товар по редиректу. Получив покупателя на
return_url, проверьте подпись, затем запросите инвойсGET /v1/invoices/{id}и отдавайте товар только по ответу API — так же, как после вебхука.
Подпись доказывает одно: адрес собрал сервис для этого инвойса в этом статусе. Она не мешает покупателю открыть ту же ссылку ещё раз, пока не истекло окно проверки, и не заменяет проверку статуса.
Как это работает
- Продукт создаёт инвойс:
POST /v1/invoices. В ответе полеcheckout_url— ссылка на страницу оплаты этого инвойса. Если оператор не настроил страницу, поле равноnull— см. инвойсы. - Продукт отправляет покупателя по
checkout_url. - Страница показывает сумму, адрес целиком, срок и подтверждения, и обновляет их, пока покупатель платит.
- Когда инвойс оплачен или срок вышел, страница отправляет покупателя на адрес возврата или отмены с параметрами и подписью.
- Продукт проверяет подпись и статус через 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 | Для статусов с исходом: kind — return или 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"
}
}