Coinrail API v1 · mainnet · BTC · LTC · USDT · XMR

Документация платёжного шлюза

Coinrail даёт адрес для оплаты (в BTC и LTC — свежий на каждый счёт, в USDT — постоянный, а счета на нём различаются суммой), ловит платёж и сообщает вам о статусе — вебхуком, опросом или real-time потоком. Ниже — весь контур приёма и выплат с примерами на PHP, Python, Node и Java: язык переключается прямо в примере.

Клиент вносит крипту→ POST /invoices → адрес→ событие confirmed→ зачисление у вас

01 — Основы

Как устроен обмен

Coinrail — платёжный процессор приёма и отправки крипты. На каждый депозит он выдаёт вам новый адрес, ловит платёж и уведомляет вас, когда монеты получены и подтверждены. Что делать дальше — зачислять своему клиенту, менять валюту — решает ваш сервис; coinrail в это не вмешивается. Отдельно, по вашему запросу, coinrail отправляет крипту наружу (выплаты). Один адрес не переиспользуется.

Ключи кошельков держит шлюз — ими подписываются выплаты, которые вы заказываете по API, поэтому для обычной интеграции ключи вам не нужны. Но кошелёк ваш: в кабинете можно забрать seed-фразу под PIN и распоряжаться средствами независимо от шлюза. У каждой монеты свой сид — забирать нужно каждый отдельно.

Base URL
https://coinrail.net/v1
OpenAPI
https://coinrail.net/openapi.json — машиночитаемая спека: импорт в Postman, генерация SDK
Монеты
BTC, LTC, USDT (TRC20), XMR
Формат
JSON. Суммы — строкой ("0.001"), не float. Комиссии — целое (сат/vByte)

Клиент

Ставить нечего — и не нужно

Готовых пакетов пока нет ни для одного языка. На внедрение это почти не влияет: все примеры ниже собирают запрос тем, что уже есть в самом языке — curl, requests, fetch, HttpClient, — без единой внешней зависимости. Аутентификация здесь один заголовок, а проверка подписи вебхука — пять строк HMAC, они есть в разделе о событиях.

Типизированный клиент из спеки
# the spec is live and covers every coin, XMR included
npx @openapitools/openapi-generator-cli generate \
  -i https://coinrail.net/openapi.json \
  -g typescript-fetch -o ./coinrail-client

# same for other languages: -g php / -g python / -g java
Повторы — на вашей стороне Повторять запрос при обрыве связи и при 429/5xx безопасно только там, где это не создаёт вторую операцию: любой GET и любая запись с ключом идемпотентности. Запись без ключа не повторяется никогда — иначе выплата уйдёт дважды.

Примеры ниже переключаются между PHP, Python, Node и Java: в каждом запрос собирается средствами самого языка.

02 — Доступ

Аутентификация

Каждый запрос подписывается заголовком Authorization: Bearer <API_KEY>. Ключ хранится только на сервере вашего сервиса — не на фронте, не в репозитории. Ключ можно дополнительно ограничить по IP вашего сервера: список задаётся в кабинете и по умолчанию пуст, то есть ограничения нет.

Конфиденциально Боевые API_KEY и WEBHOOK_SECRET вы получаете в кабинете при регистрации и видите ровно один раз. В этом документе — плейсхолдеры. Храните в переменных окружения или секрет-менеджере.
API_KEY
gw_live_••••••••• (на проде; вне продакшена префикс gw_test_)
WEBHOOK_SECRET
•••••••••• (нужен только для проверки подписи вебхуков)
Как получить API-ключ

Зарегистрируйтесь на /portal или через бота @coinrail_bot — API-ключ выдаётся сразу, без ожидания оператора.

  1. Регистрация на /portal или в боте @coinrail_bot заводит проект и сразу выдаёт API_KEY и WEBHOOK_SECRET. Оператор в этом не участвует.
  2. Тем же входом открывается кабинет мерчанта /portal: логин, кодовую фразу и PIN — вход без почты. В кабинете видны балансы, инвойсы, выплаты и префикс ключа.
  3. Ключ показывается единожды — сразу сохраните его в секрет-менеджер или переменные окружения. Полный ключ нигде не хранится в открытом виде и повторно не показывается.
  4. Потеря или компрометация ключа → ротация в кабинете под PIN: старый ключ отзывается немедленно, новый показывается один раз.
  5. Там же, под PIN, включается IP-allowlist, задаются лимиты выплат и забирается seed-фраза кошелька.
Скоупы ключа

Ключ несёт набор скоупов. Не хватает нужного — запрос отклоняется с 403 и code: "FORBIDDEN_SCOPE". Это проблема ключа, а не сети: чинить надо скоупы в кабинете, а не фаервол.

СкоупЧто открывает
invoices:writeСоздание счетов — POST /v1/invoices
payouts:writeВыплаты — POST /v1/payouts. Держите такой ключ только на бэкенде казны
readЧтение: статус счёта и выплаты, баланс, SSE-поток событий

Новый проект получает все три скоупа. 403 отдаётся и при непройденном IP-allowlist — различайте по полю code: FORBIDDEN_SCOPE против IP_NOT_ALLOWED.

Машиночитаемая спецификация всех эндпоинтов — /openapi.json.

03 — Приём

Создать счёт и получить адрес
POST /v1/invoices

Вызывается, когда клиент хочет внести монеты. В ответе — свежий адрес для клиента и invoiceId, который сохраняете у себя вместе со своим externalRef (id заказа в вашей системе).

⚠️ В USDT (TRC20) счета выставляются на ПОСТОЯННЫЙ адрес, а не на свежий. В TRON адрес — это отдельный аккаунт сети: адрес, на который приходили только токены, не активирован, и первая трата с него стоит 8–16 TRX. Поэтому одновременные счета на одном адресе различаются суммой — вторая сотня станет 100.01, третья 100.02. Показывайте плательщику amount из ответа, а не то, что просили: сумма и есть признак, по которому платёж сопоставляется со счётом. Сколько вы просили, лежит рядом в requestedAmount. Каким адресам выдавать счета — настраивается галочкой в кабинете и через PATCH /v1/addresses; пока ни один не отмечен, счета идут на hotAddress, тот же, с которого уходят выплаты.

