Finance OS / API

Managed Customers

Клиент - это конечный пользователь, которого вы ведёте на платформе: под ним живут крипто-кошельки, платежи, верификация и история операций. Вы создаёте клиентов через API, привязываете к ним свой external_id и работаете дальше по uuid.

Телефон обязателен
При создании клиента phone в формате E.164 - обязательное поле: номер служит идентификатором человека в контуре и уникален в пределах вашей учётной записи и окружения. Регулярное выражение проверки: ^\+[1-9][0-9]{6,19}$ - плюс, затем от 7 до 20 цифр, первая не ноль. Пробелы, скобки и дефисы не допускаются: +79001234567.

Модель клиента

Поля

uuid string optional
Публичный идентификатор клиента. Используется во всех путях эндпоинтов.
external_id string|null optional
Ваш собственный идентификатор пользователя. Участвует в идемпотентности создания.
name string required
Имя клиента.
email string|null optional
Электронная почта (опционально).
phone string required
Телефон в E.164: +79001234567. Уникален в пределах вашей учётной записи и окружения.
env enum optional
live или sandbox. Наследуется от ключа, которым клиент создан, и не меняется.
status enum optional
active или blocked. У заблокированного клиента запрещены вывод и расчёты; удалению мешает только блокировка со стороны платформы, ваша собственная - нет.
blocked_reason string|null optional
Причина блокировки, если она передавалась.
blocked_at timestamp|null optional
Момент блокировки.
metadata object|null optional
Ваши произвольные поля. Платформа добавляет сюда служебный ключ block_source у заблокированного клиента (см. Блокировка); при обновлении metadata он сохраняется автоматически, передавать его не нужно (а подменить - нельзя).
kyc_status enum optional
not_started, pending, processing, verified, rejected, expired.
kyc_level integer optional
Уровень верификации 0..3. См. KYC.
consent_signed boolean optional
Вы подтвердили, что клиент дал согласие на обработку данных. Обязательно перед KYC-операциями.
consent_signed_at timestamp|null optional
Когда согласие было зафиксировано.
consent_ip string|null optional
IP клиента в момент согласия (передаёте вы).
created_at timestamp optional

Объект может содержать дополнительные служебные поля - читайте только те, что перечислены выше, и не полагайтесь на полный состав ответа.

Создать клиента

POST /api/v1/customers
Bearer Token

Создать клиента (идемпотентно)

Создаёт клиента под вашей учётной записью в окружении ключа. Повторный вызов не создаёт дубль.

Request body

name string required
Имя клиента, до 255 символов.
phone string required
Телефон в E.164: ^\+[1-9][0-9]{6,19}$.
email string optional
Электронная почта, до 255 символов.
external_id string optional
Ваш идентификатор пользователя, до 100 символов.
metadata object optional
Произвольный JSON.
Идемпотентность создания
Тело запроса содержит два ключа идемпотентности, и проверяются они по порядку:
  1. external_id - если передан и клиент с таким значением у вас уже есть, возвращается он;
  2. phone - если по номеру уже заведён клиент, возвращается он.
Совпадение ищется в пределах вашей учётной записи и того же окружения. Новый клиент - 201, найденный существующий - 200 с его текущими данными (поля из запроса при этом не перезаписываются - для правки используйте PATCH). Различайте создание и совпадение по HTTP-коду.

Responses

