Finance OS / API

KYC Verification

KYC-as-a-Service для managed customers: партнёр от имени своего end-user'а запускает верификацию, загружает документы, подтверждает телефон по SMS и отслеживает статус. Finance OS выступает прокси-фасадом — документы принимаются на стороне Finance OS и передаются во внутреннюю систему верификации; апстрим-инфраструктура партнёру не видна.

Предусловие: consent
Consent end-user'а (consent_signed=true) должен быть зафиксирован до любой KYC-операции. Иначе start, documents, request-code, verify-code и reset вернут 422 CONSENT_REQUIRED. Сначала вызовите POST /api/v1/customers/{uuid}/consent. Endpoint GET /kyc/status — единственный, который читается без consent.
Окружение выводится из ключа
Sandbox или live определяется префиксом ключа, а не заголовком: sk_test_sandbox, sk_live_live. Ключ и env customer'а должны совпадать, иначе 404. Все endpoints требуют Authorization: Bearer и включённого B2B-режима (иначе 403 B2B_ACCESS_REQUIRED).

Машина состояний kyc_status

У customer'а шесть возможных статусов. В sandbox переходы детерминированы и управляются вашими вызовами; в live финальные статусы (verified / rejected / expired) приходят асинхронно и синхронизируются через webhook.

платежи.'], ['name' => 'rejected', 'type' => 'терминальный', 'desc' => 'Проверка отклонена (live). Причина — в kyc_rejection_reason. Начать заново можно через reset.'], ['name' => 'expired', 'type' => 'терминальный', 'desc' => 'Сессия / результат верификации истёк (live). Требуется новый start после reset.'], ]" />
Sandbox magic: код 000000
В sandbox апстрим не вызывается. request-code возвращает mock_code="000000", а verify-code с кодом 000000 мгновенно переводит customer'а в verified с kyc_level=2. Любой другой код → 422 INVALID_CODE.

Уровни kyc_level

Числовой уровень доверия, ортогональный статусу. Растёт по мере прохождения этапов; при успешной верификации физлица присваивается уровень 2 (в sandbox — всегда 2).

Уровни

0 numeric optional
Не верифицирован. Значение по умолчанию и после reset.
1 numeric optional
Базовый: телефон привязан, документы ещё не подтверждены.
2 numeric optional
Полная верификация физлица: документ + селфи + подтверждённый телефон. Присваивается при успешном verify-code.
3 numeric optional
Расширенная проверка (подтверждение адреса / дополнительные документы).

1. Start

POST /api/v1/customers/{uuid}/kyc/start
Bearer Token

Запустить KYC-сессию

Создаёт verification-запись для customer'а и переводит kyc_status из not_started в pending. В live дополнительно регистрирует customer'а во внутренней системе верификации; в sandbox апстрим не вызывается.

Body

phone string required
Телефон end-user'а в формате E.164 (+ и 7–20 цифр, regex ^\+[1-9][0-9]{6,19}$). Пример: +79001234567.
passport_type enum optional
ru (внутренний паспорт РФ) или foreign (иностранный документ).
Default: ru

Responses

{
  "data": {
    "session_id": "sbx_0f3ab9c7d21e4a6f8b1c2d3e",
    "next": "upload_documents",
    "env": "sandbox",
    "customer_uuid": "1a7ee75b-7f0e-48af-a075-a56721f1140e",
    "kyc_status": "pending"
  }
}

2. Documents

POST /api/v1/customers/{uuid}/kyc/documents
Bearer Token

Загрузить документы (multipart/form-data)

Принимает до трёх файлов. Все поля опциональны — можно догружать по одному. В sandbox загрузка паспорта/адреса переводит их состояние в uploaded и двигает kyc_status в processing.

Form-data (файлы)

selfie file optional
Селфи держателя документа. jpg, jpeg, png или pdf, до 10 MB.
passport file optional
Разворот удостоверения личности. jpg, jpeg, png или pdf, до 10 MB.
address file optional
Подтверждение адреса (опционально, для уровня 3). jpg, jpeg, png или pdf, до 10 MB.

Responses

{
  "data": {
    "customer_uuid": "1a7ee75b-7f0e-48af-a075-a56721f1140e",
    "passport_state": "uploaded",
    "address_state": "not_uploaded",
    "kyc_status": "processing"
  }
}

Состояния документа (passport_state, address_state): not_uploadeduploadedsuccess (или rejected в live).

3. Request code

POST /api/v1/customers/{uuid}/kyc/request-code
Bearer Token

Отправить 6-значный SMS-код

Тело не требуется. В live код отправляется на телефон из start (в ответе — только метаданные доставки, без самого кода). В sandbox SMS не отправляется — возвращается мок-код 000000. Статус kyc_status этот вызов не меняет.

Responses

{
  "data": {
    "sent": true,
    "channel": "sandbox",
    "mock_code": "000000",
    "expires_in": 300,
    "note": "In sandbox the code \"000000\" always succeeds."
  }
}