⚠️ В XMR счёт получает подадрес кошелька — штатный для Monero способ развести платежи: в цепи подадреса не выглядят связанными, поэтому переиспользовать их незачем. Для вас это обычная строка адреса. Если рисуете QR сами, ссылка Monero выглядит как monero:АДРЕС?tx_amount=СУММА — параметр называется tx_amount, а не amount; с amount кошелёк плательщика покажет нулевую сумму, и вводить её придётся руками.

POST /v1/invoices
$body = json_encode([
    'coin'        => 'BTC',
    'amount'      => '0.01',
    'externalRef' => 'order-4821',                        // your order id — makes retries safe
    'callbackUrl' => 'https://exchange.example/webhook',  // optional
]);

$ch = curl_init('https://coinrail.net/v1/invoices');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . API_KEY,
        'Content-Type: application/json',
    ],
]);
$invoice = json_decode(curl_exec($ch), true);
// store $invoice['invoiceId'] + $invoice['address'] on your side
import requests

body = {
    'coin': 'BTC',
    'amount': '0.01',
    'externalRef': 'order-4821',                          # your order id — makes retries safe
    'callbackUrl': 'https://exchange.example/webhook',    # optional
}

res = requests.post(
    'https://coinrail.net/v1/invoices',
    json=body,
    headers={'Authorization': f'Bearer {API_KEY}'},
    timeout=15,
)
invoice = res.json()
# store invoice['invoiceId'] + invoice['address'] on your side
const res = await fetch("https://coinrail.net/v1/invoices", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    coin: "BTC",
    amount: "0.01",
    externalRef: "order-4821",                        // your order id — makes retries safe
    callbackUrl: "https://exchange.example/webhook",  // optional
  }),
});

const invoice = await res.json();
// store invoice.invoiceId + invoice.address on your side
HttpClient http = HttpClient.newHttpClient();

String body = """
    { "coin": "BTC",
      "amount": "0.01",
      "externalRef": "order-4821",
      "callbackUrl": "https://exchange.example/webhook" }""";   // callbackUrl is optional

HttpRequest req = HttpRequest.newBuilder()
    .uri(URI.create("https://coinrail.net/v1/invoices"))
    .header("Authorization", "Bearer " + API_KEY)
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
// store invoiceId + address on your side

Необязательные поля запроса

ПолеЗначениеПо умолчанию
externalRefВаш id заявки. Делает создание идемпотентным — см. ниженет
callbackUrlАдрес вебхука для этого счёта. Только сервер-сервер: плательщик его не видит, ссылкой не становитсяwebhookUrl проекта
confirmationsRequiredСколько подтверждений нужно для CONFIRMED. Допустимо 1–100; ноль запрещёнBTC — 2, LTC — 3, USDT — 19, XMR — 10
expiresInSecВремя жизни счёта, 60–604800 секунд3600 (час)
localeЯзык страницы оплаты — подсказка от вас (ru / en)язык проекта
Идемпотентность Повтор с уже использованным externalRef возвращает существующий счёт и игнорирует новую сумму: адрес и amount останутся прежними, ошибки не будет. Ретраить безопасно, но менять цену тем же externalRef — нельзя, нужен новый.
201 Createdответ
{
  "invoiceId": "cmrf99urs0003esm31om7j90e",
  "checkoutToken": "cko_x8KdQ1x0m7j90eSm31oM7j90eSm3",
  "checkoutUrl": "https://coinrail.net/checkout/cko_x8KdQ1x0m7j90eSm31oM7j90eSm3",
  "coin": "BTC",
  "address": "bc1qkmcgn8ucmnuqr45t9f5rs68vm4vx26xv66tp4x",
  "amount": "0.01",
  "amountReceived": "0",
  "status": "PENDING",
  "confirmationsRequired": 2,
  "externalRef": "order-4821",
  "callbackUrl": "https://exchange.example/webhook",
  "expiresAt": "2026-07-11T09:15:02.000Z",
  "createdAt": "2026-07-11T08:15:02.000Z",
  "locale": "ru"
}
Коды ответа 201 — счёт создан. 200 — это повтор: счёт с таким externalRef уже был, вернулся он же. Обрабатывайте оба как успех, но 200 — повод не создавать заказ второй раз. Метки времени отдаются в ISO-8601 с миллисекундами (.000Z) — парсер должен это принимать.

Готовая страница оплаты

В каждом ответе есть checkoutUrl — размещённая у нас страница оплаты: сумма, адрес, QR, обратный отсчёт и live-обновление статуса. Свою страницу писать не нужно — отправьте плательщика по этой ссылке. Доступ по неугадываемому checkoutToken, без вашего API-ключа; секреты проекта и externalRef на ней не светятся. Язык берётся из поля locale, плательщик может переключить его сам.

АдресЧто отдаёт
/checkout/{token}Сама страница оплаты
/checkout/{token}/qr.svgQR-код оплаты в SVG: BIP21, а у XMR — ссылка monero: с tx_amount
/checkout/{token}/statusПубличный статус счёта в JSON
/checkout/{token}/eventsSSE-поток статуса этого счёта

Механика приёма

  • Свежий адрес каждый раз — генерируется из HD-кошелька проекта (уход от статичного адреса = меньше AML-следов).
  • Адрес авто-импортируется в watch-кошелёк ноды: шлёте на него — шлюз видит транзакцию.
  • Подтверждения считаются по минимуму среди депозитов — безопасно при мульти-депозите (не подтвердит против неподтверждённого добора).
  • Пополнить баланс проекта = создать счёт и отправить на его адрес: после подтверждений сумма ложится в баланс (доступна для выплат).

Статусы счёта

