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.
| Header | Required | Description |
|---|
Content-Type | yes | Must be application/json. |
x-public-key | conditional | Merchant public key in hex format. Required unless the public key is supplied inside the signed payload. See section 2.2. |
X-Request-Id | optional | Client-generated request correlation UUID. Echoed in logs. |
The server resolves the public key in this order:
- 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.
- 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:
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 type | Algorithm | Crypto material held by the integrator |
|---|
ED25519 | Ed25519, asymmetric | Private key in hex, public key in hex. The public key is registered with the platform. |
LEGACY | HMAC-style SHA-256 over privateKey + base64Data | Shared 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.
2.4. Algorithm A — ED25519 (recommended)
Steps:
- Build the JSON payload with DTO fields, see endpoint sections below.
json = JSON.stringify(payload)
data = base64(utf8_bytes(json))
signatureBytes = ed25519.sign(utf8_bytes(data), privateKeyBytes) — the input is the base64 string, not the original JSON or raw JSON bytes.
signature = base64(signatureBytes)
- 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:
- Build the JSON payload.
data = base64(utf8_bytes(JSON.stringify(payload)))
hexDigest = sha256_hex(privateKey + data) — string concatenation of the secret and base64 payload.
signature = base64(utf8_bytes(hexDigest)) — base64 encoding is applied to the string of hex characters, not to the raw 32-byte digest.
- 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:
| HTTP | code | Meaning |
|---|
| 400 | 2010 | The data field is missing or is not valid base64-encoded JSON. |
| 400 | 2011 | The signature field is missing. |
| 400 | 1110 | Payload validation failed: a field is missing, has the wrong type, or violates a constraint from section 4. |
| 401 | 2020 | Signature did not verify. This is almost always a bug in the integrator signing code: wrong input encoding, wrong key, or payload mutation after signing. |
| 401 | 2021 | Public key blocked. Contact platform support. |
| 401 | 2022 | Public key expired. Issue a new key in the merchant portal. |
| 401 | 2023 | Public key inactive: revoked or not yet activated. |
| 403 | 4003 | Permission is missing on the key, or source IP is not in the allowlist. |
| 404 | 4004 | Resource not found, for example an unknown transactionId or bankLinkCode. |
| 400 | 5001 | rateId does not exist. |
| 400 | 5002 | rateId is no longer offered by any partner. |
| 400 | 5003 | recipientData is missing required fields or contains invalid values. |
| 400 | 5004 | Merchant balance is insufficient to cover usdtTotal. |
| 404 | 5007 | Withdrawal transaction not found. |
| 404 | 5008 | Bank 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:
| Field | Type | Required | Notes |
|---|
amount | number | no | Reserved 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:
| Field | Type | Required | Notes |
|---|
fiatCurrencyCode | string | yes | For 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:
| Field | Type | Required | Notes |
|---|
bankCode | string | yes | For 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:
| Field | Type | Required | Notes |
|---|
bankLinkCode | string | yes | The 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:
| Field | Type | Required | Validation |
|---|
fiatAmount | number | yes | >= 0.01. |
rateId | string (UUID) | yes | The id returned by the rates endpoint. |
recipientData | object<string,string> | yes | Keys must match the name values returned by bank-link. Must not be empty. |
externalId | string | no | Merchant-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:
| 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
Details of a single withdrawal.
Decoded payload:
| Field | Type | Required | Notes |
|---|
transactionId | string (UUID) | yes | The 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
| Status | Meaning | Balance impact |
|---|
CREATED | Order is sent to a partner; the system is waiting for acceptance. | usdtTotal is locked. |
PROCESSING | Partner accepted the order; the system is waiting for payment confirmation. | Lock unchanged. |
COMPLETED | Partner marked the payout as paid; USDT is consumed. | locked -= usdtTotal, ledger OUT entry created. |
CANCELLED | All partner attempts are exhausted or the order was cancelled by an admin. | Full refund: locked -= usdtTotal, available += usdtTotal. |
FAILED | Critical 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
| Event | Trigger |
|---|
express::withdrawal.created | Order created and dispatched to the first partner. |
express::withdrawal.processing | Partner accepted the order. |
express::withdrawal.completed | Partner confirmed the fiat payout; USDT consumed. |
express::withdrawal.cancelled | All partner attempts are exhausted or the order was cancelled by an admin; full refund applied. |
express::withdrawal.failed | Critical 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:
- 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.
- Check the timestamp. Reject the delivery if
Math.abs(now - delivered_at) > 16 minutes. The 16-minute window covers maximum delivery time including retries.
- Recompute and compare the signature over
{ id, delivered_at, event }.
- 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);
}