> ## 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: підпис запитів, довідкові endpoint-и, створення фіатного виводу, статуси та вихідні webhook-и.

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

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

| Header         | Required    | Опис                                                                                                                                 |
| -------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `Content-Type` | yes         | Має бути `application/json`.                                                                                                         |
| `x-public-key` | conditional | Public key мерчанта у **hex** форматі. **Обов'язковий, якщо** public key не передано всередині підписаного payload. Див. розділ 2.2. |
| `X-Request-Id` | optional    | UUID запиту, згенерований клієнтом для 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):**

```http theme={null}
POST /merchant/api/v1/express/rates
Content-Type: application/json
x-public-key: 5a3c...hex

{ "data": "<base64(JSON)>", "signature": "<base64>" }
```

Decoded payload містить лише DTO-поля:

```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>" }
```

Decoded payload містить поле `publicKey` разом із DTO-полями:

```json theme={null}
{ "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 в інтегратора                                              |
| --------- | ----------------------------------------------- | -------------------------------------------------------------------------- |
| `ED25519` | Ed25519, asymmetric                             | Private key у hex, public key у hex. Public key реєструється на платформі. |
| `LEGACY`  | HMAC-style SHA-256 за `privateKey + base64Data` | Спільний secret, який зберігається як `privateKey` на обох сторонах.       |

`ED25519` використовується за замовчуванням для нових ключів. `LEGACY` підтримується для історичних інтеграцій. Ці два алгоритми дають різні підписи для одного й того самого payload, тому використовуйте обраний алгоритм послідовно.

### 2.4. Algorithm A — ED25519 (recommended)

Кроки:

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

```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)

Кроки:

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

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

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:

| HTTP | `code` | Meaning                                                                                                                                                       |
| ---- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `2010` | Поле `data` відсутнє або не є валідним base64-encoded JSON.                                                                                                   |
| 400  | `2011` | Поле `signature` відсутнє.                                                                                                                                    |
| 400  | `1110` | Payload validation failed: поле відсутнє, має неправильний тип або порушує constraint із розділу 4.                                                           |
| 401  | `2020` | Signature did not verify. Майже завжди це помилка у signing code інтегратора: неправильне encoding input, неправильний key або payload змінено після signing. |
| 401  | `2021` | Public key blocked. Зверніться до підтримки платформи.                                                                                                        |
| 401  | `2022` | Public key expired. Випустіть новий ключ у merchant portal.                                                                                                   |
| 401  | `2023` | Public key inactive: revoked або ще не activated.                                                                                                             |
| 403  | `4003` | На ключі відсутній permission або source IP не входить до allowlist.                                                                                          |
| 404  | `4004` | Resource not found, наприклад невідомий `transactionId` або `bankLinkCode`.                                                                                   |
| 400  | `5001` | `rateId` не існує.                                                                                                                                            |
| 400  | `5002` | `rateId` більше не пропонується жодним партнером.                                                                                                             |
| 400  | `5003` | `recipientData` не містить required fields або містить invalid values.                                                                                        |
| 400  | `5004` | Балансу мерчанта недостатньо для покриття `usdtTotal`.                                                                                                        |
| 404  | `5007` | Withdrawal transaction not found.                                                                                                                             |
| 404  | `5008` | Bank link not found.                                                                                                                                          |

***

## 3. Request / Response Envelope

**Кожен response** від authenticated endpoint-ів повертається у wrapper:

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

Non-`200` responses повертають стандартний NestJS error envelope:

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

| Field    | Type   | Required | Notes                                                                           |
| -------- | ------ | -------- | ------------------------------------------------------------------------------- |
| `amount` | number | no       | Зарезервовано для майбутнього amount-aware rate selection. Можна не передавати. |

**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` — це **`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:**

| Field              | Type   | Required | Notes              |
| ------------------ | ------ | -------- | ------------------ |
| `fiatCurrencyCode` | string | yes      | Наприклад `"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`

Повертає список fiat currencies, що підтримуються вказаним bank.

**Decoded payload:**

| Field      | Type   | Required | Notes                 |
| ---------- | ------ | -------- | --------------------- |
| `bankCode` | string | yes      | Наприклад `"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`

Повертає schema полів recipient-data, потрібних для order за bank link. Використовуйте цей endpoint перед `withdrawals`, щоб зрозуміти, які поля, наприклад card number або phone, потрібно зібрати в end-user.

**Decoded payload:**

| Field          | Type   | Required | Notes                                           |
| -------------- | ------ | -------- | ----------------------------------------------- |
| `bankLinkCode` | string | yes      | `bankLinkCode`, повернутий endpoint-ом `rates`. |

**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` може бути `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:**

| Field           | Type                    | Required | Validation                                                                                                                                |
| --------------- | ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `fiatAmount`    | number                  | yes      | `>= 0.01`.                                                                                                                                |
| `rateId`        | string (UUID)           | yes      | `id`, повернутий endpoint-ом `rates`.                                                                                                     |
| `recipientData` | `object<string,string>` | yes      | Keys мають збігатися з `name`, які повернув `bank-link`. Не має бути порожнім.                                                            |
| `externalId`    | string                  | no       | Ідентифікатор на стороні мерчанта. **Унікальний у межах мерчанта**: duplicate values викликають помилку. Корисний для 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"
  }
}
```

У цей момент баланс мерчанта змінюється: `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:**

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

Деталі одного withdrawal.

**Decoded payload:**

| Field           | Type          | Required | Notes                                                                      |
| --------------- | ------------- | -------- | -------------------------------------------------------------------------- |
| `transactionId` | string (UUID) | yes      | `transactionId`, повернутий endpoint-ом `withdrawals` або webhook payload. |

**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 для Express transaction:

```txt theme={null}
CREATED ─┬──► PROCESSING ──► COMPLETED
         └──► CANCELLED   ◄── PROCESSING (no eligible partners after retries)
              FAILED   ◄── critical system error
```

| Status       | Meaning                                                      | Balance impact                                                   |
| ------------ | ------------------------------------------------------------ | ---------------------------------------------------------------- |
| `CREATED`    | Order надіслано partner-у; система очікує acceptance.        | `usdtTotal` locked.                                              |
| `PROCESSING` | Partner прийняв order; система очікує payment confirmation.  | Lock unchanged.                                                  |
| `COMPLETED`  | Partner позначив payout як paid; USDT списано.               | `locked -= usdtTotal`, створено ledger `OUT` entry.              |
| `CANCELLED`  | Усі partner attempts вичерпано або order скасовано admin-ом. | Full refund: `locked -= usdtTotal`, `available += usdtTotal`.    |
| `FAILED`     | Critical/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

| Event                            | Trigger                                                                           |
| -------------------------------- | --------------------------------------------------------------------------------- |
| `express::withdrawal.created`    | Order створено та надіслано першому partner-у.                                    |
| `express::withdrawal.processing` | Partner прийняв order.                                                            |
| `express::withdrawal.completed`  | Partner підтвердив fiat payout; USDT consumed.                                    |
| `express::withdrawal.cancelled`  | Усі partner attempts вичерпано або order скасовано admin-ом; full refund applied. |
| `express::withdrawal.failed`     | Critical/unrecoverable error.                                                     |

Events `express::order.*` і `express::partner.*` є internal і **не доставляються** на 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

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

```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);
}
```