СтатусЧто значитДействие
PENDINGСчёт создан, платёж ещё не виден в сетиЖдать
SEENТранзакция в мемпуле, 0 подтверждений«Платёж получен, ждём подтверждений»
CONFIRMEDНабрано нужное число подтвержденийЗачислить средства
UNDERPAIDПришло меньше суммы счёта. Состояние промежуточное, а не конечноеНе зачислять и не закрывать заявку: клиент может доплатить
EXPIREDСрок жизни счёта истёк. Это НЕ значит «денег не будет»Закрыть заявку, но продолжать принимать события по ней
Что считать финалом Статусы, по которым отдают товар, — CONFIRMED и OVERPAID. UNDERPAID финальным НЕ является: как только клиент добирает недостающее, счёт уходит UNDERPAID → SEEN → CONFIRMED. Закрыв заявку на UNDERPAID, вы закроете её посреди оплаты — деньги придут, а заказ уже отменён.
EXPIRED — не «денег нет» Адрес счёта остаётся под наблюдением и после истечения срока. Поздний платёж будет записан, придёт событие invoice.paid с признаком lateAfterExpiry: true, а статус станет EXPIRED_PAID. Автоматически такие деньги никуда не зачисляются: заведите ручную сверку — вернуть клиенту или провести заявку. Игнорировать это событие значит принять платёж и не отдать ни товар, ни деньги.
Переплата Статус OVERPAID означает, что пришло больше запрошенного: счёт подтверждён, но помечен. Рядом отдельно летит событие подтверждения и событие invoice.overpaid. Тип события подтверждения при этом обычный — invoice.confirmed. Зачислять нужно по полю amountReceived, а не по amount: именно там лежит фактически полученная сумма. Разницу возвращаете или зачисляете сами — шлюз в это не вмешивается.

04 — Обновления

Три способа узнать о статусе

Выберите по инфраструктуре. Если у вашего сервиса нет публичного домена — вебхук невозможен, но polling и SSE работают (соединение исходящее от вас).

A. Вебхуки нужен домен

Мы шлём POST на ваш публичный HTTPS-адрес. Real-time, но требует открытый эндпоинт.

B. Polling без домена

Вы сами опрашиваете статус счёта. Просто и надёжно, с задержкой в интервал опроса.

C. SSE-поток без домена

Вы держите исходящее соединение, мы пушим события мгновенно. Real-time без домена.

Как выбрать Все три доступны сразу — берите что удобно, можно комбинировать. Есть публичный HTTPS-домен → вебхуки. Нет домена, но нужен real-time → SSE. Хотите максимально просто → polling. Надёжнее всего: основной канал (вебхук или SSE) + короткий polling как страховка. Все каналы шлют одни и те же типы событий.

A. Вебхуки (нужен публичный домен)

Когда счёт меняет статус, шлюз шлёт POST на ваш callbackUrl. Обязательно проверяйте подпись: заголовок X-Gateway-Signature = sha256=<hex>, HMAC-SHA256 от сырого тела на WEBHOOK_SECRET.

Подписей две В каждой доставке приходят оба заголовка. X-Gateway-Signature-V2 — основной: он привязан ко времени и потому защищает от повторной отправки перехваченного запроса. X-Gateway-Signature — legacy, оставлен ради уже работающих интеграций. Новую интеграцию делайте на v2.

Подпись v2 (рекомендуется)

Заголовок X-Gateway-Signature-V2 имеет вид t=<unix-sec>,v1=<hex>, где hex — это HMAC-SHA256 на WEBHOOK_SECRET от строки "<t>.<rawBody>": значение t, точка, затем байты сырого тела. Проверять нужно и подпись, и время — отвергайте доставку, если t расходится с текущим временем больше чем на 300 секунд. Допуск двусторонний: часы вашего сервера могут спешить.

Проверка подписи v2
function validV2(string $rawBody, ?string $header): bool {
    if ($header === null) return false;
    $t = null; $sigs = [];
    foreach (explode(',', $header) as $part) {                      // t=1752226802,v1=9f86d0…
        $kv = explode('=', trim($part), 2);
        if (count($kv) !== 2) continue;
        if ($kv[0] === 't')  $t = (int) $kv[1];
        if ($kv[0] === 'v1') $sigs[] = $kv[1];                      // may be several after a secret rotation
    }
    // Reject stale deliveries — this is what makes a captured request non-replayable
    if ($sigs === [] || $t === null || abs(time() - $t) > 300) return false;

    $expected = hash_hmac('sha256', $t . '.' . $rawBody, WEBHOOK_SECRET);
    $ok = false;
    foreach ($sigs as $sig) $ok = hash_equals($expected, $sig) || $ok;  // any of them may match
    return $ok;                                                     // constant-time compare
}

// RAW body — read it before any framework parses the request into an array
$raw = file_get_contents('php://input');
if (!validV2($raw, $_SERVER['HTTP_X_GATEWAY_SIGNATURE_V2'] ?? null)) {
    http_response_code(401);
    exit;
}
http_response_code(200);                                            // acknowledge immediately
handleEvent(json_decode($raw, true));                               // crediting must be IDEMPOTENT
import hashlib, hmac, time

def valid_v2(raw_body: bytes, header: str | None) -> bool:
    if not header:
        return False
    t, sigs = None, []
    for part in header.split(','):                                  # t=1752226802,v1=9f86d0…
        k, _, v = part.strip().partition('=')
        if not v:
            continue
        if k == 't':
            t = v
        elif k == 'v1':
            sigs.append(v)                                          # may be several after a secret rotation
    # Reject stale deliveries — this is what makes a captured request non-replayable
    if not t or not sigs or abs(int(time.time()) - int(t)) > 300:
        return False

    signed = f'{t}.'.encode() + raw_body                            # signed string is "<t>.<rawBody>"
    expected = hmac.new(WEBHOOK_SECRET.encode(), signed, hashlib.sha256).hexdigest()
    ok = False
    for sig in sigs:                                                # any of them may match
        ok = hmac.compare_digest(expected, sig) or ok
    return ok                                                       # constant-time compare

# Flask: request.get_data() returns the RAW body — never use request.json here
@app.post('/webhook')
def webhook():
    raw = request.get_data()
    if not valid_v2(raw, request.headers.get('X-Gateway-Signature-V2')):
        return '', 401
    handle_event(json.loads(raw))                                   # crediting must be IDEMPOTENT
    return '', 200                                                  # acknowledge immediately
import crypto from "node:crypto";
import express from "express";

