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

Payment gateway documentation

Coinrail gives you an address to pay to (a fresh one per invoice in BTC and LTC; a permanent one in USDT, where invoices are told apart by amount), detects the payment and reports the status — via webhook, polling or a real-time stream. Below is the full receive-and-payout flow with PHP, Python, Node and Java examples: switch the language right inside the sample.

Customer deposits crypto→ POST /invoices → address→ confirmed event→ you credit the customer

01 — Basics

How the exchange flow works

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

Nothing to install

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.

A typed client from the spec
# 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
Retries are on your side Retrying on dropped connections and on 429/5xx is safe only where it cannot create a second operation: any GET, and any write carrying an idempotency key. A write without a key must never be retried — the payout would go out twice.

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

Authentication

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.

Confidential Live 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)
How to get an API key

Sign up at /portal or via the Telegram bot @coinrail_bot — your API key is issued instantly, no operator wait.

  1. Signing up at /portal or via the Telegram bot @coinrail_bot creates the project and immediately issues API_KEY and WEBHOOK_SECRET. No operator is involved.
  2. 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.
  3. 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.
  4. A lost or compromised key → rotation in the portal under your PIN: the old key is revoked immediately, the new one is shown once.
  5. In the same place, under your PIN, you enable the IP allowlist, set payout limits and take away the wallet seed phrase.
Key scopes

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.

ScopeWhat it unlocks
invoices:writeCreating invoices — POST /v1/invoices
payouts:writePayouts — POST /v1/payouts. Keep such a key on the treasury backend only
readReading: 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

Create an invoice and get an address
POST /v1/invoices

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.

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

Optional request fields

FieldMeaningDefault
externalRefYour order id. Makes creation idempotent — see belownone
callbackUrlWebhook address for this invoice. Server-to-server only: the payer never sees it and it is never rendered as a linkthe project's webhookUrl
confirmationsRequiredHow many confirmations are needed for CONFIRMED. Allowed 1–100; zero is forbiddenBTC — 2, LTC — 3, USDT — 19, XMR — 10
expiresInSecInvoice lifetime, 60–604800 seconds3600 (one hour)
localeLanguage of the payment page — a hint from you (ru / en)the project's language
Idempotency A repeat with an already used 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.
201 Createdresponse
{
  "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"
}
Response codes 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.

AddressWhat it returns
/checkout/{token}The payment page itself
/checkout/{token}/qr.svgPayment QR code as SVG: BIP21, and for XMR a monero: link with tx_amount
/checkout/{token}/statusPublic invoice status as JSON
/checkout/{token}/eventsSSE 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

StatusWhat it meansAction
PENDINGInvoice created, payment not yet visible in the networkWait
SEENTransaction in the mempool, 0 confirmations“Payment received, waiting for confirmations”
CONFIRMEDThe required number of confirmations is reachedCredit the funds
UNDERPAIDLess than the invoice amount arrived. This is an intermediate state, not a final oneDo not credit and do not close the order: the customer may still top up
EXPIREDThe invoice lifetime expired. This does NOT mean “no money will come”Close the order, but keep accepting events for it
What counts as final The statuses you release goods on are 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.
EXPIRED is not “no money” The invoice address stays under watch after the deadline too. A late payment will be recorded and an 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.
Overpayment The status 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

Three ways to learn the status

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.

How to choose All three are available right away — take whichever suits you, they can be combined. You have a public HTTPS domain → webhooks. No domain, but you need real-time → SSE. Want it as simple as possible → polling. Most reliable of all: a main channel (webhook or SSE) + short polling as a backstop. All channels send the same event types.

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.

There are two signatures Every delivery carries both headers. 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.

Verifying the v2 signature
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=:

Header during a secret rotation
X-Gateway-Signature-V2: t=1752226802,v1=9f86d0…,v1=e3b0c4…    // new secret, then the previous one

A 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.

Legacy signature The 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 3xx counts 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.
Requirements for the webhook address In production the 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.

Test delivery
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.

Webhook receivercom.sun.net.httpserver · no dependencies
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.
Important Compute the HMAC over the bytes of the raw body, not over re-serialised JSON — otherwise the signature will not match. Reply 200 immediately; make crediting idempotent (by X-Gateway-Event-Id or externalRef) — delivery may be repeated.
invoice webhook bodyheaders: 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
  }
}

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.

payout webhook bodypayout.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)"
  }
}

For payout.sent, txHash and fee — the fee actually charged — arrive instead of the failure* block.

B. Polling (no domain)

GET /v1/invoices/{invoiceId}

Poll “hanging” invoices, for example every 10–30 seconds, until you see 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 stream (no domain, real-time)

GET /v1/events

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.

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; }
    }
}
event linedata:
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.

Connection limit Concurrently open SSE streams per project — no more than five; the sixth gets a 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.
Recommendation Keep SSE as the main channel, and short polling of hanging invoices as a backstop in case the connection drops. That way nothing gets lost.

05 — Payouts

Send crypto
POST /v1/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.

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, ... }
Response codes 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 and fee The recipient receives exactly 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.

