Guía de integración de @singula_systems/identity-js
Registra tus dominios, crea la sesión en tu servidor, carga el loader y lee el veredicto del lado del servidor. La captura corre en un iframe servido por Singula; tú sólo embebes el widget. ¿No quieres instalar nada? Manda el link que Singula hospeda: misma sesión, mismo veredicto.
Resumen
El widget de identidad es un iframe hospedado que corre una prueba de vida guiada, lee el documento, coteja el rostro y cierra en un veredicto. Hay dos formas de llegar ahí: el link que Singula hospeda o el widget dentro de tu app. Las cuatro cosas de abajo son el camino del widget; con el link sólo haces la 2 y la 4.
Sin orígenes, el widget no arranca.
En tu servidor, con tu API key.
Un script de ~10 KB en el browser.
En tu servidor: webhook o status.
Tu API key (sk_live_*) nunca llega al browser: el servidor crea la sesión y sólo el token de corta vida viaja al front-end. El widget lo acepta con o sin el prefijo vt_. Los permisos de cámara los otorga el origen de Singula, no el tuyo. Y el veredicto de registro vive del lado del servidor: onComplete es UX, no autorización.
curl -X POST https://api.singula.mx/app/identity-verification/customer/cus_9f2a7c410b8e/create \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"redirect_url": "https://acme.com/kyc/listo",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"document_types": ["ine"],
"language": "es",
"expires_in_hours": 72
}'
# El cuerpo es OPCIONAL: un POST sin body sigue funcionando igual.{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"verification_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"verification_url": "https://app.singula.mx/verify/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"embed_url": "https://app.singula.mx/embed/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expires_at": "2026-09-14T18:45:00.000Z",
"redirect_url": "https://acme.com/kyc/listo",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"document_types": ["ine"],
"language": "es"
}Dos formas de integrar
Las dos parten de la misma sesión (el mismo create) y cierran igual (webhook o status en tu servidor). Lo único que cambia es quién muestra la captura: Singula en su página, o tu app con el widget.
El flujo normal. Tu servidor crea la sesión y le mandas verification_url a la persona por SMS, WhatsApp o correo; o lo generas desde el panel sin escribir código. La página, con tu marca, vive en app.singula.mx e incluye el salto de escritorio a celular con QR.
- 1.Crea la sesión
- 2.Manda el link
- 3.Lee el veredicto en tu servidor
La captura se abre dentro de tu producto, en modal o montada en tu contenedor. Instalas @singula_systems/identity-js, registras tu dominio, pasas el verification_token al front y usas onComplete para tu UX. El veredicto de registro lo sigues leyendo en tu servidor.
- 1.Registra tu dominio
- 2.Crea la sesión
- 3.Instala y abre el widget
- 4.Lee el veredicto en tu servidor
| npm | npm install @singula_systems/identity-js@0.3.2 |
| CDN | El mismo bundle UMD por jsDelivr, con la versión fijada y integrity: la etiqueta completa está en «Embebe el widget». |
| A · Link hospedado | B · Widget en tu app | |
|---|---|---|
| Quién muestra la captura | Singula, en app.singula.mx | Tu app, en un iframe servido por Singula |
| Qué le das a la persona | verification_url (link o QR) | Nada: abres el widget con verification_token |
| Instalación | Ninguna | npm install @singula_systems/identity-js o CDN con SRI |
| Orígenes permitidos | No hacen falta | Obligatorios (Ajustes › Marca) |
| Escritorio → celular | QR incluido en la página | QR incluido dentro del widget |
| Cómo llega el veredicto | Webhook o GET status | Webhook o GET status; onComplete sólo para tu UX |
| Sin escribir código | Sí: el link se genera desde el panel | No |
Es la misma sesión: puedes mandar el link por WhatsApp a quien no está en tu web y abrir el widget a quien sí. Un <iframe> crudo con embed_url y sin la librería también carga, pero entonces el handshake init lo haces tú; sin él la página cierra con origin_unknown.
# ── A) Sin librería: Singula hospeda la página (flujo normal) ──────
# 1. Tu servidor crea la sesión
curl -X POST https://api.singula.mx/app/identity-verification/customer/cus_9f2a7c410b8e/create \
-H "Authorization: Bearer sk_live_xxx"
# → { "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
# "verification_url": "https://app.singula.mx/verify/a1b2c3d4-e5f6-7890-abcd-ef1234567890", … }
# 2. Le mandas verification_url a la persona (SMS, WhatsApp, correo)
# — o lo generas desde el panel, sin escribir código.
# 3. El veredicto llega a tu servidor: webhook identity.completed o GET status.
# No instalas nada ni registras orígenes.
# ── B) Con librería: la captura dentro de tu app ───────────────────
# 0. Registra tu dominio en Ajustes › Marca (obligatorio)
# 1. Instala el loader
npm install @singula_systems/[email protected]
# (o el mismo bundle por jsDelivr con SRI: ver «Embebe el widget»)
# 2. Tu servidor crea la sesión (el MISMO create) y manda verification_token al front
# 3. Abres el widget en tu página
# Singula.Identity.open({ token, onComplete })
# 4. El veredicto sigue llegando a tu servidor: webhook o GET status.La sesión de verificación
Una sesión (request_id) tiene DOS ejes que no hay que confundir: session_status describe la sesión y decision el veredicto. Es el mismo objeto que devuelven status, result y el stream de eventos.
| Campo | Tipo | Descripción |
|---|---|---|
request_id | string | El identificador de la sesión, el mismo que devolvió la creación. |
session_status | string pendingsubmittedcompletedcancelledexpired | El estado de la SESIÓN, no del veredicto: pending (link vivo, sin fotos), submitted (ya subió, falta el veredicto), completed, cancelled, expired. |
decision | string | null approvedreviewdeclined | El veredicto público, lo único que tu sistema debe leer para decidir. null mientras no hay veredicto. |
score | number | null | La confianza agregada de la sesión, de 0 a 1 (el overall_confidence del resultado). |
verdict | string | null verifiedpartialrejectedfailed | El veredicto crudo de la tubería, del que se deriva decision. Útil para auditoría; no ramifiques sobre él. |
result | object | null | El objeto de verificación completo: status, document_type, overall_confidence, extracted_data, checks[] y face_result. null hasta que hay veredicto. |
external_id | string | null | El identificador que mandaste al crear la sesión. |
metadata | object | null | El objeto plano que mandaste al crear la sesión. |
submitted_at | string | null | Cuándo mandó sus fotos la persona (ISO 8601). |
completed_at | string | null | Cuándo quedó el veredicto (ISO 8601). |
verification | object | El documento crudo de la sesión (token, status, s3_keys, challenge, device_info, opens…). Se conserva sin cambios para los consumidores que ya lo leían. |
| decision | verdict | Descripción |
|---|---|---|
approved | verified | Todas las etapas pasaron. El rostro está vivo y coincide con el documento. |
review | partial · failed | Quedó dudoso (partial) o la tubería no pudo dar veredicto (failed). Va a revisión humana: failed nunca es un rechazo. |
declined | rejected | Falló un control crítico: prueba de vida, cotejo de rostro o un dígito verificador del MRZ. Rechazo, sin importar el score. |
Cuando la tubería no pudo dar un veredicto no hay nada que afirmar sobre la persona, así que la sesión va a revisión humana en lugar de decir que fue rechazada.
{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"session_status": "completed",
"decision": "approved",
"score": 0.95,
"verdict": "verified",
"result": {
"status": "verified",
"document_type": "INE",
"overall_confidence": 0.95,
"extracted_data": {
"nombre": "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…)" }
}Autenticación
Todo lo que lee o crea sesiones se autentica con tu API key en el encabezado Authorization. sk_test_* para pruebas, sk_live_* para producción. Las rutas públicas (la captura y el estado que consulta la página hospedada) se autentican con el propio token de la sesión.
Ninguna ruta de identidad acepta la API key como parámetro de la URL, el stream de eventos incluido: una llave en la URL se filtra en logs y referrers.
# Producción — verifica de verdad, consume saldo
Authorization: Bearer sk_live_xxx
# Sandbox — veredicto de prueba, no consume saldo
Authorization: Bearer sk_test_xxxOrígenes permitidos (obligatorio)
Antes de integrar, registra el origen de la página que embebe el widget en Ajustes › Marca › Dominios y continuidad. Dentro de un iframe la verificación sólo arranca si el origen está en la lista: una lista vacía es una mala configuración, no un comodín. Para desarrollar en local se aceptan http://localhost y http://127.0.0.1 con su puerto; todo lo demás va en https y sin ruta.
origin_not_configured | La organización no tiene ningún origen. El widget falla de inmediato, sin esperar los 5 s. |
origin_not_allowed | El origen que embebe no está en la lista. El error se manda a ESE origen. |
origin_unknown | No llegó el handshake init en 5 s y el referrer no identificó un origen de la lista. |
La lista gobierna el enmarcado. verification_url se abre como pestaña propia (correo, SMS, WhatsApp) y no tiene padre a quien autorizar. En las tres fallas de arriba la captura nunca empieza.
# Ajustes › Marca › Dominios y continuidad
# «Allowed origins · uno por línea» — protocolo + dominio + puerto, sin ruta.
https://acme.com
https://checkout.acme.com
http://localhost:5173
# http sólo para localhost / 127.0.0.1 / [::1] (pruebas); todo lo demás https.
# Sin barra final ni ruta: https://acme.com/kyc nunca coincide y rebota con 400.
# Lista vacía = mala configuración, NO comodín: el widget falla cerrado.Crea la verificación
/app/identity-verification/customer/{customerId}/createLlama a este endpoint desde tu servidor. Devuelve el request_id de la sesión, el verification_token que le pasas al widget y los dos links de captura. El cuerpo es opcional: sirve para amarrar la sesión a tu propio flujo.
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
redirect_url | string | Opcional | A dónde mandar a la persona cuando termina la captura en su celular. https obligatorio, máximo 2048 caracteres. Se le anexan request_id y status; el token nunca viaja. |
external_id | string | Opcional | Tu propio identificador de la sesión (orden, expediente, ticket). Máximo 128 caracteres. Se devuelve en cada lectura y en el webhook. |
metadata | object | Opcional | Objeto plano de valores string, number o boolean; máximo 2 KB serializado. Se devuelve en cada lectura y en el webhook. No es lugar para blobs. |
document_types | string[] inepassport | Opcional | Los documentos que ofrece la pantalla de selección. Por defecto los dos; con uno solo se entra directo a capturarlo. |
language | string esen | Opcional | Idioma de la página hospedada. Por defecto, el del branding de tu organización. |
expires_in_hours | number | Opcional | Vigencia del link, de 1 a 720 horas. Por defecto 168 (7 días). |
| Campo | Tipo | Descripción |
|---|---|---|
request_id | string | El identificador de la sesión de verificación. Con él consultas el estado y correlacionas cada webhook. |
verification_token | string | El token de corta vida que pasas al widget en el browser. Se devuelve como un UUID pelón; el widget lo acepta con o sin el prefijo vt_. Es lo único que sale al front-end y es una credencial: trátalo como tal. |
verification_url | string | La página completa que abre la persona en su propia pestaña (correo, SMS, WhatsApp). No necesita orígenes permitidos. |
embed_url | string | La misma captura lista para iframe, ya con el token. Es la que monta el SDK. |
expires_at | string | Cuándo caduca el link (ISO 8601). Sale de expires_in_hours, o de 168 horas. |
reused | boolean | Presente y true cuando ya había una sesión pendiente del cliente: se devuelve ESA, no se cobra de nuevo y se conservan sus valores originales (un link ya compartido no se repunta a otro redirect_url). |
redirect_url | string | null | Tal como quedó guardado. |
external_id | string | null | Tal como quedó guardado. |
metadata | object | null | Tal como quedó guardado. |
document_types | string[] | Los documentos que ofrecerá la captura, ya resueltos (los dos si no los acotaste). |
language | string | null | null = la página usa el idioma del branding. |
Si el cliente ya tenía una verificación pendiente se devuelve ESA con reused: true: no se cobra de nuevo y NO se le cambian las opciones. Para empezar de cero, cancela la anterior con POST …/customer/{customerId}/cancel (cancelar no consume saldo).
curl -X POST https://api.singula.mx/app/identity-verification/customer/cus_9f2a7c410b8e/create \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"redirect_url": "https://acme.com/kyc/listo",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"document_types": ["ine"],
"language": "es",
"expires_in_hours": 72
}'
# El cuerpo es OPCIONAL: un POST sin body sigue funcionando igual.{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"verification_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"verification_url": "https://app.singula.mx/verify/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"embed_url": "https://app.singula.mx/embed/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expires_at": "2026-09-14T18:45:00.000Z",
"redirect_url": "https://acme.com/kyc/listo",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"document_types": ["ine"],
"language": "es"
}Embebe el widget
El loader se distribuye en npm y, desde ahí, en los CDN respaldados por npm. En modal se abre sobre tu página; en inline se monta en tu propio contenedor.
| npm | npm install @singula_systems/identity-js@0.3.2 |
| CDN | https://cdn.jsdelivr.net/npm/@singula_systems/identity-js@0.3.2/dist/singula-identity.umd.min.js |
Un <script> de tercero es código que delegas a un CDN. Fija la versión exacta y deja que el navegador verifique los bytes con integrity (ver el panel): así un objeto mal servido falla cerrado en vez de ejecutarse. El hash vale para UN archivo, así que no sirve con un alias móvil ni entre versiones.
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
token | string | Requerido | El verification_token que acuñó tu backend. Caracteres seguros para URL, hasta 512; el UUID pelón o con el prefijo vt_, ambos funcionan. |
mode | string modalinline | Opcional | Cómo se dibuja el widget. Por defecto "modal". |
container | string | Element | Opcional | Requerido en modo inline: el selector o el elemento donde se monta. |
embedBaseUrl | string | Opcional | Override del origen de la página hospedada (staging). Debe ser https: — http: sólo para localhost; cualquier otra cosa lanza. |
language | string esen | Opcional | Fuerza el idioma de la UI. Un valor desconocido se descarta, no se reenvía. |
theme | object | Opcional | Sobrescribe la marca para esta llamada (ver Marca blanca). |
loadTimeoutMs | number | Opcional | Plazo para el ready del iframe. Por defecto 20 000; al vencer, onError con load_timeout. 0 lo desactiva. |
verdictTimeoutMs | number | Opcional | Plazo para el veredicto después del envío. Por defecto 2 100 000 (35 min); al vencer, onError con verdict_timeout. 0 lo desactiva. |
onReady | function | Opcional | El iframe cargó y está listo. |
onStage | function | Opcional | Una etapa cambió de estado. Recibe { stage, status, score?, reason? }. |
onComplete | function | Opcional | El flujo terminó. Recibe { decision, score?, requestId? }, con decision = approved | declined | review | withheld. |
onError | function | Opcional | Error fatal. Recibe { code, message }. |
onClose | function | Opcional | El widget se cerró. Recibe la razón: user, success, error o programmatic. |
<!-- jsDelivr, versión fijada + SRI (el navegador verifica los bytes) -->
<script
src="https://cdn.jsdelivr.net/npm/@singula_systems/[email protected]/dist/singula-identity.umd.min.js"
integrity="sha384-/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>Las cinco etapas
La verificación avanza por cinco etapas y cada una entrega su resultado en cuanto termina. onStage llega por cada transición.
received | La sesión se recibe y queda lista para capturar. |
liveness | La prueba de vida multicuadro: gestos de cabeza guiados que confirman una persona viva. |
face_match | El rostro capturado se coteja contra la foto del documento. |
document | Se lee la INE o el pasaporte y se extraen sus datos. |
decision | Se agregan las etapas anteriores en un solo veredicto. |
Las etapas y el veredicto son independientes: cinco etapas en passed no implican una decisión, y con el veredicto retenido la etapa decision no llega nunca. Guía tu UI por onComplete, y el veredicto léelo en tu servidor.
// onStage fires once per stage transition (iframe → parent):
{ "stage": "received", "status": "passed" }
{ "stage": "liveness", "status": "running" }
{ "stage": "liveness", "status": "passed", "score": 0.91 }
{ "stage": "face_match", "status": "passed", "score": 0.82 }
{ "stage": "document", "status": "passed" }
{ "stage": "decision", "status": "passed" }
// With the verdict withheld (the DEFAULT) the 'decision' stage never
// arrives — the page cannot report 'passed' for a verdict it does not know.Callbacks
Conecta onStage para pintar tu propio progreso y onComplete para enrutar al usuario. onError es sólo para errores fatales; onClose te dice por qué se cerró.
approvedTodas las etapas pasaron.
reviewQuedó dudoso o sin veredicto.
declinedFalló un control crítico.
withheldTerminó, pero tu organización no publica el veredicto al browser (el default).
Nunca otorgues nada con base en un callback del browser: describe lo que la página frente a esa persona dice que pasó, y quien tiene el teclado puede llamar tu handler con lo que quiera. Usa requestId para preguntarle a TU backend, que tiene la API key.
El veredicto llega minutos después del envío, por un canal que puede morir (la pestaña se congela, la sesión se cancela). El SDK arma un respaldo al detectar el envío: si no llega nada en verdictTimeoutMs (35 min) dispara verdict_timeout una vez. retryStage() está reservado: hoy la página hospedada no lo atiende.
Identity.open({
token: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
loadTimeoutMs: 20000, // deadline for the iframe's 'ready' → load_timeout
verdictTimeoutMs: 2100000, // 35 min deadline after submit → verdict_timeout
onReady: () => renderStart(),
onStage: ({ stage, status, score, reason }) => {
// stage: 'received' | 'liveness' | 'face_match' | 'document' | 'decision'
// status: 'pending' | 'running' | 'passed' | 'failed'
renderProgress(stage, status);
},
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
/app/identity-verification/status/{requestId}La vista de sesión completa, con API key: session_status, decision, score, verdict y el result con checks[], extracted_data y face_result. Es el veredicto de registro, el que tu sistema usa para decidir.
El objeto verification crudo se conserva tal cual: quien ya leía de ahí no tiene que cambiar nada. 404 si ese requestId no es de tu organización.
curl https://api.singula.mx/app/identity-verification/status/6622f8a1c1d2e3f4a5b6c7d8 \
-H "Authorization: Bearer sk_live_xxx"{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"session_status": "completed",
"decision": "approved",
"score": 0.95,
"verdict": "verified",
"result": {
"status": "verified",
"document_type": "INE",
"overall_confidence": 0.95,
"extracted_data": {
"nombre": "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
/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.
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
files_ttl | number | Opcional | Vigencia de las URLs firmadas, en segundos. Se acota a [60, 86 400]; por defecto 600. |
include_frames | boolean | Opcional | true firma también los liveness_frame_<i> (hasta 30 URLs más). Por defecto false: se firman sólo los documentos y la selfie. |
Son evidencia de auditoría y hasta 30 firmas más, así que por defecto no se firman. Pídelos con include_frames=true cuando de verdad los vayas a revisar.
curl "https://api.singula.mx/app/identity-verification/result/6622f8a1c1d2e3f4a5b6c7d8?files_ttl=600" \
-H "Authorization: Bearer sk_live_xxx"
# Los hasta 30 cuadros de la prueba de vida se firman aparte:
curl "https://api.singula.mx/app/identity-verification/result/6622f8a1c1d2e3f4a5b6c7d8?include_frames=true" \
-H "Authorization: Bearer sk_live_xxx"{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"session_status": "submitted",
"decision": null,
"score": null,
"verdict": null,
"result": null,
"files": {
"ine_front": "https://s3…/ine_front.jpg?X-Amz-Signature=REDACTED",
"ine_back": "https://s3…/ine_back.jpg?X-Amz-Signature=REDACTED",
"selfie": "https://s3…/selfie.jpg?X-Amz-Signature=REDACTED"
},
"files_expires_at": "2026-09-11T12:10:00.000Z"
}Eventos de la sesión (SSE)
/app/identity-verification/events/{requestId}Un stream text/event-stream para seguir una sesión sin sondearla tú. Emite session al conectar y en cada cambio de session_status o decision, keepalive cada 15 s, y end al cerrar.
session | La vista de sesión sin result (el payload pesado se lee en status o result). |
keepalive | Latido cada 15 s con { at } para que ningún proxy corte el stream. |
end | { reason: "terminal" | "timeout" }. terminal = completed, cancelled o expired. |
end { reason: "timeout" } es el corte de 15 minutos de ESE stream, con la sesión todavía abierta: reconecta o lee status/{requestId}. Sólo terminal cierra la historia. Una verificación la completa una persona: tarda lo que tarda.
curl -N https://api.singula.mx/app/identity-verification/events/6622f8a1c1d2e3f4a5b6c7d8 \
-H "Authorization: Bearer sk_live_xxx" \
-H "Accept: text/event-stream"
# La llave va en el header. Esta ruta NO acepta api_key en la query.event: session
data: {"request_id":"6622f8a1c1d2e3f4a5b6c7d8","session_status":"submitted","decision":null,
"score":null,"verdict":null,"external_id":"ord_123","metadata":{"plan":"pro"},
"submitted_at":"2026-09-11T12:00:00.000Z","completed_at":null}
event: keepalive
data: {"at":"2026-09-11T12:00:15.000Z"}
event: session
data: {"request_id":"6622f8a1c1d2e3f4a5b6c7d8","session_status":"completed",
"decision":"approved","score":0.95,"verdict":"verified",
"completed_at":"2026-09-11T12:03:00.000Z"}
event: end
data: {"reason":"terminal"}Webhooks
La entrega que no depende de que el browser siga abierto. Cada evento viaja en el mismo sobre firmado que el resto de las herramientas: el payload es data, no el body.
| event | Descripción |
|---|---|
identity.session.created | Se creó la sesión y su link. Trae request_id, customer_id, verification_token y expires_at. |
identity.completed | La sesión cerró con su veredicto: decision, verdict, score, document_type, external_id y metadata. |
identity.listanominal.completed | Seguimiento informativo del cotejo contra la Lista Nominal. Mismo request_id; no invalida el veredicto. |
X-Singula-Signature llega como t=<unix>,v1=<hmac>, donde el HMAC-SHA256 se calcula sobre `${t}.${rawBody}` con el secreto de tu webhook. Calcúlalo sobre el cuerpo CRUDO, antes de parsearlo. El evento también viaja en X-Singula-Event.
Con un webhook dado de alta en el dashboard recibes el sobre { id, type, created, data } firmado y con reintentos: lee body.data. Por la ruta de respaldo (sin webhook dado de alta) el mismo objeto llega plano y sin firma. Sandbox manda exactamente el mismo payload, así que lo que integres contra pruebas funciona en producción.
// Singula → tu endpoint. El mismo sobre firmado de todas las herramientas:
// POST https://your-server.com/webhooks/singula
// X-Singula-Signature: t=1757596980,v1=<hmac-sha256("<t>.<raw body>", secret)>
// X-Singula-Event: identity.completed
// X-Singula-Delivery-Id: <id de la entrega>
{
"id": "6b1f…", // id de la entrega
"type": "identity.completed",
"created": 1757596980, // unix, en segundos
"data": {
"event": "identity.completed",
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"customer_id": "cus_9f2a7c410b8e",
"tool_type": "identityVerification",
"status": "success",
"session_status": "completed",
"decision": "approved", // approved | review | declined | null
"verdict": "verified", // verified | partial | rejected | failed
"score": 0.95, // 0..1
"document_type": "INE",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"completed_at": "2026-09-11T12:03:00.000Z"
}
}Quién ve el veredicto
Mostrar el resultado a la persona verificada es una decisión de tu organización, y viene APAGADA. Con el default el widget recibe decision: "withheld" y la pantalla final es neutra: el veredicto se lee del lado del servidor.
withheld | Apagado (default) | El widget recibe decision: "withheld" sin score, y el estado público responde decision: null, score: null y verdict_withheld: true aunque la sesión ya esté completed. |
approved · review · declined | Encendido | La página hospedada y el widget pueden mostrar el veredicto. Lo prendes en Ajustes › Marca › Resultado de la verificación. |
El gate es sólo para el browser. status y result con API key y el webhook identity.completed siguen entregando el veredicto completo, lo expongas o no. Escribe el manejo de withheld desde el primer día: es lo que emite una organización recién configurada.
// Ajustes › Marca › Resultado de la verificación → Apagado (el DEFAULT)
// 1. What the browser gets (GET /app/embed/status/:token, token-auth):
{
"data": {
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"session_status": "completed",
"decision": null,
"score": null,
"verdict_withheld": true,
"redirect_url": "https://acme.com/kyc/listo",
"started_on_desktop": false
}
}
// 2. What the widget emits:
onComplete({ decision: 'withheld', requestId: '6622f8a1c1d2e3f4a5b6c7d8' }) // no score
// 3. Where the verdict of record lives (API key / webhook):
GET /app/identity-verification/status/6622f8a1c1d2e3f4a5b6c7d8 → decision: "approved"Escritorio → celular
Quien abre la verificación en una computadora ve un código QR para seguir en su celular. El QR lleva la «URL desktop» de tu organización con el relevo anexado, y la pantalla del escritorio sigue el avance sola.
?token=…&request_id=… | Lo que anexamos a tu URL desktop en el QR. Usa {token} / {request_id} en la URL para colocarlos donde quieras; sin URL configurada el QR lleva a la página de Singula. |
redirect_url | A dónde cae la persona al terminar en el celular, con ?request_id=…&status=submitted. El token NO viaja: con request_id tu servidor ya lee el resultado con su API key. |
started_on_desktop | true = hubo salto de dispositivo. La pantalla final dice «regresa a tu computadora» y no redirige. Una tablet que abre y captura sola no cuenta: no saltó a ningún lado. |
Una página que se remonta después del envío no pinta «enlace usado»: falta el veredicto, así que sigue consultando el estado y muestra «recibimos tu verificación». La pantalla de enlace muerto es sólo para completed, cancelled y expired.
// Escritorio → celular, sin que el token viaje en la barra del navegador.
// 1. QR: la «URL desktop» de tu organización con el relevo anexado
https://acme.com/kyc/continua-en-tu-celular?token=a1b2c3d4-e5f6-7890-abcd-ef1234567890&request_id=6622f8a1c1d2e3f4a5b6c7d8
// (o usa {token} / {request_id} en la URL para colocarlos donde quieras)
// 2. Mientras el QR está en pantalla, la página consulta el estado público
GET /app/embed/status/a1b2c3d4-e5f6-7890-abcd-ef1234567890 // cada 3 s los primeros 5 min, luego cada 10 s
// 3. Al terminar en el celular, con redirect_url configurado:
https://acme.com/kyc/listo?request_id=6622f8a1c1d2e3f4a5b6c7d8&status=submitted
// NO viaja el token: con request_id tu servidor ya lee el resultado con su
// API key. Si hubo salto de dispositivo (started_on_desktop: true) la
// pantalla dice «regresa a tu computadora» y no redirige.Marca blanca
El widget toma la marca de tu organización. El campo theme la sobrescribe por llamada, así una sola integración sirve a varias marcas.
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
primaryColor | string | Opcional | Color primario de la marca. Cualquier color CSS válido; un valor con ;, {, }, url( o @import se rechaza. |
secondaryColor | string | Opcional | Color de acento para degradados y controles secundarios. Misma validación. |
logoUrl | string | Opcional | URL HTTPS absoluta del logo del encabezado. http:, data: y javascript: lanzan. |
brandName | string | Opcional | El nombre que se usa en el texto, el título y las etiquetas de accesibilidad. Se le quitan <, > y caracteres de control, y se recorta a 64. |
Los overrides viajan dos veces: como parámetros de la URL, para el primer pintado, y por el mensaje init una vez que el iframe está listo, que es el canal autoritativo. Una opción rechazada lanza sin dejar nada montado.
Identity.open({
token: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
language: 'es',
theme: {
primaryColor: '#ff6600',
secondaryColor: '#111827',
logoUrl: 'https://acme.com/logo.svg', // absolute HTTPS
brandName: 'Acme',
},
});
// One integration, many end-brands: pass a different theme per call.Sandbox
Con una llave sk_test_ la sesión corre en sandbox de punta a punta: la captura no necesita una INE real, el veredicto es de prueba (approved, score 0.95, con datos extraídos de ejemplo), no consume saldo y el webhook sale por tu URL de sandbox.
La captura es pública (el token es la auth), así que el ambiente no se decide ahí: se hereda de la llave con la que creaste la verificación. Misma forma de respuesta que producción, para que lo que integres en pruebas no cambie al pasar a vivo.
# Sandbox: la misma forma de respuesta, sin INE real y sin cobro.
curl -X POST https://api.singula.mx/app/identity-verification/customer/cus_9f2a7c410b8e/create \
-H "Authorization: Bearer sk_test_xxx"
# La sesión hereda el ambiente de la llave que la creó: una sesión
# de sk_test_ devuelve un veredicto de prueba y su webhook sale por la
# URL de sandbox de tu organización.{
"session_status": "completed",
"decision": "approved",
"verdict": "verified",
"score": 0.95,
"result": {
"status": "verified",
"document_type": "INE",
"extracted_data": { "nombre": "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 | lo emite | Descripción |
|---|---|---|
origin_not_configured | página hospedada | La organización no tiene orígenes permitidos configurados. Agrégalos en Ajustes › Marca. |
origin_not_allowed | página hospedada | El origen que embebe no está en la lista. Agrégalo tal como lo reporta el navegador (protocolo + dominio + puerto). |
origin_unknown | página hospedada | No llegó el init en 5 s y el referrer no identificó un origen de la lista. Suele ser un iframe crudo sin el SDK. |
session_cancelled | página hospedada | La sesión se canceló del lado del servidor. |
session_expired | página hospedada | La sesión caducó antes de terminar. Crea una nueva. |
verdict_timeout | SDK | No llegó el veredicto dentro de verdictTimeoutMs (35 min por defecto) desde el envío. |
load_timeout | SDK | El iframe nunca reportó ready dentro de loadTimeoutMs (20 s por defecto). |
iframe_load_error | SDK | El iframe disparó error: la URL no se pudo cargar (red, CSP frame-src, embedBaseUrl equivocado). |
Verifica tu primera identidad
Empieza en el ambiente de pruebas, sin costo.
// onError — fatal errors only. The flow cannot continue.
{
"code": "origin_not_configured",
"message": "This organization has no allowed origins configured."
}
// In modal mode a fatal error also closes the widget with onClose('error').
// In inline mode the widget stays mounted so your page renders its fallback.