4. Verify code

POST /api/v1/customers/{uuid}/kyc/verify-code
Bearer Token

Подтвердить код и завершить верификацию

Завершает flow. В sandbox код 000000 переводит customer'а в verified (kyc_level=2); любой другой код → 422 INVALID_CODE. В live результат синхронизируется с внутренней системой верификации.

Body

code string required
Ровно 6 символов. В sandbox всегда 000000.

Responses

{
  "data": {
    "customer_uuid": "1a7ee75b-7f0e-48af-a075-a56721f1140e",
    "kyc_status": "verified",
    "kyc_level": 2
  }
}
{
  "error": {
    "code": "INVALID_CODE",
    "message": "Invalid code. In sandbox the code \"000000\" always succeeds.",
    "request_id": "req_9f2ac1b0e7d8"
  }
}

5. Status

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

Снимок текущего состояния KYC

Читается без consent — используйте для поллинга. Возвращает статус, уровень, причину отклонения, флаг consent, состояние документов, телефон, окружение и остаток попыток reset.

Responses

{
  "data": {
    "kyc_status": "verified",
    "kyc_level": 2,
    "kyc_rejection_reason": null,
    "consent_signed": true,
    "documents": {
      "selfie_uploaded": true,
      "passport_state": "success",
      "address_state": "success"
    },
    "phone": "+79001234567",
    "env": "sandbox",
    "attempts_remaining": 5
  }
}

6. Reset

POST /api/v1/customers/{uuid}/kyc/reset
Bearer Token

Сбросить и начать заново

Обнуляет документы и состояние: kyc_statusnot_started, kyc_level0. Лимит — 5 сбросов за 24 часа на customer'а; при превышении 422 RESET_LIMIT_EXCEEDED. После сброса начните с start.

Responses

{
  "data": {
    "ok": true,
    "attempts_remaining": 4
  }
}

Коды ошибок

Ошибки возвращаются в конверте { "error": { "code", "message", "request_id" } }.

запишите consent.'], ['name' => 'KYC_SESSION_MISSING', 'type' => '422', 'desc' => 'documents/request-code/verify-code вызваны до kyc/start.'], ['name' => 'VALIDATION_ERROR', 'type' => '422', 'desc' => 'Некорректный phone (не E.164), passport_type вне ru|foreign, файл не того типа/размера или code ≠ 6 символов.'], ['name' => 'INVALID_CODE', 'type' => '422', 'desc' => 'Неверный SMS-код (в sandbox — любой кроме 000000).'], ['name' => 'RESET_LIMIT_EXCEEDED', 'type' => '422', 'desc' => 'Превышен лимит 5 сбросов за 24 часа.'], ['name' => 'PROVIDER_UNAVAILABLE', 'type' => '503', 'desc' => 'Внутренняя система верификации временно недоступна (live). Повторите позже.'], ['name' => 'CUSTOMER_NOT_FOUND', 'type' => '404', 'desc' => 'Customer не найден или окружение ключа не совпадает с env customer\'а.'], ['name' => 'B2B_ACCESS_REQUIRED', 'type' => '403', 'desc' => 'B2B-режим не включён на учётке владельца ключа.'], ]" />

Примеры кода

# 1. Start: запустить KYC-сессию (phone required, +E164)
curl -X POST https://fin-os.io/api/v1/customers/$UUID/kyc/start \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"phone": "+79001234567", "passport_type": "ru"}'

# 2. Documents: загрузить файлы (multipart; jpg|png|pdf, ≤10MB)
curl -X POST https://fin-os.io/api/v1/customers/$UUID/kyc/documents \
  -H "Authorization: Bearer sk_test_..." \
  -F "selfie=@selfie.jpg" \
  -F "passport=@passport.jpg" \
  -F "address=@utility_bill.pdf"

# 3. Request code: sandbox → mock_code "000000"
curl -X POST https://fin-os.io/api/v1/customers/$UUID/kyc/request-code \
  -H "Authorization: Bearer sk_test_..."

# 4. Verify code: 6 цифр; sandbox "000000" → мгновенно verified
curl -X POST https://fin-os.io/api/v1/customers/$UUID/kyc/verify-code \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"code": "000000"}'

# 5. Status: поллинг (читается без consent)
curl https://fin-os.io/api/v1/customers/$UUID/kyc/status \
  -H "Authorization: Bearer sk_test_..."

# Reset: начать заново (максимум 5 / 24ч)
curl -X POST https://fin-os.io/api/v1/customers/$UUID/kyc/reset \
  -H "Authorization: Bearer sk_test_..."
const FINOS = 'https://fin-os.io/api/v1';
const KEY   = process.env.FINOS_SECRET_KEY; // sk_test_ в sandbox
const auth  = { 'Authorization': `Bearer ${KEY}` };