SituationBehaviour
One UTXO is enoughOnly that one is taken — fewest inputs, lower fee
One is not enoughAdds the next largest ones, automatically across different addresses
Not enough in totalPayout → 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

StatusWhat it means
QUEUEDAccepted, waiting to be executed
BROADCASTINGTransaction 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.
SENTSent, with a txHash
FAILEDFailed (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.

failureCodeWhat happened
NO_WALLETThe project has no wallet for this coin
PAYOUTS_UNSUPPORTEDPayouts are not supported for this coin
SEND_LIMIT_EXCEEDEDThe per-transaction or daily limit was exceeded
BROADCAST_STUCKThe broadcast hung and was cancelled on timeout — needs manual reconciliation: the transaction may still have gone out
BROADCAST_ERRORThe 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_STUCKThe payout sat in the queue and never reached the network — funds did not move
TX_CONFLICTEDA conflicting transaction replaced this one; it can never confirm — the funds were not sent
NO_SOURCE_ADDRESSUSDT only: could not determine the address the funds would leave from
INSUFFICIENT_ON_ADDRESSUSDT 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_BLACKLISTEDUSDT only: the recipient address is blacklisted by the token issuer. The transfer would revert and the fee would burn — we check beforehand
GAS_UNAVAILABLEUSDT only: the gateway could not pay the network fee. Funds did not move, retrying is safe
FUNDS_TOO_FRAGMENTEDUSDT 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.

Careful with coin inside failureParams In the event itself the 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

LimitBTCLTCUSDTXMR
Maximum per single payout0.55020 0005
Maximum per day2200100 00020
feeRate, minimum (sat/vByte)12——
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

Endpoints
MethodPathPurpose
POST/v1/invoicesCreate an invoice, get an address
GET/v1/invoices/{id}Invoice status
POST/v1/payoutsCreate a payout (feeRate required for BTC and LTC)
POST/v1/payouts/estimateCheck a payout and its cost without moving funds
POST/v1/addressesIssue a receiving address without an invoice (up to 10 per coin)
GET/v1/addresses?coin=USDTList issued receiving addresses
GET/v1/payouts/{id}Payout status
GET/v1/balance?coin=BTCProject balance for ONE coin (coin is required)
GET/v1/eventsSSE event stream (no domain)
GET/v1/healthGateway health
—/openapi.jsonOpenAPI 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.

GET /v1/balance?coin=USDTresponse
{
  "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

Event types

The same types arrive both in webhooks (the field type) and in the SSE stream.

TypeWhen
invoice.paidPayment seen in the network (SEEN). The same event arrives for a late payment on an expired invoice — then the data carries lateAfterExpiry: true
invoice.confirmedInvoice confirmed — safe to credit
invoice.underpaidLess than the amount arrived. The invoice can still reach CONFIRMED if the customer tops up
invoice.overpaidMore than the amount arrived. Comes in addition to invoice.confirmed; the invoice status is CONFIRMED; the actual amount is in amountReceived
invoice.expiredExpired. The address stays under watch, a late payment is still possible
invoice.reorgChain 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.sentPayout sent (txHash available)
payout.confirmedPayout 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.failedPayout failed

08 — Errors

Format and codes

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.

403 Forbiddenerror body
{
  "error": {
    "code": "FORBIDDEN_SCOPE",
    "message": "Required scope: payouts:write",
    "reason": "auth.scope.required",
    "params": { "scope": "payouts:write" },
    "requestId": "req_9f2c1a"
  }
}
FieldPurpose
codeA stable machine code. The only thing you may branch logic on
messageEnglish 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
reasonThe message key — it refines code (one code can have several). Render your own text in your own language from it
paramsMachine substitutions for reason (for example scope, coin, maxPerTx). Not present on every error
requestIdRequest identifier to quote when contacting support
HTTPcodeReason
401UNAUTHORIZEDInvalid or missing API key
402SUBSCRIPTION_SUSPENDEDThe subscription is unpaid. BOTH receiving AND payouts are frozen — fixed by paying in the portal
403FORBIDDEN_SCOPEThe key lacks the required scope. Fixed in the portal, not in the firewall
403IP_NOT_ALLOWEDServer IP is not in the key's allowlist
404INVOICE_NOT_FOUNDInvoice not found, or it belongs to another project
404PAYOUT_NOT_FOUNDPayout not found, or it belongs to another project
409INSUFFICIENT_FUNDSThe balance does not cover the amount together with the fee
409SEND_LIMIT_EXCEEDEDA payout limit was exceeded — per operation or daily
409IDEMPOTENCY_CONFLICTThe same idempotency key was used with different parameters
422VALIDATION_ERRORBody validation error (e.g. required feeRate missing)
429RATE_LIMITEDToo many requests — back off
500INTERNALInternal gateway error — retry later; if it repeats, write to us with the requestId
402 stops everything An unpaid subscription freezes not only payouts but invoice creation as well: taking money stops too. Watch for 402 in your monitoring — it is not an integration error but an account state, and it is fixed by paying in the portal.