01 — Basics
Coinrail is a payment processor for receiving and sending crypto. For every deposit it issues you a fresh address, catches the payment and notifies you, the moment the coins arrive and are confirmed. What happens next — crediting your customer, converting currency — is up to your service; coinrail does not interfere. Separately, at your request, coinrail sends crypto out (payouts). An address is never reused.
The gateway holds the wallet keys — they sign the payouts you request over the API, so a normal integration never needs them. But the wallet is yours: in the portal you can take away the seed phrase under your PIN and control the funds independently of the gateway. Every coin has its own seed — each has to be taken separately.
- Base URL
- https://coinrail.net/v1
- OpenAPI
- https://coinrail.net/openapi.json — machine-readable spec: import into Postman, generate an SDK
- Coins
- BTC, LTC, USDT (TRC20), XMR
- Format
- JSON. Amounts as strings ("0.001"), not floats. Fees as integers (sat/vByte)
Client
There are no ready-made packages yet, for any language. It barely affects integration: every example below builds the request with what the language already ships — curl, requests, fetch, HttpClient — with no external dependency at all. Authentication here is a single header, and verifying a webhook signature is five lines of HMAC, shown in the events section.
# 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
The examples below switch between PHP, Python, Node and Java: in each one the request is built with the language's own tools.
02 — Access
Every request is signed with the header Authorization: Bearer <API_KEY>. The key is stored only on the server of your service — not in the frontend, not in the repository. The key can additionally be restricted to your server's IP: the list is set in the portal and is empty by default, meaning no restriction at all.
API_KEY and WEBHOOK_SECRET are issued to you in the portal at sign-up and shown exactly once. This document contains placeholders only. Keep them in environment variables or a secret manager.
- API_KEY
- gw_live_••••••••• (on production; outside production the prefix is gw_test_)
- WEBHOOK_SECRET
- •••••••••• (needed only to verify webhook signatures)
Sign up at /portal or via the Telegram bot @coinrail_bot — your API key is issued instantly, no operator wait.
- Signing up at /portal or via the Telegram bot @coinrail_bot creates the project and immediately issues
API_KEYandWEBHOOK_SECRET. No operator is involved. - The same credentials open the merchant portal /portal: login, passphrase and PIN — sign-in without email. The portal shows balances, invoices, payouts and the prefix of your key.
- The key is shown once only — save it to a secret manager or environment variables right away. The full key is never stored in plaintext anywhere and is never shown again.
- A lost or compromised key → rotation in the portal under your PIN: the old key is revoked immediately, the new one is shown once.
- In the same place, under your PIN, you enable the IP allowlist, set payout limits and take away the wallet seed phrase.
A key carries a set of scopes. If the required one is missing, the request is rejected with 403 and code: "FORBIDDEN_SCOPE". This is a key problem, not a network one: fix the scopes in the portal, not the firewall.
| Scope | What it unlocks |
|---|---|
| invoices:write | Creating invoices — POST /v1/invoices |
| payouts:write | Payouts — POST /v1/payouts. Keep such a key on the treasury backend only |
| read | Reading: invoice and payout status, balance, the SSE event stream |
A new project gets all three scopes. A 403 is also returned when the IP allowlist rejects you — tell them apart by the code field: FORBIDDEN_SCOPE versus IP_NOT_ALLOWED.
The machine-readable specification of every endpoint — /openapi.json.
03 — Receiving
Called when the customer wants to deposit coins. The response carries a fresh address for the customer and an invoiceId, which you store on your side together with your own externalRef (the order id in your system).
⚠️ In USDT (TRC20) invoices are issued to a PERMANENT address, not a fresh one. In TRON an address is a separate network account: one that has only ever received tokens is not activated, and its first spend costs 8–16 TRX. So concurrent invoices on the same address are told apart by amount — the second hundred becomes 100.01, the third 100.02. Show the payer the amount from the response rather than what you asked for: the amount is the very signal that maps a payment to an invoice. What you requested sits next to it in requestedAmount. Which addresses receive invoices is set by a checkbox in the cabinet and via PATCH /v1/addresses; while none is ticked, invoices go to hotAddress, the same address payouts are sent from.
⚠️ In XMR an invoice gets a wallet subaddress — Monero's own way to separate payments: subaddresses do not appear linked in the chain, so there is no reason to reuse them. For you it is an ordinary address string. If you draw the QR yourself, a Monero link looks like monero:ADDRESS?tx_amount=AMOUNT — the parameter is called tx_amount, not amount; with amount the payer's wallet shows a zero amount and they have to type it by hand.
$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
Optional request fields
| Field | Meaning | Default |
|---|---|---|
| externalRef | Your order id. Makes creation idempotent — see below | none |
| callbackUrl | Webhook address for this invoice. Server-to-server only: the payer never sees it and it is never rendered as a link | the project's webhookUrl |
| confirmationsRequired | How many confirmations are needed for CONFIRMED. Allowed 1–100; zero is forbidden | BTC — 2, LTC — 3, USDT — 19, XMR — 10 |
| expiresInSec | Invoice lifetime, 60–604800 seconds | 3600 (one hour) |
| locale | Language of the payment page — a hint from you (ru / en) | the project's language |
externalRef returns the existing invoice and ignores the new amount: the address and amount stay as they were, and no error is raised. Retrying is safe, but changing the price under the same externalRef is not — use a new one.{
"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 — the invoice was created. 200 — this is a repeat: an invoice with that externalRef already existed and was returned as is. Treat both as success, but 200 — is a reason not to create the order a second time. Timestamps are ISO-8601 with milliseconds (.000Z) — your parser must accept that.Hosted payment page
Every response carries a checkoutUrl — a payment page hosted by us: amount, address, QR code, countdown and live status updates. There is no need to build your own — just send the payer to that link. Access is by the unguessable checkoutToken, without your API key; project secrets and externalRef are never exposed on it. The language comes from the field locale, and the payer can switch it themselves.
| Address | What it returns |
|---|---|
| /checkout/{token} | The payment page itself |
| /checkout/{token}/qr.svg | Payment QR code as SVG: BIP21, and for XMR a monero: link with tx_amount |
| /checkout/{token}/status | Public invoice status as JSON |
| /checkout/{token}/events | SSE status stream for this invoice |
How receiving works
- A fresh address every time — generated from the project's HD wallet (moving away from a static address = fewer AML traces).
- The address is auto-imported into the node's watch-only wallet: send to it — the gateway sees the transaction.
- Confirmations are counted as the minimum across deposits — safe with multi-deposits (it will not confirm against an unconfirmed top-up).
- Topping up the project balance = create an invoice and send to its address: after confirmations the amount lands in the balance (available for payouts).
Invoice statuses
| Status | What it means | Action |
|---|---|---|
| PENDING | Invoice created, payment not yet visible in the network | Wait |
| SEEN | Transaction in the mempool, 0 confirmations | “Payment received, waiting for confirmations” |
| CONFIRMED | The required number of confirmations is reached | Credit the funds |
| UNDERPAID | Less than the invoice amount arrived. This is an intermediate state, not a final one | Do not credit and do not close the order: the customer may still top up |
| EXPIRED | The invoice lifetime expired. This does NOT mean “no money will come” | Close the order, but keep accepting events for it |
CONFIRMED and OVERPAID. UNDERPAID is NOT final: as soon as the customer tops up the shortfall, the invoice moves UNDERPAID → SEEN → CONFIRMED. Closing the order on UNDERPAID closes it in the middle of a payment — the money arrives, but the order is already cancelled.invoice.paid event arrives carrying lateAfterExpiry: true, and the status becomes EXPIRED_PAID. Such money is never credited automatically: set up a manual reconciliation — refund the customer or fulfil the order. Ignoring this event means taking a payment and delivering neither the goods nor the money.OVERPAID means more arrived than requested: the invoice is confirmed but flagged. Alongside it you get the confirmation event and invoice.overpaid. The confirmation event type itself stays the usual invoice.confirmed. Credit by the field amountReceived, not by amount: that is where the actually received amount lives. Refunding or crediting the difference is up to you — the gateway does not interfere.04 — Updates
Choose by your infrastructure. If your service has no public domain — a webhook is impossible, but polling and SSE work (the connection goes out from you).
A. Webhooks domain required
We send a POST to your public HTTPS address. Real-time, but it requires an open endpoint.
B. Polling no domain
You poll the invoice status yourself. Simple and reliable, with a delay of one poll interval.
C. SSE stream no domain
You keep an outgoing connection, we push events instantly. Real-time without a domain.
A. Webhooks (public domain required)
When an invoice changes status, the gateway sends a POST to your callbackUrl. Always verify the signature: the header X-Gateway-Signature = sha256=<hex>, HMAC-SHA256 of the raw body keyed with WEBHOOK_SECRET.
X-Gateway-Signature-V2 — the primary one: it is bound to time and therefore protects against a captured request being replayed. X-Gateway-Signature — legacy, kept for integrations that already run. Build new integrations on v2.Signature v2 (recommended)
The header X-Gateway-Signature-V2 has the form t=<unix-sec>,v1=<hex>, where hex is HMAC-SHA256 keyed with WEBHOOK_SECRET over the string "<t>.<rawBody>": the t value, a dot, then the raw body bytes. Verify both the signature and the time — reject the delivery if t differs from the current time by more than 300 seconds. The tolerance is two-sided: your server's clock may run fast.
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
}
Rotating the secret
The signing secret is rotated in the portal, under «Keys». For 24 hours after a rotation every delivery is signed with TWO secrets — the new one and the previous one — so the X-Gateway-Signature-V2 header carries several values, each of them as v1=:
X-Gateway-Signature-V2: t=1752226802,v1=9f86d0…,v1=e3b0c4… // new secret, then the previous oneA delivery is authentic if ANY of the signatures matches — that is what lets you update the secret on your side without losing a single event. The examples above already iterate over every value; if your code takes only the first or the last one, fix it before rotating. The previous secret can also be revoked early — the same button in the portal — if it leaked and waiting a day is not an option.
X-Gateway-Signature header does not support a list of values: during the rotation window the previous signature is sent in a separate header named X-Gateway-Signature-Prev.Delivery and retries
- The only response that counts as success is
2xx. Any other code is a failure and a retry. - We do not follow redirects: any
3xxcounts as a failure. Point us at the final address straight away. - Response timeout — 5 seconds. Reply immediately and do the heavy work after replying.
- Up to 8 attempts with exponential backoff starting at 30 seconds. Once they are exhausted the delivery is marked EXHAUSTED and is never repeated.
- Repeats are normal, so crediting must be idempotent by
X-Gateway-Event-Id.
callbackUrl must be https. Private ranges, loopback and hosts that do not resolve are rejected. The check runs not only when the URL is saved but before EVERY delivery: if the domain starts resolving inside the network, the delivery is killed with status EXHAUSTED and is not retried.Test delivery
You can check your handler without making a payment: the gateway sends a test event to your address and waits for the response. Headers, secret and timeout are the same as on real deliveries — so a signature check that passes here will pass on a real payment too. The «Send a test» button is in the portal, next to the address field.
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 }The event body carries the type webhook.test and the flag "test": true. It has no invoice fields and no amount — never credit an order on it under any circumstances; a handler that tries is meant to break on parsing. The test is not retried: one attempt, result right in the response. A FAILED status in the response is a report about your server, not an error of the request.
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 immediately; make crediting idempotent (by X-Gateway-Event-Id or externalRef) — delivery may be repeated.{
"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
}
}
The contents of data depend on the event: txHash and confirmations come where there is a transaction; a late payment on an expired invoice adds lateAfterExpiry: true. The coin field is always a ticker (BTC / LTC / USDT), exactly as in REST responses.
{
"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)"
}
}
For payout.sent, txHash and fee — the fee actually charged — arrive instead of the failure* block.
B. Polling (no domain)
Poll “hanging” invoices, for example every 10–30 seconds, until you see 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 stream (no domain, real-time)
Keep one outgoing connection — we push your project's events as lines data: {json} the moment they happen. No domain needed: you initiate the connection. Every ~25s a comment-ping arrives (a line with :) for 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}
This is the same content as the data field of a webhook, plus type — only flattened into a single object, without the id/createdAt envelope.
429. Keep one connection per process and close the old one when reconnecting, otherwise after a few drops the limit will be taken up by dead sockets.05 — Payouts
Always pass an idempotencyKey (the withdrawal id in your database) — on a retry the gateway will not send twice. feeRate (sat/vByte) is required: you calculate the fee, the gateway does not.
$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 — the payout is accepted into the queue (QUEUED), the send happens asynchronously. 200 — a repeat: a payout with that idempotencyKey already existed and was returned as is; there will be no second send. That is exactly why retrying after a dropped connection is safe.amount — the network fee does not need to be added. It is taken on top, from the project balance — debited as amount + fee. If the balance does not cover both — the payout goes to FAILED, there is no partial send. The actual fee is in the field fee.How inputs (UTXOs) are selected
All project addresses are one wallet (a shared HD seed), the balance is shared. You specify only the amount and the address; the gateway collects the inputs itself: it takes the largest UTXOs first and adds them one by one until it covers amount + fee, then stops.
| Situation | Behaviour |
|---|---|
| One UTXO is enough | Only that one is taken — fewest inputs, lower fee |
| One is not enough | Adds the next largest ones, automatically across different addresses |
| Not enough in total | Payout → FAILED |
Change (inputs − payout − fee) returns to an internal change address of the same wallet. Concurrent payouts from one wallet are serialised by a lock — double spending is ruled out.
Payout statuses
| Status | What it means |
|---|---|
| QUEUED | Accepted, waiting to be executed |
| BROADCASTING | Transaction signed, being broadcast to the network. If the network's reply was cut off and it is unknown whether the transaction went out, the payout stays in this status until it is reconciled with the network (30 minutes or more), then becomes SENT or FAILED. Do not retry a payout while it is BROADCASTING. |
| SENT | Sent, with a txHash |
| FAILED | Failed (see failureCode) — the funds did not leave |
Why a payout failed
Branch on failureCode — that is the contract. The field failureReason — a technical English string for humans and logs: it is not a contract, its wording changes, and it must not be parsed.
| failureCode | What happened |
|---|---|
| NO_WALLET | The project has no wallet for this coin |
| PAYOUTS_UNSUPPORTED | Payouts are not supported for this coin |
| SEND_LIMIT_EXCEEDED | The per-transaction or daily limit was exceeded |
| BROADCAST_STUCK | The broadcast hung and was cancelled on timeout — needs manual reconciliation: the transaction may still have gone out |
| BROADCAST_ERROR | The network did not accept the transaction: it was rejected by the node or by every explorer the gateway tried. The verbatim answers are in failureParams.detail. Funds did not move; a retry is safe |
| QUEUE_STUCK | The payout sat in the queue and never reached the network — funds did not move |
| TX_CONFLICTED | A conflicting transaction replaced this one; it can never confirm — the funds were not sent |
| NO_SOURCE_ADDRESS | USDT only: could not determine the address the funds would leave from |
| INSUFFICIENT_ON_ADDRESS | USDT only: not enough funds. In TRON addresses are separate accounts; we move funds onto one of them, but we cannot collect more than there is |
| RECIPIENT_BLACKLISTED | USDT only: the recipient address is blacklisted by the token issuer. The transfer would revert and the fee would burn — we check beforehand |
| GAS_UNAVAILABLE | USDT only: the gateway could not pay the network fee. Funds did not move, retrying is safe |
| FUNDS_TOO_FRAGMENTED | USDT only: the amount is spread across too many addresses to collect in one payout — send a smaller amount |
Substitutions for the code arrive in failureParams — for example { "coin": "Bitcoin" } or { "detail": "min relay fee not met" }. Build the operator-facing text from failureCode and those fields.
coin field is a ticker (BTC), while inside failureParams the same coin is named after the network (Bitcoin). failureParams are substitutions for the message text, not a contract: compare the coin using the top-level coin field.Limits and thresholds
| Limit | BTC | LTC | USDT | XMR |
|---|---|---|---|---|
| Maximum per single payout | 0.5 | 50 | 20 000 | 5 |
| Maximum per day | 2 | 200 | 100 000 | 20 |
| feeRate, minimum (sat/vByte) | 1 | 2 | — | — |
| Payout fee | — | — | 2.5 / 4 USDT | — |
- The daily limit is counted over calendar days and per coin separately. Exceeding either one is a refusal with failureCode: SEND_LIMIT_EXCEEDED.
- The table shows the defaults; the values in force for your project are visible in the portal. There they can only be tightened: a value above the global one is ignored.
- The USDT payout fee is flat, not a percentage: in TRON the gateway pays the network fee, not you out of your coins. There are two rates, by the actual path: 2.5 USDT when the fee is covered by rented energy (the typical case), and 4 USDT when the gateway had to buy network resources outright. The fee is charged AFTER a successful send, accumulates and is collected in batches, so the balance already has it deducted from confirmed. Both rates are shown in advance by POST /v1/payouts/estimate. Accepting USDT is free.
- If the requested amount is not available on any single address of the wallet, the gateway does NOT move funds on its own: the payout fails with FUNDS_ON_OTHER_ADDRESSES and reports the breakdown — the total and the largest single balance. Collecting funds onto one address is a separate operation (POST /v1/sweep or a button in the cabinet): each transfer costs the same as a payout, and the price is shown upfront. In the typical case, where the money arrived in one payment, no collection is needed at all. If the connection to the network drops mid-transfer, that transfer comes back with outcome: "unknown" — the gateway checks it against the chain within 30 minutes by itself (went through — the fee is charged, did not — the address balance is restored); do not retry it, the remaining addresses of the order are left untouched.
- Monero has no payout fee and no feeRate: you pay the network fee in your own coins, and the wallet withholds it on send. What XMR does have is its own rules — twelve decimals instead of eight, ten confirmations, and a network rule that locks every incoming payment and every payout's change for those same ten blocks (about twenty minutes). So for XMR the balance and "how much you can send right now" differ more often than for other coins, and that is not a gateway delay.
- Monero payout urgency is set via the priority field: 1 slow, 2 normal (default), 3 fast, 4 urgent. This is NOT a fee: Monero takes no fee number — the network announces a per-byte rate and the wallet multiplies it by the chosen level and the transaction size. Between the extreme levels the price differs about two hundredfold and the speed by one or two blocks, so the normal level fits almost every case. For other coins the field is ignored.
- The minimum payout is 546 satoshi (dust) for BTC and LTC: the network would not accept a smaller output. USDT has no dust threshold.
- A feeRate below the per-coin minimum is rejected before the broadcast — the node would not accept such a transaction (“min relay fee not met”). The ceiling is 2000 sat/vByte, plus an absolute cap on the fee of a single payout: 0.005 BTC and 0.5 LTC. For USDT the gateway pays the network fee, so neither feeRate nor these caps apply.
06 — Reference
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/invoices | Create an invoice, get an address |
| GET | /v1/invoices/{id} | Invoice status |
| POST | /v1/payouts | Create a payout (feeRate required for BTC and LTC) |
| POST | /v1/payouts/estimate | Check a payout and its cost without moving funds |
| POST | /v1/addresses | Issue a receiving address without an invoice (up to 10 per coin) |
| GET | /v1/addresses?coin=USDT | List issued receiving addresses |
| GET | /v1/payouts/{id} | Payout status |
| GET | /v1/balance?coin=BTC | Project balance for ONE coin (coin is required) |
| GET | /v1/events | SSE event stream (no domain) |
| GET | /v1/health | Gateway health |
| — | /openapi.json | OpenAPI spec |
Balance
The coin parameter is required: the endpoint returns the balance of a single coin, not a summary of all of them. Calling it without the parameter returns 422. If you need several coins — make several requests.
{
"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
}
The hotAddress field is returned for USDT only. In TRON addresses are separate accounts and cannot be combined in one transaction, so a payout has a single source. This is a permanent wallet address: send the funds you will pay out to it. Whatever arrives on invoice addresses the gateway will move there itself when needed.
Rate limiting
- 100 requests per minute from one IP across the whole API. Exceeding it — 429.
- GET /v1/health — 30 requests per minute.
- GET /v1/events — no more than five concurrent SSE connections per project.
07 — Events
The same types arrive both in webhooks (the field type) and in the SSE stream.
| Type | When |
|---|---|
| invoice.paid | Payment seen in the network (SEEN). The same event arrives for a late payment on an expired invoice — then the data carries lateAfterExpiry: true |
| invoice.confirmed | Invoice confirmed — safe to credit |
| invoice.underpaid | Less than the amount arrived. The invoice can still reach CONFIRMED if the customer tops up |
| invoice.overpaid | More than the amount arrived. Comes in addition to invoice.confirmed; the invoice status is CONFIRMED; the actual amount is in amountReceived |
| invoice.expired | Expired. The address stays under watch, a late payment is still possible |
| invoice.reorg | Chain reorg: a confirmed payment lost its confirmations (double spend). The invoice rolls back CONFIRMED → SEEN, so the integration must be able to reverse a credit it has already made and hold the goods |
| payout.sent | Payout sent (txHash available) |
| payout.confirmed | Payout reached the confirmation threshold and can no longer be reverted. Between sent and confirmed the transaction can still die — if you release goods on payout, wait for this event |
| payout.failed | Payout failed |
08 — Errors
An error carries two different identifiers: the HTTP status of the response and the string code in the body. They are not the same thing — one HTTP status covers several codes, and the thing to branch on is code. Log the requestId — the operator will find the request by it.
{
"error": {
"code": "FORBIDDEN_SCOPE",
"message": "Required scope: payouts:write",
"reason": "auth.scope.required",
"params": { "scope": "payouts:write" },
"requestId": "req_9f2c1a"
}
}
| Field | Purpose |
|---|---|
| code | A stable machine code. The only thing you may branch logic on |
| message | English text for the developer and the logs. NOT a contract: the wording may change, do not parse it and do not show it to your customer |
| reason | The message key — it refines code (one code can have several). Render your own text in your own language from it |
| params | Machine substitutions for reason (for example scope, coin, maxPerTx). Not present on every error |
| requestId | Request identifier to quote when contacting support |
| HTTP | code | Reason |
|---|---|---|
| 401 | UNAUTHORIZED | Invalid or missing API key |
| 402 | SUBSCRIPTION_SUSPENDED | The subscription is unpaid. BOTH receiving AND payouts are frozen — fixed by paying in the portal |
| 403 | FORBIDDEN_SCOPE | The key lacks the required scope. Fixed in the portal, not in the firewall |
| 403 | IP_NOT_ALLOWED | Server IP is not in the key's allowlist |
| 404 | INVOICE_NOT_FOUND | Invoice not found, or it belongs to another project |
| 404 | PAYOUT_NOT_FOUND | Payout not found, or it belongs to another project |
| 409 | INSUFFICIENT_FUNDS | The balance does not cover the amount together with the fee |
| 409 | SEND_LIMIT_EXCEEDED | A payout limit was exceeded — per operation or daily |
| 409 | IDEMPOTENCY_CONFLICT | The same idempotency key was used with different parameters |
| 422 | VALIDATION_ERROR | Body validation error (e.g. required feeRate missing) |
| 429 | RATE_LIMITED | Too many requests — back off |
| 500 | INTERNAL | Internal gateway error — retry later; if it repeats, write to us with the requestId |
402 in your monitoring — it is not an integration error but an account state, and it is fixed by paying in the portal.