Documentation Index
Fetch the complete documentation index at: https://docs.crypto-cash.world/llms.txt
Use this file to discover all available pages before exploring further.
Off-Ramp — довідник API для інтеграторів
1. Загальні правила
- Base URL надається платформою.
- Усі endpoint-и Off-Ramp приймають і повертають
application/json.
- Усі authenticated endpoint-и використовують метод
POST, включно з read-only операціями на кшталт rates або withdrawals/list, тому що тіло запиту має містити підписаний envelope.
- Грошові числові значення повертаються як рядки, щоб зберегти точність decimal-значень, наприклад
"25.18" або "39.7059".
- Часові мітки повертаються як ISO-8601 UTC рядки, наприклад
"2026-05-04T10:00:00.000Z".
2. Автентифікація та підпис запитів
Кожен authenticated request має бути надісланий як підписаний envelope:
{
"data": "<base64(JSON payload)>",
"signature": "<base64(signature of the base64 data string)>"
}
data — це base64 від JSON payload. signature — це base64 криптографічного підпису, розрахованого за base64-рядком data. Конкретний алгоритм залежить від типу API-ключа, див. розділ 2.3.
| Header | Required | Опис |
|---|
Content-Type | yes | Має бути application/json. |
x-public-key | conditional | Public key мерчанта у hex форматі. Обов’язковий, якщо public key не передано всередині підписаного payload. Див. розділ 2.2. |
X-Request-Id | optional | UUID запиту, згенерований клієнтом для correlation. Відображається у логах. |
Сервер визначає public key у такому порядку:
- HTTP header
x-public-key без урахування регістру. Якщо header присутній, використовується він, а тіло запиту для public key не перевіряється.
- Якщо header відсутній, використовується поле
publicKey із декодованого JSON payload, тобто всередині data після base64 decode.
Якщо public key не знайдено в жодному місці, запит відхиляється з HTTP 401 Missing public key.
Обидва варіанти допустимі. Оберіть один варіант для кожного запиту. Рекомендований варіант — передавати public key у header: так signed payload містить лише бізнес-поля, а correlation у логах стає простішою.
Header form (recommended):
POST /merchant/api/v1/express/rates
Content-Type: application/json
x-public-key: 5a3c...hex
{ "data": "<base64(JSON)>", "signature": "<base64>" }
Decoded payload містить лише DTO-поля:
Payload form (header omitted):
POST /merchant/api/v1/express/rates
Content-Type: application/json
{ "data": "<base64(JSON with publicKey)>", "signature": "<base64>" }
Decoded payload містить поле publicKey разом із DTO-полями:
{ "amount": 100, "publicKey": "5a3c...hex" }
Підпис розраховується за тим самим base64-рядком, який читатиме сервер. Тому publicKey має бути доданий у JSON до encoding і signing. Якщо додати publicKey після підписання, verification не пройде.
2.3. Типи ключів
API key мерчанта має одне з двох значень keyType. Сервер обирає алгоритм verification за merchant record, пов’язаним із public key. Інтегратор не передає key type явно.
| Key type | Алгоритм | Crypto material в інтегратора |
|---|
ED25519 | Ed25519, asymmetric | Private key у hex, public key у hex. Public key реєструється на платформі. |
LEGACY | HMAC-style SHA-256 за privateKey + base64Data | Спільний secret, який зберігається як privateKey на обох сторонах. |
ED25519 використовується за замовчуванням для нових ключів. LEGACY підтримується для історичних інтеграцій. Ці два алгоритми дають різні підписи для одного й того самого payload, тому використовуйте обраний алгоритм послідовно.
2.4. Algorithm A — ED25519 (recommended)
Кроки:
- Зберіть JSON payload із DTO-полями, див. endpoint sections нижче.
json = JSON.stringify(payload)
data = base64(utf8_bytes(json))
signatureBytes = ed25519.sign(utf8_bytes(data), privateKeyBytes) — входом є саме base64 string, а не початковий JSON і не raw bytes JSON.
signature = base64(signatureBytes)
- Надішліть
POST { data, signature } з header x-public-key: <hex>.
Node.js example (ED25519):
import * as ed25519 from '@noble/ed25519';
async function signED25519(privateKeyHex: string, payload: Record<string, unknown>) {
const json = JSON.stringify(payload);
const data = Buffer.from(json, 'utf8').toString('base64');
const signatureBytes = await ed25519.signAsync(
Buffer.from(data, 'utf8'),
Buffer.from(privateKeyHex, 'hex'),
);
const signature = Buffer.from(signatureBytes).toString('base64');
return { data, signature };
}
2.5. Algorithm B — LEGACY (SHA-256 with shared secret)
Кроки:
- Зберіть JSON payload.
data = base64(utf8_bytes(JSON.stringify(payload)))
hexDigest = sha256_hex(privateKey + data) — string concatenation secret і base64 payload.
signature = base64(utf8_bytes(hexDigest)) — base64-encode застосовується до рядка hex-символів, а не до raw 32-byte digest.
- Надішліть
POST { data, signature } з header x-public-key: <hex>.
Node.js example (LEGACY):
import * as crypto from 'node:crypto';
function signLegacy(privateKey: string, payload: Record<string, unknown>) {
const json = JSON.stringify(payload);
const data = Buffer.from(json, 'utf8').toString('base64');
const hexDigest = crypto.createHash('sha256').update(privateKey + data).digest('hex');
const signature = Buffer.from(hexDigest, 'utf8').toString('base64');
return { data, signature };
}
2.6. API key permissions and IP allowlist
API key, випущений у merchant portal, містить список permissions. Off-Ramp використовує один із них:
POST /merchant/api/v1/express/withdrawals потребує увімкнений permission Fiat Withdraw.
- Усі інші Off-Ramp endpoint-и потребують лише коректного підпису активним і не revoked ключем.
Якщо для ключа налаштовано IP allowlist, запити з будь-якої іншої IP address відхиляються.
2.7. Error codes
Error responses використовують числове поле code. Найважливіші коди для authentication і validation layer:
| HTTP | code | Meaning |
|---|
| 400 | 2010 | Поле data відсутнє або не є валідним base64-encoded JSON. |
| 400 | 2011 | Поле signature відсутнє. |
| 400 | 1110 | Payload validation failed: поле відсутнє, має неправильний тип або порушує constraint із розділу 4. |
| 401 | 2020 | Signature did not verify. Майже завжди це помилка у signing code інтегратора: неправильне encoding input, неправильний key або payload змінено після signing. |
| 401 | 2021 | Public key blocked. Зверніться до підтримки платформи. |
| 401 | 2022 | Public key expired. Випустіть новий ключ у merchant portal. |
| 401 | 2023 | Public key inactive: revoked або ще не activated. |
| 403 | 4003 | На ключі відсутній permission або source IP не входить до allowlist. |
| 404 | 4004 | Resource not found, наприклад невідомий transactionId або bankLinkCode. |
| 400 | 5001 | rateId не існує. |
| 400 | 5002 | rateId більше не пропонується жодним партнером. |
| 400 | 5003 | recipientData не містить required fields або містить invalid values. |
| 400 | 5004 | Балансу мерчанта недостатньо для покриття usdtTotal. |
| 404 | 5007 | Withdrawal transaction not found. |
| 404 | 5008 | Bank link not found. |
3. Request / Response Envelope
Кожен response від authenticated endpoint-ів повертається у wrapper:
{
"code": 200,
"data": { /* endpoint-specific payload */ }
}
Non-200 responses повертають стандартний NestJS error envelope:
{
"statusCode": 400,
"code": "VALIDATION_FAILED",
"message": "fiatAmount must not be less than 0.01"
}
4. Endpoint Reference
Усі endpoint-и нижче використовують однакову authentication model: signed { data, signature } envelope, header x-public-key, Content-Type: application/json. Лише POST /merchant/api/v1/express/withdrawals додатково потребує permission Fiat Withdraw і перевіряється за API-key IP allowlist.
4.1. POST /merchant/api/v1/express/rates
Повертає найкращі доступні merchant-facing rates за bank link.
Decoded payload:
| Field | Type | Required | Notes |
|---|
amount | number | no | Зарезервовано для майбутнього amount-aware rate selection. Можна не передавати. |
Response:
{
"code": 200,
"data": {
"items": [
{
"id": "5e2f...-rate-id",
"bankLinkCode": "PRIVAT_UAH",
"bankName": "PrivatBank",
"fiatCurrencyCode": "UAH",
"fiatCurrencyLogoUrl": "https://.../uah.png",
"rate": "39.7059"
}
]
}
}
id — це rateId, який потрібно передати в withdrawals під час створення order. rate — fiat amount за 1 USDT. Для заданого fiatAmount з мерчанта буде списано fiatAmount / rate USDT. Ця сума повертається як usdtTotal із withdrawals.
4.2. POST /merchant/api/v1/express/banks
Повертає список банків, що підтримуються для вказаної fiat currency.
Decoded payload:
| Field | Type | Required | Notes |
|---|
fiatCurrencyCode | string | yes | Наприклад "UAH". |
Response:
{
"code": 200,
"data": {
"items": [
{ "code": "PRIVAT", "name": "PrivatBank", "logoUrl": "https://.../privat.png" }
]
}
}
4.3. POST /merchant/api/v1/express/currencies
Повертає список fiat currencies, що підтримуються вказаним bank.
Decoded payload:
| Field | Type | Required | Notes |
|---|
bankCode | string | yes | Наприклад "PRIVAT". |
Response:
{
"code": 200,
"data": {
"items": [
{ "code": "UAH", "name": "Ukrainian Hryvnia", "logoUrl": "https://.../uah.png" }
]
}
}
4.4. POST /merchant/api/v1/express/bank-link
Повертає schema полів recipient-data, потрібних для order за bank link. Використовуйте цей endpoint перед withdrawals, щоб зрозуміти, які поля, наприклад card number або phone, потрібно зібрати в end-user.
Decoded payload:
| Field | Type | Required | Notes |
|---|
bankLinkCode | string | yes | bankLinkCode, повернутий endpoint-ом rates. |
Response:
{
"code": 200,
"data": {
"fields": [
{ "name": "card_number", "label": "Card number", "fieldType": "MASKED", "isRequired": true },
{ "name": "phone", "label": "Phone", "fieldType": "TEXT", "isRequired": false }
]
}
}
fieldType може бути TEXT, NUMBER або MASKED. name кожного поля — це key, який інтегратор має використати в recipientData під час виклику withdrawals.
4.5. POST /merchant/api/v1/express/withdrawals
Створює fiat withdrawal. Система блокує USDT на балансі мерчанта та надсилає order P2P-партнеру.
Required permission: Fiat Withdraw. Endpoint перевіряється за API-key IP allowlist.
Decoded payload:
| Field | Type | Required | Validation |
|---|
fiatAmount | number | yes | >= 0.01. |
rateId | string (UUID) | yes | id, повернутий endpoint-ом rates. |
recipientData | object<string,string> | yes | Keys мають збігатися з name, які повернув bank-link. Не має бути порожнім. |
externalId | string | no | Ідентифікатор на стороні мерчанта. Унікальний у межах мерчанта: duplicate values викликають помилку. Корисний для idempotent retries. |
Example payload before signing:
{
"fiatAmount": 1000,
"rateId": "5e2f5b40-1234-4abc-9def-0123456789ab",
"recipientData": {
"card_number": "4111111111111111",
"phone": "+380991234567"
},
"externalId": "merchant-order-123"
}
Response:
{
"code": 200,
"data": {
"transactionId": "f1a2b3c4-...-uuid",
"fiatAmount": "1000",
"fiatCurrencyCode": "UAH",
"usdtTotal": "25.18",
"exchangeRate": "39.7059",
"status": "CREATED"
}
}
У цей момент баланс мерчанта змінюється: available -= usdtTotal, locked += usdtTotal. Funds are released only when transaction reaches COMPLETED (consumed) or CANCELLED (refunded). Повний balance flow описано в розділі 5.
4.6. POST /merchant/api/v1/express/withdrawals/list
Paginated list Off-Ramp withdrawals мерчанта.
Decoded payload:
| Field | Type | Required | Notes |
|---|
status | string | no | CREATED, PROCESSING, COMPLETED, CANCELLED, FAILED. |
internalId | string (UUID) | no | Off-Ramp transaction id. |
externalId | string | no | Merchant-supplied identifier. |
page | int | no | Default 1. |
pageSize | int | no | Default 50. |
Response:
{
"code": 200,
"data": {
"items": [ /* array of withdrawal detail objects, see section 4.7 */ ],
"pagination": { "page": 1, "pageSize": 50, "total": 137 }
}
}
4.7. POST /merchant/api/v1/express/withdrawals/detail
Деталі одного withdrawal.
Decoded payload:
| Field | Type | Required | Notes |
|---|
transactionId | string (UUID) | yes | transactionId, повернутий endpoint-ом withdrawals або webhook payload. |
Response:
{
"code": 200,
"data": {
"id": "express-tx-uuid",
"transactionId": "main-tx-uuid",
"status": "COMPLETED",
"fiatAmount": "1000",
"fiatCurrencyCode": "UAH",
"bankName": "PrivatBank",
"exchangeRate": "39.7059",
"usdtTotal": "25.18",
"externalId": "merchant-order-123",
"createdAt": "2026-05-04T09:58:00.000Z",
"completedAt": "2026-05-04T10:00:00.000Z"
}
}
5. Withdrawal Status Lifecycle
Merchant-visible status flow для Express transaction:
CREATED ─┬──► PROCESSING ──► COMPLETED
└──► CANCELLED ◄── PROCESSING (no eligible partners after retries)
FAILED ◄── critical system error
| Status | Meaning | Balance impact |
|---|
CREATED | Order надіслано partner-у; система очікує acceptance. | usdtTotal locked. |
PROCESSING | Partner прийняв order; система очікує payment confirmation. | Lock unchanged. |
COMPLETED | Partner позначив payout як paid; USDT списано. | locked -= usdtTotal, створено ledger OUT entry. |
CANCELLED | Усі partner attempts вичерпано або order скасовано admin-ом. | Full refund: locked -= usdtTotal, available += usdtTotal. |
FAILED | Critical/unrecoverable error. | Operational follow-up; balance reconciled manually or by replay. |
usdtTotal і exchangeRate, повернуті withdrawals, фіксуються під час створення transaction і не змінюються протягом усього lifecycle transaction.
6. Outbound Webhooks
Платформа повідомляє мерчанта про зміни статусу через HTTP POST на webhook URL, налаштований мерчантом. Delivery asynchronous і повторюється при failure.
6.1. Event types
| Event | Trigger |
|---|
express::withdrawal.created | Order створено та надіслано першому partner-у. |
express::withdrawal.processing | Partner прийняв order. |
express::withdrawal.completed | Partner підтвердив fiat payout; USDT consumed. |
express::withdrawal.cancelled | Усі partner attempts вичерпано або order скасовано admin-ом; full refund applied. |
express::withdrawal.failed | Critical/unrecoverable error. |
Events express::order.* і express::partner.* є internal і не доставляються на merchant webhooks.
6.2. Webhook payload
{
"id": "delivery-uuid",
"delivered_at": "2026-05-04T10:00:00.123Z",
"event": {
"event_type": "express::withdrawal.completed",
"timestamp": "2026-05-04T10:00:00.000Z",
"data": {
"id": "express-tx-uuid",
"transactionId": "main-tx-uuid",
"externalId": "merchant-order-123",
"type": "WITHDRAWAL",
"status": "COMPLETED",
"fiatAmount": "1000",
"fiatCurrencyCode": "UAH",
"exchangeRate": "39.7059",
"usdtTotal": "25.18",
"createdAt": "2026-05-04T09:58:00.000Z",
"updatedAt": "2026-05-04T10:00:00.000Z"
}
},
"signature": "<base64 signature>"
}
6.3. Verifying the webhook
Signature covers payload object без поля signature, тобто { id, delivered_at, event }, і створюється тим самим алгоритмом, що request signature для merchant key, див. розділ 2.3.
Коректна verification implementation має:
- Перевірити replay protection. Відхиляйте delivery, якщо
id вже був оброблений. Зберігайте processed IDs у database; in-memory set буде втрачено після restart.
- Перевірити timestamp. Відхиляйте delivery, якщо
Math.abs(now - delivered_at) > 16 minutes. Вікно 16 хвилин покриває maximum delivery time з урахуванням retries.
- Recompute and compare signature за
{ id, delivered_at, event }.
- Зберегти
id лише після успішного проходження всіх трьох перевірок.
Handler має бути idempotent: одна й та сама delivery може прийти більше одного разу під час retries.
Verification (ED25519):
import * as ed25519 from '@noble/ed25519';
interface WebhookPayload {
id: string;
delivered_at: string;
event: { event_type: string; timestamp: string; data: unknown };
signature: string;
}
async function verifyWebhook(
webhook: WebhookPayload,
publicKeyHex: string,
seenIds: Set<string>,
): Promise<void> {
if (seenIds.has(webhook.id)) {
throw new Error('Replay: webhook id already processed');
}
const skewMs = Math.abs(Date.now() - new Date(webhook.delivered_at).getTime());
if (skewMs > 16 * 60 * 1000) {
throw new Error('Webhook outside acceptable time window');
}
const payload = { id: webhook.id, delivered_at: webhook.delivered_at, event: webhook.event };
const data = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
const ok = await ed25519.verifyAsync(
Buffer.from(webhook.signature, 'base64'),
Buffer.from(data, 'utf8'),
Buffer.from(publicKeyHex, 'hex'),
);
if (!ok) {
throw new Error('Invalid webhook signature');
}
seenIds.add(webhook.id);
}
Verification (LEGACY):
import * as crypto from 'node:crypto';
function verifyWebhookLegacy(
webhook: WebhookPayload,
privateKey: string,
seenIds: Set<string>,
): void {
if (seenIds.has(webhook.id)) {
throw new Error('Replay: webhook id already processed');
}
const skewMs = Math.abs(Date.now() - new Date(webhook.delivered_at).getTime());
if (skewMs > 16 * 60 * 1000) {
throw new Error('Webhook outside acceptable time window');
}
const payload = { id: webhook.id, delivered_at: webhook.delivered_at, event: webhook.event };
const data = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
const hexDigest = crypto.createHash('sha256').update(privateKey + data, 'utf8').digest('hex');
const expected = Buffer.from(hexDigest, 'utf8').toString('base64');
if (expected !== webhook.signature) {
throw new Error('Invalid webhook signature');
}
seenIds.add(webhook.id);
}