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 asratesorwithdrawals/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 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
| 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. |
2.2. Where to Put the Public Key — Header or Payload
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
publicKeyfield from the decoded JSON payload, meaning insidedataafter base64 decoding.
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):
publicKey field together with DTO fields:
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 twokeyType 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 headerx-public-key: <hex>.
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 headerx-public-key: <hex>.
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/withdrawalsrequires the Fiat Withdraw permission.- All other Off-Ramp endpoints require only a valid signature with an active, non-revoked key.
2.7. Error Codes
Error responses use a numericcode 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:200 responses return a standard NestJS error envelope:
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. |
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". |
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". |
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. |
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. |
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. |
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. |
5. Withdrawal Status Lifecycle
Merchant-visible status flow for an Off-Ramp transaction:| 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 HTTPPOST 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. |
express::order.* and express::partner.* are internal and are not delivered to merchant webhooks.
6.2. Webhook Payload
6.3. Verifying the Webhook
The signature covers the payload object without thesignature 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
idhas 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
idonly after all three checks pass successfully.