Разрабатывайте с Zoi ID
Два API на одном хосте, типизированные клиенты на шести языках, размещённый сценарий и песочница. Полное руководство открыто уже сейчас, а ключи песочницы выдаются вместе с ранним доступом.
Базовый URLhttps://kyc.zoi.network
Быстрый старт
- 1
Создайте сессию
Ваш бэкенд вызывает
POST /v1/partner/sessionsсо своим партнёрским ключом. - 2
Запустите сценарий
Передайте клиентский токен в SDK или откройте
verificationUrlв браузере. - 3
Получите результат
Проверьте подпись вебхука, затем сохраните идентификатор личности и её статус.
curl https://kyc.zoi.network/v1/partner/sessions \
-H "Authorization: Bearer $ZOI_ID_PARTNER_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: signup-user_42" \
-d '{ "externalUserId": "user_42", "level": "standard", "appId": "com.example.app" }'Два API
Партнёрский API
Сервер к серверу, по пути /v1/partner. Передавайте партнёрский ключ как Bearer-токен. Никогда не кладите его в приложение.
Клиентский API
Используется SDK, по пути /v1/client. Он передаёт клиентский токен, который действует для одной сессии 60 минут.
Соглашения
- Время задаётся в миллисекундах epoch, в полях, имя которых оканчивается на
At. - Даты в формате
YYYY-MM-DD. Неизвестные части пишутся какXX, например1990-XX-XX. - POST-запросы, которые что-то создают или решают, принимают заголовок
Idempotency-Key. Ключи хранятся 24 часа. - Изменения сессии передают ожидаемую ревизию. Если она изменилась, вы получите
revision_conflict.
Ход сессии
Ваш бэкенд создаёт сессию. SDK выполняет шаги. Сервер следит за порядком и отвечает step_not_allowed, если шаг пришёл слишком рано.
1. Создайте сессию на бэкенде
Повторный вызов для того же пользователя продолжает открытую сессию и выдаёт новый клиентский токен.
POST /v1/partner/sessions
Authorization: Bearer <partner key>
Content-Type: application/json
Idempotency-Key: 6f1c2a9e-signup-42
{
"externalUserId": "user_42",
"level": "standard",
"locale": "en",
"appId": "com.example.app"
}
200 OK
{
"sessionId": "ses_...",
"clientToken": "...",
"clientTokenExpiresAt": 1767225600000,
"status": "created",
"apiBaseUrl": "https://kyc.zoi.network",
"resumed": false
}2. Шаги, которые выполняет SDK
- 01
Прочитать сессию и оставшиеся шаги.
GET /v1/client/session
- 02
Записать согласие, вместе с отдельным необязательным выбором о сравнении лица.
POST /v1/client/consent
- 03
Проверить приложение и устройство. Результат учитывается в решении.
POST /v1/client/attestation/challenge, POST /v1/client/attestation
- 04
Отправить документ: прочитать чип после сканирования MRZ или отправить фото.
POST /v1/client/document/mrz-scan, POST /v1/client/document/chip/challenge, POST /v1/client/document/chip or POST /v1/client/document/photos
- 05
Записать одно короткое видео фронтальной камерой, пока приложение показывает движения головы.
POST /v1/client/liveness/challenge, POST /v1/client/liveness
- 06
Отправить страну проживания, а на расширенном уровне и адрес.
POST /v1/client/profile
- 07
Только с требованием адреса: загрузить документ, затем подтвердить адрес. Пропускается, если адрес уже дал документ, удостоверяющий личность.
POST /v1/client/address/document, POST /v1/client/address/confirm
- 08
Только с требованием по средствам: сохранить ответы, добавить документы, если этого требует политика, затем завершить шаг.
POST /v1/client/funds/answers, POST /v1/client/funds/document, POST /v1/client/funds/remove, POST /v1/client/funds/complete
- 09
Ответить на два вопроса: является ли человек публичным должностным лицом и действует ли от своего имени.
POST /v1/client/declarations
- 10
Отправить с текущей ревизией.
POST /v1/client/submit
Пользователь также может попросить человека пересмотреть автоматический результат (POST /review-request) или отозвать согласие, что отменяет сессию и сразу удаляет её файлы (POST /withdraw).
Подтверждение адреса и источника средств
Добавьте requirements в POST /v1/partner/sessions, чтобы проверить не только личность. address запрашивает подтверждение адреса. source_of_funds спрашивает, откуда деньги, а source_of_wealth также спрашивает об общем состоянии и о том, как оно сложилось. Оба значения по средствам вместе образуют один шаг, и оба сочетаются с address.
POST /v1/partner/sessions
Authorization: Bearer <partner key>
Content-Type: application/json
{
"externalUserId": "user_42",
"level": "standard",
"appId": "com.example.app",
"requirements": ["address", "source_of_funds"],
// Optional. Without them your partner default applies, else ours.
"addressPolicy": { "maxAgeDays": 90, "signedOnly": false },
"fundsPolicy": { "evidence": "optional", "currency": "EUR" }
}Необязательные политики
Без политики действуют ваши партнёрские настройки по умолчанию, а если их нет, наши. Политика копируется в сессию при её создании.
addressPolicy:maxAgeDays(по умолчанию 90, от 1 до 366),acceptedTypes(по умолчанию: коммунальные, телеком- и банковские документы, налоговые документы, письма госорганов и справки о месте жительства) иsignedOnly(по умолчанию false).fundsPolicy:questions,evidence(none,optionalилиrequired, по умолчаниюnone),acceptedTypes,maxAgeDays(по умолчанию 180),currency(ISO 4217, по умолчанию USD) иbracketsдля диапазонов.- Открытая сессия с другими требованиями, которая ещё не отправлена, отменяется, и начинается новая, как при смене уровня.
- Проверьте каждый исход на тест-кейсах песочницы, например
address_signed_pdf,address_document_review,funds_signed_documentsиfunds_review.
Дополнительные сессии
Проверяйте только требования для пользователя, чья личность у вас уже активна. Передайте reuseIdentity: true вместе с requirements.
- Пользователь даёт согласие и проходит только новые шаги. Документ, селфи и декларации повторно не запрашиваются.
- При одобрении личность сохраняет идентификатор, дату проверки и срок действия и получает новый результат. Вы получаете
session.approvedиidentity.updated. - Без активной личности вызов возвращает 409
step_not_allowed. - Запускайте её по своим правилам риска, например при крупном депозите, отметке PEP или стране повышенного риска. Ваши правила остаются в вашем бэкенде, в одном месте.
POST /v1/partner/sessions
Authorization: Bearer <partner key>
Content-Type: application/json
Idempotency-Key: edd-user_42-2026-11
{
"externalUserId": "user_42",
"appId": "com.example.app",
"requirements": ["source_of_funds", "source_of_wealth"],
"reuseIdentity": true,
"fundsPolicy": { "evidence": "required" }
}
// No active identity for user_42:
409 { "error": { "code": "step_not_allowed", ... } }Результаты и разрешения
Одобренные сессии содержат address и funds, а личность хранит последний результат по каждому. Поля сводки приходят с любым разрешением. Для деталей нужно разрешение.
- Разрешение
address: строки адреса. Без него вы получаетеverified,method,evidenceLevel,documentType,documentDate,verifiedAtиcountry. - Разрешение
financial_data:answers,documentsиfindings. Без него вы получаетеkinds,evidenceLevel,reviewed,documentCountиverifiedAt. Оно никогда не входит вeligibility. evidenceLevelпринимает значенияhigh,medium,lowилиnone. Способы подтверждения адреса, от самого сильного:wallet,id_document,signed_pdf,official_code,document_reviewиdeclared.- Документы не передаются через API. Участники с ролью комплаенса могут смотреть страницы документов об источнике средств в панели, если у окружения есть разрешение
financial_data. Каждый просмотр записывается.
"session": {
"id": "ses_...",
"status": "approved",
"requirements": ["address", "source_of_funds"],
"reusesIdentity": false,
"address": {
"verified": true,
"method": "signed_pdf",
"evidenceLevel": "high",
"documentType": "bank_statement",
"documentDate": "2026-10-14",
"verifiedAt": 1767225600000,
"country": "DE",
// scope "address"
"address": { "line1": "Musterstrasse 12", "city": "Berlin", "postalCode": "10115", "country": "DE" }
},
"funds": {
"kinds": ["source_of_funds"],
"evidenceLevel": "medium",
"reviewed": true,
"documentCount": 2,
"verifiedAt": 1767225600000,
// scope "financial_data"
"answers": {
"employmentStatus": "employed",
"income": { "min": 50000, "max": 100000, "currency": "EUR" },
"sources": ["salary", "savings"],
...
},
"documents": [
{ "documentType": "payslip", "documentDate": "2026-10-31", "amount": 5200, "currency": "EUR",
"signature": "valid", "signedByKnownIssuer": true, "nameMatch": "match", "findings": [], ... },
...
],
"findings": []
}
}Веб-сценарий
Нет SDK? Отправьте пользователя на нашу страницу. Она использует тот же API, те же проверки и ту же очередь ручной проверки.
1. Получите ссылку
POST /v1/partner/sessions возвращает verificationUrl. Передайте redirectUrl, чтобы вернуть пользователя к вам в конце. Адрес должен начинаться с одного из ваших зарегистрированных источников перенаправления.
POST /v1/partner/sessions
Authorization: Bearer <partner key>
Content-Type: application/json
{
"externalUserId": "user_42",
"level": "standard",
"locale": "en",
"appId": "com.example.app",
"redirectUrl": "https://example.com/kyc/done"
}
200 OK
{
"sessionId": "ses_...",
"clientToken": "...",
"verificationUrl": "https://kyc.zoi.network/verify/#token=...&lang=en",
...
}- Клиентский токен находится во фрагменте URL, после
#. Браузеры никогда не отправляют фрагмент на сервер, поэтому токен не попадает в логи и в заголовокReferer. Страница читает его, удаляет из адресной строки и хранит только в памяти. - Добавьте
langво фрагмент, чтобы выбрать один из 19 языков. Без него страница использует язык браузера. - При перезагрузке токен теряется. Создайте на своём бэкенде новую ссылку для той же сессии.
- Страница отправляет
channel: "web"вместе с согласием. Браузеры не умеют читать чипы документов, поэтому веб-сессии используют фото документа или цифровой ID-кошелёк. Они пропускают проверку устройства, а фото документов из веба всегда решает обученный проверяющий. - На компьютере страница показывает QR-код, чтобы пользователь продолжил на телефоне.
2. Когда пользователь закончил
Когда сценарий заканчивается (отправлено, отменено или финальный статус), страница отправляет это сообщение каждому из этих объектов, который существует: window.ReactNativeWebView, window.webkit.messageHandlers.zoiId, window.ZoiIdAndroid и window.opener. Затем она открывает redirectUrl с добавленными zoiIdSession и zoiIdStatus.
Никогда не доверяйте сообщению или параметрам перенаправления. Доверяйте только своему вебхуку или GET /v1/partner/sessions/:id.
// Sent to each bridge that exists:
// window.ReactNativeWebView.postMessage(JSON string)
// window.webkit.messageHandlers.zoiId.postMessage(object)
// window.ZoiIdAndroid.postMessage(JSON string)
// window.opener.postMessage(object, "<origin of your redirectUrl>")
// (popups only, and only when the session has a redirectUrl)
{
"source": "zoi-id",
"type": "finished", // or "cancelled"
"sessionId": "ses_...",
"status": "processing" // the session status at that moment
}
// Then, if redirectUrl was set (the page uses the one stored with the
// session, never one from the address bar):
https://example.com/kyc/done?zoiIdSession=ses_...&zoiIdStatus=processing3. Настройки webview
Android WebView
Включите javaScriptEnabled и domStorageEnabled и установите mediaPlaybackRequiresUserGesture в false. В WebChromeClient разрешите onPermissionRequest для RESOURCE_VIDEO_CAPTURE после того, как приложение получит разрешение CAMERA. Добавьте выбор файла для запасного варианта с фото.
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.settings.mediaPlaybackRequiresUserGesture = false
webView.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
// Only after your app holds Manifest.permission.CAMERA.
if (request.resources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) {
request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
} else {
request.deny()
}
}
override fun onShowFileChooser(
view: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams,
): Boolean {
// Open a picker and pass the result to callback (photo fallback).
return true
}
}iOS WKWebView
iOS 14.3 или новее. Установите allowsInlineMediaPlayback в true и добавьте NSCameraUsageDescription в приложение. В iOS 15 и новее разрешение на камеру можно решать в UI delegate.
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
let webView = WKWebView(frame: .zero, configuration: config)
webView.uiDelegate = self // iOS 15+: decide camera access below
func webView(_ webView: WKWebView,
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo,
type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void) {
decisionHandler(origin.host == "kyc.zoi.network" ? .grant : .deny)
}
// Info.plist: NSCameraUsageDescriptionReact Native WebView
Задайте mediaCapturePermissionGrantType="grant", allowsInlineMediaPlayback и javaScriptEnabled. Приложению нужно разрешение на камеру.
<WebView
source={{ uri: verificationUrl }}
javaScriptEnabled
allowsInlineMediaPlayback
mediaCapturePermissionGrantType="grant"
onMessage={(event) => {
const message = JSON.parse(event.nativeEvent.data);
if (message.source === "zoi-id") {
// Close the webview, then ask your backend for the real status.
}
}}
/>iframe не поддерживаются из-за разрешений камеры и кликджекинга. Используйте вкладку браузера, всплывающее окно или webview.
Статусы
Сессия
created- Сессия создана, ничего ещё не загружено.
in_progress- Согласие получено, шаги загружаются.
processing- Отправлена, идут проверки.
review- Ждёт обученного проверяющего.
approved- Окончательно. Личность подтверждена.
rejected- Окончательно. Смотрите коды причин.
resubmit- Пользователь должен повторить шаги из requiredSteps.
expired- Окончательное решение не принято вовремя.
cancelled- Отменена партнёром, пользователем или удалением.
Личность
active- Можно использовать.
suspended- Приостановлена, например после нового совпадения с санкционным списком.
expired- Срок документа или проверки закончился. Новая сессия проверит заново.
revoked- Окончательно.
SDK
SDK для React Native (Expo) даёт типизированный API-клиент, чтение чипа по NFC и проверку устройства. Он работает без интерфейса, поэтому ваше приложение полностью управляет своими экранами и камерой. Для серверов используйте типизированные клиенты для TypeScript, Python, Go, PHP, Java и Ruby.
import { ZoiIdClient, nextStep } from "@zoi/id-sdk";
// clientToken and apiBaseUrl come from your backend (POST /v1/partner/sessions).
const client = new ZoiIdClient({ baseUrl: apiBaseUrl, clientToken });
const session = await client.getSession();
switch (nextStep(session)) {
case "consent": /* show the notice and the biometric choice */ break;
case "document": /* chip read or photos */ break;
case "liveness": /* record the video */ break;
case "address": /* upload a document, then confirm the address */ break;
case "funds": /* answers, then documents when the policy asks */ break;
case "submit": await client.submit(session.revision); break;
case "done": /* wait for your backend to get the webhook */ break;
}Вебхуки
Мы отправляем вебхук, когда меняется сессия или личность. Всегда проверяйте подпись, прежде чем доверять содержимому.
- Каждый вебхук содержит заголовок
Zoi-Id-Signature:t=unix seconds,v1=hex. v1это HMAC-SHA256 от текстаt.raw bodyс вашим секретом вебхука. Используйте исходное тело, до разбора JSON.- Во время смены секрета заголовок содержит по одному
v1на каждый активный секрет. Принимайте вебхук, если совпал любой из них. - Отклоняйте его, если
tотличается от текущего времени больше чем на 300 секунд. - Неудачные доставки повторяются в течение 72 часов. Каждая попытка подписывается заново с новым
t. - У личностей есть
revision. Применяйте событие, только если его ревизия новее сохранённой у вас.
POST <your webhook URL>
Zoi-Id-Signature: t=1767225600,v1=5f2b...e9
{
"v": 1,
"eventId": "evt_...",
"type": "identity.suspended",
"createdAt": 1767225600000,
"object": "identity",
"externalUserId": "user_42",
"identity": {
"identityId": "idn_...",
"revision": 7,
"status": "suspended",
"level": "standard",
"verifiedAt": 1764547200000,
"validUntil": 1859155200000,
"ageOver18": true,
"captureMethod": "nfc"
}
}Типы событий
session.processingsession.reviewsession.approvedsession.rejectedsession.resubmitsession.expiredsession.cancelledidentity.updatedidentity.suspendedidentity.reinstatedidentity.expiredidentity.revoked
Проверка подписи на TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyZoiIdSignature(
rawBody: string,
header: string | null,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000),
): boolean {
if (!header) return false;
let t: string | undefined;
const signatures: Buffer[] = [];
for (const part of header.split(",")) {
const [key, value] = part.trim().split("=");
if (!value) continue;
if (key === "t") t = value;
if (key === "v1" && /^[0-9a-f]+$/i.test(value)) signatures.push(Buffer.from(value, "hex"));
}
if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;
if (Math.abs(nowSeconds - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return signatures.some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected));
}
// Example handler: read the raw body first, parse JSON only after the check.
export async function POST(request: Request) {
const rawBody = await request.text();
const ok = verifyZoiIdSignature(rawBody, request.headers.get("Zoi-Id-Signature"), process.env.ZOI_ID_WEBHOOK_SECRET!);
if (!ok) return new Response("bad signature", { status: 400 });
const event = JSON.parse(rawBody);
// Store event.eventId to skip duplicates, then apply it if the revision is newer.
return new Response(null, { status: 204 });
}Проверка подписи на Go
package zoiid
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300
// VerifySignature checks the Zoi-Id-Signature header against the raw body.
func VerifySignature(rawBody []byte, header, secret string, now time.Time) bool {
var t string
var sigs [][]byte
for _, part := range strings.Split(header, ",") {
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok || value == "" {
continue
}
switch key {
case "t":
t = value
case "v1":
if b, err := hex.DecodeString(value); err == nil {
sigs = append(sigs, b)
}
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || len(sigs) == 0 {
return false
}
if d := now.Unix() - ts; d > toleranceSeconds || d < -toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, sig := range sigs {
if hmac.Equal(sig, expected) {
return true
}
}
return false
}Лента событий
GET /v1/partner/events возвращает все события за последние 30 дней, от старых к новым. Передайте after с последним nextCursor, чтобы прочитать следующую страницу. Используйте после долгого сбоя или чтобы убедиться, что ничего не пропущено.
GET /v1/partner/events?after=<nextCursor>&limit=100
Authorization: Bearer <partner key>
200 OK
{
"items": [ /* webhook payloads, oldest first */ ],
"nextCursor": "..." // null when there is nothing more
}Сообщения о мошенничестве
Сообщите нам, если решённая сессия оказалась мошеннической, например после чарджбэка. Сообщения помогают нам измерять долю ошибочных одобрений. Они не меняют сессию, личность или исход для пользователя.
- Разрешение
sessions:write. Только для решённых сессий (одобренных или отклонённых). До решения вызов возвращает 409. - Пустое тело сообщает
confirmed_fraudс категориейother.statusтакже может бытьsuspected_fraudилиcleared, чтобы отозвать своё сообщение. - Категории:
forged_document,impostor,deepfake_or_replay,synthetic_identity,account_takeoverиother. Сclearedкатегория не указывается. - В
noteможно написать свободный текст для наших аналитиков, не более 1000 символов. Никогда не указывайте в нём имена, номера документов или другие персональные данные. - Новое сообщение возвращает 201. Повторное такое же сообщение возвращает уже сохранённое с кодом 200. Передавайте
Idempotency-Key, чтобы безопасно повторять запрос. Не более 20 сообщений на сессию. - То же сообщение можно отправить в один клик на странице сессии в панели.
POST /v1/partner/sessions/ses_.../fraud-reports
Authorization: Bearer <partner key>
Content-Type: application/json
Idempotency-Key: chargeback-8812
{
"status": "confirmed_fraud",
"category": "impostor",
"note": "Chargeback on the first deposit"
}
201 Created
{
"id": "flb_...",
"sessionId": "ses_...",
"status": "confirmed_fraud",
"category": "impostor",
"noteKept": true,
"createdAt": 1767225600000
}Удаление
DELETE /v1/partner/users/:externalUserId удаляет данные пользователя, когда он закрывает аккаунт или просит удалить данные. Ответ показывает, что удалено сейчас, а что хранится, почему и до какого срока.
DELETE /v1/partner/users/user_42
Authorization: Bearer <partner key>
200 OK
{
"deleted": ["images", "chip_files", "video", "personal_data", "address"],
"retained": [
{ "category": "dedup_keys", "reason": "fraud_prevention", "until": 1924992000000 },
{ "category": "sanctions_screening_records", "reason": "legal_obligation", "until": 2082758400000 }
],
"backupsPurgedWithinDays": 30
}Ошибки
Все ошибки имеют одинаковый вид, и у каждого кода свой постоянный HTTP-статус.
{
"error": {
"code": "step_not_allowed",
"message": "This step is not allowed now.",
"requestId": "req_..."
}
}| Код | HTTP | Значение |
|---|---|---|
bad_request | 400 | Запрос не соответствует контракту. |
unauthorized | 401 | Ключ или токен отсутствует или неверен. |
token_expired | 401 | Срок клиентского токена истёк. Создайте новый на бэкенде. |
forbidden | 403 | Этому ключу это запрещено. |
not_found | 404 | Такой сессии или пользователя нет. |
session_closed | 409 | Сессия завершена. Загружать больше нельзя. |
step_not_allowed | 409 | Неверный порядок, или шаг сейчас не нужен. |
revision_conflict | 409 | Ревизия сессии изменилась. Прочитайте её заново. |
idempotency_conflict | 409 | Тот же Idempotency-Key использован с другим запросом. |
challenge_expired | 410 | Срок проверочного запроса истёк, или он уже использован. Запросите новый. |
payload_too_large | 413 | Загрузка слишком большая. |
unsupported_media_type | 415 | Этот тип файла не принимается. |
rate_limited | 429 | Слишком много запросов. Повторите позже. |
internal | 500 | Что-то пошло не так на нашей стороне. |
dependency_unavailable | 503 | Нужный нам сервис недоступен. Повторите позже. |
Коды причин
Сессии содержат reasonCodes, если нужна повторная отправка или в проверке отказано. Показывайте для каждого кода своё переведённое сообщение. Никогда не показывайте пользователям код как есть.
consent_missingconsent_withdrawnattestation_faileddocument_expireddocument_unreadabledocument_not_supporteddocument_suspected_tamperingdocument_number_visiblechip_verification_failedchip_proof_missingchip_data_mismatchmrz_invalidphoto_qualityface_not_foundface_mismatchliveness_failedunderagelocation_not_supportedresidence_not_supportedsanctions_matchpep_reviewduplicate_identitypossible_duplicatedeclaration_requires_reviewuser_requested_reviewreviewer_otheraddress_unreadableaddress_document_too_oldaddress_document_not_acceptedaddress_name_mismatchaddress_signature_requiredfunds_document_unreadablefunds_document_too_oldfunds_document_not_acceptedfunds_more_evidencefunds_evidence_rejected