function validV2(rawBody, header) {
  if (!header) return false;
  let t; const sigs = [];
  for (const part of header.split(",")) {                       // t=1752226802,v1=9f86d0…
    const [k, v] = part.trim().split("=", 2);
    if (!v) continue;
    if (k === "t") t = v;
    else if (k === "v1") sigs.push(v);                          // may be several after a secret rotation
  }
  // Reject stale deliveries — this is what makes a captured request non-replayable
  if (!t || !sigs.length || Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false;

  const signed = Buffer.concat([Buffer.from(`${t}.`), rawBody]);  // "<t>.<rawBody>"
  const expected = Buffer.from(crypto.createHmac("sha256", WEBHOOK_SECRET).update(signed).digest("hex"));
  return sigs.reduce((ok, sig) => {                             // any of them may match
    const b = Buffer.from(sig);
    return (b.length === expected.length && crypto.timingSafeEqual(expected, b)) || ok;
  }, false);                                                    // constant-time compare
}

// express.raw — the RAW body; express.json() would destroy the bytes we sign
app.post("/webhook", express.raw({ type: "*/*" }), (req, res) => {
  if (!validV2(req.body, req.get("X-Gateway-Signature-V2"))) return res.sendStatus(401);
  res.sendStatus(200);                                            // acknowledge immediately
  handleEvent(JSON.parse(req.body.toString("utf8")));             // crediting must be IDEMPOTENT
});
boolean validV2(byte[] rawBody, String header) throws Exception {
    if (header == null) return false;                              // missing header => 401, not a crash
    long t = 0; List<String> sigs = new ArrayList<>();
    for (String part : header.split(",")) {                        // t=1752226802,v1=9f86d0…
        String[] kv = part.trim().split("=", 2);
        if (kv.length != 2) continue;
        if (kv[0].equals("t"))  t = Long.parseLong(kv[1]);
        if (kv[0].equals("v1")) sigs.add(kv[1]);                   // may be several after a secret rotation
    }
    // Reject stale deliveries — this is what makes a captured request non-replayable
    if (sigs.isEmpty() || Math.abs(Instant.now().getEpochSecond() - t) > 300) return false;

    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(WEBHOOK_SECRET.getBytes(UTF_8), "HmacSHA256"));
    mac.update((t + ".").getBytes(UTF_8));                          // signed string is "<t>.<rawBody>"
    String hex = HexFormat.of().formatHex(mac.doFinal(rawBody));
    boolean ok = false;
    for (String sig : sigs)                                        // any of them may match
        ok = MessageDigest.isEqual(hex.getBytes(), sig.getBytes()) || ok;
    return ok;                                                     // constant-time compare
}

Смена секрета

Секрет подписи меняется в кабинете, в разделе «Ключи». Сутки после смены каждая доставка подписывается ДВУМЯ секретами — новым и прежним, — поэтому заголовок X-Gateway-Signature-V2 содержит несколько значений v1=:

Заголовок во время смены секрета
X-Gateway-Signature-V2: t=1752226802,v1=9f86d0…,v1=e3b0c4…    // new secret, then the previous one

Доставка считается подлинной, если сошлась ЛЮБАЯ из подписей — так вы успеваете обновить секрет у себя, не потеряв ни одного события. Примеры выше уже перебирают все значения; если ваш код берёт только первое или последнее, поправьте его до смены секрета. Прежний секрет можно отозвать досрочно — той же кнопкой в кабинете, если он утёк и ждать сутки нельзя.

Legacy-подпись Заголовок X-Gateway-Signature списка значений не поддерживает: на время смены прежняя подпись уезжает отдельным заголовком X-Gateway-Signature-Prev.

Доставка и ретраи

  • Успехом считается только ответ 2xx. Любой другой код — провал и ретрай.
  • По редиректам мы не ходим: любой 3xx считается провалом. Указывайте конечный адрес сразу.
  • Таймаут ответа — 5 секунд. Отвечайте немедленно, тяжёлую работу выполняйте уже после ответа.
  • До 8 попыток с экспоненциальной задержкой, стартующей с 30 секунд. После исчерпания доставка помечается EXHAUSTED и больше не повторяется.
  • Повторы штатны, поэтому зачисление обязано быть идемпотентным по X-Gateway-Event-Id.
Требования к адресу вебхука В продакшене callbackUrl обязан быть https. Приватные диапазоны, loopback и хосты, которые не резолвятся, отвергаются. Проверка идёт не только при сохранении URL, но и перед КАЖДОЙ доставкой: если домен начнёт резолвиться внутрь сети, доставка гасится со статусом EXHAUSTED и не ретраится.

Тестовая доставка

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

Тестовая доставка
curl -X POST https://coinrail.net/v1/webhooks/test \
  -H "Authorization: Bearer $API_KEY" \
  -H "content-type: application/json" \
  -d '{"url":"https://shop.example/hooks/gateway"}'      // url is optional: defaults to the project webhookUrl

{ "deliveryId": "clz…", "eventId": "evt_test_V1StGXR8",
  "targetUrl": "https://shop.example/hooks/gateway",
  "status": "DELIVERED", "responseCode": 200, "durationMs": 143 }

Тело события несёт тип webhook.test и признак "test": true. Полей инвойса и суммы в нём нет — зачислять по нему заказ нельзя ни при каких условиях; обработчик, который попробует, обязан споткнуться на разборе. Ретраев у теста нет: одна попытка, результат сразу в ответе. Ответ ручки со статусом FAILED — это отчёт о вашем сервере, а не ошибка запроса.

Приёмник вебхуковcom.sun.net.httpserver · без зависимостей
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/webhook", ex -> {
    byte[] raw = ex.getRequestBody().readAllBytes();               // RAW body — do not parse before verifying
    String sig = ex.getRequestHeaders().getFirst("X-Gateway-Signature-V2");
    if (!validV2(raw, sig)) { ex.sendResponseHeaders(401, -1); ex.close(); return; }
    handleEvent(new String(raw, UTF_8));                           // crediting must be IDEMPOTENT
    ex.sendResponseHeaders(200, -1);                               // acknowledge immediately
    ex.close();
});
server.start();

// validV2 — implementation is in the “Signatures” section above.
// During a secret rotation the header carries SEVERAL v1= values, and the
// previous secret also arrives in X-Gateway-Signature-Prev: accept a delivery
// if it matches ANY of them, otherwise every retry inside the grace window 401s.
// X-Gateway-Signature (without -V2) is legacy and kept only for old integrations.
Важно Считайте HMAC от байтов сырого тела, а не от повторно сериализованного JSON — иначе подпись не сойдётся. Ответьте 200 сразу; зачисление делайте идемпотентным (по X-Gateway-Event-Id или externalRef) — доставка может повториться.
тело вебхука по счётузаголовки: X-Gateway-Signature-V2, -Signature, -Event, -Event-Id
{
  "id": "evt_a1b2c3",
  "type": "invoice.confirmed",
  "createdAt": "2026-07-11T09:20:00.000Z",
  "data": {
    "invoiceId": "cmrf99urs0003esm31om7j90e",
    "externalRef": "order-4821",
    "coin": "BTC",
    "amount": "0.01",
    "amountReceived": "0.01",
    "status": "CONFIRMED",
    "confirmationsRequired": 2,
    "txHash": "4f1e...c7a",
    "confirmations": 2
  }
}

