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

Страница оплаты — собственная страница сервиса, на которой покупатель оплачивает инвойс: она показывает сумму, адрес и статус платежа, а когда у инвойса появляется исход — возвращает покупателя на сайт мерчанта подписанным редиректом. Прочитайте этот документ, если отправляете покупателей по `checkout_url`: в нём — что делает страница, адреса возврата, подпись редиректа и маршруты, которыми пользуется сервер страницы.

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

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

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

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

1. Продукт создаёт инвойс: `POST /v1/invoices`. Поле ответа `checkout_url` — ссылка на страницу оплаты этого инвойса. Если в установке нет страницы оплаты, поле равно `null` — см. [инвойсы](https://stons.io/ru/docs/invoices.md#создать-инвойс).
2. Продукт отправляет покупателя по `checkout_url`.
3. У инвойса, созданного без `asset`, страница сначала даёт покупателю выбрать сеть, затем монету — см. [выбор токена](#выбор-токена). Затем показывает сумму, адрес и статус и обновляет их, пока покупатель платит.
4. Когда у инвойса появляется исход, страница отправляет покупателя на адрес возврата или отмены мерчанта с параметрами и подписью. Пока инвойс не оплачен, кнопка «Вернуться в магазин» ведёт на адрес отмены с теми же параметрами и подписью.
5. Продукт проверяет подпись и берёт статус из API.

Вебхуки при этом приходят так же, как без страницы.

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

Страница открывается по `checkout_url` без входа и без ключей. Другой сайт не может встроить её во фрейм, и страница не передаёт сайту мерчанта `Referer`.

В статусе `created` страница показывает:

- название мерчанта;
- статус «Ожидаем оплату»;
- время до срока оплаты;
- сумму к оплате — `amount_expected` — с тикером и сетью, с кнопкой копирования;
- сеть: её знак по `network_icon_slug`, название `network_name` и стандарт её токенов `token_standard`, например «BNB Smart Chain · Токен BEP-20». У нативной монеты сети — BNB, ETH, TRX, GRAM, SOL — стандарта токенов нет: она не токен, и строки «Токен …» страница не показывает;
- под «Подробнее», свёрнуто: курс котировки; если комиссию сервиса платит покупатель — разбивку «Цена · Комиссия сервиса · Итого»: `amount_requested`, комиссию со ставкой и `amount_expected`; показанная комиссия — `amount_expected` минус `amount_requested`;
- там же — уже полученную сумму, если `amount_received` больше нуля;
- предупреждение отправлять ровно этот актив и в этой сети, одной строкой вместе с тем, что комиссия сети — сверх суммы;
- QR-код;
- адрес целиком, с кнопкой копирования;
- в TON — кнопку «Открыть в кошельке»: ссылку `wallet_url`, которая открывает приложение кошелька с уже заполненными адресом, суммой и токеном;
- кнопку «Вернуться в магазин», если у инвойса или проекта есть адрес отмены.

Сумма, сеть и адрес стоят в одной карточке друг под другом. Адрес занимает свою строку во всю ширину карточки, кнопка копирования рядом с ним — значок; в колонке шириной 512 px адрес до 48 знаков помещается в одну строку, на узком экране переносится. QR-код стоит под адресом. На экране меньше 640 px он скрыт за кнопкой «Показать QR-код»: телефон не сканирует собственный экран; на экране шире код показан сразу. Время до срока — справа в строке с названием мерчанта.

Суммы страница пишет без нулей в конце: `49.900000` показывается и копируется как `49.9`.

Когда на инвойс пришли переводы, страница перечисляет их под заголовком «Полученные переводы»: сумма, подтверждения из нужных, хэш транзакции целиком с кнопкой копирования и ссылка на транзакцию в обозревателе сети. Ссылку страница показывает, только если это адрес `https`; без неё остаётся хэш.

QR-код содержит только адрес. Кошелёк, считавший его, не получает ни суммы, ни токена. Поэтому сумма показывается и копируется отдельно, а страница просит покупателя сверить адрес целиком после вставки. Адрес нигде не сокращается. В TON кнопка «Открыть в кошельке» передаёт ещё и сумму с токеном — ссылкой `ton://transfer` из документации TON; покупатель всё равно сверяет их в кошельке перед подтверждением.

Страница бывает на английском и на русском. Чтобы открыть её на нужном языке, добавьте к `checkout_url` параметр `lang`: `?lang=en` или `?lang=ru`; страница запоминает этот выбор в cookie. Без параметра страница берёт по порядку запомненный выбор, язык браузера и английский. Переключить язык покупатель может внизу страницы.

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

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

Время до срока считается по часам сервера страницы, а не устройства покупателя. Без JavaScript страница не обновляется, не ведёт отсчёт, не копирует и не переводит на сайт.

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

| Статус | Что видит покупатель |
| --- | --- |
| `created` | Перечисленное выше |
| `seen` | «Платёж замечен», число подтверждений сети из нужных, оценку оставшегося ожидания по `confirmation_estimate` — «Осталось около N минут», «Осталось меньше минуты» или «Почти готово» — и время до срока. Своего времени для сети страница не пишет: сколько ждать, зависит от числа подтверждений актива и времени блока сети, а их меняет администратор. В сети, которая ждёт ещё и окончательного блока, страница добавляет, что может выйти немного дольше. Страница не пишет, что больше ничего отправлять не нужно: если покупатель отправил не всю сумму, остаток можно дослать на тот же адрес до конца срока, а после подтверждения страница покажет, сколько осталось. Адрес, QR-код и кнопка «Вернуться в магазин» остаются; сумма счёта — для справки, без кнопки копирования и без разбивки, а кнопки «Открыть в кошельке» нет: пришедший перевод может уже покрывать сумму |
| `partially_paid` | «Пришла часть суммы» и время до срока. Вместо суммы к оплате — «Осталось доплатить»: `amount_remaining` с кнопкой копирования; сумма счёта и уже полученная сумма — под «Подробнее». Адрес, QR-код и кнопка «Вернуться в магазин» остаются; в TON «Открыть в кошельке» подставляет `amount_remaining`. Короткая подсказка под предупреждением: при выводе с биржи выводить с запасом на её комиссию за вывод, чтобы на адрес пришёл весь остаток |
| `confirmed`, `settled` | «Оплата получена» |
| `paid_late` | «Платёж пришёл после срока»: что делать с заказом, решает магазин |
| `underpaid` | «Сумма пришла не полностью». Если адреса отмены нет — ещё просьбу не отправлять остаток на этот адрес и связаться с магазином |
| `expired` | «Срок оплаты истёк» и просьбу не отправлять деньги повторно, если перевод уже ушёл, а связаться с магазином |

В статусе с исходом — `confirmed`, `settled`, `paid_late`, `underpaid` или `expired` — страница скрывает сумму, адрес и QR-код и перестаёт обновляться. Список переводов остаётся, а вместо адреса появляется ссылка на адрес оплаты в обозревателе сети. Дальше:

- **Есть адрес нужного вида** (см. [адреса возврата](#адреса-возврата)) — страница отсчитывает 5 секунд и переводит покупателя на сайт мерчанта. Кнопка переводит сразу: «Перейти в магазин» — на адрес возврата, «Вернуться в магазин» — на адрес отмены. Прямо перед переходом страница запрашивает заново подписанный адрес, а не берёт полученный раньше; если запрос не удался, страница останавливается и просит нажать кнопку ещё раз. Так же страница ведёт себя, если покупатель открыл её у инвойса, у которого исход уже есть.
- **Адреса нужного вида нет** — страница показывает исход и оставляет покупателя на месте.

Пока у инвойса нет исхода, страница никуда не переводит покупателя сама. Кнопка «Вернуться в магазин» при нажатии запрашивает заново подписанный адрес отмены. Если за это время у инвойса появился исход, страница показывает его вместо перехода, а затем переводит, как при любом исходе.

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

Несуществующий инвойс и инвойс отключённого мерчанта показывают одинаковую страницу «Счёт не найден». Страница ограничивает число запросов с одного IP-адреса в минуту и сверх лимита показывает «Слишком много запросов».

## Выбор токена

Инвойс, созданный без `asset` ([инвойсы](https://stons.io/ru/docs/invoices.md#токен-выбирает-покупатель)), пока его статус `created`, открывается экраном выбора. Сверху — название мерчанта и время до срока, под ними одной строкой сумма заказа в `USD`. Дальше:

1. **Сеть** — выпадающий список сетей, в которых API предлагает хотя бы один актив: знак и название из каталога. Сеть не выбирается заранее.
2. **Монета** — второй выпадающий список, доступный после выбора сети: только активы выбранной сети, каждый с каталожной иконкой, тикером и суммой к оплате из API по текущему курсу. Даже единственная монета требует явного выбора. Смена сети сбрасывает выбранную монету; совпадающий тикер не переносит выбор в другую сеть.
3. **Сводка** — одна строка «К оплате»: сумма в выбранной монете и название сети; под ней мелко курс и, если комиссию сервиса платит покупатель, её ставка. Ниже одна строка: отправлять только эту монету в этой сети. Суммы разбивки «Цена · Комиссия сервиса · Итого» страница показывает после выбора, под «Подробнее».

Экран не добавляет нативные монеты или другие активы, которых нет в `choices`: поддержка способа оплаты определяется ядром, а не наличием иконки.

Кнопка «Перейти к оплате» сохраняет выбор, и страница показывает экран оплаты, описанный выше. Кнопка видна на экране любой высоты: если конец формы ниже края экрана, кнопка прилипает к нижнему краю. Под кнопкой — строка о том, что после этого шага монету и сеть сменить нельзя.

- **Показанные и записанные суммы.** Сумму каждой монеты страница показывает по курсу, который API вернул вместе с ней, и отправляет `rate_id` этого курса вместе с выбором. Сервис записывает ровно показанную сумму, пока этот курс ещё свежий; иначе переводит цену по последнему свежему курсу. После выбора страница показывает записанную сумму. В токене, привязанном к доллару США, записанная сумма действует до дедлайна инвойса; в любом другом токене остаток долга может пересчитаться после окна котировки — см. [окно котировки](https://stons.io/ru/docs/invoices.md#окно-котировки).
- **Выбор окончательный.** Повторное нажатие с тем же токеном ничего не меняет. Если уже выбран другой токен, например в другой вкладке, страница показывает экран оплаты в этом токене с пометкой, что сменить его нельзя.
- **Другая вкладка.** Экран выбора раз в 10 секунд проверяет инвойс и перезагружается, если токен выбран или статус уже не `created`.
- **Срок.** Когда время до срока доходит до нуля, экран сообщает, что срок вышел, и выключает кнопку. Когда сервис переводит инвойс в `expired`, страница перезагружается, показывает «Срок оплаты истёк» и переводит на адрес отмены, если он есть.
- **Выбрать нечего.** Если ни один актив нельзя предложить — магазин сейчас не принимает ни одного из них, мерчант не одобрен, платформа приостановила режим адресов магазина или срок вышел, — страница сообщает, что оплатить сейчас нельзя, и просит не отправлять деньги. Разрешённая монета, курс которой сейчас не свежий, не предлагается, пока курс снова не станет свежим; этот случай API отмечает полем `rates_unavailable`.
- **Токен стал недоступен** к моменту нажатия — страница просит выбрать другой. Если сохранить выбор не удалось по другой причине, страница просит попробовать ещё раз через минуту.
- **Без JavaScript** интерактивный селектор скрыт; работает обычная форма с группами по сетям и вариантами монет с суммами внутри каждой группы.

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

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

| Статус | Куда | Почему |
| --- | --- | --- |
| `created`, `seen`, `partially_paid` | адрес отмены — только кнопкой «Вернуться в магазин» | Инвойс ещё можно оплатить: страница не уводит покупателя сама |
| `confirmed`, `settled`, `paid_late` | адрес возврата | Инвойс оплачен; при `paid_late` платёж пришёл после срока, и решает мерчант |
| `expired`, `underpaid` | адрес отмены | Срок вышел без полной суммы |

Адреса проекта задаются в настройках проекта в кабинете — «После успешной оплаты» это адрес возврата, «Вернуться в магазин / при неудаче» — адрес отмены — или оператором. Пустое поле означает, что адреса нет. Без адреса нужного вида редиректа нет, а без адреса отмены нет и кнопки «Вернуться в магазин».

Для отдельного инвойса адрес можно переопределить полями `return_url` и `cancel_url` в `POST /v1/invoices` или в форме счёта в кабинете. Переопределение допускается, если его origin — схема, хост и порт — совпадает с origin одного из этих адресов проекта:

- адреса того же вида из настроек проекта;
- сайта проекта из заявки на проект.

Например, при сайте проекта `https://shop.example` допустимо `https://shop.example/orders/1001/paid`, а `https://pay.shop.example/…` и `https://shop.example:8443/…` — нет. На общих хостах `t.me` и `telegram.me` под одним origin живут разные боты и каналы, поэтому там совпасть должен и первый сегмент пути — имя бота или канала, без учёта регистра. При сайте проекта `https://t.me/example_shop_bot` допустимо `https://t.me/example_shop_bot?start=paid_1001`, а `https://t.me/other_bot` — нет. Адрес проекта на общем хосте без имени в пути, например `https://t.me`, не допускает ничего.

Если совпадения нет или у проекта нет ни адреса того же вида, ни сайта, ответ — `422 redirect_url_not_allowed`, и сообщение называет допустимые origin. Адреса проекта меняются в кабинете или оператором, но никогда ключом API: утёкший ключ не отправит покупателей на сайт или к боту, которых нет в проекте.

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

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

Остальные параметры и фрагмент сохраняются как есть. Используется переопределение инвойса, если оно есть, иначе адрес проекта **на момент редиректа**: смена адреса проекта сразу касается инвойсов без переопределения.

### Переход до исхода

На адрес отмены покупатель может прийти, пока инвойс открыт: со `status=created`, `status=seen` или `status=partially_paid`, если нажал «Вернуться в магазин». Такой инвойс ещё может быть оплачен; при `seen` перевод уже замечен в сети, а при `partially_paid` часть суммы уже пришла. **Не отменяйте заказ по такому переходу**: статус берите из `GET /v1/invoices/{id}` и вебхуков, как после любого редиректа.

## Если вы переходите с Heleket

| Heleket | STONS | Как устроено здесь |
| --- | --- | --- |
| `url_success` | `return_url` | Задаётся в настройках проекта или в инвойсе. После оплаты страница переводит покупателя сама через 5 секунд, кнопкой — сразу |
| `url_return` | `cancel_url` | Задаётся там же. Пока инвойс не оплачен — кнопка «Вернуться в магазин»; после срока без полной суммы страница переводит сама |
| `lifetime` | `expires_in` | От 300 до 259200 секунд (72 часа), по умолчанию 3600 — см. [инвойсы](https://stons.io/ru/docs/invoices.md#создать-инвойс) |
| `order_id` | `order_id` | Уникален в пределах проекта: 1–200 видимых ASCII-символов. Повтор с теми же данными возвращает созданный инвойс, с другими — `409 order_id_conflict` |
| `url_callback` | адрес вебхуков проекта | Один на проект, задаётся в настройках проекта, а не в инвойсе — см. [вебхуки](https://stons.io/ru/docs/webhooks.md) |

Адреса в инвойсе допускаются только в пределах сайтов проекта — см. [адреса возврата](#адреса-возврата). К каждому редиректу сервис добавляет параметры и подпись, описанные ниже.

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

| Параметр | Значение |
| --- | --- |
| `invoice_id` | Идентификатор инвойса |
| `order_id` | Идентификатор заказа мерчанта |
| `status` | Статус инвойса в момент, когда сервис собрал адрес; после кнопки «Вернуться в магазин» — `created`, `seen` или `partially_paid` |
| `ts` | Момент, когда сервис собрал адрес, целые секунды Unix |
| `signature` | Подпись, 64 шестнадцатеричных символа в нижнем регистре |

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

## Подпись

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

```text
redirect
<ts>
<invoice_id>
<status>
<order_id>
```

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

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

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

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

```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"));
}
```

Функция возвращает `true` только для неизменённого адреса, подписанного этим секретом не раньше чем 300 секунд назад. Секрет вебхуков показывается один раз, при создании проекта в кабинете, — см. [вебхуки](https://stons.io/ru/docs/webhooks.md#подпись).

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

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

Два маршрута ниже — не для мерчантов. Их вызывает сервер страницы оплаты по приватной сети: из интернета они недоступны и в описании OpenAPI их нет.

- **Без ключа API.** Страницу открывает покупатель, у которого ключа нет; инвойс открывается его идентификатором — случайным UUID, который доходит до покупателя внутри `checkout_url`.
- **Отключённый мерчант.** Его инвойсов для этих маршрутов не существует (`404 invoice_not_found`): страница не должна продолжать собирать деньги мерчанту, которого выключил оператор или владелец.
- **Кэширование.** Успешный ответ содержит `Cache-Control: no-store`: в нём подписанные адреса.
- **Лимит запросов.** Запросы с одного IP-адреса ограничены в окне 60 секунд тем же значением, что лимит по IP у `/v1`, но отдельным счётчиком. Все запросы страницы приходят с адреса её сервера, поэтому этот лимит защищает API, но не разделяет покупателей: покупателей ограничивает сама страница.

## Прочитать инвойс для страницы

`GET /checkout/v1/invoices/{id}` возвращает инвойс в том виде, в каком его показывает страница оплаты.

### Запрос

| Параметр | Где | Тип | Обязательно | Описание |
| --- | --- | --- | --- | --- |
| `id` | путь | string (UUID) | да | `invoice_id` |

### Ответ

`200`. Все поля присутствуют всегда; поле, которое не применимо, равно `null`.

| Поле | Тип | Описание |
| --- | --- | --- |
| `invoice_id` | string (UUID) | Идентификатор инвойса |
| `merchant_name` | string | Название мерчанта, которое видит покупатель |
| `status` | string | Статус инвойса — см. [инвойсы](https://stons.io/ru/docs/invoices.md#статусы) |
| `asset` | string или null | Актив, например `USDT_TRON`; `null`, пока покупатель не выбрал токен |
| `asset_icon_slug` | string или null | Slug знака актива из `assets.icon_slug`, например `usdt0`; не выводится из тикера. `null` до выбора или если знак не задан |
| `symbol` | string или null | Тикер актива для показа: `USDT`; `null`, пока покупатель не выбрал токен |
| `network` | string или null | Идентификатор сети актива, например `tron`; `null`, пока покупатель не выбрал токен |
| `network_name` | string или null | Название сети для покупателя из реестра сетей: `TRON`; `null`, пока покупатель не выбрал токен |
| `network_icon_slug` | string или null | Slug знака сети из реестра сетей: `tron`. `null`, пока покупатель не выбрал токен или у сети нет знака; тогда страница рисует нейтральный знак с первой буквой названия |
| `token_standard` | string или null | Как кошельки называют токены сети: `TRC-20`; `null`, пока покупатель не выбрал токен, и у нативной монеты сети (у актива без контракта), которая не токен. Страница показывает сеть как «TRON (TRC-20)», а при `null` — одним названием |
| `address` | string или null | Адрес для платежа, целиком, в том виде, в каком его видит покупатель: в EVM-сетях — с контрольной суммой EIP-55, в TON — user-friendly без флага bounce, начинается с `UQ` (`0Q` в тестовой сети), см. [адреса в TON](https://stons.io/ru/docs/addresses.md#адреса-в-ton), в Solana — base58 из 32–44 знаков, регистр которых нужно сохранять, см. [адреса в Solana](https://stons.io/ru/docs/addresses.md#адреса-в-solana). `null`, пока покупатель не выбрал токен |
| `wallet_url` | string или null | Ссылка, которая открывает приложение кошелька с заполненным переводом. Только в TON: `ton://transfer/<адрес>?amount=<сумма>`, а у токена ещё `&jetton=<мастер-контракт жетона>`, как ссылку определяет документация TON (<https://docs.ton.org/onboarding/wallet-apps/deep-links>). `amount` — сколько осталось заплатить, в минимальных единицах актива: нанограммах GRAM, миллионных долях USDT; мастер-контракт — в user-friendly форме с флагом bounce. **В Solana сервис ссылки на кошелёк не отдаёт**: поле там остаётся `null`, покупатель копирует адрес и сумму со страницы, а кнопки «Открыть в кошельке» нет. `null` в других сетях, пока покупатель не выбрал токен, пока перевод ждёт подтверждений (`seen`), в статусе с исходом, после срока оплаты и пока котировка обновляется — всегда, когда точная сумма к отправке неизвестна |
| `address_explorer_url` | string или null | Страница адреса в обозревателе сети; `null`, пока покупатель не выбрал токен или у сети нет ссылки на обозреватель |
| `price` | object или null | Цена инвойса, созданного без `asset`: `amount` в долларах США с 6 знаками после точки, `currency` — `USD`. `null` у инвойса, созданного с `asset` |
| `choices` | array или null | У инвойса без токена — активы, которые можно выбрать сейчас, в порядке каталога, — см. таблицу ниже. Пустой массив — выбрать сейчас нечего: статус не `created`, срок вышел, мерчант не одобрен, платформа приостановила режим адресов мерчанта или ни один разрешённый актив не проходит проверки. `null` у инвойса с токеном |
| `rates_unavailable` | boolean | `true`, когда разрешённый актив не попал в `choices` только потому, что его курс сейчас не свежий; такой актив вернётся, как только курс станет свежим. У инвойса с токеном всегда `false` |
| `quote` | object или null | У инвойса, токен которого выбран: `rate` — курс сумм, долларов США за целую единицу токена с 18 знаками после точки; `currency` — `USD`; `expires_at` — до какого момента действуют суммы, см. [окно котировки](https://stons.io/ru/docs/invoices.md#окно-котировки). `null` у инвойса, созданного с `asset`, до выбора и у инвойса, токен которого выбран до появления курсов |
| `amount_requested` | string или null | Цена мерчанта в единицах актива; `null`, пока покупатель не выбрал токен |
| `amount_expected` | string или null | Сумма к оплате со всеми знаками актива: цена и комиссия сервиса, если её платит покупатель; `null`, пока покупатель не выбрал токен |
| `amount_received` | string или null | Сумма подтверждённых платежей; `null`, пока покупатель не выбрал токен |
| `amount_remaining` | string или null | `amount_expected` минус `amount_received`, но не меньше нуля, как в [инвойсе](https://stons.io/ru/docs/invoices.md#частичная-оплата). Страница просит его доплатить в статусе `partially_paid`; `null`, пока покупатель не выбрал токен |
| `fees` | object | Комиссии инвойса, как в [инвойсе](https://stons.io/ru/docs/invoices.md#комиссии). Разбивку страница показывает при `fees.service.payer`, равном `customer`, а `fees.network` не читает |
| `expires_at` | string | Срок оплаты |
| `confirmations_required` | integer или null | Сколько подтверждений нужно платежу в этом активе: 20 в боевой сети TRON, 1 в TON, 1 в Solana; `null`, пока покупатель не выбрал токен |
| `confirmation_estimate` | object или null | Пока перевод ждёт подтверждений (`status` у него `seen`): сколько ещё ждать самому медленному из таких переводов. `remaining_blocks` — сколько блоков не хватает до `confirmations_required`; `remaining_seconds` — эти блоки, умноженные на время блока сети из реестра сетей, с округлением вверх до целой секунды; `waits_for_finality` — `true`, если сеть засчитывает перевод, только когда его блок ещё и окончательный (тег `finalized`), и тогда ожидание может выйти дольше оценки. После нужной глубины оба числа — `0`. Это оценка: блоки идут не строго по расписанию, а сканер читает сеть проходами. `null`, когда ни один перевод подтверждений не ждёт |
| `deposits` | array | Платежи по инвойсу: `tx_hash`, `amount`, `confirmations`, `status` (`seen` или `confirmed`) и `explorer_url`, как в [инвойсе](https://stons.io/ru/docs/invoices.md#получить-инвойс) |
| `redirect` | object или null | В статусе с исходом, если у инвойса или проекта есть адрес этого вида: `kind` — `return` или `cancel`, `url` — подписанный адрес. Иначе `null` |
| `back_url` | string или null | В `created`, `seen` и `partially_paid`, если у инвойса или проекта есть адрес отмены: подписанный адрес отмены для кнопки «Вернуться в магазин». Иначе `null` |

Поля элемента `choices`:

| Поле | Тип | Описание |
| --- | --- | --- |
| `asset` | string | Идентификатор актива: значение для [сохранения выбора](#сохранить-выбор-токена) |
| `asset_icon_slug` | string или null | Slug знака актива из каталога, независимо от `symbol`; `null`, если знак не задан |
| `network` | string | Сеть актива |
| `network_name` | string | Название сети для покупателя из реестра сетей |
| `network_icon_slug` | string или null | Slug знака сети; `null`, если у сети знака нет |
| `token_standard` | string или null | Как кошельки называют токены сети; `null` у нативной монеты сети |
| `symbol` | string | Тикер актива |
| `decimals` | integer | Число знаков актива |
| `amount_requested` | string | Цена, переведённая в этот актив по курсу `rate` с округлением вверх |
| `amount_expected` | string | Сумма к оплате в этом активе по курсу `rate`, с комиссией сервиса, если её платит покупатель. Совпадает с `amount_expected` инвойса после выбора по этому курсу, если фиксированная часть комиссии за это время не изменилась |
| `min_amount` | string | Минимальный депозит актива |
| `rate` | string | Курс сумм: долларов США за целую единицу актива, с 18 знаками после точки. У актива, не привязанного к доллару США, наценка платформы уже учтена |
| `rate_id` | string | Идентификатор этого курса: его отправляют вместе с [выбором](#сохранить-выбор-токена), чтобы записались показанные суммы |
| `rate_expires_at` | string | До какого момента выбор с этим `rate_id` записывается по этому курсу. Выбор, отправленный позже, переводится по последнему свежему курсу, поэтому к этому моменту страница перечитывает инвойс |

Знаки актива и сети независимы. Страница не угадывает знак токена по `symbol`: неизвестный slug даёт нейтральный знак. Отсутствующий в ответе старого API или некорректный `asset_icon_slug` страница читает как `null`, не блокируя оплату.

Подписанные адреса в `redirect` и `back_url` собираются в момент запроса, и `ts` в них — этот момент. В ответ не попадают идентификатор мерчанта, его секреты, `external_user_id`, `order_id` отдельным полем и `amount_credited`: номер заказа доходит до покупателя только внутри подписанного адреса.

### Ошибки

| HTTP | `error.code` | Когда |
| --- | --- | --- |
| 400 | `validation_failed` | `id` — не UUID |
| 404 | `invoice_not_found` | Инвойса нет, или его мерчант отключён |
| 429 | `rate_limited` | Превышен лимит запросов с IP-адреса |
| 500 | `internal_error` | Необработанная ошибка сервиса |

### Пример

```http
GET /checkout/v1/invoices/6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44
```

`200` — оплаченный инвойс, у проекта которого есть адрес возврата:

```json
{
  "invoice_id": "6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44",
  "merchant_name": "Shop",
  "status": "confirmed",
  "asset": "USDT_TRON",
  "asset_icon_slug": "usdt",
  "symbol": "USDT",
  "network": "tron",
  "network_name": "TRON",
  "network_icon_slug": "tron",
  "token_standard": "TRC-20",
  "address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
  "address_explorer_url": "https://tronscan.org/#/address/TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
  "wallet_url": null,
  "price": null,
  "choices": null,
  "rates_unavailable": false,
  "quote": null,
  "amount_requested": "49.900000",
  "amount_expected": "49.900000",
  "amount_received": "49.900000",
  "amount_remaining": "0.000000",
  "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" }
  },
  "expires_at": "2026-09-11T21:03:05.531Z",
  "confirmations_required": 20,
  "confirmation_estimate": null,
  "deposits": [
    {
      "tx_hash": "50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c",
      "amount": "49.900000",
      "confirmations": 20,
      "status": "confirmed",
      "explorer_url": "https://tronscan.org/#/transaction/50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c"
    }
  ],
  "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"
  },
  "back_url": null
}
```

`200` — инвойс без токена, покупатель которого платит комиссию сервиса 1%, а у проекта нет адреса отмены, при курсе USDT 0.9991 USD:

```json
{
  "invoice_id": "3b9e5c2a-7d41-4f6b-8a2e-5c1d9f0b7e63",
  "merchant_name": "Shop",
  "status": "created",
  "asset": null,
  "asset_icon_slug": null,
  "symbol": null,
  "network": null,
  "network_name": null,
  "network_icon_slug": null,
  "token_standard": null,
  "address": null,
  "address_explorer_url": null,
  "wallet_url": null,
  "price": { "amount": "100.000000", "currency": "USD" },
  "choices": [
    {
      "asset": "USDT_TRON",
      "asset_icon_slug": "usdt",
      "network": "tron",
      "network_name": "TRON",
      "network_icon_slug": "tron",
      "token_standard": "TRC-20",
      "symbol": "USDT",
      "decimals": 6,
      "amount_requested": "100.090082",
      "amount_expected": "101.090983",
      "min_amount": "1.000000",
      "rate": "0.999100000000000000",
      "rate_id": "48213",
      "rate_expires_at": "2026-09-16T12:05:00.000Z"
    }
  ],
  "rates_unavailable": false,
  "quote": null,
  "amount_requested": null,
  "amount_expected": null,
  "amount_received": null,
  "amount_remaining": null,
  "fees": {
    "service": { "payer": "customer", "percent": "1.00", "fixed": null, "amount": null },
    "network": { "payer": "merchant", "quote": null, "quote_rate": null, "actual": null, "status": "included" }
  },
  "expires_at": "2026-09-11T21:03:05.531Z",
  "confirmations_required": null,
  "confirmation_estimate": null,
  "deposits": [],
  "redirect": null,
  "back_url": null
}
```

API отдаёт плоский список активов, у каждого своя сеть; группировать их по монете — дело страницы.

## Сохранить выбор токена

`POST /checkout/v1/invoices/{id}/select` записывает токен, который покупатель выбрал для инвойса, созданного без `asset`. Сервер страницы вызывает его, когда покупатель нажимает «Перейти к оплате».

### Запрос

`id` в пути — `invoice_id`. Тело — JSON-объект с `Content-Type: application/json`; поле, которого нет в таблице, отклоняется с `400 validation_failed`.

| Поле | Тип | Обязательно | Описание |
| --- | --- | --- | --- |
| `asset` | string | да | Один из `choices[].asset`, например `USDT_TRON` |
| `rate_id` | string | нет | `rate_id` того же элемента `choices`: курс, суммы по которому видел покупатель |

### Ответ

`200` с инвойсом в формате [чтения инвойса для страницы](#прочитать-инвойс-для-страницы) — уже с токеном, адресом, суммами и `quote` и с `choices`, равным `null`.

- **Выбор окончательный.** Повторный запрос с тем же активом возвращает инвойс без изменений, даже после срока, поэтому запрос, ответ на который потерялся, безопасно повторить. Другой актив получает `409 asset_already_selected`. То же у инвойса, созданного с `asset`.
- **Одновременные запросы** обрабатываются по одному: первый записывает свой актив, остальные получают `200` или `409 asset_already_selected` по правилу выше.
- **Когда выбор возможен:** статус `created`, и часы сервиса не дошли до `expires_at`.
- **Проверки** — по порядку, с теми же кодами, что при создании инвойса: мерчант одобрен; платформа не приостановила его режим адресов; актив есть в `currencies` инвойса, если у инвойса есть список; актив принимается к оплате; включён оператором и в настройках проекта; у него есть курс, и курс свежий; цена, переведённая по этому курсу, не меньше его минимального депозита.
- **Курс.** С `rate_id` цена переводится по этому курсу, если это принятый курс монеты этого актива и он ещё свежий. Иначе, как и без `rate_id`, — по последнему свежему курсу монеты. `rate_id` другой монеты или старый не ошибка: он просто не используется. Выбор записывает первую котировку, её показывает `quote` ответа, — см. [цену и курс](https://stons.io/ru/docs/invoices.md#цена-и-курс).
- **Условия.** Ставка комиссии и плательщики — записанные при создании инвойса; фиксированная часть комиссии — та, что у актива в момент выбора. `amount_expected` считается от переведённой цены так же, как у инвойса, созданного в этом активе.
- **Адрес.** Мерчанту `per_invoice` выводится новый адрес, мерчанту `per_user` — постоянный адрес пользователя из `external_user_id`.

### Ошибки

| HTTP | `error.code` | Когда |
| --- | --- | --- |
| 400 | `validation_failed` | `id` — не UUID; в теле нет `asset`, есть лишнее поле, `asset` — не идентификатор актива или `rate_id` — не идентификатор курса; сумма вместе с комиссией сервиса длиннее 78 цифр в минимальных единицах |
| 400, 413, 415 | `bad_request` | Тело — не JSON или пустое (400), больше 16 КиБ (413) или с неподдерживаемым `Content-Type` (415) |
| 403 | `merchant_not_approved` | Мерчант ещё не одобрен после проверки или отклонён |
| 404 | `invoice_not_found` | Инвойса нет, или его мерчант отключён |
| 409 | `asset_already_selected` | У инвойса уже другой актив |
| 409 | `invoice_expired` | Срок вышел, или инвойс уже не в статусе `created` |
| 422 | `address_mode_disabled` | Платформа временно приостановила режим адресов мерчанта — см. [режим на паузе](https://stons.io/ru/docs/addresses.md#режим-на-паузе) |
| 422 | `asset_not_offered` | Актива нет в `currencies` инвойса, или у него нет курса |
| 422 | `asset_not_supported` | Актив неизвестен или не принимается к оплате |
| 422 | `asset_payments_disabled` | Оператор временно выключил приём в активе |
| 422 | `asset_disabled_by_merchant` | Приём в активе выключен в настройках проекта |
| 422 | `amount_below_minimum` | Цена в активе по использованному курсу меньше его минимального депозита |
| 429 | `rate_limited` | Превышен лимит запросов с IP-адреса |
| 500 | `internal_error` | Необработанная ошибка сервиса |
| 503 | `rate_unavailable` | У монеты актива сейчас нет свежего курса. Ничего не записывается; покупатель может выбрать другой актив или попробовать позже |
| 503 | `addresses_exhausted` | Закончились индексы деривации адресов. Практически недостижимо |
| 503 | `service_unavailable` | Сервис не может вывести новый адрес в текущей конфигурации; сообщение называет причину |

### Пример

```http
POST /checkout/v1/invoices/3b9e5c2a-7d41-4f6b-8a2e-5c1d9f0b7e63/select
Content-Type: application/json

{"asset": "USDT_TRON", "rate_id": "48213"}
```

`200` — инвойс без токена из предыдущего примера после выбора по показанному курсу:

```json
{
  "invoice_id": "3b9e5c2a-7d41-4f6b-8a2e-5c1d9f0b7e63",
  "merchant_name": "Shop",
  "status": "created",
  "asset": "USDT_TRON",
  "asset_icon_slug": "usdt",
  "symbol": "USDT",
  "network": "tron",
  "network_name": "TRON",
  "network_icon_slug": "tron",
  "token_standard": "TRC-20",
  "address": "TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK",
  "address_explorer_url": "https://tronscan.org/#/address/TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK",
  "wallet_url": null,
  "price": { "amount": "100.000000", "currency": "USD" },
  "choices": null,
  "rates_unavailable": false,
  "quote": { "rate": "0.999100000000000000", "currency": "USD", "expires_at": "2026-09-11T21:03:05.531Z" },
  "amount_requested": "100.090082",
  "amount_expected": "101.090983",
  "amount_received": "0.000000",
  "amount_remaining": "101.090983",
  "fees": {
    "service": { "payer": "customer", "percent": "1.00", "fixed": "0.000000", "amount": "0.000000" },
    "network": { "payer": "merchant", "quote": null, "quote_rate": null, "actual": null, "status": "included" }
  },
  "expires_at": "2026-09-11T21:03:05.531Z",
  "confirmations_required": 20,
  "confirmation_estimate": null,
  "deposits": [],
  "redirect": null,
  "back_url": null
}
```
