跳到正文
Zoi ID

用 Zoi ID 构建

同一主机上的两个 API,六种语言的带类型客户端,一个托管流程和沙盒。完整指南现已开放阅读,沙盒密钥随抢先体验提供。

基础 URLhttps://kyc.zoi.network

快速开始

  1. 1

    创建会话

    你的后端使用合作伙伴密钥调用 POST /v1/partner/sessions。

  2. 2

    运行流程

    将客户端令牌传给 SDK,或在浏览器中打开 verificationUrl。

  3. 3

    接收结果

    校验 Webhook 签名,然后保存身份 ID 及其状态。

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" }'

两个 API

合作伙伴 API

服务器到服务器,位于 /v1/partner 下。把合作伙伴密钥作为 Bearer 令牌发送。绝不要把它放进应用里。

客户端 API

由 SDK 使用,位于 /v1/client 下。它发送一个客户端令牌,对一个会话有效 60 分钟。

约定

  • 时间是纪元毫秒数,放在名称以 At 结尾的字段中。
  • 日期格式为 YYYY-MM-DD。未知部分写作 XX,例如 1990-XX-XX。
  • 创建或决定某事的 POST 请求接受 Idempotency-Key 请求头。密钥保留 24 小时。
  • 修改会话时要带上预期的版本号。如果版本已变化,你会收到 revision_conflict。

会话流程

你的后端创建会话,SDK 执行各个步骤。服务器检查顺序,步骤来得太早时返回 step_not_allowed。

1. 从后端创建会话

对同一用户再次调用,会继续未完成的会话,并给出新的客户端令牌。

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. SDK 执行的步骤

  1. 01

    读取会话和仍需完成的步骤。

    GET /v1/client/session

  2. 02

    记录同意,包括关于面部比对的单独、可选的选择。

    POST /v1/client/consent

  3. 03

    检查应用和设备。结果会纳入决策。

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

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

  5. 05

    在应用显示头部动作时,用前置摄像头录制一段短视频。

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

  6. 06

    发送居住国家,增强级别还要发送地址。

    POST /v1/client/profile

  7. 07

    仅在有地址要求时:上传文件,然后确认地址。身份证件已提供地址时跳过。

    POST /v1/client/address/document, POST /v1/client/address/confirm

  8. 08

    仅在有资金要求时:保存回答,策略要求时添加文件,然后完成该步骤。

    POST /v1/client/funds/answers, POST /v1/client/funds/document, POST /v1/client/funds/remove, POST /v1/client/funds/complete

  9. 09

    回答两个问题:是否为政治公众人物,以及是否为自己行事。

    POST /v1/client/declarations

  10. 10

    用当前版本号提交。

    POST /v1/client/submit

用户也可以请求人工复查自动结果(POST /review-request),或者撤回同意,这会取消会话并立即删除其文件(POST /withdraw)。

地址证明和资金来源

在 POST /v1/partner/sessions 中加入 requirements,即可验证身份以外的内容。address 要求提供地址证明。source_of_funds 询问资金从哪里来,source_of_wealth 还会询问总财富及其积累方式。这两个资金值合在一起是一个步骤,并且都可以与 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" }
}

可选策略

未指定策略时,使用你的合作伙伴默认设置,没有则使用我们的默认设置。策略会在会话创建时复制到会话上。

  • 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 一起发送。

  • 用户同意后只需完成新的步骤,不会再次要求证件、自拍或声明。
  • 批准后,身份保留原有的 ID、验证日期和有效期,并获得新的结果。你会收到 session.approved 和 identity.updated。
  • 没有有效身份时,调用返回 409 step_not_allowed。
  • 根据你自己的风控规则发起,例如大额入金、PEP 标记或高风险国家。你的规则集中保留在你的后端。
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", ... } }

结果与范围

已批准的会话带有 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 范围时,具有合规角色的成员可以在控制台中查看资金来源文件页面。每次查看都有记录。
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": []
  }
}

网页流程

不用 SDK?把用户带到我们托管的页面。它使用相同的 API、相同的检查和相同的审核队列。

1. 获取链接

POST /v1/partner/sessions 会返回 verificationUrl。传入 redirectUrl,用户完成后会被送回您那里。它必须以您已注册的某个重定向来源开头。

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",
  ...
}
  • 客户端令牌在 URL 的片段中,即 # 之后。浏览器从不把片段发送给服务器,所以令牌不会出现在日志或 Referer 请求头中。页面读取令牌后会将其从地址栏删除,只保存在内存中。
  • 在片段中加入 lang 可选择 19 种语言之一。不加时,页面使用浏览器语言。
  • 刷新页面会丢失令牌。请在您的后端为同一会话创建新链接。
  • 页面在提交同意时发送 channel: "web"。浏览器无法读取证件芯片,因此网页会话使用证件照片或数字身份钱包。网页会话跳过设备检查,来自网页的证件照片始终由经过培训的审核员决定。
  • 在电脑上,页面会显示二维码,让用户在手机上继续。

2. 用户完成时

