Документация API

Базовый URL /v1. Авторизация — заголовок Authorization: Bearer sk_test_…. Все суммы передаются строками, чтобы не терять точность.

Валюта счёта — одна из: USD, EUR, RUB, KZT, UAH. Криптовалюту выбирает покупатель на странице оплаты; их список — в /v1/currencies.

Быстрый старт за 4 шага

  1. Зарегистрируйтесь и возьмите тестовый ключ в кабинете, раздел «API-ключи».
  2. Создайте счёт — в ответе придёт checkout_url.
  3. Отправьте покупателя по этой ссылке либо откройте виджет на своём сайте.
  4. Дождитесь вебхука invoice.paid и выдайте товар.
# 1. Создать счёт на 100 USD curl -X POST https://coinrail.app/v1/invoices \ -H "Authorization: Bearer sk_test_..." \ -H "Idempotency-Key: order-42" \ -H "Content-Type: application/json" \ -d '{"amount":"100.00","currency":"USD","order_id":"order-42"}' # Ответ { "id": "inv_8Kd2…", "status": "pending", "checkout_url": "https://…/pay/inv_8Kd2…", "expires_at": "2026-08-10T12:15:00.000Z" }

Сумма — строкой, в валюте счёта и с её числом знаков: "100.00". В ответе amount — это фиат, а amount_crypto и amount_received — монеты выбранного актива. Сравнивать полученное нужно с amount_crypto: у BTC-счёта на 100 USD amount_received будет вида "0.0015462746".

Все эндпоинты

ПутьПраво ключаНазначение
POST/v1/invoicesinvoices:writeСоздать счёт
GET/v1/invoicesinvoices:readСписок счетов
GET/v1/invoices/:idinvoices:readСтатус счёта
POST/v1/invoices/:id/cancelinvoices:writeОтменить неоплаченный счёт
GET/v1/ratesinvoices:readОценить сумму в крипте до создания счёта
GET/v1/currenciesПоддерживаемые активы и сети (BTC, ETH, SOL, USDT, USDC)
GET/v1/balancesbalances:readБалансы по активам
POST/v1/payoutspayouts:writeВывести средства
GET/v1/payoutspayouts:readИстория выплат
GET/v1/payout-addressespayouts:readДоверенные адреса
POST/v1/payout-addressespayouts:writeДобавить адрес (период охлаждения)
POST/v1/refundspayouts:writeВернуть покупателю
GET/v1/refundspayouts:readИстория возвратов
GET/v1/webhook_endpointswebhooks:manageСписок эндпоинтов
POST/v1/webhook_endpointswebhooks:manageДобавить эндпоинт, вернёт секрет
DELETE/v1/webhook_endpoints/:idwebhooks:manageУдалить эндпоинт
PATCH/v1/webhook_endpoints/:idwebhooks:manageИзменить подписку, адрес или паузу
GET/v1/eventsevents:readЛента событий — работает и без вебхуков

Виджет на вашем сайте

Покупатель платит, не уходя со страницы. Счёт создаётся на вашем сервере, в кнопку передаётся его id.

<script src="https://coinrail.app/widget.js"></script> <button data-coinrail-invoice="inv_8Kd2…">Оплатить криптовалютой</button>

Или программно, чтобы поймать результат:

CoinRail.open({ invoiceId: 'inv_8Kd2…', onPaid: function (invoice) { location.href = '/thanks'; }, onStatus: function (status) { console.log(status); }, onClose: function (reason) {} });

Виджет не читает вашу страницу и не имеет доступа к её данным; обмен идёт только через postMessage с проверкой источника.

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

СтатусЧто значитВыдавать товар?
pendingЖдём оплату, валюта ещё не выбрана или платёж не пришёлнет
detectedТранзакция замечена, подтверждений пока нетнет
confirmingИдут подтверждения сетинет
partially_paidПришло меньше суммы, срок ещё не истёкнет
paidОплачен полностью и подтверждёнда
paid_lateОплачен после истечения, принят по вашей политикеда
overpaidПрислали больше — заказ оплачен, излишек можно вернутьда
underpaidНедоплата, срок истёкнет, решайте вручную
expiredСрок истёк без оплатынет
canceledОтменён ваминет
quarantinedСредства на комплаенс-проверкенет
failedТехническая ошибканет

Вебхуки

POST на ваш URL при каждой смене статуса. Заголовок gw-signature: t=<unix>,v1=<hmac>, где подпись — HMAC-SHA256 от строки timestamp.тело секретом эндпоинта.

