Перейти до основного вмісту

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.

2.1. Обов’язкові headers

HeaderRequiredОпис
Content-TypeyesМає бути application/json.
x-public-keyconditionalPublic key мерчанта у hex форматі. Обов’язковий, якщо public key не передано всередині підписаного payload. Див. розділ 2.2.
X-Request-IdoptionalUUID запиту, згенерований клієнтом для correlation. Відображається у логах.

2.2. Де передавати public key — у header чи payload

Сервер визначає public key у такому порядку:
  1. HTTP header x-public-key без урахування регістру. Якщо header присутній, використовується він, а тіло запиту для public key не перевіряється.
  2. Якщо 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-поля:
{ "amount": 100 }
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 в інтегратора
ED25519Ed25519, asymmetricPrivate key у hex, public key у hex. Public key реєструється на платформі.
LEGACYHMAC-style SHA-256 за privateKey + base64DataСпільний secret, який зберігається як privateKey на обох сторонах.
ED25519 використовується за замовчуванням для нових ключів. LEGACY підтримується для історичних інтеграцій. Ці два алгоритми дають різні підписи для одного й того самого payload, тому використовуйте обраний алгоритм послідовно. Кроки:
  1. Зберіть JSON payload із DTO-полями, див. endpoint sections нижче.
  2. json = JSON.stringify(payload)
  3. data = base64(utf8_bytes(json))
  4. signatureBytes = ed25519.sign(utf8_bytes(data), privateKeyBytes) — входом є саме base64 string, а не початковий JSON і не raw bytes JSON.
  5. signature = base64(signatureBytes)
  6. Надішліть 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)

Кроки:
  1. Зберіть JSON payload.
  2. data = base64(utf8_bytes(JSON.stringify(payload)))
  3. hexDigest = sha256_hex(privateKey + data) — string concatenation secret і base64 payload.
  4. signature = base64(utf8_bytes(hexDigest)) — base64-encode застосовується до рядка hex-символів, а не до raw 32-byte digest.
  5. Надішліть 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:
HTTPcodeMeaning
4002010Поле data відсутнє або не є валідним base64-encoded JSON.
4002011Поле signature відсутнє.
4001110Payload validation failed: поле відсутнє, має неправильний тип або порушує constraint із розділу 4.
4012020Signature did not verify. Майже завжди це помилка у signing code інтегратора: неправильне encoding input, неправильний key або payload змінено після signing.
4012021Public key blocked. Зверніться до підтримки платформи.
4012022Public key expired. Випустіть новий ключ у merchant portal.
4012023Public key inactive: revoked або ще не activated.
4034003На ключі відсутній permission або source IP не входить до allowlist.
4044004Resource not found, наприклад невідомий transactionId або bankLinkCode.
4005001rateId не існує.
4005002rateId більше не пропонується жодним партнером.
4005003recipientData не містить required fields або містить invalid values.
4005004Балансу мерчанта недостатньо для покриття usdtTotal.
4045007Withdrawal transaction not found.
4045008Bank 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:
FieldTypeRequiredNotes
amountnumbernoЗарезервовано для майбутнього 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:
FieldTypeRequiredNotes
fiatCurrencyCodestringyesНаприклад "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:
FieldTypeRequiredNotes
bankCodestringyesНаприклад "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:
FieldTypeRequiredNotes
bankLinkCodestringyesbankLinkCode, повернутий 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:
FieldTypeRequiredValidation
fiatAmountnumberyes>= 0.01.
rateIdstring (UUID)yesid, повернутий endpoint-ом rates.
recipientDataobject<string,string>yesKeys мають збігатися з name, які повернув bank-link. Не має бути порожнім.
externalIdstringnoІдентифікатор на стороні мерчанта. Унікальний у межах мерчанта: 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:
FieldTypeRequiredNotes
statusstringnoCREATED, PROCESSING, COMPLETED, CANCELLED, FAILED.
internalIdstring (UUID)noOff-Ramp transaction id.
externalIdstringnoMerchant-supplied identifier.
pageintnoDefault 1.
pageSizeintnoDefault 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:
FieldTypeRequiredNotes
transactionIdstring (UUID)yestransactionId, повернутий 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
StatusMeaningBalance impact
CREATEDOrder надіслано partner-у; система очікує acceptance.usdtTotal locked.
PROCESSINGPartner прийняв order; система очікує payment confirmation.Lock unchanged.
COMPLETEDPartner позначив payout як paid; USDT списано.locked -= usdtTotal, створено ledger OUT entry.
CANCELLEDУсі partner attempts вичерпано або order скасовано admin-ом.Full refund: locked -= usdtTotal, available += usdtTotal.
FAILEDCritical/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

EventTrigger
express::withdrawal.createdOrder створено та надіслано першому partner-у.
express::withdrawal.processingPartner прийняв order.
express::withdrawal.completedPartner підтвердив fiat payout; USDT consumed.
express::withdrawal.cancelledУсі partner attempts вичерпано або order скасовано admin-ом; full refund applied.
express::withdrawal.failedCritical/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 має:
  1. Перевірити replay protection. Відхиляйте delivery, якщо id вже був оброблений. Зберігайте processed IDs у database; in-memory set буде втрачено після restart.
  2. Перевірити timestamp. Відхиляйте delivery, якщо Math.abs(now - delivered_at) > 16 minutes. Вікно 16 хвилин покриває maximum delivery time з урахуванням retries.
  3. Recompute and compare signature за { id, delivered_at, event }.
  4. Зберегти 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);
}