Guía de integración de @singula_systems/ identity-js
Registra tus dominios, crea la sesión en tu servidor, carga el loader y lee el veredicto del lado del servidor. La captura corre en un iframe servido por Singula; tú sólo embebes el widget. ¿No quieres instalar nada? Manda el link que Singula hospeda: misma sesión, mismo veredicto.
Resumen
El widget de identidad es un iframe hospedado que corre una prueba de vida guiada, lee el documento, coteja el rostro y cierra en un veredicto. Hay dos formas de llegar ahí: el link que Singula hospeda o el widget dentro de tu app. Las cuatro cosas de abajo son el camino del widget; con el link sólo haces la 2 y la 4.
Sin orígenes, el widget no arranca.
En tu servidor, con tu API key.
Un script de ~10 KB en el browser.
En tu servidor: webhook o status.
Tu API key (sk_live_*) nunca llega al browser: el servidor crea la sesión y sólo el token de corta vida viaja al front-end. El widget lo acepta con o sin el prefijo vt_. Los permisos de cámara los otorga el origen de Singula, no el tuyo. Y el veredicto de registro vive del lado del servidor: onComplete es UX, no autorización.
curl -X POST https://api.singula.mx/app/identity-verification/customer/cus_9f2a7c410b8e/create \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"redirect_url": "https://acme.com/kyc/listo",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"document_types": ["ine"],
"language": "es",
"expires_in_hours": 72
}'
# El cuerpo es OPCIONAL: un POST sin body sigue funcionando igual.{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"verification_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"verification_url": "https://app.singula.mx/verify/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"embed_url": "https://app.singula.mx/embed/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expires_at": "2026-09-14T18:45:00.000Z",
"redirect_url": "https://acme.com/kyc/listo",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"document_types": ["ine"],
"language": "es"
}Dos formas de integrar
Las dos parten de la misma sesión (el mismo create) y cierran igual (webhook o status en tu servidor). Lo único que cambia es quién muestra la captura: Singula en su página, o tu app con el widget.
El flujo normal. Tu servidor crea la sesión y le mandas verification_url a la persona por SMS, WhatsApp o correo; o lo generas desde el panel sin escribir código. La página, con tu marca, vive en app.singula.mx e incluye el salto de escritorio a celular con QR.
- 1.Crea la sesión
- 2.Manda el link
- 3.Lee el veredicto en tu servidor
La captura se abre dentro de tu producto, en modal o montada en tu contenedor. Instalas @singula_systems/identity-js, registras tu dominio, pasas el verification_token al front y usas onComplete para tu UX. El veredicto de registro lo sigues leyendo en tu servidor.
- 1.Registra tu dominio
- 2.Crea la sesión
- 3.Instala y abre el widget
- 4.Lee el veredicto en tu servidor
| npm | npm install @singula_systems/identity-js@0.8.0 |
| CDN | El mismo bundle UMD por jsDelivr, con la versión fijada y integrity: la etiqueta completa está en «Embebe el widget». |
| A · Link hospedado | B · Widget en tu app | |
|---|---|---|
| Quién muestra la captura | Singula, en app.singula.mx | Tu app, en un iframe servido por Singula |
| Qué le das a la persona | verification_url (link o QR) | Nada: abres el widget con verification_token |
| Instalación | Ninguna | npm install @singula_systems/identity-js o CDN con SRI |
| Orígenes permitidos | No hacen falta | Obligatorios (Ajustes › Marca) |
| Escritorio → celular | QR incluido en la página | QR incluido dentro del widget |
| Cómo llega el veredicto | Webhook o GET status | Webhook o GET status; onComplete sólo para tu UX |
| Sin escribir código | Sí: el link se genera desde el panel | No |
Es la misma sesión: puedes mandar el link por WhatsApp a quien no está en tu web y abrir el widget a quien sí. Un <iframe> crudo con embed_url y sin la librería también carga, pero entonces el handshake init lo haces tú; sin él la página cierra con origin_unknown.
# ── A) Sin librería: Singula hospeda la página (flujo normal) ──────
# 1. Tu servidor crea la sesión
curl -X POST https://api.singula.mx/app/identity-verification/customer/cus_9f2a7c410b8e/create \
-H "Authorization: Bearer sk_live_xxx"
# → { "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
# "verification_url": "https://app.singula.mx/verify/a1b2c3d4-e5f6-7890-abcd-ef1234567890", … }
# 2. Le mandas verification_url a la persona (SMS, WhatsApp, correo)
# — o lo generas desde el panel, sin escribir código.
# 3. El veredicto llega a tu servidor: webhook identity.completed o GET status.
# No instalas nada ni registras orígenes.
# ── B) Con librería: la captura dentro de tu app ───────────────────
# 0. Registra tu dominio en Ajustes › Marca (obligatorio)
# 1. Instala el loader
npm install @singula_systems/[email protected]
# (o el mismo bundle por jsDelivr con SRI: ver «Embebe el widget»)
# 2. Tu servidor crea la sesión (el MISMO create) y manda verification_token al front
# 3. Abres el widget en tu página
# Singula.Identity.open({ token, onComplete })
# 4. El veredicto sigue llegando a tu servidor: webhook o GET status.La sesión de verificación
Una sesión (request_id) tiene DOS ejes que no hay que confundir: session_status describe la sesión y decision el veredicto. Es el mismo objeto que devuelven status, result y el stream de eventos.
| Campo | Tipo | Descripción |
|---|---|---|
request_id | string | El identificador de la sesión, el mismo que devolvió la creación. |
session_status | string pendingsubmittedcompletedcancelledexpired | El estado de la SESIÓN, no del veredicto: pending (link vivo, sin fotos), submitted (ya subió, falta el veredicto), completed, cancelled, expired. |
decision | string | null approvedreviewdeclined | El veredicto público, lo único que tu sistema debe leer para decidir. null mientras no hay veredicto. |
score | number | null | La confianza agregada de la sesión, de 0 a 1 (el overall_confidence del resultado). Cómo se calcula. |
verdict | string | null verifiedpartialrejectedfailed | El veredicto crudo de la tubería, del que se deriva decision. Útil para auditoría; no ramifiques sobre él. |
result | object | null | El objeto de verificación completo: status, document_type, overall_confidence, extracted_data, checks[] y face_result. null hasta que hay veredicto. |
external_id | string | null | El identificador que mandaste al crear la sesión. |
metadata | object | null | El objeto plano que mandaste al crear la sesión. |
submitted_at | string | null | Cuándo mandó sus fotos la persona (ISO 8601). |
completed_at | string | null | Cuándo quedó el veredicto (ISO 8601). |
network | object | null | Desde dónde se conectó la persona: las IPs confiables de la página que abrió y del teléfono que subió las fotos, cada una con país, ASN y si es Tor, nube/hosting/VPN o iCloud Private Relay. Cada bandera es true, false o null (sin datos). null hasta la primera visita. No cobra y no sale en el webhook. |
verification | object | El documento crudo de la sesión (token, status, s3_keys, challenge, device_info, opens…). Se conserva sin cambios para los consumidores que ya lo leían. |
| decision | verdict | Descripción |
|---|---|---|
approved | verified | Todas las etapas pasaron. El rostro está vivo y coincide con el documento. |
review | partial · failed | Quedó dudoso (partial) o la tubería no pudo dar veredicto (failed). Va a revisión humana: failed nunca es un rechazo. |
declined | rejected | Falló un control crítico: prueba de vida, cotejo de rostro o un dígito verificador del MRZ. Rechazo, sin importar el score. |
Cuando la tubería no pudo dar un veredicto no hay nada que afirmar sobre la persona, así que la sesión va a revisión humana en lugar de decir que fue rechazada.
{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"session_status": "completed",
"decision": "approved",
"score": 0.95,
"verdict": "verified",
"result": {
"status": "verified",
"document_type": "INE",
"overall_confidence": 0.95,
"extracted_data": {
"nombre": "MARIA",
"apellido_paterno": "LOPEZ",
"apellido_materno": "GARCIA",
"curp": "LOGM900101MDFPRR02",
"fecha_nacimiento": "1990-01-01",
"vigencia": "2030"
},
"checks": [
{ "check_name": "curp_validation", "passed": true, "confidence": 1.0 },
{ "check_name": "mrz_check_digits", "passed": true, "confidence": 1.0 }
],
"face_result": {
"match_score": 0.82, "match_decision": true,
"liveness_score": 0.91, "liveness_decision": true
}
},
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"submitted_at": "2026-09-11T12:00:00.000Z",
"completed_at": "2026-09-11T12:03:00.000Z",
"network": {
"ips": [
{ "ip": "203.0.113.24", "first_at": "2026-09-11T11:58:10.000Z",
"last_at": "2026-09-11T12:00:00.000Z", "count": 3,
"country": "MX", "asn": 8151, "as_org": "Uninet S.A. de C.V.",
"is_tor": false, "is_hosting": false, "is_relay": false }
],
"countries": ["MX"], "any_tor": false, "any_hosting": false, "any_relay": false
},
"verification": { "…": "documento crudo de la sesión (token, status, s3_keys, device_info, opens…)" }
}Autenticación
Todo lo que lee o crea sesiones se autentica con tu API key en el encabezado Authorization. sk_test_* para pruebas, sk_live_* para producción. Las rutas públicas (la captura y el estado que consulta la página hospedada) se autentican con el propio token de la sesión.
Ninguna ruta de identidad acepta la API key como parámetro de la URL, el stream de eventos incluido: una llave en la URL se filtra en logs y referrers.
# Producción — verifica de verdad, consume saldo
Authorization: Bearer sk_live_xxx
# Sandbox — veredicto de prueba, no consume saldo
Authorization: Bearer sk_test_xxxOrígenes permitidos (obligatorio)
Antes de integrar, registra el origen de la página que embebe el widget en Ajustes › Marca › Dominios y continuidad. Dentro de un iframe la verificación sólo arranca si el origen está en la lista: una lista vacía es una mala configuración, no un comodín. Para desarrollar en local se aceptan http://localhost y http://127.0.0.1 con su puerto; todo lo demás va en https y sin ruta.
origin_not_configured | La organización no tiene ningún origen. El widget falla de inmediato, sin esperar los 5 s. |
origin_not_allowed | El origen que embebe no está en la lista. El error se manda a ESE origen. |
origin_unknown | No llegó el handshake init en 5 s y el referrer no identificó un origen de la lista. |
La lista gobierna el enmarcado. verification_url se abre como pestaña propia (correo, SMS, WhatsApp) y no tiene padre a quien autorizar. En las tres fallas de arriba la captura nunca empieza.
# Ajustes › Marca › Dominios y continuidad
# «Allowed origins · uno por línea» — protocolo + dominio + puerto, sin ruta.
https://acme.com
https://checkout.acme.com
http://localhost:5173
# http sólo para localhost / 127.0.0.1 / [::1] (pruebas); todo lo demás https.
# Sin barra final ni ruta: https://acme.com/kyc nunca coincide y rebota con 400.
# Lista vacía = mala configuración, NO comodín: el widget falla cerrado.Crea la verificación
/app/identity-verification/customer/{customerId}/createLlama a este endpoint desde tu servidor. Devuelve el request_id de la sesión, el verification_token que le pasas al widget y los dos links de captura. El cuerpo es opcional: sirve para amarrar la sesión a tu propio flujo.
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
redirect_url | string | Opcional | A dónde mandar a la persona cuando termina la captura en su celular. https obligatorio, máximo 2048 caracteres. Se le anexan request_id y status; el token nunca viaja. |
external_id | string | Opcional | Tu propio identificador de la sesión (orden, expediente, ticket). Máximo 128 caracteres. Se devuelve en cada lectura y en el webhook. |
metadata | object | Opcional | Objeto plano de valores string, number o boolean; máximo 2 KB serializado. Se devuelve en cada lectura y en el webhook. No es lugar para blobs. |
document_types | string[] inepassportresident_card | Opcional | Los documentos que ofrece la pantalla de selección: INE, pasaporte o tarjeta de residente. Por defecto los tres; con uno solo se entra directo a capturarlo. |
language | string esen | Opcional | Idioma de la página hospedada. Por defecto, el del branding de tu organización. |
expires_in_hours | number | Opcional | Vigencia del link, de 1 a 720 horas. Por defecto 168 (7 días). |
| Campo | Tipo | Descripción |
|---|---|---|
request_id | string | El identificador de la sesión de verificación. Con él consultas el estado y correlacionas cada webhook. |
verification_token | string | El token de corta vida que pasas al widget en el browser. Se devuelve como un UUID pelón; el widget lo acepta con o sin el prefijo vt_. Es lo único que sale al front-end y es una credencial: trátalo como tal. |
verification_url | string | La página completa que abre la persona en su propia pestaña (correo, SMS, WhatsApp). No necesita orígenes permitidos. |
embed_url | string | La misma captura lista para iframe, ya con el token. Es la que monta el SDK. |
expires_at | string | Cuándo caduca el link (ISO 8601). Sale de expires_in_hours, o de 168 horas. |
reused | boolean | Presente y true cuando ya había una sesión pendiente del cliente: se devuelve ESA, no se cobra de nuevo y se conservan sus valores originales (un link ya compartido no se repunta a otro redirect_url). |
redirect_url | string | null | Tal como quedó guardado. |
external_id | string | null | Tal como quedó guardado. |
metadata | object | null | Tal como quedó guardado. |
document_types | string[] | Los documentos que ofrecerá la captura, ya resueltos (los tres si no los acotaste). |
language | string | null | null = la página usa el idioma del branding. |
Si el cliente ya tenía una verificación pendiente se devuelve ESA con reused: true: no se cobra de nuevo y NO se le cambian las opciones. Para empezar de cero, cancela la anterior con POST …/customer/{customerId}/cancel (cancelar no consume saldo).
Con una INE, un pasaporte o una tarjeta de residente y un cliente con CURP, la sesión coteja la CURP, el nombre, la fecha de nacimiento y el sexo del documento contra RENAPO, sin cargo extra: reusa la validación de CURP que ya exista para esa CURP y, si no hay ninguna, corre una por dentro cuando la verificación se lanza, sin cobrarla (sale en tu actividad con bundled_with). Una validación de CURP que pidas tú —POST /app/curp/validate o un paso de CURP en un flujo— sí se cobra. La INE se consulta además en la Lista Nominal: llega después del veredicto, sobre el mismo request_id, con el webhook identity.listanominal.completed. Es la misma regla por la API, el panel, el SDK, los links y los flujos.
curl -X POST https://api.singula.mx/app/identity-verification/customer/cus_9f2a7c410b8e/create \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"redirect_url": "https://acme.com/kyc/listo",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"document_types": ["ine"],
"language": "es",
"expires_in_hours": 72
}'
# El cuerpo es OPCIONAL: un POST sin body sigue funcionando igual.{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"verification_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"verification_url": "https://app.singula.mx/verify/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"embed_url": "https://app.singula.mx/embed/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expires_at": "2026-09-14T18:45:00.000Z",
"redirect_url": "https://acme.com/kyc/listo",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"document_types": ["ine"],
"language": "es"
}Embebe el widget
El loader se distribuye en npm y, desde ahí, en los CDN respaldados por npm. En modal se abre sobre tu página; en inline se monta en tu propio contenedor.
| npm | npm install @singula_systems/identity-js@0.8.0 |
| CDN | https://cdn.jsdelivr.net/npm/@singula_systems/identity-js@0.8.0/dist/singula-identity.umd.min.js |
Un <script> de tercero es código que delegas a un CDN. Fija la versión exacta y deja que el navegador verifique los bytes con integrity (ver el panel): así un objeto mal servido falla cerrado en vez de ejecutarse. El hash vale para UN archivo, así que no sirve con un alias móvil ni entre versiones.
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
token | string | Requerido | El verification_token que acuñó tu backend. Caracteres seguros para URL, hasta 512; el UUID pelón o con el prefijo vt_, ambos funcionan. |
mode | string modalinline | Opcional | Cómo se dibuja el widget. Por defecto "modal". |
container | string | Element | Opcional | Requerido en modo inline: el selector o el elemento donde se monta. |
embedBaseUrl | string | Opcional | Override del origen de la página hospedada (staging). Debe ser https: — http: sólo para localhost; cualquier otra cosa lanza. |
language | string esen | Opcional | Fuerza el idioma de la UI. Un valor desconocido se descarta, no se reenvía. |
theme | object | Opcional | Sobrescribe la marca para esta llamada (ver Marca blanca). |
loadTimeoutMs | number | Opcional | Plazo para el ready del iframe. Por defecto 20 000; al vencer, onError con load_timeout. 0 lo desactiva. |
verdictTimeoutMs | number | Opcional | Plazo para el veredicto después del envío. Por defecto 2 100 000 (35 min); al vencer, onError con verdict_timeout. 0 lo desactiva. |
onReady | function | Opcional | El iframe cargó y está listo. |
onStage | function | Opcional | Una etapa cambió de estado. Recibe { stage, status, score?, reason? }. |
onRetake | function | Opcional | La página pidió otra foto del documento (desde 0.7.0). Recibe { side, reason, attempt } y, desde 0.8.0, detectedDocumentType con reason wrong_document. Ver Revisión previa de la foto. |
onComplete | function | Opcional | El flujo terminó. Recibe { decision, score?, requestId? }, con decision = approved | declined | review | withheld. |
onError | function | Opcional | Error fatal. Recibe { code, message }. |
onClose | function | Opcional | El widget se cerró. Recibe la razón: user, success, error o programmatic. |
<!-- jsDelivr, versión fijada + SRI (el navegador verifica los bytes) -->
<script
src="https://cdn.jsdelivr.net/npm/@singula_systems/[email protected]/dist/singula-identity.umd.min.js"
integrity="sha384-U9pyNZmbLyz/rgC73Qy0+uabd4Vv8BLwkrEP2xgwrR2KwzHTX7Dz1EQRq3VZcy+m"
crossorigin="anonymous"
></script>
<!-- expone window.Singula.Identity — esa llave y nada más -->
<button id="verify">Verificar identidad</button>
<script>
document.getElementById('verify').addEventListener('click', () => {
Singula.Identity.open({
token: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
onComplete: ({ decision, requestId }) => {
// decision: 'approved' | 'declined' | 'review' | 'withheld'
askYourBackend(requestId);
},
});
});
</script>Las cinco etapas
La verificación avanza por cinco etapas y cada una entrega su resultado en cuanto termina. onStage llega por cada transición.
received | La sesión se recibe y queda lista para capturar. |
liveness | La prueba de vida multicuadro: gestos de cabeza guiados que confirman una persona viva. |
face_match | El rostro capturado se coteja contra la foto del documento. |
document | Se lee la INE, el pasaporte o la tarjeta de residente y se extraen sus datos. |
decision | Se agregan las etapas anteriores en un solo veredicto. |
Las etapas y el veredicto son independientes: cinco etapas en passed no implican una decisión, y con el veredicto retenido la etapa decision no llega nunca. Guía tu UI por onComplete, y el veredicto léelo en tu servidor.
// onStage fires once per stage transition (iframe → parent):
{ "stage": "received", "status": "passed" }
{ "stage": "liveness", "status": "running" }
{ "stage": "liveness", "status": "passed", "score": 0.91 }
{ "stage": "face_match", "status": "passed", "score": 0.82 }
{ "stage": "document", "status": "passed" }
{ "stage": "decision", "status": "passed" }
// With the verdict withheld (the DEFAULT) the 'decision' stage never
// arrives — the page cannot report 'passed' for a verdict it does not know.Callbacks
Conecta onStage para pintar tu propio progreso y onComplete para enrutar al usuario. onError es sólo para errores fatales; onClose te dice por qué se cerró.
approvedTodas las etapas pasaron.
reviewQuedó dudoso o sin veredicto.
declinedFalló un control crítico.
withheldTerminó, pero tu organización no publica el veredicto al browser (el default).
Nunca otorgues nada con base en un callback del browser: describe lo que la página frente a esa persona dice que pasó, y quien tiene el teclado puede llamar tu handler con lo que quiera. Usa requestId para preguntarle a TU backend, que tiene la API key.
El veredicto llega minutos después del envío, por un canal que puede morir (la pestaña se congela, la sesión se cancela). El SDK arma un respaldo al detectar el envío: si no llega nada en verdictTimeoutMs (35 min) dispara verdict_timeout una vez. retryStage() está reservado: hoy la página hospedada no lo atiende.
Identity.open({
token: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
loadTimeoutMs: 20000, // deadline for the iframe's 'ready' → load_timeout
verdictTimeoutMs: 2100000, // 35 min deadline after submit → verdict_timeout
onReady: () => renderStart(),
onStage: ({ stage, status, score, reason }) => {
// stage: 'received' | 'liveness' | 'face_match' | 'document' | 'decision'
// status: 'pending' | 'running' | 'passed' | 'failed'
renderProgress(stage, status);
},
onRetake: ({ side, reason, attempt, detectedDocumentType }) => {
// SDK >= 0.7.0. side: 'front' | 'back' · reason: 'glare' | 'blurry' | …
// SDK >= 0.8.0: with reason 'wrong_document', detectedDocumentType says
// which document the photo shows: 'ine' | 'passport' | 'resident_card'.
// Informative: the hosted page already asks for the new photo itself.
track('document_retake', { side, reason, attempt, detectedDocumentType });
},
onComplete: async ({ decision, score, requestId }) => {
// decision: 'approved' | 'declined' | 'review' | 'withheld'
// Never authorize here. Ask YOUR backend, which holds the API key.
const real = await fetch(`/api/kyc/${requestId}`).then((r) => r.json());
route(real.decision);
},
onError: ({ code, message }) => console.error(code, message),
onClose: (reason) => {
// reason: 'user' | 'success' | 'error' | 'programmatic'
},
});Revisión previa de la foto
/app/identity-verification/upload/{token}/precheckCada foto del documento se revisa en cuanto se confirma, mientras la persona sigue con la captura. Si una no sirve —un reflejo, una foto borrosa, el lado equivocado, otro documento—, la página hospedada la pide otra vez al terminar la prueba de vida y antes de enviar: sólo esa foto, con la misma guía en pantalla y sin repetir la prueba de vida. Con el widget no haces nada (onRetake te avisa, si lo quieres medir). Si capturas con tus propias pantallas, la misma revisión se lee en esta ruta.
- 1.Sube y confirma cada foto del documento en cuanto la tomes (presign → S3 → confirm). El confirm arranca su revisión —
ine_front,ine_back,passport_image,resident_frontoresident_back—; su respuesta no cambia y una revisión nunca hace fallar una subida. - 2.Sigue con lo demás: el otro lado, la selfie y la prueba de vida. Para cuando terminan, la revisión normalmente ya acabó.
- 3.Antes de
submit, leeGET …/upload/{token}/precheck. Conreadyen false, vuelve a leer cada 500 ms con un tope de unos segundos; lo que siga enpendingdespués de 20 s llega comounavailable. - 4.Por cada foto con
retake_suggested: true, muestra tu pantalla con el texto de sureasony vuelve a subir ESA foto: presign del mismokind→ S3 → confirm. Vuelve apendingcon suattempt+ 1: léela otra vez. - 5.Sin ningún
retake_suggested, lanza la verificación consubmit/{token}—si la persona cambió de documento, nómbralo en el cuerpo condocument_type—. Después de enviar, esta ruta contesta 409.
| Campo | Tipo | Descripción |
|---|---|---|
max_attempts | number | Las fotos que se revisan por lado: 3. |
ready | boolean | true cuando ninguna foto sigue en pending. En false, vuelve a leer en medio segundo. |
slots | object | Una entrada por cada foto ya confirmada del documento actual de la sesión: ine_front e ine_back, passport_image, o resident_front y resident_back. Las de un documento que la persona dejó no salen ni detienen ready (desde MasterApi 1.130.0). La selfie y los cuadros de la prueba de vida nunca se revisan. |
slots.<slot>.status | string pendingokretakeunavailable | Cómo salió la revisión de esa foto (tabla de abajo). |
slots.<slot>.reason | string | null glareblurrydarkcroppedlow_resolutionunreadablewrong_sidewrong_documentno_document | Qué tiene la foto. Sólo con retake; si no, null. |
slots.<slot>.detected_document_type | string | null INEPASSPORTRESIDENT_CARD | El documento que muestra la foto en lugar del esperado. Sólo con retake y reason wrong_document; si no, null. |
slots.<slot>.attempt | number | Cuántas fotos de ese lado se han confirmado, desde 1. Confirmar otra vez la misma foto no suma. |
slots.<slot>.retake_suggested | boolean | true = pide otra foto de ese lado: status retake y attempt menor que max_attempts. En la tercera foto llega false aunque status sea retake. |
pending | Se está revisando. Vuelve a leer en medio segundo. |
ok | La foto sirve. En sandbox, siempre, sin revisar nada. |
retake | La foto haría salir peor el veredicto y reason dice por qué: el frente de la INE sin una CURP que se lea completa y con su dígito verificador correcto; el reverso de la INE o de la tarjeta de residente, o el pasaporte, con su zona MRZ ilegible o un dígito verificador que no cuadra; el otro lado; otro documento de identidad; o una foto sin documento. Una foto nítida y bien leída nunca sale retake por un detalle de calidad. |
unavailable | No hay información: la revisión no pudo correr, seguía en pending después de 20 s o es la cuarta foto de ese lado o más. No es un error: sigue como siempre y nunca detengas a la persona por esto. |
| reason | Qué pasó | Texto sugerido |
|---|---|---|
glare | Un reflejo tapa parte del documento. | Hay un reflejo sobre tu {doc}Inclínala un poco o aléjate de la luz directa. |
blurry | La foto salió movida o fuera de foco. | La foto salió borrosaSostén tu {doc} firme y espera a que enfoque. |
dark | Le faltó luz. | La foto salió muy oscuraBusca un lugar con más luz. |
cropped | Falta una parte del documento. | Tu {doc} no se ve completaQue se vean las cuatro esquinas dentro del marco. |
low_resolution | El documento salió muy lejos o muy chico. | Tu {doc} salió muy lejosAcércala para que llene el marco. |
unreadable | Los datos no se leyeron y la foto no muestra una causa clara. En el frente de la INE: | No pudimos leer tus datosQue se vea todo el frente, sin dedos ni sombras. |
unreadable | Lo mismo, en el reverso de la INE o de la tarjeta de residente y en el pasaporte: | No se leen las líneas de abajoQue las líneas con «<<<» se vean completas y nítidas. |
wrong_side | Es el otro lado del mismo documento. | Parece que es el otro ladoAquí necesitamos el frente (o el reverso) de tu {doc}. |
wrong_document | Es otro documento de identidad; detected_document_type dice cuál. | Esta foto parece de tu {detectado}Aquí necesitamos tu {doc}. |
no_document | No es un documento de identidad, o casi no tiene texto. | No encontramos tu {doc} en la fotoColócala dentro del marco, sobre una superficie lisa. |
Textos para tus pantallas: de tú y sobre la foto, nunca sobre la persona. {doc} es «INE», «pasaporte» o «tarjeta de residente»; con el pasaporte, en masculino («Inclínalo», «completo», «Acércalo», «Colócalo»). {detectado} sale de detected_document_type con las mismas palabras, y la página hospedada ofrece además «Usar mi {detectado}» cuando la sesión ofrece ese documento. Si una foto tiene varios problemas llega uno: el reflejo antes que borrosa, oscura, cortada y lejos.
Con wrong_document la foto es de OTRA identificación: detected_document_type dice cuál. La página hospedada lo dice así —«Esta foto parece de tu tarjeta de residente» / «Aquí necesitamos el reverso de tu INE»— con «Tomar otra vez» y, sólo si la sesión también ofrece el documento detectado, «Usar mi tarjeta de residente»: cambia de documento y abre su captura desde su primer lado, conserva la prueba de vida (nunca se repite) y, al tomar su último lado, va directo al envío. En el primer lado del documento nuevo, «Regresar» deshace el cambio. Si la página no reconoce el documento, dice «Esta foto parece de otro documento» y sólo ofrece tomarla otra vez. Con tus propias pantallas, cambiar de documento es confirmar las fotos del nuevo y nombrarlo al enviar: desde MasterApi 1.130.0 submit acepta { "document_type": "resident_card" } (ine, passport o resident_card), que manda sobre la inferencia —sin él, la sesión verifica el documento de la ÚLTIMA foto del documento confirmada, y la confirmación lenta del documento que se dejó puede llegar después—. Tiene que ser uno que la sesión ofrece y con sus fotos; una foto de un documento que la sesión no ofrece contesta 400 document_type_not_offered.
Se revisan hasta tres fotos de cada lado (max_attempts). Con la tercera, retake_suggested llega en false aunque la foto siga mal: lanza la verificación, y una cuarta foto llega como unavailable. Nunca repitas la prueba de vida: sólo la foto que se pidió. El veredicto decide como siempre: un documento que no se pudo leer sale failed (revisión), un dígito verificador que falla sobre una foto de baja calidad sale partial (revisión) con el control retake_suggested, y lo que se rechaza hoy se sigue rechazando.
Confirmar otra vez la MISMA foto no cuenta como foto nueva ni la vuelve a revisar. La ruta se autentica con el token, como presign y confirm: 404 si no existe, 409 si la sesión ya se envió, 410 si venció; 300 consultas por minuto por sesión y 3000 por IP (desde MasterApi 1.130.0), y nunca devuelve llaves de S3. Las demás rutas de la captura tienen su propio tope por minuto: presign, confirm y la subida multipart, 600 por sesión y 3000 por IP; submit, 60 y 600. Pasado un tope contestan 429 con Retry-After, los segundos para reintentar. Ni la revisión ni las fotos repetidas se cobran: la verificación se cobra como siempre, una vez, con su veredicto. En sandbox contesta ok sin revisar nada, y la subida multipart obsoleta no tiene revisión previa.
Con @singula_systems/identity-js 0.7.0 o posterior, onRetake({ side, reason, attempt }) llega cada vez que la página pide otra foto: side es front o back (la página de datos del pasaporte es front), reason es uno de los nueve códigos de arriba y attempt es 1 en la primera repetición de ese lado y 2 en la segunda. Desde 0.8.0, con wrong_document llega además detectedDocumentType: ine, passport o resident_card (en minúsculas; en el mensaje, detected_document_type), y no viene si la página no reconoció el documento. Un SDK 0.7.0 recibe wrong_document como unreadable, y con 0.7.0 o anterior una captura con tarjeta de residente deja de mandar onStepChange (descarta el documento que no conoce): actualiza a 0.8.0. Viaja como el mensaje document_retake. Es sólo informativo —la página ya pide la foto sola— y una versión anterior del SDK lo ignora.
Aparte de la revisión previa, el resultado final puede traer el control retake_suggested, en false: un dígito verificador del MRZ falló sobre una foto de baja calidad y por eso la sesión quedó en partial (revisión) y no en rejected, o la zona de abajo del reverso de una INE no se pudo leer y la sesión quedó en partial y no en verified. Es informativo, no cuenta en el score y su details dice qué hacer: pedir otra foto. Ver la regla dura en Cómo se calcula el score.
# 1. Cada foto del documento, en cuanto se toma: presign → S3 → confirm
curl -X POST https://api.singula.mx/app/identity-verification/upload/a1b2c3d4-e5f6-7890-abcd-ef1234567890/confirm \
-H "Content-Type: application/json" \
-d '{ "assets": [{ "kind": "ine_back" }] }'
# → la revisión previa de ine_back arranca sola
# 2. Antes de submit: ¿alguna foto pide otra?
curl https://api.singula.mx/app/identity-verification/upload/a1b2c3d4-e5f6-7890-abcd-ef1234567890/precheck
# Sin API key: el token de la ruta es la autenticación.{
"max_attempts": 3,
"ready": true,
"slots": {
"passport_image": {
"status": "retake",
"reason": "wrong_document",
"detected_document_type": "INE",
"attempt": 1,
"retake_suggested": true
}
}
}Estado y veredicto
/app/identity-verification/status/{requestId}La vista de sesión completa, con API key: session_status, decision, score, verdict y el result con checks[], extracted_data y face_result. Es el veredicto de registro, el que tu sistema usa para decidir.
El objeto verification crudo se conserva tal cual: quien ya leía de ahí no tiene que cambiar nada. 404 si ese requestId no es de tu organización.
network trae las IPs confiables de la sesión —la que vio nuestro servidor, nunca un X-Forwarded-For del cliente— con país, ASN y si son Tor, nube/hosting/VPN o iCloud Private Relay. Es una señal: no cobra y nunca sale en el webhook. La IP de quien sube las fotos también cuenta: si tu backend las sube desde una nube con POST /app/identity-verification/submit/:token, esa IP es de hosting y, en un expediente KYC, ip_not_hosting lo manda a revisión (nunca a rechazo). Súbelas desde el dispositivo de la persona —el link hospedado o el widget— o apaga kyc.checks.ip_not_hosting (y ip_country_vs_document si tu servidor está fuera de México) en la política de verificación. País y ASN salen de una base abierta de geolocalización de IP. Si los muestras en tus pantallas, incluye los créditos de datos de singula.mx/creditos.
curl https://api.singula.mx/app/identity-verification/status/6622f8a1c1d2e3f4a5b6c7d8 \
-H "Authorization: Bearer sk_live_xxx"{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"session_status": "completed",
"decision": "approved",
"score": 0.95,
"verdict": "verified",
"result": {
"status": "verified",
"document_type": "INE",
"overall_confidence": 0.95,
"extracted_data": {
"nombre": "MARIA",
"apellido_paterno": "LOPEZ",
"apellido_materno": "GARCIA",
"curp": "LOGM900101MDFPRR02",
"fecha_nacimiento": "1990-01-01",
"vigencia": "2030"
},
"checks": [
{ "check_name": "curp_validation", "passed": true, "confidence": 1.0 },
{ "check_name": "mrz_check_digits", "passed": true, "confidence": 1.0 }
],
"face_result": {
"match_score": 0.82, "match_decision": true,
"liveness_score": 0.91, "liveness_decision": true
}
},
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"submitted_at": "2026-09-11T12:00:00.000Z",
"completed_at": "2026-09-11T12:03:00.000Z",
"network": {
"ips": [
{ "ip": "203.0.113.24", "first_at": "2026-09-11T11:58:10.000Z",
"last_at": "2026-09-11T12:00:00.000Z", "count": 3,
"country": "MX", "asn": 8151, "as_org": "Uninet S.A. de C.V.",
"is_tor": false, "is_hosting": false, "is_relay": false }
],
"countries": ["MX"], "any_tor": false, "any_hosting": false, "any_relay": false
},
"verification": { "…": "documento crudo de la sesión (token, status, s3_keys, device_info, opens…)" }
}Cómo se calcula el score
El score es la confianza agregada de la verificación, de 0 a 1: el overall_confidence del resultado (el campo score de «La sesión»). Los checks[] se agrupan en cuatro categorías; cada una se califica de 0 a 1 y aporta su peso. El score decide la banda del veredicto, pero antes va una regla dura: si falla un control crítico, se rechaza sin importar el score.
| Categoría | Peso | Qué incluye |
|---|---|---|
| Críticos | 50 % | Cotejo facial, prueba de vida y los dígitos verificadores de la MRZ. Si cualquiera falla, manda la regla dura de abajo. |
| RENAPO | 25 % | La CURP, el nombre, la fecha de nacimiento y el sexo del documento contra el registro oficial de esa CURP. En la INE, el pasaporte y la tarjeta de residente, y sólo cuando el cliente tiene CURP y el registro de RENAPO lo describe (match.passed: true, el mismo cotejo que devuelve GET /app/curp/customer/:id/validate). Viene incluida, sin cargo extra: la sesión reusa la validación de CURP que ya exista y, si no hay ninguna, corre una por dentro. Sin ese registro, esta categoría no aplica. |
| Consistencia interna | 15 % | Que el documento cuadre consigo mismo: CURP ↔ MRZ ↔ datos impresos ↔ clave de elector (fecha de nacimiento, sexo y estado). |
| Calidad | 10 % | Formato de la CURP, de la clave de elector y del CIC; MRZ legible; vigencia no vencida; mayoría de edad; y la calidad de la imagen, que baja el score pero nunca rechaza por sí sola. |
Una categoría sin checks en esa verificación no cuenta como cero: su peso se reparte en proporción entre las que sí están, para que la falta de una señal no baje el score. El caso típico es RENAPO: en una INE sin registro de RENAPO (el cliente no tiene CURP, o RENAPO no la confirmó) los pesos quedan en 66.7 % críticos, 20 % consistencia interna y 13.3 % calidad (cada uno entre 0.75). El pasaporte y la tarjeta de residente se califican con la misma regla sobre sus propios checks: lo impreso y la CURP contra su MRZ cuentan en consistencia interna, y con un cliente con CURP traen también la categoría de RENAPO.
score ≥ 0.85Ningún control crítico falló y la evidencia es sólida.
0.60 ≤ score < 0.85Ningún control crítico falló, pero algún cruce o dato no es perfecto: revísalo antes de aceptar.
score < 0.60La evidencia no alcanza para afirmar nada. También sale failed, con score 0, cuando el documento no se pudo leer: pide otra captura.
Qué significa cada decision está en la tabla verdict → decision de «La sesión», con una sesión completa de ejemplo (score 0.95, verified → approved).
Si falla la prueba de vida, el cotejo facial o un dígito verificador de la MRZ, el veredicto es rejected (decision: declined) sin importar el score. Se revisa antes que las bandas: el score se calcula igual y viaja en la respuesta, pero no rescata un rechazo. Un score de 0.87 con la prueba de vida fallida sigue siendo un rechazo. En un pasaporte cuentan como críticos los dígitos verificadores de su número, de la fecha de nacimiento, del vencimiento y el compuesto; el del número personal opcional, no. Un pasaporte vencido o con una fecha de nacimiento imposible nunca sale verified: a lo más partial (decision: review), aunque el score pase de 0.85. Y uno cuya MRZ no se pudo leer sale failed. En la tarjeta de residente son críticos los dígitos verificadores del número de documento, de la fecha de nacimiento, del campo de vencimiento de su MRZ y el compuesto. Una tarjeta vencida, con una fecha de nacimiento imposible o que no expidió México nunca sale verified, y tampoco una temporal cuyo vencimiento impreso no se pudo leer (o no cae entre su expedición y cuatro años después), una cuyo MRZ no es el de una tarjeta del INM, ni una cuyo frente no trae el nombre del MRZ o cuya fecha de expedición impresa no es la del MRZ: el frente de otra persona; una permanente no vence. Sin MRZ legible, sale failed. Una excepción, en la INE, el pasaporte y la tarjeta de residente: un dígito verificador que falla sobre una foto de baja calidad —sin falla de rostro ni de prueba de vida— no rechaza: la sesión sale partial (decision: review) con el control retake_suggested, porque con esa foto el dígito pudo leerse mal. Con una foto buena sigue siendo un rechazo. Y una INE cuya zona de abajo del reverso (la MRZ) no se pudo leer nunca sale verified: a lo más partial (decision: review), también con retake_suggested.
// score = Σ peso × calificación (de 0 a 1)
// INE de un cliente con CURP: hay registro
// de RENAPO y cuentan las cuatro categorías
críticos 0.50 × 1.00 = 0.500
RENAPO 0.25 × 1.00 = 0.250
consistencia 0.15 × 0.90 = 0.135
calidad 0.10 × 0.75 = 0.075
score = 0.960
→ verified → approved
// INE sin registro de RENAPO (cliente sin
// CURP): cada peso se divide entre 0.75
críticos 0.667 × 1.00 = 0.667
consistencia 0.200 × 0.90 = 0.180
calidad 0.133 × 0.75 = 0.100
score = 0.947
→ verified → approved
// Regla dura: falló la prueba de vida. El
// score se calcula igual, pero no rescata.
{
"verdict": "rejected",
"decision": "declined",
"score": 0.87
}Resultado y fotos
/app/identity-verification/result/{requestId}Lo mismo que status más files: cada slot (ine_front, ine_back, passport_image, resident_front, resident_back, selfie y los liveness_frame_<i>) con su URL firmada, y files_expires_at. Contesta 200 aunque todavía no haya veredicto.
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
files_ttl | number | Opcional | Vigencia de las URLs firmadas, en segundos. Se acota a [60, 86 400]; por defecto 600. |
include_frames | boolean | Opcional | true firma también los liveness_frame_<i> (hasta 30 URLs más). Por defecto false: se firman sólo los documentos y la selfie. |
Son evidencia de auditoría y hasta 30 firmas más, así que por defecto no se firman. Pídelos con include_frames=true cuando de verdad los vayas a revisar.
curl "https://api.singula.mx/app/identity-verification/result/6622f8a1c1d2e3f4a5b6c7d8?files_ttl=600" \
-H "Authorization: Bearer sk_live_xxx"
# Los hasta 30 cuadros de la prueba de vida se firman aparte:
curl "https://api.singula.mx/app/identity-verification/result/6622f8a1c1d2e3f4a5b6c7d8?include_frames=true" \
-H "Authorization: Bearer sk_live_xxx"{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"session_status": "submitted",
"decision": null,
"score": null,
"verdict": null,
"result": null,
"files": {
"ine_front": "https://s3…/ine_front.jpg?X-Amz-Signature=REDACTED",
"ine_back": "https://s3…/ine_back.jpg?X-Amz-Signature=REDACTED",
"selfie": "https://s3…/selfie.jpg?X-Amz-Signature=REDACTED"
},
"files_expires_at": "2026-09-11T12:10:00.000Z"
}Eventos de la sesión (SSE)
/app/identity-verification/events/{requestId}Un stream text/event-stream para seguir una sesión sin sondearla tú. Emite session al conectar y en cada cambio de session_status o decision, keepalive cada 15 s, y end al cerrar.
session | La vista de sesión sin result (el payload pesado se lee en status o result). |
keepalive | Latido cada 15 s con { at } para que ningún proxy corte el stream. |
end | { reason: "terminal" | "timeout" }. terminal = completed, cancelled o expired. |
end { reason: "timeout" } es el corte de 15 minutos de ESE stream, con la sesión todavía abierta: reconecta o lee status/{requestId}. Sólo terminal cierra la historia. Una verificación la completa una persona: tarda lo que tarda.
curl -N https://api.singula.mx/app/identity-verification/events/6622f8a1c1d2e3f4a5b6c7d8 \
-H "Authorization: Bearer sk_live_xxx" \
-H "Accept: text/event-stream"
# La llave va en el header. Esta ruta NO acepta api_key en la query.event: session
data: {"request_id":"6622f8a1c1d2e3f4a5b6c7d8","session_status":"submitted","decision":null,
"score":null,"verdict":null,"external_id":"ord_123","metadata":{"plan":"pro"},
"submitted_at":"2026-09-11T12:00:00.000Z","completed_at":null}
event: keepalive
data: {"at":"2026-09-11T12:00:15.000Z"}
event: session
data: {"request_id":"6622f8a1c1d2e3f4a5b6c7d8","session_status":"completed",
"decision":"approved","score":0.95,"verdict":"verified",
"completed_at":"2026-09-11T12:03:00.000Z"}
event: end
data: {"reason":"terminal"}Webhooks
La entrega que no depende de que el browser siga abierto. Cada evento viaja en el mismo sobre firmado que el resto de las herramientas: el payload es data, no el body.
| event | Descripción |
|---|---|
identity.session.created | Se creó la sesión y su link. Trae request_id, customer_id, verification_token y expires_at. |
identity.completed | La sesión cerró con su veredicto: decision, verdict, score, document_type, external_id y metadata. |
identity.listanominal.completed | La consulta de la INE en la Lista Nominal, incluida sin cargo extra. Llega DESPUÉS del veredicto, con el mismo request_id, y no lo cambia. Su status es el de la verificación, y listanominal_outcome dice qué contestó la Lista Nominal (null si no hubo consulta); el registro de la sesión la guarda en response.listanominal. En un KYC, el expediente la lee para ine_vigente. |
X-Singula-Signature llega como t=<unix>,v1=<hmac>, donde el HMAC-SHA256 se calcula sobre `${t}.${rawBody}` con el secreto de tu webhook. Calcúlalo sobre el cuerpo CRUDO, antes de parsearlo. El evento también viaja en X-Singula-Event.
Con un webhook dado de alta en el dashboard recibes el sobre { id, type, created, data } firmado y con reintentos: lee body.data. Por la ruta de respaldo (sin webhook dado de alta) el mismo objeto llega plano y sin firma. Sandbox manda exactamente el mismo payload, así que lo que integres contra pruebas funciona en producción.
// Singula → tu endpoint. El mismo sobre firmado de todas las herramientas:
// POST https://your-server.com/webhooks/singula
// X-Singula-Signature: t=1757596980,v1=<hmac-sha256("<t>.<raw body>", secret)>
// X-Singula-Event: identity.completed
// X-Singula-Delivery-Id: <id de la entrega>
{
"id": "6b1f…", // id de la entrega
"type": "identity.completed",
"created": 1757596980, // unix, en segundos
"data": {
"event": "identity.completed",
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"customer_id": "cus_9f2a7c410b8e",
"tool_type": "identityVerification",
"status": "success",
"session_status": "completed",
"decision": "approved", // approved | review | declined | null
"verdict": "verified", // verified | partial | rejected | failed
"score": 0.95, // 0..1
"document_type": "INE",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"completed_at": "2026-09-11T12:03:00.000Z"
}
}Quién ve el veredicto
Mostrar el resultado a la persona verificada es una decisión de tu organización, y viene APAGADA. Con el default el widget recibe decision: "withheld" y la pantalla final es neutra: el veredicto se lee del lado del servidor.
withheld | Apagado (default) | El widget recibe decision: "withheld" sin score, y el estado público responde decision: null, score: null y verdict_withheld: true aunque la sesión ya esté completed. |
approved · review · declined | Encendido | La página hospedada y el widget pueden mostrar el veredicto. Lo prendes en Ajustes › Marca › Resultado de la verificación. |
El gate es sólo para el browser. status y result con API key y el webhook identity.completed siguen entregando el veredicto completo, lo expongas o no. Escribe el manejo de withheld desde el primer día: es lo que emite una organización recién configurada.
// Ajustes › Marca › Resultado de la verificación → Apagado (el DEFAULT)
// 1. What the browser gets (GET /app/embed/status/:token, token-auth):
{
"data": {
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"session_status": "completed",
"decision": null,
"score": null,
"verdict_withheld": true,
"redirect_url": "https://acme.com/kyc/listo",
"started_on_desktop": false
}
}
// 2. What the widget emits:
onComplete({ decision: 'withheld', requestId: '6622f8a1c1d2e3f4a5b6c7d8' }) // no score
// 3. Where the verdict of record lives (API key / webhook):
GET /app/identity-verification/status/6622f8a1c1d2e3f4a5b6c7d8 → decision: "approved"Escritorio → celular
Quien abre la verificación en una computadora ve un código QR para seguir en su celular. El QR lleva la «URL desktop» de tu organización con el relevo anexado, y la pantalla del escritorio sigue el avance sola.
?token=…&request_id=… | Lo que anexamos a tu URL desktop en el QR. Usa {token} / {request_id} en la URL para colocarlos donde quieras; sin URL configurada el QR lleva a la página de Singula. |
redirect_url | A dónde cae la persona al terminar en el celular, con ?request_id=…&status=submitted. El token NO viaja: con request_id tu servidor ya lee el resultado con su API key. |
started_on_desktop | true = hubo salto de dispositivo. La pantalla final dice «regresa a tu computadora» y no redirige. Una tablet que abre y captura sola no cuenta: no saltó a ningún lado. |
Una página que se remonta después del envío no pinta «enlace usado»: falta el veredicto, así que sigue consultando el estado y muestra «recibimos tu verificación». La pantalla de enlace muerto es sólo para completed, cancelled y expired.
// Escritorio → celular, sin que el token viaje en la barra del navegador.
// 1. QR: la «URL desktop» de tu organización con el relevo anexado
https://acme.com/kyc/continua-en-tu-celular?token=a1b2c3d4-e5f6-7890-abcd-ef1234567890&request_id=6622f8a1c1d2e3f4a5b6c7d8
// (o usa {token} / {request_id} en la URL para colocarlos donde quieras)
// 2. Mientras el QR está en pantalla, la página consulta el estado público
GET /app/embed/status/a1b2c3d4-e5f6-7890-abcd-ef1234567890 // cada 3 s los primeros 5 min, luego cada 10 s
// 3. Al terminar en el celular, con redirect_url configurado:
https://acme.com/kyc/listo?request_id=6622f8a1c1d2e3f4a5b6c7d8&status=submitted
// NO viaja el token: con request_id tu servidor ya lee el resultado con su
// API key. Si hubo salto de dispositivo (started_on_desktop: true) la
// pantalla dice «regresa a tu computadora» y no redirige.Marca blanca
El widget toma la marca de tu organización. El campo theme la sobrescribe por llamada, así una sola integración sirve a varias marcas.
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
primaryColor | string | Opcional | Color primario de la marca. Cualquier color CSS válido; un valor con ;, {, }, url( o @import se rechaza. |
secondaryColor | string | Opcional | Color de acento para degradados y controles secundarios. Misma validación. |
logoUrl | string | Opcional | URL HTTPS absoluta del logo del encabezado. http:, data: y javascript: lanzan. |
brandName | string | Opcional | El nombre que se usa en el texto, el título y las etiquetas de accesibilidad. Se le quitan <, > y caracteres de control, y se recorta a 64. |
Los overrides viajan dos veces: como parámetros de la URL, para el primer pintado, y por el mensaje init una vez que el iframe está listo, que es el canal autoritativo. Una opción rechazada lanza sin dejar nada montado.
Tres capas, y cada una pisa sólo los campos que trae: la marca de tu ORGANIZACIÓN (Ajustes › Marca) es la base; un FLUJO puede traer la suya en settings.ui, congelada en la versión publicada; y theme, en la llamada, gana sobre las dos y vale sólo para esa sesión. Sin theme, la sesión se ve como el flujo —o como la organización, si el flujo no fijó apariencia—. El objeto se manda igual para una verificación de identidad y para un flujo: es el mismo Identity.open.
Identity.open({
token: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
language: 'es',
theme: {
primaryColor: '#ff6600',
secondaryColor: '#111827',
logoUrl: 'https://acme.com/logo.svg', // absolute HTTPS
brandName: 'Acme',
},
});
// One integration, many end-brands: pass a different theme per call.Sandbox
Con una llave sk_test_ la sesión corre en sandbox de punta a punta: la captura no necesita una INE real, el veredicto es de prueba (approved, score 0.95, con datos extraídos de ejemplo), no consume saldo y el webhook sale por tu URL de sandbox.
La captura es pública (el token es la auth), así que el ambiente no se decide ahí: se hereda de la llave con la que creaste la verificación. Desde el panel lo fija su modo: en «Sandbox» manda X-Singula-Env: sandbox y la verificación nace de prueba, gratis, como con sk_test_. Misma forma de respuesta que producción, para que lo que integres en pruebas no cambie al pasar a vivo.
La identidad, la INE, la CURP ante RENAPO y el correo verificado del sandbox son todos de la misma persona de ejemplo: MARIA LOPEZ GARCIA, CURP LOGM900101MDFPRR02, nacida el 1990-01-01. Las listas de sanciones y el judicial sí contestan con el nombre que de verdad se buscó —el de tu cliente—, y lo devuelven en queries. Eso es a propósito: un expediente KYC de pruebas que cruzara a dos personas distintas nunca podría aprobar.
# Sandbox: la misma forma de respuesta, sin INE real y sin cobro.
curl -X POST https://api.singula.mx/app/identity-verification/customer/cus_9f2a7c410b8e/create \
-H "Authorization: Bearer sk_test_xxx"
# La sesión hereda el ambiente de la llave que la creó: una sesión
# de sk_test_ devuelve un veredicto de prueba y su webhook sale por la
# URL de sandbox de tu organización.{
"session_status": "completed",
"decision": "approved",
"verdict": "verified",
"score": 0.95,
"result": {
"status": "verified",
"document_type": "INE",
"extracted_data": { "nombre": "MARIA", "apellido_paterno": "LOPEZ",
"apellido_materno": "GARCIA",
"curp": "LOGM900101MDFPRR02" }
}
}Errores
onError sólo dispara en errores fatales, cuando la verificación no puede continuar. Cada uno trae un code y un message. En modal el widget además cierra con onClose("error"); en inline se queda montado para que pintes tu propio respaldo.
| code | lo emite | Descripción |
|---|---|---|
origin_not_configured | página hospedada | La organización no tiene orígenes permitidos configurados. Agrégalos en Ajustes › Marca. |
origin_not_allowed | página hospedada | El origen que embebe no está en la lista. Agrégalo tal como lo reporta el navegador (protocolo + dominio + puerto). |
origin_unknown | página hospedada | No llegó el init en 5 s y el referrer no identificó un origen de la lista. Suele ser un iframe crudo sin el SDK. |
session_cancelled | página hospedada | La sesión se canceló del lado del servidor. |
session_expired | página hospedada | La sesión caducó antes de terminar. Crea una nueva. |
verdict_timeout | SDK | No llegó el veredicto dentro de verdictTimeoutMs (35 min por defecto) desde el envío. |
load_timeout | SDK | El iframe nunca reportó ready dentro de loadTimeoutMs (20 s por defecto). |
iframe_load_error | SDK | El iframe disparó error: la URL no se pudo cargar (red, CSP frame-src, embedBaseUrl equivocado). |
Verifica tu primera identidad
Empieza en el ambiente de pruebas, sin costo.
// onError — fatal errors only. The flow cannot continue.
{
"code": "origin_not_configured",
"message": "This organization has no allowed origins configured."
}
// In modal mode a fatal error also closes the widget with onClose('error').
// In inline mode the widget stays mounted so your page renders its fallback.