Verificación de identidad

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.

1. Registra tus dominios

Sin orígenes, el widget no arranca.

2. Crea la sesión

En tu servidor, con tu API key.

3. Carga el loader

Un script de ~10 KB en el browser.

4. Lee el veredicto

En tu servidor: webhook o status.

Modelo de seguridad

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.

Solicitud
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.
Respuesta
json
{
  "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"
}
Un solo paso server-to-server: acuñas un token de corta vida y se lo pasas al widget.

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.

A · Sin librería
Link hospedado por Singula

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. 1.Crea la sesión
  2. 2.Manda el link
  3. 3.Lee el veredicto en tu servidor
No instalas nada · sin orígenes permitidos
B · Con librería
Widget dentro de tu app

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. 1.Registra tu dominio
  2. 2.Crea la sesión
  3. 3.Instala y abre el widget
  4. 4.Lee el veredicto en tu servidor
npm o CDN con SRI · orígenes permitidos obligatorios
Instala la librería (sólo la forma B)
npmnpm install @singula_systems/identity-js@0.8.0
CDNEl mismo bundle UMD por jsDelivr, con la versión fijada y integrity: la etiqueta completa está en «Embebe el widget».
Lado a lado
A · Link hospedadoB · Widget en tu app
Quién muestra la capturaSingula, en app.singula.mxTu app, en un iframe servido por Singula
Qué le das a la personaverification_url (link o QR)Nada: abres el widget con verification_token
InstalaciónNingunanpm install @singula_systems/identity-js o CDN con SRI
Orígenes permitidosNo hacen faltaObligatorios (Ajustes › Marca)
Escritorio → celularQR incluido en la páginaQR incluido dentro del widget
Cómo llega el veredictoWebhook o GET statusWebhook o GET status; onComplete sólo para tu UX
Sin escribir códigoSí: el link se genera desde el panelNo
Se pueden mezclar

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.

Solicitud
# ── 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.
Las dos formas usan el mismo create y el mismo veredicto del lado del servidor. Sólo cambia quién muestra la captura.

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.

Atributos
CampoTipoDescripción
request_idstringEl identificador de la sesión, el mismo que devolvió la creación.
session_statusstring
pendingsubmittedcompletedcancelledexpired
El estado de la SESIÓN, no del veredicto: pending (link vivo, sin fotos), submitted (ya subió, falta el veredicto), completed, cancelled, expired.
decisionstring | null
approvedreviewdeclined
El veredicto público, lo único que tu sistema debe leer para decidir. null mientras no hay veredicto.
scorenumber | nullLa confianza agregada de la sesión, de 0 a 1 (el overall_confidence del resultado). Cómo se calcula.
verdictstring | null
verifiedpartialrejectedfailed
El veredicto crudo de la tubería, del que se deriva decision. Útil para auditoría; no ramifiques sobre él.
resultobject | nullEl objeto de verificación completo: status, document_type, overall_confidence, extracted_data, checks[] y face_result. null hasta que hay veredicto.
external_idstring | nullEl identificador que mandaste al crear la sesión.
metadataobject | nullEl objeto plano que mandaste al crear la sesión.
submitted_atstring | nullCuándo mandó sus fotos la persona (ISO 8601).
completed_atstring | nullCuándo quedó el veredicto (ISO 8601).
networkobject | nullDesde 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.
verificationobjectEl 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.
verdict → decision
decisionverdictDescripción
approvedverifiedTodas las etapas pasaron. El rostro está vivo y coincide con el documento.
reviewpartial · failedQuedó dudoso (partial) o la tubería no pudo dar veredicto (failed). Va a revisión humana: failed nunca es un rechazo.
declinedrejectedFalló un control crítico: prueba de vida, cotejo de rostro o un dígito verificador del MRZ. Rechazo, sin importar el score.
failed es revisión, nunca rechazo

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.

Respuesta
json
{
  "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…)" }
}
La misma vista de sesión que devuelven status, result y el stream de eventos.

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.

La llave va en el header

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.

Solicitud
http
# Producción — verifica de verdad, consume saldo
Authorization: Bearer sk_live_xxx

# Sandbox — veredicto de prueba, no consume saldo
Authorization: Bearer sk_test_xxx

Orí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.

Qué pasa si falta
origin_not_configuredLa organización no tiene ningún origen. El widget falla de inmediato, sin esperar los 5 s.
origin_not_allowedEl origen que embebe no está en la lista. El error se manda a ESE origen.
origin_unknownNo llegó el handshake init en 5 s y el referrer no identificó un origen de la lista.
El link directo no necesita 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.

