본문으로 건너뛰기
Zoi ID

Zoi ID로 개발하기

한 호스트의 두 API, 6개 언어의 타입 지원 클라이언트, 호스팅 흐름, 샌드박스. 전체 가이드는 지금 바로 읽을 수 있고, 샌드박스 키는 얼리 액세스와 함께 제공됩니다.

기본 URLhttps://kyc.zoi.network

빠른 시작

  1. 1

    세션 만들기

    백엔드에서 파트너 키로 POST /v1/partner/sessions를 호출합니다.

  2. 2

    흐름 실행

    클라이언트 토큰을 SDK에 넘기거나, 브라우저에서 verificationUrl을 엽니다.

  3. 3

    결과 받기

    웹훅 서명을 확인한 뒤 신원 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 같은 샌드박스 시나리오로 결과별로 테스트하세요.

후속 세션

이미 신원이 활성 상태인 사용자에 대해 요건만 확인합니다. requirements와 함께 reuseIdentity: true를 보내세요.

  • 사용자는 동의한 뒤 새 단계만 마칩니다. 신분증, 셀피, 신고 사항을 다시 묻지 않습니다.
  • 승인되면 신원은 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 헤더에 남지 않습니다. 페이지는 토큰을 읽고 주소 표시줄에서 지운 뒤 메모리에만 보관합니다.
  • 19개 언어 중 하나를 고르려면 프래그먼트에 lang을 추가하세요. 없으면 브라우저 언어를 사용합니다.
  • 새로 고치면 토큰이 사라집니다. 백엔드에서 같은 세션의 새 링크를 만드세요.
  • 페이지는 동의와 함께 channel: "web"을 보냅니다. 브라우저는 신분증 칩을 읽을 수 없으므로 웹 세션은 신분증 사진이나 디지털 신분증 지갑을 사용합니다. 기기 확인은 건너뛰며, 웹에서 온 신분증 사진은 항상 교육받은 검토자가 판정합니다.
  • 컴퓨터에서는 사용자가 휴대폰에서 계속할 수 있도록 QR 코드를 보여 줍니다.

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. 웹뷰 설정

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은 지원하지 않습니다. 브라우저 탭, 팝업 또는 웹뷰를 사용하세요.

상태

세션

created
세션은 있지만 아직 아무것도 올리지 않았습니다.
in_progress
동의했고, 단계를 올리는 중입니다.
processing
제출했고, 확인이 진행 중입니다.
review
교육받은 검토자를 기다리는 중입니다.
approved
최종. 신원이 인증되었습니다.
rejected
최종. 사유 코드를 확인하세요.
resubmit
사용자가 requiredSteps의 단계를 다시 해야 합니다.
expired
제때 최종 결정이 나지 않았습니다.
cancelled
파트너, 사용자 또는 삭제로 취소되었습니다.

신원

active
사용할 수 있습니다.
suspended
보류 중입니다. 예를 들어 새 제재 후보가 나온 뒤.
expired
신분증이나 인증 기간이 끝났습니다. 새 세션에서 다시 인증합니다.
revoked
최종.

SDK

React Native (Expo) SDK는 타입이 있는 API 클라이언트, NFC 칩 읽기, 기기 확인을 제공합니다. 헤드리스 방식이라 화면과 카메라는 앱이 완전히 제어합니다. 서버에서는 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은 텍스트 t.raw body를 웹훅 시크릿으로 계산한 HMAC-SHA256입니다. 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일의 모든 이벤트를 오래된 순서로 돌려줍니다. 다음 페이지는 마지막 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