Construye con Zoi ID
Dos API en un mismo host, clientes tipados en seis lenguajes, un flujo alojado y un sandbox. La guía completa ya se puede leer, y las claves de sandbox llegan con el acceso anticipado.
URL basehttps://kyc.zoi.network
Inicio rápido
- 1
Cree una sesión
Su backend llama a
POST /v1/partner/sessionscon su clave de socio. - 2
Ejecute el flujo
Pase el token de cliente al SDK, o abra
verificationUrlen un navegador. - 3
Reciba el resultado
Compruebe la firma del webhook y luego guarde el identificador de identidad y su estado.
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" }'Dos API
API de socios
De servidor a servidor, bajo /v1/partner. Envía tu clave de socio como token Bearer. Nunca la pongas en una app.
API de cliente
La usa el SDK, bajo /v1/client. Envía un token de cliente que vale para una sesión durante 60 minutos.
Convenciones
- Las horas son milisegundos epoch, en campos cuyo nombre termina en
At. - Las fechas son
YYYY-MM-DD. Las partes desconocidas sonXX, por ejemplo1990-XX-XX. - Las peticiones POST que crean o deciden algo aceptan un encabezado
Idempotency-Key. Las claves se guardan 24 horas. - Los cambios en una sesión envían la revisión que esperan. Si cambió, recibes
revision_conflict.
Flujo de sesión
Tu backend crea la sesión. El SDK ejecuta los pasos. El servidor comprueba el orden y responde step_not_allowed cuando un paso llega antes de tiempo.
1. Crea una sesión desde tu backend
Si la llamas otra vez para el mismo usuario, se reanuda la sesión abierta y se da un nuevo 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. Pasos que ejecuta el SDK
- 01
Leer la sesión y los pasos que aún faltan.
GET /v1/client/session
- 02
Registrar el consentimiento, con la elección aparte y opcional sobre la comparación facial.
POST /v1/client/consent
- 03
Revisar la app y el dispositivo. El resultado forma parte de la decisión.
POST /v1/client/attestation/challenge, POST /v1/client/attestation
- 04
Enviar el documento: leer el chip tras escanear la MRZ, o 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
Grabar un video corto con la cámara frontal mientras la app muestra los movimientos de cabeza.
POST /v1/client/liveness/challenge, POST /v1/client/liveness
- 06
Enviar el país de residencia, y la dirección en el nivel ampliado.
POST /v1/client/profile
- 07
Solo con el requisito de dirección: subir un documento y luego confirmar la dirección. Se omite cuando el documento de identidad ya la aportó.
POST /v1/client/address/document, POST /v1/client/address/confirm
- 08
Solo con un requisito de fondos: guardar las respuestas, añadir documentos si la política los pide y luego completar el paso.
POST /v1/client/funds/answers, POST /v1/client/funds/document, POST /v1/client/funds/remove, POST /v1/client/funds/complete
- 09
Responder dos preguntas: persona expuesta políticamente, y si actúa en nombre propio.
POST /v1/client/declarations
- 10
Enviar con la revisión actual.
POST /v1/client/submit
El usuario también puede pedir que una persona revise un resultado automático (POST /review-request) o retirar su consentimiento, lo que cancela la sesión y borra sus archivos al instante (POST /withdraw).
Comprobante de domicilio y origen de los fondos
Añade requirements a POST /v1/partner/sessions para verificar algo más que la identidad. address pide un comprobante de domicilio. source_of_funds pregunta de dónde viene el dinero, y source_of_wealth también pregunta por el patrimonio total y cómo se formó. Los dos valores de fondos juntos son un solo paso, y ambos se combinan con 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 opcionales
Sin política, se aplica tu valor por defecto de socio y, si no lo hay, el nuestro. La política se copia en la sesión al crearla.
addressPolicy:maxAgeDays(por defecto 90, de 1 a 366),acceptedTypes(por defecto: documentos de servicios, telefonía y banco, documentos fiscales, cartas oficiales y certificados de residencia) ysignedOnly(por defecto false).fundsPolicy:questions,evidence(none,optionalorequired, por defectonone),acceptedTypes,maxAgeDays(por defecto 180),currency(ISO 4217, por defecto USD) ybracketspara los rangos.- Una sesión abierta con otros requisitos que aún no se envió se cancela y empieza una nueva, igual que con un cambio de nivel.
- Prueba cada resultado con escenarios de sandbox como
address_signed_pdf,address_document_review,funds_signed_documentsyfunds_review.
Sesiones de seguimiento
Verifica solo los requisitos de un usuario cuya identidad ya está activa contigo. Envía reuseIdentity: true junto con requirements.
- El usuario da su consentimiento y completa solo los pasos nuevos. No se vuelve a pedir documento, selfie ni declaraciones.
- Al aprobarse, la identidad conserva su identificador, su fecha de verificación y su validez, y recibe el nuevo resultado. Recibes
session.approvedyidentity.updated. - Sin una identidad activa, la llamada responde 409
step_not_allowed. - Iníciala desde tus propias reglas de riesgo, como un depósito grande, una alerta PEP o un país de riesgo. Tus reglas se quedan en tu backend, en un solo 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 y permisos
Las sesiones aprobadas llevan address y funds, y la identidad conserva el más reciente de cada uno. Los campos de resumen llegan con cualquier permiso. Los detalles necesitan un permiso.
- Permiso
address: las líneas de la dirección. Sin él recibesverified,method,evidenceLevel,documentType,documentDate,verifiedAtycountry. - Permiso
financial_data:answers,documentsyfindings. Sin él recibeskinds,evidenceLevel,reviewed,documentCountyverifiedAt. Nunca forma parte deeligibility. evidenceLeveleshigh,medium,lowonone. Métodos de dirección, del más sólido al más débil:wallet,id_document,signed_pdf,official_code,document_reviewydeclared.- Los documentos no se envían por la API. Los miembros con el rol de cumplimiento pueden ver las páginas de los documentos de origen de los fondos en el panel cuando el entorno tiene el permiso
financial_data. Cada consulta queda 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": []
}
}Flujo web
¿Sin SDK? Envía al usuario a nuestra página alojada. Usa la misma API, las mismas comprobaciones y la misma cola de revisión.
1. Obtén el enlace
POST /v1/partner/sessions devuelve verificationUrl. Pasa redirectUrl para que el usuario vuelva contigo al terminar. Debe empezar por uno de tus orígenes de redirección registrados.
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",
...
}- El token de cliente va en el fragmento de la URL, después de
#. Los navegadores nunca envían el fragmento a un servidor, así que el token no queda en registros ni en la cabeceraReferer. La página lo lee, lo quita de la barra de direcciones y solo lo guarda en memoria. - Añade
langal fragmento para elegir uno de los 19 idiomas. Sin él, la página usa el idioma del navegador. - Al recargar se pierde el token. Crea un enlace nuevo para la misma sesión desde tu backend.
- La página envía
channel: "web"con el consentimiento. Los navegadores no pueden leer chips de documentos, así que las sesiones web usan fotos del documento o un monedero de identidad digital. Se saltan la comprobación del dispositivo, y las fotos de documentos enviadas desde la web siempre las decide un revisor capacitado. - En un ordenador, la página muestra un código QR para que el usuario siga en un teléfono.
2. Cuando el usuario termina
Cuando el flujo termina (enviado, cancelado o un estado final), la página envía este mensaje a cada uno de estos que exista: window.ReactNativeWebView, window.webkit.messageHandlers.zoiId, window.ZoiIdAndroid y window.opener. Después abre redirectUrl con zoiIdSession y zoiIdStatus añadidos.
Nunca confíes en el mensaje ni en los parámetros de la redirección. Confía solo en tu webhook o en 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. Ajustes de la webview
Android WebView
Activa javaScriptEnabled y domStorageEnabled, y pon mediaPlaybackRequiresUserGesture en false. En un WebChromeClient, concede onPermissionRequest para RESOURCE_VIDEO_CAPTURE cuando tu app ya tenga el permiso CAMERA. Añade un selector de archivos para la alternativa con 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 o posterior. Pon allowsInlineMediaPlayback en true y añade NSCameraUsageDescription a tu app. Desde iOS 15 puedes decidir el permiso de cámara en el delegado 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
Define mediaCapturePermissionGrantType="grant", allowsInlineMediaPlayback y javaScriptEnabled. Tu app necesita el permiso de cámara.
<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.
}
}}
/>Los iframes no son compatibles, por los permisos de cámara y por el clickjacking. Usa una pestaña del navegador, una ventana emergente o una webview.
Estados
Sesión
created- La sesión existe, aún no se subió nada.
in_progress- Consentimiento dado, se están subiendo los pasos.
processing- Enviada, las revisiones están en curso.
review- Esperando a un revisor capacitado.
approved- Final. La identidad está verificada.
rejected- Final. Mira los códigos de motivo.
resubmit- El usuario debe repetir los pasos de requiredSteps.
expired- No hubo decisión final a tiempo.
cancelled- Cancelada por el socio, el usuario o un borrado.
Identidad
active- Utilizable.
suspended- En pausa, por ejemplo tras un nuevo candidato de sanciones.
expired- El documento o el periodo de verificación terminó. Una nueva sesión vuelve a verificar.
revoked- Final.
SDK
El SDK para React Native (Expo) te da un cliente de API tipado, lectura de chips por NFC y comprobaciones del dispositivo. No incluye interfaz, así que tu app mantiene el control total de sus pantallas y de su cámara. Para tus servidores, usa los clientes tipados para TypeScript, Python, Go, PHP, Java y 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 un webhook cuando cambia una sesión o una identidad. Comprueba siempre la firma antes de confiar en el contenido.
- Cada webhook lleva un encabezado
Zoi-Id-Signature:t=unix seconds,v1=hex. v1es el HMAC-SHA256 del textot.raw bodycon tu secreto de webhook. Usa el cuerpo sin procesar, antes de leer el JSON.- Mientras se cambia un secreto, el encabezado lleva un
v1por cada secreto activo. Acepta el webhook si alguno coincide. - Recházalo si
testá a más de 300 segundos de ahora. - Los envíos fallidos se reintentan durante 72 horas. Cada reintento se firma de nuevo con un
tnuevo. - Las identidades llevan una
revision. Aplica un evento solo si su revisión es más nueva que la que guardaste.
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
Comprobar la firma en 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 });
}Comprobar la firma en 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 devuelve todos los eventos de los últimos 30 días, del más antiguo al más nuevo. Pasa after con el último nextCursor para leer la página siguiente. Úsalo tras una caída larga o para comprobar que no te falta 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
Avísanos cuando una sesión ya decidida resulte ser un fraude, por ejemplo tras un contracargo. Los reportes alimentan nuestra medición de aprobaciones erróneas. No cambian la sesión, la identidad ni el resultado del usuario.
- Permiso
sessions:write. Solo para sesiones decididas (aprobadas o rechazadas). Antes de una decisión, la llamada responde 409. - Un cuerpo vacío reporta
confirmed_fraudcon la categoríaother.statustambién puede sersuspected_fraud, oclearedpara retirar tu propio reporte. - Categorías:
forged_document,impostor,deepfake_or_replay,synthetic_identity,account_takeoveryother. Sin categoría concleared. notees texto libre para nuestros analistas, de 1000 caracteres como máximo. Nunca pongas nombres, números de documentos ni otros datos personales.- Un reporte nuevo responde 201. El mismo reporte otra vez devuelve el ya guardado con 200. Envía una
Idempotency-Keypara reintentar sin riesgo. Como máximo 20 reportes por sesión. - El mismo reporte está a un clic, en la página de la sesión del panel.
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
}Borrado
DELETE /v1/partner/users/:externalUserId borra los datos de un usuario cuando cierra su cuenta o pide el borrado. La respuesta indica qué se borró ahora y qué se guarda, por qué y hasta cuándo.
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
}Errores
Todos los errores tienen la misma forma y un estado HTTP fijo por código.
{
"error": {
"code": "step_not_allowed",
"message": "This step is not allowed now.",
"requestId": "req_..."
}
}| Código | HTTP | Significado |
|---|---|---|
bad_request | 400 | La petición no cumple el contrato. |
unauthorized | 401 | Falta la clave o el token, o es incorrecto. |
token_expired | 401 | El token de cliente caducó. Crea uno nuevo desde tu backend. |
forbidden | 403 | Esta clave no puede hacer esto. |
not_found | 404 | No existe esa sesión o ese usuario. |
session_closed | 409 | La sesión es final. No se admiten más subidas. |
step_not_allowed | 409 | Orden incorrecto, o el paso no hace falta ahora. |
revision_conflict | 409 | La revisión de la sesión cambió. Léela de nuevo. |
idempotency_conflict | 409 | La misma Idempotency-Key se usó con otra petición. |
challenge_expired | 410 | El desafío caducó o ya se usó. Pide uno nuevo. |
payload_too_large | 413 | El archivo es demasiado grande. |
unsupported_media_type | 415 | No se acepta este tipo de archivo. |
rate_limited | 429 | Demasiadas peticiones. Inténtalo más tarde. |
internal | 500 | Algo salió mal de nuestro lado. |
dependency_unavailable | 503 | Un servicio que necesitamos no funciona. Inténtalo más tarde. |
Códigos de motivo
Las sesiones llevan reasonCodes cuando hay que reenviar algo o fueron rechazadas. Muestra tu propio mensaje traducido para cada código. Nunca muestres el código tal cual a los usuarios.
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