Solicitud
txt
# 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.
El origen que se compara es el event.origin del init del SDK: lo estampa el navegador y la página no lo puede falsificar.

Crea la verificación

POST/app/identity-verification/customer/{customerId}/create

Llama 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.

Cuerpo (opcional)
CampoTipoRequeridoDescripción
redirect_urlstringOpcionalA 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_idstringOpcionalTu propio identificador de la sesión (orden, expediente, ticket). Máximo 128 caracteres. Se devuelve en cada lectura y en el webhook.
metadataobjectOpcionalObjeto 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_typesstring[]
inepassportresident_card
OpcionalLos 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.
languagestring
esen
OpcionalIdioma de la página hospedada. Por defecto, el del branding de tu organización.
expires_in_hoursnumberOpcionalVigencia del link, de 1 a 720 horas. Por defecto 168 (7 días).
Respuesta
CampoTipoDescripción
request_idstringEl identificador de la sesión de verificación. Con él consultas el estado y correlacionas cada webhook.
verification_tokenstringEl 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_urlstringLa página completa que abre la persona en su propia pestaña (correo, SMS, WhatsApp). No necesita orígenes permitidos.
embed_urlstringLa misma captura lista para iframe, ya con el token. Es la que monta el SDK.
expires_atstringCuándo caduca el link (ISO 8601). Sale de expires_in_hours, o de 168 horas.
reusedbooleanPresente 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_urlstring | nullTal como quedó guardado.
external_idstring | nullTal como quedó guardado.
metadataobject | nullTal como quedó guardado.
document_typesstring[]Los documentos que ofrecerá la captura, ya resueltos (los tres si no los acotaste).
languagestring | nullnull = la página usa el idioma del branding.
Una sesión pendiente por cliente

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).

Incluido: RENAPO y Lista Nominal

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.

