KYC y KYB
Tu KYC o tu KYB se arma como un flujo: créalo, publícalo, abre una sesión y lee su veredicto. La integración completa, de punta a punta.
KYC o KYB: qué cambia
Esta guía sirve para los dos. Un flujo nace con su tipo —kyc para una persona física, kyb para una empresa— y ese tipo se queda: decide qué pasos admite el catálogo, qué pide el formulario por default y, sobre todo, contra qué vara lo juzga su expediente al cerrar.
kind: "kyc"La vara por default son tres: identidad verificada, la CURP ante RENAPO y las listas de sanciones. El formulario pide nombre, apellidos, CURP y correo. INE, RFC, comprobante, teléfono y judicial son evidencia que puedes sumar, no requisito.
kind: "kyb"La vara por default son los siete documentos: constancia de situación fiscal, acta constitutiva, poder notarial, la identificación del representante (su INE o, desde MasterApi 1.130.0, su verificación de identidad con INE, pasaporte o tarjeta de residente), comprobante de domicilio, e.firma y correo verificado. El formulario pide razón social, nombre comercial, RFC y correo. Listas, judicial, RFC ante el SAT e identidad del representante ensanchan el cotejo cuando están; si faltan, no retienen el expediente.
Las plantillas también vienen por tipo: GET /app/flows/templates?kind=kyc o ?kind=kyb devuelve sólo las que publican bajo esa vara — kyc_basico y kyb_basico, más una por vertical. Desde MasterApi 1.130.0 su paso de identidad ofrece INE, pasaporte y tarjeta de residente; en las de KYC la CURP es opcional en el formulario (un extranjero con pasaporte no tiene), el paso de CURP corre sólo con CURP y la INE contra la Lista Nominal sólo si la identidad leyó una INE (una rama sobre verification.document_type); kyb_basico identifica al representante con la verificación de identidad en vez de la INE subida, y legaltech ya no repite la INE. Una persona con INE que deja vacía la CURP va a revisión: la identidad no copia su CURP al cliente y sólo un pasaporte extranjero la excusa. Los flujos que ya creaste no cambian.
Esa vara es el valor de fábrica, no un límite fijo: la política de verificación de tu organización la mueve con required_components, y publicar valida que tus pasos la cubran.
Lo demás del recorrido es idéntico: se publica igual, la sesión se abre igual, corre por los mismos tres canales, dispara el mismo webhook, se revisa igual y se prueba igual en el sandbox.
Los tres canales
El mismo flujo, la misma versión publicada y el mismo veredicto corren de tres formas. Lo que las separa es QUIÉN corre los pasos de captura, y eso es lo que la sesión guarda en channel — que sólo tiene dos valores, porque el link y el SDK son la misma página nuestra.
channel: "hosted"Mandas el link (o el QR) y la persona captura en la página hospedada por Singula. Da igual que la sesión la hayas abierto desde tu backend: sigue siendo este canal.
channel: "hosted"La misma corrida embebida con @singula_systems/identity-js, con tu marca y tus orígenes permitidos. Del lado del servidor es el mismo canal que el link.
channel: "api"Tus pantallas corren los pasos de captura: pides el siguiente paso, llamas la herramienta sobre tu cliente, pegas el folio y cierras. Exige customer_id.
Crear la sesión desde tu backend y mandar a la persona a nuestra página es el canal UI abierto desde el backend, NO el canal API: la sesión sale con channel: "hosted" y nuestra página corre toda la captura. El canal API es el otro: el que corre tu propio front. Esta guía documenta los dos canales hospedados de punta a punta; el canal API tiene la suya.
Las cuatro piezas
Un flujo es una lista ordenada de pasos con una regla cada uno. Publicarlo congela una versión. Abrir una sesión corre esa versión para una persona. Cada paso es una de las herramientas que ya usas, y escribe su propio registro.
El borrador que edita un administrador: nombre, tipo (kyc o kyb), pasos y ajustes.
Lo que se publicó. Inmutable, y es lo único que corre una sesión nueva.
Una corrida para una persona, con su link, su token y su vigencia.
Una herramienta con sus parámetros y su regla: qué pasa si falla o si queda dudoso.
El flujo orquesta; cada producto hace su trabajo completo. Una herramienta hace lo mismo suelta que dentro de un flujo —por la API, el panel, el SDK o un link—, y el flujo nunca agrega consultas escondidas: lee y compara lo que devolvió cada producto, en el expediente. Lo único que hace de más es esperar, con un tope, a que un producto termine. Por eso la verificación de identidad ya trae, sin cargo extra, el cruce de su documento contra RENAPO (con una INE, un pasaporte o una tarjeta de residente y un cliente con CURP) y, si leyó una INE, su consulta en la Lista Nominal; un paso de CURP que pongas en el flujo sí se cobra, porque esa consulta la pides tú.
1. Crea el flujo
Un flujo nace como borrador en la versión 0. Cada paso lleva una clave única, la herramienta que corre, si es obligatorio y sus reglas: on_fail (decline | review | continue), on_review (review | continue) y on_error. El catálogo completo, con los parámetros de cada paso, se lee en GET /app/flows/catalog.
Crear, editar, publicar y archivar un flujo se hace con tu llave API o con el JWT de un Admin del panel: la llave la emite un Admin y vale como credencial de la organización. Esta guía usa la llave de principio a fin, así que no hace falta una sesión del panel para nada.
curl -X POST https://api.singula.mx/app/flows \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Alta de persona",
"kind": "kyc",
"steps": [
{ "key": "datos", "tool": "form", "required": true,
"params": { "fields": ["name","last_name","curp","email"] } },
{ "key": "identidad", "tool": "identityVerification", "required": true,
"params": { "document_types": ["ine","passport"] },
"on_fail": "decline", "on_review": "review" },
{ "key": "curp", "tool": "validateCurp", "required": true,
"on_fail": "review" },
{ "key": "listas", "tool": "checkBlacklist", "required": true,
"params": { "review_on_level": "medium" },
"on_fail": "decline" }
],
"settings": { "language": "es", "expires_in_hours": 168 }
}'{
"id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
"name": "Alta de persona",
"kind": "kyc",
"status": "draft",
"version": 0,
"steps_count": 4,
"estimate_centavos": 3300,
"requires_customer": false,
"created_by": { "kind": "api_key", "id": "ak_4f21", "label": "Onboarding prod" },
"versions_count": 0
}O parte de una plantilla
GET /app/flows/templates devuelve los flujos ya armados que puedes copiar, con ?kind= para filtrar por tipo. Cada uno trae su clave, su nombre y su descripción en español e inglés, la vertical que lo recomienda, sus pasos y estimate_centavos a los precios de tu organización. POST /app/flows/from-template/:key crea con ellos un BORRADOR editable en la versión 0 —los mismos permisos que POST /app/flows, y 404 si la clave no existe—, así que publicarlo sigue siendo tu decisión. El name del cuerpo es opcional: sin él, el borrador toma el nombre de la plantilla.
# Las plantillas publicadas, con el cargo estimado a tus precios
curl "https://api.singula.mx/app/flows/templates?kind=kyc" \
-H "Authorization: Bearer sk_live_xxx"
# Crea el borrador con los pasos de una de ellas
curl -X POST https://api.singula.mx/app/flows/from-template/fintech \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "name": "Alta de cuenta" }'{
"items": [
{
"key": "fintech",
"kind": "kyc",
"name": { "es": "Alta fintech", "en": "Fintech onboarding" },
"description": {
"es": "Identidad con prueba de vida, CURP ante RENAPO, listas de sanciones y judicial.",
"en": "Identity with liveness, CURP against RENAPO, sanctions lists and courts."
},
"vertical": "fintech",
"steps": [
{ "key": "datos", "tool": "form", "required": true,
"params": { "fields": ["name","last_name","curp","email"] } }
],
"estimate_centavos": 3300
}
]
}2. Publica la versión
Publicar valida y congela. Lo que se exige: al menos un paso; claves únicas y con formato ^[a-z0-9_]{2,40}$; cada herramienta existe en el catálogo y admite el tipo de flujo; un solo paso de formulario, primero, y con al menos un campo; cada herramienta una sola vez, salvo el paso de documento (extractDocument), que se repite con cualquier document_kind, el de representantes (representatives) de un KYB y la misma herramienta en ramas que se excluyen (ver repeated_tool en los errores); valores válidos en on_fail, on_review y on_error; COBERTURA: cada casilla que exige la política de tu expediente tiene que tener un paso que la recolecte; y PRE-EXISTENCIA: en un flujo con paso de datos, lo que lee un paso lo tiene que pedir el formulario o producirlo un paso anterior (unmet_need). Desde MasterApi 1.114.0 también exige que cada rama pueda abrir —un valor que el campo nunca tiene, o un operador que su tipo no admite, es invalid_condition— y que el paso de datos pueda crear al cliente: name y last_name obligatorios en un KYC, legal_name o name en un KYB (form_cannot_create_customer). Si algo no cuadra, la respuesta es 400 con code: "invalid_flow" y un problems[] que nombra el paso —o step_key: null y code: "missing_required_component" cuando lo que falta es una casilla del expediente—; si una herramienta no está desbloqueada, 403 con code: "tools_locked" y locked_tools[] (ver «Si tu cuenta todavía no tiene el KYB», abajo).
curl -X POST https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/publish \
-H "Authorization: Bearer sk_live_xxx"
# La respuesta trae status: "published" y version: 1.
# A partir de aquí, published_steps es inmutable.Editar el flujo después de publicarlo toca el BORRADOR, nunca una versión publicada ni una sesión abierta. Cada sesión termina con la versión con la que empezó.
Si tu cuenta todavía no tiene el KYB
Sin el KYB aprobado —con el saldo de prueba de un dominio corporativo, o con una recarga y el KYB todavía pendiente— casi todo el catálogo publica y corre igual. Así quedan las herramientas de cumplimiento:
La verificación de identidad con prueba de vida y las listas de sanciones y PEP publican y corren como cualquier otro paso.
Los antecedentes judiciales de una persona (getJudicialRecordsOfPerson): sólo en un flujo KYC y con un paso identityVerification ANTES.
Los antecedentes judiciales de una empresa (getJudicialRecordsMoral): un flujo que los trae no publica y responde 403 tools_locked.
Publicar es el juez: un paso de antecedentes de persona sin una identidad arriba responde 403 con code: "tools_locked", locked_tools: ["getJudicialRecordsOfPerson"] y el motivo. En la corrida, el paso corre sólo si se cumplen dos cosas. La identidad de ESA corrida salió aprobada, leída de su propia evidencia: una regla tuya que vuelve pass un rechazo no abre la puerta. Y el documento que verificó nombra al cliente, con el comparador y el umbral del expediente KYC (kyc.name_match_threshold); la CURP decide sólo cuando el documento no trae un nombre legible. La búsqueda corre sobre ese nombre.
Si no se cumplen, el paso no llama al proveedor ni cobra: queda skipped con reason: "kyb_required", outcome: "review" y action: "continue", y ni su on_fail ni su on_error deciden. Es review y no pass para que una rama que lee el resultado de los antecedentes no abra sobre una búsqueda que nunca corrió. Si la compuerta no se puede leer, el paso tampoco corre: queda en error, sin cargo, y decide su on_error. El sandbox no pasa por la compuerta: corre el mock, sin cargo.
GET /app/flows/catalog lo anuncia: la herramienta viene en entitlements.allowed —el editor la puede agregar— y además en entitlements.after_identity, que el editor usa para decir «corre después de la identidad». Con el KYB aprobado, after_identity llega vacío y los antecedentes corren en cualquier orden. GET /app/flows/templates trae locked_tools y publishable por plantilla: sin KYB, sólo legaltech queda bloqueada, por los antecedentes de empresa.
Fuera de un flujo —consulta directa, sobre un cliente, por API o por MCP— los antecedentes de persona siguen pidiendo el KYB en producción: 403 con el motivo. El KYB es self-service, desde el panel en Ajustes → Verificación de tu cuenta, y abre los antecedentes de cualquier persona, los de empresas y la firma electrónica.
3. Abre una sesión
La sesión exige un flujo publicado y saldo mayor o igual a estimate_centavos. El cuerpo es opcional: customer_id para correr sobre un cliente que ya existe, external_id y metadata para amarrarla a tu sistema, y language, expires_in_hours o redirect_url para pisar los ajustes del flujo. También acepta channel — "hosted" por defecto, que es lo que documenta esta guía; "api" abre la corrida para que tu propio front corra los pasos y exige customer_id.
curl -X POST https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/sessions \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "external_id": "ord_123", "metadata": { "plan": "pro" } }'{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"request_id": "66f1a2b3c4d5e6f708192a3b",
"token": "flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17",
"url": "https://app.singula.mx/flow/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17",
"embed_url": "https://app.singula.mx/embed/flow/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17",
"expires_at": "2026-09-22T12:00:00.000Z",
"flow_version": 1,
"estimate_centavos": 3300,
"reused": [],
"missing": []
}Sólo el token empieza con flw_ — ese prefijo es el que usa el SDK para saber que monta un flujo y no una verificación de identidad. El flow_id y el session_id son uuid pelones, sin prefijo.
Si algún paso de servidor necesita un dato que nadie recolecta, el flujo publica requires_customer: true y sus missing_needs, y abrir la sesión sin customer_id responde 400 con code: "customer_required". Ojo con el orden: requires_customer cuenta los pasos que PRODUCEN el dato, así que un flujo datos → INE → CURP ya se abre sin cliente; y un flujo CON paso de datos cuyo hueco no llena nadie ya no publica (unmet_need). Ver «Pre-existencia», abajo.
4. Córrelo: link, QR o widget
La url es el link hospedado por Singula: se manda por correo o WhatsApp, o se pinta como QR para pasar de un escritorio a un teléfono. No necesita orígenes permitidos ni instalar nada. El embed_url es la misma corrida dentro de tu app, y ahí los orígenes permitidos sí son obligatorios (Ajustes › Marca › Dominios). Las tres son el canal hospedado: la captura la corre nuestra página, aunque la sesión la hayas abierto desde tu backend. Esa url es de UNA persona; para un link o un QR que sirva a mucha gente, ver «Link compartible y QR», abajo.
npm install @singula_systems/[email protected]
import { Identity } from '@singula_systems/identity-js';
Identity.open({
token: 'flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17', // flw_ prefix → the widget mounts the flow runner
// kind: 'flow', // only needed when your token has no prefix
onStage: ({ stage, tool, status, reason }) => {
// stage: the step key of your published version ('identidad', 'curp'…)
// tool: the tool that step runs, or 'form'
// status: 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
renderStep(stage, status, reason);
},
onComplete: async ({ decision, sessionId }) => {
// decision: 'approved' | 'declined' | 'review' | 'withheld'
// Never authorize here. Ask YOUR backend, which holds the API key.
const run = await fetch(`/api/flows/${sessionId}`).then((r) => r.json());
route(run.decision);
},
onError: ({ code }) => {
// 'session_expired' | 'session_abandoned' — the run closed without a
// verdict, so onComplete never fires. Open a new session.
showExpired(code);
},
});Desde MasterApi 1.114.0, una corrida cuyos pasos ya terminaron cierra en la siguiente consulta de la página: el veredicto llega en cuanto termina el último paso —o en cuanto llega lo que el cierre espera—, no uno o dos minutos después. Y recargar mientras se revisa la identidad ya no reinicia la captura: la página sigue esperando el veredicto de la que ya se envió.
onComplete es UX, no autorización. Mostrar el veredicto a la persona viene APAGADO (expose_decision_to_user: false): con el interruptor apagado, las rutas públicas del runner reportan status: "completed", decision: null y expediente_overall: null para cualquier cierre —aprobado, en revisión o rechazado—, la página muestra una pantalla neutra y el SDK recibe decision: "withheld". El veredicto de registro se lee del lado del servidor, con tu API key o por webhook.
Link compartible y QR
Un link compartible (flk_…) es la puerta fija de un flujo publicado: el mismo link, o su QR, sirve para mucha gente. Cada uso abre una verificación NUEVA y su paso de datos crea un cliente NUEVO, con origen link; nunca se engancha a un cliente que ya exista. No es la url de una sesión (flw_…), que es de una sola persona y una sola corrida.
Sólo puede tener link un flujo publicado que empieza con el paso de datos y no exige cliente: el link no trae cliente, y el paso de datos es el que lo crea. Si no, crear responde 400 con code flow_not_published, link_requires_form o customer_required (con missing_needs). Se revisa otra vez en CADA apertura: el link abre siempre la versión publicada vigente, y si una versión nueva deja de cumplir, se cierra con 410 flow_unavailable.
curl -X POST https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/links \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"label": "QR de la sucursal Centro",
"max_sessions": 500,
"expires_at": "2026-12-31T23:59:59.000Z"
}'{
"id": "3d9b7e21-5c44-4f0a-9b6e-8a1f2c7d4e90",
"flow_id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
"token": "flk_6a2e4c19-8b73-4d5f-a0c1-7e9d3b5f2a84",
"label": "QR de la sucursal Centro",
"env": "production",
"active": true,
"status": "active",
"max_sessions": 500,
"sessions_count": 0,
"expires_at": "2026-12-31T23:59:59.000Z",
"last_used_at": null,
"created_by": { "kind": "api_key", "id": "ak_4f21", "label": "Onboarding prod" },
"created_at": "2026-09-23T17:00:00.000Z",
"updated_at": "2026-09-23T17:00:00.000Z",
"url": "https://app.singula.mx/l/flk_6a2e4c19-8b73-4d5f-a0c1-7e9d3b5f2a84"
}
# El QR es esa misma url. sessions_count cuenta verificaciones ABIERTAS, no terminadas.
# Sin max_sessions ni expires_at, un link de producción nace con 100 usos y vence a los 30 días.labelPara ti: con qué lo reconoces en la lista. De 1 a 120 caracteres.
max_sessionsDe 1 a 100 000: cuántas verificaciones puede ABRIR. Un link de producción siempre tiene tope: sin él, 100, y null es 400 link_requires_cap. En sandbox, sin él no hay tope.
expires_atISO 8601, en el futuro y a lo más a un año. Un link de producción siempre vence: sin él, a los 30 días, y null es 400 link_requires_expiry. En sandbox, sin él no vence.
envSólo lo elige un usuario del panel. Una llave API crea links de su propio ambiente: con sk_test_, el link abre verificaciones de sandbox.
Crear y editar links es de un Admin del panel o de una llave API, como crear un flujo; un usuario Normal recibe 403. La url es https://app.singula.mx/l/flk_… y el QR es esa misma url dibujada como código: imprímelo, ponlo en tu sitio o mándalo por mensaje.
Pausar, editar y leer sus verificaciones
GET /app/flows/:id/links trae los links del flujo, cada uno con status: active, paused, expired o exhausted —la pausa gana, luego la fecha, luego el tope—. Una llave API ve los de su ambiente; el panel, todos. PATCH /app/flows/:id/links/:linkId mueve active, label, max_sessions o expires_at sin borrar nada: active: false apaga un QR impreso y active: true lo reanuda. El tope y el vencimiento se mueven, pero en un link de producción nunca se quitan: null responde 400 link_requires_cap o link_requires_expiry (en sandbox sí los quita). No hay ruta para borrar un link: se apaga pausándolo.
Cada verificación que abre lleva link_id —en el detalle y en el webhook flow.session.created— y su created_by es quien creó el link, nunca la persona que lo abrió. GET /app/flows/sessions?link_id= las lista con el id del link, no con su token.
# Pausa el link: un QR impreso deja de abrir verificaciones. "active": true lo reanuda.
curl -X PATCH https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/links/3d9b7e21-5c44-4f0a-9b6e-8a1f2c7d4e90 \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "active": false }'
# El tope y el vencimiento se mueven; en un link de producción null es 400 link_requires_cap / link_requires_expiry
# -d '{ "max_sessions": 1000, "expires_at": "2027-03-31T23:59:59.000Z" }'
# Los links del flujo, cada uno con su status
curl https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/links \
-H "Authorization: Bearer sk_live_xxx"
# Las verificaciones que abrió ese link (su id, no su token)
curl "https://api.singula.mx/app/flows/sessions?link_id=3d9b7e21-5c44-4f0a-9b6e-8a1f2c7d4e90" \
-H "Authorization: Bearer sk_live_xxx"Qué pasa al abrirlo
La página del link hace dos llamadas públicas, sin llave. GET /app/flow-embed/link/:token contesta { open, reason?, kind, ui } SIN gastar un uso, para pintar «ya no está disponible» con tu marca. POST /app/flow-embed/link/:token abre la verificación y responde 201 { token, url } con un flw_ nuevo, y la persona sigue en esa url como en cualquier corrida hospedada. La página sólo lo llama cuando la persona toca «Comenzar verificación»: la vista previa de un link o el escáner de un correo no gastan usos. Si la misma persona vuelve en el mismo navegador con una verificación sin terminar, la página la retoma sin preguntar sólo dentro de 60 minutos; después le pregunta si quiere continuar o empezar otra.
Abrir revisa en este orden: que el link esté abierto (410 link_closed con reason paused, expired o exhausted); que el flujo siga siendo apto (410 link_closed con reason flow_unavailable); el freno por link e IP (429 too_many_opens: más de 20 aperturas en un minuto, 60 en una hora o 300 en un día desde la misma IP sobre el mismo link); el contador, que es atómico —con un solo uso libre y diez personas escaneando a la vez, abre una—; y al final la apertura de siempre. Esta ruta es pública, así que no enseña nada de tu cuenta: si el saldo no alcanza el estimado responde 402 link_unavailable_balance, sin montos, y si tu cuenta no puede abrir la verificación, 403 link_unavailable, sin su estado; los dos traen sólo el code y un mensaje genérico. Las rutas con llave conservan sus cuerpos de siempre. Un 402, o cualquier fallo después del contador, devuelve el uso. No hay captcha en esta fase.
En una verificación que abrió un link también se frena, por link e IP, lo que la persona dispara: un paso de datos ACEPTADO —arranca los pasos de servidor que cobran— responde 429 too_many_form_submits pasados 60 en una hora o 300 en un día, y un formulario rechazado con 400 no cuenta; cada correo que Singula manda por lo que tecleó la persona —el código del paso de correo y los links de representantes con delivery: "email"— responde 429 too_many_emails con los mismos topes. El 429 trae { code, window: "hour" | "day", limit, message }. Una verificación que no abrió un link no se frena. Lo que acota el gasto de un link es su tope de usos, no estos frenos: ponle el que estés dispuesto a pagar.
# La página del link, sin llave. Primero pregunta, SIN gastar un uso:
GET https://api.singula.mx/app/flow-embed/link/flk_6a2e4c19-8b73-4d5f-a0c1-7e9d3b5f2a84
{ "open": false, "reason": "paused", "kind": "kyc", "ui": { "theme": { "primary": "#0057DF" }, "layout": "card" } }
# Luego abre, cuando la persona toca «Comenzar verificación»: cada POST es un uso y una verificación NUEVA
POST https://api.singula.mx/app/flow-embed/link/flk_6a2e4c19-8b73-4d5f-a0c1-7e9d3b5f2a84
201 { "token": "flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17", "url": "https://app.singula.mx/flow/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17" }
# Cerrado, con el porqué:
410 { "statusCode": 410, "error": "Gone", "code": "link_closed", "reason": "exhausted",
"message": "Este link ya alcanzó su número máximo de usos." }
# Demasiadas aperturas desde la misma IP sobre el mismo link:
429 { "statusCode": 429, "error": "Too Many Requests", "code": "too_many_opens",
"window": "hour", "limit": 60,
"message": "Demasiadas aperturas de este link desde tu red. Intenta más tarde." }
# Sin saldo, o tu cuenta no puede abrirla: sin montos ni el estado de tu cuenta
402 { "statusCode": 402, "error": "Payment Required", "code": "link_unavailable_balance",
"message": "Este link no está disponible por ahora. Intenta más tarde o avísale a quien te lo compartió." }
403 { "statusCode": 403, "error": "Forbidden", "code": "link_unavailable", "message": "…" }El link está en pausa, venció, se acabaron sus usos o el flujo ya no se puede abrir sin cliente. Lo resuelves tú: reanúdalo, sube el tope, mueve la fecha o publica una versión apta.
Más de 20 en un minuto, 60 en una hora o 300 en un día desde la misma red sobre el mismo link. Se libera solo. Consultar el link tiene su propio límite: 60 por minuto.
El saldo de tu organización no alcanza el estimado de una verificación: 402 link_unavailable_balance, sin montos ni datos de tu cuenta. No gasta un uso.
El cobro
El link no cobra. Cada uso es una verificación normal: el saldo se revisa contra el estimado al abrir y se cobran los pasos que terminan, cada uno con el cargo de su herramienta suelta. Como el paso de datos va primero, abrir no cobra nada hasta que alguien entrega sus datos. Un link de sandbox abre verificaciones de sandbox: mocks, sin cargo y sin 402.
El link es sólo hospedado: el SDK no abre un flk_. Identity.open con un token flk_ no monta nada y responde onError({ code: "invalid_token" }) (SDK 0.6.0). Para correr el flujo dentro de tu página, abre una sesión por persona con POST /app/flows/:id/sessions y embebe su flw_.
Por MCP: createFlowLink, listFlowLinks, updateFlowLink y listFlowSessions con link_id.
Representantes (KYB)
Un KYB verifica a la empresa y a sus personas. El paso representatives —sólo en un flujo kyb— abre UNA corrida KYC hija por persona con un flujo kyc publicado de tu misma organización. Los roles son cuatro: representante_legal, apoderado, socio y ubo.
{
"key": "representantes",
"tool": "representatives",
"required": true,
"params": {
"roles": ["representante_legal", "apoderado", "socio", "ubo"],
"min": 1,
"max": 5,
"child_flow_id": "b93f5c27-8e14-4a02-9d6b-71c5e0a8f34d", // un flujo kyc publicado TUYO
"delivery": "email" // email | links
},
"on_fail": "decline",
"on_review": "review"
}
# Al publicar, el estimado del KYB pasa a ser un «desde»: la línea del paso vale
# min × el cargo de una corrida del flujo hijo, y el flujo publica estimate_is_minimum: true.Publicar lo valida: child_flow_id tiene que existir, ser tuyo, ser kyc y estar publicado (bad_child_flow), ese hijo no puede traer su propio paso de representantes (nested_children, un solo nivel) y min no puede quedar por encima de max (bad_people_range). El paso no cubre ninguna casilla del expediente: lo que junte cada persona se archiva en el expediente de ESA persona.
El recorrido hospedado
La corrida del KYB llega al paso y queda en awaiting_user. La página hospedada pide la lista —nombre, correo y rol de cada persona, entre min y max— y la entrega. Con delivery: "email" Singula manda el link a cada correo, con el mismo remitente y las mismas plantillas del código de correo, y el asunto es «<Empresa> te pide verificar tu identidad»; con delivery: "links" los devuelve para que los repartas tú. Ahí mismo se ve el avance de cada persona, sin salir de la corrida.
curl -X POST https://api.singula.mx/app/flows/sessions/2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7/steps/representantes/representatives \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"people": [
{ "name": "MARIA LOPEZ GARCIA", "email": "[email protected]", "role": "representante_legal" },
{ "name": "ANA RUIZ", "email": "[email protected]", "role": "socio" }
]
}'
# La misma entrega desde la página hospedada, sin llave y con el token en la ruta:
# POST /app/flow-embed/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17/steps/representantes/representatives{
"children": [
{
"session_id": "a41d6e02-7b58-4c39-9f71-3d8e5c0b7a62",
"role": "representante_legal",
"name": "MARIA LOPEZ GARCIA",
"email": "[email protected]",
"customer_id": null,
"status": "not_started",
"decision": null,
"url": "https://app.singula.mx/flow/flw_3c8d5a91-72e4-4f06-b1a8-5d90c26e7b43",
"charged_centavos": 0
}
]
}
# customer_id llega en null: el registro de la persona lo crea el paso de datos de SU corrida.
# Una segunda entrega sobre el mismo paso responde 409 people_already_submitted con estas mismas hijas.Cada hija es una corrida normal y hospedada, también cuando el padre corre por API. Hereda del padre el ambiente, la vigencia, el idioma, la metadata y quién la abrió, nace sin customer_id —su propio paso de datos crea el registro de la persona— y no exige saldo al abrirse: el estimado del padre ya reservó min × una corrida del flujo hijo. Su config trae el nombre y el correo que tecleó el KYB, ya precargados. Cuando TODAS cierran, el paso del padre cierra con la peor: alguna declined deja fail; alguna en review, expired o abandoned deja review; todas approved dejan pass. El paso cobra 0 en la fila del padre, porque cada hija cobró en la suya.
El mismo paso por el canal API
El bucle no cambia: next te dicta el paso, lo resuelves y vuelves a preguntar. Lo único propio de este paso es que no se pega: entregas la lista en la ruta que trae call y esperas a que las hijas cierren. Las hijas siguen siendo hospedadas — las hijas por API son una fase aparte.
# 1 · El paso sale a que lo resuelvas tú: how "api" e interaction "people", y SIN attach.
GET /app/flows/sessions/2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7/next
{
"done": false,
"next": {
"step_key": "representantes",
"tool": "representatives",
"kind": "user",
"interaction": "people",
"required": true,
"params": { "roles": ["representante_legal", "socio"], "min": 1, "max": 5, "delivery": "email" },
"how": "api",
"call": {
"method": "POST",
"path": "/app/flows/sessions/2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7/steps/representantes/representatives"
}
}
}
# 2 · Entregas la lista. Pegarle un folio con attach responde 409 not_attachable_step (how: "people").
# 3 · Mientras las hijas caminan, next es how "wait" con el avance de cada una.
GET /app/flows/sessions/2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7/next
{
"done": false,
"next": {
"step_key": "representantes",
"how": "wait",
"children": [
{ "session_id": "a41d6e02-7b58-4c39-9f71-3d8e5c0b7a62", "role": "representante_legal",
"name": "MARIA LOPEZ GARCIA", "status": "approved", "decision": "approved",
"charged_centavos": 3300 },
{ "session_id": "7d2b8f36-1c40-4e95-83af-b6045e9d1c28", "role": "socio",
"name": "ANA RUIZ", "status": "in_progress", "decision": null,
"charged_centavos": 0 }
]
}
}
# 4 · Con hijas vivas, close responde 409 steps_pending. Cuando TODAS cierran, el paso cierra con la peor:
# alguna declined → fail · alguna review, expired o abandoned → review · todas approved → pass.
POST /app/flows/sessions/2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7/closeQué avisa el webhook
Los eventos flow.session.created y flow.completed de una hija llevan parent_session_id y role; el flow.completed del padre lleva children[] con el veredicto de cada persona. El detalle de una hija trae parent y el del padre trae steps[].children, y GET /app/flows/sessions?parent= lista las hijas de una corrida.
# La hija: el mismo sobre de siempre, con dos campos más en data.
X-Singula-Event: flow.completed
{
"data": {
"session_id": "a41d6e02-7b58-4c39-9f71-3d8e5c0b7a62",
"tool_type": "kyc",
"status": "success",
"decision": "approved",
"parent_session_id": "2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7",
"role": "representante_legal"
}
}
# El padre: sus hijas, resumidas.
X-Singula-Event: flow.completed
{
"data": {
"session_id": "2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7",
"tool_type": "kyb",
"status": "completed",
"decision": "review",
"children": [
{ "session_id": "a41d6e02-7b58-4c39-9f71-3d8e5c0b7a62", "role": "representante_legal", "decision": "approved" },
{ "session_id": "7d2b8f36-1c40-4e95-83af-b6045e9d1c28", "role": "socio", "decision": "review" }
]
}
}
# Si el padre vence, se cancela o se cierra, sus hijas vivas se cierran con él: reason "parent_closed".Lo que cobra un KYB con representantes
Cada hija cobra sus propios pasos, como cualquier KYC, y cada paso cobra lo mismo que su herramienta suelta. El KYB entero es la suma de los pasos del padre y los de sus hijas. Los flujos no tienen descuentos: lo que cobró una hija ya es lo que debe, así que al cerrar el padre no se abona nada por ella en discount_credited_centavos. En sandbox todo es 0.
La relación empresa ↔ persona
La hija relaciona a la persona con la empresa cuando su paso de datos crea o liga su registro, con el rol que le tocó. La relación se queda, y una misma persona puede representar a varias empresas. El expediente KYB suelto resuelve con ella el control de la identidad del representante; el que cierra una corrida lee, desde MasterApi 1.114.0, sólo a las hijas de ESA corrida: alguien relacionado por otra corrida, de cualquier fecha, no responde por ésta.
Desde MasterApi 1.114.0 el representante queda amarrado al poder. Cada firmante —representante legal o apoderado— con la identidad aprobada tiene que ser uno de los apoderados que nombra el poder o el acta, por CURP o por nombre con los dos apellidos; si no lo es, representative_identity_verified sale en false y el expediente va a revisión. La KYC rechazada de un firmante también es false: la corrida no se aprueba aunque el on_fail del paso la deje seguir. El control sigue sin ser crítico por default; con kyb.checks.representative_identity_verified.critical: true, rechaza.
# Las personas de una empresa, con su rol y su identidad verificada.
GET /customer/:id/relations
{
"data": {
"people": [
{ "relation_id": "9a1c…", "customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"name": "MARIA LOPEZ GARCIA", "role": "representante_legal",
"source": "flow", "flow_session_id": "a41d6e02-7b58-4c39-9f71-3d8e5c0b7a62",
"created": "2026-09-18T12:10:00.000Z",
"identity": { "verified": true, "request_id": "66f1…c7", "checked_at": "2026-09-18T12:09:41.000Z" } }
]
}
}
# Sobre una persona física, la misma ruta devuelve data.companies: una persona puede representar a varias empresas.
# Alta manual de una relación, para las empresas que no llegaron por un flujo:
POST /customer/:id/relations { "person_customer_id": "6622…d8", "role": "apoderado" }
DELETE /customer/:id/relations/:relationIdCuestionario: el paso de datos a tu medida
El paso de datos pide los campos del cliente de siempre y, además, preguntas propias con su tipo. params.fields acepta los nombres de antes y también objetos. Un campo conocido llena al cliente; uno propio guarda su respuesta en la verificación, en form_data.
{
"key": "datos",
"tool": "form",
"required": true,
"params": {
"fields": [
"name",
"last_name",
"curp",
{ "name": "ocupacion", "type": "select", "maps_to": null,
"label": { "es": "Ocupación", "en": "Occupation" },
"options": [
{ "value": "empleado", "label": { "es": "Empleado", "en": "Employee" } },
{ "value": "comerciante", "label": { "es": "Comerciante", "en": "Merchant" } },
{ "value": "otro", "label": { "es": "Otro", "en": "Other" } }
] },
{ "name": "es_pep", "type": "boolean", "maps_to": null,
"label": { "es": "¿Eres persona políticamente expuesta?",
"en": "Are you a politically exposed person?" } },
{ "name": "ingreso_mensual", "type": "number", "maps_to": null,
"required": false, "min": 0, "max": 10000000,
"label": { "es": "Ingreso mensual", "en": "Monthly income" },
"help": { "es": "Antes de impuestos.", "en": "Before taxes." } }
]
},
"rules": [
{ "field": "answers.es_pep", "op": "eq", "value": true, "outcome": "review" }
],
"on_review": "review"
}
# name, last_name y curp son campos conocidos: llenan al cliente, con su tipo de siempre, y son obligatorios.
# Los otros tres son propios (maps_to: null): su respuesta va a form_data de la verificación, nunca al cliente.
# La regla manda la verificación a revisión cuando la persona contesta que sí.Una lista de nombres de antes sigue igual: guarda los mismos bytes, y cada nombre es un campo conocido y obligatorio. Un objeto lleva name (^[a-z0-9_]{2,40}$, único en el paso), type, label y help en español e inglés (hasta 80 y 200 caracteres; sin label, la del catálogo), required (true por default), options, min, max y maps_to. Un formulario pide hasta 40 campos.
Diez tipos
textTexto libre. min y max miden el largo: hasta 500 caracteres, o 200 si llena al cliente.numberUn número. min y max acotan el valor.dateUna fecha que existe, AAAA-MM-DD. birth_date acepta también DD/MM/AAAA.selectUna de sus options: de 1 a 50, cada una con value (^[a-z0-9_-]{1,40}$) y label opcional.multiselectUna lista de valores de options. min y max cuentan cuántos se eligen.booleantrue o false.emailUn correo válido.phoneDiez dígitos o formato internacional (+52…).curpUna CURP con el formato de RENAPO, 18 caracteres.rfcUn RFC de 12 o 13 caracteres.Conocidos y propios
maps_to decide a dónde va la respuesta. Con un campo conocido —name, last_name, mothers_last_name, legal_name, curp, rfc, email, phone, birth_date, gender o birth_place— llena esa columna del cliente. Su tipo es fijo: curp, rfc, email y phone el de su nombre, birth_date date, gender un select de H, M o X, birth_place un select de claves de estado y los nombres text. Una columna la llena un solo campo.
Con maps_to: null el campo es propio y su respuesta va a form_data. Sin maps_to, un name conocido es ese campo y cualquier otro es propio. birth_place guarda la clave de estado de RENAPO (JC, DF, NE…): si llega el nombre del estado, con o sin acentos, o un alias como CDMX o Edomex, se normaliza a su clave, y sólo se rechaza lo que no nombra ningún estado.
GET /app/flows/catalog publica form_fields: los tipos, los conocidos con su tipo y sus opciones, los patrones y los límites. Publicar nombra cada campo mal armado con invalid_form_field; nada se corrige en silencio.
El servidor valida cada respuesta
La validación es la misma en la página hospedada y en la API: obligatorio, tipo, opción, rango, largo y formato. Un error responde 400 invalid_form con problems[], todos a la vez. Cada problema trae field, code y message, y los códigos son required, bad_type, bad_format, bad_option, out_of_range y too_long. Cada respuesta propia mide hasta 500 caracteres y todas juntas hasta 16 KB. Las llaves que el paso no pide se ignoran.
curl -X POST https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/datos/form \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "fields": { "name": "MARIA", "last_name": "LOPEZ", "curp": "LOGM900101",
"ocupacion": "comerciante", "ingreso_mensual": -5 } }'
# 400 Bad Request
{
"statusCode": 400,
"error": "Bad Request",
"code": "invalid_form",
"message": "Hay datos del formulario que corregir.",
"problems": [
{ "field": "curp", "code": "bad_format", "message": "no es una CURP válida (18 caracteres)" },
{ "field": "es_pep", "code": "required", "message": "este dato es obligatorio" },
{ "field": "ingreso_mensual", "code": "out_of_range", "message": "tiene que estar entre 0 y 10000000" }
]
}Dónde quedan las respuestas
Los campos conocidos escriben la fila del cliente, como siempre. Los propios van a form_data de la verificación y salen en el detalle, en la API, en el MCP y en el panel. Nunca llegan al cliente ni al webhook: son datos personales. El cuestionario no cobra ni mueve el estimado.
GET https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4
{
"status": "review",
"decision": "review",
"steps": [
{ "key": "datos", "tool": "form", "status": "done", "request_id": null,
"outcome": "review", "reason": "rule:form_submitted", "action": "review",
"charged_centavos": 0 }
],
"form_data": {
"ocupacion": "comerciante",
"es_pep": true,
"ingreso_mensual": 42000
}
}
# La regla sobre answers.es_pep forzó review y el on_review del paso lo sostuvo: la verificación cierra en revisión.
# form_data sale en el detalle, en la API y en el MCP. Nunca en el cliente ni en el webhook: son datos personales.Una respuesta decide
Las reglas del paso de datos leen answers.<campo>. Una regla que fuerza review o fail sigue el on_review o el on_fail del paso, y el paso sale con reason: "rule:form_submitted". Publicar rechaza en el paso de datos una regla que lea otra cosa (invalid_rule).
Las ramas también leen el cuestionario. Un when con source: "form" sobre un campo propio lee form_data, y sobre uno conocido, la fila del cliente. Si la rama no se puede resolver, el paso corre.
{
"key": "judicial",
"tool": "getJudicialRecordsOfPerson",
"required": true,
"on_fail": "review",
"when": [
{ "source": "form", "key": "ingreso_mensual", "op": "gt", "value": 50000 }
]
}
# Un campo propio se lee de form_data; uno conocido, de la fila del cliente.
# Un multiselect sólo se compara con in, not_in (por intersección), exists o missing.Ninguna fila de cliente contesta un campo propio. Sobre un cliente que ya tienes, un campo propio obligatorio mantiene el paso de datos abierto para la persona. Por el canal API, un formulario con campos propios no nace resuelto: next lo dicta con interaction: "form" e input_contract, y se contesta en POST /app/flows/sessions/:id/steps/:key/form.
Ramas: pasos que sólo corren si…
Un paso puede correr sólo cuando se cumple algo que ya pasó en la misma corrida. La condición vive en when, dentro del paso, y es opcional: un flujo sin when corre exactamente igual que antes.
{
"key": "judicial",
"tool": "getJudicialRecordsOfPerson",
"required": true,
"on_fail": "review",
"when": [
{ "source": "step", "key": "listas", "field": "risk_summary.level",
"op": "in", "value": ["medium", "high"] },
{ "source": "customer", "key": "birth_country", "op": "eq", "value": "MX" }
]
}
# Las dos condiciones se cumplen o el paso no corre: entre condiciones es Y, y el O se escribe con in.
# field sólo existe con source "step": outcome (el default), action o una ruta de la respuesta.
# Operadores: eq, ne, in, not_in, gt, gte, lt, lte, exists y missing.La rama se evalúa cuando el paso va a arrancar, contra lo que la corrida ya resolvió. Un valor ausente sólo casa con missing, así que ne y not_in nunca se cumplen sobre un dato que no llegó — la misma guarda de las reglas.
De dónde sale el valor
source: "step"Un paso ANTERIOR de la misma corrida: su outcome, su action o una ruta dentro de la respuesta que escribió su herramienta. Un paso saltado no deja respuesta, así que sus rutas leen vacío. De un paso de identidad, verification.document_type dice qué documento leyó: INE, PASSPORT o RESIDENT_CARD (desde MasterApi 1.130.0).
source: "form"Un campo del formulario de ese flujo. Sólo los que ESE paso de datos pide, sus preguntas propias incluidas.
source: "customer"La fila del cliente de la corrida: tipo, CURP, RFC, país de nacimiento, riesgo, correo, teléfono.
En un campo conocido, form y customer leen la MISMA fila: el paso de datos convierte lo que la persona tecleó en el cliente de la corrida, y no hay otra copia. Un campo propio del cuestionario no vive en el cliente: se lee de form_data. GET /app/flows/catalog publica en conditions todo lo que una rama puede leer, y es la MISMA lista con la que publicar rechaza.
GET /app/flows/catalog
{
"conditions": {
"sources": ["step", "form", "customer"],
"operators": ["eq", "ne", "in", "not_in", "gt", "gte", "lt", "lte", "exists", "missing"],
"step": [
{ "tool": "checkBlacklist", "fields": [
{ "field": "outcome", "type": "enum", "options": ["pass", "fail", "review", "error"] },
{ "field": "action", "type": "enum", "options": ["continue", "review", "decline"] },
{ "field": "risk_summary.level", "type": "enum",
"options": ["clear", "low", "informational", "medium", "high"] }
] }
],
"form": ["name", "last_name", "mothers_last_name", "legal_name", "curp", "rfc",
"email", "phone", "birth_date", "gender", "birth_place"],
"customer": [
{ "field": "type", "type": "enum", "options": ["PHYSICAL", "MORAL"] },
{ "field": "birth_country", "type": "string" },
{ "field": "risk", "type": "enum", "options": ["LOW", "MEDIUM", "HIGH"] }
]
}
}
# Cada campo llega con su tipo, sus valores posibles y su etiqueta para el editor.
# form lista los campos conocidos; una rama lee cualquier campo que ESE flujo pida, por su name, los propios del cuestionario incluidos.Un paso que no corrió
Un paso cuya rama no abre queda status: "skipped", outcome: "pass", reason: "condition_not_met", action: "continue" y cobra 0. El veredicto lo ignora, igual que a un paso opcional que nadie hizo, y sale así en el detalle de la corrida, en status/:token y en next. La condición MANDA sobre required: un paso obligatorio cuya rama no abre se salta igual, porque no se puede exigir un paso que las respuestas de esa persona sacaron del flujo.
GET /app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4
{
"steps": [
{ "key": "listas", "tool": "checkBlacklist", "status": "done",
"request_id": "66f1...b1", "outcome": "pass", "reason": "blacklist_low",
"action": "continue", "charged_centavos": 1000 },
{ "key": "judicial", "tool": "getJudicialRecordsOfPerson", "status": "skipped",
"request_id": null, "outcome": "pass", "reason": "condition_not_met",
"action": "continue", "charged_centavos": 0,
"completed_at": "2026-09-19T12:04:12.000Z" }
]
}
# El paso saltado no deja folio, no cobra y el veredicto lo ignora.Si la rama no se puede resolver, el paso CORRE. Una lectura que falló no le quita una verificación a nadie, y el expediente sigue juzgando al cierre.
Qué valida al publicar
Publicar revisa cada rama y nombra el paso que la trae: 400 invalid_flow con code: "invalid_condition" cuando apunta a un paso POSTERIOR o inexistente, a un campo que el formulario no pide o a un campo de respuesta que la herramienta no publica; también cuando la fuente o el operador no existen, cuando el operador necesita valor y llega sin él, o cuando un in o un not_in llegan con la lista vacía. Desde MasterApi 1.114.0 revisa además que la rama pueda abrir: un valor que el campo nunca tiene (verification.status eq approved, cuando sus valores son verified, partial, rejected y failed) y un operador que su tipo no admite (gt, gte, lt y lte sólo ordenan cifras: verification.status gt 0 nunca abría) también son invalid_condition, y el mensaje dice qué sí se puede escribir. Antes esas ramas publicaban y el paso se saltaba sin cargo en cada corrida. Nada lee el futuro y nada se corrige en silencio.
Con ramas, el estimado es el PEOR caso: se cotizan todas, la respuesta lo dice con estimate_is_maximum: true y lines[].is_conditional, y el cargo se lee como un «hasta». El saldo se exige sobre ese peor caso al abrir la sesión (402), para que una corrida que toma todas las ramas no se quede a la mitad. Una rama sin cargo —el paso de datos— no vuelve el total un «hasta».
En el panel se dibujan
El editor del flujo es un lienzo: cada paso es la pantalla que la persona va a ver —la de verdad, en miniatura—, en el orden en que la camina, y el veredicto cierra la línea con sus tres salidas. Un paso con when baja a su propio carril y gana la vía punteada del salto que da la corrida cuando la condición no se cumple. Las posiciones se guardan en settings.canvas y se congelan con la versión, como la apariencia; un flujo sin lienzo se auto-acomoda.
Cruces automáticos a media corrida
Todo se verifica por default. Los cruces entre lo que recolecta un paso y lo que ya sabes de la persona corren solos, sin configurar nada: quien arma su primer flujo no tiene que prender nada para que se crucen. Aflojar uno es la decisión avanzada, y vive en la política de verificación de tu organización (Ajustes › Verificación).
Es un solo cerebro: lo que el expediente cruza al cerrar es lo mismo que puede cortar la corrida a la mitad. Hoy la señal la trae el paso de INE, que ya coteja contra RENAPO la CURP que leyó de la credencial (cross_ref_curp_document_vs_renapo), y el control que decide qué hace la corrida con ese desacuerdo es kyc.checks.ine_curp_vs_renapo. Sólo la CURP corta: el nombre se juzga al cerrar, en el expediente (kyc.checks.ine_name_vs_renapo), con el umbral de tu política y tolerante a un apellido que la lectura no vio. Los cruces de la verificación de identidad tampoco cortan su paso: los lee el expediente al cerrar.
el defaultEl paso falla ahí mismo: outcome "fail" y reason "cross_ref_ine_curp_vs_renapo". De ahí manda el on_fail del paso, que por default rechaza.
enabled, critical: falseEl paso no se mueve. El desacuerdo lo reporta el expediente al cerrar, en failed.
enabled: falseNo pasa nada, ni a media corrida ni al cierre.
{
"key": "ine", "tool": "ineExtraction", "status": "done",
"request_id": "66f1...d3",
"outcome": "fail",
"reason": "cross_ref_ine_curp_vs_renapo",
"action": "decline",
"charged_centavos": 1500,
"completed_at": "2026-09-19T12:03:48.000Z"
}
# La CURP que se leyó de la credencial no es la que RENAPO tiene para esa persona.
# El control es kyc.checks.ine_curp_vs_renapo, encendido y crítico por default; de ahí manda el on_fail del paso.El cruce sólo endurece una lectura que iba a salir pass o review. Una credencial dada de baja falla antes y con su propia razón (ine_dada_de_baja), y un fallo del servicio sigue siendo error: «esta credencial está muerta» y «el registro no contestó» son hechos distintos de «la lectura no coincidió». Sólo cuenta una fila que de verdad dice que sí o que no; una fila malformada no es un desacuerdo.
El expediente sigue cruzando TODO al cerrar. A media corrida sólo corta la CURP de la INE contra RENAPO, y sólo si tu organización la deja encendida y crítica; los demás controles críticos deciden al cierre, en expediente.failed y expediente.missing.
En el sandbox la INE de prueba y RENAPO describen a la misma persona, así que los cruces salen en acuerdo y ningún paso corta.
Apariencia por flujo y vista previa
Cada flujo puede verse distinto. settings.ui guarda su apariencia —paleta, logo, layout, tipos de documento y los textos de cada pantalla en español e inglés— y se publica con la versión, igual que los pasos: una corrida abierta termina con la apariencia con la que empezó. Un flujo sin settings.ui se ve exactamente como hoy, con la marca de la organización.
PUT https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91
{
"settings": {
"ui": {
"theme": {
"primary": "#FF6600", // obligatorio, #RRGGBB
"secondary": "#111827",
"background": "#FFFFFF",
"text": "#0F172A",
"radius": "md", // sm | md | lg → 8 | 12 | 16 px
"font": "sans" // sans | serif | mono
},
"logo_url": "https://acme.mx/logo.svg", // https, ≤ 2 KB, null = el de la organización
"layout": "card", // card | full
"document_types": ["ine", "passport"],
"texts": {
"welcome": {
"es": { "title": "Verifica tu identidad", "body": "Toma 2 minutos.", "cta": "Empezar" },
"en": { "title": "Verify your identity", "body": "It takes 2 minutes.", "cta": "Start" }
},
"result_review": {
"es": { "title": "Estamos revisando tus datos", "body": "Te avisamos por correo." }
}
}
}
}
}
# Pantallas que aceptan texto: welcome, form, doc_selection, doc_capture, liveness_intro,
# liveness, verifying, upload, otp, result_approved, result_review, result_declined, result_done.Se valida al guardar: los colores sólo en #RRGGBB, cada texto acotado a 200 caracteres, el logo en https y de menos de 2 KB, y las claves que no existen se descartan en lugar de guardarse. layout: "card" es una tarjeta centrada y "full" ocupa la pantalla completa. document_types es el default del paso de identidad, para cuando el paso no lo fija; si la plataforma deja de ofrecer la tarjeta de residente, un paso guardado que la ofrecía abre su captura con INE + pasaporte, y uno que pedía sólo la tarjeta contesta 400 document_type_not_available. logo_url en null vuelve al logo de la organización.
Se edita viéndolo
En el panel, el editor del flujo trae una pestaña «Apariencia» con los selectores de color, el logo, el layout, el radio, la fuente, los tipos de documento y los textos por pantalla e idioma. Al lado, la vista previa pinta cada pantalla del recorrido —bienvenida, formulario, elección de documento, captura de frente y reverso, prueba de vida, verificando, subir documento, código de correo y el resultado— con datos de ejemplo, en un marco de teléfono y con las animaciones reales. No pide la cámara ni abre una sesión: es el mismo runner corriendo en modo vista previa sobre tu borrador.
Quién gana: tres capas
Cada capa pisa sólo los campos que trae, así que nada es todo-o-nada.
Ajustes › MarcaLa base: logo, color, nombre e idioma de toda la organización.
settings.uiLa apariencia de ese flujo, congelada en la versión publicada. Pisa a la organización.
Identity.open({ theme })Sólo esa sesión, sólo en el navegador. Pisa a las dos anteriores.
El theme del SDK es la rebanada angosta —colores, logo y nombre de marca— y es la única capa que puede cambiar entre dos corridas del mismo flujo: úsala cuando una sola integración sirve a varias marcas. El layout, los textos por pantalla y los tipos de documento se publican con el flujo, no se mandan por llamada.
Tu marca, no la nuestra
Una empresa que ya tiene su encabezado, su título y su barra de progreso no necesita los nuestros. settings.ui.chrome apaga las siete piezas de nuestra página, una por una, y layout: "bare" quita además el marco y el fondo: lo que queda es la captura, dentro de tu pantalla, sin dos de cada cosa. Las siete banderas vienen en true: un flujo que nunca las tocó se ve exactamente igual que antes.
PUT https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91
{
"settings": {
"ui": {
"layout": "bare", // card | full | bare (bare = sin marco ni fondo)
"chrome": {
"header": false, // logo + nombre (default true)
"brand_name": false, // el nombre junto al logo (default true)
"progress": false, // barra / rail de pasos (default true)
"titles": false, // títulos de pantalla (default true)
"bodies": false, // textos de apoyo (default true)
"legal": false, // pie legal / «Secured by» (default true)
"background": "transparent" // canvas | transparent (default canvas)
}
}
}
}Se guarda y se valida como el resto de ui: sólo sobreviven las banderas que mandaste y que son booleanas, sólo un background del catálogo (canvas o transparent) y lo demás se descarta. Al publicar, un valor que no es true ni false se nombra con invalid_ui, igual que un color inválido. Se congela con la versión: cambiar el chrome no repinta una corrida abierta.
Qué ve el runner
El config público del embed devuelve el chrome COMPLETO: las siete llaves, siempre, con el default donde el flujo no dijo nada. Tu front —o el nuestro— pregunta chrome.header y recibe un booleano, nunca «la llave no venía». El bloque branding no se movió.
GET https://api.singula.mx/app/flow-embed/config/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17
{
"flow": { "kind": "kyc", "version": 1 },
"ui": {
"layout": "bare",
"chrome": {
"header": false, "brand_name": false, "progress": false,
"titles": false, "bodies": false, "legal": false,
"background": "transparent"
}
},
"branding": { "app_name": "Acme", "logo_url": "https://acme.mx/logo.svg" }
}
# Las SIETE llaves, siempre, con el default donde el flujo no dijo nada.
# El runner pregunta chrome.header y recibe un booleano, no «la llave no estaba».
# branding no se movió: el runner desplegado hoy lo sigue leyendo igual.Con todo apagado y layout: "bare", la captura embebida es la cámara, sus instrucciones y su botón, sobre tu propio fondo: sin nuestro encabezado, sin nuestro nombre, sin nuestra barra de pasos, sin nuestros títulos ni textos de apoyo y sin el pie legal. Lo que se ve es tu producto. Esto es apariencia y no toca el cobro ni el veredicto.
Pre-existencia: un paso le deja el dato al siguiente
La mitad de lo que un paso necesita ya lo leyó otro dos pasos antes. El catálogo lo dice: cada herramienta publica provides junto a needs, y ahí sólo aparece lo que la plataforma LEE de verdad de esa respuesta.
GET https://api.singula.mx/app/flows/catalog
{
"items": [
{ "tool": "ineExtraction", "kind": "both",
"needs": [],
"provides": ["curp", "name", "birth_date"] },
{ "tool": "validateCurp", "kind": "kyc",
"needs": ["curp"],
"provides": ["name", "birth_date"] },
{ "tool": "verifyEmail", "kind": "both",
"needs": ["email"],
"provides": ["email"] }
]
}
# Sólo lo que la plataforma LEE de verdad de esa respuesta.
# identityVerification no declara nada a propósito: un pasaporte puede no traer CURP.Son tres y nada más, a propósito: la INE deja CURP, nombre y fecha de nacimiento; la CURP validada deja nombre y fecha de nacimiento; el correo verificado deja el correo. identityVerification no declara nada porque un pasaporte puede no traer CURP —uno extranjero no la imprime— y una sesión que no llegó al documento no trae bloque: prometerlo dejaría publicar un flujo que estructuralmente no lo cumple.
Publicar lo exige
Un need se satisface si lo pide el formulario o si lo produce un paso ANTERIOR: una INE arriba de la CURP la desbloquea, la misma INE abajo no, y un paso nunca se satisface a sí mismo. Si el flujo TIENE paso de datos está prometiendo «mándale este link a un desconocido y funciona», así que un hueco lo rechaza al publicar.
POST https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/publish
400 {
"code": "invalid_flow",
"problems": [
{ "step_key": "curp", "code": "unmet_need",
"need": "curp",
"providers": ["ineExtraction"] }
]
}
# El flujo pide la CURP en un paso, el formulario no la pide y ningún paso ANTERIOR la produce.
# Mueve la INE antes del paso de CURP y publica: el orden es todo.
# Sólo cuenta como proveedor un paso OBLIGATORIO y sin rama: uno opcional se salta y uno condicional puede no abrir.
# GET /app/flows/:id lo reporta igual, en problems, junto al paso.Un flujo SIN paso de datos no se toca: sólo se puede abrir sobre un customer_id y sus datos salen de esa fila. Las nueve plantillas publican sin cambiarles nada.
En corrida, el paso rellena los huecos
Cuando el paso termina, lo que produjo se escribe en el registro del cliente — pero SÓLO en los huecos. Nunca se pisa un valor que tu organización tecleó, que la persona llenó o que otro paso ya escribió: la fila es contra la que el expediente compara, y reemplazar en silencio una CURP tecleada borraría justo el desacuerdo que el expediente existe para reportar. Una columna de puros espacios sí cuenta como hueco. En un KYB nunca llena datos de persona: la INE es del representante, y desde MasterApi 1.114.0 su CURP, sus apellidos y su nacimiento ya no se escriben en la fila de la empresa. Y nunca es fatal: corre después de que el veredicto del paso está escrito, así que ni el cobro ni la decisión dependen de una escritura que es una comodidad.
# datos → INE → CURP, sin pedir la CURP en el formulario
{
"steps": [
{ "key": "datos", "tool": "form",
"params": { "fields": ["name", "last_name", "email"] } },
{ "key": "ine", "tool": "ineExtraction" },
{ "key": "curp", "tool": "validateCurp" }
]
}
GET https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91
{ "requires_customer": false, "missing_needs": ["curp"], "problems": [] }
# requires_customer cuenta los pasos que PRODUCEN el dato, así que esta corrida se abre sin customer_id.
# missing_needs no cambió: sigue contestando «qué no pide el formulario».Ése es el efecto que se nota: requires_customer ahora cuenta los pasos que producen el dato, así que un flujo datos → INE → CURP se abre sin customer_id. Un paso saltado por su rama ni siquiera lee la fila.
Dos precisiones que cambian lo que publica. Sólo satisface un need un paso OBLIGATORIO y sin rama: uno opcional lo salta quien cierra por API o la persona misma, y uno condicional puede no abrir nunca, así que contarlos dejaba publicar datos → INE(opcional) → CURP y la corrida en la que la INE no pasaba llegaba a RENAPO con la fila vacía. Y en corrida hay un cinturón: un paso de servidor cuyo need sigue en blanco al arrancar se salta con reason: "need_missing", pass, continue y cargo 0, en vez de reventar. Falla abierto: si la fila no se puede leer, el paso corre.
Correr un KYC sobre un cliente que ya tienes
Un cliente al que ya le validaste la CURP hace dos semanas no tiene por qué volver a pagarla. Abre la corrida con reuse_evidence: true y los pasos cuya consulta terminal ya existe nacen done, con ese folio, reused: true y cargo 0. Exige customer_id: sin fila no hay de quién buscar evidencia (400 customer_required). Funciona igual en el canal hospedado y en el canal API.
curl -X POST https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/sessions \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "customer_id": "c7b1e940-52af-4d38-a6c0-8e35f1d0742b", "reuse_evidence": true }'{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"token": "flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17",
"url": "https://app.singula.mx/flow/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17",
"flow_version": 1,
"estimate_centavos": 1800,
"reused": [
{ "step_key": "curp", "request_id": "66f1a2b3c4d5e6f708192a3b",
"request_date": "2026-09-02T18:11:07.000Z" },
{ "step_key": "listas", "request_id": "66f0c1d2e3f405162738a9bc",
"request_date": "2026-09-11T09:40:22.000Z" }
],
"missing": [
{ "step_key": "identidad", "tool": "identityVerification", "how": "person" }
]
}El reuso se resuelve ANTES del estimado, así que el saldo que retiene la apertura —y el estimate_centavos que se guarda— es el costo de lo que FALTA por correr: la línea reusada se cotiza en 0 y el 402 deja de rechazar corridas por dinero que nunca se iba a gastar.
La respuesta te dice qué se tomó y qué falta. reused[] trae el paso, el folio y la fecha de la consulta que se reutilizó; missing[] trae el paso, su herramienta y cómo se resuelve: how: "auto" lo corre la plataforma sola y how: "person" necesita que alguien aparezca, con el link o con el SDK. Con eso decides si mandas el link o si simplemente dejas correr la corrida.
Lo que reusa un paso es evidencia que tú pagaste. La validación de CURP que una verificación de identidad o una INE corrió por dentro, sin cargo (bundled_with en el registro), nunca llena un paso de CURP, ni por reuso ni pegada: ese paso corre y se cobra como siempre.
Una corrida puede nacer rechazada
El veredicto de un paso reusado sale de su propio registro, con el on_fail que ese paso tiene en la versión publicada. Si ese veredicto rechaza, la apertura cierra la corrida ahí mismo: no arranca ningún paso, no cobra nada más y contesta next: null. Dictarte un paso que el cierre no iba a aceptar sería mandarte a pagar una herramienta para nada.
POST https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/sessions
-d '{ "customer_id": "c7b1e940-52af-4d38-a6c0-8e35f1d0742b", "reuse_evidence": true }'
{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"estimate_centavos": 0,
"reused": [
{ "step_key": "listas", "request_id": "66f1a2b3c4d5e6f708192a3b",
"request_date": "2026-09-11T09:40:22.000Z" }
],
"missing": [],
"next": null
}
# El paso reusado trae el on_fail de SU registro, así que la apertura puede terminar rechazada.
# La corrida cierra ahí mismo: no arranca ningún paso, no cobra nada y next llega en null.Qué cuenta como fresco
La ventana es settings.evidence_max_age_hours, de 1 a 8760 horas y 720 (30 días) por default. Se congela con la versión, como todo settings, y sólo se guarda si la elegiste. Una consulta se reutiliza cuando es terminal y es del mismo cliente, del mismo ambiente, de la misma herramienta y —en el paso genérico de documentos— del mismo tipo de documento: sin eso, un KYB con acta y comprobante se llevaría los dos archivos más nuevos en el orden que saliera. Una consulta se gasta una vez y una consulta sin fecha nunca es fresca.
PUT https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91
{ "settings": { "evidence_max_age_hours": 168 } } // 1..8760, default 720 = 30 díasDos cosas que un paso reusado NO hace. No se re-sella el registro: el folio conserva la corrida que de verdad lo recolectó. Y no abona nada: se pagó en su propia llamada, semanas antes, en una fila que esta corrida ni conoce, con el mismo cargo que tendría como paso. Lo que sí hace, y es lo importante: su veredicto sale del registro con la política de verificación de tu organización, así que los cruces automáticos cortan igual sobre una INE reusada que sobre una recién subida. Reusar no es una puerta trasera a los controles.
¿Qué le falta a este cliente?
Antes de abrir nada puedes preguntarlo. Los dos expedientes sueltos —el KYC y el KYB— aceptan ?flow_id= y contestan la cobertura de ESE flujo con las mismas reglas del reuso: qué se reutilizaría, qué falta y qué sigue. Con flow_id no cruza nada, no escribe registro y no cobra. La ventana que usa es la de la VERSIÓN PUBLICADA, no la del borrador que estés moviendo, porque ésa es la que va a usar la corrida. Un flow_id que no sea un flujo publicado, de tu organización y de ese tipo es 404 flow_not_found, y un cliente de otra organización también: la cobertura se resuelve con el inquilino, nunca sólo con el ambiente.
POST https://api.singula.mx/app/kyc-expediente/customer/c7b1e940-52af-4d38-a6c0-8e35f1d0742b?flow_id=7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91
{
"flow_id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
"coverage": {
"reused": [{ "step_key": "curp", "request_id": "66f1a2b3c4d5e6f708192a3b" }],
"missing": [{ "step_key": "identidad", "tool": "identityVerification", "how": "person" }]
},
"next": "open_session"
}
# Con flow_id NO cruza nada, NO escribe registro y NO cobra: sólo contesta qué falta.
# next: "run" cuando todo lo que falta lo corre la plataforma sola; "open_session" cuando alguien tiene que aparecer.Y si tienes varios KYC, elige cuál: sin flow_id el expediente sigue siendo el de siempre, más flows[] con los publicados de tu organización y missing_required[] con el how de cada componente. Si el listado de flujos truena, el expediente igual contesta.
POST https://api.singula.mx/app/kyc-expediente/customer/c7b1e940-52af-4d38-a6c0-8e35f1d0742b
{
"overall": "review",
"missing_required": [
{ "component": "identity", "how": "person" }
],
"flows": [
{ "id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91", "name": "Alta de persona", "kind": "kyc" },
{ "id": "e1d4c7b0-2a93-4f58-8c16-7b0d5e93a2f4", "name": "Alta exprés", "kind": "kyc" }
]
}
# Sin flow_id es el expediente de siempre, más los flujos publicados de tu organización: si tienes varios KYC, elige cuál.5. Escucha el webhook
Al abrir la sesión se dispara flow.session.created; al cerrar, flow.completed —apruebe, rechace, quede en revisión o venza—. Si un revisor pide un reenvío sale además flow.resubmitted, y al volver a cerrar, otro flow.completed (ver «Repetir un paso», abajo). Todos van en el sobre firmado con HMAC-SHA256 en X-Singula-Signature. flow.session.created trae además flow_id, flow_version y link_id: el link compartible que abrió la verificación, o null. flow.completed sale cuando la corrida cierra, y hay dos cierres que esperan algo después del último paso: un KYC de producción con identidad (INE) espera la Lista Nominal de esa sesión, y un KYB espera unos segundos a RENAPO por la INE de su representante (ver «6. Lee la corrida»).
POST https://tu-servidor.com/webhooks/singula
X-Singula-Signature: t=1758000251,v1=<hmac-sha256("<t>.<cuerpo crudo>", secret)>
X-Singula-Event: flow.completed
X-Singula-Delivery-Id: <id de la entrega>
{
"id": "6b1f…", // id de la entrega
"type": "flow.completed",
"created": 1758000251, // unix, en segundos
"data": {
"request_id": "66f1a2b3c4d5e6f708192a3b",
"tool_type": "kyc", // kyc | kyb
"status": "completed", // success | completed | cancelled
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"decision": "review", // approved | review | declined | null
"expediente_overall": "review" // pass | review | fail | null
}
}
# El sobre es delgado, como todos los webhooks: trae los ids y el veredicto, no el detalle.
# expediente_overall es el veredicto del expediente con el que cerró la corrida (null cuando no hubo: un rechazo a media corrida, un vencimiento o una cancelación). El detalle de la corrida trae el resumen del expediente; el cotejo completo se lee con su request_id en GET /dashboard/request/:id.
# status resume qué pasó con la CORRIDA, no el veredicto (ese va en decision): success = cerró con veredicto (approved o declined); completed = quedó en review, o la corrida alcanzó a cobrar; cancelled = se cerró sin cobrar nada (venció o se abandonó antes del primer paso que cobra).
# El detalle se lee con el session_id, NO con el request_id:
# GET /app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b46. Lee la corrida
El detalle de la sesión trae el veredicto y la lista de pasos, cada uno con su resultado (pass | fail | review | error), la acción que tomó la regla, el folio de la consulta que corrió y lo que sumó al cargo. status describe la sesión; decision es lo único sobre lo que se decide, y es null mientras la corrida no cierra (y también en las que cierran sin veredicto: expired y abandoned).
Un paso cobra con la misma regla que su herramienta suelta (MasterApi 1.113.0): lo entregado, su cargo; si falla después de una llamada con costo —un documento o una INE ya leídos, también cuando la Lista Nominal está en mantenimiento; una identidad cuya foto ya se revisó; una e.firma o una investigación que ya consultaron—, su cargo base, aunque la falla sea nuestra; si falla antes de cualquier llamada con costo, o ninguna fuente contestó, 0. Un paso de CURP, RFC, teléfono, listas, judiciales o correo que falla cobra 0. Repetir una captura en la página no se cobra.
charged_centavos es lo que cobró la fila de la corrida: sus pasos y, desde MasterApi 1.113.0, los archivos de la pantalla «Sube tus documentos» que ya se habían leído y ningún paso usó. Cada uno sale en unused_uploads[] —{ request_id, file_name, document_type, pages, reason: removed (la persona lo quitó) | unused (sobró al cerrar) | failed (no se pudo leer), charged_centavos }—: el que ya se había leído se cobra una vez, a su cargo como consulta suelta; el que nunca se leyó, 0. Su request_id contesta 404 a tu organización y el archivo no sale en la actividad, el cliente ni el expediente. En el canal API la lista es siempre [].
expediente es el cierre: { request_id, overall (pass | review | fail), failed[], missing[] }. Se arma con los request_id de ESA corrida —nunca con el historial del cliente— y cobra 0. Es el resumen: su request_id abre el cotejo completo, control por control, en GET /dashboard/request/:id, como cualquier otra consulta. failed son los controles que se contradicen; un control sin evaluar no acusa a nadie, sale en missing como evidencia que falta. Llega en null mientras la corrida sigue y en las que cerraron sin él (un rechazo a media corrida, un vencimiento, una cancelación), y si no se pudo armar, request_id es null y failed trae un solo código: expediente_error, no_customer o expediente_no_evidence.
En un KYC sin paso de INE, los tres controles de la INE —ine_name_vs_renapo, ine_curp_vs_renapo e ine_vigente— leen la sesión de identidad: su documento contra el registro de RENAPO y la Lista Nominal que ella misma consultó. Desde MasterApi 1.112.0, una corrida cuya identidad contradice a RENAPO (otra CURP, o un nombre de verdad distinto), o cuya INE ya venció o no está vigente en la Lista Nominal, cierra declined: esos controles son críticos por default. Si prefieres revisión, ponlos en critical: false en la política de verificación. Desde MasterApi 1.128.0, si la identidad leyó un pasaporte —y desde 1.130.0 una tarjeta de residente—, identity_doc_vigente revisa que no haya vencido —es crítico: uno vencido cierra declined; una tarjeta permanente no vence— e identity_doc_dob_vs_renapo compara su fecha de nacimiento con la de RENAPO —no es crítico: si no coincide, revisión—; con una INE los dos quedan en null y no retienen nada. Un control cuya evidencia la corrida nunca recolectó —la CURP de un pasaporte extranjero, una Lista Nominal que no llegó— queda en null y no retiene el veredicto, salvo que tu política exija esa evidencia. Desde MasterApi 1.130.0, si la identidad leyó un pasaporte expedido fuera de México que no imprime CURP y cuyo titular no es de nacionalidad mexicana, la casilla curp sale en excused y no en missing —un extranjero no tiene CURP— y no retiene; un pasaporte mexicano y la tarjeta de residente sí traen CURP y la siguen pidiendo.
Por lo mismo, un KYC de producción cuya identidad leyó una INE (y sin paso de INE) espera para cerrar esa Lista Nominal: hasta 10 minutos desde el veredicto de la sesión. La persona ve su paso listo igual; flow.completed sale cuando llega —desde MasterApi 1.114.0, en la siguiente consulta de la página, no uno o dos minutos después—, y pasado el tope la corrida cierra con ine_vigente en null. La Lista Nominal no se espera en sandbox, en un KYB ni con un pasaporte.
En un KYB, desde MasterApi 1.114.0, el cierre coteja la INE del representante contra RENAPO con la CURP que la credencial imprime —la empresa no tiene CURP—: reusa una validación de esa CURP o corre una incluida en la INE, sin cargo extra, y la espera unos segundos. ine_renapo_match compara la CURP, la fecha de nacimiento, el sexo y el nombre. Si coincide, un KYB coherente aprueba solo; si se contradice, rechaza (es crítico); si RENAPO no tiene esa CURP o la CURP impresa está mal formada, va a revisión; y si la consulta falló o la INE no trae una CURP legible, queda en null y retiene: un KYB nunca aprueba sin cruzar a la persona contra RENAPO. Desde MasterApi 1.130.0, sin INE leída y con la casilla ine exigida, la sesión de identidad del representante —INE, pasaporte o tarjeta de residente, con prueba de vida— es su identificación (ine_source: identity_session) sólo si está VERIFICADA y es de la persona que nombran el poder o el acta: una en revisión, rechazada o claramente de otra persona deja ine faltando, y el expediente va a revisión. Entonces el cruce usa la CURP que imprime, incluido, el cierre espera su Lista Nominal si leyó una INE, la vigencia de un pasaporte o una tarjeta ocupa el lugar de la Lista Nominal y su aviso de calidad de imagen en false manda a revisión, nunca reprueba. Un pasaporte expedido fuera de México que no imprime CURP, de un titular que no es mexicano, deja ine_renapo_match en null sin retener.
Los nombres de persona del KYB —el apoderado contra la INE y contra el acta, quien firma contra el poder— se juzgan con la vara del KYC: kyc.name_match_threshold (0.8 por default; antes 0.6) y, cuando un lado separa los apellidos, el otro tiene que traer los dos. Uno que alcanza el umbral sin un apellido va a revisión. Y un acta sin RFC —lo normal— ya no retiene la aprobación cuando su denominación es IDÉNTICA a la razón social de la constancia, sin contar la forma jurídica del final («GRUPO SINGULA, S.A. DE C.V.» = «GRUPO SINGULA»); una parecida sigue reteniendo, porque dos sociedades de un mismo grupo sólo se distinguen por el RFC.
Desde MasterApi 1.114.0 cada control del expediente, KYC o KYB, trae holds_approval: true cuando ESE control es lo que impide el pass, con la misma regla que decide overall. Es true en todo control en false —uno crítico rechaza, cualquier otro manda a revisión— y en un null que retiene (la política exige su evidencia y el expediente no lo excusa); es false en un control en true y en un null que no retiene nada. Léelo en vez de recalcularlo. Sale en el detalle de la consulta del expediente (GET /dashboard/request/:id con el request_id de expediente, en response.checks[]) y en las rutas directas POST /app/kyc-expediente/customer/:id y POST /app/kyb/expediente/customer/:id (data.checks[]). Un expediente cerrado antes de 1.114.0 no lo trae.
Cada paso dice además si nació reusado. reused es una booleana por paso, así que un paso con cargo 0 que se tomó del historial de ese cliente no se confunde con uno pegado por el canal API (attached: true) ni con uno que se saltó. Y un paso de servidor que se saltó porque su dato seguía en blanco llega con reason: "need_missing" y cargo 0. En una cuenta sin KYB, un paso de antecedentes de persona que no pasó la compuerta de la identidad llega skipped con reason: "kyb_required", outcome: "review" y cargo 0 (ver «Si tu cuenta todavía no tiene el KYB», arriba).
La verificación trae además link_id, el link compartible que la abrió (null si no fue un link), y network, desde dónde se conectó la persona. Ver «Link compartible y QR», arriba, y «Análisis de IP», abajo.
El veredicto sale en este orden: un paso con regla de rechazo cierra declined ahí mismo, sin expediente; expediente.overall en fail cierra declined; un paso que pidió revisión, un expediente en review o una corrida sin expediente cierran review; sólo un expediente en pass sin pasos en revisión cierra approved. Sin expediente no se aprueba.
{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"request_id": "66f1a2b3c4d5e6f708192a3b",
"flow_id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
"flow_name": "Alta de persona",
"flow_version": 1,
"kind": "kyc",
"status": "review",
"decision": "review",
"decided_by": "rules",
"reviewer_note": null,
"steps": [
{ "key": "datos", "tool": "form", "status": "done", "request_id": null,
"outcome": "pass", "reason": "form_submitted", "action": "continue",
"charged_centavos": 0, "completed_at": "2026-09-15T12:01:02.000Z" },
{ "key": "identidad", "tool": "identityVerification", "status": "done",
"request_id": "66f1...9c", "outcome": "pass", "reason": "identity_approved",
"action": "continue", "charged_centavos": 2000,
"completed_at": "2026-09-15T12:02:40.000Z" },
{ "key": "curp", "tool": "validateCurp", "status": "done",
"request_id": "66f1...a4", "outcome": "pass", "reason": "curp_valid",
"action": "continue", "charged_centavos": 300, "reused": false,
"completed_at": "2026-09-15T12:03:05.000Z" },
{ "key": "listas", "tool": "checkBlacklist", "status": "done",
"request_id": "66f1...b1", "outcome": "review", "reason": "blacklist_medium",
"action": "review", "charged_centavos": 1000,
"completed_at": "2026-09-15T12:04:10.000Z" }
],
"expediente": {
"request_id": "66f1...c7",
"overall": "review",
"failed": [],
"missing": []
},
"charged_centavos": 3300,
"unused_uploads": [],
"estimate_centavos": 3300,
"checked_at": "2026-09-15T12:04:11.000Z"
}not_startedLa sesión existe y el link no se ha abierto.in_progressCorriendo: hay pasos de servidor en curso.awaiting_userEsperando una captura de la persona.resubmittedUn revisor pidió repetir pasos («Reenvío pedido» en el panel). No es un veredicto: pasa a awaiting_user o in_progress en cuanto la corrida toma el primer paso reabierto.reviewEspera a una persona de tu equipo: algún paso pidió revisión, el expediente cerró en review, o no hubo expediente.approvedEl expediente cerró en pass y ningún paso pidió revisión. Es la única forma de aprobar.declinedUn paso con regla de rechazo la detuvo, o el expediente cerró en fail.expiredPasó su expires_at sin terminar.abandonedSe canceló por API antes de terminar.7. Decide a mano lo que quedó en revisión
Una corrida en review espera a una persona. Se aprueba o se rechaza con una nota, desde el panel o por API. La corrida queda con decided_by en "reviewer", guarda la nota y vuelve a disparar flow.completed.
curl -X POST https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/decision \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "decision": "approved", "note": "CURP con apellido compuesto, verificado contra el acta" }'
# Sólo sobre una corrida en review. Después, decided_by pasa a "reviewer".Decidir cierra la corrida tal como está. Si lo que falta es que la persona repita una foto, un documento o un dato, pide un reenvío en su lugar (abajo).
Repetir un paso: reenvío y reintento
Una verificación en review o declined no tiene que empezar de cero. Un revisor pide que la persona repita sólo los pasos que salieron mal, con el MISMO link, y al volver a cerrar el expediente juzga con la evidencia nueva. Repetir un paso no se cobra otra vez.
curl -X POST https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/resubmit \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"steps": ["datos", "listas"],
"reason": "data_mismatch",
"note": "El segundo apellido no coincide con la INE; se pide corregirlo.",
"expires_in_hours": 72
}'{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"status": "resubmitted",
"decision": null,
"decided_by": null,
"expediente": null,
"url": "https://app.singula.mx/flow/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17",
"expires_at": "2026-09-22T12:00:00.000Z",
"resubmission_count": 1,
"steps": [
{ "key": "datos", "tool": "form", "status": "pending", "attempt": 2,
"reset_at": "2026-09-16T10:00:00.000Z", "retry": null,
"attempts": [
{ "attempt": 1, "request_id": null, "outcome": "pass",
"reason": "form_submitted", "charged_centavos": 0,
"ended_by": "resubmit", "resubmission": 1 }
] },
{ "key": "listas", "tool": "checkBlacklist", "status": "pending", "attempt": 2,
"reset_at": "2026-09-16T10:00:00.000Z", "retry": null,
"attempts": [
{ "attempt": 1, "request_id": "66f1...b1", "outcome": "review",
"reason": "blacklist_medium", "action": "review",
"charged_centavos": 1000, "ended_by": "resubmit", "resubmission": 1 }
] }
],
"resubmissions": [
{ "n": 1, "at": "2026-09-16T10:00:00.000Z", "steps": ["datos", "listas"],
"reason": "data_mismatch",
"note": "El segundo apellido no coincide con la INE; se pide corregirlo.",
"previous": { "status": "review", "decision": "review", "decided_by": "rules",
"reviewer_note": null, "completed_at": "2026-09-15T12:04:11.000Z",
"expediente": { "request_id": "66f1...c7", "overall": "review",
"failed": [], "missing": [] } } }
],
"charged_centavos": 3300
}
# La misma url. expires_at nunca se acorta: aquí la vigencia original ya cubría las 72 horas.
# charged_centavos no baja: lo que cobró cada intento se queda en su attempts[].
# attempts[] trae además tool_charged_centavos, attached, reused, started_at, completed_at y ended_at.stepsDe 1 a 20 claves, sin repetir. Pasos de la persona, el de datos (la persona corrige) o de servidor: ésos los vuelve a correr la plataforma sola.
reasondocument_unreadable, document_expired, wrong_document, selfie_retake, data_mismatch u other. La página lo traduce para la persona y viaja en el webhook.
noteNota interna, de 3 a 1000 caracteres. Sale en el detalle y en el MCP; nunca en la página ni en el webhook.
expires_in_hoursOpcional, de 1 a 720: cuántas horas más vive el link desde ahora. La vigencia nunca se acorta. Sin él, la del flujo.
Quién lo pide y sobre qué
Se pide sobre una verificación en review o en declined: desde el panel («Pedir reenvío»), por API o por MCP (resubmitFlowSession), con los mismos permisos que decidir a mano. Una corrida se reabre hasta 3 veces. Una corrida hija de un KYB no se reabre por separado: se revisa desde la de la empresa.
En una declined, el paso que la rechazó tiene que ir en steps: sin él, la corrida se vuelve a rechazar al reabrirse. No se pueden pedir el paso de representantes ni uno que su rama saltó. Un paso que nunca corrió, porque la corrida se detuvo antes, corre al llegar a él.
Qué ve la persona
El mismo link vuelve a abrir. La página empieza con «Necesitamos que repitas un paso», con el motivo en su idioma, y sólo pide los pasos reabiertos. La corrida queda en resubmitted («Reenvío pedido» en el panel) hasta que toma el primer paso reabierto. Se puede cancelar, y vence como cualquier corrida viva.
Reintento en la página
En la página hospedada, una captura mala vuelve a la persona antes de asentarse: «Vuelve a intentarlo (2 de 3)». Son hasta 3 intentos por ronda, y la cuenta vuelve a empezar con cada reenvío. En el último intento el paso se asienta con su regla, como siempre, y un intento repetido no corre on_fail ni on_review.
identity_failed, ine_low_confidence, ine_missing_fields, ine_bad_field_format, ine_unsupported_model y document_wrong_kind: el documento no es el que pide el paso.
Una falla del rostro o de la prueba de vida (identity_declined), un negativo de la Lista Nominal, un cross_ref_*, un documento alterado (document_forged) ni un error del servicio.
El canal API no reintenta: tus pantallas deciden cuántas veces pedir la foto.
Un paso paga a lo más una vez
El primer intento que compró su herramienta paga como siempre; los siguientes pagan 0, los reintentos de la página y los reenvíos por igual. Un intento que no compró nada —uno que falló antes de cualquier llamada con costo, un documento que nunca se llegó a leer, una respuesta vacía porque ninguna fuente contestó, un paso reusado o uno saltado— no cuenta: el siguiente es la primera compra. La excepción es un intento que falló después de una llamada con costo —un documento o una INE que ya se leyeron, una identidad cuya foto ya se revisó; desde MasterApi 1.113.0 también e.firma e investigación de persona—: cobra su cargo base, igual que suelto, pero eso no cierra la compra. El siguiente intento paga sólo la diferencia hasta su propio cargo: 0 en un cargo fijo; en un documento, el siguiente que sí se lee —un éxito u otro error leído— paga sus hojas de más sobre el cargo base, y el primero que se lee sin ser error cierra la compra. charged_centavos nunca baja, porque lo de cada intento se queda en su attempts[]. Por el canal API, la herramienta que vuelves a llamar cobra al llamarla y el cierre te la abona completa en discount_credited_centavos, salvo esa diferencia cuando lo que pagó fue un intento que falló después de una llamada con costo. En sandbox todo es 0.
Qué avisa el webhook
Al pedir el reenvío sale flow.resubmitted, con los pasos, el motivo y el número de ronda, y sin la nota del revisor. Al volver a cerrar sale otro flow.completed, que trae resubmission_count.
X-Singula-Event: flow.resubmitted
{
"id": "8c4d…",
"type": "flow.resubmitted",
"created": 1758016800,
"data": {
"request_id": "66f1a2b3c4d5e6f708192a3b",
"tool_type": "kyc",
"status": "pending",
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"steps": ["datos", "listas"],
"reason": "data_mismatch",
"resubmission": 1
}
}
X-Singula-Event: flow.completed
{
"id": "9e2a…",
"type": "flow.completed",
"created": 1758020400,
"data": {
"request_id": "66f1a2b3c4d5e6f708192a3b",
"tool_type": "kyc",
"status": "success",
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"decision": "approved",
"expediente_overall": "pass",
"resubmission_count": 1
}
}
# flow.resubmitted nunca trae la nota del revisor: es interna.
# resubmission_count sólo sale en una corrida que se reabrió; en las demás, el sobre no cambia.Cómo se lee el historial
Cada paso trae attempt, qué intento es contando desde el primero, y attempts[], los intentos ANTERIORES con su folio, su lectura, su cargo y ended_by: retry si fue un reintento en la página, resubmit si lo reabrió un revisor. También reset_at, cuándo se reabrió, y retry, { code, max } mientras la persona repite una captura y null si no.
La corrida trae resubmission_count y resubmissions[]: cada ronda con n, at, steps, reason, note y previous, el estado, el veredicto y el expediente que tenía al reabrirse. El veredicto anterior no se borra: queda como historia.
Análisis de IP: desde dónde se conectó
Cada verificación guarda desde dónde se conectó la persona, en network. Es una señal de la corrida, no una herramienta: no cobra, no escribe un registro propio y no aparece en tu estado de cuenta.
Se guarda la IP confiable —la que ve nuestro servidor, nunca un X-Forwarded-For que mande el cliente— de la página hospedada (al cargar y en cada paso), de la apertura por link y de la sesión de identidad, que suele ser el teléfono que escaneó el QR y se funde en la corrida cuando ese paso cierra. Hasta 10 IPs distintas por corrida, cada una con cuántas veces llegó y cuándo por primera y por última vez. Sólo el navegador de la persona es evidencia: en una corrida channel: "api" las rutas públicas del runner no registran nada —identity_start lo llama tu servidor, que no es la persona— y la red sale de la captura de identidad que hace su navegador.
countryPaís de la IP (ISO 3166-1). En un iCloud Private Relay, el país que publica Apple.
asn · as_orgLa red de la IP y quién la opera.
is_torEs una salida de la red Tor.
is_hostingEs de una nube, un hosting o una VPN conocida. Un Private Relay NO cuenta: es un iPhone con Safari, no un servidor.
is_relayEs un egreso de iCloud Private Relay.
countries · any_*El resumen de la corrida: los países sin repetir y si alguna IP es Tor, hosting o relay.
Cada bandera es true, false o null. null quiere decir «sin datos» —una IP privada, una lista que no cargó—, nunca «limpia». network completo en null es una corrida que no registró ninguna IP.
GET https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4
{
"link_id": "3d9b7e21-5c44-4f0a-9b6e-8a1f2c7d4e90",
"network": {
"ips": [
{ "ip": "203.0.113.24", "first_at": "2026-09-23T18:04:00.000Z",
"last_at": "2026-09-23T18:09:12.000Z", "count": 4,
"country": "MX", "asn": 8151, "as_org": "Uninet S.A. de C.V.",
"is_tor": false, "is_hosting": false, "is_relay": false },
{ "ip": "198.51.100.7", "first_at": "2026-09-23T18:05:30.000Z",
"last_at": "2026-09-23T18:07:02.000Z", "count": 2,
"country": "US", "asn": 16509, "as_org": "Amazon.com, Inc.",
"is_tor": false, "is_hosting": true, "is_relay": false }
],
"countries": ["MX", "US"],
"any_tor": false,
"any_hosting": true,
"any_relay": false
},
"expediente": {
"request_id": "66f1...c7",
"overall": "review",
"failed": ["ip_country_vs_document", "ip_not_hosting"],
"missing": []
}
}
# El cotejo completo: GET /dashboard/request/66f1...c7 (el request_id del expediente), en response.checks[]
{ "code": "ip_country_vs_document", "ok": false, "critical": false, "holds_approval": true,
"detail": "IP desde US; el documento es de MX" }
{ "code": "ip_not_hosting", "ok": false, "critical": false, "holds_approval": true,
"detail": "IP de nube, hosting o VPN: 198.51.100.7 (AS16509 Amazon.com, Inc.)" }Sale en el detalle de la verificación (GET /app/flows/sessions/:id y el listado), en GET /app/identity-verification/status/:requestId y …/result/:requestId, y en el MCP (getFlowSession, getVerificationStatus). Nunca en los webhooks ni en la página de la persona.
Tres controles del expediente KYC
ip_country_vs_documentEl país de cada IP contra el país del documento de identidad o de la INE. Con un pasaporte extranjero valen su país y México; con una tarjeta de residente, México y el país de su nacionalidad: sólo se marca una IP de un tercer país.
ip_not_torNinguna IP es salida de Tor.
ip_not_hostingNinguna IP es de nube, hosting o VPN conocida.
Por defecto vienen encendidos y no son críticos: en false mandan la verificación a revisión, nunca la rechazan. Tampoco aprueban por sí solos: la red es una señal, no evidencia, así que sin otro control que se pueda evaluar el expediente queda en revisión aunque la red salga limpia. Sin datos, el control queda en null y no cuenta —no retiene el veredicto ni acusa a nadie—, así que una corrida sin IP decide igual que antes. Se apagan en la política de verificación, con kyc.checks.ip_* ({ "ip_not_hosting": { "enabled": false } }): un control apagado ni aparece. Si tu gente entra por una VPN corporativa o una red de nube, esa verificación irá a revisión; apaga ip_not_hosting si no te sirve. En un KYB la red se guarda y se muestra, sin control.
Lo mismo si subes tú las fotos de la identidad (presign, S3, confirm y submit): POST /app/identity-verification/submit/:token guarda la IP de quien lo llama. Desde un backend en la nube, esa IP es de hosting y el expediente va a revisión. Deja que la captura corra en el dispositivo de la persona —la página hospedada o el widget— o apaga kyc.checks.ip_not_hosting (y ip_country_vs_document si tu servidor está fuera de México).
En sandbox se guarda tu IP real con las señales fijas de la persona de prueba —México, AS8151, sin Tor, sin nube, sin relay—, así que el expediente de sandbox no se mueve aunque pruebes desde una VPN.
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.
8. Pruébalo sin gastar
Con una llave sk_test_ el flujo corre completo: cada paso responde con el mock determinista de su herramienta, la corrida cierra con la misma forma de respuesta —expediente incluido— y no consume saldo. El saldo tampoco se exige al abrir la sesión. Arma la integración entera ahí antes de la primera corrida real.
El panel en «Sandbox» (el modo prueba) es igual de gratis: manda X-Singula-Env: sandbox en cada llamada y corre como una llave sk_test_ —los mismos mocks, sin cargo, con los datos de sandbox de tu organización—. En ese modo, lo que arrancaría trabajo en producción responde 409 sandbox_mode y no escribe nada; leer, decidir, cerrar o cancelar una verificación de producción sí se puede. Una llave de API no lee ese encabezado: su ambiente es el de la llave. El sandbox tampoco pasa por la compuerta de los antecedentes: corre con o sin KYB.
El sandbox describe a UNA sola persona —MARIA LOPEZ GARCIA, CURP LOGM900101MDFPRR02, nacida el 1990-01-01— en identidad, INE, CURP y correo; las listas y el judicial contestan con el nombre que de verdad se buscó, el de tu cliente. Por eso el expediente de una corrida de prueba puede cerrar en pass: si cruzara a dos personas distintas, nunca aprobaría.
El reenvío y el cuestionario corren igual en el sandbox, sin cargo. El reintento de la página no se ve ahí: la INE de prueba sale vigente antes de cualquier filtro y un documento de prueba nunca se reintenta. Un link creado con una llave sk_test_ abre verificaciones de sandbox, sin cargo y sin 402, y su tope y su vencimiento son opcionales; la red de una corrida de sandbox es tu IP real con las señales fijas de la persona de prueba.
9. Errores que vas a ver
400 · invalid_flow (al publicar)Una clave repetida o mal formada, un paso de formulario que no va primero o está repetido, un tool que no existe en el catálogo o que no admite el kind, o un on_fail / on_review / on_error con un valor que no existe. La respuesta trae problems[] con el paso y el motivo; nunca se corrige en silencio.
400 · invalid_flow · missing_required_componentEl mismo 400, con un problema por casilla que la política de tu expediente exige y ningún paso recolecta (identity ⇐ identityVerification, curp ⇐ validateCurp, blacklist ⇐ checkBlacklist; en KYB csf ⇐ verifyCsf, acta y comprobante ⇐ extractDocument, poder ⇐ verifyPoder, ine ⇐ ineExtraction, fiel ⇐ checkFiel, email ⇐ verifyEmail…). Va con step_key: null porque el hueco es del flujo entero, no de un paso. GET /app/flows/:id trae la misma lista en problems, para verlo antes de publicar.
400 · invalid_flow · bad_child_flow / nested_children / bad_people_rangeLos tres son del paso de representantes. bad_child_flow: el child_flow_id no existe, es de otra organización, está archivado o sin publicar, o es un KYB — tiene que ser un flujo kyc publicado tuyo. nested_children: ese flujo hijo trae su propio paso de representantes, y sólo hay un nivel. bad_people_range: min quedó por encima de max.
400 · invalid_flow · invalid_conditionUna rama (when) que apunta a un paso POSTERIOR o inexistente, a un campo que el formulario de ese flujo no pide, o a un campo de respuesta que la herramienta no publica. También una fuente o un operador que no existen, un operador que necesita valor y llega sin él, y un in o un not_in con la lista vacía. Desde MasterApi 1.114.0, también un valor que el campo nunca tiene —verification.status eq approved, cuando sus valores son verified, partial, rejected y failed—, comparado con las options del catálogo, las de un select del formulario o true / false; y un tipo equivocado: gt, gte, lt y lte sólo sobre un campo numérico, y un campo numérico sólo con cifras. Esas ramas nunca abrían y el paso se saltaba sin cargo en cada corrida. Llega con el step_key del paso que la trae, el message dice los valores u operadores que sí valen, y los campos se leen en conditions, dentro de GET /app/flows/catalog.
400 · invalid_flow · form_cannot_create_customerDesde MasterApi 1.114.0. El paso de datos no pide, OBLIGATORIOS, los campos con los que se crea al cliente: name y last_name en un KYC; legal_name o name en un KYB. Sin ellos, toda corrida que se abre sin cliente —un link, un QR— se detendría en el primer paso. Pídelos como campos conocidos o con required: true. Una versión ya publicada sigue corriendo como se congeló.
400 · invalid_flow · unmet_needUn flujo CON paso de datos que lee algo que ni el formulario pide ni un paso ANTERIOR produce. Llega con step_key, need y providers[] —las herramientas que sí dejan ese dato—, así que la salida suele ser subir una de ellas por encima del paso que la necesita. Un flujo SIN paso de datos no se valida así: ése sólo se abre sobre un customer_id.
400 · invalid_flow · invalid_form_field / invalid_ruleLos dos son del cuestionario del paso de datos. invalid_form_field es un campo mal armado: nombre, tipo, etiqueta, opciones o min / max; un maps_to que no es un campo conocido o es de otro tipo; dos campos que llenan la misma columna; más de 40 campos. Llega un problema por campo. invalid_rule es una regla del paso de datos que no lee answers.<campo de ese formulario>, o un multiselect comparado con algo distinto de in, not_in, exists o missing.
400 · repeated_tool (al guardar el borrador) · invalid_flow · repeated_tool (al publicar)El flujo repite una herramienta. Cada herramienta va una sola vez por flujo; sólo se repiten el paso de documento (extractDocument), con cualquier document_kind, y el de representantes (representatives) de un KYB. El paso de datos tiene su propia regla. POST /app/flows y PUT /app/flows/:id lo rechazan al guardar, con problems[] —un problema por paso repetido— y sin escribir nada. La excepción son las ramas que se excluyen (desde 1.110.0): la misma herramienta puede ir en dos pasos cuando una condición de cada uno lee lo MISMO —el outcome o el action de un mismo paso anterior, un campo que pide el paso de datos o una columna del cliente que no cambia a media corrida (risk sí cambia)— y ningún valor cumple las dos: eq con valores distintos, eq contra ne del mismo valor, eq contra un in que no lo trae o un not_in que sí, in sin valores en común (en un multiselect no prueba nada), in contra un not_in que lo cubre, o rangos gt/gte contra lt/lte que no se tocan. Por ejemplo «si es extranjero → identidad con pasaporte» y «si es mexicano → identidad con INE». Lo que no se puede probar se sigue rechazando, y el mensaje lo dice. El estimado cuenta todas las ramas (un «hasta»); una corrida cobra sólo el paso que corrió. Un borrador guardado antes lo nombra al publicar, dentro de invalid_flow. Una versión que ya estaba publicada sigue corriendo como se congeló.
400 · invalid_form (al contestar el paso de datos)Una o más respuestas no caben en su campo. problems[] las trae todas, cada una con field, code (required | bad_type | bad_format | bad_option | out_of_range | too_long) y message. Se corrigen esas y se manda el formulario completo otra vez. Las llaves que el paso no pide se ignoran.
403 · tools_locked (al publicar)Un paso usa una herramienta que el estado de tu cuenta aún no desbloquea. Sin el KYB aprobado son los antecedentes de empresa, o los de persona sin un paso de identidad ANTES en un flujo KYC. La respuesta trae locked_tools[] y el motivo, que te manda a Ajustes → Verificación de tu cuenta; desde MasterApi 1.131.0 también step_keys[] (los pasos que las usan) y problems[], uno por paso.
400 · customer_required (al abrir la sesión)La versión publicada tiene requires_customer: true —algún paso de servidor necesita un dato que el formulario no pide— y la llamada no mandó customer_id.
402 · al abrir la sesiónEl saldo de la organización es menor que estimate_centavos. Con ramas ese número es el peor caso (estimate_is_maximum): tiene que caber la corrida que las toma todas. El sandbox no lo pide. Abrir con un link responde 402 link_unavailable_balance, sin montos, y no gasta un uso.
400 · al crear un linkflow_not_published: el flujo no está publicado. link_requires_form: no tiene paso de datos, y sin él nadie crea al cliente. customer_required (con missing_needs): algún paso lee un dato que el formulario no pide, así que exige un cliente que el link no trae. bad_expires_at: la fecha no es futura o pasa de un año.
400 · link_requires_cap / link_requires_expiry (al crear o editar un link)Un link de producción siempre tiene tope de usos y vencimiento: max_sessions o expires_at en null lo rechaza. Se mueven, nunca se quitan. Omitidos al crear, nace con 100 usos y 30 días. En sandbox los dos son opcionales.
410 · link_closed (al abrir un link)reason dice por qué: paused (en pausa), expired (venció), exhausted (se acabaron sus usos) o flow_unavailable (el flujo se archivó, o su versión publicada ya no empieza con el paso de datos o exige un cliente). GET /app/flow-embed/link/:token lo contesta sin gastar un uso.
403 · link_unavailable (al abrir un link)Tu cuenta no puede abrir la verificación. La ruta pública no da el motivo ni el estado de tu cuenta; con tu llave, abrir una sesión trae el cuerpo de siempre. No gasta un uso.
429 · too_many_opens (al abrir un link)Más de 20 aperturas en un minuto, 60 en una hora o 300 en un día desde la misma IP sobre el mismo link. En los de hora y día, window dice cuál (hour o day) y limit, el tope. Se libera solo.
429 · too_many_form_submits · too_many_emails (en una verificación de un link)Más de 60 en una hora o 300 en un día desde la misma IP sobre el mismo link: pasos de datos aceptados (un formulario rechazado con 400 no cuenta) y correos que manda Singula —el código del paso de correo y los links de representantes con delivery: "email"—. El mismo cuerpo que too_many_opens. Una verificación que no abrió un link no se frena.
409 · al decidirLa corrida ya no está en review: alguien decidió antes o las reglas la cerraron.
409 · al pedir un reenvíonot_resubmittable: la corrida no está en review ni en declined, o cambió mientras se pedía. close_in_progress: su veredicto todavía se está publicando; reintenta en unos segundos. child_run: es la corrida hija de un KYB y se revisa desde la de la empresa. step_not_resubmittable: una clave que no existe, el paso de representantes o uno que su rama saltó. declining_step_not_included: en una declined falta el paso que la rechazó, y steps lo nombra. max_resubmissions: la corrida ya se reabrió 3 veces.
409 · al saltar un pasoEse paso es obligatorio. Sólo se salta un paso con required: false.
409 · sandbox_mode (desde el panel)El panel está en «Sandbox» y la acción arrancaría trabajo en producción: abrir una verificación de producción o avanzarla (pegar, saltar, paso de datos, representantes, reenvío). No escribe nada. Cambia el panel a producción. Una llave de API nunca lo recibe.
400 / 409 · al entregar la lista de representantes400 bad_people_count si mandaste menos de min o más de max; bad_role si un rol no está en params.roles; email_required si el paso entrega por correo y una persona llega sin él; bad_name si el nombre no mide entre 2 y 120 caracteres; bad_email si el correo no es válido. 409 people_already_submitted si el paso ya abrió sus hijas —la respuesta trae esas mismas hijas—, y 409 not_attachable_step (con how: "people") si intentas pegarle un folio.
410 · en el runnerEl token de la sesión venció o la sesión ya cerró. Abre una sesión nueva, o pide un reenvío si la corrida cerró en review o declined: el mismo link vuelve a abrir.