API мерчанта
Всё, что нужно, чтобы подключить сервис к платёжке. Базовый адрес —
https://pay.suprapay.org. Все ответы — JSON, все суммы — строки в целых
монетах с десятичной точкой ("12.5"), никогда не числа.
Машиночитаемое описание — /docs/openapi.json
(OpenAPI 3.1): все эндпойнты, схемы ответов и тела колбэков. Из него можно
сгенерировать клиент или импортировать его в Postman.
1. Ключи и подпись запросов
У мерчанта есть ключ Ed25519: приватный сид (32 байта) хранит мерчант, публичную половину хранит платёжка. Ключ выпускает оператор в админке платёжки («Мерчанты → Подключение → Выпустить ключ»): либо пара создаётся там и сид показывается один раз, либо вы делаете пару сами любой библиотекой Ed25519 и передаёте оператору публичную половину (64 hex-символа) — тогда сид не покидает ваш сервер вообще. Утечка базы платёжки не даёт ничего, чем можно подписать запрос.
Каждый запрос несёт четыре заголовка:
| Заголовок | Значение |
|---|---|
X-Gw-Key |
id ключа, gwk_… |
X-Gw-Ts |
unix-время в секундах; окно ±5 минут |
X-Gw-Nonce |
16–64 печатных ASCII-символов, уникальных на запрос; повтор — отказ |
X-Gw-Sign |
base64 (стандартный, с =) подписи Ed25519 над сообщением ниже |
Сообщение для подписи — строки через \n, без завершающего \n:
<ts>
<nonce>
<METHOD в верхнем регистре>
<путь, например /v1/invoices>
<query без "?", может быть пустым>
<hex(SHA-256(тело)); для пустого тела — hex(SHA-256(""))>
Пример (Go, стандартная библиотека):
msg := fmt.Sprintf("%d\n%s\n%s\n%s\n%s\n%x", ts, nonce, "POST", "/v1/invoices", "", sha256.Sum256(body))
sig := ed25519.Sign(ed25519.NewKeyFromSeed(seed), []byte(msg))
req.Header.Set("X-Gw-Sign", base64.StdEncoding.EncodeToString(sig))
Неподписанный или неверно подписанный запрос получает 401 на любом
пути, в том числе несуществующем. Если у мерчанта задан список IP, запрос с
другого адреса — 403 ip_not_allowed.
2. Ошибки
{"error": {"code": "amount_out_of_range", "message": "…"}}
| HTTP | code | Когда |
|---|---|---|
| 400 | invalid_request, invalid_amount, invalid_accuracy, invalid_currency, invalid_address, asset_disabled, amount_out_of_range, bad_ttl, payout_not_allowed, over_limit |
что-то в запросе; GET /v1/assets покажет допустимое |
| 401 | unauthenticated, replay |
подпись, время, nonce |
| 403 | ip_not_allowed, merchant_suspended, payouts_disabled |
|
| 404 | not_found |
нет такого объекта у этого мерчанта |
| 409 | order_mismatch, not_cancellable, not_offered, already_selected, checkout_closed, insufficient_balance, refund_exceeds |
тот же order_id с другими параметрами; отмена не в том статусе; монету сейчас нельзя предложить; баланса не хватает |
| 413 | body_too_large |
тело больше 64 KiB |
| 429 | rate_limited, quota_exceeded |
частота или квота (в message — какая) |
| 503 | no_address |
пул адресов пуст; повторить через несколько секунд |
3. Режим «счёт» — адрес на одну операцию
POST /v1/invoices
{"order_id": "dep-10021", "asset": "USDT-TRC20", "amount": "25", "ttl_sec": 3600, "customer_ref": "player-77",
"accuracy_percent": "1.5", "return_url": "https://shop.example/wallet", "description": "Пополнение #10021"}
order_id— ваш идентификатор,[A-Za-z0-9_.:@-]{1,64}. Повтор с теми жеassetиamountвозвращает тот же счёт (идемпотентно); с другими —409.ttl_sec— необязателен; по умолчанию 3600, границы задаёт оператор (5 мин – 24 ч).customer_ref— необязателен, но рекомендован: на одного клиента действует лимит открытых счетов (по умолчанию 3), который защищает вас от клиента, жмущего «пополнить» без конца.accuracy_percent— допуск недоплаты (аналогaccuracy_payment_percentв Cryptomus): «1.5» значит, что 98.5 из 100 считается оплатой. Кошельки плательщиков часто удерживают комиссию из суммы. Не больше двух знаков после точки; потолок задаёт оператор (по умолчанию 5 %). Пусто или «0» — значение по умолчанию мерчанта.return_url— куда вернуть плательщика со страницы оплаты; только https.description— до 200 символов, показывается плательщику.
Ответ 201:
{"invoice": {
"id": "01926b0c-…", "order_id": "dep-10021", "asset": "USDT-TRC20", "chain": "tron",
"amount": "25", "paid_amount": "0", "surcharge": "0", "credited": "25", "accuracy_percent": "1.5",
"address": "TXYZ…", "address_display": "TXYZ…",
"status": "open", "customer_ref": "player-77",
"description": "Пополнение #10021", "return_url": "https://shop.example/wallet",
"pay_url": "https://pay.suprapay.org/i/01926b0c-…",
"expires_at": "2026-09-10T13:00:00Z", "created_at": "2026-09-10T12:00:00Z",
"payments": []
}}
Наценка оператора. Если оператор её включил, amount — это сумма, которую
платит плательщик, а credited — сколько зачислится мерчанту после оплаты;
разница в surcharge остаётся оператору и в баланс мерчанта не попадает. Без
наценки surcharge = «0», а credited = amount. Плательщику наценка видна
на странице оплаты отдельной строкой. При недоплате в пределах допуска
наценка и доля мерчанта уменьшаются пропорционально.
Два способа показать оплату клиенту:
- Страница оплаты — отправьте клиента на
pay_url. Там сумма, сеть, адрес, QR-код, таймер и предупреждение о сети; страница обновляется сама, после оплаты показывает «Оплачено» и кнопку возврата наreturn_url. Языки: русский и английский (поAccept-Languageили?lang=en). - Свой интерфейс — показывайте
address_display(для TON это формаUQ…, для ETH — с контрольной суммой),amountиexpires_at.
Оплата после expires_at всё равно засчитывается ещё несколько дней
(карантин адреса) — со статусом paid_late. Оплата в пределах допуска
(paid_amount ≥ amount × (1 − accuracy_percent/100)) даёт paid; недобор
виден по разнице amount и paid_amount.
Статусы счёта
| Статус | Значение |
|---|---|
open |
ждёт оплаты |
partially_paid |
пришла часть; paid_amount растёт; ждём |
paid |
paid_amount ≥ amount; переплата видна в paid_amount |
paid_late |
то же, но после expires_at |
expired |
срок вышел, ничего не пришло |
cancelled |
отменён вами; деньги на такой счёт не засчитываются, а уходят оператору как unmatched |
GET /v1/invoices/{id}, GET /v1/invoices/by-order/{order_id} — текущий
статус с массивом payments (txid, сумма, отправитель, статус подтверждения).
В массиве есть и переводы, которые ещё не засчитаны (seen — увиден в сети,
confirmed — ждёт последних подтверждений): по ним видно, что плательщик уже
отправил деньги. Зачисленной считается только сумма в paid_amount.
POST /v1/invoices/{id}/cancel — только для open. Отмена идемпотентна.
4. Счёт в фиате — клиент сам выбирает монету
Сумма в долларах, евро или рублях; плательщик видит, сколько это в каждой
монете, и выбирает, чем платить. В момент выбора сумма фиксируется по
текущему курсу и превращается в обычный счёт из §3 — с тем же order_id.
POST /v1/checkouts
{"order_id": "ord-5521", "currency": "USD", "amount": "50.00", "assets": ["USDT-TRC20", "BTC", "TON"],
"ttl_sec": 1800, "customer_ref": "player-77", "accuracy_percent": "1",
"return_url": "https://shop.example/wallet", "description": "Заказ #5521"}
currency—USD,EURилиRUB;amount— не больше двух знаков после точки.assets— необязателен; пусто — все включённые активы.ttl_sec— от 300 до 604800 (7 дней), по умолчанию 1800; можно давать как долгоживущую ссылку на оплату.- Идемпотентно по
order_id: те же валюта и сумма → тот же объект, другие →409.order_id, уже занятый обычным счётом, тоже409. customer_ref,accuracy_percent,return_url,description— как у счёта.
Ответ 201:
{"checkout": {
"id": "01926b0d-…", "order_id": "ord-5521", "currency": "USD", "amount": "50.00",
"assets": ["USDT-TRC20", "BTC", "TON"], "status": "open",
"pay_url": "https://pay.suprapay.org/c/01926b0d-…",
"expires_at": "2026-09-10T12:30:00Z", "created_at": "2026-09-10T12:00:00Z",
"offers": [
{"asset": "USDT-TRC20", "chain": "tron", "amount": "50.02", "rate": "0.99970000", "sources": ["coingecko", "kraken", "okx"]},
{"asset": "BTC", "chain": "btc", "amount": "0.00064735", "rate": "77238.00000000", "sources": ["coingecko", "kraken", "okx"]}
]
}}
offers — сколько это стоило бы сейчас, с наценкой оператора, если она
есть; сумма к оплате фиксируется только при выборе. Отправьте клиента на
pay_url: там он выбирает валюту и сеть (у каждой сети видны комиссия и
примерное время зачисления), видит сумму к оплате, жмёт «Перейти к оплате» — и
попадает на страницу обычного счёта с адресом и QR. Или выберите за него сами:
POST /v1/checkouts/{id}/select с {"asset": "BTC"} — ответ тот же объект
со status: "selected", asset, rate, rate_sources и вложенным invoice
(полный счёт из §3). Повторный выбор той же монеты возвращает тот же счёт,
другой — 409 already_selected. Монета без согласованного курса — 409 not_offered, истёкший объект — 409 checkout_closed.
GET /v1/checkouts/{id}, GET /v1/checkouts/by-order/{order_id}.
Статусы: open (ждёт выбора), selected (есть счёт — дальше смотрите его
статус), expired.
Откуда курс. Цена монеты берётся у нескольких независимых источников
(CoinGecko, Kraken, OKX; евро — через курс EUR/USD Kraken, рубли — через
официальный курс ЦБ РФ) не старше 10 минут. Монета предлагается, только если
хотя бы два источника согласны в пределах 2 % (порог задаёт оператор);
курс — медиана. Иначе монеты просто нет в списке: неверной цены не бывает,
бывает «сейчас недоступна». Сумма в монете округляется вверх — у долларовых
стейблкоинов (USDT, USDC) до цента, у остальных монет до 6 знаков (у BTC до 8),
так что пришедшее покрывает фиатную сумму, а плательщику не нужно набирать
50.015005. Округление идёт в наценку оператора: мерчанту зачисляется ровно
сумма по курсу (credited у счёта).
После выбора счёт живёт до expires_at объекта, но не меньше 15 минут. Все
колбэки invoice.* этого счёта несут checkout_id и
"fiat": {"currency": "USD", "amount": "50.00", "rate": "0.9997"}.
Зачисляйте, как и у обычного счёта, по статусу счёта и paid_amount — деньги
пришли в монете, фиат здесь справочно.
5. Режим «статический кошелёк» — адрес на клиента навсегда
POST /v1/wallets
{"chain": "tron", "customer_ref": "player-77"}
Идемпотентно: тот же клиент на той же сети → тот же адрес. Ответ 201:
{"wallet": {"id": "…", "chain": "tron", "customer_ref": "player-77", "address": "T…", "address_display": "T…", "created_at": "…"}}
Адрес принимает все включённые активы своей сети (на TRON — и TRX, и
USDT). Каждое подтверждённое поступление выше порога пыли — отдельное событие
payment.received со своим payment_id. Держите идемпотентность по нему.
GET /v1/wallets/{id}, GET /v1/wallets/{id}/payments?limit=100,
GET /v1/payments/{id}.
6. Активы и курсы
GET /v1/assets — что включено, с точностью и порогами:
{"assets": [{"code": "USDT-TRC20", "chain": "tron", "kind": "token", "contract": "TR7NH…", "decimals": 6, "min_amount": "1", "max_amount": "1000000", "display": "USDT (TRON)"}]}
Сумма счёта должна лежать в [min_amount, max_amount] и иметь не больше
decimals знаков после точки. Поступление ниже min_amount — «пыль»: не
засчитывается и не уведомляется.
GET /v1/rates?currency=USD (EUR, RUB) — цена одной монеты каждого
включённого актива по тем же правилам, что и в §4 (монеты без согласованного
курса в списке нет):
{"currency": "USD", "rates": [{"asset": "BTC", "price": "77238.00000000", "sources": ["coingecko", "kraken", "okx"]}]}
7. Баланс, выплаты и возвраты
Каждое зачисленное поступление (счёт или кошелёк) увеличивает баланс
мерчанта в своём активе; выплаты и возвраты его уменьшают. Каждый актив —
отдельный баланс в своей сети: USDT-TRC20 и USDT-BEP20 не складываются,
и выводятся они каждый на адрес в своей сети.
Вывести можно тремя путями: подписанным запросом к API (ниже); в кабинете,
«Баланс и выплаты» → «Вывести» — такая заявка всегда ждёт подтверждения
оператора, и её можно отменить, пока он не решил; или попросить оператора —
он выведет за вас из админки. В ответах API у выплаты есть поле source:
api, cabinet или operator. Колбэки payout.* приходят только по
выплатам, заказанным через API. Выплаты включает
оператор и задаёт для каждого актива комиссию, максимум одной выплаты, дневной
лимит и порог ручного подтверждения.
GET /v1/balance
{"balances": [{"asset": "USDT-TRC20", "amount": "1250.5"}]}
POST /v1/payouts
{"order_id": "wd-9001", "asset": "USDT-TRC20", "address": "TXYZ…", "amount": "100"}
- Идемпотентно по
order_id: те же актив, адрес и сумма → та же выплата, другие →409 order_mismatch. При сетевой ошибке повторяйте с тем жеorder_id— второй выплаты не будет. - С баланса сразу списывается
amount + fee(fee— комиссия оператора в том же активе). Не хватает —409 insufficient_balance. - Адрес проверяется на формат сети; адреса самой платёжки не принимаются.
Для TON адрес уходит в том виде, в каком вы его прислали (
UQ…— без возврата,EQ…— с возвратом при ошибке получателя). - Порог подтверждения считается по сумме ваших выплат актива за сутки
(UTC): пока она не больше порога, выплаты уходят сами; всё сверх — статус
pending_approval, пока оператор не подтвердит (с 2FA) или не отклонит. Разбить выплату на части, чтобы уложиться под порог, не выйдет. - Новый API-ключ может заказывать выплаты сразу, но отправлены они будут не раньше, чем ключу исполнятся сутки: у оператора есть время отозвать ключ, выпущенный не вами.
- Ниже минимума, выше максимума или дневного лимита —
400 over_limit; выплаты выключены —403 payouts_disabled; для актива не настроены —400 payout_not_allowed.
Ответ 201:
{"payout": {"id": "01926b0e-…", "order_id": "wd-9001", "kind": "payout", "chain": "tron", "asset": "USDT-TRC20",
"address": "TXYZ…", "amount": "100", "fee": "1", "status": "approved", "created_at": "2026-09-10T12:00:00Z"}}
Подпись защищает и выплату. Сам ваш подписанный запрос сохраняется, и
сервис, который подписывает транзакции, проверяет вашу подпись ещё раз
перед отправкой: взломанный API-сервер не может выдумать выплату. Отзыв
ключа останавливает ещё не отправленные выплаты, подписанные им (они
закрываются как failed, деньги возвращаются на баланс).
| Статус | Значение |
|---|---|
pending_approval |
ждёт решения оператора; можно отменить |
approved |
в очереди на отправку |
sending |
транзакция подписана и отправляется |
sent |
в сети, ждём окончательного подтверждения; есть txid |
confirmed |
окончательно в цепочке |
failed |
не ушла и не уйдёт; amount + fee вернулись на баланс; причина в error |
rejected |
отклонена оператором; деньги вернулись |
cancelled |
отменена вами; деньги вернулись |
POST /v1/payouts/batch — массовая выплата одним подписанным запросом:
{"payouts": [ … до 100 элементов, как выше … ]}. Каждый элемент —
отдельная выплата со своим результатом:
{"results": [{"order_id": "wd-1", "payout": {"id": "…", "status": "approved"}},
{"order_id": "wd-2", "error": {"code": "insufficient_balance", "message": "…"}}]}
GET /v1/payouts?limit=100, GET /v1/payouts/{id},
GET /v1/payouts/by-order/{order_id}; POST /v1/payouts/{id}/cancel — только
для pending_approval.
Возврат: POST /v1/payments/{id}/refund
{"order_id": "rf-10021", "address": "TXYZ…", "amount": "25"}
Выплата в активе поступления (kind: "refund", payment_id), с баланса, по
тем же правилам. Только для зачисленного поступления, и в сумме по всем
возвратам — не больше, чем оно принесло (409 refund_exceeds).
8. Колбэки
Этот адрес вы придумываете сами. Платёжка не выдаёт эндпойнт — она в него
стучится. Нужен любой путь на вашем сервисе, доступный из интернета по https,
например https://ваш-сервис.com/webhooks/suprapay. Его оператор вписывает в
админке в поле «Адрес для уведомлений» и нажимает «Сохранить» — в этот момент
один раз показывается секрет подписи (64 hex-символа), его надо положить в
конфиг вашего сервиса.
POST на этот адрес (только https).
Тело — JSON:
{"event_id": "01926b0c-…", "event": "invoice.paid", "occurred_at": "2026-09-10T12:34:56Z",
"data": {"invoice_id": "…", "order_id": "dep-10021", "status": "paid", "asset": "USDT-TRC20",
"amount": "25", "paid_amount": "24.8", "accuracy_percent": "1.5",
"payment_id": "…", "txid": "…", "from": "T…", "customer_ref": "player-77"}}
Заголовки:
| Заголовок | Что в нём |
|---|---|
X-Gw-Ts |
время отправки, unix-секунды |
X-Gw-Sign |
hex(HMAC-SHA256(key, <X-Gw-Ts> + "\n" + тело)) |
X-Gw-Event |
имя события, как в теле |
X-Gw-Event-Id |
id события, как в теле |
Две детали, на которых спотыкается почти каждая интеграция:
- Ключ HMAC — это байты секрета, а не его текст. В админке секрет показан
как 64 hex-символа; декодируйте их в 32 байта и подписывайте этими байтами
(
Buffer.from(secret, "hex"),hex.DecodeString,bytes.fromhex). - Подписывается сырое тело, байт в байт. Считайте HMAC по тому, что пришло в запросе, до разбора JSON. Если фреймворк уже разобрал тело, а вы соберёте JSON заново, подпись не сойдётся: порядок полей и пробелы будут другими.
Пример обработчика (Node + Express; на любом языке то же самое):
import express from "express";
import crypto from "node:crypto";
const KEY = Buffer.from(process.env.SUPRAPAY_CALLBACK_SECRET, "hex"); // 64 hex → 32 байта
const app = express();
// express.raw — чтобы получить СЫРОЕ тело: подпись считается по нему.
app.post("/webhooks/suprapay", express.raw({ type: "*/*", limit: "256kb" }), (req, res) => {
const ts = req.get("X-Gw-Ts") || "";
const sign = (req.get("X-Gw-Sign") || "").toLowerCase();
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(403); // окно ±5 минут
const want = crypto.createHmac("sha256", KEY)
.update(ts).update("\n").update(req.body).digest("hex");
const a = Buffer.from(want), b = Buffer.from(sign);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(403);
const ev = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200); // сначала ответить (≤ 10 с), потом работать
handle(ev); // идемпотентно по ev.event_id; статус — спросить у API
});
Проверить эндпойнт можно не дожидаясь оплаты — подпишите тестовое тело своим же секретом:
SECRET=<64 hex из админки>
TS=$(date +%s)
BODY='{"event_id":"test-1","event":"invoice.paid","data":{"order_id":"test","status":"paid"}}'
SIGN=$(printf '%s\n%s' "$TS" "$BODY" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$SECRET -hex | awk '{print $2}')
curl -sS -i -X POST https://ваш-сервис.com/webhooks/suprapay \
-H "Content-Type: application/json" -H "X-Gw-Ts: $TS" -H "X-Gw-Sign: $SIGN" \
-H "X-Gw-Event: invoice.paid" -H "X-Gw-Event-Id: test-1" --data "$BODY"
Ответ 200 — эндпойнт готов. 403 — не сходится подпись: почти всегда либо
секрет не декодирован из hex, либо тело подписано не сырым.
События: invoice.partially_paid, invoice.paid, invoice.paid_late,
invoice.expired, payment.received (кошельки), payment.unmatched
(деньги пришли, но засчитать нельзя: отменённый счёт, платёж в другом
активе, отправитель уже платил на этот адрес по предыдущему счёту —
разбирает оператор); выплаты — payout.sent, payout.confirmed,
payout.failed, payout.rejected:
{"event_id": "…", "event": "payout.confirmed", "occurred_at": "…",
"data": {"payout_id": "…", "order_id": "wd-9001", "kind": "payout", "status": "confirmed", "chain": "tron",
"asset": "USDT-TRC20", "address": "TXYZ…", "amount": "100", "fee": "1", "txid": "…"}}
В data событий invoice.* (кроме invoice.expired) всегда есть surcharge
(сколько наценки оператора заложено в счёт) и fee (сколько удержано с этого
поступления): мерчанту зачислено поступление минус fee. Без наценки оба «0».
Счёт, созданный из §4, добавляет checkout_id и fiat.
Остальные события короче:
{"event": "invoice.expired", "data": {"invoice_id": "…", "order_id": "dep-10021", "status": "expired"}}
{"event": "payment.received", "data": {"payment_id": "…", "wallet_id": "…", "customer_ref": "player-77", "chain": "tron",
"asset": "USDT-TRC20", "amount": "25", "txid": "…", "from": "T…", "address": "T…"}}
{"event": "payment.unmatched", "data": {"payment_id": "…", "chain": "tron", "asset": "TRX", "txid": "…", "address": "T…",
"reason": "…"}}
У payout.* для возврата есть ещё payment_id, у failed и rejected — error.
Точные схемы всех тел — в разделе webhooks файла openapi.json.
Правило. Проверьте подпись и время (окно ±5 минут), ответьте 2xx быстро
(≤ 10 с), и затем спросите статус GET /v1/invoices/{id} (для выплат —
GET /v1/payouts/{id}) — действуйте только по нему. Колбэк — повод спросить, не основание зачислить. Один
event_id может прийти дважды; повторы должны быть безвредны.
Доставка: при не-2xx повтор с растущей задержкой (10 с → 6 ч) в течение 7 дней, потом оператор может повторить вручную.
9. Что сделать на стороне мерчанта — чек-лист
- Хранить сид ключа как секрет; при компрометации — отозвать ключ в админке (мгновенно) и выпустить новый.
- Синхронизировать часы (NTP) — окно 5 минут.
- Использовать
customer_ref. - Проверять подпись колбэков и всегда переспрашивать статус.
- Хранить
event_idиpayment_idдля идемпотентности. - Показывать клиенту
address_display,amount,expires_at; для TON ещё и предупреждение «только TON-сеть». - Для выплат: уникальный
order_idна каждую, повтор при сбое — с тем жеorder_id; окончательный результат —confirmedилиfailed/rejected.