ข้ามไปที่เนื้อหา
Zoi ID

สร้างด้วย Zoi ID

สอง API บนโฮสต์เดียว ไคลเอนต์แบบมีชนิดข้อมูลในหกภาษา ขั้นตอนแบบโฮสต์ และ Sandbox คู่มือฉบับเต็มเปิดให้อ่านแล้ว ส่วนคีย์ Sandbox จะได้รับพร้อมสิทธิ์ใช้งานก่อนใคร

Base URLhttps://kyc.zoi.network

เริ่มต้นอย่างรวดเร็ว

  1. 1

    สร้างเซสชัน

    แบ็กเอนด์ของคุณเรียก POST /v1/partner/sessions ด้วยคีย์พาร์ทเนอร์

  2. 2

    รันขั้นตอน

    ส่งโทเค็นไคลเอนต์ให้ SDK หรือเปิด verificationUrl ในเบราว์เซอร์

  3. 3

    รับผลลัพธ์

    ตรวจลายเซ็นของเว็บฮุค แล้วเก็บรหัสตัวตนและสถานะของมัน

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 นาที

ข้อตกลง

  • เวลาเป็นมิลลิวินาทีแบบ epoch อยู่ในฟิลด์ที่ชื่อลงท้ายด้วย 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)

การยืนยันที่อยู่และแหล่งที่มาของเงินทุน

เพิ่ม requirements ใน POST /v1/partner/sessions เพื่อยืนยันได้มากกว่าตัวตน 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 สำหรับช่วงเงิน
  • เซสชันที่ยังเปิดอยู่ซึ่งมีข้อกำหนดอื่นและยังไม่ได้ส่ง จะถูกยกเลิกและเริ่มเซสชันใหม่ เหมือนการเปลี่ยนระดับ
  • ทดสอบทุกผลลัพธ์ด้วยสถานการณ์จำลองใน Sandbox เช่น 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
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", ... } }

ผลลัพธ์และสิทธิ์ (scope)

เซสชันที่อนุมัติแล้วมี 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 สมาชิกที่มีบทบาท Compliance เปิดดูหน้าเอกสารแหล่งที่มาของเงินทุนได้ในแดชบอร์ด เมื่อสภาพแวดล้อมมีสิทธิ์ 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 มาด้วยเพื่อพาผู้ใช้กลับเมื่อเสร็จ โดยต้องขึ้นต้นด้วย redirect origin ที่คุณลงทะเบียนไว้

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",
  ...
}
  • โทเค็นไคลเอนต์อยู่ในส่วน fragment ของ URL หลัง # เบราว์เซอร์ไม่ส่ง fragment ไปยังเซิร์ฟเวอร์ โทเค็นจึงไม่อยู่ในล็อกหรือในเฮดเดอร์ Referer หน้านี้อ่านโทเค็น ลบออกจากแถบที่อยู่ และเก็บไว้ในหน่วยความจำเท่านั้น
  • เพิ่ม lang ใน fragment เพื่อเลือกหนึ่งใน 19 ภาษา หากไม่ใส่ หน้านี้จะใช้ภาษาของเบราว์เซอร์
  • การโหลดหน้าใหม่ทำให้โทเค็นหายไป ให้สร้างลิงก์ใหม่สำหรับเซสชันเดิมจากแบ็กเอนด์ของคุณ
  • หน้านี้ส่ง channel: "web" พร้อมความยินยอม เบราว์เซอร์อ่านชิปของเอกสารไม่ได้ เซสชันบนเว็บจึงใช้รูปเอกสารหรือกระเป๋าดิจิทัลไอดี เซสชันเหล่านี้ข้ามการตรวจอุปกรณ์ และรูปเอกสารจากเว็บจะตัดสินโดยผู้ตรวจที่ผ่านการฝึกเสมอ
  • บนคอมพิวเตอร์ หน้านี้แสดงคิวอาร์โค้ดเพื่อให้ผู้ใช้ทำต่อบนโทรศัพท์

2. เมื่อผู้ใช้ทำเสร็จ

