SupraPay ENRUSign in
Machine-readable spec: openapi.json (OpenAPI 3.1, callbacks included) · The guide below is currently in Russian; the OpenAPI spec is in English.

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. Плательщику наценка видна на странице оплаты отдельной строкой. При недоплате в пределах допуска наценка и доля мерчанта уменьшаются пропорционально.

Два способа показать оплату клиенту:

  1. Страница оплаты — отправьте клиента на pay_url. Там сумма, сеть, адрес, QR-код, таймер и предупреждение о сети; страница обновляется сама, после оплаты показывает «Оплачено» и кнопку возврата на return_url. Языки: русский и английский (по Accept-Language или ?lang=en).
  2. Свой интерфейс — показывайте 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 события, как в теле

Две детали, на которых спотыкается почти каждая интеграция:

  1. Ключ HMAC — это байты секрета, а не его текст. В админке секрет показан как 64 hex-символа; декодируйте их в 32 байта и подписывайте этими байтами (Buffer.from(secret, "hex"), hex.DecodeString, bytes.fromhex).
  2. Подписывается сырое тело, байт в байт. Считайте 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. Что сделать на стороне мерчанта — чек-лист

  1. Хранить сид ключа как секрет; при компрометации — отозвать ключ в админке (мгновенно) и выпустить новый.
  2. Синхронизировать часы (NTP) — окно 5 минут.
  3. Использовать customer_ref.
  4. Проверять подпись колбэков и всегда переспрашивать статус.
  5. Хранить event_id и payment_id для идемпотентности.
  6. Показывать клиенту address_display, amount, expires_at; для TON ещё и предупреждение «только TON-сеть».
  7. Для выплат: уникальный order_id на каждую, повтор при сбое — с тем же order_id; окончательный результат — confirmed или failed/rejected.