{
  "data": {
    "uuid": "5f8d7a3c-1234-4567-89ab-cdef01234567",
    "external_id": "user-42",
    "name": "Иван Петров",
    "email": "ivan@partner.com",
    "phone": "+79001234567",
    "env": "live",
    "status": "active",
    "blocked_reason": null,
    "blocked_at": null,
    "kyc_status": "not_started",
    "kyc_level": 0,
    "consent_signed": false,
    "consent_ip": null,
    "metadata": {"tier": "gold"},
    "created_at": "2026-08-17T11:23:00.000000Z",
    "updated_at": "2026-08-17T11:23:00.000000Z"
  }
}
{
  "data": {
    "uuid": "5f8d7a3c-1234-4567-89ab-cdef01234567",
    "external_id": "user-42",
    "name": "Иван Петров",
    "email": "ivan@partner.com",
    "phone": "+79001234567",
    "env": "live",
    "status": "active",
    "blocked_reason": null,
    "blocked_at": null,
    "kyc_status": "verified",
    "kyc_level": 2,
    "consent_signed": true,
    "consent_ip": "203.0.113.10",
    "metadata": {"tier": "gold"},
    "created_at": "2026-07-02T09:14:00.000000Z",
    "updated_at": "2026-08-17T11:23:00.000000Z"
  }
}
{
  "error": {
    "code": "invalid_request",
    "message": "Некорректные данные запроса.",
    "request_id": "req_a1B2c3D4e5F6g7H8i9J0kLmN",
    "fields": {
      "phone": ["The phone field format is invalid."],
      "name": ["The name field is required."]
    }
  }
}

Набор полей в ответе одинаков у 201 и 200 - различается только HTTP-код и сами значения. Разбирайте оба ответа одним кодом.

Создание, изменение, удаление клиента и фиксация согласия делят общую корзину лимита частоты: 120 запросов в минуту на всю четвёрку. У чтения (список, карточка, баланс, история) выделенной корзины нет.

Список клиентов

GET /api/v1/customers
Bearer Token

Постраничный список ваших клиентов

Query parameters

filter[phone] string optional
Точный поиск по номеру (не подстрока). Значение обязано быть валидным E.164, иначе 422. Самый быстрый способ найти клиента по телефону.
filter[status] string optional
active или blocked.
filter[kyc_status] string optional
not_started, pending, processing, verified, rejected, expired.
filter[env] string optional
live или sandbox. По умолчанию - окружение ключа.
filter[search] string optional
Подстрока по имени, почте, телефону и external_id. До 100 символов.
per_page integer optional
Размер страницы, 1..100.
Default: 20
page integer optional
Номер страницы.
Default: 1

Responses

{
  "data": [ { "uuid": "5f8d7a3c-…", "name": "Иван Петров", "phone": "+79001234567", "status": "active" } ],
  "links": {
    "first": "https://fin-os.io/api/v1/customers?page=1",
    "last":  "https://fin-os.io/api/v1/customers?page=1",
    "prev":  null,
    "next":  null
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "to": 1,
    "per_page": 20,
    "total": 1,
    "last_page": 1
  }
}

Получить клиента

GET /api/v1/customers/{uuid}
Bearer Token

Карточка клиента по UUID

Изоляция окружений
Клиент, созданный с env='live', для ключа sk_test_ не существует - придёт 404 (и наоборот). Это намеренно: ключ не должен даже узнать о существовании клиента в чужом окружении.

Обновить клиента

PATCH /api/v1/customers/{uuid}
Bearer Token

Изменить имя, почту, телефон, external_id или metadata

Request body

name string optional
До 255 символов.
email string|null optional
Передайте null, чтобы очистить.
phone string|null optional
Новый номер в E.164 либо null для очистки. Здесь телефон необязателен - обязателен он только при создании, поэтому GETPATCH тем же телом безопасен.
external_id string|null optional
До 100 символов либо null. Значение уникально среди ваших клиентов окружения: конфликт отвечает 422 invalid_request с подсказкой в fields.external_id.
metadata object|null optional
Заменяет объект целиком; служебный ключ block_source платформа сохраняет сама.

Поля kyc_*, env, status и consent_* через PATCH не меняются - для них есть отдельные эндпоинты.

Responses

{
  "error": {
    "code": "duplicate_phone",
    "message": "Этот номер телефона уже занят другим клиентом.",
    "request_id": "req_7Kp2Rt9Wx4Yz1Qb6Nm3Vc8L"
  }
}

Ответ 409 duplicate_phone приходит, когда назначаемый номер уже принадлежит другому вашему клиенту того же окружения. Назначение клиенту его же текущего номера ошибкой не является.

