> ## 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: подпись запросов, справочники, создание фиатного вывода, статусы и исходящие 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 для 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 отправлен 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);
}
```