Состав data зависит от события: txHash и confirmations приходят там, где есть транзакция; у поздней оплаты истёкшего счёта добавляется lateAfterExpiry: true. Поле coin — всегда тикер (BTC / LTC / USDT), как и в REST-ответах.

тело вебхука по выплатеpayout.failed
{
  "id": "evt_d4e5f6",
  "type": "payout.failed",
  "createdAt": "2026-07-11T10:02:11.000Z",
  "data": {
    "payoutId": "cmrfa10ab0005esm3v2k8n11x",
    "coin": "BTC",
    "toAddress": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzz...",
    "amount": "9.99",
    "status": "FAILED",
    "txHash": null,
    "failureCode": "SEND_LIMIT_EXCEEDED",
    "failureParams": { "amount": "9.99", "coin": "Bitcoin", "maxPerTx": 0.5 },
    "failureReason": "Amount 9.99 Bitcoin exceeds the per-transaction limit (0.5)"
  }
}

У payout.sent вместо блока failure* приходят txHash и fee — фактически удержанная комиссия.

B. Polling (без домена)

GET /v1/invoices/{invoiceId}

Опрашивайте «висящие» счета, например раз в 10–30 секунд, пока не увидите CONFIRMED/EXPIRED.

GET /v1/invoices/{id}
function pollUntilFinal(string $invoiceId): string {
    while (true) {
        $ch = curl_init('https://coinrail.net/v1/invoices/' . $invoiceId);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . API_KEY],
        ]);
        $body = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);
        // 429 (rate limit) or 5xx — back off; hammering a limited endpoint keeps it limited
        if ($code !== 200) { sleep(30); continue; }
        $status = json_decode($body, true)['status'];
        // Only these two end the poll. UNDERPAID is NOT final: the customer
        // may still top up, and the invoice moves UNDERPAID -> SEEN -> CONFIRMED.
        if ($status === 'CONFIRMED' || $status === 'EXPIRED') return $status;
        sleep(15);                                                   // poll interval
    }
}
import time, requests

def poll_until_final(invoice_id: str) -> str:
    while True:
        res = requests.get(
            f'https://coinrail.net/v1/invoices/{invoice_id}',
            headers={'Authorization': f'Bearer {API_KEY}'},
            timeout=15,
        )
        # 429 (rate limit) or 5xx — back off. Without this res.json()['status'] raises KeyError
        if res.status_code != 200:
            time.sleep(30)
            continue
        status = res.json()['status']
        # Only these two end the poll. UNDERPAID is NOT final: the customer
        # may still top up, and the invoice moves UNDERPAID -> SEEN -> CONFIRMED.
        if status in ('CONFIRMED', 'EXPIRED'):
            return status
        time.sleep(15)                                               # poll interval
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function pollUntilFinal(invoiceId) {
  for (;;) {
    const res = await fetch(`https://coinrail.net/v1/invoices/${invoiceId}`, {
      headers: { "Authorization": `Bearer ${API_KEY}` },
    });
    // 429 (rate limit) or 5xx — back off instead of looping at full speed
    if (!res.ok) { await sleep(30_000); continue; }
    const { status } = await res.json();
    // Only these two end the poll. UNDERPAID is NOT final: the customer
    // may still top up, and the invoice moves UNDERPAID -> SEEN -> CONFIRMED.
    if (status === "CONFIRMED" || status === "EXPIRED") return status;
    await sleep(15_000);                                          // poll interval
  }
}
HttpClient http = HttpClient.newHttpClient();

String pollUntilFinal(String invoiceId) throws Exception {
    while (true) {
        HttpRequest req = HttpRequest.newBuilder()
            .uri(URI.create("https://coinrail.net/v1/invoices/" + invoiceId))
            .header("Authorization", "Bearer " + API_KEY)
            .GET().build();
        HttpResponse<String> res = http.send(req, ofString());
        // 429 (rate limit) or 5xx — back off instead of parsing an error body
        if (res.statusCode() != 200) { Thread.sleep(30_000); continue; }
        String status = parseStatus(res.body());                         // your JSON parser
        // Only these two end the poll. UNDERPAID is NOT final: the customer
        // may still top up, and the invoice moves UNDERPAID -> SEEN -> CONFIRMED.
        if (status.equals("CONFIRMED") || status.equals("EXPIRED")) return status;
        Thread.sleep(15_000);                                            // poll interval
    }
}

C. SSE-поток (без домена, real-time)

GET /v1/events

Держите одно исходящее соединение — мы пушим события вашего проекта строками data: {json} сразу как они происходят. Домен не нужен: соединение инициируете вы. Каждые ~25с приходит комментарий-пинг (строка с :) для keep-alive.

GET /v1/events
// Run as a worker process (CLI), not inside a web request — the connection stays open.
$tail = '';                                                     // carry-over between chunks
while (true) {                                                // auto-reconnect on drop
    $ch = curl_init('https://coinrail.net/v1/events');
    curl_setopt_array($ch, [
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . API_KEY],
        CURLOPT_TIMEOUT    => 0,                              // no timeout: the stream is long-lived
        // Called for every chunk as it arrives — that is what makes it real-time
        // TCP cuts the stream anywhere: a chunk often ends mid-line. Keep the tail
        // until the next chunk — otherwise an event landing on the seam is dropped
        // silently, and the order stays unpaid while the invoice is paid.
        CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (&$tail): int {
            $lines = explode("\n", $tail . $chunk);
            $tail  = array_pop($lines);                       // last piece may be incomplete
            foreach ($lines as $line) {
                if (!str_starts_with($line, 'data:')) continue;   // ": ping" lines keep it alive
                $json = trim(substr($line, 5));
                if ($json !== '' && $json !== '{}') {
                    handleEvent(json_decode($json, true));    // update the order IDEMPOTENTLY
                }
            }
            return strlen($chunk);                            // must return bytes handled
        },
    ]);
    curl_exec($ch);
    curl_close($ch);
    sleep(3);                                                 // network dropped — pause and reconnect
}
import json, time, requests