Блокировка

POST /api/v1/customers/{uuid}/block
Bearer Token

Заблокировать клиента

По заблокированному клиенту запрещены вывод, charge и payout. Уходит вебхук customer.blocked. Удалению ваша собственная блокировка не мешает: заблокированного вами клиента DELETE удалит, если у него нет остатков, адресов и истории.

Request body

reason string optional
Причина блокировки, до 255 символов. Попадает в поле blocked_reason и в полезную нагрузку вебхука.
POST /api/v1/customers/{uuid}/unblock
Bearer Token

Снять блокировку

Снимает только вашу блокировку. Уходит вебхук customer.unblocked.
Блокировку комплаенса вы снять не можете
Происхождение блокировки видно в metadata.block_source: merchant - наложили вы, officer - комплаенс-офицер платформы, sanctions - автоматическая блокировка по санкционному совпадению. Снять через API можно только merchant; на остальные приходит 409 conflict. Блокировка всегда усиливается: санкционное совпадение по уже заблокированному вами клиенту перекрывает источник, и снять её вашим вызовом будет нельзя.

Оба эндпоинта возвращают 200 с обновлённой карточкой клиента. Повторная блокировка уже заблокированного клиента и разблокировка активного ошибкой не считаются. Блокировка и разблокировка делят общую корзину мутирующих вызовов кошельков (30 запросов в минуту на всю группу, вместе с выдачей адреса и симуляцией депозита), а не по 30 на каждый эндпоинт.

Удалить клиента

DELETE /api/v1/customers/{uuid}
Bearer Token

Мягкое удаление клиента

Успех - 204 без тела. Удаление мягкое: запись сохраняется для целей комплаенса, полное стирание не предусмотрено.

Удаление возможно не всегда - платформа отказывает в трёх случаях:

customer_blocked 409 optional
Клиент заблокирован платформой - комплаенс-офицером или автоматически по санкционному совпадению. Иначе удаление и повторное создание по тому же номеру давало бы чистого клиента вместо заблокированного. Ваша собственная блокировка (block_source: merchant) удалению не мешает - её вы вправе снять сами.
customer_has_funds 409 optional
На счетах клиента есть остатки, включая замороженные. Сначала выведите или спишите деньги.
customer_has_wallets 409 optional
Клиенту выданы крипто-адреса либо по нему есть история движений. Такой клиент не удаляется даже с нулевым остатком: платёж «в полёте» придёт на уже выданный адрес, а номер телефона к тому моменту мог быть отдан другому человеку.
POST /api/v1/customers/{uuid}/consent
Bearer Token

Подтвердить, что клиент дал согласие на обработку данных

Обязательно перед KYC-операциями. Сохраняет IP клиента и отметку времени как доказательство согласия. Ответственность за фактическое получение согласия от пользователя несёте вы.

Request body

ip string required
IP клиента в момент согласия. Валидный IPv4 или IPv6.

Балансы клиента

GET /api/v1/customers/{uuid}/balance
Bearer Token

Торговые остатки и крипто-балансы одним ответом

Responses

{
  "data": [
    { "currency": "RUB", "available": "15000.0000000000", "locked": "0.0000000000" }
  ],
  "meta": {
    "crypto": [
      {
        "asset": "USDT",
        "symbol": "USDT",
        "network": "TRON",
        "currency": "USDT_TRON",
        "available": "150.0000000000",
        "locked": "0.0000000000"
      }
    ],
    "status": "active"
  }
}
data и meta.crypto - разные контуры
В data лежат строки торговых остатков клиента, в meta.crypto - крипто-балансы по каждой доступной вам монете (то же, что отдаёт GET /api/v1/customers/{uuid}/wallet). Поле locked в обоих случаях - сумма, временно недоступная клиенту: резерв под операцию либо заморозка комплаенса. Подробнее - Customer Wallets.

История операций