Solicitud
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.
Respuesta
json
{
  "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.

Instalación
npmnpm install @singula_systems/identity-js@0.8.0
CDNhttps://cdn.jsdelivr.net/npm/@singula_systems/identity-js@0.8.0/dist/singula-identity.umd.min.js
Fija la versión con SRI

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.

Opciones de Identity.open()
CampoTipoRequeridoDescripción
tokenstringRequeridoEl verification_token que acuñó tu backend. Caracteres seguros para URL, hasta 512; el UUID pelón o con el prefijo vt_, ambos funcionan.
modestring
modalinline
OpcionalCómo se dibuja el widget. Por defecto "modal".
containerstring | ElementOpcionalRequerido en modo inline: el selector o el elemento donde se monta.
embedBaseUrlstringOpcionalOverride del origen de la página hospedada (staging). Debe ser https: — http: sólo para localhost; cualquier otra cosa lanza.
languagestring
esen
OpcionalFuerza el idioma de la UI. Un valor desconocido se descarta, no se reenvía.
themeobjectOpcionalSobrescribe la marca para esta llamada (ver Marca blanca).
loadTimeoutMsnumberOpcionalPlazo para el ready del iframe. Por defecto 20 000; al vencer, onError con load_timeout. 0 lo desactiva.
verdictTimeoutMsnumberOpcionalPlazo para el veredicto después del envío. Por defecto 2 100 000 (35 min); al vencer, onError con verdict_timeout. 0 lo desactiva.
onReadyfunctionOpcionalEl iframe cargó y está listo.
onStagefunctionOpcionalUna etapa cambió de estado. Recibe { stage, status, score?, reason? }.
onRetakefunctionOpcionalLa 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.
onCompletefunctionOpcionalEl flujo terminó. Recibe { decision, score?, requestId? }, con decision = approved | declined | review | withheld.
onErrorfunctionOpcionalError fatal. Recibe { code, message }.
onClosefunctionOpcionalEl widget se cerró. Recibe la razón: user, success, error o programmatic.
Solicitud
<!-- 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>
El widget monta un iframe servido desde el origen de Singula. La cámara, la captura y la lógica corren ahí, nunca en tu página. El integrity sólo vale con la versión fijada: cada release produce otro hash.

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.

stage
receivedLa sesión se recibe y queda lista para capturar.
livenessLa prueba de vida multicuadro: gestos de cabeza guiados que confirman una persona viva.
face_matchEl rostro capturado se coteja contra la foto del documento.
documentSe lee la INE, el pasaporte o la tarjeta de residente y se extraen sus datos.
decisionSe agregan las etapas anteriores en un solo veredicto.
status
pendingrunningpassedfailed
«Todo en verde» no es «aprobado»

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.

Respuesta
json
// 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.
Cada transición de etapa llega por onStage en cuanto ocurre.

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ó.

El decision de onComplete
approved

Todas las etapas pasaron.

review

Quedó dudoso o sin veredicto.

declined

Falló un control crítico.

withheld

Terminó, pero tu organización no publica el veredicto al browser (el default).

onComplete no es autorización

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 plazo del veredicto

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.

Solicitud
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

GET/app/identity-verification/upload/{token}/precheck

Cada 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.

Con tus propias pantallas
  1. 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_front o resident_back—; su respuesta no cambia y una revisión nunca hace fallar una subida.
  2. 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. 3.Antes de submit, lee GET …/upload/{token}/precheck. Con ready en false, vuelve a leer cada 500 ms con un tope de unos segundos; lo que siga en pending después de 20 s llega como unavailable.
  4. 4.Por cada foto con retake_suggested: true, muestra tu pantalla con el texto de su reason y vuelve a subir ESA foto: presign del mismo kind → S3 → confirm. Vuelve a pending con su attempt + 1: léela otra vez.
  5. 5.Sin ningún retake_suggested, lanza la verificación con submit/{token} —si la persona cambió de documento, nómbralo en el cuerpo con document_type—. Después de enviar, esta ruta contesta 409.
Respuesta
CampoTipoDescripción
max_attemptsnumberLas fotos que se revisan por lado: 3.
readybooleantrue cuando ninguna foto sigue en pending. En false, vuelve a leer en medio segundo.
slotsobjectUna 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>.statusstring
pendingokretakeunavailable
Cómo salió la revisión de esa foto (tabla de abajo).
slots.<slot>.reasonstring | null
glareblurrydarkcroppedlow_resolutionunreadablewrong_sidewrong_documentno_document
Qué tiene la foto. Sólo con retake; si no, null.
slots.<slot>.detected_document_typestring | 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>.attemptnumberCuántas fotos de ese lado se han confirmado, desde 1. Confirmar otra vez la misma foto no suma.
slots.<slot>.retake_suggestedbooleantrue = pide otra foto de ese lado: status retake y attempt menor que max_attempts. En la tercera foto llega false aunque status sea retake.
status
pendingSe está revisando. Vuelve a leer en medio segundo.
okLa foto sirve. En sandbox, siempre, sin revisar nada.
retakeLa 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.
unavailableNo 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, y qué decirle a la persona
reasonQué pasóTexto sugerido
glareUn reflejo tapa parte del documento.Hay un reflejo sobre tu {doc}Inclínala un poco o aléjate de la luz directa.
blurryLa foto salió movida o fuera de foco.La foto salió borrosaSostén tu {doc} firme y espera a que enfoque.
darkLe faltó luz.La foto salió muy oscuraBusca un lugar con más luz.
croppedFalta una parte del documento.Tu {doc} no se ve completaQue se vean las cuatro esquinas dentro del marco.
low_resolutionEl documento salió muy lejos o muy chico.Tu {doc} salió muy lejosAcércala para que llene el marco.
unreadableLos 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.
unreadableLo 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_sideEs el otro lado del mismo documento.Parece que es el otro ladoAquí necesitamos el frente (o el reverso) de tu {doc}.
wrong_documentEs otro documento de identidad; detected_document_type dice cuál.Esta foto parece de tu {detectado}Aquí necesitamos tu {doc}.
no_documentNo 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.

Otra identificación en la foto

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.

Tres fotos por lado, y el veredicto decide

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.

Lo que no cambia

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.

onRetake, en el widget

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.

En el resultado: retake_suggested

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.

Solicitud
# 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.
Respuesta
json
{
  "max_attempts": 3,
  "ready": true,
  "slots": {
    "passport_image": {
      "status": "retake",
      "reason": "wrong_document",
      "detected_document_type": "INE",
      "attempt": 1,
      "retake_suggested": true
    }
  }
}
Sólo salen las fotos del documento ya confirmadas, y reason viene sólo con retake; detected_document_type, sólo con wrong_document (si no, null). retakePhoto es tu pantalla: pide la foto otra vez y la sube igual que la primera.

Estado y veredicto

GET/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.

Aditivo

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.

Desde dónde se conectó

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.

Solicitud
curl https://api.singula.mx/app/identity-verification/status/6622f8a1c1d2e3f4a5b6c7d8 \
  -H "Authorization: Bearer sk_live_xxx"
Respuesta
json
{
  "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.

Las cuatro categorías (INE)
CategoríaPesoQué incluye
Críticos50 %Cotejo facial, prueba de vida y los dígitos verificadores de la MRZ. Si cualquiera falla, manda la regla dura de abajo.
RENAPO25 %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 interna15 %Que el documento cuadre consigo mismo: CURP ↔ MRZ ↔ datos impresos ↔ clave de elector (fecha de nacimiento, sexo y estado).
Calidad10 %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.
Si una categoría no aplica, su peso se reparte

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.

Del score al veredicto
score ≥ 0.85
verified → approved

Ningún control crítico falló y la evidencia es sólida.

0.60 ≤ score < 0.85
partial → review

Ningún control crítico falló, pero algún cruce o dato no es perfecto: revísalo antes de aceptar.

score < 0.60
failed → review

La 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).

Regla dura: un control crítico manda sobre el score

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.

Respuesta
json
// 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
}
Números de ejemplo; los pesos, el reparto entre 0.75 y las bandas son los reales.