def run_event_stream():                                       # run in a worker process/thread
    while True:                                               # auto-reconnect on drop
        try:
            with requests.get(
                'https://coinrail.net/v1/events',
                headers={'Authorization': f'Bearer {API_KEY}'},
                stream=True,                                  # keep the connection open
                timeout=(10, None),                           # connect timeout only — never read timeout
            ) as res:
                # 401/402/429 never become a stream — reconnecting at full speed makes it worse
                if res.status_code != 200:
                    time.sleep(30)
                    continue
                for line in res.iter_lines(decode_unicode=True):
                    if not line or not line.startswith('data:'):
                        continue                              # ': ping' lines keep it alive
                    payload = line[5:].strip()
                    if payload and payload != '{}':
                        handle_event(json.loads(payload))     # update the order IDEMPOTENTLY
        except requests.RequestException:
            pass                                              # network failed — fall through to the pause
        time.sleep(3)                                         # pause and reconnect
async function runEventStream() {                     // keep one process on it
  for (;;) {                                          // auto-reconnect on drop
    try {
      const res = await fetch("https://coinrail.net/v1/events", {
        headers: { "Authorization": `Bearer ${API_KEY}` },
      });
      let buf = "";
      // The body is an async iterable — chunks arrive as events happen
      for await (const chunk of res.body) {
        buf += Buffer.from(chunk).toString("utf8");
        const lines = buf.split("\n");
        buf = lines.pop() ?? "";                      // keep the unfinished tail
        for (const line of lines) {
          if (!line.startsWith("data:")) continue;    // ": ping" lines keep it alive
          const json = line.slice(5).trim();
          if (json && json !== "{}") handleEvent(JSON.parse(json));  // IDEMPOTENTLY
        }
      }
    } catch {
      // network failed — pause and reconnect
    }
    await new Promise((r) => setTimeout(r, 3000));
  }
}
void runEventStream() {                       // run in a separate thread
    HttpClient http = HttpClient.newHttpClient();
    while (true) {                            // auto-reconnect on drop
        try {
            HttpRequest req = HttpRequest.newBuilder()
                .uri(URI.create("https://coinrail.net/v1/events"))
                .header("Authorization", "Bearer " + API_KEY)
                .GET().build();
            // ofLines() yields lines as they arrive — the connection stays open
            http.send(req, HttpResponse.BodyHandlers.ofLines()).body()
                .filter(line -> line.startsWith("data:"))
                .map(line -> line.substring(5).trim())
                .filter(json -> !json.isEmpty() && !json.equals("{}"))
                .forEach(this::handleEvent);  // parse, update the order (idempotently)
        } catch (Exception e) {
            // network failed — pause and reconnect
        }
        try { Thread.sleep(3_000); } catch (InterruptedException ie) { return; }
    }
}
строка событияdata:
data: {"type":"invoice.confirmed","invoiceId":"cmrf99urs...","externalRef":"order-4821","coin":"BTC","amount":"0.01","amountReceived":"0.01","status":"CONFIRMED","confirmationsRequired":2,"txHash":"4f1e...c7a","confirmations":2}

Это то же самое содержимое, что в поле data вебхука, плюс type — только развёрнутое в один объект, без конверта id/createdAt.

Лимит соединений Одновременно открытых SSE-потоков на проект — не больше пяти; шестой получит 429. Держите одно соединение на процесс и закрывайте старое при переподключении, иначе после нескольких обрывов лимит выберут «мёртвые» сокеты.
Рекомендация Держите SSE как основной канал, а короткий polling «висящих» счетов — как страховку на случай разрыва соединения. Так ничего не потеряется.

05 — Выплаты

Отправить крипту
POST /v1/payouts

Всегда передавайте idempotencyKey (id вывода в вашей БД) — при повторе шлюз не отправит дважды. feeRate (сат/vByte) обязателен: комиссию считаете вы, шлюз её не вычисляет.

POST /v1/payouts
$body = json_encode([
    'coin'           => 'BTC',
    'toAddress'      => 'bc1q...',
    'amount'         => '0.0009',
    'feeRate'        => 8,                        // sat/vByte — you choose it, we do not
    'idempotencyKey' => 'withdraw-91422',         // your withdrawal id — never sends twice
]);

$ch = curl_init('https://coinrail.net/v1/payouts');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . API_KEY,
        'Content-Type: application/json',
    ],
]);
$payout = json_decode(curl_exec($ch), true);
// 202 Accepted -> { "payoutId": "...", "status": "QUEUED", "feeRate": 8, ... }
import requests

body = {
    'coin': 'BTC',
    'toAddress': 'bc1q...',
    'amount': '0.0009',
    'feeRate': 8,                                 # sat/vByte — you choose it, we do not
    'idempotencyKey': 'withdraw-91422',           # your withdrawal id — never sends twice
}

res = requests.post(
    'https://coinrail.net/v1/payouts',
    json=body,
    headers={'Authorization': f'Bearer {API_KEY}'},
    timeout=15,
)
payout = res.json()
# 202 Accepted -> { "payoutId": "...", "status": "QUEUED", "feeRate": 8, ... }
const res = await fetch("https://coinrail.net/v1/payouts", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    coin: "BTC",
    toAddress: "bc1q...",
    amount: "0.0009",
    feeRate: 8,                              // sat/vByte — you choose it, we do not
    idempotencyKey: "withdraw-91422",        // your withdrawal id — never sends twice
  }),
});

const payout = await res.json();
// 202 Accepted -> { "payoutId": "...", "status": "QUEUED", "feeRate": 8, ... }
String body = """
    { "coin": "BTC",
      "toAddress": "bc1q...",
      "amount": "0.0009",
      "feeRate": 8,
      "idempotencyKey": "withdraw-91422" }""";

HttpRequest req = HttpRequest.newBuilder()
    .uri(URI.create("https://coinrail.net/v1/payouts"))
    .header("Authorization", "Bearer " + API_KEY)
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();
// 202 Accepted -> { "payoutId": "...", "status": "QUEUED", "feeRate": 8, ... }
Коды ответа 202 — выплата принята в очередь (QUEUED), отправка произойдёт асинхронно. 200 — повтор: выплата с таким idempotencyKey уже была, вернулась она же, второй отправки не будет. Ретрай при обрыве связи безопасен именно поэтому.
Сумма и комиссия Получатель получает ровно amount — комиссию сети добавлять не нужно. Она берётся сверху, из баланса проекта (списывается amount + fee). Если баланса не хватает на сумму + комиссию — выплата уходит в FAILED, частичной отправки нет. Фактическая комиссия — в поле fee.

