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 — справочник 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:
{
"data": "<base64(JSON payload)>",
"signature": "<base64(signature of the base64 data string)>"
}
data — это base64 от JSON payload. signature — это base64 криптографической подписи, рассчитанной по base64-строке data. Конкретный алгоритм зависит от типа API-ключа, см. раздел 2.3.
| 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. Отображается в логах. |
Сервер определяет public key в таком порядке:
- HTTP header
x-public-key без учета регистра. Если header присутствует, используется он, а тело запроса для public key не проверяется.
- Если header отсутствует, используется поле
publicKey из декодированного JSON payload, то есть внутри data после base64 decode.
Если public key не найден ни в одном из мест, запрос отклоняется с HTTP 401 Missing public key.
Оба варианта допустимы. Выберите один вариант для каждого запроса. Рекомендуемый вариант — передавать public key в header: так signed payload содержит только бизнес-поля, а correlation в логах становится проще.
Header form (recommended):
POST /merchant/api/v1/express/rates
Content-Type: application/json
x-public-key: 5a3c...hex
{ "data": "<base64(JSON)>", "signature": "<base64>" }
Decoded payload содержит только DTO-поля:
Payload form (header omitted):
POST /merchant/api/v1/express/rates
Content-Type: application/json
{ "data": "<base64(JSON with publicKey)>", "signature": "<base64>" }
Decoded payload содержит поле publicKey вместе с DTO-полями:
{ "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)
Шаги:
- Соберите JSON payload с DTO-полями, см. endpoint sections ниже.
json = JSON.stringify(payload)
data = base64(utf8_bytes(json))
signatureBytes = ed25519.sign(utf8_bytes(data), privateKeyBytes) — входом является именно base64 string, а не исходный JSON и не raw bytes JSON.
signature = base64(signatureBytes)
- Отправьте
POST { data, signature } с 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)
Шаги:
- Соберите JSON payload.
data = base64(utf8_bytes(JSON.stringify(payload)))
hexDigest = sha256_hex(privateKey + data) — string concatenation secret и base64 payload.
signature = base64(utf8_bytes(hexDigest)) — base64-encode применяется к строке hex-символов, а не к raw 32-byte digest.
- Отправьте
POST { data, signature } с 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
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:
{
"code": 200,
"data": { /* endpoint-specific payload */ }
}
Non-200 responses возвращают стандартный NestJS error envelope:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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"
}
}
В этот момент баланс мерчанта изменяется: 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:
{
"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:
{
"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:
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
{
"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 должна:
- Проверить replay protection. Отклоняйте delivery, если
id уже был обработан. Храните processed IDs в database; in-memory set будет потерян после restart.
- Проверить timestamp. Отклоняйте delivery, если
Math.abs(now - delivered_at) > 16 minutes. Окно 16 минут покрывает maximum delivery time с учетом retries.
- Recompute and compare signature по
{ id, delivered_at, event }.
- Сохранить
id только после успешного прохождения всех трех проверок.
Handler должен быть idempotent: одна и та же delivery может прийти больше одного раза во время 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);
}