> ## 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

> Off-Ramp integration: request signing, reference endpoints, fiat withdrawal creation, statuses, and outbound webhooks.

# 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**:

```json theme={null}
{
  "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

| 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:

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):**

```http theme={null}
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:

```json theme={null}
{ "amount": 100 }
```

**Payload form (header omitted):**

```http theme={null}
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:

```json theme={null}
{ "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:

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):**

```ts theme={null}
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):**

```ts theme={null}
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:

```json theme={null}
{
  "code": 200,
  "data": { /* endpoint-specific payload */ }
}
```

Non-`200` responses return a standard NestJS error envelope:

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "fiatAmount": 1000,
  "rateId": "5e2f5b40-1234-4abc-9def-0123456789ab",
  "recipientData": {
    "card_number": "4111111111111111",
    "phone": "+380991234567"
  },
  "externalId": "merchant-order-123"
}
```

**Response:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:

```txt theme={null}
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

```json theme={null}
{
  "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):**

```ts theme={null}
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):**

```ts theme={null}
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);
}
```
