Skip to main content

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 — Integrator API Reference

1. Conventions

  • Base URL: provided by the platform.
  • All Off-Ramp endpoints accept and return application/json.
  • All authenticated endpoints use POST, including read-only operations such as rates or withdrawals/list, because the request body must carry a signed envelope.
  • Numeric monetary values are returned as strings to preserve decimal precision, for example "25.18" or "39.7059".
  • Timestamps are ISO-8601 UTC strings, for example "2026-05-04T10:00:00.000Z".

2. Authentication and Request Signing

Every authenticated request must be sent as a signed envelope:
{
  "data": "<base64(JSON payload)>",
  "signature": "<base64(signature of the base64 data string)>"
}
data is the base64 representation of the JSON payload. signature is the base64 representation of the cryptographic signature computed over the base64 data string. The exact algorithm depends on the API key type, see section 2.3.

2.1. Required Headers

HeaderRequiredDescription
Content-TypeyesMust be application/json.
x-public-keyconditionalMerchant public key in hex format. Required unless the public key is supplied inside the signed payload. See section 2.2.
X-Request-IdoptionalClient-generated request correlation UUID. Echoed in logs.

2.2. Where to Put the Public Key — Header or Payload

The server resolves the public key in this order:
  1. HTTP header x-public-key, case-insensitive. If the header is present, this value is used and the body is not consulted for the public key.
  2. Otherwise, the publicKey field from the decoded JSON payload, meaning inside data after base64 decoding.
If the public key is not found in either place, the request is rejected with HTTP 401 Missing public key. Both placements are valid. Pick one option per request. Passing the public key in the header is the recommended default: it keeps the signed payload focused on business fields and makes log correlation easier. Header form (recommended):
POST /merchant/api/v1/express/rates
Content-Type: application/json
x-public-key: 5a3c...hex

{ "data": "<base64(JSON)>", "signature": "<base64>" }
The decoded payload contains only DTO fields:
{ "amount": 100 }
Payload form (header omitted):
POST /merchant/api/v1/express/rates
Content-Type: application/json

{ "data": "<base64(JSON with publicKey)>", "signature": "<base64>" }
The decoded payload contains the publicKey field together with DTO fields:
{ "amount": 100, "publicKey": "5a3c...hex" }
The signature is computed over the same base64 string that the server reads. Therefore, publicKey must be added to the JSON before encoding and signing. Adding publicKey after signing breaks verification.

2.3. Key Types

A merchant API key has one of two keyType values. The server chooses the verification algorithm from the merchant record linked to the public key. The integrator does not pass the key type explicitly.
Key typeAlgorithmCrypto material held by the integrator
ED25519Ed25519, asymmetricPrivate key in hex, public key in hex. The public key is registered with the platform.
LEGACYHMAC-style SHA-256 over privateKey + base64DataShared secret stored as privateKey on both sides.
ED25519 is the default for new keys. LEGACY is supported for historical integrations. The two algorithms produce different signatures for the same payload, so use the selected algorithm consistently. Steps:
  1. Build the JSON payload with DTO fields, see endpoint sections below.
  2. json = JSON.stringify(payload)
  3. data = base64(utf8_bytes(json))
  4. signatureBytes = ed25519.sign(utf8_bytes(data), privateKeyBytes) — the input is the base64 string, not the original JSON or raw JSON bytes.
  5. signature = base64(signatureBytes)
  6. Send POST { data, signature } with 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)

Steps:
  1. Build the JSON payload.
  2. data = base64(utf8_bytes(JSON.stringify(payload)))
  3. hexDigest = sha256_hex(privateKey + data) — string concatenation of the secret and base64 payload.
  4. signature = base64(utf8_bytes(hexDigest)) — base64 encoding is applied to the string of hex characters, not to the raw 32-byte digest.
  5. Send POST { data, signature } with 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

