Construa com o Zoi ID
Duas APIs num mesmo host, clientes tipados em seis linguagens, um fluxo hospedado e um sandbox. O guia completo já está aberto para leitura, e as chaves de sandbox vêm com o acesso antecipado.
URL basehttps://kyc.zoi.network
Início rápido
- 1
Crie uma sessão
Seu backend chama
POST /v1/partner/sessionscom a sua chave de parceiro. - 2
Rode o fluxo
Passe o token de cliente ao SDK ou abra
verificationUrlnum navegador. - 3
Receba o resultado
Confira a assinatura do webhook e depois guarde o identificador da identidade e o seu status.
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" }'Duas APIs
API de parceiros
De servidor para servidor, em /v1/partner. Envie a sua chave de parceiro como token Bearer. Nunca a coloque num app.
API de cliente
Usada pelo SDK, em /v1/client. Ela envia um token de cliente que vale para uma sessão por 60 minutos.
Convenções
- Horários são milissegundos epoch, em campos cujo nome termina em
At. - Datas são
YYYY-MM-DD. Partes desconhecidas sãoXX, por exemplo1990-XX-XX. - Requisições POST que criam ou decidem algo aceitam um cabeçalho
Idempotency-Key. As chaves ficam guardadas por 24 horas. - Mudanças numa sessão enviam a revisão esperada. Se ela mudou, você recebe
revision_conflict.
Fluxo de sessão
Seu backend cria a sessão. O SDK executa os passos. O servidor confere a ordem e responde step_not_allowed quando um passo chega cedo demais.
1. Crie uma sessão no seu backend
Chamar de novo para o mesmo usuário retoma a sessão aberta e dá um novo token de cliente.
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. Passos que o SDK executa
- 01
Ler a sessão e os passos que ainda faltam.
GET /v1/client/session
- 02
Registrar o consentimento, com a escolha separada e opcional sobre a comparação facial.
POST /v1/client/consent
- 03
Conferir o app e o aparelho. O resultado faz parte da decisão.
POST /v1/client/attestation/challenge, POST /v1/client/attestation
- 04
Enviar o documento: ler o chip depois de escanear a MRZ, ou enviar fotos.
POST /v1/client/document/mrz-scan, POST /v1/client/document/chip/challenge, POST /v1/client/document/chip or POST /v1/client/document/photos
- 05
Gravar um vídeo curto com a câmera frontal enquanto o app mostra os movimentos de cabeça.
POST /v1/client/liveness/challenge, POST /v1/client/liveness
- 06
Enviar o país de residência, e o endereço no nível ampliado.
POST /v1/client/profile
- 07
Só com o requisito de endereço: enviar um documento e depois confirmar o endereço. Pulado quando o documento de identidade já o forneceu.
POST /v1/client/address/document, POST /v1/client/address/confirm
- 08
Só com um requisito de recursos: salvar as respostas, adicionar documentos quando a política pedir e então concluir a etapa.
POST /v1/client/funds/answers, POST /v1/client/funds/document, POST /v1/client/funds/remove, POST /v1/client/funds/complete
- 09
Responder duas perguntas: pessoa politicamente exposta, e se age em nome próprio.
POST /v1/client/declarations
- 10
Enviar com a revisão atual.
POST /v1/client/submit
O usuário também pode pedir que uma pessoa analise um resultado automático (POST /review-request) ou retirar o consentimento, o que cancela a sessão e apaga os arquivos na hora (POST /withdraw).
Comprovação de endereço e origem de recursos
Adicione requirements a POST /v1/partner/sessions para verificar mais do que a identidade. address pede comprovação de endereço. source_of_funds pergunta de onde vem o dinheiro, e source_of_wealth também pergunta o patrimônio total e como ele foi construído. Os dois valores de recursos juntos formam uma única etapa, e ambos se combinam com 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" }
}Políticas opcionais
Sem política, vale o seu padrão de parceiro, ou então o nosso. A política é copiada para a sessão quando ela é criada.
addressPolicy:maxAgeDays(padrão 90, de 1 a 366),acceptedTypes(padrão: contas de consumo, de telefone e documentos bancários, documentos fiscais, cartas do governo e certificados de residência) esignedOnly(padrão false).fundsPolicy:questions,evidence(none,optionalourequired, padrãonone),acceptedTypes,maxAgeDays(padrão 180),currency(ISO 4217, padrão USD) ebracketspara as faixas.- Uma sessão aberta com outros requisitos que ainda não foi enviada é cancelada e uma nova começa, como numa mudança de nível.
- Teste cada resultado com cenários de sandbox como
address_signed_pdf,address_document_review,funds_signed_documentsefunds_review.
Sessões de acompanhamento
Verifique só os requisitos de um usuário cuja identidade já está ativa com você. Envie reuseIdentity: true junto com requirements.
- O usuário dá o consentimento e conclui só as etapas novas. Documento, selfie e declarações não são pedidos de novo.
- Na aprovação, a identidade mantém o identificador, a data de verificação e a validade, e recebe o novo resultado. Você recebe
session.approvedeidentity.updated. - Sem uma identidade ativa, a chamada responde 409
step_not_allowed. - Dispare a partir das suas próprias regras de risco, como um depósito alto, uma marcação de PEP ou um país de risco. Suas regras ficam no seu backend, num só lugar.
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", ... } }Resultados e permissões
Sessões aprovadas trazem address e funds, e a identidade guarda o mais recente de cada um. Os campos de resumo vêm com qualquer permissão. Os detalhes exigem uma permissão.
- Permissão
address: as linhas do endereço. Sem ela, você recebeverified,method,evidenceLevel,documentType,documentDate,verifiedAtecountry. - Permissão
financial_data:answers,documentsefindings. Sem ela, você recebekinds,evidenceLevel,reviewed,documentCounteverifiedAt. Nunca faz parte deeligibility. evidenceLeveléhigh,medium,lowounone. Métodos de endereço, do mais forte ao mais fraco:wallet,id_document,signed_pdf,official_code,document_reviewedeclared.- Os documentos não são enviados pela API. Membros com o papel de compliance podem ver as páginas dos documentos de origem de recursos no painel quando o ambiente tem a permissão
financial_data. Cada visualização é registrada.
"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": []
}
}Fluxo web
Sem SDK? Envie o usuário para a nossa página hospedada. Ela usa a mesma API, as mesmas verificações e a mesma fila de revisão.
1. Obtenha o link
POST /v1/partner/sessions retorna verificationUrl. Passe redirectUrl para mandar o usuário de volta no fim. O endereço deve começar com uma das suas origens de redirecionamento registradas.
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",
...
}- O token do cliente fica no fragmento da URL, depois de
#. Os navegadores nunca enviam o fragmento a um servidor, então o token não aparece em logs nem no cabeçalhoReferer. A página lê o token, remove da barra de endereço e guarda só na memória. - Adicione
langao fragmento para escolher um dos 19 idiomas. Sem ele, a página usa o idioma do navegador. - Recarregar a página perde o token. Crie um novo link para a mesma sessão no seu backend.
- A página envia
channel: "web"junto com o consentimento. Navegadores não conseguem ler chips de documentos, então sessões web usam fotos do documento ou uma carteira de identidade digital. Elas pulam a verificação do aparelho, e fotos de documentos enviadas pela web são sempre decididas por um analista treinado. - Em um computador, a página mostra um código QR para o usuário continuar no celular.
2. Quando o usuário termina
Quando o fluxo termina (enviado, cancelado ou um status final), a página envia esta mensagem para cada um destes que existir: window.ReactNativeWebView, window.webkit.messageHandlers.zoiId, window.ZoiIdAndroid e window.opener. Depois abre redirectUrl com zoiIdSession e zoiIdStatus adicionados.
Nunca confie na mensagem nem nos parâmetros do redirecionamento. Confie só no seu webhook ou em 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. Configuração da webview
Android WebView
Ative javaScriptEnabled e domStorageEnabled e defina mediaPlaybackRequiresUserGesture como false. Em um WebChromeClient, conceda onPermissionRequest para RESOURCE_VIDEO_CAPTURE depois que seu app tiver a permissão CAMERA. Adicione um seletor de arquivos para a alternativa com fotos.
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 ou mais recente. Defina allowsInlineMediaPlayback como true e adicione NSCameraUsageDescription ao seu app. No iOS 15 ou mais recente, você pode decidir a permissão da câmera no delegate de UI.
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
Defina mediaCapturePermissionGrantType="grant", allowsInlineMediaPlayback e javaScriptEnabled. Seu app precisa da permissão de câmera.
<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.
}
}}
/>iframes não são compatíveis, por causa das permissões de câmera e de clickjacking. Use uma aba do navegador, um popup ou uma webview.
Status
Sessão
created- A sessão existe, nada foi enviado ainda.
in_progress- Consentimento dado, os passos estão sendo enviados.
processing- Enviada, as verificações estão rodando.
review- Aguardando um analista treinado.
approved- Final. A identidade está verificada.
rejected- Final. Veja os códigos de motivo.
resubmit- O usuário precisa refazer os passos em requiredSteps.
expired- Sem decisão final a tempo.
cancelled- Cancelada pelo parceiro, pelo usuário ou por uma exclusão.
Identidade
active- Utilizável.
suspended- Em espera, por exemplo após um novo candidato de sanções.
expired- O documento ou o período de verificação terminou. Uma nova sessão verifica de novo.
revoked- Final.
SDK
O SDK para React Native (Expo) dá a você um cliente de API tipado, leitura de chip por NFC e verificação do aparelho. Ele é headless, então seu app mantém controle total das próprias telas e da câmera. Nos seus servidores, use os clientes tipados para TypeScript, Python, Go, PHP, Java e 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;
}Webhooks
Enviamos um webhook quando uma sessão ou identidade muda. Sempre confira a assinatura antes de confiar no conteúdo.
- Todo webhook traz um cabeçalho
Zoi-Id-Signature:t=unix seconds,v1=hex. v1é o HMAC-SHA256 do textot.raw bodycom o seu segredo de webhook. Use o corpo bruto, antes de ler o JSON.- Enquanto um segredo está sendo trocado, o cabeçalho traz um
v1por segredo ativo. Aceite o webhook se qualquer um deles bater. - Recuse se
testiver a mais de 300 segundos de agora. - Entregas com falha são repetidas por 72 horas. Cada nova tentativa é assinada de novo com um novo
t. - Identidades trazem uma
revision. Aplique um evento só se a revisão dele for mais nova que a que você guardou.
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"
}
}Tipos de evento
session.processingsession.reviewsession.approvedsession.rejectedsession.resubmitsession.expiredsession.cancelledidentity.updatedidentity.suspendedidentity.reinstatedidentity.expiredidentity.revoked
Conferir a assinatura em 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 });
}Conferir a assinatura em 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
}Feed de eventos
GET /v1/partner/events devolve todos os eventos dos últimos 30 dias, do mais antigo ao mais novo. Passe after com o último nextCursor para ler a próxima página. Use depois de uma queda longa, ou para conferir que você não perdeu nada.
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
}Reportes de fraude
Avise quando uma sessão decidida se revelar fraude, por exemplo depois de um chargeback. Os reportes alimentam a nossa medição de aprovações erradas. Eles não mudam a sessão, a identidade nem o resultado do usuário.
- Permissão
sessions:write. Só para sessões decididas (aprovadas ou rejeitadas). Antes de uma decisão, a chamada responde 409. - Um corpo vazio reporta
confirmed_fraudcom a categoriaother.statustambém pode sersuspected_fraud, ouclearedpara retirar o seu próprio reporte. - Categorias:
forged_document,impostor,deepfake_or_replay,synthetic_identity,account_takeovereother. Sem categoria comcleared. noteé texto livre para os nossos analistas, com no máximo 1000 caracteres. Nunca coloque nomes, números de documentos ou outros dados pessoais nele.- Um reporte novo responde 201. O mesmo reporte de novo devolve o que já está guardado, com 200. Envie uma
Idempotency-Keypara repetir com segurança. No máximo 20 reportes por sessão. - O mesmo reporte está a um clique na página da sessão no painel.
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
}Exclusão
DELETE /v1/partner/users/:externalUserId apaga os dados de um usuário quando ele encerra a conta ou pede a exclusão. A resposta lista o que foi apagado agora, e o que é guardado, por quê e até quando.
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
}Erros
Todo erro tem o mesmo formato e um status HTTP fixo por código.
{
"error": {
"code": "step_not_allowed",
"message": "This step is not allowed now.",
"requestId": "req_..."
}
}| Código | HTTP | Significado |
|---|---|---|
bad_request | 400 | A requisição não segue o contrato. |
unauthorized | 401 | Chave ou token ausente ou errado. |
token_expired | 401 | O token de cliente venceu. Crie um novo no seu backend. |
forbidden | 403 | Esta chave não pode fazer isso. |
not_found | 404 | Essa sessão ou esse usuário não existe. |
session_closed | 409 | A sessão é final. Nada mais pode ser enviado. |
step_not_allowed | 409 | Ordem errada, ou o passo não é necessário agora. |
revision_conflict | 409 | A revisão da sessão mudou. Leia de novo. |
idempotency_conflict | 409 | A mesma Idempotency-Key foi usada com outra requisição. |
challenge_expired | 410 | O desafio venceu ou já foi usado. Peça um novo. |
payload_too_large | 413 | O envio é grande demais. |
unsupported_media_type | 415 | Esse tipo de arquivo não é aceito. |
rate_limited | 429 | Requisições demais. Tente de novo mais tarde. |
internal | 500 | Algo deu errado do nosso lado. |
dependency_unavailable | 503 | Um serviço de que precisamos está fora do ar. Tente de novo mais tarde. |
Códigos de motivo
As sessões trazem reasonCodes quando precisam de reenvio ou foram recusadas. Mostre a sua própria mensagem traduzida para cada código. Nunca mostre o código bruto aos usuários.
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