KYC y KYB por API
Tu front, nuestras herramientas: el flujo te dicta el siguiente paso, lo corres sobre tu cliente, pegas el folio y cierras con un servicio. Mismo veredicto y mismo total que la página hospedada.
Qué cambia y qué no
El flujo se define igual que siempre: mismo editor, mismo catálogo de pasos, misma versión publicada. Lo único que cambia es QUIÉN corre los pasos de usuario. En vez de nuestra página, los corre tu front: muestras tus propias pantallas, llamas nuestras herramientas sobre tu cliente y pegas el resultado a la corrida. Los pasos de servidor —CURP, RFC, listas, judicial, teléfono, correo, e.firma— los sigue corriendo el runner solo, igual que en los otros dos canales. Al final pides cerrar y el expediente juzga.
channel: "hosted"Abres la sesión y mandas a la persona a la url (o pintas el QR). Nuestra página corre TODOS los pasos de captura. Tu backend sólo lee el veredicto.
channel: "hosted"La misma corrida en un iframe con @singula_systems/identity-js y tu marca. Del lado del servidor sigue siendo channel: "hosted".
channel: "api"Tus pantallas, nuestras herramientas. Pides el siguiente paso, llamas la ruta que te dicta sobre tu cliente, pegas el folio y cierras con un servicio.
Abrir la sesión desde tu backend y mandar a la persona a nuestra página NO es el canal API: es el canal UI abierto desde el backend, y sale con channel: "hosted". El canal API es sólo aquel en el que TU front corre los pasos de captura.
1. Crea el cliente con tu front
En el canal UI el paso de datos lo captura nuestra página. Aquí lo capturas tú, con tu formulario, y creas el cliente antes de abrir la corrida: el canal API EXIGE customer_id. El paso form de la versión publicada se resuelve solo —status: "skipped", outcome: "pass", reason: "customer_provided"— y no cobra.
curl -X POST https://api.singula.mx/customer \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"type": "physical",
"name": "MARIA",
"last_name": "LOPEZ",
"mothers_last_name": "GARCIA",
"curp": "LOGM900101MDFPRR02",
"email": "[email protected]"
}'
# Devuelve el cliente con su id. Ese id es el que exige el canal API.Si el formulario trae preguntas propias
Ninguna fila de cliente contesta una pregunta propia del cuestionario, así que con una sola el paso de datos no nace resuelto. next lo dicta con how: "api", interaction: "form" e input_contract: los campos que faltan, con su tipo. Se contesta en la ruta de call con { fields } y no se pega. La validación es la de la página hospedada: un valor malo responde 400 invalid_form con problems[], todos a la vez.
{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"status": "awaiting_user",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"done": false,
"next": {
"step_key": "datos",
"tool": "form",
"kind": "form",
"interaction": "form",
"required": true,
"params": { "fields": [ … ] },
"attempt": 1,
"how": "api",
"call": {
"method": "POST",
"path": "/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/datos/form",
"body": { "fields": "required" }
},
"input_contract": [
{ "name": "ocupacion", "type": "select",
"label": { "es": "Ocupación", "en": "Occupation" }, "help": null,
"required": true, "min": null, "max": null, "maps_to": null,
"options": [{ "value": "empleado" }, { "value": "comerciante" }, { "value": "otro" }] },
{ "name": "es_pep", "type": "boolean", "label": null, "help": null,
"required": true, "options": null, "min": null, "max": null, "maps_to": null }
]
}
}
# input_contract son los campos que faltan, con su tipo: los que tu cliente ya trae no se vuelven a pedir.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": { "ocupacion": "comerciante", "es_pep": false } }'
# → 201 con el detalle de la corrida ya avanzada. Un valor malo responde 400 invalid_form con problems[].
# Las respuestas propias quedan en form_data de la corrida: nunca en el cliente ni en el webhook.2. Abre la corrida en el canal API
El flujo tiene que estar publicado y el saldo tiene que alcanzar estimate_centavos, igual que en cualquier corrida. Lo nuevo es channel: "api" — sin él la sesión nace hospedada (el default es "hosted", y el SDK también lo es del lado del servidor).
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 '{
"channel": "api",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"external_id": "ord_123"
}'{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"request_id": "66f1a2b3c4d5e6f708192a3b",
"channel": "api",
"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-25T12:00:00.000Z",
"flow_version": 1,
"estimate_centavos": 4000,
"reused": [],
"next": {
"step_key": "identidad",
"tool": "identityVerification",
"kind": "user",
"interaction": "kyc",
"required": true,
"params": { "document_types": ["ine", "passport"] },
"how": "sdk",
"identity_start": {
"method": "POST",
"path": "/app/flow-embed/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17/steps/identidad/identity/start"
},
"attach": {
"method": "POST",
"path": "/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/identidad/attach"
}
}
}La respuesta trae next de una vez, para que empieces sin una segunda llamada. url, embed_url y token siguen ahí: el token es el que usan las rutas de identidad de la corrida.
Abrir con channel: "api" y sin customer_id responde 400 con code: "customer_required". Es la misma respuesta que ya daba un flujo con requires_customer, por el mismo motivo: sin cliente no hay sobre qué correr las herramientas.
3. El flujo dicta: GET …/next
Nunca adivinas el orden. Preguntas por el siguiente paso, haces lo que te dice how, y vuelves a preguntar. done: true significa que ya no queda nada por resolver: toca cerrar.
curl https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/next \
-H "Authorization: Bearer sk_live_xxx"how: "api"Corre la herramienta sobre TU cliente, espera a que termine y pega su request_id con attach. En los pasos de subida (INE, documento, constancia y poder) la dicta upload: reservas con upload.init, subes cada archivo directo a S3 y confirmas con upload.complete; ningún archivo pasa por la API. En el de correo por código la dicta call (mandas el código con tu UI y pegas el request_id de otp/verify). El paso de datos con preguntas propias también sale como api, pero se contesta con sus respuestas y no se pega.
how: "sdk"Sólo la verificación de identidad. Llama identity_start, manda el verification_token al navegador y ábrelo con Identity.open({ token }). NO se pega: el registro nace dentro del paso y el runner avanza solo cuando cierra.
how: "wait"El siguiente paso es de servidor y ya está corriendo (CURP, RFC, listas, judicial, teléfono, huella del correo, huella digital, e.firma). No hay nada que hacer: vuelve a preguntar por next.
{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"status": "awaiting_user",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"done": false,
"next": {
"step_key": "ine",
"tool": "ineExtraction",
"kind": "user",
"interaction": "upload",
"required": true,
"params": {},
"how": "api",
"upload": {
"init": {
"method": "POST",
"path": "/app/ine-extraction/customer/6622f8a1c1d2e3f4a5b6c7d8/init",
"body": { "ine_front": "required", "ine_back": "required", "min_ocr_confidence": "optional" }
},
"complete": {
"method": "POST",
"path": "/app/ine-extraction/request/{request_id}/complete"
}
},
"attach": {
"method": "POST",
"path": "/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/ine/attach"
}
}
}{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"status": "in_progress",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"done": false,
"next": {
"step_key": "curp",
"tool": "validateCurp",
"kind": "server",
"required": true,
"params": {},
"how": "wait",
"attach": {
"method": "POST",
"path": "/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/curp/attach"
}
}
}{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"status": "in_progress",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"done": true,
"next": null
}params trae los del paso tal como los publicó el flujo (document_types, document_kind…), para que tu pantalla pida lo correcto. attach siempre viene con la ruta del paso; en un paso how: "sdk" no la usas.
4. Corre la herramienta y pega el folio
La herramienta se llama como cualquier otra consulta sobre un cliente, con tu llave, y cobra en ese momento. En un paso de subida eso son tres pasos y ningún archivo pasa por la API: reservas, subes cada archivo directo a S3 con su POST firmado y confirmas (el detalle, en Archivos: directo a S3). Cuando termina, pegas su request_id al paso. Pegar no cobra: el renglón de la corrida guarda charged_centavos: 0 y el informativo tool_charged_centavos, que ya trae lo que cobró la herramienta —también el cargo base de una consulta que falló después de una llamada con costo— aunque la pegues en cuanto terminó.
# 1 · Reserva las dos caras: el nombre y el tipo de cada foto (JPG o PNG), en JSON.
curl -s -X POST https://api.singula.mx/app/ine-extraction/customer/6622f8a1c1d2e3f4a5b6c7d8/init \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "ine_front": { "filename": "ine_frente.jpg", "content_type": "image/jpeg" },
"ine_back": { "filename": "ine_reverso.jpg", "content_type": "image/jpeg" } }' > init.json
# → { "request_id": "66f1a2b3c4d5e6f708192a7c",
# "uploads": { "ine_front": { "url": "https://…", "fields": { … } },
# "ine_back": { "url": "https://…", "fields": { … } } } }
# 2 · Cada foto, directo a S3: sus fields primero y la foto al final, en file. Sin tu llave.
s3_post() {
local args=()
while IFS= read -r kv; do args+=(--form-string "$kv"); done \
< <(jq -r "${1}.fields | to_entries[] | \"\(.key)=\(.value)\"" init.json)
curl -s -X POST "$(jq -r "${1}.url" init.json)" "${args[@]}" -F "file=@${2}" -w "%{http_code}\n"
}
s3_post .uploads.ine_front ine_frente.jpg # → 204
s3_post .uploads.ine_back ine_reverso.jpg # → 204
# 3 · Confirma: revisamos las dos fotos en S3 y arranca la lectura y la Lista Nominal.
curl -s -X POST "https://api.singula.mx/app/ine-extraction/request/$(jq -r .request_id init.json)/complete" \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{}'
# → { "request_id": "66f1a2b3c4d5e6f708192a7c", "status": "processing" }
# Asíncrona: espera a que termine antes de pegarla.
# GET /app/ine-extraction/status/66f1a2b3c4d5e6f708192a7ccurl -X POST https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/ine/attach \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "request_id": "66f1a2b3c4d5e6f708192a7c" }'{
"step": {
"key": "ine",
"tool": "ineExtraction",
"status": "done",
"request_id": "66f1a2b3c4d5e6f708192a7c",
"outcome": "pass",
"reason": "ine_valid",
"action": "continue",
"attached": true,
"charged_centavos": 0,
"tool_charged_centavos": 500,
"completed_at": "2026-09-18T12:06:41.000Z"
},
"next": {
"step_key": "correo",
"tool": "verifyEmail",
"kind": "user",
"interaction": "otp",
"required": true,
"params": {},
"how": "api",
"call": {
"method": "POST",
"path": "/app/email/otp/customer/6622f8a1c1d2e3f4a5b6c7d8/send"
},
"attach": {
"method": "POST",
"path": "/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/correo/attach"
}
}
}El paso se resuelve por el camino normal: se lee el resultado de la consulta y se aplica la regla que el flujo le puso (on_fail, on_review, on_error). Pegar una consulta que salió mal no es un error de attach — es un paso que falló, y la regla decide si la corrida sigue, se va a revisión o se rechaza.
5. La identidad va por el SDK
La prueba de vida es la única captura que no reimplementas: arrancas el paso, y el verification_token que devuelve se abre con nuestro widget tal cual —es un token de identidad normal—. No se pega nada: el registro de esa verificación nace DENTRO del paso, así que el runner avanza solo en cuanto cierra.
curl -X POST https://api.singula.mx/app/flow-embed/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17/steps/identidad/identity/start \
-H "Content-Type: application/json"
# → { "status": "started", "verification_token": "…", "embed_url": "…", "expires_at": "…" }
# Si la captura de este intento ya se envió (hace menos de 15 min), no se abre otra:
# → { "status": "submitted", "verification_token": null, "embed_url": null, "expires_at": "…" }
# Esta ruta se autentica con el token de la corrida en la URL: no lleva API key.import { Identity } from '@singula_systems/identity-js';
// El verification_token viaja de tu backend al navegador. La API key NUNCA.
Identity.open({
token: verificationToken,
onComplete: ({ requestId }) => {
// No hay attach: el registro de esa identidad NACE dentro del paso,
// así que el runner avanza solo en cuanto cierra.
// Vuelve a preguntar por next cuando quieras seguir.
pollNext();
},
});Para embeber el widget hacen falta los orígenes permitidos (Ajustes › Marca › Dominios): con la lista vacía el widget no arranca. onComplete es UX, nunca autorización — el veredicto de registro se lee del lado del servidor.
Si vuelves a llamar identity_start cuando la captura de ese intento ya se envió —tu pantalla se recargó mientras se revisaba—, desde MasterApi 1.114.0 contesta status: "submitted", sin verification_token ni embed_url: no abras otra captura, la corrida espera el veredicto de la enviada y next lo dice cuando cierra. Antes cancelaba la captura enviada y la persona empezaba de nuevo. Vale mientras el envío tiene menos de 15 minutos; después contesta status: "started" como siempre.
Desde dónde se conectó la persona (network, ver la guía de KYC y KYB): en el canal API sólo cuenta el navegador de la persona. En una corrida channel: "api" ninguna ruta pública del runner registra IP —identity_start incluido: lo llama tu servidor, que no es la persona—, así que la red sale de la captura de identidad que el widget hace en su navegador o su teléfono, y se funde en la corrida cuando ese paso cierra. Sin ninguna IP, los tres controles de red del expediente quedan en null y no retienen el veredicto.
La verificación de identidad hace su trabajo completo, igual que suelta: con una INE, un pasaporte o una tarjeta de residente y un cliente con CURP coteja el documento contra RENAPO, y una INE la consulta además en la Lista Nominal, sin cargo extra. No necesitas un paso de CURP para eso; si tu flujo trae uno, ese sí se cobra.
6. Salta lo opcional
Un paso que el flujo publicó con required: false se puede saltar, igual que en la página hospedada. Es la versión autenticada de la misma ruta.
curl -X POST https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/comprobante/skip \
-H "Authorization: Bearer sk_live_xxx"
# Sólo sobre un paso con required: false. Sobre uno obligatorio responde 409.Y si nunca lo saltas, tampoco te detiene: al cerrar, los pasos opcionales sin resolver se marcan skipped con reason "skipped_optional".
7. Cierra: el expediente juzga
Cerrar es la única forma de terminar una corrida del canal API: arma el expediente con los request_id de ESA corrida —los pegados incluidos—, aplica el ajuste de cobro y cierra. La respuesta es el detalle de la corrida, con su decision, su expediente y sus cargos. Es idempotente: sobre una corrida que ya cerró responde 200 con el mismo detalle.
curl -X POST https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/close \
-H "Authorization: Bearer sk_live_xxx"{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"request_id": "66f1a2b3c4d5e6f708192a3b",
"channel": "api",
"flow_id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
"flow_version": 1,
"kind": "kyc",
"status": "approved",
"decision": "approved",
"decided_by": "rules",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"external_id": "ord_123",
"steps": [
{ "key": "datos", "tool": "form", "status": "skipped", "request_id": null,
"outcome": "pass", "reason": "customer_provided", "action": "continue",
"charged_centavos": 0 },
{ "key": "identidad", "tool": "identityVerification", "status": "done",
"request_id": "66f1a2b3c4d5e6f708192a40", "outcome": "pass",
"reason": "identity_approved", "action": "continue",
"charged_centavos": 2000 },
{ "key": "ine", "tool": "ineExtraction", "status": "done",
"request_id": "66f1a2b3c4d5e6f708192a7c", "outcome": "pass",
"reason": "ine_valid", "action": "continue", "attached": true,
"charged_centavos": 0, "tool_charged_centavos": 500 },
{ "key": "correo", "tool": "verifyEmail", "status": "done",
"request_id": "66f1a2b3c4d5e6f708192a91", "outcome": "pass",
"reason": "email_verified", "action": "continue", "attached": true,
"charged_centavos": 0, "tool_charged_centavos": 200 },
{ "key": "curp", "tool": "validateCurp", "status": "done",
"request_id": "66f1a2b3c4d5e6f708192a44", "outcome": "pass",
"reason": "curp_valid", "action": "continue", "charged_centavos": 300 },
{ "key": "listas", "tool": "checkBlacklist", "status": "done",
"request_id": "66f1a2b3c4d5e6f708192a51", "outcome": "pass",
"reason": "blacklist_clear", "action": "continue",
"charged_centavos": 1000 }
],
"expediente": {
"request_id": "66f1a2b3c4d5e6f708192a60",
"overall": "pass",
"failed": [],
"missing": []
},
"charged_centavos": 3300,
"tool_charged_centavos": 700,
"discount_credited_centavos": 0,
"estimate_centavos": 4000,
"checked_at": "2026-09-18T12:11:03.000Z"
}Cerrar no es decidir. POST …/decision es la anulación humana de una corrida que quedó en review; close es el fin normal de una corrida del canal API, y el veredicto lo siguen poniendo las reglas y el expediente. Sin expediente no se aprueba.
En un KYC de producción cuya sesión de identidad leyó una INE (y sin paso de INE), close puede contestar la corrida AÚN ABIERTA, con decision: null: el cierre espera la Lista Nominal que consulta la propia identidad, hasta 10 minutos desde su veredicto (suele llegar en uno o dos). El veredicto llega después por flow.completed o en GET /app/flows/sessions/:id; pasado el tope cierra con ine_vigente en null.
En un KYB, desde MasterApi 1.114.0, close coteja la INE del representante contra RENAPO con la CURP que la credencial imprime —reusa una validación de esa CURP o corre una incluida en la INE, sin cargo extra— y la espera unos segundos. Si sigue en curso, close contesta la corrida AÚN ABIERTA (decision: null) y el veredicto llega por flow.completed o en GET /app/flows/sessions/:id. Una consulta que falla, o que no contesta en 2 minutos, deja el expediente en revisión: un KYB nunca aprueba sin ese cruce. Desde MasterApi 1.130.0, sin INE leída, la sesión de identidad del representante (INE, pasaporte o tarjeta de residente) es su identificación si está verificada y es de la persona que nombran el poder o el acta (si no, la casilla falta y va a revisión), y close espera también su Lista Nominal; un pasaporte expedido fuera de México sin CURP, de un titular que no es mexicano, deja su cruce en null sin retener.
Una corrida que nadie cierra vence igual que cualquier otra: pasado su expires_at queda en expired, con el mismo ajuste de cobro sobre lo que alcanzaste a pegar. Cerrar es tuyo, vencer no.
Si un revisor pide repetir un paso
Una corrida que cerró en review o declined se puede reabrir con POST /app/flows/sessions/:id/resubmit, desde tu backend o desde el panel. Avisa el webhook flow.resubmitted, y next vuelve a dictar los pasos reabiertos con attempt y resubmission: { n, reason, steps }. Corres la herramienta otra vez y pegas el folio nuevo: attach exige una consulta posterior al reset_at del paso (409 stale_evidence) y el folio del intento anterior ya no entra (already_attached).
{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"status": "awaiting_user",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"done": false,
"next": {
"step_key": "ine",
"tool": "ineExtraction",
"kind": "user",
"interaction": "upload",
"required": true,
"params": {},
"attempt": 2,
"resubmission": { "n": 1, "reason": "document_unreadable", "steps": ["ine"] },
"how": "api",
"upload": {
"init": {
"method": "POST",
"path": "/app/ine-extraction/customer/6622f8a1c1d2e3f4a5b6c7d8/init",
"body": { "ine_front": "required", "ine_back": "required", "min_ocr_confidence": "optional" }
},
"complete": {
"method": "POST",
"path": "/app/ine-extraction/request/{request_id}/complete"
}
},
"attach": {
"method": "POST",
"path": "/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/ine/attach"
}
}
}
# Corre la INE otra vez y pega el folio NUEVO: uno anterior al reenvío responde 409 stale_evidence con reset_at.
# El folio del intento anterior ya no entra (already_attached). Después, close vuelve a cerrar.Después close vuelve a cerrar: el expediente juzga con la evidencia nueva y sale otro flow.completed, con resubmission_count. Aquí no hay reintento automático: tus pantallas deciden cuántas veces pedir la foto.
8. El cobro es el mismo que en los otros canales
Una herramienta que llamas tú cobra al llamarla, como cualquier consulta suelta: ese es su cargo de lista, con la misma regla de fallas que la consulta suelta. Un paso que corre la corrida cobra exactamente ese mismo cargo. Los flujos no tienen descuentos, así que un paso cobra lo mismo en los dos canales. Sin página hospedada tampoco hay pantalla «Sube tus documentos»: unused_uploads llega siempre [].
Por eso un paso pegado no deja nada que abonar al cerrar. La corrida guarda los tres números: charged_centavos (lo que cobró la corrida), tool_charged_centavos (lo que cobraron las herramientas que llamaste) y discount_credited_centavos (lo que se te abonó al cerrar: 0, salvo que un paso se haya repetido).
charged_centavos 3300 + tool_charged_centavos 700 − discount_credited_centavos 0 = 4000 = estimate_centavosExactamente lo que habría cobrado la misma corrida por la página hospedada. Los tres canales pagan lo mismo por el mismo flujo.
Repetir un paso no se cobra otra vez. Cuando un revisor reabre un paso, la herramienta que vuelves a llamar cobra su cargo de lista al llamarla y el cierre te lo abona completo, salvo cuando lo que pagó fue una consulta que falló después de una llamada con costo (su cargo base) —un documento que se leyó y después falló; desde MasterApi 1.113.0 también una INE, una identidad, una e.firma o una investigación—: entonces el siguiente intento debe la diferencia hasta su propio cargo (0 en un cargo fijo) y el cierre abona sólo lo demás. El abono se acumula entre cierres: cada cierre paga sólo la diferencia contra lo ya abonado, así que nada se abona dos veces.
En sandbox (sk_test_) todo va en cero y no hay abono: la forma de la respuesta es la misma, los números son 0.
9. KYB por API: el mismo bucle
No hay un segundo contrato para empresas. Creas el cliente moral con tu front (POST /customer con type: "moral", name, legal_name y rfc), abres la corrida del flujo kyb con channel: "api" y su customer_id, y corres el mismo bucle: next → la ruta que te dicte → attach → close.
Lo único que cambia es qué pasos salen: los documentos de la empresa llegan como how: "api" con su upload —la constancia, el acta constitutiva, el poder notarial, el comprobante de domicilio y, si tu flujo la pide, la INE del representante—, la e.firma, el RFC, el judicial de empresa y las listas son de servidor (how: "wait"), y la identidad del representante sigue siendo how: "sdk" (desde MasterApi 1.130.0, kyb_basico identifica así al representante, con INE, pasaporte o tarjeta de residente). El paso de documento trae su document_kind en params, para que tu pantalla pida el papel correcto. Al cerrar, el expediente que corre es el KYB: coteja la INE del representante contra RENAPO (ver «7. Cierra») y, desde MasterApi 1.114.0, cada control trae holds_approval, que dice si ESE control es lo que impide el pass.
La vara la sigue poniendo la política de tu expediente, y publicar valida cobertura: si al flujo le falta un paso que recolecte una casilla exigida, no publica (400 invalid_flow con code: "missing_required_component" y step_key: null). El canal API no afloja esa vara — sólo cambia quién captura.
10. Las guardas y sus errores
Viven en attach, en el paso de datos y en close, y todas dicen exactamente qué pasó. Ninguna corrige en silencio.
409 · not_api_channelLa corrida es del canal UI (channel: "hosted"). attach, close y el next del canal API sólo existen sobre una corrida abierta con channel: "api"; una corrida hospedada la corre nuestra página de punta a punta.
409 · not_current_stepEl paso que nombraste no es en el que va la corrida. La respuesta trae current_step_key: pega ese. El flujo dicta el orden; el canal API no lo salta.
404 · (el folio no es tuyo)Ese request_id no existe, es de otra organización o es del OTRO ambiente que tu llave (una consulta de sandbox no se pega a una corrida de producción, ni al revés).
409 · customer_mismatchLa consulta se corrió sobre otro cliente. Es la guarda que impide meter la INE de una persona en la corrida de otra: el cliente de la consulta y el de la corrida tienen que ser el mismo.
409 · tool_mismatchLa consulta es de otra herramienta que la que el paso espera. La respuesta trae expected y received.
409 · bundled_evidenceLa consulta la corrió otro producto por dentro (bundled_with en el registro): por ejemplo, la CURP que validó una verificación de identidad para cotejar contra RENAPO. Va incluida en ese producto y no se pega como paso: corre la herramienta del paso.
409 · log_not_terminalLa consulta todavía está corriendo. Espera a que llegue a un estado terminal (GET /app/<herramienta>/status/:requestId) y vuelve a pegarla.
409 · already_attachedEse folio ya está pegado a una corrida, o al intento anterior de un paso reabierto. Una evidencia vale en UNA corrida y en un intento: para otro, córrela otra vez.
409 · stale_evidenceLa consulta es ANTERIOR a la corrida o, en un paso que un revisor reabrió, anterior al reenvío (la respuesta trae reset_at). Evidencia vieja no entra a un alta nueva; si la quieres usar, es el expediente lo que la lee, no el flujo.
409 · not_attachable_stepEse paso no se pega. El de datos se contesta en POST …/steps/:key/form, el de representantes con su lista, y la identidad y los pasos de servidor avanzan solos. La respuesta trae how.
400 · invalid_form · 409 · not_form_step (paso de datos)invalid_form: una o más respuestas no caben en su campo; problems[] trae cada una con field, code (required | bad_type | bad_format | bad_option | out_of_range | too_long) y message, y se manda el formulario completo otra vez. not_form_step: la clave que nombraste no es la del paso de datos. También aplican session_closed y not_current_step.
409 · steps_pending (al cerrar)Quedan pasos OBLIGATORIOS sin resolver. La respuesta trae pending[] con { step_key, tool, how } para que sepas qué falta. Los opcionales no detienen el cierre: se marcan skipped con reason "skipped_optional".