Skip to content
Zoi ID

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. 1

    Create a session

    Your backend calls POST /v1/partner/sessions with your partner key.

  2. 2

    Run the flow

    Pass the client token to the SDK, or open verificationUrl in a browser.

  3. 3

    Receive the result

    Check the webhook signature, then store the identity id and its status.

shell
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 are XX, for example 1990-XX-XX.
  • POST requests that create or decide something accept an Idempotency-Key header. 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
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

  1. 01

    Read the session and the steps it still needs.

    GET /v1/client/session

  2. 02

    Record consent, with the separate optional choice for face comparison.

    POST /v1/client/consent

  3. 03

    Check the app and the device. The result is part of the decision.

    POST /v1/client/attestation/challenge, POST /v1/client/attestation

  4. 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

  5. 05

    Record one short front camera video while the app shows the head moves.

    POST /v1/client/liveness/challenge, POST /v1/client/liveness

  6. 06

    Send the country of residence, and the address on the enhanced level.

    POST /v1/client/profile

  7. 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

  8. 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

  9. 09

    Answer two questions: politically exposed person, and acting on own behalf.

    POST /v1/client/declarations

  10. 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
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) and signedOnly (default false).
  • fundsPolicy: questions, evidence (none, optional or required, default none), acceptedTypes, maxAgeDays (default 180), currency (ISO 4217, default USD) and brackets for 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_documents and funds_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.approved and identity.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
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 get verified, method, evidenceLevel, documentType, documentDate, verifiedAt and country.
  • Scope financial_data: answers, documents and findings. Without it you get kinds, evidenceLevel, reviewed, documentCount and verifiedAt. It is never part of eligibility.
  • evidenceLevel is high, medium, low or none. Address methods, strongest first: wallet, id_document, signed_pdf, official_code, document_review and declared.
  • 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_data scope. Every view is logged.
GET /v1/partner/sessions/:id
"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
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 the Referer header. The page reads it, removes it from the address bar and keeps it only in memory.
  • Add lang to 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.

postMessage
// 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=processing

3. 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.

Kotlin
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.

Swift
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: NSCameraUsageDescription

React Native WebView

Set mediaCapturePermissionGrantType="grant", allowsInlineMediaPlayback and javaScriptEnabled. Your app needs the camera permission.

TSX
<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.

@zoi/id-sdk
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-Signature header: t=unix seconds,v1=hex.
  • v1 is the HMAC-SHA256 of the text t.raw body with your webhook secret. Use the raw body, before any JSON parsing.
  • While a secret is being changed, the header carries one v1 per active secret. Accept the webhook if any of them matches.
  • Reject it if t is 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.
identity.suspended
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.processing
  • session.review
  • session.approved
  • session.rejected
  • session.resubmit
  • session.expired
  • session.cancelled
  • identity.updated
  • identity.suspended
  • identity.reinstated
  • identity.expired
  • identity.revoked

Check the signature in TypeScript

verify.ts
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

verify.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
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_fraud with category other. status can also be suspected_fraud, or cleared to take back your own report.
  • Categories: forged_document, impostor, deepfake_or_replay, synthetic_identity, account_takeover and other. No category with cleared.
  • note is 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-Key to 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/:id/fraud-reports
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/: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
}

Errors

Every error has the same shape and a fixed HTTP status per code.

error
{
  "error": {
    "code": "step_not_allowed",
    "message": "This step is not allowed now.",
    "requestId": "req_..."
  }
}
CodeHTTPMeaning
bad_request400The request does not match the contract.
unauthorized401Missing or wrong key or token.
token_expired401The client token expired. Create a new one from your backend.
forbidden403The key may not do this.
not_found404No such session or user.
session_closed409The session is final. No more uploads.
step_not_allowed409Wrong order, or the step is not needed now.
revision_conflict409The session revision moved. Read it again.
idempotency_conflict409The same Idempotency-Key was used with a different request.
challenge_expired410The challenge expired or was already used. Ask for a new one.
payload_too_large413The upload is too big.
unsupported_media_type415The file type is not accepted.
rate_limited429Too many requests. Try again later.
internal500Something went wrong on our side.
dependency_unavailable503A 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_missing
  • consent_withdrawn
  • attestation_failed
  • document_expired
  • document_unreadable
  • document_not_supported
  • document_suspected_tampering
  • document_number_visible
  • chip_verification_failed
  • chip_proof_missing
  • chip_data_mismatch
  • mrz_invalid
  • photo_quality
  • face_not_found
  • face_mismatch
  • liveness_failed
  • underage
  • location_not_supported
  • residence_not_supported
  • sanctions_match
  • pep_review
  • duplicate_identity
  • possible_duplicate
  • declaration_requires_review
  • user_requested_review
  • reviewer_other
  • address_unreadable
  • address_document_too_old
  • address_document_not_accepted
  • address_name_mismatch
  • address_signature_required
  • funds_document_unreadable
  • funds_document_too_old
  • funds_document_not_accepted
  • funds_more_evidence
  • funds_evidence_rejected