สร้างด้วย Zoi ID
สอง API บนโฮสต์เดียว ไคลเอนต์แบบมีชนิดข้อมูลในหกภาษา ขั้นตอนแบบโฮสต์ และ Sandbox คู่มือฉบับเต็มเปิดให้อ่านแล้ว ส่วนคีย์ Sandbox จะได้รับพร้อมสิทธิ์ใช้งานก่อนใคร
Base URLhttps://kyc.zoi.network
เริ่มต้นอย่างรวดเร็ว
- 1
สร้างเซสชัน
แบ็กเอนด์ของคุณเรียก
POST /v1/partner/sessionsด้วยคีย์พาร์ทเนอร์ - 2
รันขั้นตอน
ส่งโทเค็นไคลเอนต์ให้ SDK หรือเปิด
verificationUrlในเบราว์เซอร์ - 3
รับผลลัพธ์
ตรวจลายเซ็นของเว็บฮุค แล้วเก็บรหัสตัวตนและสถานะของมัน
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
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)
การยืนยันที่อยู่และแหล่งที่มาของเงินทุน
เพิ่ม requirements ใน POST /v1/partner/sessions เพื่อยืนยันได้มากกว่าตัวตน 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สำหรับช่วงเงิน- เซสชันที่ยังเปิดอยู่ซึ่งมีข้อกำหนดอื่นและยังไม่ได้ส่ง จะถูกยกเลิกและเริ่มเซสชันใหม่ เหมือนการเปลี่ยนระดับ
- ทดสอบทุกผลลัพธ์ด้วยสถานการณ์จำลองใน 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
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ทุกการเปิดดูถูกบันทึกไว้
"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
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 เท่านั้น
// 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 ใน WebChromeClient ให้อนุญาต onPermissionRequest สำหรับ RESOURCE_VIDEO_CAPTURE หลังจากแอปของคุณได้สิทธิ์ CAMERA แล้ว เพิ่มตัวเลือกไฟล์สำหรับทางเลือกแบบรูปถ่าย
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 เพราะเรื่องสิทธิ์กล้องและ 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
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ให้ใช้เหตุการณ์ก็ต่อเมื่อเวอร์ชันใหม่กว่าที่คุณเก็บไว้
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 วันล่าสุด เรียงจากเก่าไปใหม่ ส่ง after พร้อม nextCursor ล่าสุดเพื่ออ่านหน้าถัดไป ใช้หลังระบบล่มนาน หรือเพื่อตรวจว่าไม่พลาดอะไร
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/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 | คำขอไม่ตรงกับสัญญา API |
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