GET /api/v1/customers/{uuid}/transactions
Bearer Token

Единая лента: крипто-движения и торговые операции

Записи отдаются от новых к старым. Пагинация курсорная: номера страниц здесь не поддерживаются, потому что лента собирается из двух источников.

Query parameters

per_page integer optional
Размер страницы, 1..100.
Default: 25
starting_after string optional
Курсор: id последней полученной записи (значение meta.next_cursor). Неизвестный или устаревший курсор не считается ошибкой - вернётся первая страница.

Responses

{
  "data": [
    {
      "kind": "crypto",
      "id": "7c1f2b90-4a3e-4a1f-9c62-8b0f4a7d2e11",
      "network": "TRON",
      "asset": "USDT",
      "direction": "in",
      "amount": "150.000000000000000000",
      "status": "confirmed",
      "aml_status": "clear",
      "frozen": false,
      "fee": { "platform": "0.000000000000000000", "merchant": "0.000000000000000000" },
      "created_at": "2026-08-17T12:04:11+00:00"
    },
    {
      "kind": "trading",
      "id": "c93a1f70-2b48-4f61-a0d5-88e2c4b71f03",
      "type": "payment",
      "status": 2,
      "amount": "10000.00",
      "currency": "RUB",
      "amount_to": "117.4500",
      "currency_to": "USDT",
      "created_at": "2026-08-16T09:12:40+00:00"
    }
  ],
  "meta": {
    "per_page": 25,
    "returned": 2,
    "has_more": false,
    "next_cursor": null
  }
}

Разбор ленты

kind enum optional
crypto - движение по крипто-кошельку клиента (полный состав полей описан в Customer Wallets); trading - операция обмена фиата и криптовалюты.
id string optional
Идентификатор записи. Он же передаётся в starting_after и работает для обоих источников - различать их не нужно.
meta.returned integer optional
Сколько записей в этом ответе.
meta.has_more bool optional
Есть ли ещё записи.
meta.next_cursor string|null optional
Курсор для следующего запроса. null, когда лента закончилась.
type и status зависят от источника строки
У строк торгового контура (kind: "trading") поле status - число, код статуса сделки: 0 - в обработке, 1 - ожидание банка, 2 - успешно, 3 - холд, 4 - отменена, 5 - отклонена антифродом, 6 - ошибка банка, 7 - запрошен возврат, 8 - возвращена, 9 - ошибка инициализации, 10 - истекло время, а type - строка операции (payment - покупка, payout - продажа; значений buy/sell здесь нет). У крипто-строк (kind: "crypto") status - строка из трёх значений pending / confirmed / failed, как описано в разделе Customer Wallets.

События по клиенту

Изменения статуса клиента и движения по его счетам приходят вебхуками: customer.blocked, customer.unblocked, customer.deposit.*, customer.withdrawal.*, customer.charged, customer.payout и события верификации customer.kyc.*. Полный список и формат - Webhooks.

Примеры кода

KEY="sk_live_..."
B="https://fin-os.io/api/v1"

# 1. Создать клиента (телефон обязателен)
curl -X POST $B/customers \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Иван Петров",
    "phone": "+79001234567",
    "email": "ivan@example.com",
    "external_id": "user-42",
    "metadata": {"tier": "gold"}
  }'
# 201 - создан, 200 - клиент с таким external_id или телефоном уже был

# 2. Найти клиента по телефону (точное совпадение)
curl -G $B/customers -H "Authorization: Bearer $KEY" \
  --data-urlencode "filter[phone]=+79001234567"

# 3. Согласие на обработку данных (нужно перед KYC)
curl -X POST $B/customers/$UUID/consent \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"ip": "203.0.113.42"}'

# 4. Заблокировать и разблокировать
curl -X POST $B/customers/$UUID/block \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Подозрительная активность"}'

curl -X POST $B/customers/$UUID/unblock -H "Authorization: Bearer $KEY"

