Перейти к основному содержанию

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 для Off-Ramp 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);
}