# STONS: платёжный API > Приём и выплаты стейблкоинов USDT и USDC в сети TRON. Инвойс отдаёт адрес и точную сумму для оплаты на своей странице и ссылку на готовую страницу оплаты; о событиях сервис сообщает вебхуками с подписью HMAC-SHA256; выплаты — только на адреса из вайтлиста. API: https://api.stons.io/v1, ключ в заголовке X-Api-Key. --- # Интеграция под ключ с ИИ-ассистентом Этот документ — задание для ИИ-ассистента в проекте мерчанта: Claude Code, Cursor или любого другого, который пишет код по инструкции. Он собирает из документации STONS то, что нужно, чтобы подключить приём платежей от начала до конца, в порядке, в котором это делается. Каждое правило здесь — из документов API, на которые стоят ссылки; при расхождении прав документ API. ## Шаг 0. Что спросить у человека Прежде чем писать код, выясните у владельца проекта: 1. **Ключ API** вида `sk_<16 шестнадцатеричных символов>_<43 символа base64url>`. Его выдаёт оператор STONS, показывается он один раз — см. [аутентификацию](https://stons.io/docs/authentication.md). 2. **Секрет вебхуков.** Выдаётся вместе с ключом при создании мерчанта — см. [вебхуки](https://stons.io/docs/webhooks.md#подпись). 3. **URL для вебхуков**, который оператор указал у мерчанта. Сменить его может только оператор. 4. **Режим адресов мерчанта**: `per_invoice` — новый адрес под каждый инвойс, `per_user` — постоянный адрес пользователя. Режим задаёт оператор — см. [адреса](https://stons.io/docs/addresses.md#режимы). 5. **Как показывать оплату:** своей страницей в продукте (адрес и сумма из ответа API) или страницей оплаты сервиса по ссылке `checkout_url`. Во втором случае — адреса возврата и отмены, которые оператор задал мерчанту: без них страница не вернёт покупателя на сайт — см. [страницу оплаты](https://stons.io/docs/checkout.md#адреса-возврата). 6. **Активы:** `USDT_TRON`, `USDC_TRON` или оба — см. [инвойсы](https://stons.io/docs/invoices.md#активы). Не придумывайте значения за человека: ключ и секрет существуют только у него. ## Шаг 1. Переменные окружения Ключ и секрет — только на сервере продукта, в менеджере секретов или переменных окружения. Их нет в браузере, мобильном приложении, репозитории и логах — см. [чек-лист](https://stons.io/docs/checklist.md#ключи-и-доступ). Имена переменных выбирает проект, например: ```sh PAYMENTS_API_URL=https://api.stons.io PAYMENTS_API_KEY=sk_... PAYMENTS_WEBHOOK_SECRET=... ``` Адрес API — с `https://`: по `http://` ключ ушёл бы открытым текстом ещё до перенаправления — см. [аутентификацию](https://stons.io/docs/authentication.md#сеть). ## Шаг 2. Клиент API - Базовый адрес — `https://api.stons.io/v1`, ключ — в заголовке `X-Api-Key`, тела запросов и ответов — JSON. - **Суммы — строки** в единицах актива: `"49.9"` в запросе, `"49.900000"` в ответе. Не превращайте их в числа с плавающей точкой — см. [инвойсы](https://stons.io/docs/invoices.md#суммы). - Ошибка — тело `{"error": {"code", "message", "details"}}`. Решения принимайте по `error.code`, а не по тексту `message` — см. [коды ошибок](https://stons.io/docs/errors.md). - `429 rate_limited` — повторить через время из заголовка `Retry-After`. - `X-Request-Id` из ответа сохраняйте в логах: по нему поддержка находит запрос. - Описание API в формате OpenAPI 3.1 — `https://api.stons.io/v1/openapi.json`. ## Шаг 3. Создать инвойс `POST /v1/invoices` — см. [создание инвойса](https://stons.io/docs/invoices.md#создать-инвойс). ```sh curl -s -X POST https://api.stons.io/v1/invoices \ -H "X-Api-Key: $PAYMENTS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"order_id": "ord_1001", "asset": "USDT_TRON", "amount": "49.9", "expires_in": 3600}' ``` - `order_id` — номер заказа в продукте, 1–200 видимых ASCII-символов без пробелов. Это ключ идемпотентности: при таймауте повторяйте запрос **с тем же** `order_id` — вернётся тот же инвойс с кодом `200`, второй не создастся. Тот же `order_id` с другой суммой, активом или пользователем — `409 order_id_conflict`. - `expires_in` — от 60 до 604800 секунд. - В режиме `per_user` обязателен `external_user_id`. - Сумма не меньше минимального депозита актива, иначе `422 amount_below_minimum`. - Сохраните у заказа `invoice_id`: по нему дальше проверяется статус. ## Шаг 4А. Оплата на своей странице Покажите покупателю из ответа: - `address` — **целиком**, без сокращений вида «начало…конец»: на сокращённые адреса охотятся подменой похожим адресом — см. [адреса](https://stons.io/docs/addresses.md#показ-адреса); - `amount_expected` и актив. Это сумма к оплате: если в проекте комиссию сервиса платит покупатель, она больше цены заказа, а сама цена — в `amount_requested`. Свою цену сверяйте с `amount_requested` — см. [комиссии](https://stons.io/docs/invoices.md#комиссии); - сеть — TRON, с предупреждением, что перевод другого токена или в другой сети не зачисляется автоматически — см. [чек-лист](https://stons.io/docs/checklist.md#отображение). Статус узнавайте из вебхуков (шаг 5) и запросом `GET /v1/invoices/{id}`. ## Шаг 4Б. Страница оплаты сервиса Отправьте покупателя по `checkout_url` из ответа. Если поле `null`, оператор не настроил страницу — используйте шаг 4А или обратитесь к оператору. Страница сама показывает сумму, адрес, QR-код и статус, а когда у инвойса появляется исход, возвращает покупателя на адрес возврата или отмены — см. [страницу оплаты](https://stons.io/docs/checkout.md). Для отдельного инвойса адреса можно переопределить полями `return_url` и `cancel_url` в `POST /v1/invoices`, но только с тем же origin, что у адресов мерчанта, иначе `422 redirect_url_not_allowed`. ## Шаг 5. Обработчик вебхуков Сервис шлёт `POST` на URL мерчанта. Обработчик — см. [вебхуки](https://stons.io/docs/webhooks.md): 1. Читает **сырое тело** запроса, до разбора JSON. 2. Проверяет подпись: `X-Signature: sha256=` — это HMAC-SHA256 секретом вебхуков по строке `.<сырое тело>`. Отклоняет вебхук, если `X-Timestamp` отличается от текущего времени больше чем на 5 минут. Сравнивает подписи за постоянное время. 3. Отбрасывает дубли по `id` события: одно событие может прийти несколько раз. 4. Отвечает `2xx` сразу после проверки, а работу выполняет асинхронно: вебхук без ответа `2xx` за 10 секунд доставляется повторно. 5. **Не доверяет телу.** По `data.invoice_id` запрашивает `GET /v1/invoices/{id}` и действует по ответу API. Проверка подписи на Node.js — из [вебхуков](https://stons.io/docs/webhooks.md#подпись): ```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); } ``` Порядок доставки не гарантирован: `invoice.confirmed` может прийти раньше `invoice.seen`. Ориентируйтесь на состояние из API. ## Шаг 6. Когда отдавать товар По статусу из `GET /v1/invoices/{id}` — см. [статусы](https://stons.io/docs/invoices.md#статусы): | Статус | Что делать | | --- | --- | | `created`, `seen` | Ждать. `seen` — перевод замечен, но ещё может откатиться | | `confirmed`, `settled` | Отдать товар. Переплата — тоже `confirmed`, `amount_received` больше `amount_expected` | | `underpaid` | Срок вышел, пришла часть суммы: решает продукт | | `expired` | Срок вышел без платежа: закрыть заказ | | `paid_late` | Платёж пришёл после срока: решает продукт | Выдачу товара делайте идемпотентной: повтор вебхука или редиректа не должен выдать товар второй раз. ## Шаг 7. Возврат со страницы оплаты Если используется страница оплаты, покупатель приходит на адрес возврата или отмены с параметрами `invoice_id`, `order_id`, `status`, `ts` и `signature`. Обработчик — см. [подпись редиректа](https://stons.io/docs/checkout.md#подпись): 1. Проверяет подпись: HMAC-SHA256 секретом вебхуков по строкам `redirect`, `ts`, `invoice_id`, `status`, `order_id`, соединённым символом `\n`, и окно `ts` в 5 минут. 2. Проверяет, что `invoice_id` — инвойс этого `order_id`. 3. Берёт статус из `GET /v1/invoices/{id}`, а не из параметра. **Товар по редиректу не отдаётся.** ```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")); } ``` ## Шаг 8. Выплаты — если нужны `POST /v1/payouts` — см. [выплаты](https://stons.io/docs/payouts.md): - платить можно только на адреса из вайтлиста мерчанта. Адреса вносит оператор, новый адрес становится доступен через задержку, по умолчанию 24 часа. Адрес для выплаты никогда не берётся из истории транзакций; - `reference` — ключ идемпотентности: повтор с тем же `reference` возвращает ту же выплату; - `pending_review` — не ошибка: выплата принята и ждёт решения человека; - `503 payouts_halted` — повторить позже с тем же `reference`; - исход приходит вебхуком `payout.confirmed` или `payout.failed`. ## Шаг 9. Перед запуском Пройдите [чек-лист перед продом](https://stons.io/docs/checklist.md) целиком и проверьте весь путь на небольшой сумме: инвойс, оплата, вебхук, проверка статуса, выдача товара. --- # Аутентификация Каждый запрос к маршрутам `/v1` передаёт ключ API мерчанта в заголовке `X-Api-Key`: ```sh curl -H "X-Api-Key: $API_KEY" https://api.stons.io/v1/invoices/ ``` Ключ выдаёт оператор STONS — см. мерчанты и ключи API. Он имеет вид `sk_<16 шестнадцатеричных символов>_<43 символа base64url>`. Первая часть — публичный идентификатор ключа, вторая — секрет. Сервис хранит не ключ, а хэш argon2id его секрета, и сравнивает хэши за постоянное время. Отсюда два следствия: - утерянный ключ нельзя восстановить, только выпустить новый; - ключ показывается один раз, в момент выпуска, — сохраните его в менеджер секретов. Ключ предназначен только для запросов сервер-сервер. Он не должен попадать в браузер, мобильное приложение, репозиторий или логи. Ключи мерчантов изолированы: с ключом одного мерчанта не видно данных другого. ## Ошибки | HTTP | `error.code` | Когда | | --- | --- | --- | | 401 | `unauthorized` | Заголовка нет, ключ неверного формата, неизвестен, отозван или секрет не совпадает. Причина намеренно не уточняется | | 403 | `merchant_disabled` | Ключ верный, но мерчант отключён оператором | | 429 | `rate_limited` | Превышен лимит запросов. Заголовок `Retry-After` — через сколько секунд повторить | ## Лимиты запросов Число запросов в минуту ограничено дважды: для каждого IP-адреса и для каждого ключа API. Значения задаёт оператор (`API_RATE_LIMIT_PER_IP`, `API_RATE_LIMIT_PER_KEY`). Лимит по IP действует и на запросы с неверным ключом. Каждый ответ `/v1` содержит заголовки `x-ratelimit-limit`, `x-ratelimit-remaining` и `x-ratelimit-reset`. ## Ротация У мерчанта может быть несколько действующих ключей одновременно. Замена ключа проходит без простоя: оператор выпускает новый ключ, мерчант переключается, оператор отзывает старый. Отозванный ключ перестаёт приниматься в течение 60 секунд. ## Сеть API мерчантов отвечает по адресу `https://api.stons.io/v1`. В боевой установке перед ним стоит HTTPS-прокси: снаружи доступны только маршруты `/v1`, любой другой путь отвечает `404`, а запрос по `http://` перенаправляется кодом `308` на `https://`. Заголовок с ключом к моменту перенаправления уже ушёл по сети открытым текстом, поэтому в клиенте указывайте адрес с `https://`. Как устроен прокси — HTTPS. Маршруты `/admin/v1` предназначены консоли оператора, а `/cabinet/v1` — приложению кабинета, а не интеграции мерчанта; снаружи недоступны и те и другие — см. административный API и API кабинета. --- # Инвойсы Инвойс — ожидание платежа на конкретную сумму в конкретном активе до дедлайна. Мерчант создаёт инвойс под свой заказ, получает адрес и показывает его пользователю вместе с суммой. ## Активы Что сервис принимает в боевой сети TRON. Адреса контрактов прочитаны в сети, а не взяты из статей (ADR-0026): | Актив | Контракт | Знаков | Минимум | Выплаты | | --- | --- | --- | --- | --- | | `USDT_TRON` | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` | 6 | 1 USDT | да | | `USDC_TRON` | `TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8` | 6 | 1 USDC | да | | `TRX_TRON` | нативная монета | 6 | — | нет | `TRX_TRON` **платежом пока не считается**: перевод TRX на адрес приёма записывается как `unexpected_asset` и инвойс не закрывает. Причина и условие для изменения — в том же ADR. Выплаты в TRX не появятся и после: суточные лимиты складывают суммы в одной учётной единице, что верно только для привязанных к доллару активов. В тестовых сетях принимается только USDT: проверяемого контракта USDC в Nile и Shasta нет — см. тестовые сети. Единственный источник знаков, минимумов и числа подтверждений — таблица `assets`; в коде эти значения не зашиты нигде, поэтому добавление актива не меняет ни одной строки логики. ## Создать инвойс `POST /v1/invoices` ### Запрос | Поле | Тип | Обязательно | Описание | | --- | --- | --- | --- | | `order_id` | string | да | Идентификатор заказа мерчанта, уникальный в пределах мерчанта: 1–200 видимых ASCII-символов без пробелов. Ключ идемпотентности | | `asset` | string | да | Актив, например `USDT_TRON` | | `amount` | string | да | Сумма в единицах актива: `"49.9"`. Не больше знаков после запятой, чем у актива, без знака, экспоненты и пробелов | | `expires_in` | integer | да | Сколько секунд ждать платежа: от 60 до 604800 (7 дней) | | `external_user_id` | string | в режиме `per_user` | Пользователь мерчанта. В режиме `per_user` инвойс получает его постоянный адрес. В режиме `per_invoice` поле только сохраняется | | `return_url` | string | нет | Куда [страница оплаты](https://stons.io/docs/checkout.md) вернёт покупателя после оплаты. Переопределяет адрес мерчанта и допускается только с тем же origin, что у него; `https`, до 2000 символов, без `@`, пробелов и параметров `invoice_id`, `order_id`, `status`, `ts`, `signature` | | `cancel_url` | string | нет | Куда страница оплаты вернёт покупателя, если срок вышел без полной суммы. Те же правила, что у `return_url`, относительно адреса отмены мерчанта | Сумма должна быть не меньше минимального депозита актива: для `USDT_TRON` это 1 USDT. Переводы меньше минимального депозита сервис считает пылью и не зачисляет. ### Ответ `201` — инвойс создан, `200` — инвойс для этого `order_id` уже был создан раньше. Тело в обоих случаях одинаковое: | Поле | Тип | Описание | | --- | --- | --- | | `invoice_id` | string (UUID) | Идентификатор инвойса | | `order_id` | string | Идентификатор заказа мерчанта | | `status` | string | Статус, см. [ниже](#статусы) | | `asset` | string | Актив | | `network` | string | Сеть актива: `tron` | | `address` | string | Адрес для платежа, целиком | | `amount_requested` | string | Цена мерчанта — `amount` запроса со всеми знаками актива: `"49.900000"` | | `amount_expected` | string | Сумма, которую должен заплатить покупатель: цена и комиссия сервиса, если её платит покупатель — см. [комиссии](#комиссии) | | `amount_received` | string | Сумма подтверждённых платежей | | `amount_credited` | string | Подтверждённые платежи за вычетом удержанных комиссий: сколько зачислено мерчанту | | `fees` | object | Комиссия сервиса и комиссия сети — см. [комиссии](#комиссии) | | `external_user_id` | string или null | Пользователь мерчанта | | `return_url` | string или null | Переопределение адреса возврата, как его прислал мерчант; `null` — действует адрес мерчанта | | `cancel_url` | string или null | Переопределение адреса отмены, как его прислал мерчант; `null` — действует адрес мерчанта | | `checkout_url` | string или null | [Страница оплаты](https://stons.io/docs/checkout.md) этого инвойса; `null`, если оператор её не настроил | | `expires_at` | string | Дедлайн, ISO 8601 в UTC | | `created_at` | string | Время создания, ISO 8601 в UTC | | `deposits` | array | Платежи по инвойсу, см. [получить инвойс](#получить-инвойс) | ### Идемпотентность Повторный запрос с тем же `order_id`, активом, суммой, `external_user_id`, `return_url` и `cancel_url` не создаёт второй инвойс и не возвращает ошибку. Ответом будет уже существующий инвойс с кодом `200`. Поэтому запрос можно безопасно повторять после таймаута. Одновременные запросы с одним `order_id` тоже создают один инвойс. Сумма повтора сравнивается с ценой `amount_requested`, а не с `amount_expected`: повтор присылает ту же цену, а комиссия, добавленная к ней при создании, — условие уже выставленного инвойса. Ответ на повтор показывает инвойс с условиями, записанными при создании, даже если ставка с тех пор изменилась. Запрос с тем же `order_id`, но другой суммой, активом, пользователем или адресами возврата отклоняется с кодом `409 order_id_conflict`: это другой платёж, а не повтор. Повтор принятого запроса возвращает инвойс, даже если оператор с тех пор сменил адреса мерчанта: переопределения проверяются при создании. ### Ошибки | HTTP | `error.code` | Когда | | --- | --- | --- | | 400 | `validation_failed` | Поле отсутствует, лишнее или неверного формата; у суммы слишком много знаков после запятой; в режиме `per_user` нет `external_user_id` | | 401 | `unauthorized` | Нет ключа API или он неверный | | 403 | `merchant_disabled` | Мерчант отключён | | 403 | `merchant_not_approved` | Проект ещё не прошёл проверку или отклонён. Повтор запроса с `order_id` уже созданного инвойса по-прежнему возвращает инвойс | | 409 | `order_id_conflict` | Инвойс с этим `order_id` уже есть и описывает другой платёж | | 422 | `redirect_url_not_allowed` | `return_url` или `cancel_url` указаны, но у мерчанта нет адреса того же вида или origin отличается от него | | 422 | `asset_not_supported` | Актив неизвестен или не принимается к оплате | | 422 | `asset_payments_disabled` | Оператор временно выключил приём в этом активе. Повтор запроса с `order_id` уже созданного инвойса по-прежнему возвращает инвойс | | 422 | `asset_disabled_by_merchant` | Приём в этом активе выключен в настройках проекта. Повтор запроса с `order_id` уже созданного инвойса по-прежнему возвращает инвойс | | 422 | `amount_below_minimum` | Сумма меньше минимального депозита актива | | 429 | `rate_limited` | Превышен лимит запросов | | 503 | `addresses_exhausted` | Индексы деривации закончились. Практически недостижимо | | 503 | `network_fee_quote_unavailable` | В настройках проекта комиссию сети платит покупатель, а посчитать её сейчас нельзя: инвойс не создаётся. Повтор запроса с `order_id` уже созданного инвойса по-прежнему возвращает инвойс | ### Пример ```sh curl -s -X POST https://api.stons.io/v1/invoices \ -H "X-Api-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"order_id": "ord_1001", "asset": "USDT_TRON", "amount": "49.9", "expires_in": 3600}' ``` ```json { "invoice_id": "6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44", "order_id": "ord_1001", "status": "created", "asset": "USDT_TRON", "network": "tron", "address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", "amount_requested": "49.900000", "amount_expected": "49.900000", "amount_received": "0.000000", "amount_credited": "0.000000", "fees": { "service": { "payer": "merchant", "percent": "1.00", "amount": "0.000000" }, "network": { "payer": "merchant", "quote": null, "quote_rate": null, "actual": null, "status": "pending" } }, "external_user_id": null, "return_url": null, "cancel_url": null, "checkout_url": null, "expires_at": "2026-09-11T21:03:05.531Z", "created_at": "2026-09-11T20:03:05.531Z", "deposits": [] } ``` ## Получить инвойс `GET /v1/invoices/{id}` возвращает текущее состояние инвойса в том же формате, что и ответ на создание. Именно по этому ответу, а не по телу вебхука, решается, отдавать ли товар — см. [вебхуки](https://stons.io/docs/webhooks.md). Инвойс другого мерчанта для вызывающего не существует: ответ `404 invoice_not_found`. Идентификатор не в формате UUID — `400 validation_failed`. Поля элемента `deposits`: | Поле | Тип | Описание | | --- | --- | --- | | `tx_hash` | string | Хэш транзакции | | `amount` | string | Сумма перевода в единицах актива | | `confirmations` | integer | Сколько блоков набрано над блоком перевода | | `status` | string | `seen` — перевод в блоке, подтверждений недостаточно; `confirmed` — подтверждений достаточно | | `block_time` | string | Время блока с переводом, ISO 8601 в UTC | | `late` | boolean | `true`, если блок с переводом новее дедлайна инвойса | В `deposits` попадают только платежи. Пыль, переводы другого актива и переводы, исчезнувшие при реорганизации цепи, в список не входят. ```sh curl -s https://api.stons.io/v1/invoices/6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44 -H "X-Api-Key: $API_KEY" ``` ## Статусы Схема статусов и её обоснование — в ADR-0006. | Статус | Значение | Что делать мерчанту | | --- | --- | --- | | `created` | Инвойс создан, переводов нет | Ждать | | `seen` | До дедлайна пришёл перевод, но подтверждённая сумма меньше ожидаемой | Ждать: перевод ещё можно откатить | | `confirmed` | Подтверждённые переводы до дедлайна покрывают сумму. Переплата — тоже `confirmed`, см. `amount_received` | Отдать товар | | `underpaid` | Дедлайн прошёл, подтверждено больше нуля, но меньше ожидаемого | Решить самому: доплата, частичная выдача, возврат через поддержку | | `expired` | Дедлайн прошёл, подтверждённых переводов нет | Закрыть заказ | | `paid_late` | После `expired` или `underpaid` пришёл и подтвердился новый перевод | Решить самому | | `settled` | Средства инвойса перенесены в хранилище | Ничего: для мерчанта это то же, что `confirmed` | ```mermaid stateDiagram-v2 [*] --> created created --> seen: перевод в блоке до дедлайна seen --> created: перевод исчез при реорганизации seen --> confirmed: подтверждено не меньше ожидаемого created --> confirmed: подтверждено не меньше ожидаемого seen --> underpaid: дедлайн, подтверждено меньше ожидаемого created --> expired: дедлайн без переводов underpaid --> paid_late: подтверждён поздний перевод expired --> paid_late: подтверждён поздний перевод confirmed --> settled: средства в хранилище ``` Правила: - Вовремя пришёл перевод или нет, решает время блока, в который он попал, а не момент подтверждения. Перевод, отправленный за минуту до дедлайна, считается своевременным, даже если подтверждения наберутся позже. - Инвойс становится `underpaid` или `expired` только после того, как сервис просканировал блоки после дедлайна и все своевременные переводы подтвердились. Поэтому инвойс может оставаться в `created` или `seen` немного дольше дедлайна. - До дедлайна переводы на адрес инвойса суммируются: первый перевод не считается окончательным. - Переплата не отдельный статус: инвойс становится `confirmed`, а `amount_received` больше `amount_expected`. - Перевод, исчезнувший из цепи при реорганизации, перестаёт учитываться: инвойс без других переводов возвращается в `created`, мерчант получает `deposit.orphaned`. Если перевод затем снова попадает в цепь, он учитывается заново. В этом редком случае инвойс может выйти из `expired` или `underpaid` обратно в `seen`. Статусы меняет сканер блокчейна. Он читает блоки с отставанием в один блок от головы цепи, поэтому перевод виден через несколько секунд. Подтверждение USDT в TRON — 20 блоков, около минуты. О каждой смене статуса мерчант получает [вебхук](https://stons.io/docs/webhooks.md#события). ## Режим per_user В режиме `per_user` у пользователя один постоянный адрес. Перевод закрывает самый ранний открытый инвойс этого пользователя в том же активе на точно такую же сумму. Перевод на другую сумму — это пополнение баланса, а не частичная оплата. Поэтому в этом режиме не бывает статусов `underpaid` и `paid_late`, а перевод на точную сумму после дедлайна тоже считается пополнением. Сумма, с которой сравнивается перевод, — `amount_expected`: если комиссию сервиса платит покупатель, это цена вместе с комиссией. ## Комиссии С каждого подтверждённого платежа STONS удерживает комиссию сервиса — процент, который назначает оператор. Кто её платит — мерчант или покупатель, — задаёт настройка проекта. Комиссия сети по инвойсам пока не начисляется: её расчёт по каждой операции появится отдельным обновлением, и поля `fees.network` до тех пор пустые. Ставка и плательщики **записываются в инвойс при его создании** и дальше не меняются: смена тарифа или настроек проекта касается только новых инвойсов. Инвойсы, созданные до появления комиссии, записаны со ставкой 0. ### Поля | Поле | Описание | | --- | --- | | `fees.service.payer` | `merchant` — комиссия удерживается из полученного; `customer` — комиссия добавлена к цене и входит в `amount_expected` | | `fees.service.percent` | Ставка, записанная в инвойс, в процентах с двумя знаками: `"1.00"` | | `fees.service.amount` | Комиссия, удержанная с подтверждённых платежей инвойса на сейчас. До первого подтверждения — ноль | | `fees.network.payer` | Кто несёт комиссию сети. Сейчас всегда `merchant` | | `fees.network.quote` | Котировка комиссии сети, которую платит покупатель. Сейчас `null` | | `fees.network.quote_rate` | Курс, по которому посчитана котировка. Сейчас `null` | | `fees.network.actual` | Комиссия сети по факту операций. Сейчас `null`: сетевая комиссия по инвойсам ещё не начисляется | | `fees.network.status` | `quoted` — котировка зафиксирована; `pending` — рассчитается по факту операции; `settled` — проведена. Сейчас всегда `pending` | | `amount_credited` | `amount_received` минус `fees.service.amount`: сколько из полученного зачислено мерчанту | ### Как считается комиссия сервиса - **Покупатель платит комиссию.** К цене прибавляется ставка от цены, округлённая вверх до последнего знака актива: `amount_expected = amount_requested + ⌈amount_requested × ставка⌉`. Инвойс на 100 USDT при 1% ждёт 101 USDT. - **Удерживается при подтверждении каждого платежа**, в той же операции, что его зачисление, и считается от суммы всех подтверждённых платежей инвойса: - платит мерчант — `⌊получено × ставка⌋`; - платит покупатель — `⌊получено × ставка / (1 + ставка)⌋`, доля комиссии внутри уже оплаченной суммы. - **Округление вниз**, в пользу мерчанта. Инвойс, оплаченный несколькими переводами, удерживает столько же, сколько оплаченный одним: каждый следующий перевод удерживает разницу между комиссией с новой суммы и уже удержанной. - **Переплата** — комиссия с большей суммы. **Недоплата** — с полученного. **Платёж после срока** — как любой подтверждённый. - **Пыль и переводы другого актива** комиссии не несут: это не платежи. Перевод, исчезнувший при реорганизации до подтверждения, не зачислен и комиссии не несёт. - **Пополнение `per_user` без инвойса** — комиссия с суммы перевода по ставке мерчанта на момент блока перевода, платит мерчант. Её видно в балансе проекта; события `deposit.*` полей комиссий не несут. Пример: цена 100 USDT, ставка 1%, комиссию платит покупатель. | Момент | `amount_requested` | `amount_expected` | `amount_received` | `fees.service.amount` | `amount_credited` | | --- | --- | --- | --- | --- | --- | | Создан | `100.000000` | `101.000000` | `0.000000` | `0.000000` | `0.000000` | | Оплачен | `100.000000` | `101.000000` | `101.000000` | `1.000000` | `100.000000` | **Интеграция сравнивает свою цену с `amount_requested`**, а покупателю показывает `amount_expected`: только эта сумма закрывает инвойс. ## Суммы Суммы передаются строками в единицах актива, например `"49.900000"`. JSON-числа для сумм не используются: при разборе в число с плавающей точкой копейки теряются. Число знаков после запятой у каждого актива своё: у USDT в TRON — 6, у USDT в BNB Chain — 18. --- # Страница оплаты Кроме интеграции, в которой продукт сам показывает покупателю адрес и сумму, сервис умеет принимать платёж на своей странице. Продукт создаёт инвойс, отправляет покупателя по ссылке из ответа, страница показывает сумму, адрес и статус платежа, а когда у инвойса появляется исход — возвращает покупателя на сайт мерчанта подписанным редиректом. Вебхуки при этом приходят так же, как без страницы. Решение и его границы — ADR-0032. ## Главное правило > **Не отдавайте товар по редиректу.** Получив покупателя на `return_url`, проверьте подпись, затем запросите инвойс `GET /v1/invoices/{id}` и отдавайте товар только по ответу API — так же, как после вебхука. Подпись доказывает одно: адрес собрал сервис для этого инвойса в этом статусе. Она не мешает покупателю открыть ту же ссылку ещё раз, пока не истекло окно проверки, и не заменяет проверку статуса. ## Как это работает 1. Продукт создаёт инвойс: `POST /v1/invoices`. В ответе поле `checkout_url` — ссылка на страницу оплаты этого инвойса. Если оператор не настроил страницу, поле равно `null` — см. [инвойсы](https://stons.io/docs/invoices.md#создать-инвойс). 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`), без перевода строки в конце: ```text redirect ``` Разделитель — перевод строки, а не точка, потому что `order_id` может содержать точки, а перевод строки не может содержать ни одно поле. Первая строка `redirect` не даёт подписи редиректа сойти за подпись вебхука тем же секретом. Получатель обязан: - отклонять редирект, если `ts` отличается от текущего времени больше чем на 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")); } ``` Секрет вебхуков выдаётся при создании мерчанта и хранится в менеджере секретов — см. [вебхуки](https://stons.io/docs/webhooks.md#подпись). Пример для проверки своей реализации. При секрете `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 | Статус инвойса, см. [инвойсы](https://stons.io/docs/invoices.md#статусы) | | `asset` | string | Актив, например `USDT_TRON` | | `symbol` | string | Тикер актива для показа: `USDT` | | `network` | string | Сеть актива: `tron` | | `address` | string | Адрес для платежа, целиком | | `amount_requested` | string | Цена мерчанта | | `amount_expected` | string | Сумма к оплате со всеми знаками актива: цена и комиссия сервиса, если её платит покупатель | | `amount_received` | string | Сумма подтверждённых платежей | | `fees` | object | Комиссии инвойса, как в [инвойсе](https://stons.io/docs/invoices.md#комиссии). Разбивку страница показывает при `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-адреса | ```json { "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" } } ``` --- # Вебхуки Сервис сообщает мерчанту о событиях — платёж замечен, платёж подтверждён, инвойс просрочен — запросом `POST` на его URL. События формирует сканер блокчейна и записывает их в очередь в базе в той же транзакции, что и само изменение; отправляет их API. Устройство доставки — ADR-0011. ## Главное правило > **Не доверяйте телу вебхука.** Получив вебхук, проверьте подпись, затем запросите актуальное состояние объекта у API и действуйте по ответу API. Тело вебхука — сигнал «сходи проверь», а не источник истины. Причина: если секрет вебхуков утечёт или в проверке подписи окажется ошибка, злоумышленник сможет прислать поддельное «оплачено». Если товар выдаётся только по ответу API, подделка ничего не даст. ## События | Событие | Когда приходит | | --- | --- | | `invoice.seen` | До дедлайна в блоке появился перевод на адрес инвойса. Подтверждений ещё нет: товар не отдавать | | `invoice.confirmed` | Подтверждённые переводы, пришедшие до дедлайна, покрыли сумму инвойса. Отдать товар, проверив статус через API | | `invoice.overpaid` | Вместе с `invoice.confirmed`, если подтверждено больше ожидаемого | | `invoice.underpaid` | Дедлайн прошёл, подтверждена только часть суммы | | `invoice.expired` | Дедлайн прошёл, подтверждённых переводов нет | | `invoice.paid_late` | После `invoice.expired` или `invoice.underpaid` подтвердился перевод, пришедший после дедлайна | | `deposit.confirmed` | Платёж набрал нужное число подтверждений. В режиме `per_user` приходит и для пополнения без инвойса: тогда `invoice_id` равен `null` | | `deposit.orphaned` | Замеченный платёж исчез из цепи при реорганизации или его транзакция оказалась неуспешной. Отменяет ожидание, которое начал `invoice.seen` | | `payout.confirmed` | Выплата подтверждена сетью: средства ушли получателю. Окончательное состояние | | `payout.failed` | Транзакция выплаты попала в блок неуспешной: средства **не** ушли. Выплата не повторяется автоматически — новая попытка требует нового `reference` | Статусы инвойса и переходы между ними — в [описании инвойсов](https://stons.io/docs/invoices.md#статусы). Переводы другого актива и пыль событий не порождают. ## Тело ```json { "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", "amount_requested": "49.900000", "amount_expected": "49.900000", "amount_received": "49.900000", "amount_credited": "49.401000", "fees": { "service": { "payer": "merchant", "percent": "1.00", "amount": "0.499000" }, "network": { "payer": "merchant", "quote": null, "quote_rate": null, "actual": null, "status": "pending" } }, "external_user_id": null } } ``` | Поле | Описание | | --- | --- | | `id` | Идентификатор события. Повторная доставка приходит с тем же `id`: по нему обработчик отбрасывает дубли | | `type` | Событие из таблицы выше | | `created_at` | Когда событие произошло, ISO 8601 в UTC | | `data` | Объект события, поля ниже | Поля `data` у событий `invoice.*`: | Поле | Описание | | --- | --- | | `invoice_id` | Идентификатор инвойса | | `order_id` | Идентификатор заказа мерчанта | | `status` | Статус инвойса после изменения | | `asset` | Актив | | `amount_requested` | Цена мерчанта | | `amount_expected` | Сумма, которую должен заплатить покупатель, со всеми знаками актива: цена и комиссия сервиса, если её платит покупатель | | `amount_received` | Подтверждённая сумма на момент события | | `amount_credited` | Подтверждённая сумма за вычетом комиссий, удержанных на момент события | | `fees` | Комиссия сервиса и комиссия сети, как в [инвойсе](https://stons.io/docs/invoices.md#комиссии) | | `external_user_id` | Пользователь мерчанта или `null` | Комиссия платежа проводится в той же транзакции, что его подтверждение, поэтому `invoice.confirmed` уже несёт удержанную комиссию и `amount_credited` после неё. Поля `data` у событий `deposit.*`: | Поле | Описание | | --- | --- | | `tx_hash` | Хэш транзакции | | `log_index` | Номер перевода в транзакции: вместе с `tx_hash` однозначно определяет платёж | | `asset` | Актив | | `amount` | Сумма перевода со всеми знаками актива | | `address` | Адрес приёма, целиком | | `invoice_id` | Инвойс, который оплачивает перевод, или `null` для пополнения в режиме `per_user` | | `external_user_id` | Пользователь мерчанта или `null` | | `block_time` | Время блока с переводом, ISO 8601 в UTC | | `confirmations` | Сколько блоков набрано над блоком перевода | Пример `deposit.confirmed`: ```json { "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", "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` | Сумма выплаты со всеми знаками актива | | `to_address` | Адрес получателя, целиком | | `tx_hash` | Хэш транзакции | | `reason` | Только у `payout.failed`: что именно произошло | Пример `payout.confirmed`: ```json { "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", "to_address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", "tx_hash": "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0" } } ``` Комиссию сети платит горячий кошелёк сервиса, и она не вычитается из суммы: получатель получает ровно `amount` (ADR-0023). ## Подпись Каждый вебхук содержит два заголовка: ```text X-Timestamp: <время отправки, секунды Unix> X-Signature: sha256= ``` Подпись — это HMAC-SHA256 по строке `.<сырое тело запроса>` с секретом вебхуков мерчанта в качестве ключа. Подписывается именно сырое тело: байты в том виде, в каком они пришли, до разбора JSON. Разобранный и заново сериализованный JSON может отличаться порядком ключей и пробелами, и подпись не сойдётся. Получатель обязан: - отклонять вебхук, если `X-Timestamp` отличается от текущего времени больше чем на 5 минут: так перехваченный вебхук нельзя отправить повторно позже; - сравнивать подписи за постоянное время, а не обычным сравнением строк. Проверка на Node.js: ```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); } ``` ## Доставка - Вебхук считается доставленным, если URL мерчанта ответил любым кодом `2xx` за 10 секунд. - Иначе отправка повторяется через 10 секунд, 1 минуту, 5 минут, 30 минут, 2 часа и 6 часов после предыдущей попытки. После последней неудачи вебхук помечается недоставленным, и оператор получает сигнал. - Доставка «хотя бы один раз»: одно и то же событие может прийти несколько раз. Обработчик должен быть идемпотентным — повторная обработка события не должна повторно выдавать товар. - Событие записывается в той же транзакции базы, что и изменение, о котором оно сообщает. Поэтому событие не теряется при сбое между изменением и отправкой, но и не гарантирует порядок доставки: `invoice.confirmed` может прийти раньше `invoice.seen`. Ориентируйтесь на состояние из API. - Проекту без адреса вебхуков — например, заведённому в кабинете для счетов, выставляемых вручную, — события не записываются вовсе. Инвойсы при этом оплачиваются и зачисляются как обычно. События, случившиеся до того, как адрес задан, после его появления не досылаются: состояние берите из API. Заданный адрес можно заменить, но не удалить. Отвечайте `2xx` сразу после проверки подписи, а долгую обработку выполняйте асинхронно: иначе таймаут в 10 секунд приведёт к лишним повторам. Ответ `3xx` успехом не считается, и сервис по нему не переходит: подписанное тело не должно уходить на другой адрес. Меняете URL — сообщите оператору, он поменяет его в настройках мерчанта. Каждая попытка доставки записывается: код ответа, длительность и ошибка. Оператор видит недоставленные события и может поставить их в очередь заново — см. застрявшие вебхуки. --- # Адреса для приёма платежей ## Постоянный адрес пользователя `POST /v1/addresses` выдаёт пользователю мерчанта постоянный адрес. Маршрут доступен только мерчантам в режиме `per_user`. Мерчанты в режиме `per_invoice` получают адрес вместе с инвойсом — см. [инвойсы](https://stons.io/docs/invoices.md). ### Запрос | Поле | Тип | Обязательно | Описание | | --- | --- | --- | --- | | `external_user_id` | string | да | Идентификатор пользователя на стороне мерчанта: 1–200 видимых ASCII-символов без пробелов | | `network` | string | да | Сеть. Сейчас принимается только `tron` | ### Ответ `200` | Поле | Тип | Описание | | --- | --- | --- | | `address` | string | Адрес в сети TRON в формате base58 | | `network` | string | `tron` | | `assets` | string[] | Активы, которые сейчас можно предлагать пользователю для оплаты на этот адрес. Актив, приём которого оператор временно выключил или который выключен в настройках проекта, в список не входит | Повторный запрос для того же `external_user_id` и той же сети возвращает тот же адрес. Одновременные запросы для одного пользователя тоже получают один адрес. ### Ошибки | HTTP | `error.code` | Когда | | --- | --- | --- | | 400 | `validation_failed` | Поле отсутствует, лишнее или неверного формата | | 401 | `unauthorized` | Нет ключа API или он неверный | | 403 | `merchant_disabled` | Мерчант отключён | | 403 | `merchant_not_approved` | Проект ещё не прошёл проверку или отклонён: постоянный адрес — это способ оплаты, и до одобрения он не выдаётся | | 409 | `address_mode_mismatch` | Мерчант работает в режиме `per_invoice` | | 429 | `rate_limited` | Превышен лимит запросов | | 503 | `addresses_exhausted` | Индексы деривации закончились. Практически недостижимо: их 2^31 | ### Пример ```sh curl -s -X POST https://api.stons.io/v1/addresses \ -H "X-Api-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"external_user_id": "user_42", "network": "tron"}' ``` ```json {"address":"TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH","network":"tron","assets":["USDT_TRON"]} ``` Адрес в примере условный: у каждой установки свои адреса, выведенные из её собственного ключа. ## Откуда берутся адреса Каждый адрес выводится из мастер-ключа сервиса по стандарту BIP44. Для TRON это путь `m/44'/195'/0'/0/{index}`, для EVM-сетей — путь `m/44'/60'/0'/0/{index}`. Индексы выдаются строго по порядку и никогда не используются повторно, даже если адрес так и не получил платежа. Поэтому два пользователя не могут получить один адрес. Сервер API знает только публичную часть мастер-ключа. Он умеет выводить адреса, но не может потратить средства с них. Один и тот же EVM-адрес действует во всех поддерживаемых EVM-сетях. Показывайте пользователю не только адрес, но и сеть, в которой ждёте платёж. ## Режимы Режим задаёт оператор при подключении мерчанта. Почему режимов два — в ADR-0005. | Режим | Адрес | Когда подходит | | --- | --- | --- | | `per_user` | Постоянный адрес пользователя мерчанта | Пополнение баланса: пользователь может платить на один адрес сколько угодно раз | | `per_invoice` | Новый адрес под каждый инвойс. Адрес однозначно определяет инвойс | Оплата конкретного заказа | Платёж никогда не сопоставляется с заказом по «уникальной сумме» с копейками: такая схема даёт коллизии и в режиме `per_user` не нужна. ## Что приходит на адрес - Адрес остаётся под наблюдением всегда, в том числе после истечения инвойса. Поздний платёж не теряется: он фиксируется, и мерчант получает об этом событие. - Зачисляются активы, которые сервис принимает к оплате. Список `assets` — те из них, что можно предлагать пользователю сейчас: актив, приём которого оператор временно выключил, из списка пропадает, но перевод в нём, пришедший на адрес, зачисляется как обычно. Другой известный сервису актив фиксируется, но не зачисляется мерчанту и инвойс не закрывает. Такие средства разбираются вручную. - Неизвестные токены игнорируются полностью: среди них бывают контракты-ловушки, и любое взаимодействие с ними опасно. ## Показ адреса Показывайте адрес целиком, без сокращений вида «первые и последние символы через многоточие». Злоумышленники рассылают переводы с адресов, у которых совпадают первые и последние символы, в расчёте на то, что человек сверит только их (address poisoning). --- # Выплаты Выплата — перевод средств мерчанта на адрес, который заранее внесён в его вайтлист. Приём ошибается в пользу сервиса, вывод — в чужую, поэтому контроль на выводе строже: адрес только из вайтлиста и только после задержки, три лимита, порог автоподтверждения и ручное подтверждение другим человеком. ## Откуда платятся выплаты Только с отдельного горячего кошелька. Не с адресов приёма и не из холодного хранилища. Баланс горячего кошелька ограничен сверху и пополняется вручную из холодного хранилища: это потолок потерь, если сервер подписи будет скомпрометирован. ## Создать выплату `POST /v1/payouts` ### Запрос | Поле | Тип | Обязательно | Описание | | --- | --- | --- | --- | | `reference` | string | да | Идентификатор выплаты на стороне мерчанта, уникальный в пределах мерчанта: 1–200 видимых ASCII-символов без пробелов. Ключ идемпотентности | | `asset` | string | да | Актив, например `USDT_TRON`. Сервис платит не во всех активах, в которых принимает | | `to_address` | string | да | Адрес получателя целиком, 20–100 букв и цифр. Должен быть в вайтлисте мерчанта по этому активу | | `amount` | string | да | Сумма в единицах актива: `"100.5"`. Не больше знаков после запятой, чем у актива, без знака, экспоненты и пробелов. Ноль не принимается | ### Ответ `202` — выплата создана, `200` — выплата с этим `reference` уже была создана раньше. Тело в обоих случаях одинаковое: | Поле | Тип | Описание | | --- | --- | --- | | `payout_id` | string (UUID) | Идентификатор выплаты | | `reference` | string | Идентификатор выплаты на стороне мерчанта | | `status` | string | Статус, см. [ниже](#статусы) | | `asset` | string | Актив | | `network` | string | Сеть актива: `tron` | | `to_address` | string | Адрес получателя целиком | | `amount` | string | Сумма со всеми знаками актива: `"100.500000"` | | `review_reasons` | array | Какие правила выплата перешла и потому ждёт человека. Пусто у выплаты, прошедшей по правилам | | `tx_hash` | string или null | Хэш транзакции, когда она отправлена | | `created_at` | string | Время создания, ISO 8601 в UTC | | `updated_at` | string | Время последнего изменения, ISO 8601 в UTC | Созданная выплата получает статус `queued`, если прошла все правила, и `pending_review`, если нет. Второй случай — не ошибка: выплата принята и ждёт решения человека, а `review_reasons` говорит, чего именно. ## Получить выплату `GET /v1/payouts/{id}` Отвечает тем же телом. Выплаты других мерчантов для вызывающего не существуют: ответ `404 payout_not_found`. ## Статусы ```text pending_review → approved → queued → signing → broadcast → confirmed ↓ ↓ rejected failed ``` | Статус | Смысл | | --- | --- | | `pending_review` | Выплата перешла лимит или порог и ждёт подтверждения человека | | `approved` | Человек подтвердил выплату. Ставится только ручным подтверждением: выплата в пределах правил через этот статус не проходит | | `queued` | Выплата принята к исполнению и ждёт подписи | | `signing` | Сервер подписи взял выплату в работу | | `broadcast` | Транзакция отправлена в сеть и ждёт подтверждений | | `confirmed` | Транзакция подтверждена сетью. Окончательный статус | | `rejected` | Человек отклонил выплату. Окончательный статус: повтор требует нового `reference` | | `failed` | Отправка не удалась. Окончательный статус: автоматических повторов нет | Выплата в `failed` **не повторяется автоматически** — сначала проверяется, что транзакция действительно не попала в сеть, иначе повтор означал бы двойную выплату. Переход в `confirmed` и в `failed` сопровождается вебхуком `payout.confirmed` или `payout.failed` — см. [события](https://stons.io/docs/webhooks.md#события). Комиссию сети платит горячий кошелёк сервиса, и из суммы выплаты она не вычитается: получатель получает ровно `amount` (ADR-0023). ## Куда можно платить Только на адреса из вайтлиста мерчанта. Вайтлистом управляет оператор командами, см. команды оператора. - Адрес, добавленный в вайтлист, можно использовать только после задержки: `WHITELIST_COOLDOWN_HOURS`, по умолчанию 24 часа. Момент «использовать с» считается при добавлении и хранится в строке, поэтому смена настройки не ускоряет уже добавленные адреса. До этого момента выплата отклоняется кодом `address_not_whitelisted`, и сообщение называет момент, с которого адрес станет годен. - **Добавление подписывается операторским ключом офлайн.** Подпись покрывает мерчанта, актив, адрес целиком и момент добавления, поэтому её нельзя переставить на другой адрес или другого мерчанта. Строку без верной подписи не примет ни команда, ни сервер подписи — обоснование в ADR-0021. - Добавление адреса, у которого первые и последние символы совпадают с уже внесённым, блокируется до явного подтверждения с показом обоих адресов целиком. Так отсекается подмена адреса похожим (address poisoning). - Адрес для выплаты никогда не берётся из истории транзакций. - Адрес сверяется с вайтлистом дважды: при создании выплаты в API и ещё раз, независимо, сервером подписи перед подписью — и Signer проверяет не только наличие строки, но и подпись оператора под ней. Запись вайтлиста, разрешившая выплату, хранится на самой выплате, поэтому проверять нечего заново выводить. - Отзыв адреса подписи не требует: он только убирает разрешение. Отозванный адрес можно добавить заново, и задержка начнётся сначала. ## Лимиты Действуют четыре значения. Все они заданы в одной учётной единице — в долларах, — потому что суточный лимит сервиса складывает выплаты по всем активам, а складывать минимальные единицы активов с разными `decimals` нельзя. Отсюда правило: сервис платит только в активах, привязанных к доллару (ADR-0022). | Переменная | Что ограничивает | | --- | --- | | `PAYOUT_MAX_AMOUNT` | Одну выплату | | `PAYOUT_AUTO_APPROVE_LIMIT` | Порог, ниже которого подтверждение человека не нужно | | `PAYOUT_DAILY_LIMIT_MERCHANT` | Сумму выплат одного мерчанта за сутки | | `PAYOUT_DAILY_LIMIT_TOTAL` | Сумму выплат всего сервиса за сутки | Суточные лимиты считаются по скользящему окну в 24 часа, а не по календарным суткам: полночь ничего не обнуляет. В сумму входят выплаты, деньги по которым ещё могут уйти или уже ушли, включая ожидающие подтверждения; отклонённые и неудавшиеся не входят. Лимит ограничивает сумму вместе с создаваемой выплатой, то есть общий поток наружу, а не каждую выплату по отдельности. Переход лимита **не отклоняет** выплату — он переводит решение человеку. В `review_reasons` появляется одно или несколько значений: | Значение | Когда | | --- | --- | | `above_transaction_limit` | Сумма больше `PAYOUT_MAX_AMOUNT` | | `above_auto_approve_limit` | Сумма больше `PAYOUT_AUTO_APPROVE_LIMIT` | | `above_merchant_daily_limit` | Вместе с этой выплатой мерчант превысит свой суточный лимит | | `above_service_daily_limit` | Вместе с этой выплатой сервис превысит суточный лимит целиком | ## Ручное подтверждение Подтверждает и отклоняет выплату человек, командой на сервере — маршрута для этого нет, пока нет административных ролей и второго фактора (ADR-0019). Процедура целиком, с параметрами команд, — в командах оператора. Коротко: 1. Оператор смотрит выплаты в `pending_review`: `payout:list`. Вывод показывает адрес целиком, сумму в минимальных единицах актива и действующие лимиты. 2. Он сверяет адрес получателя символ за символом с тем, что ожидает мерчант, и проверяет причину ожидания. 3. `payout:approve --actor <кто> --id <выплата>` переводит выплату в `approved`. `payout:reject --actor <кто> --id <выплата> --note <почему>` — в `rejected`, окончательно. **Разделение полномочий.** Тот, кто добавил адрес в вайтлист, не может подтвердить выплату на этот адрес: команда откажет и назовёт, кто добавлял. Одна учётная запись не должна уметь и выбрать адрес, и отпустить на него деньги. Отклонение такого ограничения не имеет: остановить выплату может кто угодно. Каждое подтверждение, отклонение и добавление в вайтлист попадает в неизменяемый журнал аудита вместе с адресом целиком. ## Идемпотентность Выплата создаётся с идентификатором мерчанта `reference`, уникальным в пределах мерчанта. Повторный запрос с тем же `reference`, активом, адресом и суммой не создаёт вторую выплату и не возвращает ошибку: ответом будет уже созданная выплата с кодом `200`. Поэтому запрос можно безопасно повторять после таймаута, и повтор работает даже когда выплаты остановлены. Одновременные запросы с одним `reference` тоже создают одну выплату. Запрос с тем же `reference`, но другой суммой, активом или адресом отклоняется кодом `409 reference_conflict`: это другая выплата, а не повтор. ## Остановка выплат Выплаты останавливаются автоматически при расхождении реестра с сетью и аварийной командой оператора. Пока остановка действует, новые выплаты не принимаются — код `503 payouts_halted`; повтор с тем же `reference` после снятия остановки создаст выплату. Причина остановки наружу не раскрывается: она описывает внутренний инцидент. Снять остановку можно только вручную, с записью в журнал аудита — см. остановку выплат. ## Ошибки | HTTP | `error.code` | Когда | | --- | --- | --- | | 400 | `validation_failed` | Поле отсутствует, лишнее или неверного формата; у суммы слишком много знаков после запятой; сумма не больше нуля | | 401 | `unauthorized` | Нет ключа API или он неверный | | 403 | `merchant_disabled` | Мерчант отключён | | 404 | `payout_not_found` | Выплаты нет или она принадлежит другому мерчанту | | 409 | `reference_conflict` | Выплата с этим `reference` уже есть и описывает другую выплату | | 422 | `asset_not_payable` | В этом активе сервис не делает выплат | | 422 | `asset_payouts_disabled` | Оператор временно выключил выплаты в этом активе. Повтор с существующим `reference` возвращает выплату | | 422 | `address_not_whitelisted` | Адреса нет в вайтлисте по этому активу или его задержка не прошла | | 429 | `rate_limited` | Превышен лимит запросов | | 503 | `payouts_halted` | Выплаты остановлены | ## Пример ```sh curl -s -X POST https://api.stons.io/v1/payouts \ -H "X-Api-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"reference": "wd_501", "asset": "USDT_TRON", "to_address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", "amount": "100.5"}' ``` ```json { "payout_id": "4c2f1a7e-93d5-4c0b-8a2e-5f6b7c8d9e01", "reference": "wd_501", "status": "queued", "asset": "USDT_TRON", "network": "tron", "to_address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", "amount": "100.500000", "review_reasons": [], "tx_hash": null, "created_at": "2026-09-12T12:00:00.000Z", "updated_at": "2026-09-12T12:00:00.000Z" } ``` Выплата, которая ждёт человека, отвечает тем же кодом `202`: ```json { "payout_id": "7e1b2c3d-4f5a-6b7c-8d9e-0f1a2b3c4d5e", "reference": "wd_502", "status": "pending_review", "asset": "USDT_TRON", "network": "tron", "to_address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", "amount": "900.000000", "review_reasons": ["above_auto_approve_limit"], "tx_hash": null, "created_at": "2026-09-12T12:05:00.000Z", "updated_at": "2026-09-12T12:05:00.000Z" } ``` --- # Коды ошибок Любой ответ API с кодом `4xx` или `5xx` имеет одно и то же тело: ```json { "error": { "code": "route_not_found", "message": "Route not found" } } ``` | Поле | Тип | Смысл | | --- | --- | --- | | `error.code` | string | Машиночитаемый код из таблицы ниже. Коды не переименовываются и не удаляются; новые появляются в [журнале изменений](https://stons.io/docs/changelog.md) | | `error.message` | string | Пояснение для человека на английском. Текст может меняться: не разбирайте его в коде | | `error.details` | array | Только у `validation_failed`: список проблем, у каждой `path` — какое поле — и `message` | Каждый ответ, включая ошибки, содержит заголовок `X-Request-Id`. Сообщайте его при обращении в поддержку: по нему находится запрос в логах. Если клиент сам передал `X-Request-Id` из латинских букв, цифр и символов `.`, `_`, `:`, `-` длиной до 128 символов, сервис использует его, иначе генерирует UUID. ## Перечень | HTTP | `error.code` | Когда возникает | Что делать клиенту | | --- | --- | --- | --- | | 400 | `validation_failed` | Тело, путь или параметры запроса не проходят проверку: поле отсутствует, лишнее или неверного формата. `details` перечисляет проблемы | Исправить запрос. Повтор без изменений даст ту же ошибку | | 400–499 | `bad_request` | Запрос некорректен на уровне HTTP: неверный JSON, неподдерживаемый `Content-Type`, тело больше 16 КиБ. HTTP-код ответа уточняет причину | Исправить запрос | | 401 | `unauthorized` | Нет заголовка `X-Api-Key`, ключ неверный, неизвестный или отозванный. В административном API и API кабинета — нет своего токена или он неверный | Проверить ключ. Причина намеренно не уточняется | | 403 | `merchant_disabled` | Мерчант выключен — оператором или владельцем в кабинете | Включить проект в кабинете или обратиться к оператору | | 403 | `merchant_not_approved` | Проект ещё не одобрен после проверки или отклонён: `POST /v1/invoices` и `POST /v1/addresses` не принимают платежей. Ключи работают, чтение инвойсов тоже | Дождаться проверки; причину отклонения владелец видит в кабинете. Повтор с `order_id` уже созданного инвойса возвращает этот инвойс | | 404 | `route_not_found` | Нет маршрута с таким методом и путём | Проверить метод и путь по документации | | 404 | `invoice_not_found` | Инвойса нет или он принадлежит другому мерчанту | Проверить идентификатор | | 404 | `payout_not_found` | Выплаты нет или она принадлежит другому мерчанту | Проверить идентификатор | | 404 | `merchant_not_found` | Только административный API и API кабинета: мерчанта нет, а в API кабинета — или он принадлежит другой учётной записи | Проверить идентификатор | | 404 | `api_key_not_found` | Только административный API и API кабинета: у мерчанта нет ключа с таким `key_id` | Проверить идентификатор ключа и мерчанта | | 404 | `delivery_not_found` | Только административный API и API кабинета: доставки вебхука нет или она принадлежит проекту другой учётной записи | Проверить идентификатор | | 404 | `asset_not_found` | Только административный API: актива нет в каталоге | Проверить идентификатор актива | | 409 | `delivery_not_failed` | Только административный API: повторно отправляется только доставка в состоянии `failed` | Дождаться окончания попыток; доставленное событие повторно не отправляется | | 409 | `asset_flag_locked` | Только административный API: каталог активов не разрешает операцию, которую пытаются переключить | Переключать можно только то, что разрешает каталог | | 409 | `address_mode_mismatch` | Операция не подходит к режиму адресации мерчанта, например постоянный адрес для мерчанта в режиме `per_invoice` | Использовать операции своего режима | | 409 | `order_id_conflict` | Инвойс с этим `order_id` уже существует для другой суммы, актива или пользователя | Проверить, не используется ли `order_id` повторно для другого заказа | | 409 | `reference_conflict` | Выплата с этим `reference` уже существует для другой суммы, актива или адреса | Проверить, не используется ли `reference` повторно для другой выплаты | | 422 | `asset_not_supported` | Актив неизвестен или не принимается к оплате | Выбрать актив из списка `assets` | | 422 | `asset_not_payable` | В этом активе сервис не делает выплат: он неизвестен, отключён или предназначен только для приёма | Выбрать актив, в котором сервис платит | | 422 | `asset_payments_disabled` | Оператор временно выключил приём в этом активе | Повторить позже или предложить пользователю другой актив из списка `assets`. Повтор с `order_id` уже созданного инвойса возвращает этот инвойс | | 422 | `asset_disabled_by_merchant` | Приём в этом активе выключен в настройках проекта | Предложить пользователю другой актив из списка `assets` или включить актив в кабинете. Повтор с `order_id` уже созданного инвойса возвращает этот инвойс | | 422 | `asset_payouts_disabled` | Оператор временно выключил выплаты в этом активе | Повторить позже с тем же `reference` или обратиться к оператору. Выплаты, созданные раньше, исполняются как обычно | | 422 | `amount_below_minimum` | Сумма меньше минимального депозита актива | Увеличить сумму | | 422 | `network_fee_payer_not_supported` | Только API кабинета: `network_fee_payer` = `customer`. Покупатель пока не может нести комиссию сети — для этого сервису нужна котировка в курсе TRX, которой ещё нет. Запрос отклоняется целиком, ничего не записывается | Оставить `merchant` | | 422 | `service_fee_confirmation_required` | Только административный API: ставка комиссии сервиса выше 3% без `confirm_high_rate: true` | Проверить ставку и повторить с подтверждением | | 422 | `service_fee_valid_from_in_past` | Только административный API: момент начала действия ставки уже прошёл | Указать момент в будущем или не указывать его — ставка начнёт действовать сразу | | 422 | `redirect_url_not_allowed` | `return_url` или `cancel_url` инвойса указаны, но у мерчанта нет адреса того же вида или origin переопределения отличается от него | Использовать адрес на сайте, настроенном у мерчанта, или попросить оператора задать адрес — см. [страницу оплаты](https://stons.io/docs/checkout.md#адреса-возврата) | | 422 | `address_not_whitelisted` | Адреса нет в вайтлисте мерчанта по этому активу, либо задержка перед его первым использованием ещё не прошла. Сообщение называет, какой из двух случаев | Добавить адрес через оператора и дождаться окончания задержки. Ответ не зависит от суммы: без вайтлиста выплата невозможна | | 429 | `rate_limited` | Превышен лимит запросов по ключу или по IP-адресу | Повторить через время из заголовка `Retry-After` | | 500 | `internal_error` | Необработанная ошибка сервиса. Подробности пишутся в лог сервиса и в ответ не попадают | Повторить позже с тем же ключом идемпотентности; при повторении сообщить `X-Request-Id` | | 503 | `service_unavailable` | Сервис временно не может обслуживать запросы: например, недоступна база данных | Повторить позже с экспоненциальной задержкой | | 503 | `payouts_halted` | Выплаты остановлены — по этому активу или целиком. Причина остановки наружу не раскрывается | Повторить позже с тем же `reference`: повтор не создаёт вторую выплату. Если остановка длится, обратиться к оператору | | 503 | `addresses_exhausted` | Закончились индексы деривации адресов. Практически недостижимо | Сообщить оператору | | 503 | `network_fee_quote_unavailable` | В настройках проекта комиссию сети платит покупатель, а посчитать её котировку сейчас нельзя: инвойс не создаётся. Повтор с `order_id` уже созданного инвойса возвращает этот инвойс | Выбрать в кабинете «Платит проект» для комиссии сети или обратиться к оператору | Если клиент получил код, которого нет в таблице, он должен обработать ответ по классу HTTP-статуса: `4xx` — ошибка в запросе, `5xx` — временная проблема сервиса. ## Примеры ```sh curl -s https://api.stons.io/v1/unknown ``` ```json {"error":{"code":"route_not_found","message":"Route not found"}} ``` Ошибка проверки перечисляет все проблемные поля: ```json { "error": { "code": "validation_failed", "message": "Request validation failed", "details": [{ "path": "body.expires_in", "message": "Too small: expected number to be >=60" }] } } ``` --- # Чек-лист перед продом Пройдите его, прежде чем принимать реальные платежи. Каждый пункт закрывает известный способ потерять деньги. ## Ключи и доступ - [ ] Ключ API хранится в менеджере секретов и используется только на сервере. Его нет в браузере, мобильном приложении, репозитории и логах. - [ ] Секрет вебхуков хранится так же и отдельно от ключа API. - [ ] Продукт обращается к API только по `https://`: по `http://` ключ ушёл бы открытым текстом ещё до перенаправления — см. [аутентификацию](https://stons.io/docs/authentication.md#сеть). ## Вебхуки - [ ] Подпись проверяется по сырому телу запроса, до разбора JSON. - [ ] Подписи сравниваются за постоянное время. - [ ] Вебхуки с `X-Timestamp`, отличающимся от текущего времени больше чем на 5 минут, отклоняются. - [ ] Решение отдать товар принимается по ответу API, а не по телу вебхука. - [ ] Повторная доставка того же события не приводит к повторной выдаче товара. - [ ] Обработчик отвечает `2xx` за секунды, а тяжёлую работу выполняет асинхронно. ## Платежи - [ ] Товар отдаётся только после подтверждения платежа, а не при первом появлении транзакции. - [ ] Продукт определил, что делает при недоплате, переплате и платеже после дедлайна. - [ ] Суммы обрабатываются как строки или целые числа в минимальных единицах, никогда как числа с плавающей точкой. - [ ] Число знаков после запятой берётся для конкретного актива: у USDT в BNB Chain их 18, а не 6. - [ ] Каждый заказ создаёт инвойс со своим `order_id`, а повтор запроса после таймаута использует тот же `order_id`. ## Отображение - [ ] Адрес показывается пользователю целиком, без сокращений, вместе с сетью и активом. - [ ] Пользователь предупреждён, что перевод в другой сети или другого токена не зачисляется автоматически. ## Выплаты - [ ] Адреса для выплат вносятся в вайтлист заранее: новый адрес становится доступен через 24 часа. - [ ] Адрес для выплаты берётся из вайтлиста и никогда — из истории транзакций. - [ ] Каждая выплата создаётся со своим `reference`, а повтор использует тот же `reference`. ## Проверка - [ ] Весь сценарий — создание инвойса, оплата, вебхук и проверка статуса через API — пройден на небольшой сумме до приёма реальных платежей. Разработчикам самого сервиса для этого есть локальное окружение и тестовые сети. --- # Журнал изменений API Все изменения публичного API: новые маршруты, поля, события вебхуков и коды ошибок. Новые записи добавляются сверху, даты указаны по UTC. Изменение, ломающее совместимость, отмечается словом **несовместимо** и объясняет, что должен сделать интегратор. ## 2026-09-13, комиссия сервиса - У инвойса новые поля: `amount_requested` — цена мерчанта, `amount_credited` — полученное за вычетом удержанных комиссий, `fees` — комиссия сервиса и комиссия сети. Поля только добавлены, прежние не меняют смысла: `amount_expected` — по-прежнему сумма, которую должен заплатить покупатель. См. [комиссии](https://stons.io/docs/invoices.md#комиссии). - **Проверьте интеграцию.** Если в проекте комиссию сервиса платит покупатель, `amount_expected` больше цены на эту комиссию. Код, который сравнивает `amount_expected` со своей ценой заказа, должен сравнивать `amount_requested`. В проектах, где комиссию платит мерчант (так по умолчанию), `amount_expected` равен цене, как раньше. - Идемпотентность `POST /v1/invoices` по `order_id` сравнивает `amount` запроса с ценой `amount_requested`: повтор присылает цену, а не сумму с комиссией. - Комиссия сервиса удерживается с каждого подтверждённого платежа отдельной проводкой реестра и уменьшает баланс мерчанта. Ставка записывается в инвойс при создании: смена тарифа не меняет уже выставленные инвойсы. - Те же поля — в данных событий `invoice.*` ([вебхуки](https://stons.io/docs/webhooks.md#тело)) и в ответе маршрута страницы оплаты; страница оплаты показывает разбивку «Цена · Комиссия сервиса · Итого», когда комиссию платит покупатель ([страница оплаты](https://stons.io/docs/checkout.md)). - Новый код `503 network_fee_quote_unavailable`: проект настроен так, что комиссию сети платит покупатель, а посчитать её сейчас нельзя. Такую настройку API кабинета больше не принимает — `422 network_fee_payer_not_supported`. - Административный API: тарифы комиссии сервиса — `GET /admin/v1/service-fees`, `POST /admin/v1/service-fees/platform`, `GET /admin/v1/merchants/{id}/service-fees`, `POST /admin/v1/merchants/{id}/service-fees`, коды `service_fee_confirmation_required` и `service_fee_valid_from_in_past` — административный API. `GET /admin/v1/fees` остался отчётом о комиссиях сети. ## 2026-09-13, проверка проектов и настройки проекта - Проект, который бизнес заводит сам, принимает платежи только после проверки. Пока он не одобрен или если отклонён, `POST /v1/invoices` и `POST /v1/addresses` отвечают новым кодом `403 merchant_not_approved`. Ключи API при этом работают, чтение инвойсов тоже. Все мерчанты, созданные раньше, одобрены: для существующих интеграций ничего не меняется. - Владелец может выключить приём в активе для своего проекта. Тогда `POST /v1/invoices` в этом активе отвечает новым кодом `422 asset_disabled_by_merchant`, а `POST /v1/addresses` не включает актив в список `assets`. Уже открытые инвойсы оплачиваются и закрываются, повтор принятого запроса возвращает инвойс. - `403 merchant_disabled` теперь означает выключение и оператором, и владельцем проекта. - Проект без адреса вебхуков событий не получает: они не ставятся в очередь и после появления адреса не досылаются ([вебхуки](https://stons.io/docs/webhooks.md#доставка)). - Административный API: у мерчанта новые поля — `review_status`, `review_reason`, `reviewed_at`, `reviewed_by`, `site_url`, `description`, `network_fee_payer`, `service_fee_payer`, `disabled_by`. Появились фильтр `GET /admin/v1/merchants?review_status=`, маршруты `POST /admin/v1/merchants/{id}/approve` и `POST /admin/v1/merchants/{id}/reject` — административный API. **Несовместимо** для клиентов административного API: `webhook_url` мерчанта может быть `null`. Консоль обновлена в том же изменении. - Маршруты `/cabinet/v1` для приложения кабинета: служебные, снаружи недоступны и в `GET /v1/openapi.json` не входят. ## 2026-09-13, переключатели приёма и выплат - Оператор может временно выключить приём или выплаты в активе. Пока приём выключен, `POST /v1/invoices` в этом активе отвечает новым кодом `422 asset_payments_disabled`, а `POST /v1/addresses` не включает актив в список `assets`. Пока выключены выплаты, `POST /v1/payouts` отвечает новым кодом `422 asset_payouts_disabled`. - Повторы запросов, принятых раньше, отвечают как прежде: инвойс по тому же `order_id` и выплата по тому же `reference` возвращаются. - Уже открытые инвойсы оплачиваются и закрываются, уже созданные выплаты исполняются: переключатель касается только нового. - Административный API: `PATCH /admin/v1/assets/{id}`, поля `payments_enabled` и `payouts_enabled` в объекте актива, новые коды `asset_not_found` и `asset_flag_locked` — административный API. ## 2026-09-13, административный API консоли - Маршруты `/admin/v1` для консоли оператора: мерчанты и их ключи, инвойсы, остатки счетов реестра, движение средств, комиссии сети, каталог активов, доставки вебхуков и журнал аудита — административный API. Доступ по токену консоли `ADMIN_API_TOKEN` и заголовку оператора `X-Operator`; без токена маршрутов нет (ADR-0033). - Адреса возврата мерчанта `return_url` и `cancel_url` видны в объекте мерчанта и меняются `PATCH /admin/v1/merchants/{id}` по правилам [страницы оплаты](https://stons.io/docs/checkout.md#адреса-возврата). - Новые коды ошибок, только у этих маршрутов: `merchant_not_found`, `api_key_not_found`, `delivery_not_found`, `delivery_not_failed`. - Маршруты `/v1` и описание `GET /v1/openapi.json` не изменились: административные маршруты в него не входят. ## 2026-09-13, страница оплаты и адреса возврата - У инвойса новое поле `checkout_url` — ссылка на [страницу оплаты](https://stons.io/docs/checkout.md), если оператор её настроил, иначе `null`. - Необязательные поля `return_url` и `cancel_url` в `POST /v1/invoices`: куда страница оплаты вернёт покупателя. Допускаются только в пределах origin адресов, заданных у мерчанта оператором; иначе новый код `422 redirect_url_not_allowed`. Те же поля появились в ответе инвойса. - Повтор `POST /v1/invoices` с другими `return_url` или `cancel_url` — `409 order_id_conflict`, как повтор с другой суммой. Запросы без этих полей ведут себя как раньше. - Возврат покупателя подписан: параметры `invoice_id`, `order_id`, `status`, `ts`, `signature`, подпись HMAC-SHA256 секретом вебхуков. Формат и код проверки — в [странице оплаты](https://stons.io/docs/checkout.md#подпись). Решение — ADR-0032. - `GET /checkout/v1/invoices/{id}` — служебный маршрут сервера страницы оплаты, без ключа API, в приватной сети. Мерчантам он не нужен. ## 2026-09-12, USDC в сети TRON - Новый актив `USDC_TRON`: инвойсы и выплаты в USDC принимаются наравне с USDT, 6 знаков после запятой, 20 подтверждений. Контракт `TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8` — прочитан в сети, а не взят из статьи: `symbol() = USDC`, `name() = USD Coin`, `decimals() = 6`. Обоснование и граница решения — в ADR-0026. - USDC на адресе приёма больше **не** считается неожиданным активом. Для TRON `unexpected_asset` теперь означает токен, которого нет в каталоге. - TRX платёжным активом пока **не** стал: в режиме сжигания энергии (`SWEEP_ENERGY_MODE=burn`) комиссию за свип платит сам адрес приёма, поэтому TRX на него кладёт оператор — и с включённым флагом эти операционные деньги были бы зачислены как чей-то платёж. Сначала правило, отличающее переводы с наших адресов, и тест; потом флаг. Выплаты в TRX не появятся и после: суточные лимиты складывают суммы в одной единице, что верно только для привязанных к доллару активов. - Активы тестовых сетей не изменились: проверяемого контракта USDC в Nile и Shasta нет. ## 2026-09-12, домен в установщике - `POST /setup/domain` — панель находит у провайдера DNS зону, которой принадлежит указанное имя, создаёт или исправляет запись A на адрес этого сервера и возвращает то, что видно в публичном DNS прямо сейчас. Проверяется **не своя запись, а ответ сети**: записанное и видимое — разные утверждения, и работает только второе. - Если зоны у провайдера нет, панель **создаёт её сама** и показывает NS-серверы, которые нужно прописать у регистратора домена: без делегирования зону не видит никто, какими бы правильными ни были записи в ней. Зона создаётся под регистрируемым именем — последние две метки. - Ответ маршрута говорит, видит ли провайдер делегирование (`delegated`), создавалась ли зона (`zone_created`) и какие NS-серверы нужны (`nameservers`). - Шаг «Домен» стоит первым и необязателен: без домена сервис доступен по адресу сервера. Переменные `SETUP_DOMAIN` и `BUNNY_API_KEY` пишет сама панель; ключ провайдера используется только для записи и проверки и обратно не показывается. - Панель первого запуска теперь открывается в браузере по адресу сервера — порт API публикуется на время установки одной переменной и возвращается на loopback после неё (ADR-0025). Маршруты `/v1` не изменились. ## 2026-09-12, установщик записывает конфигурацию сам - Страница первого запуска больше не печатает блок переменных для вставки руками: она **записывает** файл окружения на сервере — `POST /setup/env`. Значения проверяются теми же разборщиками, которыми сервис читает конфигурацию при старте, поэтому записанный файл — это файл, с которым сервис запускается. Маршруты `/v1` не изменились. - `POST /setup/signer-values` и `GET /setup/signer-env` — выбор владельца для сервера подписи и **готовый файл окружения** для него вместе с командами по порядку. Пароль роли базы и два значения сида в него не попадают: первый уже есть на этом сервере, вторые создаются на том. - Шаги «Сеть и узлы» и «Файл окружения» — новые и обязательные. Второй закрывается только когда значения не просто записаны, но и применены: переменные читаются один раз, при старте. - Секрет, принятый страницей, обратно не отдаётся: в ответе `GET /setup/state` у него только признак «задано». Пустое поле означает «не менять». - Поле, в которое вставили 12 или 24 коротких слова, отвергается: мнемоника не вводится на этой странице ни в одно поле. Обоснование и границы — в ADR-0024. ## 2026-09-12, страница первого запуска - Пока настройка сервиса не завершена, API отдаёт страницу `GET /setup`, которая ведёт по шагам и проверяет каждый. Когда все обязательные шаги закрыты, её маршруты отвечают `404`. Мерчантов это не касается: маршруты `/v1` не изменились. - Служебные маршруты страницы: `GET /setup/state`, `POST /setup/verify`, `POST /setup/steps/{key}`, `POST /setup/merchant`. Доступны только из приватной сети и только с одноразовым кодом из лога сервиса. См. первый запуск. - Страница не принимает и не создаёт мнемонику, парольную фразу и приватную половину операторского ключа — обоснование в ADR-0024. ## 2026-09-12, отправка выплат - События `payout.confirmed` и `payout.failed`: выплата подтверждена сетью или её транзакция попала в блок неуспешной. Поля — в [вебхуках](https://stons.io/docs/webhooks.md#события). Как и раньше, тело вебхука — сигнал «сходи проверь»: состояние выплаты берите из `GET /v1/payouts/{id}`. - Выплата в `failed` не повторяется автоматически: новая попытка требует нового `reference`. - Остальное в этом обновлении внутреннее: подпись выплат с горячего кошелька, вторая независимая проверка вайтлиста вместе с подписью оператора, предохранитель, останавливающий выплаты сам. Публичные маршруты не изменились. ## 2026-09-12, выплаты - `POST /v1/payouts` — создание выплаты, идемпотентное по `reference`. Ответ `202` у созданной выплаты, `200` у повтора. См. [выплаты](https://stons.io/docs/payouts.md). - `GET /v1/payouts/{id}` — текущее состояние выплаты с её статусом и причинами ожидания. - Выплата возможна только на адрес из вайтлиста мерчанта и только после задержки перед первым использованием. Переход лимита выплату не отклоняет: она принимается со статусом `pending_review` и ждёт подтверждения человека, а `review_reasons` называет перейденные пределы. См. [лимиты](https://stons.io/docs/payouts.md#лимиты). - Новые коды ошибок: `payout_not_found`, `reference_conflict`, `asset_not_payable`, `address_not_whitelisted`, `payouts_halted`. См. [коды ошибок](https://stons.io/docs/errors.md). - Отправка выплат в сеть и события вебхуков `payout.confirmed` и `payout.failed` появятся в следующем обновлении: пока выплата доходит до статуса `queued` и ждёт исполнения. ## 2026-09-12, свипы - Инвойс доходит до статуса `settled`: средства по нему перенесены в холодное хранилище. Для мерчанта это то же, что `confirmed`, вебхука у перехода нет — товар отдаётся на `confirmed`, как и раньше. См. [статусы](https://stons.io/docs/invoices.md#статусы). - Остальное в этом обновлении внутреннее: подпись и отправка свипов, подтверждение их в цепи и проводки в реестре. Публичные маршруты и события вебхуков не изменились. ## 2026-09-11, операционные маршруты - `GET /admin/health` — состояние сервиса для оператора и мониторинга: отставание сканера в блоках и глубина очереди вебхуков. См. операционное состояние. - `GET /metrics` — те же показатели в текстовом формате Prometheus. - Оба маршрута служебные: аутентификации не требуют, данных мерчантов не содержат и доступны только в приватной сети. ## 2026-09-11, доставка вебхуков - События из очереди уходят на URL мерчанта с подписью `X-Signature` и заголовком `X-Timestamp`, с повторами по расписанию из [описания вебхуков](https://stons.io/docs/webhooks.md#доставка). До этого события только записывались. - Заголовок `User-Agent` исходящих вебхуков содержит название продукта. - Ответ `3xx` не считается успешной доставкой и не выполняется. ## 2026-09-11, сканер платежей - Инвойсы меняют статус по переводам в сети: `seen`, `confirmed`, `underpaid`, `expired`, `paid_late`. См. [статусы](https://stons.io/docs/invoices.md#статусы). - В ответе `GET /v1/invoices/{id}` поле `deposits` заполняется платежами по инвойсу. - Сервис формирует события вебхуков `invoice.seen`, `invoice.confirmed`, `invoice.overpaid`, `invoice.underpaid`, `invoice.expired`, `invoice.paid_late`, `deposit.confirmed` и `deposit.orphaned`. См. [события](https://stons.io/docs/webhooks.md#события). Отправка событий на URL мерчанта включится в следующем обновлении. ## 2026-09-11, адреса и инвойсы - Аутентификация по заголовку `X-Api-Key` и лимиты запросов по ключу и по IP-адресу — см. [аутентификацию](https://stons.io/docs/authentication.md). - `POST /v1/addresses` — постоянный адрес пользователя для мерчантов в режиме `per_user`. См. [адреса](https://stons.io/docs/addresses.md). - `POST /v1/invoices` — создание инвойса, идемпотентное по `order_id`. См. [инвойсы](https://stons.io/docs/invoices.md). - `GET /v1/invoices/{id}` — текущее состояние инвойса с его платежами. - `GET /v1/openapi.json` — машиночитаемое описание API в формате OpenAPI 3.1. - Новые коды ошибок: `validation_failed`, `unauthorized`, `merchant_disabled`, `invoice_not_found`, `address_mode_mismatch`, `order_id_conflict`, `asset_not_supported`, `amount_below_minimum`, `rate_limited`, `addresses_exhausted`. См. [коды ошибок](https://stons.io/docs/errors.md). ## 2026-09-11, служебные маршруты - Добавлены служебные маршруты `GET /healthz` и `GET /readyz` — см. мониторинг. - Введён единый формат ошибок `{ "error": { "code", "message" } }` и коды `bad_request`, `route_not_found`, `internal_error`, `service_unavailable` — см. [коды ошибок](https://stons.io/docs/errors.md). - Каждый ответ содержит заголовок `X-Request-Id` для поиска запроса в логах.