# 5. История операций постранично по курсору
curl -G $B/customers/$UUID/transactions -H "Authorization: Bearer $KEY" \
  --data-urlencode "per_page=50" \
  --data-urlencode "starting_after=7c1f2b90-4a3e-4a1f-9c62-8b0f4a7d2e11"
const B   = 'https://fin-os.io/api/v1';
const KEY = process.env.FINOS_SECRET_KEY;
const H   = { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json' };

// Создание идемпотентно: 201 - новый, 200 - уже был
async function createCustomer(data) {
  const res  = await fetch(`${B}/customers`, { method: 'POST', headers: H, body: JSON.stringify(data) });
  const body = await res.json();
  if (!res.ok) throw Object.assign(new Error(body.error.message), body.error);
  return { customer: body.data, created: res.status === 201 };
}

// Поиск по телефону - точное совпадение
async function findByPhone(phone) {
  const q = new URLSearchParams({ 'filter[phone]': phone });
  const { data } = await (await fetch(`${B}/customers?${q}`, { headers: H })).json();
  return data[0] ?? null;
}

// Полная история клиента по курсору
async function history(uuid) {
  const rows = [];
  let cursor = null;

  do {
    const q = new URLSearchParams({ per_page: '100' });
    if (cursor) q.set('starting_after', cursor);

    const body = await (await fetch(`${B}/customers/${uuid}/transactions?${q}`, { headers: H })).json();
    rows.push(...body.data);
    cursor = body.meta.has_more ? body.meta.next_cursor : null;
  } while (cursor);

  return rows;
}

const { customer, created } = await createCustomer({
  name: 'Иван Петров', phone: '+79001234567', external_id: 'user-42',
});
console.log(created ? 'создан' : 'уже существовал', customer.uuid);
use Illuminate\Support\Facades\Http;

$api = Http::withToken(config('services.finos.secret_key'))
    ->acceptJson()
    ->baseUrl('https://fin-os.io/api/v1');

// 1. Создание. 201 - новый клиент, 200 - уже был (по external_id или телефону)
$response = $api->post('/customers', [
    'name'        => 'Иван Петров',
    'phone'       => '+79001234567',
    'email'       => 'ivan@example.com',
    'external_id' => 'user-42',
]);

$customer = $response->json('data');
$created  = $response->status() === 201;

// 2. Согласие на обработку данных
$api->post("/customers/{$customer['uuid']}/consent", ['ip' => request()->ip()]);

// 3. Поиск по телефону
$found = $api->get('/customers', ['filter' => ['phone' => '+79001234567']])->json('data');

// 4. Удаление: 204 либо 409 с причиной отказа
$delete = $api->delete("/customers/{$customer['uuid']}");

if ($delete->status() === 409) {
    match ($delete->json('error.code')) {
        'customer_has_funds'   => 'сначала выведите остатки',
        'customer_has_wallets' => 'у клиента есть адреса или история движений',
        'customer_blocked'     => 'блокировка снимается комплаенс-офицером',
    };
}
import os, requests

B   = 'https://fin-os.io/api/v1'
KEY = os.environ['FINOS_SECRET_KEY']
H   = {'Authorization': f'Bearer {KEY}'}

# Создание идемпотентно по external_id, затем по телефону
r = requests.post(f'{B}/customers', headers=H, json={
    'name': 'Иван Петров',
    'phone': '+79001234567',
    'external_id': 'user-42',
})
r.raise_for_status()
customer, created = r.json()['data'], r.status_code == 201

# Точный поиск по телефону
found = requests.get(f'{B}/customers', headers=H,
                     params={'filter[phone]': '+79001234567'}).json()['data']

# История по курсору
def history(uuid, per_page=100):
    cursor = None
    while True:
        params = {'per_page': per_page}
        if cursor:
            params['starting_after'] = cursor
        body = requests.get(f'{B}/customers/{uuid}/transactions',
                            headers=H, params=params).json()
        yield from body['data']
        if not body['meta']['has_more']:
            return
        cursor = body['meta']['next_cursor']