快速开始
- 1
创建会话
你的后端使用合作伙伴密钥调用
POST /v1/partner/sessions。 - 2
运行流程
将客户端令牌传给 SDK,或在浏览器中打开
verificationUrl。 - 3
接收结果
校验 Webhook 签名,然后保存身份 ID 及其状态。
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
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)。
地址证明和资金来源
在 POST /v1/partner/sessions 中加入 requirements,即可验证身份以外的内容。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 一起发送。
- 用户同意后只需完成新的步骤,不会再次要求证件、自拍或声明。
- 批准后,身份保留原有的 ID、验证日期和有效期,并获得新的结果。你会收到
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"。浏览器无法读取证件芯片,因此网页会话使用证件照片或数字身份钱包。网页会话跳过设备检查,来自网页的证件照片始终由经过培训的审核员决定。 - 在电脑上,页面会显示二维码,让用户在手机上继续。
2. 用户完成时
流程结束时(已提交、已取消或进入最终状态),页面会把这条消息发送给以下对象中存在的每一个:window.ReactNativeWebView、window.webkit.messageHandlers.zoiId、window.ZoiIdAndroid 和 window.opener。然后打开加上 zoiIdSession 和 zoiIdStatus 的 redirectUrl。
不要信任该消息或重定向参数。只信任您的 Webhook 或 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。在您的应用获得 CAMERA 权限后,在 WebChromeClient 的 onPermissionRequest 中授予 RESOURCE_VIDEO_CAPTURE。为照片备用方式添加文件选择器。
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
React Native (Expo) SDK 提供带类型的 API 客户端、NFC 芯片读取和设备检查。它是无界面 (headless) 的,你的应用可以完全掌控自己的界面和相机。服务端可使用适用于 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;
}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。只有当事件的版本比你保存的更新时,才应用该事件。
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 天的所有事件,最早的在前。把最后一个 nextCursor 作为 after 传入,即可读取下一页。可在长时间中断后使用,或用来确认没有漏掉任何事件。
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/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