เมื่อโฟลว์จบ (ส่งแล้ว ยกเลิก หรือถึงสถานะสุดท้าย) หน้านี้จะส่งข้อความนี้ไปยังทุกตัวต่อไปนี้ที่มีอยู่: window.ReactNativeWebView, window.webkit.messageHandlers.zoiId, window.ZoiIdAndroid และ window.opener จากนั้นจะเปิด redirectUrl โดยเพิ่ม zoiIdSession และ zoiIdStatus

อย่าเชื่อข้อความหรือพารามิเตอร์ของการ redirect เชื่อเฉพาะ 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 ใน WebChromeClient ให้อนุญาต onPermissionRequest สำหรับ RESOURCE_VIDEO_CAPTURE หลังจากแอปของคุณได้สิทธิ์ CAMERA แล้ว เพิ่มตัวเลือกไฟล์สำหรับทางเลือกแบบรูปถ่าย

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 เพราะเรื่องสิทธิ์กล้องและ clickjacking ให้ใช้แท็บเบราว์เซอร์ หน้าต่างป๊อปอัป หรือ webview

สถานะ

เซสชัน

created
มีเซสชันแล้ว แต่ยังไม่ได้อัปโหลดอะไร
in_progress
ยินยอมแล้ว กำลังอัปโหลดขั้นตอนต่าง ๆ
processing
ส่งแล้ว กำลังตรวจ
review
รอผู้ตรวจที่ผ่านการฝึก
approved
สิ้นสุด ยืนยันตัวตนแล้ว
rejected
สิ้นสุด ดูรหัสเหตุผล
resubmit
ผู้ใช้ต้องทำขั้นตอนใน requiredSteps ใหม่
expired
ไม่มีการตัดสินสุดท้ายทันเวลา
cancelled
ถูกยกเลิกโดยพาร์ทเนอร์ ผู้ใช้ หรือการลบข้อมูล

ตัวตน

active
ใช้งานได้
suspended
พักไว้ เช่น หลังพบผู้ที่อาจตรงกับรายชื่อคว่ำบาตรรายใหม่
expired
เอกสารหรือช่วงเวลาการยืนยันหมดแล้ว เซสชันใหม่จะยืนยันอีกครั้ง
revoked
สิ้นสุด

SDK

SDK สำหรับ React Native (Expo) ให้ไคลเอนต์ API แบบมีชนิดข้อมูล การอ่านชิปด้วย NFC และการตรวจอุปกรณ์ SDK เป็นแบบ 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;
}

เว็บฮุค

เราส่งเว็บฮุคเมื่อเซสชันหรือตัวตนเปลี่ยน ตรวจลายเซ็นทุกครั้งก่อนเชื่อเนื้อหา

  • ทุกเว็บฮุคมีเฮดเดอร์ Zoi-Id-Signature: t=unix seconds,v1=hex
  • v1 คือ HMAC-SHA256 ของข้อความ t.raw body ด้วยซีเคร็ตเว็บฮุคของคุณ ใช้ body ดิบก่อนแปลง JSON
  • ระหว่างเปลี่ยนซีเคร็ต เฮดเดอร์จะมี v1 หนึ่งค่าต่อซีเคร็ตที่ใช้งานอยู่ ยอมรับเว็บฮุคถ้ามีค่าใดค่าหนึ่งตรง
  • ปฏิเสธถ้า 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 วันล่าสุด เรียงจากเก่าไปใหม่ ส่ง after พร้อม nextCursor ล่าสุดเพื่ออ่านหน้าถัดไป ใช้หลังระบบล่มนาน หรือเพื่อตรวจว่าไม่พลาดอะไร

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
}

รายงานการฉ้อโกง

แจ้งเราเมื่อเซสชันที่ตัดสินแล้วกลายเป็นการฉ้อโกง เช่น หลังเกิด chargeback รายงานช่วยให้เราวัดการอนุมัติที่ผิดพลาด แต่ไม่เปลี่ยนเซสชัน ตัวตน หรือผลลัพธ์ของผู้ใช้

  • สิทธิ์ sessions:write ใช้ได้เฉพาะเซสชันที่ตัดสินแล้ว (อนุมัติหรือปฏิเสธ) ก่อนมีการตัดสิน การเรียกจะตอบกลับ 409
  • body ว่างจะรายงานเป็น 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/: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คำขอไม่ตรงกับสัญญา API
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