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
Примеры ниже переключаются между PHP, Python, Node и Java: в каждом запрос собирается средствами самого языка.
02 — Доступ
Каждый запрос подписывается заголовком Authorization: Bearer <API_KEY>. Ключ хранится только на сервере вашего сервиса — не на фронте, не в репозитории. Ключ можно дополнительно ограничить по IP вашего сервера: список задаётся в кабинете и по умолчанию пуст, то есть ограничения нет.
API_KEY и WEBHOOK_SECRET вы получаете в кабинете при регистрации и видите ровно один раз. В этом документе — плейсхолдеры. Храните в переменных окружения или секрет-менеджере.
- API_KEY
- gw_live_••••••••• (на проде; вне продакшена префикс gw_test_)
- WEBHOOK_SECRET
- •••••••••• (нужен только для проверки подписи вебхуков)
Зарегистрируйтесь на /portal или через бота @coinrail_bot — API-ключ выдаётся сразу, без ожидания оператора.
- Регистрация на /portal или в боте @coinrail_bot заводит проект и сразу выдаёт
API_KEYиWEBHOOK_SECRET. Оператор в этом не участвует. - Тем же входом открывается кабинет мерчанта /portal: логин, кодовую фразу и PIN — вход без почты. В кабинете видны балансы, инвойсы, выплаты и префикс ключа.
- Ключ показывается единожды — сразу сохраните его в секрет-менеджер или переменные окружения. Полный ключ нигде не хранится в открытом виде и повторно не показывается.
- Потеря или компрометация ключа → ротация в кабинете под PIN: старый ключ отзывается немедленно, новый показывается один раз.
- Там же, под 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 — Приём
Вызывается, когда клиент хочет внести монеты. В ответе — свежий адрес для клиента и 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 кошелёк плательщика покажет нулевую сумму, и вводить её придётся руками.
$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 — нельзя, нужен новый.{
"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.svg | QR-код оплаты в SVG: BIP21, а у XMR — ссылка monero: с tx_amount |
| /checkout/{token}/status | Публичный статус счёта в JSON |
| /checkout/{token}/events | SSE-поток статуса этого счёта |
Механика приёма
- Свежий адрес каждый раз — генерируется из HD-кошелька проекта (уход от статичного адреса = меньше AML-следов).
- Адрес авто-импортируется в watch-кошелёк ноды: шлёте на него — шлюз видит транзакцию.
- Подтверждения считаются по минимуму среди депозитов — безопасно при мульти-депозите (не подтвердит против неподтверждённого добора).
- Пополнить баланс проекта = создать счёт и отправить на его адрес: после подтверждений сумма ложится в баланс (доступна для выплат).
Статусы счёта
| Статус | Что значит | Действие |
|---|---|---|
| PENDING | Счёт создан, платёж ещё не виден в сети | Ждать |
| SEEN | Транзакция в мемпуле, 0 подтверждений | «Платёж получен, ждём подтверждений» |
| CONFIRMED | Набрано нужное число подтверждений | Зачислить средства |
| UNDERPAID | Пришло меньше суммы счёта. Состояние промежуточное, а не конечное | Не зачислять и не закрывать заявку: клиент может доплатить |
| EXPIRED | Срок жизни счёта истёк. Это НЕ значит «денег не будет» | Закрыть заявку, но продолжать принимать события по ней |
CONFIRMED и OVERPAID. UNDERPAID финальным НЕ является: как только клиент добирает недостающее, счёт уходит UNDERPAID → SEEN → CONFIRMED. Закрыв заявку на UNDERPAID, вы закроете её посреди оплаты — деньги придут, а заказ уже отменён.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 без домена.
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 секунд. Допуск двусторонний: часы вашего сервера могут спешить.
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Доставка считается подлинной, если сошлась ЛЮБАЯ из подписей — так вы успеваете обновить секрет у себя, не потеряв ни одного события. Примеры выше уже перебирают все значения; если ваш код берёт только первое или последнее, поправьте его до смены секрета. Прежний секрет можно отозвать досрочно — той же кнопкой в кабинете, если он утёк и ждать сутки нельзя.
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 — это отчёт о вашем сервере, а не ошибка запроса.
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.
200 сразу; зачисление делайте идемпотентным (по X-Gateway-Event-Id или externalRef) — доставка может повториться.{
"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-ответах.
{
"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 (без домена)
Опрашивайте «висящие» счета, например раз в 10–30 секунд, пока не увидите CONFIRMED/EXPIRED.
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)
Держите одно исходящее соединение — мы пушим события вашего проекта строками data: {json} сразу как они происходят. Домен не нужен: соединение инициируете вы. Каждые ~25с приходит комментарий-пинг (строка с :) для keep-alive.
// 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: {"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.
429. Держите одно соединение на процесс и закрывайте старое при переподключении, иначе после нескольких обрывов лимит выберут «мёртвые» сокеты.05 — Выплаты
Всегда передавайте idempotencyKey (id вывода в вашей БД) — при повторе шлюз не отправит дважды. feeRate (сат/vByte) обязателен: комиссию считаете вы, шлюз её не вычисляет.
$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 — это тикер (BTC), а внутри failureParams та же монета названа по имени сети (Bitcoin). failureParams — это подстановки для текста сообщения, а не контракт: сравнивайте монету по полю coin верхнего уровня.Лимиты и пороги
| Ограничение | BTC | LTC | USDT | XMR |
|---|---|---|---|---|
| Максимум на одну выплату | 0.5 | 50 | 20 000 | 5 |
| Максимум за сутки | 2 | 200 | 100 000 | 20 |
| feeRate, минимум (сат/vByte) | 1 | 2 | — | — |
| Плата за выплату | — | — | 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/events | SSE-поток событий (без домена) |
| GET | /v1/health | Здоровье шлюза |
| — | /openapi.json | OpenAPI-спека |
Баланс
Параметр coin — обязательный: эндпоинт отдаёт баланс одной монеты, а не сводку по всем. Вызов без него вернёт 422. Нужно несколько монет — сделайте несколько запросов.
{
"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 — по нему оператор найдёт запрос.
{
"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 | Идентификатор запроса для обращения в поддержку |
| HTTP | code | Причина |
|---|---|---|
| 401 | UNAUTHORIZED | Неверный или отсутствующий API-ключ |
| 402 | SUBSCRIPTION_SUSPENDED | Подписка не оплачена. Заморожены И приём, И выплаты — лечится оплатой в кабинете |
| 403 | FORBIDDEN_SCOPE | У ключа нет нужного скоупа. Чинится в кабинете, не в фаерволе |
| 403 | IP_NOT_ALLOWED | IP сервера не в allowlist ключа |
| 404 | INVOICE_NOT_FOUND | Счёт не найден или принадлежит другому проекту |
| 404 | PAYOUT_NOT_FOUND | Выплата не найдена или принадлежит другому проекту |
| 409 | INSUFFICIENT_FUNDS | Баланса не хватает на сумму вместе с комиссией |
| 409 | SEND_LIMIT_EXCEEDED | Превышен лимит выплаты — на одну операцию или суточный |
| 409 | IDEMPOTENCY_CONFLICT | Тот же ключ идемпотентности использован с другими параметрами |
| 422 | VALIDATION_ERROR | Ошибка валидации тела (напр. нет обязательного feeRate) |
| 429 | RATE_LIMITED | Слишком много запросов — притормозить |
| 500 | INTERNAL | Внутренняя ошибка шлюза — повторить позже, при повторении писать с requestId |
402 в мониторинге — это не ошибка интеграции, а состояние аккаунта, и чинится оплатой в кабинете.