流程结束时(已提交、已取消或进入最终状态),页面会把这条消息发送给以下对象中存在的每一个:window.ReactNativeWebView、window.webkit.messageHandlers.zoiId、window.ZoiIdAndroid 和 window.opener。然后打开加上 zoiIdSession 和 zoiIdStatus 的 redirectUrl。

不要信任该消息或重定向参数。只信任您的 Webhook 或 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 设置

Android WebView

开启 javaScriptEnabled 和 domStorageEnabled,并把 mediaPlaybackRequiresUserGesture 设为 false。在您的应用获得 CAMERA 权限后,在 WebChromeClient 的 onPermissionRequest 中授予 RESOURCE_VIDEO_CAPTURE。为照片备用方式添加文件选择器。

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 或更高版本。把 allowsInlineMediaPlayback 设为 true,并在应用中添加 NSCameraUsageDescription。iOS 15 及以上可以在 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

设置 mediaCapturePermissionGrantType="grant"、allowsInlineMediaPlayback 和 javaScriptEnabled。您的应用需要相机权限。

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

由于相机权限和点击劫持问题,不支持 iframe。请使用浏览器标签页、弹出窗口或 webview。

状态

会话

created
会话已存在,还没有上传任何内容。
in_progress
已同意,正在上传各步骤。
processing
已提交,检查正在进行。
review
等待经过培训的审核员。
approved
最终状态。身份已验证。
rejected
最终状态。请查看原因代码。
resubmit
用户必须重做 requiredSteps 中的步骤。
expired
未能及时做出最终决定。
cancelled
被合作伙伴、用户或删除操作取消。

身份

active
可用。
suspended
暂停,例如出现新的制裁候选匹配后。
expired
证件或验证期限已结束。新的会话会重新验证。
revoked
最终状态。

SDK

React Native (Expo) SDK 提供带类型的 API 客户端、NFC 芯片读取和设备检查。它是无界面 (headless) 的,你的应用可以完全掌控自己的界面和相机。服务端可使用适用于 TypeScript、Python、Go、PHP、Java 和 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;
}

Webhook

会话或身份发生变化时,我们会发送 Webhook。在信任内容之前,务必先校验签名。

  • 每个 Webhook 都带有 Zoi-Id-Signature 请求头:t=unix seconds,v1=hex。
  • v1 是用你的 Webhook 密钥对文本 t.raw body 计算的 HMAC-SHA256。请使用原始请求体,在解析 JSON 之前计算。
  • 在更换密钥期间,请求头会为每个有效密钥带一个 v1。只要其中任意一个匹配,就接受该 Webhook。
  • 如果 t 与当前时间相差超过 300 秒,就拒绝它。
  • 发送失败会在 72 小时内重试。每次重试都会用新的 t 重新签名。
  • 身份带有 revision。只有当事件的版本比你保存的更新时,才应用该事件。
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"
  }
}

事件类型

  • session.processing
  • session.review
  • session.approved
  • session.rejected
  • session.resubmit
  • session.expired
  • session.cancelled
  • identity.updated
  • identity.suspended
  • identity.reinstated
  • identity.expired
  • identity.revoked

用 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 });
}

用 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
}

事件流

GET /v1/partner/events 返回最近 30 天的所有事件,最早的在前。把最后一个 nextCursor 作为 after 传入,即可读取下一页。可在长时间中断后使用,或用来确认没有漏掉任何事件。

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
}

欺诈报告

已决策的会话事后被发现是欺诈时(例如发生拒付后),请告诉我们。报告用于衡量错误批准的情况,不会改变会话、身份或用户的结果。

  • 范围 sessions:write。仅适用于已决策的会话(已批准或已拒绝)。决策前调用会返回 409。
  • 空请求体会以类别 other 报告 confirmed_fraud。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/: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
}

删除

DELETE /v1/partner/users/:externalUserId 会在用户关闭账户或要求删除时删除其数据。返回结果列出现在删除了什么,以及保留了什么、为什么保留、保留到什么时候。

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
{
  "error": {
    "code": "step_not_allowed",
    "message": "This step is not allowed now.",
    "requestId": "req_..."
  }
}
错误码HTTP含义
bad_request400请求不符合接口约定。
unauthorized401缺少密钥或令牌,或者不正确。
token_expired401客户端令牌已过期。请从后端重新创建。
forbidden403此密钥无权执行该操作。
not_found404没有这个会话或用户。
session_closed409会话已是最终状态。不能再上传。
step_not_allowed409顺序错误,或者现在不需要这个步骤。
revision_conflict409会话版本已变化。请重新读取。
idempotency_conflict409同一个 Idempotency-Key 被用于不同的请求。
challenge_expired410挑战已过期或已被使用。请重新申请。
payload_too_large413上传内容太大。
unsupported_media_type415不接受这种文件类型。
rate_limited429请求太多。请稍后再试。
internal500我们这边出了问题。
dependency_unavailable503我们依赖的某个服务不可用。请稍后再试。

原因代码

当需要重新提交或被拒绝时,会话会带有 reasonCodes。请为每个代码显示你自己翻译好的提示。绝不要把原始代码直接显示给用户。

  • 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