The API key issued in the merchant portal contains a list of permissions. Off-Ramp uses one of them:
  • POST /merchant/api/v1/express/withdrawals requires the Fiat Withdraw permission.
  • All other Off-Ramp endpoints require only a valid signature with an active, non-revoked key.
If an IP allowlist is configured for the key, requests from any other IP address are rejected.

2.7. Error Codes

Error responses use a numeric code field. The most relevant codes for the authentication and validation layer are:
HTTPcodeMeaning
4002010The data field is missing or is not valid base64-encoded JSON.
4002011The signature field is missing.
4001110Payload validation failed: a field is missing, has the wrong type, or violates a constraint from section 4.
4012020Signature did not verify. This is almost always a bug in the integrator signing code: wrong input encoding, wrong key, or payload mutation after signing.
4012021Public key blocked. Contact platform support.
4012022Public key expired. Issue a new key in the merchant portal.
4012023Public key inactive: revoked or not yet activated.
4034003Permission is missing on the key, or source IP is not in the allowlist.
4044004Resource not found, for example an unknown transactionId or bankLinkCode.
4005001rateId does not exist.
4005002rateId is no longer offered by any partner.
4005003recipientData is missing required fields or contains invalid values.
4005004Merchant balance is insufficient to cover usdtTotal.
4045007Withdrawal transaction not found.
4045008Bank link not found.

3. Request / Response Envelope

Every response from authenticated endpoints is wrapped:
{
  "code": 200,
  "data": { /* endpoint-specific payload */ }
}
Non-200 responses return a standard NestJS error envelope:
{
  "statusCode": 400,
  "code": "VALIDATION_FAILED",
  "message": "fiatAmount must not be less than 0.01"
}

4. Endpoint Reference

All endpoints below use the same authentication model: signed { data, signature } envelope, x-public-key header, and Content-Type: application/json. Only POST /merchant/api/v1/express/withdrawals additionally requires the Fiat Withdraw permission and is checked against the API key IP allowlist.

4.1. POST /merchant/api/v1/express/rates

Returns the best available merchant-facing rates per bank link. Decoded payload:
FieldTypeRequiredNotes
amountnumbernoReserved for future amount-aware rate selection. May be omitted.
Response:
{
  "code": 200,
  "data": {
    "items": [
      {
        "id": "5e2f...-rate-id",
        "bankLinkCode": "PRIVAT_UAH",
        "bankName": "PrivatBank",
        "fiatCurrencyCode": "UAH",
        "fiatCurrencyLogoUrl": "https://.../uah.png",
        "rate": "39.7059"
      }
    ]
  }
}
id is the rateId that must be passed to withdrawals when creating an order. rate is the fiat amount per 1 USDT. For a given fiatAmount, the merchant will be charged fiatAmount / rate USDT. This amount is returned as usdtTotal from withdrawals.

4.2. POST /merchant/api/v1/express/banks

Returns the list of banks supported for the specified fiat currency. Decoded payload:
FieldTypeRequiredNotes
fiatCurrencyCodestringyesFor example "UAH".
Response:
{
  "code": 200,
  "data": {
    "items": [
      { "code": "PRIVAT", "name": "PrivatBank", "logoUrl": "https://.../privat.png" }
    ]
  }
}

4.3. POST /merchant/api/v1/express/currencies

Returns the list of fiat currencies supported by the specified bank. Decoded payload:
FieldTypeRequiredNotes
bankCodestringyesFor example "PRIVAT".
Response:
{
  "code": 200,
  "data": {
    "items": [
      { "code": "UAH", "name": "Ukrainian Hryvnia", "logoUrl": "https://.../uah.png" }
    ]
  }
}

4.4. POST /merchant/api/v1/express/bank-link