Как выбираются входы (UTXO)

Все адреса проекта — это один кошелёк (общий HD-seed), баланс общий. Вы указываете только сумму и адрес; входы шлюз собирает сам: берёт самые крупные UTXO первыми и добирает по одному, пока не покроет amount + fee, затем останавливается.

СитуацияПоведение
Хватает одного UTXOБерётся только он — минимум входов, ниже комиссия
Одного малоДобирает следующие по величине, автоматически с разных адресов
Суммарно не хватаетВыплата → FAILED

Сдача (входы − выплата − комиссия) возвращается на внутренний change-адрес того же кошелька. Параллельные выплаты с одного кошелька сериализуются блокировкой — двойная трата исключена.

Статусы выплаты

СтатусЧто значит
QUEUEDПринята, ждёт исполнения
BROADCASTINGТранзакция подписана, рассылается в сеть. Если ответ сети оборвался и неизвестно, ушла ли транзакция, выплата остаётся в этом статусе до сверки с сетью (от 30 минут) и затем станет SENT или FAILED. Не повторяйте выплату, пока она в BROADCASTING.
SENTОтправлена, есть txHash
FAILEDНе удалась (см. failureCode) — средства не ушли

Почему выплата не удалась

Ветвитесь по failureCode — это контракт. Поле failureReason — технический английский текст для человека и логов: он не контракт, его формулировки меняются, парсить их нельзя.

failureCodeЧто произошло
NO_WALLETУ проекта нет кошелька этой монеты
PAYOUTS_UNSUPPORTEDВыплаты по этой монете не поддерживаются
SEND_LIMIT_EXCEEDEDПревышен лимит на одну выплату или суточный
BROADCAST_STUCKОтправка зависла и снята по таймауту — требует ручной сверки: транзакция могла всё же уйти
BROADCAST_ERRORСеть не приняла транзакцию: её отверг узел или все обозреватели, через которые шлюз её отправлял. Дословные ответы — в failureParams.detail. Средства не ушли, повтор безопасен
QUEUE_STUCKВыплата провисела в очереди и ни разу не ушла в сеть — деньги не двигались
TX_CONFLICTEDТранзакцию вытеснила конфликтующая; подтвердиться она уже не может — деньги не ушли
NO_SOURCE_ADDRESSТолько USDT: не удалось определить адрес, с которого уйдут деньги
INSUFFICIENT_ON_ADDRESSТолько USDT: средств не хватает. В TRON адреса — отдельные счета, и мы свозим деньги на один, но собрать больше, чем есть, нельзя
RECIPIENT_BLACKLISTEDТолько USDT: адрес получателя заблокирован эмитентом токена. Перевод откатился бы, а комиссия сгорела — проверяем заранее
GAS_UNAVAILABLEТолько USDT: шлюз не смог оплатить комиссию сети. Деньги не двигались, повтор безопасен
FUNDS_TOO_FRAGMENTEDТолько USDT: сумма размазана по слишком многим адресам, чтобы собрать её за одну выплату — отправьте меньшую

Подстановки к коду приходят в failureParams — например { "coin": "Bitcoin" } или { "detail": "min relay fee not met" }. Текст для оператора собирайте из failureCode и этих полей.

Осторожно с coin внутри failureParams В самом событии поле coin — это тикер (BTC), а внутри failureParams та же монета названа по имени сети (Bitcoin). failureParams — это подстановки для текста сообщения, а не контракт: сравнивайте монету по полю coin верхнего уровня.

Лимиты и пороги

ОграничениеBTCLTCUSDTXMR
Максимум на одну выплату0.55020 0005
Максимум за сутки2200100 00020
feeRate, минимум (сат/vByte)12——
Плата за выплату——2.5 / 4 USDT—
  • Суточный лимит считается по календарным суткам и по каждой монете отдельно. Превышение любого из двух — отказ с failureCode: SEND_LIMIT_EXCEEDED.
  • В таблице — значения по умолчанию; действующие для вашего проекта видны в кабинете. Там их можно только ужесточить: значение выше глобального игнорируется.
  • Плата за выплату USDT — фиксированная, не процент: в TRON комиссию сети платит шлюз, а не вы своими монетами. Ставок две, по фактическому пути: 2.5 USDT, когда комиссия закрыта арендованной энергией (типовой случай), и 4 USDT, когда шлюз покупал ресурсы сети напрямую. Плата начисляется ПОСЛЕ успешной отправки, копится и списывается пачкой, поэтому в балансе уже вычтена из confirmed. Обе ставки видны заранее в POST /v1/payouts/estimate. Приём USDT бесплатен.
  • Если запрошенной суммы не хватает ни на одном адресе кошелька, шлюз НЕ свозит средства сам: выплата отказывает с кодом FUNDS_ON_OTHER_ADDRESSES и показывает раскладку — сколько всего и сколько лежит на самом крупном адресе. Собрать деньги в одно место можно отдельной операцией (POST /v1/sweep или кнопка в кабинете): каждая перевозка стоит столько же, сколько выплата, а цену видно заранее. В типовом случае, когда деньги пришли одним платежом, сбор не нужен вовсе. Если связь с сетью оборвалась посреди перевозки, у неё в ответе будет outcome: "unknown" — шлюз сам сверит её с цепью в течение 30 минут (ушла — начислит плату, нет — вернёт остаток адреса); повторять её не нужно, следующие адреса заказа не тронуты.
  • У Monero платы за выплату нет, и feeRate там тоже нет: комиссию сети вы платите своими монетами, её удерживает сам кошелёк при отправке. Зато у XMR есть своё: двенадцать знаков после точки вместо восьми, десять подтверждений и правило сети, по которому каждое поступление и сдача с каждой выплаты лежат неподвижно те же десять блоков (около двадцати минут). Поэтому баланс и «сколько можно отправить прямо сейчас» у XMR расходятся чаще, чем у остальных монет, — это не задержка шлюза.
  • Срочность выплаты Monero задаётся полем priority: 1 медленно, 2 обычно (по умолчанию), 3 быстро, 4 срочно. Это НЕ комиссия: числом её в Monero не задают — сеть объявляет ставку за байт, а кошелёк умножает её на выбранный уровень и размер транзакции. Разница в цене между крайними уровнями примерно двухсоткратная, в скорости — один-два блока, поэтому обычный уровень подходит почти всегда. У остальных монет поле игнорируется.
  • Минимум выплаты — 546 сатоши (dust) у BTC и LTC: меньше сеть такой выход не примет. У USDT dust-порога нет.
  • feeRate ниже минимума по монете отвергается ещё до отправки — узел не принял бы такую транзакцию («min relay fee not met»). Потолок — 2000 сат/vByte, плюс абсолютный предел комиссии одной выплаты: 0.005 BTC и 0.5 LTC. У USDT комиссию сети платит шлюз, поэтому ни feeRate, ни этих пределов там нет.