// Полный sandbox-flow: verified за один прогон
async function runKyc(uuid, phone, files) {
  // 1. start (kyc_status: not_started → pending)
  await fetch(`${FINOS}/customers/${uuid}/kyc/start`, {
    method: 'POST',
    headers: { ...auth, 'Content-Type': 'application/json' },
    body: JSON.stringify({ phone, passport_type: 'ru' }),
  });

  // 2. documents (multipart → pending → processing)
  const fd = new FormData();
  if (files.selfie)   fd.append('selfie', files.selfie);
  if (files.passport) fd.append('passport', files.passport);
  if (files.address)  fd.append('address', files.address);
  await fetch(`${FINOS}/customers/${uuid}/kyc/documents`, {
    method: 'POST', headers: auth, body: fd,
  });

  // 3. request-code → sandbox mock_code "000000"
  const codeRes = await fetch(`${FINOS}/customers/${uuid}/kyc/request-code`, {
    method: 'POST', headers: auth,
  });
  const { data } = await codeRes.json();
  const code = data.mock_code ?? '000000'; // в live код приходит по SMS

  // 4. verify-code → verified, kyc_level 2
  const verifyRes = await fetch(`${FINOS}/customers/${uuid}/kyc/verify-code`, {
    method: 'POST',
    headers: { ...auth, 'Content-Type': 'application/json' },
    body: JSON.stringify({ code }),
  });
  return (await verifyRes.json()).data; // { kyc_status: 'verified', kyc_level: 2 }
}

async function kycStatus(uuid) {
  const r = await fetch(`${FINOS}/customers/${uuid}/kyc/status`, { headers: auth });
  return (await r.json()).data;
}
use Illuminate\Support\Facades\Http;

class FinosKyc
{
    private string $base = 'https://fin-os.io/api/v1';

    public function __construct(private string $secretKey) {} // sk_test_ или sk_live_

    public function start(string $uuid, string $phone, string $passportType = 'ru'): array
    {
        return Http::withToken($this->secretKey)
            ->post("{$this->base}/customers/{$uuid}/kyc/start", [
                'phone'         => $phone,
                'passport_type' => $passportType,
            ])
            ->json('data');
    }

    public function submitDocuments(string $uuid, string $selfie, string $passport, ?string $address = null): array
    {
        $req = Http::withToken($this->secretKey)
            ->attach('selfie', file_get_contents($selfie), basename($selfie))
            ->attach('passport', file_get_contents($passport), basename($passport));
        if ($address) {
            $req = $req->attach('address', file_get_contents($address), basename($address));
        }
        return $req->post("{$this->base}/customers/{$uuid}/kyc/documents")->json('data');
    }

    public function verify(string $uuid): array
    {
        // sandbox: request-code отдаёт mock_code "000000"
        $code = Http::withToken($this->secretKey)
            ->post("{$this->base}/customers/{$uuid}/kyc/request-code")
            ->json('data.mock_code') ?? '000000';

        return Http::withToken($this->secretKey)
            ->post("{$this->base}/customers/{$uuid}/kyc/verify-code", ['code' => $code])
            ->json('data'); // ['kyc_status' => 'verified', 'kyc_level' => 2]
    }

    public function status(string $uuid): array
    {
        return Http::withToken($this->secretKey)
            ->get("{$this->base}/customers/{$uuid}/kyc/status")
            ->json('data');
    }
}
import requests, os

FINOS = 'https://fin-os.io/api/v1'
KEY   = os.environ['FINOS_SECRET_KEY']  # sk_test_ в sandbox
H     = {'Authorization': f'Bearer {KEY}'}

def start(uuid, phone, passport_type='ru'):
    r = requests.post(f'{FINOS}/customers/{uuid}/kyc/start', headers=H,
                      json={'phone': phone, 'passport_type': passport_type})
    r.raise_for_status()
    return r.json()['data']

def submit_documents(uuid, selfie, passport, address=None):
    files = {'selfie': open(selfie, 'rb'), 'passport': open(passport, 'rb')}
    if address:
        files['address'] = open(address, 'rb')
    r = requests.post(f'{FINOS}/customers/{uuid}/kyc/documents', headers=H, files=files)
    r.raise_for_status()
    return r.json()['data']

def verify(uuid):
    # sandbox: request-code возвращает mock_code "000000" (в live — приходит по SMS)
    code = requests.post(f'{FINOS}/customers/{uuid}/kyc/request-code',
                         headers=H).json()['data'].get('mock_code', '000000')
    r = requests.post(f'{FINOS}/customers/{uuid}/kyc/verify-code',
                      headers=H, json={'code': code})
    r.raise_for_status()
    return r.json()['data']  # {'kyc_status': 'verified', 'kyc_level': 2}

# usage
start(customer_uuid, '+79001234567')
submit_documents(customer_uuid, 'selfie.jpg', 'passport.jpg', 'utility_bill.pdf')
result = verify(customer_uuid)
print('KYC:', result['kyc_status'], 'level', result['kyc_level'])