Build with Zoi ID
Two APIs on one host, typed clients in six languages, a hosted flow and a sandbox. The full guide is open to read, and sandbox keys come with early access.
Base URLhttps://kyc.zoi.network
Quickstart
- 1
Create a session
Your backend calls
POST /v1/partner/sessionswith your partner key. - 2
Run the flow
Pass the client token to the SDK, or open
verificationUrlin a browser. - 3
Receive the result
Check the webhook signature, then store the identity id and its status.
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" }'Two APIs
Partner API
Server to server, under /v1/partner. Send your partner key as a Bearer token. Never put it in an app.
Client API
Used by the SDK, under /v1/client. It sends a client token that works for one session for 60 minutes.
Conventions
- Times are epoch milliseconds, in fields whose names end in
At. - Dates are
YYYY-MM-DD. Unknown parts areXX, for example1990-XX-XX. - POST requests that create or decide something accept an
Idempotency-Keyheader. Keys are kept for 24 hours. - Changes to a session send the revision they expect. If it has moved, you get
revision_conflict.
Session flow
Your backend creates the session. The SDK runs the steps. The server checks the order and answers step_not_allowed when a step comes too early.
1. Create a session from your backend
Calling it again for the same user resumes the open session and gives a new client token.
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. Steps the SDK runs
- 01
Read the session and the steps it still needs.
GET /v1/client/session
- 02
Record consent, with the separate optional choice for face comparison.
POST /v1/client/consent
- 03
Check the app and the device. The result is part of the decision.
POST /v1/client/attestation/challenge, POST /v1/client/attestation
- 04
Send the document: read the chip after an MRZ scan, or send photos.
POST /v1/client/document/mrz-scan, POST /v1/client/document/chip/challenge, POST /v1/client/document/chip or POST /v1/client/document/photos
- 05
Record one short front camera video while the app shows the head moves.
POST /v1/client/liveness/challenge, POST /v1/client/liveness
- 06
Send the country of residence, and the address on the enhanced level.
POST /v1/client/profile
- 07
Only with the address requirement: upload a document, then confirm the address. Skipped when the identity document already gave it.
POST /v1/client/address/document, POST /v1/client/address/confirm
- 08
Only with a funds requirement: save the answers, add documents when the policy asks for them, then complete the step.
POST /v1/client/funds/answers, POST /v1/client/funds/document, POST /v1/client/funds/remove, POST /v1/client/funds/complete
- 09
Answer two questions: politically exposed person, and acting on own behalf.
POST /v1/client/declarations
- 10
Submit with the current revision.
POST /v1/client/submit
The user can also ask a person to review an automated result (POST /review-request) or withdraw consent, which cancels the session and deletes its files at once (POST /withdraw).
Proof of address and source of funds
Add requirements to POST /v1/partner/sessions to verify more than the identity. address asks for proof of address. source_of_funds asks where the money comes from, and source_of_wealth also asks for total wealth and how it was built. The two funds values together are one step, and both combine with 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" }
}Optional policies
Without a policy, your partner default applies, else ours. The policy is copied onto the session when it is created.
addressPolicy:maxAgeDays(default 90, 1 to 366),acceptedTypes(default: utility, telecom and bank documents, tax documents, government letters and residence certificates) andsignedOnly(default false).fundsPolicy:questions,evidence(none,optionalorrequired, defaultnone),acceptedTypes,maxAgeDays(default 180),currency(ISO 4217, default USD) andbracketsfor the ranges.- An open session with other requirements that was not submitted yet is cancelled and a new one starts, like a level change.
- Test each outcome with sandbox scenarios such as
address_signed_pdf,address_document_review,funds_signed_documentsandfunds_review.
Follow-up sessions
Verify only the requirements for a user whose identity is already active with you. Send reuseIdentity: true together with requirements.
- The user gives consent and completes only the new steps. No document, selfie or declarations are asked again.
- On approval the identity keeps its id, verification date and validity, and gets the new result. You receive
session.approvedandidentity.updated. - Without an active identity, the call answers 409
step_not_allowed. - Start it from your own risk rules, such as a large deposit, a PEP flag or a risk country. Your rules stay in your backend, in one place.
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", ... } }Results and scopes
Approved sessions carry address and funds, and the identity keeps the latest of each. The summary fields come with every scope. The details need a scope.
- Scope
address: the address lines. Without it you getverified,method,evidenceLevel,documentType,documentDate,verifiedAtandcountry. - Scope
financial_data:answers,documentsandfindings. Without it you getkinds,evidenceLevel,reviewed,documentCountandverifiedAt. It is never part ofeligibility. evidenceLevelishigh,medium,lowornone. Address methods, strongest first:wallet,id_document,signed_pdf,official_code,document_reviewanddeclared.- Documents are not sent through the API. Members with the compliance role can view source of funds document pages in the dashboard when the environment has the
financial_datascope. Every view is logged.
"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": []
}
}Web flow
No SDK? Send the user to our hosted page. It uses the same API, the same checks and the same review queue.
1. Get the link
POST /v1/partner/sessions returns verificationUrl. Pass redirectUrl to send the user back when they finish. It must start with one of your registered redirect origins.
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",
...
}- The client token is in the URL fragment, after
#. Browsers never send the fragment to a server, so the token is not in logs or in theRefererheader. The page reads it, removes it from the address bar and keeps it only in memory. - Add
langto the fragment to pick one of the 19 languages. Without it, the page uses the browser language. - A reload loses the token. Create a new link for the same session from your backend.
- The page sends
channel: "web"with the consent. Browsers cannot read ID chips, so web sessions use document photos or a digital ID wallet. They skip the device check, and document photos from the web are always decided by a trained reviewer. - On a computer, the page shows a QR code so the user can continue on a phone.
2. When the user is done
When the flow ends (sent, cancelled or a final status), the page posts this message to each of these that exists: window.ReactNativeWebView, window.webkit.messageHandlers.zoiId, window.ZoiIdAndroid and window.opener. Then it opens redirectUrl with zoiIdSession and zoiIdStatus added.
Never trust the message or the redirect parameters. Trust only your webhook or 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 settings
Android WebView
Turn on javaScriptEnabled and domStorageEnabled, and set mediaPlaybackRequiresUserGesture to false. In a WebChromeClient, grant onPermissionRequest for RESOURCE_VIDEO_CAPTURE after your app holds the CAMERA permission. Add a file chooser for the photo fallback.
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 or later. Set allowsInlineMediaPlayback to true and add NSCameraUsageDescription to your app. On iOS 15 and later you can decide the camera permission in the 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
Set mediaCapturePermissionGrantType="grant", allowsInlineMediaPlayback and javaScriptEnabled. Your app needs the camera permission.
<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.
}
}}
/>iframes are not supported, because of camera permissions and clickjacking. Use a browser tab, a popup or a webview.
Statuses
Session
created- The session exists, nothing uploaded yet.
in_progress- Consent given, steps are being uploaded.
processing- Submitted, checks are running.
review- Waiting for a trained reviewer.
approved- Final. The identity is verified.
rejected- Final. See the reason codes.
resubmit- The user must redo the steps in requiredSteps.
expired- No final decision in time.
cancelled- Cancelled by the partner, the user or an erasure.
Identity
active- Usable.
suspended- On hold, for example after a new sanctions candidate.
expired- The document or the verification period ended. A new session verifies again.
revoked- Final.
SDK
The React Native (Expo) SDK gives you a typed API client, NFC chip reading and device checks. It is headless, so your app keeps full control of its screens and its camera. For your servers, use the typed clients for TypeScript, Python, Go, PHP, Java and 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;
}Webhooks
We send a webhook when a session or an identity changes. Always check the signature before you trust the body.
- Every webhook carries a
Zoi-Id-Signatureheader:t=unix seconds,v1=hex. v1is the HMAC-SHA256 of the textt.raw bodywith your webhook secret. Use the raw body, before any JSON parsing.- While a secret is being changed, the header carries one
v1per active secret. Accept the webhook if any of them matches. - Reject it if
tis more than 300 seconds away from now. - Failed deliveries are retried for 72 hours. Each retry is signed again with a new
t. - Identities carry a
revision. Apply an event only if its revision is newer than the one you stored.
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"
}
}Event types
session.processingsession.reviewsession.approvedsession.rejectedsession.resubmitsession.expiredsession.cancelledidentity.updatedidentity.suspendedidentity.reinstatedidentity.expiredidentity.revoked
Check the signature in 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 });
}Check the signature in 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
}Events feed
GET /v1/partner/events returns every event of the last 30 days, oldest first. Pass after with the last nextCursor to read the next page. Use it after a long outage, or to check that you missed nothing.
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
}Fraud reports
Tell us when a decided session turns out to be fraud, for example after a chargeback. Reports feed our measurement of wrong approvals. They do not change the session, the identity or the user's outcome.
- Scope
sessions:write. Only for decided sessions (approved or rejected). Before a decision the call answers 409. - An empty body reports
confirmed_fraudwith categoryother.statuscan also besuspected_fraud, orclearedto take back your own report. - Categories:
forged_document,impostor,deepfake_or_replay,synthetic_identity,account_takeoverandother. No category withcleared. noteis free text for our analysts, at most 1000 characters. Never put names, document numbers or other personal data in it.- A new report answers 201. The same report again returns the stored one with 200. Send an
Idempotency-Keyto retry safely. At most 20 reports per session. - The same report is one click away on the session page of the dashboard.
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
}Erasure
DELETE /v1/partner/users/:externalUserId deletes a user's data when they close their account or ask for erasure. The response lists what was deleted now, and what is kept, why and until when.
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
}Errors
Every error has the same shape and a fixed HTTP status per code.
{
"error": {
"code": "step_not_allowed",
"message": "This step is not allowed now.",
"requestId": "req_..."
}
}| Code | HTTP | Meaning |
|---|---|---|
bad_request | 400 | The request does not match the contract. |
unauthorized | 401 | Missing or wrong key or token. |
token_expired | 401 | The client token expired. Create a new one from your backend. |
forbidden | 403 | The key may not do this. |
not_found | 404 | No such session or user. |
session_closed | 409 | The session is final. No more uploads. |
step_not_allowed | 409 | Wrong order, or the step is not needed now. |
revision_conflict | 409 | The session revision moved. Read it again. |
idempotency_conflict | 409 | The same Idempotency-Key was used with a different request. |
challenge_expired | 410 | The challenge expired or was already used. Ask for a new one. |
payload_too_large | 413 | The upload is too big. |
unsupported_media_type | 415 | The file type is not accepted. |
rate_limited | 429 | Too many requests. Try again later. |
internal | 500 | Something went wrong on our side. |
dependency_unavailable | 503 | A service we need is down. Try again later. |
Reason codes
Sessions carry reasonCodes when they need a resubmit or are rejected. Show your own translated message for each code. Never show the raw code to users.
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