06 — Справочник

Эндпоинты
МетодПутьНазначение
POST/v1/invoicesСоздать счёт, получить адрес
GET/v1/invoices/{id}Статус счёта
POST/v1/payoutsСоздать выплату (feeRate обязателен для BTC и LTC)
POST/v1/payouts/estimateПроверить выплату и её цену, не двигая деньги
POST/v1/addressesВыдать адрес приёма без счёта (до 10 на монету)
GET/v1/addresses?coin=USDTСписок выданных адресов приёма
GET/v1/payouts/{id}Статус выплаты
GET/v1/balance?coin=BTCБаланс проекта по ОДНОЙ монете (coin обязателен)
GET/v1/eventsSSE-поток событий (без домена)
GET/v1/healthЗдоровье шлюза
—/openapi.jsonOpenAPI-спека

Баланс

Параметр coin — обязательный: эндпоинт отдаёт баланс одной монеты, а не сводку по всем. Вызов без него вернёт 422. Нужно несколько монет — сделайте несколько запросов.

GET /v1/balance?coin=USDTответ
{
  "coin": "USDT",
  "confirmed": "120.50",    // available for payouts, service fee already deducted
  "pending": "0.00",        // seen but not yet confirmed
  "hotAddress": "T..."       // USDT only: send funds for payouts here
}

Поле hotAddress приходит только у USDT. В TRON адреса — отдельные счета, и объединить их в одной транзакции нельзя, поэтому у выплаты есть один источник. Это постоянный адрес кошелька: присылайте на него средства, которыми будете платить. Полученное на адреса счетов шлюз при необходимости свезёт туда сам.

Ограничение частоты

  • 100 запросов в минуту с одного IP на весь API. Превышение — 429.
  • GET /v1/health — 30 запросов в минуту.
  • GET /v1/events — не больше пяти одновременных SSE-соединений на проект.

07 — События

Типы событий

Одни и те же типы приходят и в вебхуках (поле type), и в SSE-потоке.

ТипКогда
invoice.paidПлатёж замечен в сети (SEEN). Он же приходит на позднюю оплату истёкшего счёта — тогда в данных есть lateAfterExpiry: true
invoice.confirmedСчёт подтверждён — можно зачислять
invoice.underpaidПришло меньше суммы. Счёт ещё может дойти до CONFIRMED, если клиент доплатит
invoice.overpaidПришло больше суммы. Приходит вдобавок к invoice.confirmed, статус счёта — CONFIRMED; фактическая сумма в amountReceived
invoice.expiredИстёк срок. Адрес остаётся под наблюдением, поздняя оплата ещё возможна
invoice.reorgОткат сети: подтверждённый платёж потерял подтверждения (двойная трата). Счёт откатывается CONFIRMED → SEEN, поэтому интеграция обязана уметь снять уже сделанное зачисление и придержать товар
payout.sentВыплата отправлена (есть txHash)
payout.confirmedВыплата набрала подтверждения и откатиться уже не может. Между sent и confirmed транзакция ещё может умереть — если отдаёте товар по факту выплаты, ждите это событие
payout.failedВыплата не удалась

08 — Ошибки

Формат и коды

У ошибки два разных идентификатора: HTTP-статус ответа и строковый code в теле. Это не одно и то же — на один HTTP-статус приходится несколько кодов, и ветвиться надо именно по code. Логируйте requestId — по нему оператор найдёт запрос.

403 Forbiddenтело ошибки
{
  "error": {
    "code": "FORBIDDEN_SCOPE",
    "message": "Required scope: payouts:write",
    "reason": "auth.scope.required",
    "params": { "scope": "payouts:write" },
    "requestId": "req_9f2c1a"
  }
}
ПолеНазначение
codeСтабильный машинный код. Единственное, по чему можно ветвить логику
messageАнглийский текст для разработчика и логов. НЕ контракт: формулировка может измениться, не парсить и не показывать клиенту
reasonКлюч сообщения — уточняет code (у одного кода их бывает несколько). По нему рендерите свой текст на своём языке
paramsМашинные подстановки к reason (например scope, coin, maxPerTx). Есть не у каждой ошибки
requestIdИдентификатор запроса для обращения в поддержку
HTTPcodeПричина
401UNAUTHORIZEDНеверный или отсутствующий API-ключ
402SUBSCRIPTION_SUSPENDEDПодписка не оплачена. Заморожены И приём, И выплаты — лечится оплатой в кабинете
403FORBIDDEN_SCOPEУ ключа нет нужного скоупа. Чинится в кабинете, не в фаерволе
403IP_NOT_ALLOWEDIP сервера не в allowlist ключа
404INVOICE_NOT_FOUNDСчёт не найден или принадлежит другому проекту
404PAYOUT_NOT_FOUNDВыплата не найдена или принадлежит другому проекту
409INSUFFICIENT_FUNDSБаланса не хватает на сумму вместе с комиссией
409SEND_LIMIT_EXCEEDEDПревышен лимит выплаты — на одну операцию или суточный
409IDEMPOTENCY_CONFLICTТот же ключ идемпотентности использован с другими параметрами
422VALIDATION_ERRORОшибка валидации тела (напр. нет обязательного feeRate)
429RATE_LIMITEDСлишком много запросов — притормозить
500INTERNALВнутренняя ошибка шлюза — повторить позже, при повторении писать с requestId
402 останавливает всё Неоплаченная подписка замораживает не только выплаты, но и создание счетов: приём денег встанет тоже. Следите за 402 в мониторинге — это не ошибка интеграции, а состояние аккаунта, и чинится оплатой в кабинете.