KYC Verification
KYC-as-a-Service для managed customers: партнёр от имени своего end-user'а запускает верификацию, загружает документы, подтверждает телефон по SMS и отслеживает статус. Finance OS выступает прокси-фасадом — документы принимаются на стороне Finance OS и передаются во внутреннюю систему верификации; апстрим-инфраструктура партнёру не видна.
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.
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.
kyc_rejection_reason. Начать заново можно через reset.'],
['name' => 'expired', 'type' => 'терминальный', 'desc' => 'Сессия / результат верификации истёк (live). Требуется новый start после reset.'],
]" />
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
| Name | Type | Required | Description |
|---|---|---|---|
0
|
numeric
|
optional |
Не верифицирован. Значение по умолчанию и после reset.
|
1
|
numeric
|
optional | Базовый: телефон привязан, документы ещё не подтверждены. |
2
|
numeric
|
optional |
Полная верификация физлица: документ + селфи + подтверждённый телефон. Присваивается при успешном verify-code.
|
3
|
numeric
|
optional | Расширенная проверка (подтверждение адреса / дополнительные документы). |
1. Start
/api/v1/customers/{uuid}/kyc/start
Запустить KYC-сессию
kyc_status из not_started в pending. В live дополнительно регистрирует customer'а во внутренней системе верификации; в sandbox апстрим не вызывается.Body
phone
string
required
+ и 7–20 цифр, regex ^\+[1-9][0-9]{6,19}$). Пример: +79001234567.passport_type
enum
optional
ru (внутренний паспорт РФ) или foreign (иностранный документ).ru| Name | Type | Required | Description |
|---|---|---|---|
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
/api/v1/customers/{uuid}/kyc/documents
Загрузить документы (multipart/form-data)
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
jpg, jpeg, png или pdf, до 10 MB.| Name | Type | Required | Description |
|---|---|---|---|
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_uploaded → uploaded → success (или rejected в live).
3. Request code
/api/v1/customers/{uuid}/kyc/request-code
Отправить 6-значный SMS-код
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
/api/v1/customers/{uuid}/kyc/verify-code
Подтвердить код и завершить верификацию
000000 переводит customer'а в verified (kyc_level=2); любой другой код → 422 INVALID_CODE. В live результат синхронизируется с внутренней системой верификации.Body
code
string
required
000000.| Name | Type | Required | Description |
|---|---|---|---|
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
/api/v1/customers/{uuid}/kyc/status
Снимок текущего состояния KYC
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
/api/v1/customers/{uuid}/kyc/reset
Сбросить и начать заново
kyc_status → not_started, kyc_level → 0. Лимит — 5 сбросов за 24 часа на customer'а; при превышении 422 RESET_LIMIT_EXCEEDED. После сброса начните с start.Responses
{
"data": {
"ok": true,
"attempts_remaining": 4
}
}
Коды ошибок
Ошибки возвращаются в конверте { "error": { "code", "message", "request_id" } }.
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'])