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.3.2
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).
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).
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": "JUAN",
      "apellido_paterno": "PEREZ",
      "apellido_materno": "LOPEZ",
      "curp": "PELJ900101HDFRPN07",
      "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",
  "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[]
inepassport
OpcionalLos documentos que ofrece la pantalla de selección. Por defecto los dos; 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 dos 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).

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.3.2
CDNhttps://cdn.jsdelivr.net/npm/@singula_systems/identity-js@0.3.2/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? }.
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-/O+VSG1LzU++WQbBdiin/yCYW7jUNMFETY9+SnB9XK20YR8xYge8WoLp1N91fmAl"
  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 o el pasaporte 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);
  },
  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'
  },
});

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.

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": "JUAN",
      "apellido_paterno": "PEREZ",
      "apellido_materno": "LOPEZ",
      "curp": "PELJ900101HDFRPN07",
      "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",
  "verification": { "…": "documento crudo de la sesión (token, status, s3_keys, device_info, opens…)" }
}

Resultado y fotos

GET/app/identity-verification/result/{requestId}

Lo mismo que status más files: cada slot (ine_front, ine_back, passport_image, 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.completedSeguimiento informativo del cotejo contra la Lista Nominal. Mismo request_id; no invalida el veredicto.
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.

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. Misma forma de respuesta que producción, para que lo que integres en pruebas no cambie al pasar a vivo.

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": "JUAN", "apellido_paterno": "PEREZ",
                        "curp": "PELJ900101HDFRPN07" }
  }
}

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.