Finance OS / API

Errors

Единый обезличенный конверт ошибок (unified error envelope) для B2B API. Один и тот же формат возвращается на любую неуспешную операцию - валидация, аутентификация, недостаток средств, конфликт состояния или недоступность сервиса. Разбирайте ошибки по стабильному строковому полю code, а не по тексту message (текст локализован и может меняться).

Applies to ALL /api/v1/* endpoints
Этот конверт применяется ко всем merchant-endpoints под /api/v1/* и заменяет (supersedes) легаси-формат ошибок с полями error_code / docs на верхнем уровне - кроме одного исключения, описанного ниже. Если ваш код всё ещё читает старые поля - переключитесь на error.code + error.request_id.
Одно исключение - гейт доступа
Единственный ответ вне этого конверта - отказ гейта B2B-доступа: пока доступ не активирован администратором, любой вызов /api/v1/* возвращает плоскую форму с полем error_code на верхнем уровне (UNAUTHENTICATED при 401, B2B_ACCESS_REQUIRED при 403). Разбирайте обе формы: сначала верхнеуровневый error_code, затем вложенный error.code.

Responses

{
  "message": "B2B access is not enabled for this account. Contact admin@fin-os.io to request B2B integration onboarding.",
  "error_code": "B2B_ACCESS_REQUIRED",
  "docs": "https://fin-os.io/docs/b2b"
}

Формат конверта (envelope)

Успех - всегда объект с ключом data. Ошибка - всегда объект с ключом error, внутри которого code, message и request_id. Поле fields встречается только у invalid_request, но не у каждого: ошибки валидации формата приходят с разбором по полям, а доменные отказы с тем же кодом (например, сумма не проходит бизнес-проверку) - без fields. Не полагайтесь на его присутствие.

Responses

{
  "data": {
    "...": "полезная нагрузка операции"
  }
}
{
  "error": {
    "code": "string",
    "message": "string",
    "request_id": "req_...",
    "fields": {
      "field_name": ["сообщение валидации", "..."]
    }
  }
}
Поля конверта ошибки
  • code - стабильный машиночитаемый идентификатор ошибки (см. таблицу ниже). Ветвите логику по нему.
  • message - человекочитаемое, уже обезличенное сообщение. Никогда не раскрывает внутреннюю инфраструктуру. Для отображения пользователю пригодно как есть.
  • request_id - уникальный идентификатор запроса формата req_<random> для обращения в поддержку.
  • fields - присутствует только при invalid_request; объект вида { "имя_поля": ["ошибка", ...] }.

request_id (для поддержки)

Каждый ответ об ошибке содержит уникальный request_id формата req_<random> - префикс req_ плюс 24 случайных символа (например req_a1B2c3D4e5F6g7H8i9J0kLmN). Логируйте его на своей стороне и указывайте при обращении в поддержку: по этому идентификатору во внутреннем канале Finance OS хранится полная техническая детализация инцидента, которая никогда не отдаётся наружу. В merchant-ответ и merchant-лог попадает только обезличенная версия.

Коды ошибок

Полный реестр кодов. Каждый код жёстко привязан к одному HTTP-статусу. В таблице приведён точный текст сообщения по умолчанию (в кавычках) и семантика кода.

Общие

Error codes

invalid_request 422 optional
«Некорректные данные запроса.» - не прошла валидация тела/параметров. У ошибок формата ответ содержит объект fields с разбором по полям; у доменных отказов fields нет.
unauthorized 401 optional
«Требуется аутентификация. Проверьте ключ API.» - ключ отсутствует, недействителен или отозван. Проверьте заголовок Authorization: Bearer sk_live_… / sk_test_….
forbidden 403 optional
«Доступ запрещён.» - ключ аутентифицирован, но у него нет прав на этот ресурс или действие (в том числе роль сотрудника на денежной операции).
not_found 404 optional
«Ресурс не найден.» - объект отсутствует. Также возвращается при попытке доступа к ресурсу из другого окружения (sk_test_ к live-ресурсу и наоборот) - намеренно 404, а не 403.
method_not_allowed 405 optional
«Метод не разрешён для этого адреса - проверьте HTTP-метод в документации.» - путь существует, но не поддерживает использованный HTTP-метод.
conflict 409 optional
«Конфликт состояния операции.» - состояние ресурса несовместимо с запросом: операция в терминальном статусе, повтор Idempotency-Key с другими параметрами, попытка снять блокировку, наложенную комплаенсом.
rate_limited 429 optional
«Слишком много запросов. Повторите позже.» - превышен лимит частоты. Ответ несёт заголовки Retry-After и X-RateLimit-*: повторяйте с экспоненциальной задержкой (backoff).
service_unavailable 503 optional
«Сервис временно недоступен. Попробуйте позже.» - вышестоящий сервис временно недоступен или контур ещё не активирован. Запрос можно повторить позже.
internal_error 500 optional
«Внутренняя ошибка. Обратитесь в поддержку с указанным request_id.» - непредвиденная ошибка на стороне Finance OS. Сохраните request_id и обратитесь в поддержку.
sandbox_unsupported 403 optional
«Песочница для этого сервиса недоступна - используйте боевой ключ.» - сервис не имеет тестового окружения, вызов возможен только ключом sk_live_.
merchant_suspended 403 optional
«Учётная запись мерчанта приостановлена - обратитесь в поддержку.» - ключ рабочий, но учётная запись приостановлена. Ошибка не в запросе: повтор не поможет.

Клиенты

Error codes

verification_required 403 optional
«Требуется завершить верификацию клиента.» - операция недоступна, пока клиент не прошёл верификацию. Сначала завершите верификацию.
duplicate_phone 409 optional
«Этот номер телефона уже занят другим клиентом.» - при PATCH номер, который вы назначаете, принадлежит другому вашему клиенту. Создание (POST) эту ошибку не возвращает: там повтор номера идемпотентно отдаёт существующего клиента.
customer_blocked 409 optional
«Клиент заблокирован - операции по нему запрещены.» - по заблокированному клиенту запрещены вывод, charge, payout и удаление.
customer_has_funds 409 optional
«У клиента есть остатки на счетах - удаление запрещено.» - сначала выведите или спишите остатки, включая замороженные.
customer_has_wallets 409 optional
«У клиента есть крипто-адреса или история движений - удаление запрещено.» - клиент с выданными адресами или движениями по кошельку не удаляется даже с нулевым остатком.

Кошельки клиентов и мастер-счёт

Error codes

idempotency_key_required 400 optional
«Требуется заголовок Idempotency-Key.» - обязателен для вывода, charge, payout и внутренних переводов мастер-счёта.
insufficient_funds 402 optional
«Недостаточно средств для операции.» - не хватает средств на счёте клиента (с учётом обеих комиссий) либо на счёте финансирования владельца при пополнении мастер-счёта.
service_balance_insufficient 402 optional
«Недостаточно средств на сервисном счёте. Пополните мастер-счёт.» - ответ дополнен полями currency, required и available, чтобы вы могли показать точную нехватку. Пополнение - Merchant Balance.
wallets_disabled 403 optional
«Крипто-кошельки клиентов сейчас недоступны.» - выдача адресов клиентам недоступна вашей учётной записи.
withdraw_disabled 403 optional
«Вывод средств клиентов сейчас недоступен.» - вывод недоступен вашей учётной записи.
coin_not_allowed 422 optional
«Эта монета недоступна для вашей учётной записи.» - монета или сеть не поддерживаются, отключены для вас, либо адрес получателя принадлежит платформе (внутренние расчёты делаются через charge / payout).
amount_below_minimum 422 optional
«Сумма меньше минимальной для этой монеты.» - минимум смотрите в min_withdraw витрины тарифа и в предпросмотре вывода.
invalid_address 422 optional
«Некорректный адрес получателя для указанной сети.» - формат адреса не соответствует сети. Проверьте, что network и адрес из одной сети.
withdraw_limit_exceeded 422 optional
«Превышен лимит вывода.» - сумма больше лимита на операцию либо суточного лимита клиента. Актуальные значения - в предпросмотре (max_per_tx, daily_left).
aml_blocked 422 optional
«Операция отклонена комплаенс-проверкой.» - адрес назначения не прошёл проверку. Отказ приходит до списания; уходит вебхук customer.withdrawal.blocked.
aml_screening_in_progress 409 optional
«По клиенту идёт комплаенс-проверка поступления. Повторите позже.» - по клиенту есть поступление без вердикта. Состояние временное, повторите запрос через некоторое время.
Ветвление по code, не по message
message предназначен для показа человеку и может меняться. Программную логику (retry, показ формы, пополнение баланса) стройте по error.code и HTTP-статусу.

Примеры тел ошибок

Реальные тела ответов для типичных ситуаций.

Responses

{
  "error": {
    "code": "idempotency_key_required",
    "message": "Требуется заголовок Idempotency-Key.",
    "request_id": "req_5Hj7Kd2Nf8Rt1Yq4Wp6Vb3M"
  }
}
{
  "error": {
    "code": "unauthorized",
    "message": "Требуется аутентификация. Проверьте ключ API.",
    "request_id": "req_M9n8B7v6C5x4Z3a2S1d0F1gH"
  }
}
{
  "error": {
    "code": "insufficient_funds",
    "message": "Недостаточно средств для операции.",
    "request_id": "req_7Kp2Rt9Wx4Yz1Qb6Nm3Vc8L"
  }
}
{
  "error": {
    "code": "service_balance_insufficient",
    "message": "Недостаточно средств на сервисном счёте. Пополните мастер-счёт.",
    "request_id": "req_3Nd8Vc1Xz5Bm7Kq0Lp2Rt6W",
    "currency": "USDT",
    "required": "0.2",
    "available": "0.0500000000"
  }
}
{
  "error": {
    "code": "invalid_request",
    "message": "Некорректные данные запроса.",
    "request_id": "req_a1B2c3D4e5F6g7H8i9J0kLmN",
    "fields": {
      "amount_rub": ["The amount rub field must be at least 100."],
      "bank_id": ["The bank id field is required."]
    }
  }
}
{
  "error": {
    "code": "rate_limited",
    "message": "Слишком много запросов. Повторите позже.",
    "request_id": "req_Qw3Er5Ty7Ui9Op1As2Df4Gh6"
  }
}
{
  "error": {
    "code": "service_unavailable",
    "message": "Сервис временно недоступен. Попробуйте позже.",
    "request_id": "req_Zx8Cv6Bn4Mk2Lj0Hg9Fd7Sa5"
  }
}
Дополнительные поля у 402 мастер-счёта
Единственный код с расширенным конвертом, помимо invalid_request, - service_balance_insufficient: к обязательным code, message и request_id добавляются currency, required и available. Их можно показать пользователю как есть: «не хватает 0.15 USDT». Остальные коды расширений не имеют - не рассчитывайте на них при разборе.

Что повторять, а что нет

Повторять с задержкой 429, 503 optional
rate_limited и service_unavailable - временные. На 429 опирайтесь на заголовок Retry-After, дальше - экспоненциальная задержка.
Повторить позже, без изменений 409 optional
aml_screening_in_progress - состояние временное, запрос корректен. Остальные 409 повторять бессмысленно: сначала измените запрос или состояние ресурса.
Исправить запрос 400, 422 optional
Валидация, лимиты, адрес, монета, минимальная сумма. Повтор без изменений даст тот же ответ.
Пополнить счёт 402 optional
Пополните счёт клиента (insufficient_funds) или мастер-счёт (service_balance_insufficient) и повторите.
Обратиться в поддержку 403, 500 optional
merchant_suspended, wallets_disabled, withdraw_disabled снимаются на нашей стороне; для internal_error сохраните request_id.
Ретрай денежной операции - только с тем же Idempotency-Key
Вывод, charge, payout и внутренние переводы мастер-счёта требуют заголовок Idempotency-Key. Повторяя запрос после таймаута или 503, отправляйте тот же ключ: так повтор вернёт результат первой операции с duplicate: true вместо второго списания. Новый ключ на ретрае - это новая операция и вторые деньги.
Sandbox vs live
Окружение определяется префиксом ключа: sk_test_… - sandbox, sk_live_… - live. Отдельного заголовка окружения нет. Обрабатывайте ошибки одинаково в обоих окружениях. См. Sandbox.

Примеры кода

# Любой /api/v1/* endpoint при неуспехе отдаёт конверт ошибки.
# Пример: недостаточно средств под sell → 402.
curl -i -X POST https://fin-os.io/api/v1/customers/$UUID/payments/sell \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"amount_usdt": 999999, "bank_id": "sber", "phone": "+79001234567"}'

# HTTP/1.1 402 Payment Required
# {
#   "error": {
#     "code": "insufficient_funds",
#     "message": "Недостаточно средств для операции.",
#     "request_id": "req_7Kp2Rt9Wx4Yz1Qb6Nm3Vc8L"
#   }
# }

# Невалидное тело → 422 с объектом fields:
curl -i -X POST https://fin-os.io/api/v1/customers/$UUID/payments/buy \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"amount_rub": 5}'
# HTTP/1.1 422 Unprocessable Entity
# { "error": { "code": "invalid_request", "request_id": "req_...",
#   "fields": { "amount_rub": ["..."], "bank_id": ["..."] } } }
// Единый разбор конверта ошибки для всех вызовов /api/v1/*
async function apiCall(path, opts = {}) {
  const res = await fetch(`https://fin-os.io/api/v1${path}`, {
    ...opts,
    headers: {
      'Authorization': `Bearer ${process.env.FINOS_SECRET_KEY}`, // sk_test_ или sk_live_
      'Content-Type': 'application/json',
      ...(opts.headers || {}),
    },
  });

  const body = await res.json();

  if (!res.ok) {
    const { code, message, request_id, fields } = body.error;
    switch (code) {
      case 'invalid_request':
        console.error('Validation failed:', fields);
        break;
      case 'unauthorized':
        console.error('Проверьте API-ключ (Bearer sk_...)');
        break;
      case 'insufficient_funds':
        console.error('Пополните баланс клиента');
        break;
      case 'rate_limited':
      case 'service_unavailable':
        // повторить с экспоненциальной задержкой (backoff)
        break;
    }
    // request_id - для обращения в поддержку
    throw Object.assign(new Error(message), { code, request_id, fields });
  }

  return body.data;
}
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

$response = Http::withToken($secretKey) // sk_test_ или sk_live_
    ->acceptJson()
    ->post("https://fin-os.io/api/v1/customers/{$uuid}/payments/sell", [
        'amount_usdt' => '10',
        'bank_id'     => 'sber',
        'phone'       => '+79001234567',
    ]);

if ($response->failed()) {
    $err = $response->json('error'); // ['code','message','request_id','fields'?]

    match ($err['code']) {
        'invalid_request'     => report_fields($err['fields'] ?? []),
        'insufficient_funds'  => notify_top_up($uuid),
        'rate_limited',
        'service_unavailable' => retry_later(),          // backoff + retry
        default               => Log::warning('B2B error', $err),
    };

    throw new \RuntimeException(
        "{$err['code']}: {$err['message']} (request_id: {$err['request_id']})"
    );
}

$data = $response->json('data');
import os, requests

FINOS = 'https://fin-os.io/api/v1'
KEY   = os.environ['FINOS_SECRET_KEY']  # sk_test_ или sk_live_
H     = {'Authorization': f'Bearer {KEY}', 'Content-Type': 'application/json'}

def api_call(method, path, **kwargs):
    resp = requests.request(method, f'{FINOS}{path}', headers=H, **kwargs)
    if not resp.ok:
        err = resp.json()['error']
        code = err['code']
        if code == 'invalid_request':
            print('Validation failed:', err.get('fields'))
        elif code == 'unauthorized':
            print('Проверьте API-ключ (Bearer sk_...)')
        elif code == 'insufficient_funds':
            print('Пополните баланс клиента')
        elif code in ('rate_limited', 'service_unavailable'):
            pass  # повторить с экспоненциальной задержкой (backoff)
        # request_id - указать при обращении в поддержку
        raise RuntimeError(
            f"{code}: {err['message']} (request_id: {err['request_id']})"
        )
    return resp.json()['data']