Проверяйте подпись и свежесть таймстемпа, иначе кто угодно сможет прислать вам «оплату». Порядок доставки не гарантирован — при сомнении запросите GET /v1/invoices/:id.

Node.js

const crypto = require('crypto'); const [t, v1] = req.headers['gw-signature'].split(',').map(p => p.split('=')[1]); if (Math.abs(Date.now()/1000 - t) > 300) throw new Error('устаревшая подпись'); const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) throw new Error('подпись неверна');

PHP

[$t, $v1] = array_map(fn($p) => explode('=', $p)[1], explode(',', $_SERVER['HTTP_GW_SIGNATURE'])); $expected = hash_hmac('sha256', $t . '.' . $raw, $secret); if (!hash_equals($expected, $v1)) http_response_code(400);

Секрет подставляется в HMAC целиком, вместе с префиксом whsec_ — ровно так, как он показан при создании эндпоинта.

Python

import hmac, hashlib, time t, v1 = [p.split('=')[1] for p in request.headers['gw-signature'].split(',')] expected = hmac.new(secret.encode(), f"{t}.{raw}".encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, v1): abort(400)

Не дошло — повторяем 8 раз с нарастающей паузой до 12 часов. История доставок с отправленной подписью, телом запроса и ответом вашего сервера — в кабинете, там же ручной повтор.

Можно подписаться не на всё: передайте events при создании эндпоинта, и приходить будут только они. Без этого поля приходит всё.

POST /v1/webhook_endpoints { "url": "https://shop.example.com/hook", "events": ["invoice.paid", "invoice.underpaid"] }

Товар выдавайте по invoice.paid, а не по payment.detected. Второе означает «перевод виден в сети» — подтверждений может ещё не хватать, и в поле status внутри такого события лежит текущий статус счёта, а не смысл самого события. Смысл события — в его type.

Один и тот же URL дважды подключить нельзя: события приходили бы в двух копиях, и заказ, обработанный по доставке, выдался бы дважды. Менять подписку, адрес и паузу существующего эндпоинта можно в кабинете, не пересоздавая его — секрет при этом остаётся прежним.

Лента событий

Если вебхук не дошёл — или вы просто не хотите поднимать приёмник — те же события лежат в ленте. Она не зависит от эндпоинтов: события пишутся, даже если ни один вебхук не подключён.

GET /v1/events?limit=50&type=invoice.paid&after=evt_… { "data": [ { "id": "evt_…", "type": "invoice.paid", "livemode": false, "created_at": "…", "data": { "id": "inv_…", "amount": "100.00" } } ], "has_more": false }

Событие происходит один раз и имеет один id, сколько бы эндпоинтов его ни получили — по нему и дедуплицируйте. after продолжает чтение с места, где вы остановились.

Списки и страницы

Списки отдаются страницами: limit (1–100, по умолчанию 50) и offset. В ответе — total и has_more, так что видно, осталось ли что-то за краем.

GET /v1/invoices?limit=100&offset=100&status=paid&order_id=order-42

Счета фильтруются по status и по вашему order_id — по нему заказ находится, даже если его id у вас потерялся.

Защита от двойного счёта при повторной отправке

В других API это называют идемпотентностью. Речь о простой вещи: ваш запрос ушёл, ответ не дошёл, вы повторяете — и не хотите получить второй счёт на тот же заказ.

Один order_id сам по себе этого не даёт — два запроса с одним order_id создадут два счёта. Уникальность обеспечивает именно заголовок ниже.

Передавайте Idempotency-Key при создании счёта. Повтор с тем же ключом вернёт исходный счёт, а не создаст второй. Тот же ключ с другим телом — ошибка 409 idempotency_conflict. Ключи хранятся сутки, после чего тот же ключ снова создаёт новый счёт — берите его от идентификатора заказа, а не от даты.

Права ключей

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

POST /app/keys { "kind": "secret", "scopes": ["invoices:read", "events:read"] }

Доступны: invoices:read, invoices:write, payouts:read, payouts:write, balances:read, webhooks:manage, events:read. Публичный ключ pk_ можно отдавать в браузер: он читает один счёт по его id и больше ничего.

Проблемные платежи

Покупатель прислал не ту сумму — это не тупик, решение за вами.

Что случилосьСтатусЧто можно сделать
Прислал меньшеunderpaid Принять как оплату (зачислится фактически полученное) или вернуть покупателю
Прислал большеoverpaid Закрыть заказ; излишек остаётся на балансе и возвращается через возврат
Не успелexpiredПродлить счёт — курс пересчитается
Отправитель под санкциямиquarantined Средства заморожены до решения комплаенса — товар пока не отгружайте