Resultado y fotos

GET/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.

query
CampoTipoRequeridoDescripción
files_ttlnumberOpcionalVigencia de las URLs firmadas, en segundos. Se acota a [60, 86 400]; por defecto 600.
include_framesbooleanOpcionaltrue firma también los liveness_frame_<i> (hasta 30 URLs más). Por defecto false: se firman sólo los documentos y la selfie.
Los cuadros de la prueba de vida se piden

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.

Solicitud
cURL
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"
Respuesta
json
{
  "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"
}
Contesta 200 aunque no haya veredicto: revisar las fotos de una sesión submitted es justo para lo que sirve.

Eventos de la sesión (SSE)

GET/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.

event
sessionLa vista de sesión sin result (el payload pesado se lee en status o result).
keepaliveLatido cada 15 s con { at } para que ningún proxy corte el stream.
end{ reason: "terminal" | "timeout" }. terminal = completed, cancelled o expired.
timeout no es el final de la sesión

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.

Solicitud
cURL
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.
Respuesta
json
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"}
end con reason: "timeout" NO es el final de la sesión — es el corte de 15 min de ese stream. Reconecta.

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
eventDescripción
identity.session.createdSe creó la sesión y su link. Trae request_id, customer_id, verification_token y expires_at.
identity.completedLa sesión cerró con su veredicto: decision, verdict, score, document_type, external_id y metadata.
identity.listanominal.completedLa 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.
Verifica la firma

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.

Dónde cae el payload

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.

Respuesta
json
// 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.

Apagado (recomendado) vs. encendido
withheldApagado (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 · declinedEncendidoLa página hospedada y el widget pueden mostrar el veredicto. Lo prendes en Ajustes › Marca › Resultado de la verificación.
El camino del servidor no cambia

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.

Respuesta
json
// 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.

Cómo viaja el relevo
?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_urlA 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_desktoptrue = 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.
La sesión submitted sigue viva

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.

Respuesta
json
// 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.

theme
CampoTipoRequeridoDescripción
primaryColorstringOpcionalColor primario de la marca. Cualquier color CSS válido; un valor con ;, {, }, url( o @import se rechaza.
secondaryColorstringOpcionalColor de acento para degradados y controles secundarios. Misma validación.
logoUrlstringOpcionalURL HTTPS absoluta del logo del encabezado. http:, data: y javascript: lanzan.
brandNamestringOpcionalEl 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.
Se valida antes de montar nada

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.

Quién gana: organización, flujo, llamada

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.

Solicitud
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.

El ambiente lo fija la llave que creó la sesión

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.

El sandbox describe a UNA sola persona

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.

Solicitud
cURL
# 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.
Respuesta
json
{
  "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
codelo emiteDescripción
origin_not_configuredpágina hospedadaLa organización no tiene orígenes permitidos configurados. Agrégalos en Ajustes › Marca.
origin_not_allowedpágina hospedadaEl origen que embebe no está en la lista. Agrégalo tal como lo reporta el navegador (protocolo + dominio + puerto).
origin_unknownpágina hospedadaNo 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_cancelledpágina hospedadaLa sesión se canceló del lado del servidor.
session_expiredpágina hospedadaLa sesión caducó antes de terminar. Crea una nueva.
verdict_timeoutSDKNo llegó el veredicto dentro de verdictTimeoutMs (35 min por defecto) desde el envío.
load_timeoutSDKEl iframe nunca reportó ready dentro de loadTimeoutMs (20 s por defecto).
iframe_load_errorSDKEl 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.

Respuesta
json
// 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.