Returns the recipient-data field schema required for an order against a bank link. Use this endpoint before withdrawals to understand which fields, for example card number or phone, must be collected from the end user. Decoded payload:
FieldTypeRequiredNotes
bankLinkCodestringyesThe bankLinkCode returned by the rates endpoint.
Response:
{
  "code": 200,
  "data": {
    "fields": [
      { "name": "card_number", "label": "Card number", "fieldType": "MASKED", "isRequired": true },
      { "name": "phone",       "label": "Phone",       "fieldType": "TEXT",   "isRequired": false }
    ]
  }
}
fieldType can be TEXT, NUMBER, or MASKED. The name of each field is the key the integrator must use in recipientData when calling withdrawals.

4.5. POST /merchant/api/v1/express/withdrawals

Creates a fiat withdrawal. The system locks USDT on the merchant balance and dispatches the order to a P2P partner. Required permission: Fiat Withdraw. The endpoint is checked against the API key IP allowlist. Decoded payload:
FieldTypeRequiredValidation
fiatAmountnumberyes>= 0.01.
rateIdstring (UUID)yesThe id returned by the rates endpoint.
recipientDataobject<string,string>yesKeys must match the name values returned by bank-link. Must not be empty.
externalIdstringnoMerchant-side identifier. Unique per merchant: duplicate values cause an error. Useful for 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"
  }
}
At this moment the merchant balance changes: available -= usdtTotal, locked += usdtTotal. Funds are released only when the transaction reaches COMPLETED (consumed) or CANCELLED (refunded). The full balance flow is described in section 5.

4.6. POST /merchant/api/v1/express/withdrawals/list

Paginated list of the merchant’s 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

Details of a single withdrawal. Decoded payload:
FieldTypeRequiredNotes
transactionIdstring (UUID)yesThe transactionId returned by withdrawals or webhook payloads.
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 for an Off-Ramp transaction:
CREATED ─┬──► PROCESSING ──► COMPLETED
         └──► CANCELLED   ◄── PROCESSING (no eligible partners after retries)
              FAILED   ◄── critical system error
StatusMeaningBalance impact
CREATEDOrder is sent to a partner; the system is waiting for acceptance.usdtTotal is locked.
PROCESSINGPartner accepted the order; the system is waiting for payment confirmation.Lock unchanged.
COMPLETEDPartner marked the payout as paid; USDT is consumed.locked -= usdtTotal, ledger OUT entry created.
CANCELLEDAll partner attempts are exhausted or the order was cancelled by an admin.Full refund: locked -= usdtTotal, available += usdtTotal.
FAILEDCritical or unrecoverable error.Operational follow-up; balance is reconciled manually or by replay.
usdtTotal and exchangeRate returned by withdrawals are fixed when the transaction is created and do not change during the transaction lifecycle.

6. Outbound Webhooks

The platform notifies the merchant about status changes by sending HTTP POST requests to the merchant-configured webhook URL. Delivery is asynchronous and retried on failure.

6.1. Event Types

EventTrigger
express::withdrawal.createdOrder created and dispatched to the first partner.
express::withdrawal.processingPartner accepted the order.
express::withdrawal.completedPartner confirmed the fiat payout; USDT consumed.
express::withdrawal.cancelledAll partner attempts are exhausted or the order was cancelled by an admin; full refund applied.
express::withdrawal.failedCritical or unrecoverable error.
Events express::order.* and express::partner.* are internal and are not delivered to 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

The signature covers the payload object without the signature field, meaning { id, delivered_at, event }, and is produced with the same algorithm as the request signature for the merchant key, see section 2.3. A correct verification implementation must:
  1. Check replay protection. Reject the delivery if id has already been processed. Store processed IDs in a database; an in-memory set is lost after restart.
  2. Check the timestamp. Reject the delivery if Math.abs(now - delivered_at) > 16 minutes. The 16-minute window covers maximum delivery time including retries.
  3. Recompute and compare the signature over { id, delivered_at, event }.
  4. Store id only after all three checks pass successfully.
The handler must be idempotent: the same delivery may arrive more than once during 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);
}