Недоплата больше 5% требует подтверждения: первый запрос вернёт 400 confirm_shortfall с точным размером недоплаты в error.details.shortfall_bps, повторите с этим значением в поле confirm_shortfall_bps.

Ошибки

Формат одинаковый для всех ответов:

{ "error": { "type": "invalid_request", "code": "insufficient_funds", "message": "…" } }
КодHTTPЧто делать
missing_api_key / invalid_api_key401Проверьте заголовок и не отозван ли ключ
insufficient_scope403Ключу не хватает права — выпустите с нужным
account_suspended403Аккаунт заблокирован, напишите в поддержку
live_not_available409При выпуске боевого ключа в кабинете: пройдите верификацию
unsupported_currency400Список валют — в /v1/currencies
rate_unavailable503Курс временно недоступен, повторите через минуту
address_not_whitelisted400Добавьте адрес выплаты и дождитесь охлаждения
ip_not_allowed403Ключ привязан к другим адресам — вызывайте с разрешённого сервера
rate_limited429Повторите через Retry-After секунд; сколько осталось — в X-RateLimit-Remaining
request_in_progress409Тот же Idempotency-Key ещё выполняется — повторите через секунду, ответ придёт тот же
idempotency_conflict409Тот же ключ с другим телом — возьмите новый ключ
wrong_mode403Ключ и счёт в разных режимах: боевой счёт — боевым ключом, тестовый — тестовым
live_key_required403Вывод возможен только боевым ключом: тестовые деньги не покидают песочницу
step_up_required403Боевой адрес вывода добавляется в кабинете, где мы просим второй фактор. В самом кабинете тот же код приходит с 401 — там это просьба подтвердить себя, а не запрет
method_not_allowed405Другой метод для этого адреса — подходящие перечислены в заголовке Allow
unsupported_media_type415Отправляйте тело с Content-Type: application/json
invalid_body / invalid_json / forbidden_key400Тело должно быть объектом JSON; __proto__ и подобные поля не принимаются
endpoint_exists409Такой URL уже подключён — иначе события приходили бы дважды
amount_too_small400Сумма меньше минимальной для выбранного актива
invalid_amount400Сумма не число, отрицательна или с большим числом знаков, чем есть у валюты
unknown_cursor400after в ленте событий должен быть id события из этой же ленты
public_key_not_allowed403pk_ читает счёт по id и курс; всё остальное — секретным ключом

Безопасность ключа

Ключ — это доступ к деньгам. Держите его только на сервере: из браузера или мобильного приложения его видно любому. При выпуске боевого ключа можно указать IP-адреса вашего сервера — тогда утёкший ключ бесполезен откуда-либо ещё, а запрос с чужого адреса вернёт ip_not_allowed.

Ключ передаётся заголовком Authorization: Bearer sk_… либо X-Api-Key: sk_… — работают оба.

Ключ показывается один раз. Потеряли — отзовите и выпустите новый; скомпрометированный ключ отзывайте немедленно, отзыв действует сразу.

Тестовый режим

Ключи sk_test_… работают сразу после регистрации. Тестовый счёт выставляет заведомо неоплачиваемый адрес вида sandbox-… — реальные монеты на него отправить нельзя. Оплату можно сымитировать кнопкой на странице оплаты или запросом:

curl -X POST https://coinrail.app/checkout/inv_8Kd2…/simulate \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"scenario":"paid"}' # paid | underpaid | overpaid | expired

Ключ обязателен: иначе любой, кому попала ссылка на счёт, мог бы отправить в вашу интеграцию событие об оплате.

Валюту оплаты счёт получает на странице оплаты. Если вы имитируете платёж запросом, выберите её сами — иначе simulate ответит no_option:

curl -X POST https://coinrail.app/checkout/inv_8Kd2…/select -H "Content-Type: application/json" -d '{"asset":"USDT","network":"tron"}'

Что в песочнице работает не так:

  • Возврат покупателю — да: тестовым ключом по тестовому счёту.
  • Вывод средств — нет: тестовые деньги не покидают песочницу, /v1/payouts ответит live_key_required. Отладить можно только обработку ошибок.
  • Адрес вывода тестовым ключом добавляется, но годится только для песочницы. Боевой адрес добавляется в кабинете, где мы просим второй фактор.

Тестовые деньги учитываются отдельно и не выводятся.