Base URL and keys

Base URL
https://api.singula.mx
Authentication
Authorization: Bearer sk_live_…

Generate your key in the dashboard. Every call carries the Authorization header. The test environment (sk_test_) answers with mocked data and consumes no balance. The dashboard in «Sandbox» runs the same: it sends X-Singula-Env: sandbox and its checks are free, with sample data. A key never reads that header: its environment is the key’s.

The customer pattern

Almost every product has two shapes: direct (you send the value and get the result) and by customer (you create a file and the checks are stored on it, tied to that person or company). Each endpoint below says which is which.

A customer_id only works inside the organization that created it: one from another organization gets exactly the answer of one that does not exist, the same 404 or 409 each route returns. The risk profile (GET /app/risk/customer/:id) and its signals (…/signals) also read only the customers of your key’s environment — with sk_test_, the sandbox ones —: any other id answers empty, { profile: null, signals: [] } and { signals: [] }, just like one that does not exist.

Direct queries (no customer)

Sometimes you just want the answer: validate a phone, screen a name, read a PDF. You do not need to create a file for that. There is one convention and you read it off the route.

POST /app/<tool>

Direct query. The value goes in the body, the response comes back the same, and no customer is created or touched.

…/customer/:id

On an existing customer: it reads their stored data, writes the result on their file and runs the risk engine.

Async does not change: you still read the state at GET /app/<tool>/status/:requestId, which was never customer-scoped.

What changes and what does not
  • The charge is identical. Same amount as the customer variant, same row in your statement and in usage by service, same webhook. In sandbox (sk_test_) it returns the same mock and consumes no balance.
  • The log record says so. The query is stored with source: "direct" and customer_id: null, and you can filter by it in the dashboard activity.
  • The risk engine does not run. By design: risk is computed over a file, and here there is none.
  • You can keep it afterwards. POST /app/customers/from-request/:requestId creates the customer from that response, sets the customer_id on that record and lets the risk engine process it once. No charge.
  • They can expire. retention.direct_days in the verification policy anonymizes the content of customer-less direct queries after N days — the row is never deleted. It ships off (null).

Both shapes exist for: CURP, RFC, INE, sanction lists, judicial (person and company), email and phone lookup, digital footprint, the document engine, e.firma, SPEI tracking — which carries the transfer in the body in both — and the email code. The CLABE validator exists direct only. Still customer-only: the risk engine, the KYB dossier and identity verification — they do not exist without a file.

Files: straight to S3

No file goes through the API. A document, both sides of an INE or the photos of a verification go straight to S3 in three steps, always the same: you reserve and confirm against the API, and the file travels to S3 with a signed POST that carries no key.

1Reserve
POST …/init

JSON with the name (filename) and the type (content_type) of each file. Returns the request_id and one signed S3 POST per file, { url, fields }: it pins the type, caps the size and expires in 10 minutes.

2Upload to S3
POST upload.url

multipart/form-data to url: EVERY field first and the file last, under file. No API key. S3 answers 204 with no body. Until it expires, the same POST works for a retry.

3Confirm
POST …/complete

We check the file is in S3 and the job starts: { request_id, status: "processing" }. If it has not landed yet, 409. From here on everything is as always: the same webhook, the same status and the same charge.

Where you reserve and where you confirm
  • Document engine (and CSF, power of attorney)
    POST /app/document/init · /csf/init · /poder/init (and …/customer/:customerId/init · /csf/init · /poder/init)POST /app/document/request/:requestId/complete
  • INE (both sides in one reservation)
    POST /app/ine-extraction/init (and …/customer/:customerId/init)POST /app/ine-extraction/request/:requestId/complete
  • Photos of an identity verification
    POST /app/identity-verification/upload/:token/presignPOST …/upload/:token/confirm, then POST …/submit/:token
  • Upload step of the hosted page
    POST /app/flow-embed/:token/steps/:key/upload/initPOST /app/flow-embed/:token/steps/:key/upload/complete
End to end
# 1 · Reserve: the file name and type, as JSON. The file never goes through the API.
curl -s -X POST https://api.singula.mx/app/document/init \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "filename": "estado-de-cuenta.pdf", "content_type": "application/pdf" }' > init.json
# → { "request_id": "6622f8a1c1d2e3f4a5b6c7d8", "upload": { "url": "https://…", "fields": { … } } }

# 2 · Upload the file straight to S3 with the signed POST: EVERY field first and the
#     file last, under file. No API key; S3 answers 204 with no body.
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 .upload estado-de-cuenta.pdf
# → 204

# 3 · Confirm: we check the file is in S3 and the job starts.
curl -s -X POST "https://api.singula.mx/app/document/request/$(jq -r .request_id init.json)/complete" \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
# → { "request_id": "6622f8a1c1d2e3f4a5b6c7d8", "status": "processing" }

Step 2 runs on your server. In bash it uses jq, and --form-string sends each field as is. If S3 answers 403, the POST has expired: reserve again; if it answers 400, the file is over the cap.

Deprecated routes. The ones that took the file as multipart answer until with the Deprecation and Sunset headers, and then they are switched off. Do not use them in a new integration:

  • POST /app/ine-extraction · POST /app/ine-extraction/customer/:customerId
  • POST /app/document/extract · /app/document/csf · /app/document/poder
  • POST /app/document/customer/:customerId · …/csf · …/poder

POST /app/identity-verification/upload/:token as multipart goes away in the next version, and the multipart of the hosted page upload step (POST /app/flow-embed/:token/steps/:key/upload) no longer exists.

Async: polling and webhooks

Slower products return { request_id, status }. You get the result two ways: poll GET .../status/:requestId until status is final, or register a webhook and wait for the event. Each product below states its pattern.

Each tool’s event
Digital footprint and Digital footprint Premium
intelligence.completed

The report is done. Since 1.117.0 it is the Digital footprint event; documents and e.firma have their own (compatibility below).

Documents (extractDocument, verifyCsf, verifyPoder)
document.summarydocument.transactionsdocument.validated

By phase: document.summary is the first answer — the whole result of a single-phase document, the summary of a two-phase one, or its failure; document.transactions, a bank statement’s list of movements; document.validated, a certificate or CFDI checked against the SAT.

e.firma (checkFiel)
fiel.checked

New in 1.117.0: the e.firma (FIEL) check of a tax ID is done.

SPEI tracking (checkSpei)
spei.checked

New in 1.123.0: the trace of a transfer with Banxico is done, or it failed (status: "error").

INE (ineExtraction)
ine_extraction.completed

An INE was read and checked against the Lista Nominal.

Identity verification
identity.completedidentity.listanominal.completed

The verdict; the Lista Nominal of its INE arrives later, with its own event.

Since 1.117.0 documents and e.firma have their own events; before, they went out as intelligence.completed. The rule applies per webhook: the new types (document.summary, document.transactions, document.validated and fiel.checked) reach only a webhook explicitly subscribed to them, and one subscribed to both the old and the new receives only the new, once. One subscribed to intelligence.completed or to all events keeps receiving documents and e.firma as always, signed as intelligence.completed. That compatibility will be retired later, on an announced date: subscribe your webhooks to the new events. SPEI tracking (spei.checked, since 1.123.0) is outside it: it was born with its own event, so it reaches a webhook subscribed to spei.checked or to all events, and never as intelligence.completed.

Errors

The API uses standard HTTP codes: 2xx success, 401 invalid key, 402 no balance, 403 service locked for your account, 422 invalid input, 5xx our error. The body carries a readable message. Without your company’s approved KYB, identity verification and the lists already run; in production, the 403 comes on company judicial records, electronic signature and person judicial records outside a KYC flow (inside one, they run after the identity check). The KYB is self-service: Settings → Account verification.

38 endpoints

KYC y KYB

Un flujo es la DEFINICIÓN de tu KYC (persona física) o de tu KYB (persona moral): pasos del catálogo con una regla cada uno, publicados como versión inmutable y corridos para tus usuarios por los tres canales —link hospedado, iframe con el SDK y tu propio front por API—. Toda corrida CIERRA con su expediente —el KYC si es de persona, el KYB si es de empresa, armado con lo que esa corrida recolectó y sin cargo propio— y ese cotejo es el que decide: termina en approved, review o declined, y cobra los pasos que terminaron (y, una vez, un archivo de la página hospedada que ya se leyó y ningún paso usó). Un KYB verifica además a sus PERSONAS: el paso `representatives` abre una corrida KYC hija por cada representante, apoderado, socio o beneficiario controlador —con un flujo `kyc` publicado de la misma organización—, el paso cierra con la peor de sus decisiones y el KYB entero se cobra como uno solo.

En estos textos una CORRIDA de un flujo se llama **verificación**: es el mismo objeto y las rutas no cambian (`/sessions` sigue siendo `/sessions`, `session_id` sigue siendo `session_id`). La gestión de flujos (crear, editar, publicar, archivar) acepta el JWT de un Admin o una llave API (Authorization: Bearer <API_KEY>): la llave la emite un Admin y vale como credencial de la organización. Un JWT de usuario Normal no escribe flujos. La lectura y las sesiones también van con llave. Las rutas del runner (/app/flow-embed) NO llevan llave: el token de la sesión en la ruta es la autenticación, y van limitadas por IP. Una corrida nace en uno de dos canales (`channel`): `hosted`, en el que nuestra página corre los pasos de captura —sea por link, por QR o embebida con el SDK—, o `api`, en el que los corre tu propio front con `next`, `attach` y `close`. Las tres rutas del canal API van con llave, como el resto de las sesiones. Un link compartible (`flk_…`) abre verificaciones desde una url o un QR, también sin llave: se administra con `/app/flows/:id/links` y sus dos rutas públicas son `/app/flow-embed/link/:token`.

Catálogo de pasos

GET/app/flows/catalog

Devuelve los pasos que tu organización puede poner en un flujo, los cargos con los que se estima una corrida y qué herramientas tiene desbloqueadas. Es lo que alimenta el editor.

Los cargos que devuelve son los de TU organización (su `pricing_config`), no los de lista. El editor los usa para mostrar el costo estimado en vivo. El ejemplo es el de una cuenta sin el KYB aprobado: por eso `after_identity` trae los antecedentes de persona. El KYB es self-service, en el panel: Ajustes → Verificación de tu cuenta.

Request

No parameters.

curl -X GET https://api.singula.mx/app/flows/catalog \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
kindsstring[]

Los tipos de flujo que existen: ['kyc','kyb'].

stepsFlowStepDefinition[]

Cada paso con su `tool`, `kind` (form | user | server), `interaction` (form | kyc | upload | otp, sólo en los pasos que captura el usuario), `label`, `family`, los `kinds` de flujo que admite, sus `params` configurables, los datos que `needs` (curp, rfc, email, phone, name, legal_name) y los que `provides` a los pasos que van DESPUÉS. `provides` sólo declara lo que la plataforma LEE de verdad de esa respuesta, y hoy son tres: `ineExtraction` deja `curp`, `name` y `birth_date`; `validateCurp` deja `name` y `birth_date`; `verifyEmail` deja `email`. `identityVerification` no declara nada a propósito —un pasaporte puede no traer CURP (uno extranjero no la imprime) y una sesión que no llegó al documento no trae bloque—, y `validateCurp` no declara `curp` porque la necesita para correr. En la corrida lo que un paso deja sólo llena huecos del cliente, y en un KYB nunca datos de persona: desde 1.114.0 la CURP, los apellidos y el nacimiento que lee la INE del representante ya no se escriben en la fila de la empresa. `repeatable: true` marca los pasos que un flujo puede llevar más de una vez —el de documento y el de representantes—; repetir cualquier otro es 400 `repeated_tool`, salvo en pasos con ramas que se excluyen (desde 1.110.0): una condición `when` de cada uno lee lo mismo —el `outcome`/`action` de un mismo paso anterior, un campo del paso de datos o una columna del cliente que no sea `risk`— y ningún valor cumple las dos (p. ej. «si es extranjero» / «si es mexicano»).

pricesobject

Cargo por herramienta en centavos, ya con los precios de tu organización aplicados.

flow_pricingobject

Desde MasterApi 1.108.0 siempre sin descuentos: `{ volume_tiers: [], repeated_tool_discount_pct: 0, min_charge_centavos: 0 }`. Un paso cobra exactamente el cargo de su herramienta suelta: el de `prices` y, en un documento, su base más sus páginas extra, igual que la consulta suelta.

conditionsobject

Todo lo que puede leer una RAMA (`steps[].when`): `sources` (`step` | `form` | `customer`), `operators` (`eq`, `ne`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `exists`, `missing`), `step[]` (`{ tool, fields[] }` — por herramienta, `outcome` y `action` más las rutas de su respuesta que una condición puede leer, cada campo con su `type`, sus `options` cuando son un conjunto cerrado y su `label` para el editor), `form` (los campos CONOCIDOS que puede pedir el paso de datos; una rama `form` lee cualquier campo que ESE flujo pida, por su `name`, y los propios del cuestionario se leen de `form_data` de la corrida) y `customer` (las columnas del cliente: `type`, `curp`, `rfc`, `birth_country`, `risk`, `email`…). Es la MISMA lista con la que publicar rechaza: un campo que no salga de aquí responde `invalid_condition`, y desde 1.114.0 también un valor que el campo nunca tiene (fuera de sus `options`, de las de un select del formulario o de `true` / `false`, con `eq`, `ne`, `in` o `not_in`) y un operador que su `type` no admite: `gt`, `gte`, `lt` y `lte` sólo sobre un campo `number`, y un campo `number` sólo con cifras.

form_fieldsobject

Cómo se escribe `params.fields` del paso de datos, el CUESTIONARIO: `types` (`text`, `number`, `date`, `select`, `multiselect`, `boolean`, `email`, `phone`, `curp`, `rfc`), `known` (los once campos conocidos con su tipo fijo y, en `gender` y `birth_place`, sus opciones), `name_pattern`, `option_value_pattern`, `limits` (`max_fields` 40, `label_max_length` 80, `help_max_length` 200, `max_options` 50, `value_max_length` 500, `known_text_max_length` 200, `form_data_max_bytes` 16384) y `multiselect_operators` (`in`, `not_in`, `exists`, `missing`: lo único con que una rama o una regla comparan un multiselect).

entitlementsobject

`allowed` (las herramientas que tu estado de cuenta desbloquea: las que el editor puede agregar), `lock_reason` (por qué las demás están cerradas, cuando `allowed` llega vacío) y `after_identity` (aditivo): las de `allowed` que una cuenta SIN el KYB aprobado corre sólo después de un paso de identidad. Hoy es `getJudicialRecordsOfPerson`: publica en un flujo KYC con un paso `identityVerification` ANTES (si no, 403 `tools_locked`) y en la corrida sólo corre si esa identidad salió aprobada y el documento verificado nombra al cliente; si no, el paso queda `skipped` con `reason: "kyb_required"` y cargo 0. Sin KYB ya corren la verificación de identidad y las listas; los antecedentes de empresa siguen cerrados. Con el KYB aprobado, `after_identity` llega vacío.

Example
{
  "kinds": ["kyc", "kyb"],
  "steps": [
    { "tool": "form", "kind": "form", "interaction": "form", "label": "Datos del usuario",
      "kinds": ["kyc", "kyb"], "family": "datos",
      "params": [{ "name": "fields", "label": "Campos a pedir", "type": "multi",
                   "options": ["name", "last_name", "curp", "rfc", "email", "phone"],
                   "default": ["name", "last_name", "mothers_last_name", "curp", "email"] }] },
    { "tool": "ineExtraction", "kind": "user", "interaction": "upload", "label": "INE",
      "kinds": ["kyc", "kyb"], "family": "identidad", "params": [], "needs": [],
      "provides": ["curp", "name", "birth_date"] },
    { "tool": "validateCurp", "kind": "server", "label": "CURP ante RENAPO",
      "kinds": ["kyc"], "family": "identidad", "params": [], "needs": ["curp"],
      "provides": ["name", "birth_date"] }
  ],
  "prices": { "validateCurp": 300, "identityVerification": 2000, "checkBlacklist": 1000 },
  "flow_pricing": {
    "volume_tiers": [],
    "repeated_tool_discount_pct": 0,
    "min_charge_centavos": 0
  },
  "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"],
        "label": "Resultado del paso" },
      { "field": "risk_summary.level", "type": "enum",
        "options": ["clear", "low", "informational", "medium", "high"],
        "label": "Nivel de riesgo en listas" }
    ] }],
    "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"],
                   "label": "Tipo de cliente" }]
  },
  "form_fields": {
    "types": ["text", "number", "date", "select", "multiselect", "boolean", "email", "phone", "curp", "rfc"],
    "known": [{ "name": "curp", "type": "curp" },
              { "name": "gender", "type": "select", "options": ["H", "M", "X"] }],
    "name_pattern": "^[a-z0-9_]{2,40}$",
    "option_value_pattern": "^[a-z0-9_-]{1,40}$",
    "limits": { "max_fields": 40, "label_max_length": 80, "help_max_length": 200,
                "max_options": 50, "value_max_length": 500, "known_text_max_length": 200,
                "form_data_max_bytes": 16384 },
    "multiselect_operators": ["in", "not_in", "exists", "missing"]
  },
  "entitlements": {
    "allowed": ["validateCurp", "identityVerification", "checkBlacklist", "getJudicialRecordsOfPerson"],
    "lock_reason": null,
    "after_identity": ["getJudicialRecordsOfPerson"]
  }
}

Listar flujos

GET/app/flows

Los flujos de tu organización, con su estado, su versión publicada y el cargo estimado de una corrida.

Request
kindstring (enum: kyc|kyb)query

Filtra por tipo de flujo. Sin él, vienen los dos.

curl -X GET https://api.singula.mx/app/flows \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
itemsFlowSummary[]

`{ id, name, kind, status, version, published_at, steps_count, estimate_centavos, requires_customer, updated_at, created_by, last_run_at, sessions_count }`.

items[].statusstring (enum: draft|published|archived)

`draft` nunca ha publicado; `published` tiene una versión viva; `archived` no admite sesiones nuevas.

items[].estimate_centavosnumber

El cargo de una corrida completa de la versión publicada, en centavos: la suma del cargo de cada herramienta.

items[].requires_customerboolean

`true` cuando algún paso de servidor necesita un dato que el formulario no pide: abrir una sesión sin `customer_id` responde 400 con `code: "customer_required"`.

items[].created_byobject | null

Quién creó el flujo, como objeto: `{ kind: "user" | "api_key" | "mcp", id, label, email? }`. `null` cuando no se pudo resolver.

items[].last_run_atstring (ISO 8601) | null

Cuándo se abrió la última sesión de este flujo. `null` si nunca corrió.

items[].sessions_countnumber

Cuántas corridas acumula el flujo.

Example
{
  "items": [
    {
      "id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
      "name": "Alta de persona",
      "kind": "kyc",
      "status": "published",
      "version": 3,
      "published_at": "2026-09-12T18:20:00.000Z",
      "steps_count": 4,
      "estimate_centavos": 3300,
      "requires_customer": false,
      "updated_at": "2026-09-12T18:20:00.000Z",
      "created_by": { "kind": "user", "id": "64a1...", "label": "Ana Torres", "email": "[email protected]" },
      "last_run_at": "2026-09-15T12:00:00.000Z",
      "sessions_count": 128
    }
  ]
}

Crear un flujo

POST/app/flows

Crea el borrador. Nace en `draft` y `version: 0`; hasta que no se publica, no corre ninguna sesión.

Escribir flujos (crear, editar, publicar, archivar) es de Admin: una llave API o el JWT de un Admin. La validación fuerte corre al publicar, no al crear: un borrador puede quedar a medias a propósito. La excepción es una herramienta repetida (desde 1.108.0): guardar la rechaza con 400 `{ code: "repeated_tool", message, problems: [{ step_key, code: "repeated_tool", message }] }` —un problema por paso repetido— y no escribe nada.

Request
namestringbodyrequired

Nombre del flujo, el que ve tu equipo en el panel y el que sale en el estado de cuenta.

kindstring (enum: kyc|kyb)bodyrequired

`kyc` para persona física, `kyb` para persona moral. Decide qué pasos del catálogo admite el flujo.

stepsFlowStepConfig[]bodyrequired

Los pasos en orden. Cada uno: `key` (única, `^[a-z0-9_]{2,40}$`), `tool` (del catálogo, o `form`), `required` (bool), `params` (los del catálogo, se normalizan), `on_fail` (decline | review | continue), `on_review` (review | continue), `on_error` (decline | review | continue), `rules` opcionales y `when` opcional (la RAMA: las condiciones que deciden si ese paso corre). Cada herramienta va UNA sola vez por flujo: sólo se repiten el paso de documento (`extractDocument`), con cualquier `document_kind`, el de representantes (`representatives`) de un KYB y la misma herramienta en ramas que se excluyen —una condición `when` de cada paso lee lo mismo y ningún valor cumple las dos, p. ej. «si es extranjero» / «si es mexicano»—; si no, 400 `repeated_tool`.

steps[].whenFlowCondition[]body

La RAMA del paso: `[{ source: "step" | "form" | "customer", key, field?, op, value? }]`. `key` es la clave del paso ANTERIOR, el campo del formulario o la columna del cliente; `field` sólo existe con `source: "step"` y vale `outcome` (el default), `action` o una ruta con puntos dentro de la respuesta de esa herramienta; `op` es uno de `eq`, `ne`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `exists`, `missing`. Entre condiciones es **Y**; el **O** se escribe con `in`. Sin `when`, el paso siempre corre. Un valor ausente sólo casa con `missing`, así que `ne` y `not_in` nunca se cumplen sobre un dato que no llegó. El paso cuya rama no abre queda `skipped` / `pass` / `condition_not_met` / `continue` y **cobra 0**, y eso MANDA sobre `required`. Qué campos se pueden leer —con su `type` y, si son un conjunto cerrado, sus `options`— lo publica `conditions` en `GET /app/flows/catalog` (desde MasterApi 1.130.0 también `verification.document_type` de un paso de identidad: `INE`, `PASSPORT` o `RESIDENT_CARD`, el documento que leyó; así una plantilla corre la INE contra la Lista Nominal sólo cuando la persona se identificó con INE); publicar rechaza cualquier otro con `invalid_condition`, y desde 1.114.0 también un valor que el campo nunca tiene (`verification.status eq approved`: sus valores son verified, partial, rejected y failed) y un tipo equivocado (`gt`, `gte`, `lt` y `lte` sólo sobre un campo numérico; un campo numérico sólo con cifras). El mensaje nombra el paso y lo que sí se puede escribir. Como todas las ramas se cotizan, el estimado pasa a ser un máximo (`estimate_is_maximum`). Una rama `form` sobre un campo propio del cuestionario lee `form_data` de la corrida; un multiselect sólo se compara con `in` / `not_in` (por intersección), `exists` o `missing`.

steps[].params.fields(string | FlowFormFieldConfig)[]body

Sólo en el paso de datos (`tool: "form"`): el CUESTIONARIO. Cada entrada es el nombre de un campo conocido (`name`, `last_name`, `mothers_last_name`, `legal_name`, `curp`, `rfc`, `email`, `phone`, `birth_date`, `gender`, `birth_place`), que es obligatorio y guarda los mismos bytes que antes, o un objeto `{ name, type, label?, help?, required?, options?, min?, max?, maps_to? }`. `name`: `^[a-z0-9_]{2,40}$`, único en el paso. `type`: `text` | `number` | `date` | `select` | `multiselect` | `boolean` | `email` | `phone` | `curp` | `rfc`. `label` y `help`: `{ es?, en? }`, hasta 80 y 200 caracteres (sin `label`, la del catálogo). `required`: `true` por default. `options` (sólo `select` y `multiselect`): de 1 a 50 `{ value (^[a-z0-9_-]{1,40}$), label? }`. `min` / `max`: en `number` acotan el valor, en `text` el largo (hasta 500; 200 si llena al cliente) y en `multiselect` cuántas opciones. `maps_to`: un campo conocido —llena esa columna del cliente, con el MISMO tipo que ese campo y sin que otro campo la llene— o `null`, un campo PROPIO cuya respuesta va a `form_data` de la corrida y nunca al cliente ni al webhook. Sin `maps_to`, un `name` conocido es ese campo y cualquier otro es propio. Los tipos de los conocidos son fijos: `curp`, `rfc`, `email` y `phone` el de su nombre, `birth_date` `date`, `gender` un `select` de `H` / `M` / `X`, `birth_place` un `select` de claves de estado de RENAPO y los nombres `text`. Hasta 40 campos; no hay expresiones regulares del autor. Un campo mal armado es `invalid_form_field` al publicar. El cuestionario no cobra ni mueve el estimado.

steps[].rulesFlowRule[]body

`[{ field, op, value?, outcome }]`: la primera regla que casa fuerza el `outcome` del paso, y de ahí manda su `on_fail` / `on_review`. En un paso de herramienta `field` es una ruta de su respuesta (`risk_summary.level`); una regla nunca toca un `error` ni un cruce crítico (`cross_ref_*`). En el paso de datos una regla SÓLO lee `answers.<campo de ese formulario>` —p. ej. `{ "field": "answers.es_pep", "op": "eq", "value": true, "outcome": "review" }`— y el paso sale con `reason: "rule:form_submitted"`; cualquier otro `field` es `invalid_rule` al publicar, igual que un multiselect comparado con algo distinto de `in`, `not_in`, `exists` o `missing`.

settingsobjectbody

`language` (es | en), `expires_in_hours` (1..720, default 168), `redirect_url` (https obligatorio, a dónde mandar a la persona al terminar), `expose_decision_to_user` (bool, default **false**: apagado, la página y el SDK no ven el veredicto — el runner público contesta `status: "completed"` y `decision: null`), `evidence_max_age_hours` (1..8760, default **720** = 30 días: la ventana dentro de la cual una consulta terminal del mismo cliente se puede reutilizar al abrir una corrida con `reuse_evidence`; sólo se guarda si la elegiste), `ui`, la apariencia del flujo, y `canvas`, las posiciones del editor de lienzo.

settings.uiobjectbody

Cómo se ve la corrida. `theme` (`primary` obligatorio y `secondary`, `background`, `text` opcionales, todos `#RRGGBB`; `radius`: sm | md | lg = 8 | 12 | 16 px; `font`: sans | serif | mono), `logo_url` (https, ≤ 2 KB; `null` = el de la organización), `layout` (`card` centrada, `full` a pantalla completa o `bare` —sin marco y sin fondo, para embeber la captura dentro de tu propia pantalla—, default `card`), `chrome` (las SIETE piezas de nuestra página que se pueden apagar, todas `true` por default: `header` —logo + nombre—, `brand_name`, `progress` —la barra o el rail de pasos—, `titles`, `bodies` —los textos de apoyo—, `legal` —el pie legal / «Secured by»— y `background`: `canvas` | `transparent`), `document_types` (de `ine`, `passport` y `resident_card`: el default del paso de identidad cuando el paso no fija los suyos; sin él, los tres; si la plataforma deja de ofrecer la tarjeta, 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`) y `texts`: `{ <pantalla>: { es | en: { title?, body?, cta? } } }` con `welcome`, `form`, `doc_selection`, `doc_capture`, `liveness_intro`, `liveness`, `verifying`, `upload`, `otp`, `result_approved`, `result_review`, `result_declined` y `result_done`. Se valida y normaliza al guardar: un color fuera de `#RRGGBB` se rechaza, cada texto se acota a 200 caracteres y las claves desconocidas se van. El `chrome` se normaliza igual —sólo sobreviven las banderas que mandaste y que son booleanas, y sólo un `background` del catálogo— y un flujo que no lo tocó guarda `null`, NO `{}`: «no dijo nada» y «lo dejó prendido» tienen que seguir siendo distinguibles. Al publicar, una bandera que no es `true` ni `false` sale como `invalid_ui`, igual que un color inválido. Sin `settings.ui` la corrida se ve exactamente como hoy.

settings.canvasobject | nullbody

Dónde queda cada paso en el editor de lienzo: `{ nodes: { <clave del paso>: { x, y } }, viewport?: { x, y, zoom } }`. Es apariencia —no toca pasos, cargos ni veredicto— y se congela con la versión, igual que `ui`. Se normaliza al guardar: números finitos, claves que existan entre los pasos de ESE flujo, hasta 200 nodos y `zoom` entre 0.1 y 4; lo que no pase se descarta, y un lienzo sin un solo nodo válido queda en `null`. Sin `canvas`, el editor auto-acomoda.

curl -X POST https://api.singula.mx/app/flows \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -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,
    "ui": {
      "theme": { "primary": "#FF6600", "secondary": "#111827", "radius": "md", "font": "sans" },
      "logo_url": "https://acme.mx/logo.svg",
      "layout": "card",
      "document_types": ["ine", "passport"],
      "texts": {
        "welcome": { "es": { "title": "Verifica tu identidad", "cta": "Empezar" } }
      }
    }
  }
}'
Response
idstring

Id del flujo. Se usa en todas las rutas siguientes.

statusstring

Siempre `draft` al crear.

versionnumber

Siempre 0 al crear. Sube en cada publicación.

estimate_centavosnumber

Lo que costaría una corrida de este borrador: la suma del cargo de cada herramienta.

Example
{
  "id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
  "name": "Alta de persona",
  "kind": "kyc",
  "status": "draft",
  "version": 0,
  "published_at": null,
  "steps_count": 4,
  "estimate_centavos": 3300,
  "versions_count": 0
}

Plantillas de flujo

GET/app/flows/templates

Los flujos ya armados de los que puedes partir, con el cargo estimado a los precios de tu organización. Son fijos: se leen, no se editan — lo que se edita es el borrador que creas a partir de uno.

Las plantillas viven en el código de la API, no en tu organización: nadie las edita ni las borra desde aquí. Tool MCP: `listFlowTemplates`.

Request
kindstring (enum: kyc|kyb)query

Filtra por tipo de flujo. Sin él, vienen los dos.

curl -X GET https://api.singula.mx/app/flows/templates \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
itemsFlowTemplate[]

`{ key, kind, name, description, vertical?, steps, settings?, estimate_centavos }`.

items[].keystring

La clave con la que se crea el borrador: `kyc_basico`, `kyb_basico` y una por vertical (`fintech`, `proptech`, `hr`, `ecommerce`, `marketplaces`, `legaltech`, `insurtech`).

items[].nameobject

`{ es, en }` — el nombre en los dos idiomas. La plantilla se escribe una vez y la lee quien la lee.

items[].descriptionobject

`{ es, en }` — qué recolecta el flujo, en una línea.

items[].verticalstring | null

La página de soluciones que la recomienda. Ausente en las dos plantillas base.

items[].stepsFlowStepConfig[]

Los pasos en orden, con la misma forma que manda `POST /app/flows`. Cada plantilla cubre la política por defecto de su tipo, así que el borrador que sale de ella publica sin huecos. 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, el paso de CURP corre sólo con CURP y la INE contra la Lista Nominal sólo si la identidad leyó una INE; `kyb_basico` identifica al representante con la verificación de identidad (`identidad_representante`) 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 (`curp` falta): la identidad no copia su CURP al cliente y sólo un pasaporte extranjero la excusa. Los flujos que ya creaste no cambian.

items[].estimate_centavosnumber

Lo que costaría una corrida de esa plantilla, en centavos: la suma del cargo de cada herramienta, a TUS precios.

items[].locked_toolsToolType[]

Las herramientas de la plantilla que el estado de tu cuenta TODAVÍA tiene cerradas. Crear el borrador se permite siempre; publicarlo responde 403 `tools_locked` con esta misma lista. Sin el KYB aprobado sólo `legaltech` trae algo (`getJudicialRecordsMoral`, los antecedentes de empresa): las plantillas KYC ya ponen la identidad antes de los antecedentes de persona.

items[].publishableboolean

`locked_tools` vacío: tu cuenta puede publicar la plantilla tal cual.

Example
{
  "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"
            ]
          }
        },
        {
          "key": "identidad",
          "tool": "identityVerification",
          "required": true,
          "on_fail": "decline",
          "on_review": "review"
        },
        {
          "key": "curp",
          "tool": "validateCurp",
          "required": true,
          "on_fail": "review"
        },
        {
          "key": "listas",
          "tool": "checkBlacklist",
          "required": true,
          "on_fail": "decline"
        }
      ],
      "settings": {
        "language": "es",
        "expires_in_hours": 168
      },
      "estimate_centavos": 3300,
      "locked_tools": [],
      "publishable": true
    }
  ]
}

Crear un flujo desde una plantilla

POST/app/flows/from-template/:key

Crea un BORRADOR con los pasos y los ajustes de la plantilla. Nace igual que cualquier otro flujo —`draft`, `version: 0`— y desde ahí se edita y se publica.

Mismos permisos de escritura que `POST /app/flows`: llave API o JWT de Admin. 404 `template_not_found` cuando la clave no existe. Crear desde una plantilla no publica nada ni cobra nada — lo que cobra es una corrida. Tool MCP: `createFlowFromTemplate`.

Request
keystringpathrequired

La `key` de la plantilla, tal como la devuelve `GET /app/flows/templates`. 404 si no existe.

namestringbody

Nombre del flujo nuevo. Omitido, el borrador toma el nombre de la plantilla.

curl -X POST https://api.singula.mx/app/flows/from-template/{key} \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Alta de cuenta"
}'
Response
idstring

Id del flujo nuevo. Se usa en todas las rutas de gestión.

stepsFlowStepConfig[]

Los pasos que copió de la plantilla, ya como borrador editable.

statusstring

Siempre `draft`: una plantilla NO publica por su cuenta.

estimate_centavosnumber

Lo que costaría una corrida de este borrador: la suma del cargo de cada herramienta.

Example
{
  "id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
  "name": "Alta fintech",
  "kind": "kyc",
  "status": "draft",
  "version": 0,
  "published_at": null,
  "steps_count": 4,
  "estimate_centavos": 3300,
  "versions_count": 0
}

Detalle de un flujo

GET/app/flows/:id

El flujo completo: el borrador que se edita y, aparte, los pasos de la última versión publicada.

Request
idstringpathrequired

Id del flujo.

curl -X GET https://api.singula.mx/app/flows/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
stepsFlowStepConfig[]

Los pasos del BORRADOR, cada uno con su `when` cuando lleva RAMA (`[{ source, key, field?, op, value? }]`). Es lo que edita `PUT /app/flows/:id`.

published_stepsFlowStepConfig[] | null

Los pasos congelados de la última versión publicada. `null` mientras el flujo nunca haya publicado.

settingsobject

`language`, `expires_in_hours`, `redirect_url`, `expose_decision_to_user`, `evidence_max_age_hours` (1..8760, default 720), `ui` (la apariencia: `theme`, `logo_url`, `layout` —`card` | `full` | `bare`—, `chrome`, `document_types`, `texts`) y `canvas` (las posiciones del editor de lienzo: `{ nodes: { <clave del paso>: { x, y } }, viewport }`, o `null` si el flujo nunca abrió el lienzo). Son los del BORRADOR; publicar congela los suyos en la versión. Un `ui` o un `canvas` mal formados salen aquí en `problems` como `invalid_ui` e `invalid_canvas`, y una rama inválida como `invalid_condition`.

versions_countnumber

Cuántas versiones se han publicado.

problems{ step_key, code, message }[]

Lo que todavía impide publicar el BORRADOR, con los mismos códigos del 400 `invalid_flow` de publicar (ver sus notas), para que el editor lo avise antes. Desde 1.114.0 también `invalid_condition` con un valor que el campo nunca tiene o un operador que su tipo no admite —el `message` nombra el paso y los valores válidos— y `form_cannot_create_customer`: el paso de datos no pide, obligatorios, `name` y `last_name` (KYC) o `legal_name` o `name` (KYB).

Editar el borrador

PUT/app/flows/:id

Cambia el nombre, los pasos o los ajustes del BORRADOR. Nunca toca una versión publicada ni una sesión abierta.

Un flujo publicado se sigue editando: lo que cambias es el borrador. Desde MasterApi 1.131.0, al publicar, una corrida de la página (`channel: hosted`) toma la versión nueva al volver a cargarse: entera si no había empezado nada; a medio camino, sólo la apariencia y los parámetros de los pasos que no ha hecho, cuando los pasos siguen iguales (claves, herramientas, orden, ramas y reglas), y nunca si le cuesta más. Las corridas del canal `api` terminan con la versión con la que empezaron.

Request
idstringpathrequired

Id del flujo.

namestringbody

Nombre nuevo.

stepsFlowStepConfig[]body

La lista completa de pasos del borrador, en orden. Reemplaza a la anterior. Cada paso acepta `when`, su RAMA: el paso sólo corre si se cumplen TODAS sus condiciones. Unos `steps` que repiten una herramienta (salvo `extractDocument`, `representatives` y la misma herramienta en ramas que se excluyen) responden 400 `repeated_tool` y el borrador no cambia; un cambio sin `steps`, como renombrar, pasa aunque el borrador guardado repita.

settingsobjectbody

Los mismos ajustes que al crear, `settings.ui` (con su `chrome`), `settings.evidence_max_age_hours` y `settings.canvas` incluidos: la apariencia, el marco y las posiciones del lienzo se editan en el borrador y se congelan al publicar.

curl -X PUT https://api.singula.mx/app/flows/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
versionnumber

No cambia al editar: sólo `POST /app/flows/:id/publish` la sube.

Publicar una versión

POST/app/flows/:id/publish

Valida el borrador y lo congela: `version` sube uno y `published_steps` queda inmutable. Las sesiones nuevas corren esa versión.

400 `{ code: "invalid_flow", problems: [{ step_key, code, message }] }` cuando: no hay ni un paso; una `key` se repite o no cumple `^[a-z0-9_]{2,40}$`; un `tool` no existe en el catálogo o no admite el `kind`; hay más de un paso `form`, el `form` no va primero o no pide ni un campo; una herramienta se repite (`repeated_tool`: sólo `extractDocument` y `representatives` van más de una vez, y así sale un borrador guardado antes de 1.108.0; desde 1.110.0 también pasa la misma herramienta en ramas que se excluyen, probadas con `eq` de 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 —nunca en un multiselect—, `in` contra un `not_in` que lo cubre o rangos `gt`/`gte` contra `lt`/`lte` que no se tocan; lo que no se prueba se rechaza y el mensaje nombra la salida); o un `on_fail` / `on_review` / `on_error` trae un valor que no existe. Un `on_fail` / `on_review` / `on_error` inválido se RECHAZA, nunca se corrige en silencio. TAMBIÉN valida COBERTURA del expediente: cada casilla que exige la política de tu organización para ese tipo (`kyc.required_components` / `kyb.required_components`) tiene que tener un paso que la recolecte —identity ⇐ identityVerification, ine ⇐ ineExtraction, curp ⇐ validateCurp, rfc ⇐ validateRfc, comprobante ⇐ extractDocument, email ⇐ verifyEmail, phone ⇐ checkPhone*, blacklist ⇐ checkBlacklist, judicial ⇐ getJudicialRecordsOfPerson; en KYB csf ⇐ verifyCsf, acta y comprobante ⇐ extractDocument, poder ⇐ verifyPoder, ine ⇐ ineExtraction, fiel ⇐ checkFiel, email ⇐ verifyEmail, judicial ⇐ getJudicialRecordsMoral, rfc ⇐ validateRfc, identity ⇐ identityVerification—. Si falta alguna, 400 `invalid_flow` con un problema por casilla: `{ step_key: null, code: "missing_required_component", message }` — `step_key` va en `null` porque el hueco es del flujo entero, no de un paso. `GET /app/flows/:id` publica la MISMA lista en `problems`, para que el editor lo avise antes de publicar. El expediente NO es un paso: un borrador que todavía traiga `kybExpediente` o `kycExpediente` se normaliza sin él, y si sobreviviera, publicar responde `code: "expediente_is_not_a_step"`. El paso de datos (`form`) es SIEMPRE obligatorio: mandarlo con `required: false` no rompe la publicación, se ignora y queda en `true`. 403 `{ code: "tools_locked", locked_tools: ToolType[], reason }` cuando un paso usa una herramienta que tu estado de cuenta no desbloquea. Sin el KYB aprobado, eso son los antecedentes de empresa (`getJudicialRecordsMoral`) y los de persona (`getJudicialRecordsOfPerson`) sin un paso `identityVerification` ANTES en el flujo KYC; con la identidad arriba publican, y en la corrida los compuerta esa identidad (ver `after_identity` en el catálogo). `reason` te manda al KYB, en Ajustes → Verificación de tu cuenta. PRE-EXISTENCIA (`unmet_need`): un `need` se satisface si lo pide el formulario o si lo `provides` un paso **ANTERIOR** —el orden es todo: una INE arriba de la CURP la desbloquea, la misma INE abajo no, y un paso nunca se satisface a si mismo—, y **sólo cuenta como proveedor un paso que SIEMPRE corre**: `required: true` y sin `when`. Uno opcional lo salta quien cierra por API o la persona, y uno condicional puede no abrir nunca, así que contarlos publicaba `datos → INE(opcional) → CURP` sin exigir `customer_id` y la corrida en la que la INE no pasaba llegaba a RENAPO con la fila vacía; el mensaje del problema dice que el arreglo es hacer obligatorio ese paso. Si el flujo TIENE paso de datos y algo queda sin cubrir, publicar lo RECHAZA: 400 `invalid_flow` con `{ code: "unmet_need", step_key, need, providers }`, y `GET /app/flows/:id` lo reporta en `problems`, junto al paso. Si el flujo NO tiene paso de datos no es un error: ese solo se abre sobre un `customer_id` y sus datos salen de esa fila. `requires_customer` cuenta ahora los pasos que PRODUCEN el dato, asi que un flujo `datos → INE → CURP` cuyo formulario no pide la CURP ya se abre sin `customer_id`; `missing_needs[]` no cambio —sigue diciendo que no pide el formulario— y se exige al abrir la sesion, 400 `{ code: "customer_required" }` si va sin `customer_id`. El paso `representatives` suma TRES codigos mas al mismo 400 `invalid_flow`: `bad_child_flow` (el `child_flow_id` no existe, es de otra organizacion, esta archivado o sin publicar, o es un flujo `kyb` — tiene que ser un `kyc` PUBLICADO de la misma organizacion), `nested_children` (ese flujo hijo trae su propio paso de representantes: solo hay UN nivel) y `bad_people_range` (`min` por encima de `max`). Los tres llegan con el `step_key` del paso. Publicar un KYB con representantes vuelve su estimado un «desde»: la linea del paso vale `min` × una corrida del flujo hijo, marcada `is_minimum`, y el resumen publica `estimate_is_minimum: true`. Una RAMA inválida suma `invalid_condition` al mismo 400 `invalid_flow`, con el `step_key` del paso que la trae: 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; o trae una fuente o un operador que no existen, un operador que necesita valor y llega sin él, o un `in` / `not_in` con la lista vacía. Desde 1.114.0 también es `invalid_condition` un VALOR que el campo nunca tiene —contra las `options` del catálogo, las de un select del formulario o `true` / `false`, con `eq`, `ne`, `in` o `not_in`: `verification.status eq approved` nunca abría, porque sus valores son verified, partial, rejected y failed, y el paso se saltaba sin cargo en cada corrida— y un TIPO equivocado: `gt`, `gte`, `lt` y `lte` sólo ordenan un campo numérico (`verification.status gt 0` u `outcome lte 1` nunca abrían), y un campo numérico sólo se compara con cifras (`"noventa"` contra un campo `number` del formulario). El `message` nombra el paso y lo que sí se puede escribir. Los campos que sí valen los publica `conditions` en `GET /app/flows/catalog`. Un lienzo mal formado sale como `invalid_canvas`, igual que `invalid_ui`: es apariencia y se descarta lo que no pasa la normalización. Un flujo con ramas publica su estimado como MÁXIMO: se cotizan TODAS (`lines[].is_conditional`) y el resumen trae `estimate_is_maximum: true` — ese es el saldo que retiene el 402 al abrir la sesión, para que una corrida que toma todas las ramas no se quede a la mitad. El CUESTIONARIO del paso de datos suma dos códigos al mismo 400 `invalid_flow`, con el `step_key` del paso: `invalid_form_field`, un problema por cada campo mal armado de `params.fields` (nombre, tipo, etiqueta, opciones o `min` / `max` fuera de forma; un `maps_to` que no es un campo conocido o de otro tipo; dos campos que llenan la misma columna; más de 40 campos), e `invalid_rule`, 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`. Nada se corrige en silencio: una lista de nombres de antes publica igual que siempre. Desde 1.114.0 el paso de datos suma un tercer código, `form_cannot_create_customer`: 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—, así que toda corrida que se abre sin cliente (un link, un QR) se detendría en el primer paso. Las reglas de 1.114.0 aplican al publicar: una versión ya publicada sigue corriendo como se congeló. Desde 1.131.0 cada problema trae también `step_index`, `step_label` y `hint` (cómo arreglarlo, en palabras de persona) y, cuando aplica, `field`, `subcode`, `values`, `valid`, `related_step_key` y `related_step_index`: con eso el panel te lleva al paso y te dice qué cambiar.

Request
idstringpathrequired

Id del flujo.

curl -X POST https://api.singula.mx/app/flows/{id}/publish \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
statusstring

Pasa a `published`.

versionnumber

La versión recién creada.

published_stepsFlowStepConfig[]

Los pasos congelados de esa versión.

Example
{
  "id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
  "name": "Alta de persona",
  "kind": "kyc",
  "status": "published",
  "version": 1,
  "published_at": "2026-09-15T12:00:00.000Z",
  "steps_count": 4,
  "estimate_centavos": 3300,
  "versions_count": 1
}

Archivar un flujo

POST/app/flows/:id/archive

Deja de admitir sesiones nuevas. Las abiertas terminan normal y el histórico se conserva.

Request
idstringpathrequired

Id del flujo.

curl -X POST https://api.singula.mx/app/flows/{id}/archive \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
statusstring

Pasa a `archived`.

Versiones publicadas

GET/app/flows/:id/versions

El historial de versiones, con los pasos con los que se congeló cada una. Es lo que hace auditable una corrida vieja.

Request
idstringpathrequired

Id del flujo.

curl -X GET https://api.singula.mx/app/flows/{id}/versions \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
[].versionnumber

Número de la versión.

[].published_atstring (ISO 8601)

Cuándo se publicó.

[].stepsFlowStepConfig[]

Los pasos exactos con los que corrió.

[].estimate_centavosnumber

Lo que costaba una corrida de esa versión.

Estimar una verificación

GET/app/flows/:id/estimate

El cargo de una corrida completa de una versión, renglón por renglón. Es el número que `CreditGuard` exige como saldo al abrir una sesión.

Es una estimación de la corrida COMPLETA. Lo que se cobra de verdad es la suma de los pasos que terminaron —más, en la página hospedada, cada archivo de «Sube tus documentos» que ya se leyó y ningún paso usó, una vez (`unused_uploads` de la corrida)—: un abandono a medio flujo paga menos.

Request
idstringpathrequired

Id del flujo.

versionnumberquery

Qué versión estimar. Sin él, la publicada.

curl -X GET https://api.singula.mx/app/flows/{id}/estimate \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
total_centavosnumber

La suma de los renglones, en centavos.

volume_discount_pctnumber

Siempre 0: desde 1.108.0 los flujos no tienen descuento por volumen.

repeated_tool_discount_pctnumber

Siempre 0: desde 1.108.0 una herramienta cobra lo mismo cada vez, y en un flujo sólo se repiten el paso de documento, el de representantes y una herramienta en ramas que se excluyen (el estimado cuenta todas las ramas; una corrida cobra sólo la que corrió).

billable_stepsnumber

Cuántos pasos de la versión cobran (los de cargo 0 y el formulario no cuentan).

linesobject[]

Un renglón por paso: `{ key, tool, base_centavos, repeated, centavos, is_minimum, is_conditional }`. Desde 1.108.0 `centavos` es siempre igual a `base_centavos`, el cargo de la herramienta suelta, y `repeated` ya no lo cambia; un documento se cotiza a su base y al correr suma sus páginas extra, como la consulta suelta. `is_conditional` marca un paso detrás de una RAMA (`when`): se cotiza aunque quizá no corra.

estimate_is_maximumboolean

`true` cuando algún paso va detrás de una RAMA y cobra: todas las ramas se cotizaron, así que el total es un TECHO — «hasta» ese cargo, según lo que contesten. Es el número que `CreditGuard` exige al abrir la sesión (402), para que una corrida que toma todas las ramas no se quede a la mitad. Una rama que no cobra —el paso de datos— no vuelve el total un «hasta».

estimate_is_minimumboolean

`true` cuando algún renglón es un PISO: el paso `representatives` se cotiza a `min` personas y cada persona de más cobra sus propios pasos al correr.

Example
{
  "total_centavos": 3300,
  "volume_discount_pct": 0,
  "repeated_tool_discount_pct": 0,
  "billable_steps": 3,
  "lines": [
    { "key": "datos", "tool": null, "base_centavos": 0, "repeated": false, "centavos": 0 },
    { "key": "identidad", "tool": "identityVerification", "base_centavos": 2000, "repeated": false, "centavos": 2000 },
    { "key": "curp", "tool": "validateCurp", "base_centavos": 300, "repeated": false, "centavos": 300 },
    { "key": "listas", "tool": "checkBlacklist", "base_centavos": 1000, "repeated": false, "centavos": 1000 }
  ]
}

Abrir una verificación (sesión)

POST/app/flows/:id/sessions

Crea una corrida de la versión publicada y devuelve el link, el token y la url para embeber. Exige flujo publicado y saldo mayor o igual a la estimación.

402 cuando el saldo es menor que `estimate_centavos` (el sandbox no lo pide). 403 cuando `kyc` / `kyb` no está desbloqueado para tu estado de cuenta. El token se acuña en tu servidor: nunca mandes la API key al navegador. Con `channel: "api"` la respuesta trae además `channel` y `next`, y sin `customer_id` responde 400 `{ code: "customer_required" }`. Con `reuse_evidence: true` —que también exige `customer_id`— la respuesta trae `reused[]` con lo que se tomó del historial de ese cliente y `missing[]` con lo que falta y cómo se resuelve; el reuso se resuelve ANTES del estimado, así que `estimate_centavos` (y el 402) sólo cubren lo que falta por correr. Si el veredicto de un paso reusado RECHAZA, la corrida cierra en la misma apertura: no arranca ningún paso, no cobra nada y `next` llega en `null`. El recorrido completo del canal API está en https://singula.mx/docs/kyc-api.

Request
idstringpathrequired

Id del flujo.

channelstring (enum: hosted|api)body

Quién corre los pasos de captura. `hosted` (el default) es nuestra página: el link, el QR y el iframe del SDK son todos `hosted`, aunque la sesión la abras desde tu backend. `api` es el canal en el que tu propio front los corre: tú muestras tus pantallas, llamas la herramienta de cada paso sobre tu cliente y pegas el folio con `attach`. `api` EXIGE `customer_id` —400 `{ code: "customer_required" }` si falta— y el paso `form` de la versión publicada se resuelve solo: `status: "skipped"`, `outcome: "pass"`, `reason: "customer_provided"`, sin cargo. La excepción es un formulario con campos PROPIOS (el cuestionario): ninguna fila los contesta, así que el paso queda pendiente, `next` lo dicta con `interaction: "form"` e `input_contract`, y se contesta en `POST /app/flows/sessions/:id/steps/:key/form`.

customer_idstringbody

Corre el flujo sobre un cliente que ya existe, en vez de crearlo desde el formulario. Obligatorio cuando la versión publicada exige `requires_customer`, y SIEMPRE con `channel: "api"`.

reuse_evidencebooleanbody

Default `false`. Con `true`, los pasos cuya consulta ya existe para ESE cliente nacen `done` con ese folio, `reused: true`, `charged_centavos: 0` y `tool_charged_centavos: 0`. Se reutiliza una consulta **terminal** (`success` / `completed`) del **mismo cliente**, del **mismo ambiente**, de la **misma herramienta** y —en el paso genérico de documentos— del **mismo tipo de documento**, dentro de `settings.evidence_max_age_hours` (1..8760, default 720 = 30 días). Una consulta se gasta una vez y una consulta sin fecha nunca es fresca. **EXIGE `customer_id`**: sin fila no hay de quién buscar evidencia (400 `{ code: "customer_required" }`). Funciona en los dos canales. El registro reusado NO se re-sella —conserva la corrida que de verdad lo recolectó— y NO abona nada al cerrar: se pagó en su propia llamada. El veredicto del paso sale del registro con la política de verificación de tu organización, así que los cruces automáticos cortan igual sobre evidencia reusada.

external_idstringbody

Tu propio identificador (una orden, un expediente). Vuelve tal cual en el detalle y en el webhook.

metadataobjectbody

Objeto plano tuyo, máximo 2 KB. Se devuelve sin tocar.

languagestring (enum: es|en)body

Idioma de la página hospedada. Pisa el del flujo.

expires_in_hoursnumber (1..720)body

Vigencia del link. Pisa la del flujo (default 168 horas).

redirect_urlstring (https)body

A dónde mandar a la persona al terminar. Pisa el del flujo.

curl -X POST https://api.singula.mx/app/flows/{id}/sessions \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "external_id": "ord_123",
  "metadata": { "plan": "pro" }
}'
Response
session_idstring

Id de la corrida. Es el que se usa para consultarla, decidirla o cancelarla.

request_idstring

Folio del registro de la corrida (LogRequest). Es el que llega en el webhook.

channelstring (enum: hosted|api)

El canal con el que nació la corrida. Eco de lo que mandaste, `hosted` si no mandaste nada.

tokenstring

Token de la sesión: `flw_` + uuid. El SDK enruta por ese prefijo. En el canal API sigue sirviendo: es el que usa la ruta de arranque de identidad.

urlstring

El link hospedado que abre la persona. No necesita orígenes permitidos.

embed_urlstring

La misma corrida para el iframe del SDK. Ahí los orígenes permitidos SÍ son obligatorios.

expires_atstring (ISO 8601)

Cuándo vence el link.

flow_versionnumber

La versión a la que queda anclada esta corrida.

estimate_centavosnumber

Lo que va a costar lo que FALTA por correr, y el saldo que se exige para abrir. Con `reuse_evidence` el reuso se resuelve ANTES del estimado y cada línea reusada se cotiza en 0, así que la guarda de saldo no retiene dinero que la corrida nunca va a gastar.

reused{ step_key, request_id, request_date }[]

Los pasos que nacieron resueltos con una consulta que este cliente ya tenía, cada uno con su folio y la fecha de esa consulta. Con `reuse_evidence` apagado (el default) es `[]`. **Cambió de forma**: hasta 1.96.1 era un `boolean` que ninguna ruta de flujos ponía nunca en `true`.

missing{ step_key, tool, how }[]

Lo que queda por hacer, con 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. Es lo que te dice si mandas el link o si sólo dejas correr la corrida.

nextobject | null

SÓLO en `channel: "api"`: el primer paso por resolver, con la misma forma que devuelve `GET /app/flows/sessions/:id/next`. Viene de una vez para que empieces sin una segunda llamada. Llega `null` cuando ya no hay paso que dictar — incluida la corrida que nace RECHAZADA: un paso reusado trae el `on_fail` de su propio registro, así que la apertura puede cerrar la corrida ahí mismo, sin arrancar ni cobrar ningún paso.

Example
{
  "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": []
}

Consultar una verificación

GET/app/flows/sessions/:id

El estado y el veredicto de una corrida, con el resultado de cada paso y lo que lleva cobrado.

`status` describe la sesión y `decision` es el veredicto: son dos ejes distintos. Una corrida en `review` o `declined` se puede REABRIR para que la persona repita pasos (`POST /app/flows/sessions/:id/resubmit`): la misma corrida y el mismo link, con el historial en `attempts[]` y `resubmissions[]`. Cada paso escribe además su propio registro normal (el `request_id` del renglón), que puedes consultar como cualquier otra consulta — sin cargo propio: el dinero se cobra en el renglón de la corrida. El `expediente` es el CIERRE, no un paso: corre una sola vez al final, sin cargo propio, sobre lo que esa corrida recolectó, y el autor del flujo no tiene perilla para quitarlo. Una decisión manual posterior NO lo borra: es la lectura con la que se juzgó. El flujo orquesta y cada producto hace su trabajo completo: la verificación de identidad ya trae, incluidos, su cruce con RENAPO y la Lista Nominal de una INE, y sin un paso de INE el expediente lee de ella `ine_name_vs_renapo`, `ine_curp_vs_renapo` e `ine_vigente`. Por eso un KYC de producción con identidad (INE) puede seguir abierto después de su último paso: el cierre espera esa Lista Nominal, hasta 10 minutos desde el veredicto de la sesión. En un KYB, desde 1.114.0, el cierre coteja la INE del representante contra RENAPO con la CURP que la credencial imprime —incluido, sin cargo extra— y espera esa consulta igual; desde 1.130.0, si la identificación del representante es su sesión de identidad (`ine_source: identity_session`) y leyó una INE, espera también su Lista Nominal. Y desde 1.114.0 la página hospedada cierra la corrida en su siguiente consulta, en cuanto termina el último paso o llega lo que el cierre esperaba; antes la cerraba el rescate de cada minuto, uno o dos minutos después (sin la página abierta, ese rescate la sigue cerrando). En el ejemplo, un `medium` en listas deja su control sin evaluar, así que el expediente cierra en `review` y la corrida también.

Request
idstringpathrequired

El `session_id` que devolvió al abrir la sesión.

curl -X GET https://api.singula.mx/app/flows/sessions/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
statusstring (enum: not_started|in_progress|awaiting_user|resubmitted|review|approved|declined|expired|abandoned)

Cómo va la SESIÓN. `awaiting_user` espera una captura; `resubmitted`, un revisor pidió repetir pasos y la corrida espera a la persona otra vez —no es terminal ni es un veredicto, y pasa a `awaiting_user` o `in_progress` en cuanto toma el primer paso reabierto—; `expired` pasó su `expires_at`; `abandoned` se canceló.

channelstring (enum: hosted|api)

Quién corrió los pasos de captura: `hosted` (nuestra página, sea por link, QR o SDK) o `api` (tu propio front, pegando cada folio). No cambia después de abrir la corrida.

decisionstring (enum: approved|review|declined) | null

El veredicto. Es lo único sobre lo que se decide. Sale en este orden: un paso con acción `decline` cierra `declined` ahí mismo, sin expediente; `expediente.overall: "fail"` → `declined`; un paso que pidió revisión, un `expediente.overall: "review"` o una corrida SIN expediente → `review`; sólo un `expediente.overall: "pass"` sin pasos en revisión cierra `approved`. Sin expediente no se aprueba. `null` mientras la corrida sigue abierta, y también en las que cierran sin veredicto (`expired` y `abandoned`).

decided_bystring (enum: rules|reviewer) | null

`rules` cuando cerró el flujo solo; `reviewer` después de una decisión manual.

reviewer_notestring | null

La nota que dejó quien decidió a mano.

stepsobject[]

Un renglón por paso: `key`, `tool`, `status` (pending | awaiting_user | running | done | skipped), `request_id` (el folio de la consulta que corrió, `null` en el formulario), `outcome` (pass | fail | review | error | null), `reason`, `action` (continue | review | decline | null), `charged_centavos`, `reused` (booleana: el paso nació resuelto con una consulta que ese cliente ya tenía — sin ella un paso con cargo 0 no se distingue de uno pegado) y `completed_at`. En una corrida `channel: "api"`, un paso que pegaste trae además `attached: true` y `tool_charged_centavos` —lo que cobró la herramienta cuando TÚ la llamaste, informativo— y su `charged_centavos` es 0, porque la corrida no vuelve a cobrarlo. Un paso `representatives` (KYB) trae además `children[]`: `{ session_id, role, name, email, customer_id, status, decision, url, charged_centavos }` por persona. Cuatro `reason` que sólo salen aquí: `need_missing` —un paso de SERVIDOR cuyo dato seguía en blanco al arrancar, así que se saltó con `status: "skipped"`, `outcome: "pass"`, `action: "continue"` y `charged_centavos: 0` en vez de reventar contra la herramienta; falla abierto, si la fila no se puede leer el paso corre—; `condition_not_met` —el paso iba detrás de una RAMA que no se cumplió, así que quedó `status: "skipped"`, `outcome: "pass"`, `action: "continue"`, sin `request_id` y con `charged_centavos: 0`, y el veredicto lo ignora—; `cross_ref_<control>` (hoy sólo `cross_ref_ine_curp_vs_renapo`): la herramienta ya cruzó ese dato contra su fuente, tu política lo tiene encendido y crítico, y el paso falló ahí mismo sin esperar al expediente; de ahí manda el `on_fail` del paso. Desde 1.112.0 sólo la CURP de la INE corta a media corrida: el nombre se juzga al cierre, en el expediente, y los cruces de la verificación de identidad nunca cortan su paso; y `kyb_required` —en una cuenta SIN el KYB aprobado, un paso de antecedentes de persona sin una identidad aprobada antes en ESA corrida, o con una cuyo documento no nombra al cliente: queda `status: "skipped"`, `outcome: "review"`, `action: "continue"`, sin `request_id` y con `charged_centavos: 0`; no llamó al proveedor y ni su `on_fail` ni su `on_error` corrieron. Es `review` y no `pass` para que una rama que lee ese paso no abra sobre una búsqueda que nunca corrió. El sandbox no pasa por esa compuerta—. Un paso se puede REPETIR, así que cada renglón trae además `attempt` (qué intento es, contando desde el primero; sus campos son siempre los del intento vivo), `attempts[]` (los intentos ANTERIORES: `{ attempt, request_id, outcome, reason, action, charged_centavos, tool_charged_centavos, attached, reused, started_at, completed_at, ended_at, ended_by, resubmission }`, con `ended_by: "retry"` si la persona repitió una captura mala en la página y `"resubmit"` si un revisor lo reabrió), `reset_at` (cuándo lo reabrió un revisor, o `null`) y `retry` (`{ code, max }` mientras la persona repite una captura; si no, `null`). El dinero de cada intento se queda en su `attempts[]`: un paso paga a lo más una vez y los totales de la corrida nunca bajan. Un paso de documento sigue la regla de la consulta suelta: si no pudimos procesar su archivo antes de cualquier lectura con costo (no es imagen ni PDF, protegido sin su contraseña, vacío o demasiado grande) cobra 0 y no cuenta como compra —si la persona sube otro y ése sí se lee, paga una vez—; uno que sí leímos, aunque salga ilegible, cobra su cargo. Si el documento ya se leyó y después falla el proceso (el paso termina en `error`), cobra su cargo base, igual que suelto; si el paso se vuelve a pedir, el siguiente archivo que sí se lee paga sólo la diferencia hasta su propio cargo y con eso queda pagado; si falla antes de leerse, cobra 0. Desde 1.113.0 la misma regla vale para todo paso de INE, identidad, e.firma e investigación de persona: si falla después de su llamada con costo (`error`), cobra su cargo base, aunque la falla sea nuestra, y el siguiente intento paga sólo la diferencia hasta su propio cargo (0 en un cargo fijo); si falla antes de cualquier llamada con costo, cobra 0 y no cuenta como compra; una respuesta vacía porque ninguna fuente contestó, 0. Un paso de CURP, RFC, teléfono, listas, judiciales o correo que falla cobra 0. Una INE ya leída cuya consulta a la Lista Nominal no contestó (mantenimiento) se cobra. Repetir una captura en la página sigue sin costo.

parentobject | null

Sólo en una corrida HIJA: `{ session_id, step_key, role }` de la corrida del KYB que la abrió y del paso de representantes que la creó. `null` en cualquier otra corrida. Las hijas de una corrida también se listan con `GET /app/flows/sessions?parent=:id`.

expedienteobject | null

El cierre de la corrida: `{ 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. Aquí viaja sólo el resumen: su `request_id` abre el cotejo completo en `GET /dashboard/request/:id`, como cualquier otra consulta —los 19 controles del expediente KYC o los 30 del KYB en `response.checks[]`, cada uno con `code`, `ok`, `critical`, `detail`, `sources` y, desde 1.114.0, `holds_approval`—. `failed` son los controles que se CONTRADICEN (`ok: false`); un control sin evaluar no acusa a nadie y lo que se refleja es la casilla en `missing`. `null` mientras la corrida sigue y en las que cerraron sin él (un rechazo a media corrida, un vencimiento, una cancelación). Si no se pudo armar, `request_id` es `null` y `failed` trae UN código: `expediente_error`, `no_customer` o `expediente_no_evidence` — y la corrida queda en `review`, nunca aprobada.

charged_centavosnumber

Lo que lleva cobrado la fila de la corrida: la suma de sus pasos terminados y, desde 1.113.0, los archivos de «Sube tus documentos» que ya se habían leído y ningún paso usó (`unused_uploads`). En una corrida `channel: "api"` sólo cuenta los pasos que corrió ella, no los que pegaste.

unused_uploadsobject[]

Los archivos de la pantalla «Sube tus documentos» de la página hospedada que ningún paso usó (1.113.0), cada uno cuando se decide su cobro: `{ request_id, file_name, document_type, pages, reason, charged_centavos }`. `reason`: `removed` (la persona lo quitó) | `unused` (seguía en la bandeja cuando cerró la corrida) | `failed` (no se pudo leer, así que nunca se pudo asignar). `charged_centavos` es lo que sumó a la fila de la corrida: si ya lo habíamos leído, su cargo como consulta suelta —la base y sus hojas extra; uno que falló después de leerse, la base—, una sola vez; si nunca se leyó, 0; siempre 0 en sandbox. `document_type` es `null` si nunca se clasificó y `pages`, `null` si no se sabe. Su `request_id` contesta 404 a tu organización, como todo archivo de la bandeja: el archivo no sale en la actividad, el cliente ni el expediente. `[]` en una corrida sin ninguno —toda corrida del canal API— y en las anteriores a 1.113.0.

tool_charged_centavosnumber

Sólo en `channel: "api"`: la suma de lo que cobraron las herramientas que llamaste tú y pegaste con `attach`. Ese dinero ya salió al llamarlas; aquí sólo se reporta.

discount_credited_centavosnumber

Lo que se abonó al saldo al cerrar. Desde 1.108.0 los flujos no tienen descuentos: un paso cobra el cargo de su herramienta suelta, así que un paso pegado (`channel: "api"`) o el de una corrida hija de un KYB ya pagó lo que debe y no abona nada. Lo único que regresa es un INTENTO repetido de un paso, porque repetir no se cobra: por el canal API, la herramienta que vuelves a llamar cobra al llamarla y el cierre te la devuelve completa, salvo cuando el intento que pagó falló después de una llamada con costo (su cargo base): entonces el siguiente debe la diferencia hasta su propio cargo y sólo lo demás regresa. Si un revisor reabrió la corrida, el abono se ACUMULA entre cierres —cada cierre paga sólo la diferencia contra lo ya abonado—. `charged_centavos` + `tool_charged_centavos` − `discount_credited_centavos` da el mismo total que la misma corrida por el canal hospedado. En sandbox es 0.

estimate_centavosnumber

Lo que se estimó al abrir la sesión, para comparar.

flow_versionnumber

La versión con la que corrió, aunque el flujo ya tenga otra publicada.

customer_idstring | null

El cliente que se creó desde el formulario o el que se pasó al abrir la sesión.

external_idstring | null

Tu identificador, tal cual lo mandaste.

customer_namestring | null

El nombre ya resuelto del cliente de la corrida, para no tener que pedirlo aparte.

created_byobject | null

Quién abrió la sesión: `{ kind: "user" | "api_key" | "mcp", id, label, email? }`. En una verificación que abrió un link compartible, quien creó el link. `null` cuando no se pudo resolver.

link_idstring | null

El link compartible (`flk_…`) que abrió la verificación —su `id`, no su token—; `null` en cualquier otra. El listado filtra por él con `?link_id=`.

networkobject | null

Desde dónde se conectó la persona: `{ ips: [{ ip, first_at, last_at, count, country, asn, as_org, is_tor, is_hosting, is_relay }], countries, any_tor, any_hosting, any_relay }`. Hasta 10 IPs confiables —la página hospedada, la apertura por link y el teléfono de la sesión de identidad—, nunca el `X-Forwarded-For` que mande el cliente. En una corrida `channel: "api"` sólo cuenta el navegador de la persona (la captura de identidad): lo que llama tu servidor nunca entra. Cada bandera es `true`, `false` o `null` (sin datos, nunca «limpia»); `network: null` = la corrida no registró IPs. `is_hosting` es nube, hosting o VPN conocida, y un iCloud Private Relay (`is_relay`) no cuenta como hosting; en un relay, `country` es el que publica Apple. Es una señal: no cobra y no sale en webhooks ni en la página pública. En sandbox, tu IP real con las señales fijas de la persona de prueba (MX, sin Tor, sin nube, sin relay). Créditos de datos: https://singula.mx/creditos.

resubmission_countnumber

Cuántas veces un revisor reabrió la corrida con `POST /app/flows/sessions/:id/resubmit`. 0 en una que nunca se reabrió; el máximo es 3.

resubmissionsobject[]

Cada ronda de «repite estos pasos»: `{ n, at, steps, reason, note, previous }`. `previous` es cómo estaba la corrida al reabrirla —`{ status, decision, decided_by, reviewer_note, expediente, completed_at }`—: el veredicto anterior no se borra, queda como historia. `note` es la nota interna del revisor: sale aquí y en el MCP, nunca en el webhook ni en la página de la persona.

form_dataobject

Las respuestas a los campos PROPIOS del cuestionario (`maps_to: null`), por su `name`: texto, número, `true` / `false` o lista de opciones. Son datos personales: salen en el detalle, la API y el MCP, y nunca en el cliente ni en el webhook. `{}` cuando el formulario no trae campos propios.

checked_atstring (ISO 8601)

Cuándo se armó esta respuesta.

Example
{
  "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,
  "customer_id": "cus_9a1b",
  "external_id": "ord_123",
  "metadata": { "plan": "pro" },
  "link_id": null,
  "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",
  "created_at": "2026-09-15T12:00:00.000Z",
  "completed_at": "2026-09-15T12:04:11.000Z",
  "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": "66f1a2b3c4d5e6f708192a40", "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": "66f1a2b3c4d5e6f708192a44", "outcome": "pass",
      "reason": "curp_valid", "action": "continue", "charged_centavos": 300,
      "completed_at": "2026-09-15T12:03:05.000Z" },
    { "key": "listas", "tool": "checkBlacklist", "status": "done",
      "request_id": "66f1a2b3c4d5e6f708192a51", "outcome": "review",
      "reason": "blacklist_medium", "action": "review", "charged_centavos": 1000,
      "completed_at": "2026-09-15T12:04:10.000Z" }
  ],
  "expediente": {
    "request_id": "66f1a2b3c4d5e6f708192a60",
    "overall": "review",
    "failed": [],
    "missing": []
  },
  "charged_centavos": 3300,
  "unused_uploads": [],
  "estimate_centavos": 3300,
  "resubmission_count": 0,
  "resubmissions": [],
  "form_data": {},
  "network": {
    "ips": [
      { "ip": "203.0.113.24", "first_at": "2026-09-15T12:00:40.000Z",
        "last_at": "2026-09-15T12:02:31.000Z", "count": 5,
        "country": "MX", "asn": 8151, "as_org": "Uninet S.A. de C.V.",
        "is_tor": false, "is_hosting": false, "is_relay": false }
    ],
    "countries": ["MX"],
    "any_tor": false,
    "any_hosting": false,
    "any_relay": false
  },
  "checked_at": "2026-09-15T12:04:11.000Z"
}

Listar verificaciones

GET/app/flows/sessions

Las corridas de tu organización, filtrables por flujo y por estado. Es la tabla de la pantalla de sesiones.

Request
flow_idstringquery

Sólo las corridas de ese flujo.

statusstring (enum: not_started|in_progress|awaiting_user|resubmitted|review|approved|declined|expired|abandoned)query

Sólo las corridas en ese estado. `review` es la cola de revisión manual; `resubmitted`, las que un revisor reabrió y todavía no toman su primer paso reabierto.

channelstring (enum: hosted|api)query

Sólo las corridas de ese canal. Sin él vienen las dos.

parentstringquery

Sólo las corridas HIJAS de esa corrida: las que abrió su paso de representantes, una por persona. Sin él, el listado no las separa de las demás.

link_idstringquery

Sólo las verificaciones que abrió ese link compartible, con su `id` (no el token `flk_`): una por uso, cada una con su cliente nuevo.

limitnumberquery

Cuántas devolver por página.

offsetnumberquery

Desde cuál empezar.

curl -X GET https://api.singula.mx/app/flows/sessions \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
itemsFlowSessionDetail[]

Cada corrida con la misma forma que el detalle.

totalnumber

Cuántas corridas cumplen el filtro, sin paginar.

Decidir a mano una verificación

POST/app/flows/sessions/:id/decision

Cierra a mano una corrida que quedó en revisión. Queda con `decided_by: "reviewer"`, guarda la nota y vuelve a disparar `flow.completed`. El `expediente` de la corrida NO se borra: es la lectura con la que se juzgó.

Sólo funciona sobre una corrida en `review`. Si ya cerró, la respuesta es 409. Lo pueden llamar los usuarios Admin y Normal del panel, y también una API key. Decidir cierra la corrida tal como está: para que la persona repita una foto, un documento o un dato, se pide un reenvío (`POST /app/flows/sessions/:id/resubmit`).

Request
idstringpathrequired

El `session_id` de la corrida.

decisionstring (enum: approved|declined)bodyrequired

El veredicto de la persona. `review` no es un valor válido aquí: decidir es cerrar.

notestringbodyrequired

Por qué se decidió así. Queda en el expediente y en la auditoría.

curl -X POST https://api.singula.mx/app/flows/sessions/{id}/decision \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "decision": "approved",
  "note": "CURP con apellido compuesto, verificado contra el acta de nacimiento"
}'
Response
decisionstring

El veredicto ya aplicado.

decided_bystring

Pasa a `reviewer`.

reviewer_notestring

La nota que se mandó.

Pedir un reenvío (repetir pasos)

POST/app/flows/sessions/:id/resubmit

Reabre una verificación en `review` o `declined` para que la persona repita SÓLO los pasos que nombras, con el MISMO link. La corrida queda `resubmitted`, esos pasos vuelven a empezar con `attempt` + 1 y, al volver a cerrar, el expediente juzga con la evidencia nueva. Repetir un paso no se cobra otra vez.

Lo pueden llamar los usuarios Admin y Normal del panel y una API key, como la decisión manual; en el panel es «Pedir reenvío». 404 si la corrida no existe. 409 con `code`: `not_resubmittable` (no está en `review` ni en `declined`, o cambió mientras se pedía: una decisión manual que llega en medio gana), `close_in_progress` (su veredicto todavía se está publicando; reintenta en unos segundos), `child_run` (es la corrida hija de un KYB: 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ó; `steps` los nombra), `declining_step_not_included` (una corrida `declined` sin el paso que la rechazó; `steps` lo nombra) y `max_resubmissions` (ya se reabrió 3 veces). De dos reenvíos simultáneos gana uno. COBRO: un paso paga a lo más una vez —el primer intento que compró su herramienta—; los intentos siguientes cobran 0, salvo cuando el que pagó falló después de una llamada con costo (su cargo base) —un documento que se leyó y después falló; desde 1.113.0 también INE, identidad, e.firma o investigación de persona—: el siguiente intento paga sólo la diferencia hasta su propio cargo (0 en un cargo fijo). Un intento que falló antes de cualquier llamada con costo no compró nada. `charged_centavos` nunca baja y, por el canal API, el cierre devuelve completa la herramienta que se volvió a llamar, menos esa diferencia. WEBHOOK: sale `flow.resubmitted` con `{ request_id, tool_type, status: "pending", session_id, steps, reason, resubmission }` —sin la nota— y, al volver a cerrar, otro `flow.completed` con `resubmission_count`. En el canal API, `next` dicta los pasos reabiertos con `attempt` y `resubmission: { n, reason, steps }`, y `attach` exige una consulta posterior al `reset_at` del paso. En la página hospedada, además, una captura mala se reintenta hasta 3 veces por ronda antes de asentarse (`identity_failed`, `ine_low_confidence`, `ine_missing_fields`, `ine_bad_field_format`, `ine_unsupported_model`, `document_wrong_kind`); nunca una falla de rostro o de prueba de vida (`identity_declined`), un negativo de la Lista Nominal, un `cross_ref_*`, `document_forged` ni un `error`. En el ejemplo, la INE salió ilegible en sus tres intentos de la página y quedó en revisión; el reenvío abre el cuarto. En sandbox funciona igual y no cobra.

Request
idstringpathrequired

El `session_id` de la corrida.

stepsstring[]bodyrequired

Las claves de los pasos a repetir: de 1 a 20, sin repetir. Pasos de la persona, el de datos (la persona corrige) o de servidor (la plataforma los vuelve a correr sola). No se pueden pedir el de representantes ni uno que su rama saltó (`condition_not_met`). En una corrida `declined`, el paso que la rechazó tiene que ir aquí.

reasonstring (enum: document_unreadable|document_expired|wrong_document|selfie_retake|data_mismatch|other)bodyrequired

Por qué. La página lo traduce para la persona («Necesitamos que repitas…») y viaja en el webhook `flow.resubmitted`.

notestringbodyrequired

Nota INTERNA del revisor, de 3 a 1000 caracteres. Queda en `resubmissions[]` y sale en el detalle y el MCP; nunca en el webhook ni en la página de la persona.

expires_in_hoursnumberbody

De 1 a 720: cuántas horas más vive el link desde ahora. La vigencia nunca se acorta: `expires_at` queda en la mayor entre la actual y ahora + esas horas. Sin él, la de la versión del flujo (`settings.expires_in_hours`).

curl -X POST https://api.singula.mx/app/flows/sessions/{id}/resubmit \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "steps": ["ine"],
  "reason": "document_unreadable",
  "note": "La foto del reverso salió movida; se pide otra.",
  "expires_in_hours": 72
}'
Response
statusstring

Pasa a `resubmitted`: no es terminal ni es un veredicto. Cambia a `awaiting_user` o `in_progress` en cuanto la corrida toma el primer paso reabierto. Se puede cancelar y vence como cualquier corrida viva.

decisionnull

Vuelve a `null`, igual que `decided_by`, `reviewer_note`, `expediente` y `completed_at`: lo que había queda en `resubmissions[n].previous`.

stepsobject[]

Cada paso nombrado queda `pending`, con `attempt` + 1, sin folio ni lectura y con `reset_at`; su intento anterior pasa a `attempts[]` (`ended_by: "resubmit"`) con lo que cobró.

resubmission_countnumber

Sube uno. Una corrida se reabre hasta 3 veces.

resubmissionsobject[]

Suma la ronda: `{ n, at, steps, reason, note, previous }`.

expires_atstring (ISO 8601)

La vigencia, extendida si hizo falta. El link (`url`) es el mismo.

Example
{
  "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",
  "steps": [
    { "key": "ine", "tool": "ineExtraction", "status": "pending", "request_id": null,
      "outcome": null, "reason": null, "action": null, "charged_centavos": 0,
      "attempt": 4, "reset_at": "2026-09-16T10:00:00.000Z", "retry": null,
      "attempts": [
        { "attempt": 1, "request_id": "66f1a2b3c4d5e6f708192a7a", "outcome": "review",
          "reason": "ine_low_confidence", "action": null, "charged_centavos": 500,
          "ended_by": "retry", "resubmission": null },
        { "attempt": 2, "request_id": "66f1a2b3c4d5e6f708192a7b", "outcome": "review",
          "reason": "ine_low_confidence", "action": null, "charged_centavos": 0,
          "ended_by": "retry", "resubmission": null },
        { "attempt": 3, "request_id": "66f1a2b3c4d5e6f708192a7c", "outcome": "review",
          "reason": "ine_low_confidence", "action": "review", "charged_centavos": 0,
          "ended_by": "resubmit", "resubmission": 1 }
      ] }
  ],
  "resubmission_count": 1,
  "resubmissions": [
    { "n": 1, "at": "2026-09-16T10:00:00.000Z", "steps": ["ine"],
      "reason": "document_unreadable",
      "note": "La foto del reverso salió movida; se pide otra.",
      "previous": { "status": "review", "decision": "review", "decided_by": "rules",
        "reviewer_note": null, "completed_at": "2026-09-15T12:04:11.000Z",
        "expediente": { "request_id": "66f1a2b3c4d5e6f708192a60", "overall": "pass",
                        "failed": [], "missing": [] } } }
  ],
  "charged_centavos": 3300
}

Cancelar una verificación

POST/app/flows/sessions/:id/cancel

Corta una corrida que aún no termina. Pasa a `abandoned` y no cobra los pasos que no corrieron.

Sólo sobre una corrida en `not_started`, `in_progress`, `awaiting_user` o `resubmitted` (una que un revisor reabrió).

Request
idstringpathrequired

El `session_id` de la corrida.

curl -X POST https://api.singula.mx/app/flows/sessions/{id}/cancel \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
statusstring

Pasa a `abandoned`.

charged_centavosnumber

Lo que alcanzó a cobrarse: los pasos que sí terminaron y, desde 1.113.0, cada archivo de «Sube tus documentos» que ya se había leído y ningún paso usó, una vez (`unused_uploads`).

Pedir el siguiente paso (canal API)

GET/app/flows/sessions/:id/next

El paso en el que va la corrida y qué tienes que hacer con él. Es el bucle del canal API: preguntas, obedeces el `how`, vuelves a preguntar. `done: true` significa que ya no queda nada por resolver y toca cerrar.

Acepta el JWT de un usuario del panel o la llave API de la organización. Un paso opcional que no quieras correr se salta con `POST /app/flows/sessions/:id/steps/:key/skip`. El recorrido completo, con el ejemplo de punta a punta, está en https://singula.mx/docs/kyc-api. Con un paso de representantes vivo, `close` responde 409 `steps_pending`: las hijas son corridas hospedadas y hay que esperarlas. Si un revisor reabre la corrida (`POST /app/flows/sessions/:id/resubmit`), `next` vuelve a dictar los pasos reabiertos con su `attempt` y su `resubmission`, y al terminar se cierra otra vez con `close`. El reintento automático de la página hospedada no aplica en este canal.

Request
idstringpathrequired

El `session_id` de la corrida.

curl -X GET https://api.singula.mx/app/flows/sessions/{id}/next \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
doneboolean

`true` cuando todos los pasos están resueltos: la corrida está lista para `POST /app/flows/sessions/:id/close`.

nextobject | null

El paso por resolver, o `null` cuando `done` es `true`. Trae `step_key`, `tool` (la herramienta del paso, `form` o `representatives`), `kind` (form | user | server), `interaction` (form | kyc | upload | otp | people, sólo en los de captura), `required`, los `params` que publicó el flujo (`document_types`, `document_kind`, `roles`, `min`, `max`, `delivery`, `fields`…), `attempt` y el `how`.

next.attemptnumber

Qué intento del paso es. 1 de entrada; sube cuando un revisor pide repetirlo.

next.resubmissionobject | undefined

Sólo si un revisor REABRIÓ este paso: `{ n, reason, steps }`, la ronda, el motivo y los pasos que pidió. La consulta que pegues tiene que ser posterior al reenvío: una anterior responde 409 `stale_evidence`, y el folio del intento anterior ya no entra (`already_attached`).

next.input_contractobject[] | undefined

Sólo en el paso de DATOS de una corrida cuyo formulario trae campos propios: los campos que hay que mandar a `call`, cada uno `{ name, type, label, help, required, options, min, max, maps_to }`. Los conocidos que la fila del cliente ya trae no se vuelven a pedir.

next.howstring (enum: api|sdk|wait)

Qué hacer. `api`: corre la herramienta sobre TU cliente —en un paso de subida, con `next.upload`: reservas, subes a S3 y confirmas; en el de correo por código, con `next.call`— y pega el folio con `next.attach`. `sdk`: arranca la identidad con `next.identity_start` y abre el `verification_token` con el SDK; NO se pega, el runner avanza solo. `wait`: el siguiente es un paso de servidor que ya está corriendo — vuelve a preguntar. El paso `representatives` (KYB) sale también como `api` pero con `interaction: "people"` y SIN `attach`: su `call` es `POST /app/flows/sessions/:id/steps/:key/representatives` y se resuelve entregando la lista. Mientras sus corridas hijas caminan, el mismo paso vuelve como `wait` con `children[]`. El paso de DATOS sale como `api` con `interaction: "form"`, `input_contract` y SIN `attach` cuando el formulario trae campos propios: su `call` es `POST /app/flows/sessions/:id/steps/:key/form` y se contesta con `{ fields }`.

next.callobject | null

Sólo con `how: "api"`: `{ method, path, body? }`, la ruta que corre el paso, ya resuelta sobre el cliente de la corrida. En el paso `representatives` el cuerpo es `{ people: [{ name, email?, role }] }`. En un paso de SUBIDA no la uses: todavía nombra la ruta multipart obsoleta (con `multipart`), que se apaga el 30 de octubre de 2026; la subida va por `next.upload`.

next.uploadobject | undefined

Sólo en los pasos de subida (INE, documento, constancia y poder): `{ init: { method, path, body }, complete: { method, path, body? } }`. `init.path` ya viene resuelto sobre el cliente de la corrida (p. ej. `/app/ine-extraction/customer/<id>/init`) e `init.body` nombra lo que pide: `filename` y `content_type`, o `ine_front`, `ine_back` y `min_ocr_confidence` opcional en la INE. Entre los dos subes cada archivo directo a S3 con el POST firmado que contesta `init`. `complete.path` lleva `{request_id}`, el que contestó `init`, y ese mismo `request_id` es el que pegas con `attach`.

next.identity_startobject | null

Sólo con `how: "sdk"`: `{ method, path }` de `POST /app/flow-embed/:token/steps/:key/identity/start`, la ruta pública que acuña el `verification_token` de esa identidad. Ese token se abre con `Identity.open({ token })` tal cual. Desde 1.114.0, si la captura de ese intento ya se envió, la ruta contesta `status: "submitted"` sin token: no abras otra, la corrida espera el veredicto de la enviada.

next.attachobject

`{ method, path }` del `attach` de ese paso. En un paso `how: "sdk"` no se usa, y en el paso `representatives` NO viene: pegarle un folio responde 409 `not_attachable_step` con `how: "people"`.

next.childrenobject[] | null

Sólo en el paso `representatives` mientras sus hijas caminan (`how: "wait"`): una foto por persona, `{ session_id, role, name, email, customer_id, status, decision, url, charged_centavos }`. Cuando TODAS quedan terminales el paso cierra solo, con la peor de las decisiones.

statusstring

El estado de la sesión, el mismo enum del detalle.

customer_idstring

El cliente de la corrida. Es sobre el que se llaman las herramientas.

Example
{
  "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": 1,
    "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"
    }
  }
}

Pegar una consulta a un paso (canal API)

POST/app/flows/sessions/:id/steps/:key/attach

Mete a la corrida una consulta que corriste tú. El paso se resuelve por el camino normal —se lee el resultado y se aplica la regla que el flujo le puso— y la corrida avanza.

Todas las guardas son explícitas. 409 `not_api_channel` si la corrida es del canal hospedado. 409 `not_current_step` (con `current_step_key`) si nombraste otro paso. 404 si el folio no existe, es de otra organización o es del OTRO ambiente que tu llave. 409 `customer_mismatch` si la consulta corrió sobre otro cliente. 409 `tool_mismatch` (con `expected` y `received`) si es de otra herramienta. 409 `bundled_evidence` si la consulta la corrió otro producto por dentro (`bundled_with`, p. ej. la CURP que validó una verificación de identidad): viene incluida en ese producto y no se pega como paso; corre la herramienta del paso. 409 `log_not_terminal` si la consulta sigue corriendo — espérala en `GET /app/<herramienta>/status/:requestId`. 409 `already_attached` si ese folio ya está en otra corrida o en el intento anterior de un paso reabierto. 409 `stale_evidence` si la consulta es anterior a la corrida o, en un paso que un revisor reabrió, anterior a su `reset_at` (la respuesta lo trae): evidencia vieja no entra a un alta nueva. 409 `not_attachable_step` (con `how`) si el paso no se pega: el de datos se contesta en `POST /app/flows/sessions/:id/steps/:key/form`, el de representantes con su lista, y la identidad y los pasos de servidor avanzan solos. Pegar una consulta que salió mal NO es un error: es un paso que falló, y su regla decide. El paso queda con el `attempt` vivo: un folio vale para un solo intento.

Request
idstringpathrequired

El `session_id` de la corrida.

keystringpathrequired

La clave del paso. Tiene que ser el paso en el que va la corrida.

request_idstringbodyrequired

El folio de la consulta que corriste sobre el cliente de la corrida.

curl -X POST https://api.singula.mx/app/flows/sessions/{id}/steps/{key}/attach \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "request_id": "66f1a2b3c4d5e6f708192a7c"
}'
Response
stepobject

El paso ya resuelto: `key`, `tool`, `status`, `request_id`, `outcome`, `reason`, `action`, `attached: true`, `charged_centavos` (siempre 0: la herramienta ya cobró) y `tool_charged_centavos` (lo que cobró, informativo).

nextobject | null

El siguiente paso, con la misma forma que `GET …/next`, para encadenar sin otra llamada.

Contestar el paso de datos (cuestionario)

POST/app/flows/sessions/:id/steps/:key/form

La versión autenticada del paso de datos, con la MISMA validación que la página hospedada. Es como el canal API contesta un formulario con campos propios —`next` lo dicta con `interaction: "form"` e `input_contract`—, y la usa también el panel. Los campos conocidos llenan al cliente; los propios se guardan en `form_data` de la corrida.

400 `{ code: "invalid_form", problems: [{ field, code, message }] }` con TODOS los campos que no caben, a la vez: `required`, `bad_type`, `bad_format` (CURP, RFC, fecha, correo o teléfono de 10 dígitos o E.164), `bad_option`, `out_of_range` y `too_long` (una respuesta más larga que su máximo; con `field: null`, las respuestas propias juntas por encima de 16 KB). Se corrigen y se manda el formulario completo otra vez. 409 `session_closed` si la corrida ya cerró, `not_current_step` (con `current_step_key`) si no va en ese paso y `not_form_step` si la clave no es la del paso de datos. Si el paso trae reglas sobre `answers.<campo>` (p. ej. «PEP = sí → revisión»), se aplican aquí y el paso sale con `reason: "rule:form_submitted"`. No cobra. Acepta el JWT de un usuario del panel o la llave API de la organización.

Request
idstringpathrequired

El `session_id` de la corrida.

keystringpathrequired

La clave del paso de datos. Tiene que ser el paso en el que va la corrida.

fieldsobjectbodyrequired

Una llave por campo del paso, con su `name`. Los conocidos van como texto: `birth_date` en AAAA-MM-DD o DD/MM/AAAA, `gender` H / M / X y `birth_place` la clave de estado de RENAPO —el nombre del estado, con o sin acentos, o un alias como CDMX se normaliza a su clave—. Los propios van con su tipo: texto, número, `true` / `false`, fecha AAAA-MM-DD, una opción o una lista de opciones. Las llaves que el paso no pide se ignoran.

curl -X POST https://api.singula.mx/app/flows/sessions/{id}/steps/{key}/form \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "fields": {
    "ocupacion": "comerciante",
    "es_pep": false,
    "ingreso_mensual": 42000
  }
}'
Response
statusstring

El estado de la corrida ya avanzada. La respuesta completa es el detalle, con la misma forma que `GET /app/flows/sessions/:id`.

stepsobject[]

El paso de datos ya resuelto: `outcome: "pass"` y `reason: "form_submitted"`, o lo que forzó una regla sobre las respuestas.

form_dataobject

Las respuestas a los campos propios, por su `name`. Nunca van al cliente ni al webhook.

Entregar la lista de representantes (KYB)

POST/app/flows/sessions/:id/steps/:key/representatives

Manda las personas de la empresa —representante legal, apoderado, socios, beneficiarios controladores— y abre UNA corrida KYC hija por cada una, con el flujo `kyc` que el paso declaró en `child_flow_id`. Es la ruta que dicta `next` cuando toca el paso de representantes, y es la misma que usa el panel.

Una SOLA entrega por paso: la segunda responde 409 `people_already_submitted` con las hijas que ya existen. Las guardas del cuerpo son 400 con su código: `bad_people_count` (fuera de `[min, max]`), `bad_role` (un rol que el paso no publicó), `email_required` (con `delivery: "email"`), `bad_name` (fuera de 2..120) y `bad_email`. 409 si nombraste un paso que no es el actual. Este paso NO se pega: `POST .../attach` sobre él responde 409 `not_attachable_step` con `how: "people"`. Cada hija nace `hosted` —también cuando el padre corre por `channel: "api"`—, sin `customer_id`, heredando del padre el ambiente, la vigencia, el idioma, la `metadata` y el `created_by`, y SIN la guarda de saldo 402: el estimado del padre ya reservó `min` × una corrida del flujo hijo. Con `delivery: "email"` el link sale por el mismo remitente y las mismas plantillas del código de correo; con `"links"` sólo se devuelve. El paso del padre sigue en `awaiting_user` hasta que TODAS cierran, y entonces cierra con la peor: alguna `declined` → `fail`; alguna `review`, `expired` o `abandoned` → `review`; todas `approved` → `pass`. Cobra 0 en la fila del padre: cada hija cobró en la suya. Desde 1.114.0 el expediente KYB que cierra la corrida lee sólo a ESTAS hijas: cada firmante (representante legal o apoderado) con identidad aprobada tiene que ser uno de los apoderados del poder, y la KYC rechazada de un firmante deja `representative_identity_verified` en `false`: con el control encendido (el default), la corrida no se aprueba aunque el `on_fail` del paso la deje seguir —va a revisión, o a rechazo si lo marcaste crítico—.

Request
idstringpathrequired

El `session_id` de la corrida del KYB.

keystringpathrequired

La clave del paso `representatives`. Tiene que ser el paso en el que va la corrida.

peopleobject[]bodyrequired

Entre `min` y `max` personas, cada una `{ name, email?, role }`. `name` mide de 2 a 120 caracteres; `role` es uno de los que el paso publicó en `params.roles` (`representante_legal`, `apoderado`, `socio`, `ubo`); `email` es obligatorio cuando el paso entrega por correo (`delivery: "email"`).

curl -X POST https://api.singula.mx/app/flows/sessions/{id}/steps/{key}/representatives \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -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" }
  ]
}'
Response
childrenobject[]

Una hija por persona: `{ session_id, role, name, email, customer_id, status, decision, url, charged_centavos }`. `customer_id` llega en `null` —el registro de la persona lo crea el paso de datos de SU corrida— y `url` es el link hospedado que abre esa persona.

Example
{
  "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 }
  ]
}

Saltar un paso opcional (canal API)

POST/app/flows/sessions/:id/steps/:key/skip

La versión autenticada del salto del runner: deja en `skipped` un paso que el flujo publicó con `required: false` y pasa al siguiente.

Sólo sobre un paso con `required: false`: sobre uno obligatorio responde 409. No cobra. Tampoco hace falta saltarlos uno por uno: al cerrar, los opcionales sin resolver quedan `skipped` con `reason: "skipped_optional"`.

Request
idstringpathrequired

El `session_id` de la corrida.

keystringpathrequired

La clave del paso a saltar.

curl -X POST https://api.singula.mx/app/flows/sessions/{id}/steps/{key}/skip \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
nextobject | null

El siguiente paso, con la misma forma que `GET …/next`.

Cerrar una verificación (canal API)

POST/app/flows/sessions/:id/close

Termina la corrida a petición tuya: arma el expediente con los folios de ESA corrida —los pegados incluidos—, aplica el ajuste de cobro y cierra. La respuesta es el detalle completo de la corrida.

Sólo sobre una corrida `channel: "api"`: en una hospedada responde 409 `not_api_channel`. 409 `{ code: "steps_pending", pending: [{ step_key, tool, how }] }` cuando quedan pasos OBLIGATORIOS sin resolver — incluido un paso `representatives` con hijas vivas, que sale con `how: "wait"` hasta que todas cierran. Es idempotente: sobre una corrida que ya cerró responde 200 con su detalle. En un KYC de producción cuya sesión de identidad leyó una INE (y sin una INE extraída en la corrida), el cierre ESPERA la Lista Nominal que consulta la propia identidad —hasta 10 minutos desde el veredicto de la sesión; suele llegar en uno o dos—: esta llamada puede contestar la corrida AÚN ABIERTA (`decision: null`), y el veredicto llega después por el webhook `flow.completed` o con `GET /app/flows/sessions/:id`. Pasado el tope cierra igual y `ine_vigente` queda en `null`. La Lista Nominal no se espera en sandbox, en un KYB ni con un pasaporte. En un KYB, desde 1.114.0, el cierre 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, esta llamada contesta la corrida AÚN ABIERTA y el veredicto llega por `flow.completed` o con `GET /app/flows/sessions/:id`. Una consulta que no contesta en 2 minutos cuenta como fallida y el expediente queda en revisión, nunca aprobado. NO es lo mismo que `POST /app/flows/sessions/:id/decision`, que es la anulación humana de una corrida en `review`. Una corrida que nadie cierra vence igual: pasado su `expires_at` queda `expired`, con el mismo ajuste de cobro sobre lo que alcanzaste a pegar. Si un revisor la reabre después (`POST /app/flows/sessions/:id/resubmit`), vuelves a correr los pasos que dicte `next` y la cierras otra vez: el expediente juzga con los folios nuevos y sale otro `flow.completed` con `resubmission_count`.

Request
idstringpathrequired

El `session_id` de la corrida.

curl -X POST https://api.singula.mx/app/flows/sessions/{id}/close \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
decisionstring (enum: approved|review|declined) | null

El veredicto, con el mismo orden de siempre: un paso con acción `decline` cierra `declined`; `expediente.overall: "fail"` → `declined`; un paso en revisión, un expediente en `review` o una corrida sin expediente → `review`; sólo un `pass` sin pasos en revisión cierra `approved`. `null` si la corrida sigue abierta: el cierre puede estar esperando la Lista Nominal de la sesión de identidad de un KYC de producción o, desde 1.114.0, la consulta a RENAPO de la INE del representante de un KYB (ver notas).

expedienteobject | null

El cierre: `{ request_id, overall, failed[], missing[] }`, armado con los `request_id` de la corrida, los pegados incluidos. Sin expediente no se aprueba. `null` mientras la corrida sigue abierta.

charged_centavosnumber

Lo que cobró la corrida: sus pasos automáticos. Los pegados van en 0.

tool_charged_centavosnumber

Lo que cobraron las herramientas que llamaste tú y pegaste.

discount_credited_centavosnumber

Lo que se te abonó al saldo al cerrar. Desde 1.108.0 un paso pegado ya pagó el cargo de su herramienta, que es el mismo cargo del paso, así que no abona nada; lo mismo los pasos de las corridas hijas de un KYB. Sólo regresa, COMPLETO, un paso repetido tras un reenvío, porque repetir no se cobra —salvo cuando el intento que pagó falló después de una llamada con costo (su cargo base): entonces el siguiente debe la diferencia hasta su propio cargo y sólo lo demás regresa—, y el abono se acumula entre cierres: cada cierre paga sólo lo que falta, nunca dos veces lo mismo. `charged_centavos` + `tool_charged_centavos` − `discount_credited_centavos` = lo mismo que habría cobrado la corrida por el canal hospedado.

Example
{
  "session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
  "channel": "api",
  "status": "approved",
  "decision": "approved",
  "decided_by": "rules",
  "expediente": {
    "request_id": "66f1a2b3c4d5e6f708192a60",
    "overall": "pass",
    "failed": [],
    "missing": []
  },
  "charged_centavos": 3300,
  "tool_charged_centavos": 700,
  "discount_credited_centavos": 0,
  "estimate_centavos": 4000
}

Configuración de la verificación (por token)

GET/app/flow-embed/config/:token

Lo que la página hospedada necesita para pintarse: los pasos, en cuál va, la marca de tu organización y los orígenes permitidos. Sin API key: el token es la autenticación.

404 cuando el token no existe; 410 cuando la sesión venció o ya cerró. Esta ruta nunca devuelve reglas, umbrales, llaves ni datos extraídos. La apariencia es la de la VERSIÓN publicada con la que arrancó la sesión, no la del borrador. El `theme` del SDK (`Identity.open({ theme })`) se aplica encima de este `ui`, sólo para esa sesión y sólo en el navegador: esta ruta no lo conoce.

Request
tokenstringpathrequired

El `token` de la sesión (`flw_` + uuid).

curl -X GET https://api.singula.mx/app/flow-embed/config/{token} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
sessionobject

`session_id`, `request_id`, `kind`, `status`, `current_step_key`, `language`, `redirect_url`, `expires_at`, los `steps` públicos, `customer_prefill` (`name`, `legal_name`, `email` del cliente ligado) y `resubmission` (`{ n, reason, steps }` de la última ronda de reenvío, para la pantalla «Necesitamos que repitas…»; nunca la nota del revisor; `null` si nadie reabrió la corrida).

session.stepsobject[]

`{ key, tool, kind, interaction, label, params (sólo los públicos, fields del cuestionario incluidos), required, status, outcome?, attempt, max_attempts, retry }`. Nunca trae reglas ni umbrales. `attempt` cuenta dentro de la ronda (tras un reenvío vuelve a 1) y `retry` es `{ code }` mientras la persona repite una captura mala: dice qué pasó con la foto, no un veredicto.

session.decisionstring | null

El veredicto, SÓLO con `expose_decision_to_user` encendido en los ajustes del flujo. Apagado (el default), esta ruta contesta `status: "completed"` y `decision: null` para cualquier cierre —aprobado, en revisión o rechazado por igual— y el SDK recibe `decision: "withheld"`. El veredicto de registro se lee del lado del servidor.

brandingobject

El mismo bloque de marca que la verificación de identidad —logo, colores y textos—, ya FUSIONADO: base la marca de la organización y encima el `settings.ui` de la versión que corre esta sesión. Se conserva tal cual para el runner viejo.

uiobject

La apariencia resuelta de esta corrida: `{ theme: { primary, secondary?, background?, text?, radius?: sm|md|lg, font?: sans|serif|mono }, logo_url?, layout: card | full | bare, chrome: { header, brand_name, progress, titles, bodies, legal, background }, document_types?, texts? }`. El `chrome` viene COMPLETO —las siete llaves, siempre, con el default donde el flujo no dijo nada—, para que el runner pregunte `chrome.header` y reciba un booleano y no «la llave no estaba» — la misma forma que `settings.ui` al crear el flujo, con los textos ya por pantalla e idioma. Sale de la marca de la organización con el `settings.ui` de la versión encima. Un flujo sin `settings.ui` devuelve lo que devolvía antes.

allowed_originsstring[]

Los orígenes que pueden embeber la corrida. Vacío no es comodín: el widget falla cerrado.

Estado de la verificación (por token)

GET/app/flow-embed/status/:token

La versión ligera para hacer polling desde la página hospedada mientras corren los pasos de servidor.

Limitada a 300 llamadas por minuto. Es la ruta que sostiene la barra de progreso, no una API de resultados: el veredicto de registro se lee con la API key. Cada consulta asienta lo que ya terminó: el paso cuya herramienta ya contestó y, desde 1.114.0, la corrida cuyos pasos ya terminaron. Por eso el veredicto llega en la consulta siguiente al último paso —o a lo que el cierre esperaba: la Lista Nominal de la identidad, RENAPO en un KYB—, no uno o dos minutos después con el rescate de cada minuto.

Request
tokenstringpathrequired

El `token` de la sesión.

curl -X GET https://api.singula.mx/app/flow-embed/status/{token} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
statusstring (enum: not_started|in_progress|awaiting_user|resubmitted|review|approved|declined|expired|abandoned)

El estado de la sesión. `resubmitted` sale tal cual: no es un veredicto.

current_step_keystring | null

El paso en el que va la corrida.

decisionstring | null

Sólo con `expose_decision_to_user` encendido. Apagado, es `null` incluso cuando la corrida ya cerró, y `status` llega como `completed`.

expediente_overallstring (enum: pass|review|fail) | null

El veredicto del expediente con el que cerró la corrida, con la MISMA máscara que `decision`: con `expose_decision_to_user` apagado (el default) sale `null` aunque la corrida ya haya cerrado. `null` también cuando cerró sin expediente (un rechazo a media corrida, un vencimiento, una cancelación). El cotejo completo NUNCA sale por esta ruta. Con la API key, `GET /app/flows/sessions/:id` trae sólo el resumen (`expediente: { request_id, overall, failed, missing }`) y el `request_id` de ese expediente abre el cotejo control por control en `GET /dashboard/request/:id`.

stepsobject[]

`{ key, status, outcome?, attempt, max_attempts, retry }` por paso, nada más. `attempt` y `max_attempts` sostienen el «Vuelve a intentarlo (2 de 3)» de la página: cuentan dentro de la ronda, así que tras un reenvío vuelven a 1. `retry` es `{ code }` mientras la persona repite una captura mala (`ine_low_confidence`, `identity_failed`, `document_wrong_kind`…) y `null` si no: dice qué pasó con la foto, no un veredicto.

resubmissionobject | null

La última ronda de reenvío, `{ n, reason, steps }`, para la pantalla «Necesitamos que repitas…». Nunca trae la nota del revisor. `null` si nadie reabrió la corrida.

childrenobject[] | null

Sólo cuando la corrida va en un paso `representatives`: `{ session_id, role, name, status, decision, url }` por persona, para la pantalla de espera del KYB. `decision` lleva la MISMA máscara que la del padre: con `expose_decision_to_user` apagado sale `null`.

Capturar el formulario de datos

POST/app/flow-embed/:token/steps/:key/form

Manda los datos que llenó la persona. Crea el cliente de tu organización (físico si el flujo es `kyc`, moral si es `kyb`) o actualiza el que ya estaba ligado, y avanza al siguiente paso. En una verificación que abrió un link compartible el cliente siempre es NUEVO, con origen `link`. Las respuestas a los campos propios del cuestionario van a `form_data` de la corrida, nunca al cliente.

En una verificación que abrió un link compartible, cada paso de datos ACEPTADO cuenta contra un freno por link e IP: más de 60 en una hora o 300 en un día desde la misma IP responde 429 `{ code: "too_many_form_submits", window: "hour" | "day", limit, message }`. Un formulario rechazado (400) no cuenta, y una verificación que no abrió un link no se frena.

Request
tokenstringpathrequired

El `token` de la sesión.

keystringpathrequired

La clave del paso de formulario.

fieldsobjectbodyrequired

Una llave por campo del paso, con su `name`: los conocidos (`name`, `last_name`, `mothers_last_name`, `legal_name`, `curp`, `rfc`, `email`, `phone`, `birth_date`, `gender`, `birth_place`) como texto y los propios del cuestionario con su tipo (texto, número, `true` / `false`, fecha, una opción o una lista de opciones). Sólo se leen los que el paso declaró; las demás llaves se ignoran. Cada valor se valida contra SU campo, igual que en `POST /app/flows/sessions/:id/steps/:key/form`: un error responde 400 `invalid_form` con `problems[]`.

curl -X POST https://api.singula.mx/app/flow-embed/{token}/steps/{key}/form \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "fields": {
    "name": "Juan",
    "last_name": "Perez",
    "mothers_last_name": "Garcia",
    "curp": "PEGJ900101HJCRZN01",
    "email": "[email protected]"
  }
}'
Response
customer_idstring

El cliente que quedó ligado a la corrida.

next_step_keystring | null

El paso que sigue, o `null` si el formulario era el último.

Arrancar el paso de identidad

POST/app/flow-embed/:token/steps/:key/identity/start

Crea la sesión de verificación de identidad del cliente de la corrida, con los documentos que configuró el paso. El runner la sigue y avanza solo cuando cierra.

En una corrida `channel: "api"` esta ruta, como el resto de `/app/flow-embed`, no registra IP en `network`: la llama tu servidor, que no es la persona. La red de esa corrida sale de la captura de identidad que hace el navegador de la persona. Desde 1.114.0, si la captura de este intento ya se envió, contesta `{ status: "submitted", verification_token: null, embed_url: null, expires_at }` en vez de cancelarla y abrir otra: antes, recargar la página mientras se revisaba la identidad reiniciaba la captura y el veredicto caía en una sesión cancelada. Vale mientras el envío tiene menos de 15 minutos; pasado eso contesta `started` como antes —cancela la captura enviada y abre una nueva—. Un intento nuevo del paso (un reintento o un reenvío) arranca siempre con `started`.

Request
tokenstringpathrequired

El `token` de la sesión.

keystringpathrequired

La clave del paso de identidad.

curl -X POST https://api.singula.mx/app/flow-embed/{token}/steps/{key}/identity/start \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
statusstring (enum: started|submitted)

`started`: la sesión de captura, con su `verification_token` y su `embed_url`. `submitted` (desde 1.114.0): la captura de ESTE intento ya se envió hace menos de 15 minutos y se está revisando —la página se recargó o se volvió a pedir el arranque—: no se abre otra, y la corrida espera el veredicto de la enviada.

verification_tokenstring | null

El token de la sesión de identidad, para montar su captura. `null` con `status: "submitted"`.

embed_urlstring | null

La url de la captura de identidad dentro de la corrida. `null` con `status: "submitted"`.

expires_atstring (ISO 8601) | null

Cuándo vence esa captura; con `status: "submitted"`, la de la captura que ya se envió.

Example
{
  "status": "started",
  "verification_token": "0b6c2f3e-8d1a-4a7e-9c55-2f1d0e9b7a11",
  "embed_url": "https://app.singula.mx/embed/0b6c2f3e-8d1a-4a7e-9c55-2f1d0e9b7a11",
  "expires_at": "2026-10-06T18:00:00.000Z"
}

Reservar la subida de un paso (1/3)

POST/app/flow-embed/:token/steps/:key/upload/init

Paso 1 de 3 del paso de carga: reserva la subida del documento que pide el paso —la INE, un comprobante, la constancia o un poder— y regresa su POST firmado de S3. El archivo va directo a S3: nunca pasa por la API.

Documento, constancia o poder: hasta 50 MB. INE: hasta 25 MB cada foto. Cada POST fija su tipo y vence en 10 minutos. Reservar NO toma el paso: una reserva cuyo archivo no llega no lo toca. Errores: 400 si falta el nombre o el tipo del archivo, o el paso no es de carga; 409 `step_already_bound` si el intento vivo del paso ya tiene su archivo, o si el paso no es el que sigue o ya está resuelto; 410 token vencido o corrida cerrada; 415 `unsupported_file` (documento: PDF, JPG, PNG, WEBP, HEIC o TIFF; INE: JPG o PNG). 20 por minuto por IP y token. Los tres pasos, con código, están en «Archivos: directo a S3» (arriba, en Fundamentos).

Request
tokenstringpathrequired

El `token` de la sesión.

keystringpathrequired

La clave del paso de captura.

filenamestringbody

Documento, constancia o poder: el nombre del archivo. Es el campo de TODOS los pasos de carga salvo el de INE, que pide dos objetos (ver abajo).

content_typestringbody

Documento, constancia o poder: el tipo del archivo. PDF o imagen (PDF, JPG, PNG, WEBP, HEIC o TIFF); el POST lo fija.

ine_frontobjectbody

Sólo en el paso `ineExtraction`: `{ filename, content_type }` de la foto del FRENTE, JPG o PNG. Ese paso no acepta `filename` suelto.

ine_backobjectbody

Sólo en el paso `ineExtraction`: `{ filename, content_type }` del REVERSO. Las dos caras se reservan en la misma llamada.

curl -X POST https://api.singula.mx/app/flow-embed/{token}/steps/{key}/upload/init \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "filename": "comprobante-domicilio.pdf",
  "content_type": "application/pdf"
}'
Response
request_idstring

El folio de la consulta del paso. Lo mandas al confirmar.

uploadobject

Documento, constancia o poder: su POST firmado, `{ url, fields }`. Manda multipart/form-data a `url` con TODOS los `fields` y el archivo al final, en `file`.

uploadsobject

INE: un POST por cara, `{ ine_front: { url, fields }, ine_back: { url, fields } }`.

Example
{
  "request_id": "66fa1c2b9e3d4a0012345678",
  "upload": {
    "url": "https://singula-documents.s3.amazonaws.com/",
    "fields": { "key": "documents/incoming/66fa1c2b9e3d4a0012345678.pdf", "Content-Type": "application/pdf", "Policy": "<REDACTED>", "X-Amz-Signature": "<REDACTED>" }
  }
}

Confirmar la subida de un paso (3/3)

POST/app/flow-embed/:token/steps/:key/upload/complete

Paso 3 de 3: avisa que el archivo ya está en S3. Revisa el objeto, corre la herramienta del paso sobre el cliente de la corrida y el paso espera su respuesta.

Se cobra cuando la consulta aterriza, con la regla del documento suelto: un archivo que no pudimos procesar antes de cualquier lectura con costo cobra 0 (el primero que sí se lee es el que paga); uno que sí se leyó cobra aunque salga ilegible, y su cargo base si después falla el proceso (`error`); en ese caso, si el paso se vuelve a pedir, el siguiente archivo que sí se lee paga sólo la diferencia hasta su propio cargo. Un archivo por intento: si el paso ya tiene el suyo, 409 `step_already_bound`, también cuando llega una segunda subida al MISMO paso de la MISMA corrida mientras se lee la primera (cada persona tiene su propia corrida, así que nunca se bloquean entre sí). Confirmar dos veces el mismo `request_id` contesta lo mismo. Errores: 400 `empty_file`; 404 `upload_not_found` (no es una reserva de este paso y de su intento vivo); 409 `upload_not_received` (el archivo no está en S3: súbelo con el mismo POST mientras no venza); 410; 413 `file_too_large`. Cuando la corrida pide varios documentos seguidos, la página los recibe juntos en su pantalla «Sube tus documentos» y cada archivo entra al paso que le toca con esta misma regla; uno que ningún paso usa —la persona lo quitó, sobró al cerrar o falló— se cobra una vez en la fila de la corrida, a su cargo como consulta suelta, sólo si ya se había leído, y sale en `unused_uploads` (desde 1.113.0). Asíncrono: la respuesta es el acuse, no el resultado. La página hospedada sigue el avance con `GET /app/flow-embed/status/:token`, y tanto la sesión como el paso se quedan en `awaiting_user` hasta que el registro del paso llega a un estado terminal. El multipart de este paso (POST /app/flow-embed/:token/steps/:key/upload) ya no existe.

Request
tokenstringpathrequired

El `token` de la sesión.

keystringpathrequired

La clave del paso de captura.

request_idstringbodyrequired

El `request_id` que devolvió la reserva de ESTE paso.

passwordstringbody

Contraseña del PDF, cuando viene protegido. Viaja a la lectura y no se guarda.

curl -X POST https://api.singula.mx/app/flow-embed/{token}/steps/{key}/upload/complete \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "request_id": "66fa1c2b9e3d4a0012345678"
}'
Response
request_idstring

El folio de la consulta que quedó corriendo. El runner avanza el paso cuando termina.

Example
{
  "request_id": "66fa1c2b9e3d4a0012345678"
}

Mandar el código al correo

POST/app/flow-embed/:token/steps/:key/otp/send

Manda el código de seis dígitos al correo del cliente de la corrida. Reusa la verificación de correo por código.

En una verificación que abrió un link compartible, cada envío cuenta contra el freno de correos por link e IP: más de 60 en una hora o 300 en un día desde la misma IP responde 429 `{ code: "too_many_emails", window: "hour" | "day", limit, message }`.

Request
tokenstringpathrequired

El `token` de la sesión.

keystringpathrequired

La clave del paso de correo.

curl -X POST https://api.singula.mx/app/flow-embed/{token}/steps/{key}/otp/send \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
sentboolean

`true` cuando el código salió.

Verificar el código del correo

POST/app/flow-embed/:token/steps/:key/otp/verify

Comprueba el código que escribió la persona y cierra el paso.

Request
tokenstringpathrequired

El `token` de la sesión.

keystringpathrequired

La clave del paso de correo.

codestringbodyrequired

El código de seis dígitos que recibió la persona.

curl -X POST https://api.singula.mx/app/flow-embed/{token}/steps/{key}/otp/verify \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "code": "483920"
}'
Response
verifiedboolean

`true` cuando el código coincide.

Entregar la lista de representantes (por token)

POST/app/flow-embed/:token/steps/:key/representatives

La versión pública de la misma entrega: la página hospedada manda las personas que capturó la empresa y el runner abre una corrida KYC hija por cada una. Sin API key: el token de la sesión en la ruta es la autenticación.

Limitada a 10 llamadas por minuto por IP, como el resto del runner público. Mismas guardas y mismos códigos que la ruta con llave (`bad_people_count`, `bad_role`, `email_required`, `bad_name`, `bad_email`, 409 `people_already_submitted`), y 410 si el token venció o la sesión ya cerró. Si la cuenta de tu organización no puede abrir las corridas de las personas, esta ruta pública responde 403 `{ code: "verification_unavailable", message }`, genérico y sin el estado de la cuenta; la ruta con llave conserva su cuerpo. Con `delivery: "email"`, en una verificación que abrió un link, cada entrega cuenta contra el freno de correos por link e IP: 429 `too_many_emails` pasadas 60 en una hora o 300 en un día. Después de entregarla, la página se queda en la pantalla de espera: `GET /app/flow-embed/status/:token` publica `children` con el avance de cada persona.

Request
tokenstringpathrequired

El `token` de la sesión del KYB (`flw_` + uuid).

keystringpathrequired

La clave del paso `representatives`. Tiene que ser el paso en el que va la corrida.

peopleobject[]bodyrequired

El mismo cuerpo que la ruta autenticada: entre `min` y `max` personas `{ name, email?, role }`.

curl -X POST https://api.singula.mx/app/flow-embed/{token}/steps/{key}/representatives \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "people": [
    { "name": "MARIA LOPEZ GARCIA", "email": "[email protected]", "role": "representante_legal" }
  ]
}'
Response
childrenobject[]

Las hijas recién abiertas, con la misma forma que la ruta autenticada. Con `delivery: "links"` es de aquí de donde la página saca los links para repartir.

Saltar un paso opcional

POST/app/flow-embed/:token/steps/:key/skip

Salta un paso que el flujo marcó como no obligatorio y pasa al siguiente.

Sólo sobre un paso con `required: false`. Sobre uno obligatorio la respuesta es 409 (el paso existe y es válido; lo que no procede es saltarlo). El paso queda con `status: "skipped"` y no cobra.

Request
tokenstringpathrequired

El `token` de la sesión.

keystringpathrequired

La clave del paso a saltar.

curl -X POST https://api.singula.mx/app/flow-embed/{token}/steps/{key}/skip \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
next_step_keystring | null

El paso que sigue, o `null` si era el último.

6 endpoints

CURP

Obtén un CURP a partir de los datos de una persona, o valida un CURP de 18 caracteres contra RENAPO y devuelve los datos oficiales del titular (con PDF descargable).

El consulta de CURP (getCurp: POST /app/curp y GET /app/curp/customer/:id) es SÍNCRONO — la respuesta trae el CURP al instante. La validación contra RENAPO (validateCurp: POST /app/curp/validate y GET /app/curp/customer/:id/validate) es ASÍNCRONA por default: la respuesta inmediata trae `request_id` y `data.isValid` (solo confirma que el formato es válido y que la consulta entró a la cola); el resultado final con los datos del titular llega por el webhook configurado de la organización, o se obtiene haciendo polling a GET /dashboard/request/{request_id}. Para recibir el resultado final en la misma respuesta, manda `sync: true` en el body de POST /app/curp/validate (espera interna hasta 15s; si expira devuelve el log parcial con `timed_out: true` y el job sigue en background). En sandbox (API key con prefijo sk_test_) validateCurp devuelve un mock al instante y además dispara el webhook. Auth en todos los endpoints: header `Authorization: Bearer sk_live_...` (producción) o `sk_test_...` (sandbox). VALIDACIONES INCLUIDAS (`bundled_with`): una validación de CURP que PIDES —por la API, el MCP, el panel o un paso de CURP en un flujo— se cobra como siempre. Una que un PRODUCTO necesita por dentro va incluida: la verificación de identidad (con una INE) y la INE de un cliente cotejan contra RENAPO reusando la validación que ya exista de esa CURP y, si no hay ninguna, corren UNA por dentro, sin cargo. Esa interna aparece en tu actividad, en la búsqueda y en el historial del cliente como una consulta de CURP con `bundled_with` = el producto que la corrió (`identityVerification` o `ineExtraction`) y cargo 0. No es una consulta tuya: su detalle (GET /dashboard/request/{request_id}) llega sin la respuesta de RENAPO (`response: {}`), no se escribe en el cliente, no llena la casilla `curp` de un expediente ni un paso de CURP, y pegarla a una corrida responde 409 `bundled_evidence`. En una consulta que pediste tú, `bundled_with` es `null` (un registro anterior puede no traerlo).

Obtener CURP (datos sueltos, sin customer)

POST/app/curp

Síncrono: busca a la persona en RENAPO por sus datos y espera hasta ~35 s (normalmente 1-5 s). Si no existe responde `found: false` y `data: null` (se cobra: es un resultado válido); si RENAPO no responde, `status: 'error'` y no se cobra. Este path NO crea ni actualiza ningún customer. Errores comunes: 400 (validación del body), 401 (API key inválida), 402 (créditos insuficientes).

Request
namestringbodyrequired

Nombre(s) de pila de la persona, SIN apellidos: los apellidos van en `last_name` y `mothers_last_name`. Con el nombre completo aquí puede salir otra CURP.

last_namestringbodyrequired

Apellido paterno.

mothers_last_namestringbody

Apellido materno. Opcional: si se omite, se obtén con 'X' (extranjeros / sin apellido materno).

genderstring (enum: H|M|X)bodyrequired

Sexo: H = Hombre, M = Mujer, X = No binario.

birth_daystringbodyrequired

Día de nacimiento, 1 o 2 dígitos (1–31); se rellena a 2 dígitos automáticamente.

birth_monthstringbodyrequired

Mes de nacimiento, 1 o 2 dígitos (1–12); se rellena a 2 dígitos automáticamente.

birth_yearstringbodyrequired

Año de nacimiento, 4 dígitos (ej. 1990).

birth_placestring (enum de estados MX)body

Estado de nacimiento (ej. JALISCO). Opcional: los extranjeros pueden omitirlo y se usa 'NE' (Nacido en el Extranjero). 'CDMX' / 'Ciudad de Mexico' / 'Distrito Federal' / 'D.F.' se normalizan a 'DF'.

curl -X POST https://api.singula.mx/app/curp \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Juan",
  "last_name": "Perez",
  "mothers_last_name": "Garcia",
  "gender": "H",
  "birth_day": "01",
  "birth_month": "01",
  "birth_year": "1990",
  "birth_place": "JALISCO"
}'
Response
datastring

El CURP OFICIAL de 18 caracteres tal como lo devolvió RENAPO (búsqueda por datos). null si RENAPO no tiene a esa persona (`found: false`) o si sigue respondiendo (`status: pending`). Nunca se calcula: un nombre inventado no obtiene CURP. En sandbox (sk_test_) devuelve el valor fijo 'TEST_CURP'.

foundboolean | null

true = RENAPO devolvió el registro; false = no existe una persona con esos datos; null = aún pendiente.

statusstring

'pending' si RENAPO tardó más de ~35 s (el resultado se lee después en /dashboard/request/{request_id}); 'error' si RENAPO no respondió (no se cobra).

request_idstring

ID del registro de la consulta (LogRequest). Sirve para auditoría y para consultarla en /dashboard/request/{request_id}.

Example
{
  "data": "PEGJ900101HJCRZN01",
  "found": true,
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8"
}

Obtener CURP de un customer existente

GET/app/curp/customer/:id

Obtén el CURP usando los datos ya guardados de un customer y lo persiste en customer.curp.

Síncrono. Toma los datos (nombre, apellidos, sexo, fecha y lugar de nacimiento) del customer; campos opcionales faltantes se manejan solos (materno vacío → 'X', lugar vacío → 'NE'). Además guarda el resultado en customer.curp y corre el risk engine sobre ese customer. Devuelve 409 (Conflict) si el customer no existe o si no tiene sexo capturado (`gender: null`): una CURP lleva el sexo y nunca se asume uno.

Request
idstringpathrequired

ID del customer previamente creado en tu organización.

curl -X GET https://api.singula.mx/app/curp/customer/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
datastring

El CURP obtenido de 18 caracteres (o 'TEST_CURP' en sandbox).

request_idstring

ID del LogRequest de esta consulta.

Example
{
  "data": "PEGJ900101HJCRZN01",
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8"
}

Validar CURP contra RENAPO (sin customer)

POST/app/curp/validate

Valida un CURP de 18 caracteres contra RENAPO y devuelve los datos oficiales del titular. Asíncrono por default; usa sync:true para esperar el resultado inline.

vía HTTP el body con formato inválido se rechaza antes con 400 por validación de patrón. ASYNC por default: la respuesta inmediata trae request_id + data.isValid; el resultado final (persona, file) llega por webhook o por polling a GET /dashboard/request/{request_id}. Con sync:true la respuesta espera hasta 15s y devuelve el LogRequest final ya post-procesado (mismo shape que /dashboard/request/{id}); si expira, devuelve el log con timed_out:true y el job continúa. En sandbox (sk_test_) devuelve un mock inmediato de persona y dispara el webhook. create_customer:true solo aplica en este path (sin customer previo).

Request
curpstringbodyrequired

CURP de 18 caracteres a validar (formato: 4 letras + 6 dígitos + H/M/X + 2 letras + 3 letras + 2 alfanuméricos).

create_customerbooleanbody

Si true, al completar la validación se crea automáticamente un Customer con los datos devueltos (nombre, apellidos, fecha de nacimiento, sexo, CURP); el customer_id aparece en el resultado final. Default false.

syncbooleanbody

Si true, la respuesta espera el resultado final en vez de operar async (timeout interno 15s). Default false.

curl -X POST https://api.singula.mx/app/curp/validate \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "curp": "PEGJ900101HJCRZN01",
  "create_customer": false,
  "sync": false
}'
Response
request_idstring

ID de la consulta. Úsalo para hacer polling en GET /dashboard/request/{request_id} o para casar el evento del webhook.

data.isValidboolean

data.messagestring

(Resultado final) Mensaje del veredicto, ej. 'CURP validado correctamente.'

data.persona.CURPstring

(Resultado final) CURP confirmado por RENAPO.

data.persona.nombresstring

(Resultado final) Nombre(s) oficial(es) del titular.

data.persona.apellidoPaternostring

data.persona.apellidoMaternostring

data.persona.sexostring (HOMBRE|MUJER)

(Resultado final) Sexo registrado en RENAPO, como lo escribe el registro. Es el sexo que vale para esa persona.

data.persona.fechaNacimientostring (DD/MM/YYYY)

(Resultado final) Fecha de nacimiento oficial.

data.persona.nacionalidadstring

(Resultado final) Nacionalidad, ej. 'MEX'.

data.persona.docProbatorionumber

(Resultado final, opcional) Código del documento probatorio de identidad de RENAPO.

data.persona.statusCurpstring

(Resultado final, opcional) Estatus del CURP en RENAPO, ej. 'RCN'.

data.persona.entidadstring

(Resultado final, opcional) Clave de la entidad de nacimiento, ej. 'DF'.

data.filestring | null

(Resultado final) Referencia al PDF oficial de la CURP cuando está disponible; null si no hay archivo. Para descargarlo usa los endpoints /customer/:id/file (requiere customer).

customer_idstring

(Resultado final) Presente solo si mandaste create_customer:true — id del Customer auto-creado.

Example
{
  "data": { "isValid": true },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8"
}

Validar CURP de un customer existente

GET/app/curp/customer/:id/validate

Valida contra RENAPO el CURP ya guardado de un customer; el resultado final incluye un cotejo (match) de los datos de RENAPO contra los del customer en tu BD.

ASYNC (resultado por webhook o GET /dashboard/request/{request_id}); este path GET no acepta sync ni create_customer. Al completar, además del veredicto agrega el objeto `match` (confianza del cotejo RENAPO vs los datos del customer). Devuelve 409 si el customer no existe o no tiene CURP guardado.

Request
idstringpathrequired

ID del customer, que debe tener ya un CURP guardado (customer.curp).

curl -X GET https://api.singula.mx/app/curp/customer/{id}/validate \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

ID de la consulta para polling/webhook.

data.isValidboolean

(Respuesta async inmediata) true si el formato pasó y se encoló a RENAPO.

data.personaobject

(Resultado final) Datos oficiales del titular (mismos campos que en POST /app/curp/validate: CURP, nombres, apellidoPaterno, apellidoMaterno, sexo, fechaNacimiento, nacionalidad, docProbatorio, statusCurp, entidad).

data.filestring | null

(Resultado final) Referencia al PDF oficial de la CURP; descargable vía /customer/:id/file.

data.matchobject

(Resultado final, solo en este path) Cotejo persona-RENAPO vs el customer en tu BD: `{ confidence, passed, fields, validated_at }`, con cada campo de `fields` (`name`, `last_name`, `mothers_last_name`, `curp`, `date_of_birth`, `gender`) como `{ match, expected, actual }` —`expected` es lo de RENAPO y `actual` lo del customer—. Nombres, apellidos y CURP se comparan sin acentos, mayúsculas, puntuación ni espacios: «Diana Alejandra.» es «DIANA ALEJANDRA». `confidence`: `high` = todo lo que cuenta coincide; `medium` = una diferencia suave (segundo apellido o fecha de nacimiento); `low` = otra CURP, otro nombre de pila u otro primer apellido, o las dos suaves. `passed` = `high` o `medium`. `fields.gender` es INFORMATIVO (`informational: true`, el único campo que lo trae): se reporta, pero nunca cuenta para `confidence` ni `passed`. El sexo que vale es el de RENAPO (`expected`, HOMBRE o MUJER), y el del customer (`actual`) casi nunca se captura: `null` cuando no se capturó; el cotejo de sexo que sí cuenta es el del documento contra RENAPO, en la verificación de identidad.

Example
{
  "data": { "isValid": true },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8"
}

Obtener URL firmada del PDF de CURP del customer

GET/app/curp/customer/:id/file

Devuelve una URL firmada temporal (15 min) para descargar el último PDF oficial de CURP validado del customer.

Gratis (no lleva @ToolCost, no consume créditos). Devuelve 404 si el customer no existe. La URL firmada dura 15 minutos. Si prefieres evitar CORS/content-type de S3, usa el endpoint /file/download que proxya el PDF.

Request
idstringpathrequired

ID del customer.

curl -X GET https://api.singula.mx/app/curp/customer/{id}/file \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
urlstring | null

URL firmada de S3 para descargar el PDF; null si aún no hay archivo.

expires_atstring (ISO 8601) | null

Momento en que expira la URL firmada (15 min desde la petición); null si no hay archivo.

s3_keystring

Key del objeto en S3 (presente solo cuando hay archivo).

bucketstring

Bucket de S3 donde vive el PDF (presente solo cuando hay archivo).

reasonstring

Presente solo cuando url es null. 'no_validation_yet' = el customer aún no se ha validado; 'no_file_stored' = se validó pero no quedó PDF guardado.

Example
{
  "url": "https://s3.amazonaws.com/singula-curp/CURP_PEGJ900101HJCRZN01.pdf?X-Amz-Signature=REDACTED",
  "expires_at": "2026-09-03T18:15:00.000Z",
  "s3_key": "curp/6622f8a1c1d2e3f4a5b6c7d8.pdf",
  "bucket": "singula-curp"
}

Descargar (stream) el PDF de CURP del customer

GET/app/curp/customer/:id/file/download

Descarga el PDF oficial de CURP proxiado por MasterApi (Content-Type application/pdf inline), evitando problemas de CORS y content-type de las URLs firmadas de S3.

Gratis (sin @ToolCost). Responde con los bytes del PDF, no con JSON. Devuelve 404 si el customer no tiene archivo de CURP todavía, y 422 si el objeto en S3 no es un PDF válido. Auto-repara archivos legados que quedaron guardados en base64.

Request
idstringpathrequired

ID del customer.

curl -X GET https://api.singula.mx/app/curp/customer/{id}/file/download \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
(binario)application/pdf

El cuerpo de la respuesta es el PDF crudo (no JSON). Headers: Content-Type: application/pdf, Content-Disposition: inline; filename="CURP_<curp>.pdf", Cache-Control: private, max-age=60.

5 endpoints

RFC

Obtén el RFC de una persona física o moral a partir de sus datos, y valida un RFC contra el padrón del SAT (formato + registro real).

Los consultas de RFC son SÍNCRONOS: el RFC llega en la misma respuesta HTTP (GET /app/rfc/customer/:id, POST /app/rfc, POST /app/rfc/company). La VALIDACIÓN contra el SAT (POST /app/rfc/validate y GET /app/rfc/customer/:id/validate) encola un trabajo al Worker que consulta al SAT y la API bloquea haciendo poll interno hasta ~30 s, devolviendo el veredicto en la MISMA llamada: no necesitas consultar un status ni esperar el webhook en el caso normal. Si la consulta al SAT excede 30 s, la respuesta trae status='pending' y timed_out=true, el trabajo sigue corriendo en segundo plano, y (si tu organización tiene webhook configurado) el resultado final se entrega por webhook al terminar. Con una API key de sandbox (sk_test_) todo responde de inmediato con datos de prueba, sin tocar al SAT.

Obtener RFC de persona física (sin customer)

POST/app/rfc

Obtén el RFC de una persona física a partir del nombre, apellidos y fecha de nacimiento enviados en el body.

NO crea ni modifica ningún customer (si quieres persistir estos datos, llama aparte a POST /customer). En sandbox (sk_test_) devuelve el valor fijo 'TEST_RFC'. 402 si no hay saldo suficiente; 400 si algún campo falla la validación de formato.

Request
Authorizationstringheaderrequired

API key como token Bearer. Usa 'Bearer sk_live_...' para producción o 'Bearer sk_test_...' para sandbox.

namestringbodyrequired

Nombre(s) de pila de la persona, SIN apellidos: los apellidos van en `last_name` y `mothers_last_name`. Con el nombre completo aquí puede salir otro RFC. Ej: 'Juan'.

last_namestringbodyrequired

Apellido paterno.

mothers_last_namestringbody

Apellido materno. Opcional: déjalo vacío o fuera si no aplica (extranjeros); internamente se usa 'X'.

birth_daystringbodyrequired

Día de nacimiento, 1 o 2 dígitos (1–31). Se rellena a 2 dígitos automáticamente.

birth_monthstringbodyrequired

Mes de nacimiento, 1 o 2 dígitos (1–12).

birth_yearstringbodyrequired

Año de nacimiento, 4 dígitos. Ej: '1990'.

curl -X POST https://api.singula.mx/app/rfc \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Juan",
  "last_name": "Perez",
  "mothers_last_name": "Garcia",
  "birth_day": "01",
  "birth_month": "01",
  "birth_year": "1990"
}'
Response
datastring

RFC obtenido de 13 caracteres (con homoclave), listo para usar.

request_idstring

ID de la solicitud (LogRequest) para auditoría y trazabilidad.

Example
{
  "data": "PEGJ900101XXX",
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8"
}

Obtener RFC de persona moral (empresa)

POST/app/rfc/company

Obtén el RFC de 12 caracteres de una persona moral a partir de su razón social y fecha de constitución/registro.

Consulta puro, síncrono, sin consultar al SAT. No está ligado a ningún customer (no lo crea ni actualiza). En sandbox (sk_test_) devuelve el valor fijo 'TEST_COMPANY_RFC'. 402 sin saldo; 400 en error de formato.

Request
Authorizationstringheaderrequired

API key como token Bearer ('Bearer sk_live_...' o 'Bearer sk_test_...').

namestringbodyrequired

Razón social / nombre de la empresa. Ej: 'Singula SA de CV'.

registrationDaystringbodyrequired

Día de constitución/registro, 1 o 2 dígitos (1–31).

registrationMonthstringbodyrequired

Mes de constitución/registro, 1 o 2 dígitos (1–12).

registrationYearstringbodyrequired

Año de constitución/registro, 4 dígitos. Ej: '2020'.

curl -X POST https://api.singula.mx/app/rfc/company \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Singula SA de CV",
  "registrationDay": "01",
  "registrationMonth": "01",
  "registrationYear": "2020"
}'
Response
datastring

RFC de persona moral obtenido (12 caracteres, con homoclave).

request_idstring

ID de la solicitud (LogRequest) para auditoría.

Example
{
  "data": "SIN200101XX9",
  "request_id": "6622f8a1c1d2e3f4a5b6c7d9"
}

Validar RFC contra el SAT (sin customer)

POST/app/rfc/validate

Valida un RFC: primero el formato y luego si está registrado en el padrón de contribuyentes del SAT (consulta real).

Si tu organización tiene webhook configurado, el resultado final también se dispara por webhook al terminar. En sandbox (sk_test_) responde de inmediato con un veredicto mock (isValid=true, registered=true) y dispara el webhook de prueba. 400 si el rfc no cumple el patrón; 402 sin saldo.

Request
Authorizationstringheaderrequired

API key como token Bearer ('Bearer sk_live_...' o 'Bearer sk_test_...').

rfcstringbodyrequired

RFC a validar. 13 caracteres para persona física, 12 para persona moral. Debe cumplir el patrón oficial (3–4 letras + 6 dígitos de fecha + 3 alfanuméricos).

curl -X POST https://api.singula.mx/app/rfc/validate \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "rfc": "PEGJ900101XXX"
}'
Response
data.messagestring

Texto literal del veredicto del SAT. Ej: 'RFC válido, y susceptible de recibir facturas.'

data.isValidboolean

true si el RFC es válido / existe en el padrón del SAT.

data.registeredboolean

true si el RFC está registrado en el padrón de contribuyentes del SAT.

request_idstring

ID de la solicitud (LogRequest); sirve para consultar el resultado más tarde si hubo timeout.

statusstring

'success' = RFC registrado; 'completed' = consulta exitosa pero el RFC no está registrado; 'pending' = la consulta al SAT no terminó dentro del tiempo de espera.

timed_outboolean

Presente solo cuando status='pending'. true indica que la API dejó de esperar tras ~30 s pero el trabajo sigue corriendo en segundo plano.

Example
{
  "data": {
    "message": "RFC válido, y susceptible de recibir facturas.",
    "isValid": true,
    "registered": true
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7da",
  "status": "success"
}

Obtener RFC de un customer (con customer)

GET/app/rfc/customer/:id

Obtén el RFC de un customer ya existente a partir de los datos que tiene guardados (nombre, apellidos, fecha de nacimiento) y lo persiste en el customer.

Efectos secundarios frente a la versión sin customer: guarda el RFC obtenido en el campo rfc del customer y ejecuta el risk engine sobre ese customer. Maneja de forma defensiva a extranjeros sin apellido materno (usa 'X'). Devuelve 409 (Conflict) 'Customer not found' si el ID no existe en tu entorno. En sandbox (sk_test_) devuelve 'TEST_RFC'. 402 sin saldo.

Request
Authorizationstringheaderrequired

API key como token Bearer ('Bearer sk_live_...' o 'Bearer sk_test_...').

idstringpathrequired

ID del customer previamente creado en tu organización. El customer debe existir en el mismo entorno (producción o sandbox) que tu API key.

curl -X GET https://api.singula.mx/app/rfc/customer/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
datastring

RFC obtenido (13 caracteres) a partir de los datos guardados del customer.

request_idstring

ID de la solicitud (LogRequest) para auditoría.

Example
{
  "data": "PEGJ900101XXX",
  "request_id": "6622f8a1c1d2e3f4a5b6c7db"
}

Validar RFC de un customer contra el SAT (con customer)

GET/app/rfc/customer/:id/validate

Valida contra el SAT el RFC que ya tiene guardado un customer existente (formato + registro en el padrón).

Consulta real al SAT vía Worker con poll interno de hasta ~30 s (mismo comportamiento que POST /app/rfc/validate). Al resolver, actualiza el risk engine del customer. Devuelve 409 (Conflict) 'Customer not found' si el ID no existe, o 409 'Customer has no RFC' si el customer aún no tiene RFC guardado (llama primero a GET /app/rfc/customer/:id). En sandbox (sk_test_) responde con veredicto mock inmediato.

Request
Authorizationstringheaderrequired

API key como token Bearer ('Bearer sk_live_...' o 'Bearer sk_test_...').

idstringpathrequired

ID del customer. Debe existir y tener un RFC ya guardado (p. ej. obtenido antes con GET /app/rfc/customer/:id).

curl -X GET https://api.singula.mx/app/rfc/customer/{id}/validate \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
data.messagestring

Texto literal del veredicto del SAT.

data.isValidboolean

true si el RFC es válido / existe en el padrón del SAT.

data.registeredboolean

true si el RFC está registrado en el padrón de contribuyentes del SAT.

request_idstring

ID de la solicitud (LogRequest).

statusstring

'success' = RFC registrado; 'completed' = consultado con éxito pero no registrado; 'pending' = la consulta no terminó a tiempo.

timed_outboolean

Presente solo con status='pending'; true = la API dejó de esperar (~30 s) pero el job sigue en segundo plano.

Example
{
  "data": {
    "message": "RFC válido, y susceptible de recibir facturas.",
    "isValid": true,
    "registered": true
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7dc",
  "status": "success"
}
4 endpoints

INE (extracción)

Lee los datos de una credencial para votar INE/IFE (anverso + reverso) y, cuando el modelo lo permite, valida la Clave de Elector contra la Lista Nominal del INE — sin selfie, sin prueba de vida, sin comparación facial. Sobre un cliente con CURP, la credencial se coteja además contra RENAPO, incluido sin cargo extra. Las dos fotos van directo a S3: reservas con `…/init`, subes cada cara con su POST firmado y confirmas con `…/complete`.

Asíncrono y en tres pasos: la reserva (…/init) regresa el request_id y un POST firmado de S3 por cara; subes las dos fotos directo a S3; y la confirmación (POST /app/ine-extraction/request/:requestId/complete) responde de inmediato { request_id, status: "processing" }, antes de leer nada. Con las fotos en S3 se lee la credencial y, cuando el modelo inferido es C/D/E/F/G/H y la lectura pasa los filtros de confianza y calidad, se valida contra la Lista Nominal del INE. Cuando la cadena termina se dispara UN webhook a la URL configurada por la organización; trae `listanominal_outcome`, lo que contestó la Lista Nominal (`null` si no hubo consulta). El resultado completo se obtiene con GET /app/ine-extraction/status/:requestId (poll o al recibir el webhook). El estado avanza pending → processing → success | completed | error. Un `error` antes de cualquier llamada con costo no se cobra; uno después de leer la credencial se cobra su cargo base (desde 1.113.0), también la página de mantenimiento del INE (`maintenance`), que sale como `error` en la consulta suelta igual que dentro de un flujo. Las subidas multipart (POST /app/ine-extraction y …/customer/:customerId) están obsoletas: contestan con los encabezados Deprecation y Sunset hasta el 30 de octubre de 2026 y después se apagan.

Reservar la subida de una INE (consulta directa)

POST/app/ine-extraction/init

Paso 1 de 3, sin customer: declaras las dos fotos —nombre y tipo de cada una— y recibes el request_id más un POST firmado de S3 por cara. Las fotos van directo a S3: la API nunca recibe los bytes.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningún customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si después quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta. Las fotos se suben desde tu servidor, cada una con su POST, y luego confirmas con POST /app/ine-extraction/request/:requestId/complete: reservar no arranca nada. Los tres pasos, con código, están en «Archivos: directo a S3» (arriba, en Fundamentos). Errores: 400 si falta una cara o su tipo no es JPG o PNG, 401, 402. La subida multipart a POST /app/ine-extraction está obsoleta y se apaga el 30 de octubre de 2026.

Request
ine_frontobjectbodyrequired

La foto del ANVERSO (frente): `{ filename, content_type }`. JPG o PNG (`image/jpeg` o `image/png`); el POST firmado de esa cara fija ese tipo.

ine_backobjectbodyrequired

La foto del REVERSO, con la misma forma: `{ filename, content_type }`, JPG o PNG.

min_ocr_confidencenumberbody

Umbral 0..1 de confianza de la lectura de la credencial por debajo del cual NO se consulta la Lista Nominal. Default 0.5; manda 0 para desactivar el filtro. Va aquí, en la reserva: la confirmación no lleva cuerpo.

curl -X POST https://api.singula.mx/app/ine-extraction/init \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -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"
  }
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Es el que confirmas en el paso 3, el que consultas después y el que sirve para guardarla como cliente.

uploads.ine_frontobject

El POST firmado del anverso: `{ url, fields }`. Manda multipart/form-data a `url` con TODOS los `fields` y la foto al final, en `file`. Hasta 25 MB por foto; vence en 10 minutos y no lleva tu llave.

uploads.ine_backobject

El POST firmado del reverso, con la misma forma.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "uploads": {
    "ine_front": {
      "url": "https://singula-kyc-files.s3.amazonaws.com/",
      "fields": { "key": "…", "Content-Type": "image/jpeg", "Policy": "<REDACTED>", "X-Amz-Signature": "<REDACTED>" }
    },
    "ine_back": {
      "url": "https://singula-kyc-files.s3.amazonaws.com/",
      "fields": { "key": "…", "Content-Type": "image/jpeg", "Policy": "<REDACTED>", "X-Amz-Signature": "<REDACTED>" }
    }
  }
}

Reservar la subida de una INE (con customer)

POST/app/ine-extraction/customer/:customerId/init

Paso 1 de 3, sobre un customer: declaras las dos fotos —nombre y tipo de cada una— y recibes el request_id más un POST firmado de S3 por cara. Las fotos van directo a S3: la API nunca recibe los bytes.

El CreditGuard verifica saldo por adelantado (402 'Insufficient credits' si no alcanza). Sobre un cliente, el cruce de la INE contra RENAPO viene INCLUIDO, sin cargo extra: se reusa la validación de CURP que el cliente ya tenga y, si no hay ninguna, se corre UNA por dentro al confirmar la subida (…/complete), sin cobrarla; reservar no consulta nada. Esa validación interna sale en tu actividad como una consulta de CURP con `bundled_with: "ineExtraction"` (ver CURP). Una validación de CURP que pidas tú (POST /app/curp/validate, o un paso de CURP en un flujo) sí se cobra. Sin cliente no hay CURP que cruzar. Reservar no arranca nada: sube cada foto con su POST y confirma con POST /app/ine-extraction/request/:requestId/complete. Los tres pasos, con código, están en «Archivos: directo a S3» (arriba, en Fundamentos). Errores: 400 si falta una cara o su tipo no es JPG o PNG; 404 si el customer no existe en ese entorno; 401 API key inválida. La subida multipart a POST /app/ine-extraction/customer/:customerId está obsoleta y se apaga el 30 de octubre de 2026.

Request
Authorizationstringheaderrequired

API key como Bearer token. Formato: 'Bearer <API_KEY>'.

customerIdstringpathrequired

ID del customer (persona) bajo el cual se registra la extracción. Debe existir en el mismo entorno (sandbox si la API key es de desarrollo, producción si no); si no existe devuelve 404.

ine_frontobjectbodyrequired

La foto del ANVERSO (frente): `{ filename, content_type }`. JPG o PNG (`image/jpeg` o `image/png`); el POST firmado de esa cara fija ese tipo.

ine_backobjectbodyrequired

La foto del REVERSO, con la misma forma: `{ filename, content_type }`, JPG o PNG.

min_ocr_confidencenumberbody

Umbral 0..1 de confianza de la lectura de la credencial por debajo del cual NO se consulta la Lista Nominal. Default 0.5; manda 0 para desactivar el filtro. Va aquí, en la reserva: la confirmación no lleva cuerpo.

curl -X POST https://api.singula.mx/app/ine-extraction/customer/{customerId}/init \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -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"
  }
}'
Response
request_idstring

ID de la solicitud (id del LogRequest). Es el que confirmas en el paso 3, el que consultas en GET .../status/:requestId y el que casa el webhook.

uploads.ine_frontobject

El POST firmado del anverso: `{ url, fields }`. Manda multipart/form-data a `url` con TODOS los `fields` y la foto al final, en `file`. Hasta 25 MB por foto; vence en 10 minutos y no lleva tu llave.

uploads.ine_backobject

El POST firmado del reverso, con la misma forma.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "uploads": {
    "ine_front": {
      "url": "https://singula-kyc-files.s3.amazonaws.com/",
      "fields": { "key": "…", "Content-Type": "image/jpeg", "Policy": "<REDACTED>", "X-Amz-Signature": "<REDACTED>" }
    },
    "ine_back": {
      "url": "https://singula-kyc-files.s3.amazonaws.com/",
      "fields": { "key": "…", "Content-Type": "image/jpeg", "Policy": "<REDACTED>", "X-Amz-Signature": "<REDACTED>" }
    }
  }
}

Confirmar la subida de la INE

POST/app/ine-extraction/request/:requestId/complete

Paso 3 de 3: revisa que las dos fotos ya estén en S3 y arranca la lectura de la credencial y, cuando el modelo lo permite, la consulta a la Lista Nominal. Es el mismo para la consulta directa y para la de un customer.

El cuerpo va vacío ({}). Idempotente: confirmar dos veces contesta lo mismo y arranca el trabajo una sola vez. 409 `upload_not_received` si una de las dos fotos todavía no está en S3: súbela con el mismo POST mientras no venza y vuelve a confirmar. 400 `empty_file` si una foto llegó vacía; 413 `file_too_large` si pasa de 25 MB; 404 si el request_id no existe o no es una INE de tu organización. Desde aquí todo es igual que antes: el mismo webhook, el mismo estado y el mismo cobro. Lo entregado se cobra como siempre; si la consulta falla antes de cualquier llamada con costo —una credencial que nunca se llegó a leer—, no se cobra (en una cuenta en prueba, lo apartado regresa); si falla después, se cobra su cargo base, aunque la falla sea nuestra. Una INE ya leída cuya consulta a la Lista Nominal no contestó (mantenimiento o sin respuesta) se cobra.

Request
Authorizationstringheaderrequired

API key como Bearer token. Formato: 'Bearer <API_KEY>'.

requestIdstringpathrequired

El request_id que devolvió la reserva (POST /app/ine-extraction/init o …/customer/:customerId/init). Tiene que ser de tu organización.

curl -X POST https://api.singula.mx/app/ine-extraction/request/{requestId}/complete \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
Response
request_idstring

El mismo id de la reserva.

statusstring

'processing': el trabajo ya arrancó. El resultado llega por webhook y por GET /app/ine-extraction/status/:requestId.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "status": "processing"
}

Consultar estado y resultado de la extracción

GET/app/ine-extraction/status/:requestId

Devuelve el snapshot del LogRequest: los datos leídos de la credencial, el veredicto de Lista Nominal y URLs firmadas de las fotos subidas.

solo requiere API key válida. Es de solo lectura y está limitado a la organización de la API key (no puedes leer requests de otra organización). Puedes hacer polling hasta ver status 'success'/'completed'/'error', o esperar el webhook. Las URLs de 'files' caducan a los 10 min; vuelve a llamar este endpoint para refrescarlas. Nota: aunque la reserva se hace con /customer/:customerId/init, la consulta de estado NO es customer-scoped: se busca por request_id + organización.

Request
Authorizationstringheaderrequired

API key como Bearer token. Formato: 'Bearer <API_KEY>'.

requestIdstringpathrequired

El request_id que devolvió la reserva (…/init). Debe pertenecer a la misma organización de la API key; si no existe o es de otra organización devuelve 404.

curl -X GET https://api.singula.mx/app/ine-extraction/status/{requestId} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

ID de la solicitud consultada (eco del path).

statusstring

Estado del flujo: pending → processing → success | completed | error. 'success'/'completed' = terminó; 'error' = falló.

responseobject

Contenido llenado al terminar. Vacío ({}) hasta que completa. Trae 'extract' (lo leído de la credencial) y 'listanominal' (veredicto de Lista Nominal).

response.extract.ine_modelstring|null

Modelo de credencial inferido. A/B = IFE legado (no validable). C/D = 2008-2014. E/F/G/H = 2014+. 'unknown' = la lectura fue demasiado ambigua.

response.extract.extracted_dataobject

Campos leídos de la credencial (nombre, apellido_paterno, apellido_materno, curp, clave_elector, etc.).

response.extract.checksarray<object>

Validaciones internas (formato de CURP, dígitos verificadores de MRZ, etc.). Cada item: { check_name, passed, confidence }. En una INE sobre un cliente con CURP trae además los cruces de la credencial contra RENAPO, incluidos sin cargo extra: `cross_ref_curp_document_vs_renapo`, `cross_ref_name_document_vs_renapo`, `cross_ref_dob_document_vs_renapo` y `cross_ref_sex_document_vs_renapo` (sólo si RENAPO confirmó esa CURP y su registro describe al cliente).

response.extract.qualityobject|null

Reporte de calidad de las imágenes subidas. Es INFORMATIVO: nunca rechaza por su cuenta, sólo pondera el score. { front, back, ok }; cada cara: { ok, score 0..1, issues: [{ code: blurry|grayscale|glare|low_resolution|cropped|dark|unreadable, severity: low|medium|high }] }. back va en null en documentos de una sola cara. Ausente en extracciones anteriores a este bloque.

response.extract.derivedobject

Lo que se DEDUCE de la credencial al leerla, sin cargo extra: { rfc_calculado (RFC calculado con nombre + fecha de nacimiento), entidad_nacimiento y pais_nacimiento (posiciones 12-13 de la CURP; NE = nacido en el extranjero), nacionalidad }. Cada campo va en null cuando no se pudo deducir — nunca se adivina — y el bloque se omite si ninguno se pudo calcular. Se computa al consultar, así que también aparece en extracciones viejas.

response.listanominal.outcomestring

Resultado de Lista Nominal. Veredictos INE: vigente / dada_de_baja / no_encontrada / duplicada_o_robada / datos_no_coinciden. Rechazos de filtro previo (sin gasto): unsupported_model / missing_fields / bad_field_format / low_confidence / inconsistent_data. Fallas del servicio: maintenance (el INE mostró su página de mantenimiento) / unknown / scraping_error; dejan la consulta en `error` y, como la credencial ya se leyó, se cobra su cargo base (desde 1.113.0: la lectura ya nos costó), en la consulta suelta igual que dentro de un flujo.

response.listanominal.baja_reasonstring|null

Causa específica cuando outcome = 'dada_de_baja' (duplicado, defuncion, cancelacion_tramite, perdida_nacionalidad, documentacion_apocrifa, suspension_derechos, domicilio_irregular, datos_irregulares, vencimiento, no_recogida). null si la página no la mostró; ausente en otros outcomes.

response.listanominal.model_usedstring|null

Modelo INE/IFE (A-H) usado para la consulta.

response.listanominal.messagestring

Resumen legible del veredicto.

response.listanominal.queried_atstring

Timestamp ISO de cuándo se consultó Lista Nominal.

filesobject

URLs firmadas (TTL 10 min) de las fotos que SÍ llegaron, para mostrarlas en UI: { ine_front, ine_back }. Sólo trae el lado cuya foto existe; viene vacío ({}) si no llegó ninguna.

files_receivedobject

Qué fotos llegaron: { ine_front: boolean, ine_back: boolean }. Viene en todos los estados, también en una reserva que venció sin sus fotos (`error_code: upload_expired`), para saber si faltó una cara o las dos.

created_atstring|null

Fecha de creación del LogRequest (ISO). null si no está disponible.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "status": "success",
  "response": {
    "extract": {
      "ine_model": "G",
      "extracted_data": {
        "nombre": "MARIA",
        "apellido_paterno": "LOPEZ",
        "apellido_materno": "GARCIA",
        "curp": "LOGM900101MDFXXX01",
        "clave_elector": "LPGRMR90010109M100"
      },
      "checks": [
        { "check_name": "curp_format", "passed": true, "confidence": 0.98 },
        { "check_name": "clave_elector_format", "passed": true, "confidence": 0.95 }
      ],
      "quality": {
        "front": { "ok": true, "score": 0.94, "issues": [] },
        "back": { "ok": false, "score": 0.61, "issues": [{ "code": "glare", "severity": "medium" }] },
        "ok": true
      },
      "derived": {
        "rfc_calculado": "LOGM900101",
        "entidad_nacimiento": "CIUDAD DE MEXICO",
        "pais_nacimiento": "MEXICO",
        "nacionalidad": "MEXICANA"
      }
    },
    "listanominal": {
      "outcome": "vigente",
      "baja_reason": null,
      "model_used": "G",
      "message": "Tu credencial es vigente",
      "queried_at": "2026-09-03T18:30:12Z"
    }
  },
  "files": {
    "ine_front": "https://s3.amazonaws.com/singula-kyc/...signed...",
    "ine_back": "https://s3.amazonaws.com/singula-kyc/...signed..."
  },
  "files_received": { "ine_front": true, "ine_back": true },
  "created_at": "2026-09-03T18:29:40.000Z"
}
13 endpoints

Verificación de identidad (KYC)

Verifica la identidad de una persona con su INE, su pasaporte o su tarjeta de residente más una selfie con prueba de vida, y regresa un veredicto (verified / partial / rejected / failed) con los datos extraídos del documento. Incluido, sin cargo extra: con un cliente con CURP, la INE, el pasaporte o la tarjeta de residente se coteja contra RENAPO, y la INE se consulta además en la Lista Nominal. Desde MasterApi 1.128.0 el pasaporte es un documento de primera: se leen los dígitos verificadores de su zona MRZ, lo impreso y la CURP contra el MRZ, la CURP impresa contra RENAPO y su vigencia, y corre la misma prueba de vida guiada que la INE. Desde MasterApi 1.130.0 se ofrece también la tarjeta de residente del INM, temporal o permanente, con la misma regla sobre la zona MRZ de su reverso; una permanente no vence. Ningún servicio público confirma que un pasaporte o una tarjeta de residente se haya expedido: lo que se comprueba es su coherencia, su CURP ante RENAPO, su vigencia, el rostro y que la persona está presente. Es la misma regla por la API, el panel, el SDK, los links y los flujos.

Flujo en varias fases y asíncrono. 1) POST customer/:customerId/create genera la sesión, un token y dos URLs de captura (verification_url para abrir como pestaña, embed_url para el iframe del SDK @singula_systems/identity-js). Para EMBEBER hace falta registrar antes los orígenes permitidos de tu sitio en Ajustes › Marca; sin ellos el widget no arranca (el link directo no los necesita). 2) La persona captura sus fotos en la página hospedada; cada foto del documento se revisa al confirmarse y, si no sirve, se le pide otra antes de enviar, sin repetir la prueba de vida (con tus propias pantallas, GET upload/:token/precheck). 3) El veredicto llega por el webhook identity.completed (sobre firmado { id, type, created, data }, con un follow-up informativo identity.listanominal.completed, cuyo `status` es el de la verificación y cuyo `listanominal_outcome` dice qué contestó la Lista Nominal —`null` si no hubo consulta—), leyendo GET status/:requestId — o GET result/:requestId si además quieres las fotos firmadas — o siguiendo el stream GET events/:requestId. Además existe identity.session.created al crear el link. Crear el link y cancelar no cobran. El veredicto de la persona verificada está APAGADO por defecto: el navegador recibe decision: withheld y el veredicto se lee del lado del servidor. Guía de integración completa del widget: https://singula.mx/docs/identidad

Cancelar verificación

POST/app/identity-verification/customer/:customerId/cancel

Cancela la verificación activa (pendiente o enviada) del cliente: invalida el link y marca la solicitud como cancelada si aún no produjo veredicto. Permite iniciar una nueva.

Requiere API key (Bearer). Devuelve 404 si el cliente no tiene una verificación activa (pendiente/enviada) que cancelar.

Request
customerIdstringpathrequired

ID del cliente cuya verificación activa se cancela.

curl -X POST https://api.singula.mx/app/identity-verification/customer/{customerId}/cancel \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
cancelledboolean

true cuando el token se marcó como cancelado.

request_idstring

ID del LogRequest asociado a la verificación cancelada.

log_cancelledboolean

true si el LogRequest estaba pendiente y también se marcó como cancelado; false si ya había llegado a un estado terminal (no se sobrescribe un veredicto existente).

Example
{
  "cancelled": true,
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "log_cancelled": true
}

Pedir los POST firmados de las fotos (público, por token)

POST/app/identity-verification/upload/:token/presign

Paso 1 de 3 de cada foto: declaras qué fotos vas a subir y recibes un POST firmado de S3 por foto. Las fotos van directo a S3; la API nunca recibe los bytes. Se autentica con el token del link, no con API key.

Firmar no registra, no encola ni cobra nada. Cada foto tiene siempre la misma llave en S3: pedir otra vez su POST y volver a subirla sobrescribe el mismo objeto, y los cuadros de la prueba de vida no se duplican. Después de subir, confirma con upload/:token/confirm y, con todo confirmado, lanza la verificación con submit/:token. Los tres pasos, con código, están en «Archivos: directo a S3» (arriba, en Fundamentos). Errores: 400 si una foto es desconocida, está repetida o su index está fuera de rango, y 400 `document_type_not_offered` si es de un documento que la sesión no ofrece (`document_types`; desde MasterApi 1.130.0, sin registrar nada); 404 token no encontrado; 409 ya enviada; 410 token expirado; 415 `unsupported_file` si no es JPG ni PNG; 429 pasado el tope —600 llamadas por minuto por sesión (el token) y 3000 por IP— con `Retry-After`, los segundos para reintentar. La subida multipart a POST /app/identity-verification/upload/:token está obsoleta (mismos topes) y se quita en la siguiente versión.

Request
tokenstringpathrequired

verification_token del link creado.

assetsobject[]bodyrequired

Las fotos a subir, cada una una sola vez: `{ kind, content_type }` con kind ine_front | ine_back | passport_image | resident_front | resident_back | selfie, o `{ kind: "liveness_frame", index, content_type }` por cada cuadro de la prueba de vida (index de 0 a 59). content_type: JPG o PNG (`image/jpeg` o `image/png`); el POST lo fija. De 1 a 64 fotos por llamada, ninguna dos veces.

curl -X POST https://api.singula.mx/app/identity-verification/upload/{token}/presign \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "assets": [
    {
      "kind": "ine_front",
      "content_type": "image/jpeg"
    },
    {
      "kind": "ine_back",
      "content_type": "image/jpeg"
    },
    {
      "kind": "selfie",
      "content_type": "image/jpeg"
    },
    {
      "kind": "liveness_frame",
      "index": 0,
      "content_type": "image/jpeg"
    }
  ]
}'
Response
uploadsobject[]

Un POST firmado por foto, en el orden pedido: `{ kind, index?, url, fields }`. Manda multipart/form-data a `url` con TODOS los `fields` y la foto al final, en `file`. Hasta 25 MB por foto; vence en 2 minutos (pídelo justo antes de subir) y no lleva tu llave.

Example
{
  "uploads": [
    { "kind": "ine_front", "url": "https://singula-kyc-files.s3.amazonaws.com/", "fields": { "key": "…", "Content-Type": "image/jpeg", "Policy": "<REDACTED>", "X-Amz-Signature": "<REDACTED>" } },
    { "kind": "liveness_frame", "index": 0, "url": "https://singula-kyc-files.s3.amazonaws.com/", "fields": { "key": "…", "Content-Type": "image/jpeg", "Policy": "<REDACTED>", "X-Amz-Signature": "<REDACTED>" } }
  ]
}

Confirmar las fotos subidas (público, por token)

POST/app/identity-verification/upload/:token/confirm

Paso 3 de 3 de cada foto: revisa que cada objeto ya esté en S3 y lo registra en la sesión, igual que la subida de siempre. No lanza la verificación: eso lo hace submit/:token.

Si falta una sola foto en S3, 409 `upload_not_received` (la nombra) y no se registra NINGUNA: súbela y vuelve a confirmar. Se puede llamar a medida que se capturan las fotos, y confirmar otra vez la misma foto no la duplica. Confirmar una foto del documento (ine_front, ine_back, passport_image, resident_front o resident_back) arranca su revisión previa, que se lee en GET upload/:token/precheck, y una revisión nunca hace fallar una subida. Desde MasterApi 1.130.0 la sesión verifica el documento de la ÚLTIMA foto del documento que confirmaste —o el que nombre `submit` con `document_type`—: cambiar de documento a media captura es confirmar las fotos del nuevo. Errores: 400 `empty_file` o una foto desconocida, repetida o fuera de rango, y 400 `document_type_not_offered` si es de un documento que la sesión no ofrece (no se registra ninguna foto del lote); 404 token no encontrado; 409 ya enviada; 410 token expirado; 413 `file_too_large` (más de 25 MB); 429 pasado el tope —600 por minuto por sesión y 3000 por IP— con `Retry-After`. Se autentica por token en la URL, no con API key.

Request
tokenstringpathrequired

verification_token del link creado.

assetsobject[]bodyrequired

Las fotos que ya subiste: `{ kind }` con kind ine_front | ine_back | passport_image | resident_front | resident_back | selfie, o `{ kind: "liveness_frame", index }` por cada cuadro. De 1 a 64 fotos por llamada, ninguna dos veces.

curl -X POST https://api.singula.mx/app/identity-verification/upload/{token}/confirm \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "assets": [
    {
      "kind": "ine_front"
    },
    {
      "kind": "ine_back"
    },
    {
      "kind": "selfie"
    },
    {
      "kind": "liveness_frame",
      "index": 0
    }
  ]
}'
Response
confirmedobject[]

Las fotos que quedaron registradas en la sesión: `{ kind, index? }`.

Example
{
  "confirmed": [
    { "kind": "ine_front" },
    { "kind": "ine_back" },
    { "kind": "selfie" },
    { "kind": "liveness_frame", "index": 0 }
  ]
}

Revisión previa de las fotos (público, por token)

GET/app/identity-verification/upload/:token/precheck

Cada foto del documento que confirmas se revisa sola, mientras la persona sigue con la captura. Esta ruta dice, foto por foto, si sirve o si conviene pedir otra ANTES de lanzar la verificación. La página hospedada hace lo mismo por su cuenta; con tus propias pantallas, la lees tú. Se autentica con el token del link, no con API key.

Para quien captura con sus propias pantallas: la página hospedada (también dentro del widget) ya lo hace sola. Léela antes de submit/:token —después contesta 409—, cada 500 ms mientras ready sea false y con un tope de unos segundos: lo que siga en pending después de 20 s llega como unavailable. Con retake_suggested: true pide otra foto sólo de ese lado, con el texto de su reason (la guía de identidad trae uno sugerido por código; con wrong_document, detected_document_type dice qué documento salió en la foto), y súbela igual que la primera: presign del mismo kind → S3 → confirm. La nueva vuelve a pending con su attempt + 1. Nunca repitas la prueba de vida. Se revisan hasta tres fotos por lado: con la tercera ya no se sugiere otra y una cuarta llega como unavailable; el veredicto decide como siempre (ver retake_suggested en result.checks de status). Ni la revisión ni las fotos repetidas se cobran. En sandbox contesta ok sin revisar nada. La subida multipart obsoleta no tiene revisión previa. Nunca devuelve llaves de S3. Un `wrong_document` se detecta en cualquier lado y con cualquier foto, antes que los demás motivos (el problema es el documento, no la foto). Errores: 404 token no encontrado; 409 ya enviada; 410 token expirado; 429 pasado el tope —desde MasterApi 1.130.0, 300 consultas por minuto por sesión (el token) y 3000 por IP— con `Retry-After`. Se autentica por token en la URL, no con API key.

Request
tokenstringpathrequired

verification_token del link creado.

curl -X GET https://api.singula.mx/app/identity-verification/upload/{token}/precheck \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
max_attemptsnumber

Las fotos que se revisan por lado: 3.

readyboolean

true cuando ninguna foto sigue en pending. En false, vuelve a leer en medio segundo.

slotsobject

Una entrada por cada foto ya confirmada del documento ACTUAL de la sesión —ine_front e ine_back, passport_image, o resident_front y resident_back—, con { status, reason, detected_document_type, attempt, retake_suggested }. Desde MasterApi 1.130.0 las fotos de un documento que la persona dejó no salen ni detienen `ready`. La selfie y los cuadros de la prueba de vida nunca se revisan y no salen aquí.

slots.<slot>.statusstring

pending: se está revisando. ok: la foto sirve (en sandbox, siempre). retake: la foto tiene un problema que bajaría el veredicto. unavailable: no hay información —la revisión no pudo correr, seguía en pending después de 20 s o es la cuarta foto de ese lado o más—; no es un error: sigue como siempre.

slots.<slot>.reasonstring | null

Sólo con retake (si no, null): glare (un reflejo), blurry (borrosa), dark (oscura), cropped (falta una parte del documento), low_resolution (salió muy lejos o muy chica), unreadable (los datos no se leyeron y la foto no muestra una causa clara), wrong_side (es el otro lado del documento), wrong_document (es OTRO documento de identidad —una INE donde va el pasaporte, una tarjeta de residente donde va la INE…—; detected_document_type dice cuál) o no_document (la foto no muestra un documento de identidad). Si una foto tiene varios problemas llega uno: el reflejo antes que borrosa, oscura, cortada y lejos.

slots.<slot>.detected_document_typestring | null

Sólo con retake y reason wrong_document (si no, null): el documento que muestra la foto, INE | PASSPORT | RESIDENT_CARD. La página hospedada ofrece entonces pasar a ese documento cuando la sesión también lo ofrece («Usar mi …»: desde su primer lado y sin repetir la prueba de vida); con tus propias pantallas, pide la foto correcta o cambia a ese documento confirmando sus fotos y nómbralo al enviar (`submit` con `document_type`).

slots.<slot>.attemptnumber

Cuántas fotos de ese lado se han confirmado, desde 1. Confirmar otra vez la misma foto no suma.

slots.<slot>.retake_suggestedboolean

true = pide otra foto de ese lado: status retake con attempt menor que max_attempts. En la tercera foto llega false aunque status sea retake: sigue con submit y el veredicto decide.

Example
{
  "max_attempts": 3,
  "ready": true,
  "slots": {
    "ine_front": { "status": "ok", "reason": null, "detected_document_type": null, "attempt": 1, "retake_suggested": false },
    "ine_back": { "status": "retake", "reason": "glare", "detected_document_type": null, "attempt": 1, "retake_suggested": true }
  }
}

Lanzar la verificación (público, por token)

POST/app/identity-verification/submit/:token

Con las fotos ya en S3 y confirmadas (upload/:token/presign → S3 → upload/:token/confirm), lanza el procesamiento y responde de inmediato status: 'processing'. El cuerpo va vacío ({}) o nombra el documento que se verifica; ningún archivo viaja en esta llamada. Se autentica con el token del link, no con API key.

La sesión verifica el documento que nombra `document_type` o, sin él, el de la ÚLTIMA foto del documento confirmada (desde MasterApi 1.130.0; las fotos que lleguen con esta llamada van primero): INE requiere ine_front + ine_back; pasaporte, passport_image; tarjeta de residente, resident_front + resident_back; la selfie siempre es obligatoria. Un documento que la sesión no ofrece contesta 400 `document_type_not_offered`. Tope: 60 por minuto por sesión y 600 por IP; pasado, 429 con `Retry-After`. Si capturas con tus propias pantallas, lee antes la revisión previa (GET upload/:token/precheck): después de esta llamada contesta 409. Es idempotente: un doble envío solo encola una vez (el segundo recibe 409). Errores: 400 si falta una foto requerida, 404 token no encontrado, 409 ya enviado, 410 token expirado. Esta llamada NO se autentica con API key sino con el token en la URL. La IP de QUIEN LLAMA queda en `network` de la sesión y alimenta los controles de red del expediente KYC: si tu backend llama desde una nube (AWS, GCP, DigitalOcean…), esa IP es de hosting y el expediente cae en revisión (nunca en rechazo). Deja que la captura corra en el dispositivo de la persona (el link hospedado o el widget) o apaga `kyc.checks.ip_not_hosting` —y `ip_country_vs_document` si tu servidor está fuera de México— en `PUT /organization/verification-policy`.

Request
tokenstringpathrequired

verification_token del link creado.

document_typestringbody

Desde MasterApi 1.130.0: el documento que se verifica, el que la persona TERMINÓ de capturar: ine | passport | resident_card (en JSON o como campo del multipart). Manda sobre la inferencia: sin él, la sesión verifica el de la última foto del documento cuya confirmación llegó, y la confirmación lenta de un documento que la persona dejó puede llegar después. Tiene que ser uno que la sesión ofrece (400 `document_type_not_offered`; `document_type_not_available` si la plataforma no lo ofrece) y con sus fotos confirmadas (400 «… is required»); otro valor, 400. Mándalo si capturas con tus propias pantallas y la persona puede cambiar de documento. Sin él (`{}`), como siempre.

curl -X POST https://api.singula.mx/app/identity-verification/submit/{token} \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
Response
statusstring

Siempre 'processing' — el veredicto final llega por webhook o por GET status/:requestId.

request_idstring

ID del LogRequest para consultar el estado o correlacionar el webhook.

Example
{
  "status": "processing",
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8"
}

Estado de la sesión + veredicto

GET/app/identity-verification/status/:requestId

La vista pública de la sesión: session_status (cómo va la SESIÓN), decision (el veredicto sobre el que decides), score, verdict crudo y result con checks[], extracted_data y face_result. Más el documento crudo de la sesión en verification, intacto.

Requiere API key (Bearer) y está acotado a la organización de la key: 404 si ese requestId no es de tu organización. Este es el VEREDICTO DE REGISTRO — el que tu sistema usa para autorizar, no el callback del navegador. La respuesta es aditiva: verification se conserva tal cual para los consumidores que ya lo leían. Una sesión que ya tiene veredicto pero seguía marcada como submitted se repara al leerla, así que session_status y verification.status nunca se contradicen en la misma respuesta.

Request
requestIdstringpathrequired

request_id devuelto por create/submit (equivale al log_request_id de la sesión).

curl -X GET https://api.singula.mx/app/identity-verification/status/{requestId} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

El identificador de la sesión, el mismo que devolvió create. Es también el que correlaciona cada webhook.

session_statusstring

El estado de la SESIÓN, no del veredicto: pending (link vivo, sin fotos) | submitted (ya subió, falta el veredicto) | completed | cancelled | expired.

decisionstring | null

El veredicto PÚBLICO, lo único sobre lo que debe ramificar tu sistema: approved | review | declined; null mientras no hay veredicto. Se deriva de verdict: verified→approved, partial→review, rejected→declined, failed→review. failed es REVISIÓN, jamás declined: no hubo veredicto, así que se manda a una persona en vez de afirmar un rechazo.

scorenumber | null

La confianza agregada de la sesión, 0..1 (el overall_confidence del resultado). null mientras no hay veredicto.

verdictstring | null

El veredicto CRUDO de la tubería: verified | partial | rejected | failed. Sirve para auditoría; para decidir usa decision. Un fallo crítico (prueba de vida, cotejo de rostro, dígito verificador del MRZ) deja verdict=rejected sin importar el score, salvo un dígito verificador que falla sobre una foto de baja calidad sin falla de rostro ni de prueba de vida: ése deja partial, con el control retake_suggested. En un pasaporte son críticos los dígitos verificadores del número de pasaporte, de la fecha de nacimiento, del vencimiento y el compuesto; el del número personal opcional, no. Un pasaporte vencido o con una fecha de nacimiento imposible nunca sale verified (a lo más partial), y uno cuya zona MRZ no se pudo leer sale failed. En una tarjeta de residente son críticos los dígitos verificadores del número de documento, de la fecha de nacimiento, del campo de vencimiento de su MRZ y el compuesto. Nunca sale verified (a lo más partial) una tarjeta vencida o temporal sin un vencimiento impreso válido, con una fecha de nacimiento imposible, que no expidió México o cuyo MRZ no es el de una tarjeta del INM (`mrz_document_code`), ni una cuyo frente no trae el nombre del MRZ o cuya fecha de expedición impresa no es la del MRZ —el frente de otra persona—; una permanente no vence, y sin zona MRZ legible sale failed.

resultobject | null

El objeto de verificación completo, el mismo que llevaba el webhook: status, document_type, overall_confidence, extracted_data, checks[] y face_result. null hasta que hay veredicto.

result.document_typestring

El documento que leyó la sesión: `INE` | `PASSPORT` | `RESIDENT_CARD` (desde MasterApi 1.130.0). Decide qué llaves trae `extracted_data` y qué controles trae `checks`. El mismo valor viaja en el webhook identity.completed.

result.extracted_dataobject

Los datos leídos del documento, con las llaves de ese documento. INE: nombre, apellidos, CURP, clave de elector, fecha de nacimiento, vigencia… PASAPORTE: `nombres` y `apellidos` (los impresos), `nombres_mrz` y `apellidos_mrz` (los del MRZ), `numero_pasaporte`, `fecha_nacimiento` y `fecha_expiracion` (DD/MM/YYYY: el valor del MRZ cuando su dígito verificador pasó; si no, el impreso), `sexo` (M o F, como el MRZ), `nacionalidad` y `pais_emisor` (códigos ICAO de tres letras), `tipo` (el código de documento del MRZ, como P), `curp` (los pasaportes mexicanos la imprimen), `lugar_nacimiento`, `autoridad`, `mrz_line1` y `mrz_line2`, y lo impreso, para compararlo, en `numero_pasaporte_viz`, `fecha_nacimiento_viz`, `fecha_expiracion_viz` y `sexo_viz`. TARJETA DE RESIDENTE (fechas DD/MM/YYYY): `tipo` (TRT temporal o TRP permanente, del título impreso), `nombres` y `apellidos` (los de la línea que sigue a «Nombre / Name»: los apellidos van en mayúsculas; si no se leyó, los del MRZ), `nombres_mrz` y `apellidos_mrz`, `numero_documento` (el del MRZ, 8 dígitos) y `numero_documento_reverso` (el «No.» impreso en el reverso), `codigo_inm` (el código SEGOB-INM del reverso de la tarjeta nueva, otro número), `nue` (13 dígitos), `curp` (la de una persona extranjera trae la entidad NE) con `entidad_nacimiento` y `pais_nacimiento` (EXTRANJERO con NE), que salen de ella, `nacionalidad` y `pais_emisor` (códigos ICAO; el emisor es MEX), `fecha_nacimiento`, `sexo` (M o F), `fecha_expedicion` (la impresa), `fecha_expedicion_mrz` (el campo de vencimiento del MRZ de esta tarjeta trae la fecha de expedición; sólo cuando su dígito verificador pasó), `fecha_expiracion` (el vencimiento IMPRESO, nunca el del MRZ; null en una permanente o si no se leyó), `vigencia_indefinida` (true en una permanente; false con vencimiento impreso; null si no se leyó ninguno y nada dice que sea permanente), `permiso_trabajo` (true si imprime «PERMISO PARA TRABAJAR»; false en una temporal sin esa leyenda; null en una permanente o si no se leyó el frente) y `mrz_line1`, `mrz_line2` y `mrz_line3`. Un campo que no se leyó no viene.

result.checksobject[]

Cada control individual con { check_name, passed, confidence, details }. Es lo que se le muestra a un revisor cuando la decisión es review o declined. Con un cliente con CURP, la INE, el pasaporte y la tarjeta de residente traen además los cruces del documento contra RENAPO, incluidos sin cargo extra: `cross_ref_curp_document_vs_renapo`, `cross_ref_name_document_vs_renapo`, `cross_ref_dob_document_vs_renapo` y `cross_ref_sex_document_vs_renapo` (los mismos nombres en los tres documentos; en el pasaporte desde MasterApi 1.128.0 y en la tarjeta de residente desde 1.130.0). Sólo salen si RENAPO confirmó esa CURP y su registro describe al cliente; si no, la categoría de RENAPO no aplica. El de sexo es el cotejo de sexo que cuenta: el documento contra RENAPO. Un PASAPORTE trae sus propios controles: los dígitos verificadores ICAO de su zona MRZ —`mrz_check_digit_passport_number`, `mrz_check_digit_date_of_birth`, `mrz_check_digit_expiry_date` y `mrz_check_digit_composite`, críticos (uno en false deja la sesión rejected, salvo sobre una foto de baja calidad: ver `retake_suggested`), y `mrz_check_digit_personal_number`, que no lo es—; `issuing_country` (el estado emisor es un código ICAO válido); lo impreso contra el MRZ (`cross_ref_name`, `cross_ref_passport_number`, `cross_ref_dob`); `passport_number_format`; si el pasaporte imprime CURP, `curp_validation` y lo que esa CURP codifica contra el MRZ (`cross_ref_dob_curp_vs_mrz`, `cross_ref_sex_curp_vs_mrz`); los cuatro cruces contra RENAPO de arriba; `expiry_valid` y `dob_valid` (en false la sesión nunca sale verified: a lo más partial), e `image_quality`, que baja el score pero nunca rechaza por sí sola. Sin una zona MRZ legible sale `mrz_available` en false y la sesión termina failed. Una TARJETA DE RESIDENTE trae los dígitos verificadores de la zona MRZ de su reverso —`mrz_check_digit_document_number`, `mrz_check_digit_date_of_birth`, `mrz_check_digit_expiry_date` (en esta tarjeta ese campo trae la fecha de expedición) y `mrz_check_digit_composite`, los cuatro críticos—; `mrz_document_code` (el código de documento es `I<` y el estado emisor `MEX`: el reverso de una INE no lo pasa); `issuing_country` (la expide México; la nacionalidad de la persona puede ser cualquiera, y una tarjeta que no expidió MEX nunca sale verified); lo impreso contra el MRZ —`cross_ref_name_mrz_vs_document` (la parte del nombre del MRZ que aparece impresa, mínimo 0.70: un frente leído que no trae ese nombre lo reprueba), `cross_ref_dob_mrz_vs_document`, `cross_ref_issue_date_mrz_vs_printed` (la fecha de expedición impresa contra la del MRZ) y `cross_ref_document_number_back_vs_mrz` (el «No.» impreso en el reverso contra el número del MRZ)—; si imprime CURP, `curp_validation`, `cross_ref_dob_curp_vs_mrz` y `cross_ref_sex_curp_vs_mrz`; los cuatro cruces contra RENAPO de arriba; `expiry_valid` —el vencimiento IMPRESO, vigente hasta ese día inclusive y de esta tarjeta: posterior a la expedición del MRZ y a no más de 4 años de ella; una permanente pasa por vigencia indefinida y una temporal sin vencimiento legible no pasa— y `dob_valid`, e `image_quality`. En false, `expiry_valid`, `dob_valid`, `issuing_country`, `mrz_document_code`, `cross_ref_name_mrz_vs_document` o `cross_ref_issue_date_mrz_vs_printed` dejan la tarjeta a lo más en partial: nunca verified. Sin zona MRZ legible, `mrz_available` en false y failed. En la INE, el pasaporte y la tarjeta de residente, `retake_suggested` sale al final, en false y con confianza 0, cuando un dígito verificador del MRZ falló sobre una foto cuyo `image_quality` también falló y ni el rostro ni la prueba de vida fallaron: con esa foto el dígito pudo leerse mal, así que la sesión queda partial (review) en vez de rejected. Es informativo, no cuenta en el score y su details dice qué hacer: pedir otra foto. Con una foto buena, el dígito que falla sigue rechazando.

result.face_resultobject

{ face_detected_in_document, face_detected_in_selfie, match_score, match_decision, liveness_score, liveness_decision } y, cuando aplica, liveness_checks[].

external_idstring | null

El identificador que mandaste al crear la sesión.

metadataobject | null

El objeto plano que mandaste al crear la sesión.

submitted_atstring (ISO 8601) | null

Cuándo mandó sus fotos la persona.

completed_atstring (ISO 8601) | null

Cuándo quedó el veredicto.

verificationobject

El documento CRUDO de la sesión (token, status, document_type, s3_keys, challenge, device_info, opens, expires_at…). Se conserva sin cambios: quien ya leía de aquí no tiene que mover nada. `opens[].ip` es la IP confiable, la que vio nuestro servidor; el `X-Forwarded-For` que mande el cliente queda aparte, en `forwarded_for`, sólo para auditoría.

networkobject | null

Desde dónde se conectó la persona: la página que abrió y el teléfono que subió las fotos, con la misma forma que el `network` de una verificación de KYC y KYB (`ips[]` con `ip`, `first_at`, `last_at`, `count`, `country`, `asn`, `as_org`, `is_tor`, `is_hosting`, `is_relay`; más `countries` y `any_tor` / `any_hosting` / `any_relay`). Cada bandera es `true`, `false` o `null` (sin datos). `null` hasta la primera visita. Es una señal: no cobra y no sale en el webhook ni en la página pública. En sandbox, tu IP real con las señales fijas de la persona de prueba. Créditos de datos: https://singula.mx/creditos.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "session_status": "completed",
  "decision": "approved",
  "score": 0.95,
  "verdict": "verified",
  "result": {
    "status": "verified",
    "document_type": "INE",
    "overall_confidence": 0.95,
    "extracted_data": {
      "nombre": "MARIA",
      "apellido_paterno": "LOPEZ",
      "apellido_materno": "GARCIA",
      "curp": "LOGM900101MDFPRR02",
      "fecha_nacimiento": "1990-01-01",
      "vigencia": "2030"
    },
    "checks": [
      {
        "check_name": "curp_validation",
        "passed": true,
        "confidence": 1.0
      },
      {
        "check_name": "mrz_check_digits",
        "passed": true,
        "confidence": 1.0
      }
    ],
    "face_result": {
      "match_score": 0.82,
      "match_decision": true,
      "liveness_score": 0.91,
      "liveness_decision": true
    }
  },
  "external_id": "ord_123",
  "metadata": {
    "plan": "pro"
  },
  "submitted_at": "2026-09-11T12:00:00.000Z",
  "completed_at": "2026-09-11T12:03:00.000Z",
  "verification": {
    "status": "completed",
    "document_type": "INE",
    "expires_at": "2026-09-14T12:00:00.000Z"
  }
}

Resultado de la sesión + fotos firmadas

GET/app/identity-verification/result/:requestId

Todo lo que devuelve status MÁS files (slot → URL firmada de cada foto) y files_expires_at. Contesta 200 aunque todavía no haya veredicto: revisar lo que subió la persona en una sesión submitted es justo para lo que sirve.

Requiere API key (Bearer), acotado a tu organización (404 si el requestId no es tuyo). Es status + las fotos: si sólo necesitas el veredicto, status es más barato de servir. Los cuadros de la prueba de vida NO se firman por defecto — un integrador que los necesite tiene que pedir include_frames=true.

Request
requestIdstringpathrequired

request_id de la sesión.

files_ttlnumberquery

Vigencia de las URLs firmadas en segundos. Se acota a [60, 86400]; por defecto 600 (10 min). Vuelve a llamar el endpoint para refrescarlas.

include_framesbooleanquery

true | 1 firma también los liveness_frame_<i> (hasta 30 URLs más). Por defecto false: se firman sólo los documentos y la selfie, porque cada cuadro es una firma adicional y los cuadros son evidencia de auditoría, no la foto que se revisa.

curl -X GET https://api.singula.mx/app/identity-verification/result/{requestId} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

El identificador de la sesión, el mismo que devolvió create. Es también el que correlaciona cada webhook.

session_statusstring

El estado de la SESIÓN, no del veredicto: pending (link vivo, sin fotos) | submitted (ya subió, falta el veredicto) | completed | cancelled | expired.

decisionstring | null

El veredicto PÚBLICO, lo único sobre lo que debe ramificar tu sistema: approved | review | declined; null mientras no hay veredicto. Se deriva de verdict: verified→approved, partial→review, rejected→declined, failed→review. failed es REVISIÓN, jamás declined: no hubo veredicto, así que se manda a una persona en vez de afirmar un rechazo.

scorenumber | null

La confianza agregada de la sesión, 0..1 (el overall_confidence del resultado). null mientras no hay veredicto.

verdictstring | null

El veredicto CRUDO de la tubería: verified | partial | rejected | failed. Sirve para auditoría; para decidir usa decision. Un fallo crítico (prueba de vida, cotejo de rostro, dígito verificador del MRZ) deja verdict=rejected sin importar el score, salvo un dígito verificador que falla sobre una foto de baja calidad sin falla de rostro ni de prueba de vida: ése deja partial, con el control retake_suggested. En un pasaporte son críticos los dígitos verificadores del número de pasaporte, de la fecha de nacimiento, del vencimiento y el compuesto; el del número personal opcional, no. Un pasaporte vencido o con una fecha de nacimiento imposible nunca sale verified (a lo más partial), y uno cuya zona MRZ no se pudo leer sale failed. En una tarjeta de residente son críticos los dígitos verificadores del número de documento, de la fecha de nacimiento, del campo de vencimiento de su MRZ y el compuesto. Nunca sale verified (a lo más partial) una tarjeta vencida o temporal sin un vencimiento impreso válido, con una fecha de nacimiento imposible, que no expidió México o cuyo MRZ no es el de una tarjeta del INM (`mrz_document_code`), ni una cuyo frente no trae el nombre del MRZ o cuya fecha de expedición impresa no es la del MRZ —el frente de otra persona—; una permanente no vence, y sin zona MRZ legible sale failed.

resultobject | null

El objeto de verificación completo, el mismo que llevaba el webhook: status, document_type, overall_confidence, extracted_data, checks[] y face_result. null hasta que hay veredicto.

result.document_typestring

El documento que leyó la sesión: `INE` | `PASSPORT` | `RESIDENT_CARD` (desde MasterApi 1.130.0). Decide qué llaves trae `extracted_data` y qué controles trae `checks`. El mismo valor viaja en el webhook identity.completed.

result.extracted_dataobject

Los datos leídos del documento, con las llaves de ese documento. INE: nombre, apellidos, CURP, clave de elector, fecha de nacimiento, vigencia… PASAPORTE: `nombres` y `apellidos` (los impresos), `nombres_mrz` y `apellidos_mrz` (los del MRZ), `numero_pasaporte`, `fecha_nacimiento` y `fecha_expiracion` (DD/MM/YYYY: el valor del MRZ cuando su dígito verificador pasó; si no, el impreso), `sexo` (M o F, como el MRZ), `nacionalidad` y `pais_emisor` (códigos ICAO de tres letras), `tipo` (el código de documento del MRZ, como P), `curp` (los pasaportes mexicanos la imprimen), `lugar_nacimiento`, `autoridad`, `mrz_line1` y `mrz_line2`, y lo impreso, para compararlo, en `numero_pasaporte_viz`, `fecha_nacimiento_viz`, `fecha_expiracion_viz` y `sexo_viz`. TARJETA DE RESIDENTE (fechas DD/MM/YYYY): `tipo` (TRT temporal o TRP permanente, del título impreso), `nombres` y `apellidos` (los de la línea que sigue a «Nombre / Name»: los apellidos van en mayúsculas; si no se leyó, los del MRZ), `nombres_mrz` y `apellidos_mrz`, `numero_documento` (el del MRZ, 8 dígitos) y `numero_documento_reverso` (el «No.» impreso en el reverso), `codigo_inm` (el código SEGOB-INM del reverso de la tarjeta nueva, otro número), `nue` (13 dígitos), `curp` (la de una persona extranjera trae la entidad NE) con `entidad_nacimiento` y `pais_nacimiento` (EXTRANJERO con NE), que salen de ella, `nacionalidad` y `pais_emisor` (códigos ICAO; el emisor es MEX), `fecha_nacimiento`, `sexo` (M o F), `fecha_expedicion` (la impresa), `fecha_expedicion_mrz` (el campo de vencimiento del MRZ de esta tarjeta trae la fecha de expedición; sólo cuando su dígito verificador pasó), `fecha_expiracion` (el vencimiento IMPRESO, nunca el del MRZ; null en una permanente o si no se leyó), `vigencia_indefinida` (true en una permanente; false con vencimiento impreso; null si no se leyó ninguno y nada dice que sea permanente), `permiso_trabajo` (true si imprime «PERMISO PARA TRABAJAR»; false en una temporal sin esa leyenda; null en una permanente o si no se leyó el frente) y `mrz_line1`, `mrz_line2` y `mrz_line3`. Un campo que no se leyó no viene.

result.checksobject[]

Cada control individual con { check_name, passed, confidence, details }. Es lo que se le muestra a un revisor cuando la decisión es review o declined. Con un cliente con CURP, la INE, el pasaporte y la tarjeta de residente traen además los cruces del documento contra RENAPO, incluidos sin cargo extra: `cross_ref_curp_document_vs_renapo`, `cross_ref_name_document_vs_renapo`, `cross_ref_dob_document_vs_renapo` y `cross_ref_sex_document_vs_renapo` (los mismos nombres en los tres documentos; en el pasaporte desde MasterApi 1.128.0 y en la tarjeta de residente desde 1.130.0). Sólo salen si RENAPO confirmó esa CURP y su registro describe al cliente; si no, la categoría de RENAPO no aplica. El de sexo es el cotejo de sexo que cuenta: el documento contra RENAPO. Un PASAPORTE trae sus propios controles: los dígitos verificadores ICAO de su zona MRZ —`mrz_check_digit_passport_number`, `mrz_check_digit_date_of_birth`, `mrz_check_digit_expiry_date` y `mrz_check_digit_composite`, críticos (uno en false deja la sesión rejected, salvo sobre una foto de baja calidad: ver `retake_suggested`), y `mrz_check_digit_personal_number`, que no lo es—; `issuing_country` (el estado emisor es un código ICAO válido); lo impreso contra el MRZ (`cross_ref_name`, `cross_ref_passport_number`, `cross_ref_dob`); `passport_number_format`; si el pasaporte imprime CURP, `curp_validation` y lo que esa CURP codifica contra el MRZ (`cross_ref_dob_curp_vs_mrz`, `cross_ref_sex_curp_vs_mrz`); los cuatro cruces contra RENAPO de arriba; `expiry_valid` y `dob_valid` (en false la sesión nunca sale verified: a lo más partial), e `image_quality`, que baja el score pero nunca rechaza por sí sola. Sin una zona MRZ legible sale `mrz_available` en false y la sesión termina failed. Una TARJETA DE RESIDENTE trae los dígitos verificadores de la zona MRZ de su reverso —`mrz_check_digit_document_number`, `mrz_check_digit_date_of_birth`, `mrz_check_digit_expiry_date` (en esta tarjeta ese campo trae la fecha de expedición) y `mrz_check_digit_composite`, los cuatro críticos—; `mrz_document_code` (el código de documento es `I<` y el estado emisor `MEX`: el reverso de una INE no lo pasa); `issuing_country` (la expide México; la nacionalidad de la persona puede ser cualquiera, y una tarjeta que no expidió MEX nunca sale verified); lo impreso contra el MRZ —`cross_ref_name_mrz_vs_document` (la parte del nombre del MRZ que aparece impresa, mínimo 0.70: un frente leído que no trae ese nombre lo reprueba), `cross_ref_dob_mrz_vs_document`, `cross_ref_issue_date_mrz_vs_printed` (la fecha de expedición impresa contra la del MRZ) y `cross_ref_document_number_back_vs_mrz` (el «No.» impreso en el reverso contra el número del MRZ)—; si imprime CURP, `curp_validation`, `cross_ref_dob_curp_vs_mrz` y `cross_ref_sex_curp_vs_mrz`; los cuatro cruces contra RENAPO de arriba; `expiry_valid` —el vencimiento IMPRESO, vigente hasta ese día inclusive y de esta tarjeta: posterior a la expedición del MRZ y a no más de 4 años de ella; una permanente pasa por vigencia indefinida y una temporal sin vencimiento legible no pasa— y `dob_valid`, e `image_quality`. En false, `expiry_valid`, `dob_valid`, `issuing_country`, `mrz_document_code`, `cross_ref_name_mrz_vs_document` o `cross_ref_issue_date_mrz_vs_printed` dejan la tarjeta a lo más en partial: nunca verified. Sin zona MRZ legible, `mrz_available` en false y failed. En la INE, el pasaporte y la tarjeta de residente, `retake_suggested` sale al final, en false y con confianza 0, cuando un dígito verificador del MRZ falló sobre una foto cuyo `image_quality` también falló y ni el rostro ni la prueba de vida fallaron: con esa foto el dígito pudo leerse mal, así que la sesión queda partial (review) en vez de rejected. Es informativo, no cuenta en el score y su details dice qué hacer: pedir otra foto. Con una foto buena, el dígito que falla sigue rechazando.

result.face_resultobject

{ face_detected_in_document, face_detected_in_selfie, match_score, match_decision, liveness_score, liveness_decision } y, cuando aplica, liveness_checks[].

external_idstring | null

El identificador que mandaste al crear la sesión.

metadataobject | null

El objeto plano que mandaste al crear la sesión.

submitted_atstring (ISO 8601) | null

Cuándo mandó sus fotos la persona.

completed_atstring (ISO 8601) | null

Cuándo quedó el veredicto.

verificationobject

El documento CRUDO de la sesión (token, status, document_type, s3_keys, challenge, device_info, opens, expires_at…). Se conserva sin cambios: quien ya leía de aquí no tiene que mover nada.

networkobject | null

Desde dónde se conectó la persona, igual que en `status`: IPs confiables con país, ASN y banderas Tor / hosting / Private Relay. `null` hasta la primera visita. Créditos de datos: https://singula.mx/creditos.

filesobject (slot → url)

Mapa de slot a URL firmada: ine_front, ine_back, passport_image, resident_front, resident_back, selfie y —sólo con include_frames=true— liveness_frame_0 … liveness_frame_N. Vacío ({}) si la persona todavía no sube nada.

files_expires_atstring (ISO 8601)

Cuándo caducan esas URLs firmadas (ahora + files_ttl).

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "session_status": "submitted",
  "decision": null,
  "score": null,
  "verdict": null,
  "result": null,
  "external_id": "ord_123",
  "metadata": {
    "plan": "pro"
  },
  "submitted_at": "2026-09-11T12:00:00.000Z",
  "completed_at": null,
  "files": {
    "ine_front": "https://s3.amazonaws.com/bucket/...ine_front.jpg?X-Amz-Signature=REDACTED",
    "ine_back": "https://s3.amazonaws.com/bucket/...ine_back.jpg?X-Amz-Signature=REDACTED",
    "selfie": "https://s3.amazonaws.com/bucket/...selfie.jpg?X-Amz-Signature=REDACTED"
  },
  "files_expires_at": "2026-09-11T12:10:00.000Z"
}

Eventos de la sesión (SSE)

GET/app/identity-verification/events/:requestId

Stream text/event-stream de una sola sesión: emite session al conectar y en cada cambio de session_status o decision, keepalive cada 15 s, y end al cerrar. Sirve para pintar el avance sin escribir tu propio polling.

Requiere API key (Bearer) o la sesión del dashboard, SIEMPRE en el header Authorization: esta ruta NO acepta api_key en la query (una llave en la URL se filtra en logs y referrers). 404 si el requestId no es de tu organización, y el 404 llega antes de abrir el stream. El corte duro es a los 15 minutos; end con reason timeout no significa que la sesión terminó.

Request
requestIdstringpathrequired

request_id de la sesión que quieres seguir.

curl -X GET https://api.singula.mx/app/identity-verification/events/{requestId} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
event: sessionSSE event

La vista de sesión SIN result (el payload pesado se lee en status o result): request_id, session_status, decision, score, verdict, external_id, metadata, submitted_at y completed_at. Llega al conectar y en cada cambio de session_status o decision.

event: keepaliveSSE event

Latido cada 15 s con { at } para que ningún proxy intermedio corte el stream.

event: endSSE event

{ reason: "terminal" | "timeout" }. terminal = la sesión llegó a completed, cancelled o expired: ahí se acabó la historia. timeout = el corte de 15 minutos de ESE stream con la sesión TODAVÍA ABIERTA — reconecta o lee status/:requestId; una verificación la completa una persona y tarda lo que tarda.

Example
event: session
data: {"request_id":"6622f8a1c1d2e3f4a5b6c7d8","session_status":"submitted","decision":null,"score":null,"verdict":null,"external_id":"ord_123","metadata":{"plan":"pro"},"submitted_at":"2026-09-11T12:00:00.000Z","completed_at":null}

event: keepalive
data: {"at":"2026-09-11T12:00:15.000Z"}

event: session
data: {"request_id":"6622f8a1c1d2e3f4a5b6c7d8","session_status":"completed","decision":"approved","score":0.95,"verdict":"verified","completed_at":"2026-09-11T12:03:00.000Z"}

event: end
data: {"reason":"terminal"}

Estado público de la sesión (por token)

GET/app/embed/status/:token

El estado que consulta la página hospedada (el escritorio mientras muestra el QR, el celular al terminar). No necesita API key: el token de la verificación ES la auth. Nunca devuelve checks, datos extraídos ni llaves de S3.

PÚBLICO: no lleva API key (el token es la auth), así que el detalle —checks, datos extraídos, fotos— nunca sale por aquí; eso se lee con API key en /app/identity-verification/result/:requestId o llega por webhook. 404 token no encontrado, 410 token expirado. Tiene límite por IP (300 por minuto) porque sin API key no hay cuota de organización que lo frene; la página del QR lo consulta cada 3 s los primeros 5 minutos y cada 10 s hasta los 30, así que queda con holgura. No registra opens ni telemetría, pero como toda lectura de la sesión cierra el token a completed una vez que el veredicto llegó.

Request
tokenstringpathrequired

verification_token del link creado.

curl -X GET https://api.singula.mx/app/embed/status/{token} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
data.request_idstring

El request_id de la sesión, para que tu servidor lea el resultado con su API key.

data.session_statusstring

pending | submitted | completed | cancelled | expired.

data.decisionstring | null

approved | review | declined, y null mientras no hay veredicto. TAMBIÉN null cuando tu organización no expone el veredicto a la persona verificada (el default).

data.scorenumber | null

0..1; null si no hay veredicto o si está retenido.

data.verdict_withheldboolean

true = tu organización NO expone el veredicto en el navegador (organization_branding.expose_verdict_to_user, default false): decision y score llegan en null aunque session_status ya sea completed. La página sabe que terminó, no sabe cómo salió. Se prende en Ajustes › Marca › Resultado de la verificación.

data.redirect_urlstring | null

A dónde mandar a la persona tras enviar sus fotos, tal como quedó en la sesión.

data.started_on_desktopboolean

true = hubo SALTO de dispositivo (se abrió en algo que no es un teléfono y la captura terminó en uno). Una tablet que abre y captura sola no cuenta: no saltó a ningún lado. Con true la pantalla final dice «regresa a tu computadora» y no redirige.

Example
{
  "data": {
    "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
    "session_status": "completed",
    "decision": null,
    "score": null,
    "verdict_withheld": true,
    "redirect_url": "https://cliente.com/kyc/listo",
    "started_on_desktop": false
  }
}

URL firmada de una foto (por key)

GET/app/identity-verification/signed-url

Devuelve una URL firmada de S3 (TTL 10 min) para ver una foto de verificación, dada su key. Solo permite keys que pertenecen a tu organización.

Requiere API key (Bearer). 400 si falta el parámetro key; 403 si la key no pertenece a tu organización (debe iniciar con verifications/{tuOrgId}/).

Request
keystringqueryrequired

Key S3 del objeto. Debe empezar con verifications/{organizationId}/; de lo contrario se rechaza.

curl -X GET https://api.singula.mx/app/identity-verification/signed-url \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
urlstring

URL firmada de S3 con vigencia de 10 minutos.

Example
{
  "url": "https://s3.amazonaws.com/bucket/verifications/org_REDACTED/a1b2.../ine_front.jpg?X-Amz-Signature=REDACTED&X-Amz-Expires=600"
}

URLs firmadas de todas las fotos de un cliente

GET/app/identity-verification/customer/:customerId/files

Devuelve, en una sola llamada, las URLs firmadas (TTL 10 min) de todas las fotos de la última verificación del cliente, listas para renderizar la galería.

Requiere API key (Bearer), acotado a tu organización. Si el cliente aún no tiene una verificación con assets, data es un objeto vacío ({}).

Request
customerIdstringpathrequired

ID del cliente.

curl -X GET https://api.singula.mx/app/identity-verification/customer/{customerId}/files \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
data.request_idstring

ID del LogRequest de la verificación de la que provienen las fotos.

data.document_typestring | null

INE | PASSPORT | RESIDENT_CARD.

data.filesobject (slot to url)

Mapa de slot a URL firmada: ine_front, ine_back, passport_image, resident_front, resident_back, selfie y liveness_frame_i por cada cuadro. Cada URL vive 10 minutos.

Example
{
  "data": {
    "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
    "document_type": "INE",
    "files": {
      "ine_front": "https://s3.amazonaws.com/bucket/...ine_front.jpg?X-Amz-Signature=REDACTED",
      "selfie": "https://s3.amazonaws.com/bucket/...selfie.jpg?X-Amz-Signature=REDACTED"
    }
  }
}

Descargar una foto (stream por MasterApi)

GET/app/identity-verification/customer/:customerId/files/:slot/download

Transmite una sola foto de verificación a través de la API (image/jpeg), evitando la fricción de CORS/Content-Disposition al acceder directo a S3.

Requiere API key (Bearer), acotado a tu organización. Usa la última verificación del cliente. 404 si el cliente no tiene fotos o el slot no existe.

Request
customerIdstringpathrequired

ID del cliente.

slotstringpathrequired

Slot de la foto: ine_front | ine_back | passport_image | resident_front | resident_back | selfie | liveness_frame_i.

curl -X GET https://api.singula.mx/app/identity-verification/customer/{customerId}/files/{slot}/download \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
(binario)image/jpeg

Bytes de la imagen. No es JSON. Headers: Content-Type: image/jpeg, Content-Disposition: inline, Cache-Control: private, max-age=300.

Example
(binario image/jpeg — sin cuerpo JSON)
2 endpoints

Listas de sanciones

Cruza a un customer (persona o empresa) contra listas internacionales de sanciones, listados criminales, PEP, inhabilitaciones, listas fiscales y medios adversos, y devuelve un veredicto de riesgo agrupado en 9 categorías.

Síncrono. La llamada es un GET que devuelve el resultado completo en el mismo response (objeto data + request_id); no hay que hacer polling a ningún /status ni esperar webhook. Con API key de sandbox (sk_test_) devuelve una respuesta mock "clear" al instante; con API key de producción (sk_live_) consulta el proveedor en vivo (timeout interno de 20 s).

Consultar listas de sanciones (consulta directa)

POST/app/blacklist

Criba a una persona o a una empresa contra las listas internacionales mandando sus datos en el cuerpo, sin crear un customer. Para una empresa manda la razón social en `legal_name` y el nombre comercial en `name`. Mismo veredicto de riesgo y mismas 9 categorías que la variante por customer.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningún customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si después quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta. Cada atributo extra sube el tope de certeza del cruce: con sólo el nombre el resultado es más ruidoso. Se filtran los hits con certeza < 65. Empresas: manda `legal_name` con la razón social y `name` con el nombre comercial (p. ej. `{ "name": "Ferretería El Tornillo", "legal_name": "Comercializadora Tornillo, S.A. de C.V.", "rfc": "CTO050101SX2" }`). Con `legal_name` presente el sujeto se criba como empresa: los dos nombres en la misma consulta —la razón social primero—, el `rfc` como identificador y los atributos de persona (sexo, fecha y lugar de nacimiento) ignorados. Un solo cargo y un solo `request_id`; `data.queries` dice qué se cribó. Mandar sólo `name` sigue funcionando igual que antes. Corre sin el KYB aprobado, también con el saldo de prueba. Errores: 400 cuerpo inválido, 401 llave inválida, 402 sin saldo, 503 si el servicio de listas no está disponible.

Request
namestringbodyrequired

Persona: sólo el nombre o los nombres de pila, SIN apellidos; los apellidos van en `last_name` y `mothers_last_name`. Desde 1.115.0 el nombre completo aquí se tolera: un apellido con el que `name` ya termina —sin importar mayúsculas, acentos ni espacios extra— no se busca dos veces. Empresa: el nombre comercial (o la razón social si es lo único que tienes), con la razón social en `legal_name`.

legal_namestringbody

Razón social de la empresa. Mandarla criba al sujeto como EMPRESA: se busca primero la razón social y después el nombre comercial (`name`), el identificador es el `rfc` y los atributos de persona —sexo, fecha y lugar de nacimiento— se ignoran. Si los dos nombres normalizan al mismo —se ignoran acentos, puntuación y la forma legal— se criba una sola vez. En los dos casos es un solo cargo y un solo `request_id`.

last_namestringbody

Apellido paterno. Va aquí, no en `name`.

mothers_last_namestringbody

Apellido materno. Va aquí, no en `name`.

birth_yearstringbody

Año de nacimiento, 4 dígitos (ej. 1985). Desambigua homónimos.

birth_monthstringbody

Mes de nacimiento, exactamente 2 dígitos (01–12). «4» se rechaza; manda «04».

birth_daystringbody

Día de nacimiento, exactamente 2 dígitos (01–31). «4» se rechaza; manda «04».

birth_countrystringbody

País de nacimiento en ISO-2 (MX, US, CN, ...). Default 'MX'. Un país distinto de 'MX' invalida cualquier birth_place.

birth_placestringbody

Estado de nacimiento de México (DF, JC, NL, ...). Sólo aplica cuando el país es 'MX'; para extranjeros se ignora en silencio.

curpstringbody

CURP de 18 caracteres del titular.

rfcstringbody

RFC del titular (12 en persona moral, 13 en física).

genderstring (enum: H|M|X)body

Sexo (H: hombre, M: mujer, X: otro). No aplica a personas morales.

curl -X POST https://api.singula.mx/app/blacklist \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Juan",
  "last_name": "Perez",
  "mothers_last_name": "Gomez",
  "birth_year": "1985",
  "birth_month": "04",
  "birth_day": "12",
  "birth_country": "MX",
  "curp": "PEGJ850412HDFRMN09",
  "gender": "H"
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoría, para el estado de cuenta y para guardarla después como cliente.

dataobject

Idéntico a la variante por customer: misma forma, mismos campos, mismo detalle.

data.queriesstring[]

Los nombres que efectivamente se cribaron, en orden: la razón social primero y el nombre comercial después. Un solo elemento cuando no mandaste `legal_name` o cuando los dos nombres normalizan al mismo.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "query": "Juan Perez Gomez",
    "queries": ["Juan Perez Gomez"],
    "has_disambiguators": true,
    "risk_summary": { "level": "medium", "best_certainty": 78.5 },
    "groups": "(las 9 categorías, idénticas a la variante por customer)",
    "checked_at": "2026-09-14T18:22:41.000Z"
  }
}

Consultar listas de sanciones de un customer

GET/app/blacklist/customer/:id

Cruza a un customer ya existente contra listas internacionales de sanciones y afines usando los atributos guardados del customer (nombre/razón social y nombre comercial, fecha de nacimiento, país, CURP/RFC, sexo, estado), y devuelve un veredicto de riesgo agrupado en 9 categorías.

puede haber descuento por organización). Auth: header Authorization: Bearer <API_KEY>. Con sk_test_ (sandbox) devuelve una respuesta mock 'clear' con las 9 categorías vacías; con sk_live_ consulta en vivo. Gotchas: (1) el endpoint NO recibe datos de la persona en el body — usa los atributos ya guardados del customer (nombre/razón social, DOB, país, CURP o RFC, sexo, estado); si su `name` ya termina con sus apellidos, no se buscan dos veces (desde 1.115.0); el sexo es el de RENAPO si su CURP ya se validó, si no el capturado, y sin ninguno no se manda (nunca se asume); para mejores resultados el customer debe estar completo. (2) Personas morales se criban por sus dos nombres guardados —la razón social (legal_name) primero y el nombre comercial (name) después— más RFC y país, sin sexo ni fecha de nacimiento. Es una sola llamada, un solo cargo y un solo request_id; si los dos nombres normalizan al mismo (se ignoran acentos, puntuación y la forma legal) se criba una sola vez, y data.queries dice qué se cribó. (3) Se filtran hits con certeza < 65 (weak por debajo de eso no aparecen). (4) Si el servicio de búsqueda no está disponible en ese momento, responde 503. (5) Errores: 401 sin/mal token, 402 sin créditos, 409 'Customer not found', 503 servicio no disponible. (6) Corre sin el KYB aprobado, también con el saldo de prueba.

Request
idstringpathrequired

ID del customer que quieres cribar. Debe existir ya en tu organización (creado previamente por el endpoint de customers) y pertenecer al mismo entorno que tu API key (sandbox vs producción). Si no existe, responde 409.

birth_countrystringquery

País de nacimiento en ISO-2 (MX, US, CN, ...). Opcional. Solo se usa/persiste si el customer NO tenía birth_country previo; no sobreescribe un valor existente. Afina el matching (más atributos = mayor cap de certeza). Si lo mandas distinto de 'MX', invalida cualquier birth_place. Default backend 'MX'.

birth_placestringquery

Estado de nacimiento de México (DF, JC, NL, ...). Opcional. Solo aplica si el país resultante es 'MX' y el customer no tenía birth_place previo; para extranjeros se ignora en silencio. Se normaliza y valida contra el catálogo de estados.

curl -X GET https://api.singula.mx/app/blacklist/customer/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

ID del LogRequest de esta consulta (Mongo ObjectId). Sirve para auditoría y para relacionar el resultado con tus logs.

data.querystring

Texto de búsqueda que se envió al proveedor (nombre completo de la persona, sin repetir un apellido con el que su `name` ya termina, o razón social de la empresa). Cuando se criban dos nombres éste es el primero; la lista completa está en data.queries.

data.queriesstring[]

Los nombres que efectivamente se cribaron, en orden: la razón social primero y el nombre comercial después. En persona física y en una empresa con un solo nombre trae un elemento; dos cuando la razón social y el nombre comercial del customer difieren.

data.query_attributesobject<string,string>

Atributos con los que se afinó la búsqueda (p.ej. country, dob, gender, id_number, place_of_birth).

data.has_disambiguatorsboolean

true si la respuesta trae información de desambiguación de homónimos relevante.

data.totalnumber

Total de registros del universo del proveedor que empataron el nombre antes de filtrar (métrica del pool).

data.pool_examinednumber

Cuántos candidatos del pool se examinaron a detalle.

data.risk_summary.levelstring

Veredicto de riesgo agregado: 'high' (bloquear: match fuerte en categoría crítica), 'medium' (revisar), 'informational' (solo PEP/empresa del Estado/medios adversos: se reporta pero no sube el score), 'low' (solo matches débiles) o 'clear' (sin coincidencias).

data.risk_summary.best_certaintynumber

Certeza (0-100) del mejor match encontrado tras el filtro.

data.risk_summary.best_matchobject|null

El hit con mayor certeza (mismo shape que un elemento de groups[].results), o null si no hubo coincidencias.

data.risk_summary.categories_foundstring[]

Categorías con al menos un match relevante (subconjunto de las 9 keys canónicas).

data.risk_summary.tier_breakdownobject

Conteo de hits por nivel de certeza: { definitive, strong, possible, weak, no_match }.

data.disambiguation.homonym_countnumber

Número de homónimos detectados.

data.disambiguation.strong_homonymsnumber

Homónimos con señal fuerte.

data.disambiguation.by_countryarray<{key,count}>

Distribución de homónimos por país (ISO-2 y conteo).

data.disambiguation.warningstring?

Aviso opcional cuando hay múltiples homónimos que pueden confundir el veredicto.

data.groupsobject<categoryKey,group>

Las 9 categorías canónicas SIEMPRE presentes (vacías o con resultados): sanctions, criminal, pep, debarment, fiscal, export_control, state_owned, adverse_media, other. Cada grupo trae label, icon, relevant_count, weak_count, raw_count y results[].

data.groups[].labelstring

Nombre legible de la categoría (p.ej. 'Sanciones internacionales').

data.groups[].iconstring

Emoji/ícono de la categoría.

data.groups[].relevant_countnumber

Hits con tier definitive/strong/possible tras el filtro de certeza (>=65).

data.groups[].weak_countnumber

Hits con tier weak tras el filtro (posibles falsos positivos).

data.groups[].raw_countnumber

Total de hits que devolvió el proveedor para la categoría ANTES del filtro de certeza (para auditoría).

data.groups[].resultsobject[]

Lista de coincidencias (certeza >= 65). Cada hit incluye score, certainty, certainty_tier, match_type, full_name, aliases, date_of_birth, nationality, source_list/source_name/source_country, category, programs, listed_on, confirms/conflicts, explanation, attribute_analysis, y (para PEP) position/pep_category/wiki_url, entre otros campos opcionales.

data.checked_atstring (ISO-8601)

Timestamp en que se generó este resultado del lado de Singula.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "query": "Juan Perez Gomez",
    "queries": ["Juan Perez Gomez"],
    "query_attributes": { "country": "MX", "dob": "1985-04-12", "gender": "male", "id_number": "PEGJ850412HDFRMN09" },
    "has_disambiguators": true,
    "total": 87616,
    "pool_examined": 200,
    "risk_summary": {
      "level": "medium",
      "best_certainty": 78.5,
      "best_match": {
        "certainty": 78.5,
        "certainty_tier": "possible",
        "match_type": "fuzzy",
        "full_name": "JUAN PEREZ G.",
        "source_list": "OFAC SDN",
        "source_country": "US",
        "category": "sanctions"
      },
      "categories_found": ["sanctions"],
      "tier_breakdown": { "definitive": 0, "strong": 0, "possible": 1, "weak": 3, "no_match": 196 }
    },
    "disambiguation": {
      "homonym_count": 3,
      "strong_homonyms": 1,
      "by_country": [{ "key": "US", "count": 2 }, { "key": "MX", "count": 1 }],
      "warning": "Multiple homonyms detected"
    },
    "groups": {
      "sanctions": {
        "label": "Sanciones internacionales",
        "icon": "🚫",
        "relevant_count": 1,
        "weak_count": 3,
        "raw_count": 12,
        "results": [
          {
            "score": 0.81,
            "certainty": 78.5,
            "certainty_tier": "possible",
            "match_type": "fuzzy",
            "full_name": "JUAN PEREZ G.",
            "aliases": ["J. PEREZ"],
            "date_of_birth": "1985",
            "nationality": "US",
            "source_list": "OFAC SDN",
            "source_name": "Office of Foreign Assets Control",
            "source_country": "US",
            "category": "sanctions",
            "programs": ["SDNTK"],
            "listed_on": "2019-06-01",
            "confirms": ["name"],
            "conflicts": ["country"],
            "explanation": "Name matches with medium confidence; country differs."
          }
        ]
      },
      "criminal": { "label": "Listados criminales", "icon": "⚖️", "relevant_count": 0, "weak_count": 0, "raw_count": 0, "results": [] },
      "pep": { "label": "Personas políticamente expuestas", "icon": "🏛️", "relevant_count": 0, "weak_count": 0, "raw_count": 0, "results": [] },
      "debarment": { "label": "Inhabilitaciones", "icon": "⛔", "relevant_count": 0, "weak_count": 0, "raw_count": 0, "results": [] },
      "fiscal": { "label": "Listas fiscales", "icon": "💰", "relevant_count": 0, "weak_count": 0, "raw_count": 0, "results": [] },
      "export_control": { "label": "Control de exportaciones", "icon": "📦", "relevant_count": 0, "weak_count": 0, "raw_count": 0, "results": [] },
      "state_owned": { "label": "Empresas del Estado", "icon": "🏢", "relevant_count": 0, "weak_count": 0, "raw_count": 0, "results": [] },
      "adverse_media": { "label": "Medios adversos", "icon": "📰", "relevant_count": 0, "weak_count": 0, "raw_count": 0, "results": [] },
      "other": { "label": "Otros", "icon": "📌", "relevant_count": 0, "weak_count": 0, "raw_count": 0, "results": [] }
    },
    "checked_at": "2026-09-03T18:22:41.000Z"
  }
}
4 endpoints

Búsqueda judicial

Busca a un cliente (persona física o moral) en el Boletín Judicial y devuelve los expedientes/casos judiciales relacionados, con un nivel de riesgo agregado y el detalle completo de inteligencia por persona.

Síncrono: la respuesta llega de inmediato en `data` (no hay que hacer polling ni esperar webhook). Ojo: la petición puede tardar hasta ~60 s porque el buscador (OpenSearch) puede ser lento en nombres muy comunes o con muchos tokens; usa un timeout de cliente holgado. Con una API key de sandbox/dev la respuesta es un mock determinista inmediato (siempre `status: "clean"`).

Búsqueda judicial de una persona física (consulta directa)

POST/app/judicial

Busca expedientes judiciales por nombre, sin crear un customer. Misma inteligencia de personas que la variante por customer.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningún customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si después quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta. Síncrono: devuelve todo en `data` de inmediato, pero puede tardar hasta ~60 s con nombres muy comunes — pon un timeout amplio. Con llave de sandbox devuelve el mock fijo ('clean'). En producción pide el KYB aprobado, aquí y sobre un cliente: una cuenta sin él recibe 403 con el motivo en `message` (el sandbox no lo pide). Sin KYB, los antecedentes de persona corren sólo dentro de un flujo KYC, después de un paso de identidad aprobado de esa corrida y si el documento verificado nombra al cliente (`after_identity` en `GET /app/flows/catalog`). El KYB es self-service: Ajustes → Verificación de tu cuenta. Errores: 400 nombre vacío, 401, 402, 403 sin KYB, 503 si el proveedor judicial falla.

Request
namestringbodyrequired

Nombre completo de la persona tal como aparecería en un expediente: nombre(s) + apellido paterno + apellido materno.

curl -X POST https://api.singula.mx/app/judicial \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Juan Perez Gomez"
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoría, para el estado de cuenta y para guardarla después como cliente.

dataobject

Idéntico a la variante por customer: misma forma, mismos campos, mismo detalle.

data.queriesstring[]

Los nombres que efectivamente se enviaron al buscador. En persona física siempre trae un solo elemento: el nombre completo que se buscó.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "status": "records_found",
    "total_records": 2,
    "risk_level": "medium",
    "summary": "Encontramos persona: Juan Perez Gomez (2 casos)",
    "intelligence": "(personas_exactas, coincidencias_parciales y estadísticas, idénticas a la variante por customer)"
  }
}

Búsqueda judicial de una persona moral (consulta directa)

POST/app/judicial-moral

La misma búsqueda, resuelta como entidad (empresa) en vez de persona física. Sin customer. Manda el nombre comercial en `name` y la razón social en `legal_name`: cuando difieren se buscan las dos en la misma consulta.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningún customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si después quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta. Mismas condiciones que la búsqueda de persona física: síncrona, hasta ~60 s, mock fijo en sandbox. Dos nombres, una sola consulta: si mandas `legal_name` y difiere de `name`, por dentro se corren las dos búsquedas y el resultado llega fusionado en un solo `data`, con un solo cargo y un solo `request_id`. Si los dos normalizan al mismo nombre (se ignoran acentos, puntuación y la forma legal) se busca una sola vez. `data.queries` dice exactamente qué se buscó. Mandar sólo `name` sigue funcionando igual que antes. En producción pide el KYB aprobado, aquí y sobre un cliente: una cuenta sin él recibe 403 con el motivo en `message` (el sandbox no lo pide). Un flujo con este paso tampoco publica sin KYB.

Request
namestringbodyrequired

Nombre comercial de la empresa — o la razón social, si es lo único que tienes. Es el único campo obligatorio.

legal_namestringbody

Razón social de la empresa, con su forma legal (S.A. de C.V., S. de R.L., ...). Cuando difiere del nombre comercial se buscan las dos en la misma consulta: primero la razón social y después el nombre comercial. Si ambas normalizan al mismo nombre —se ignoran acentos, puntuación y la forma legal— se busca una sola vez. En los dos casos es un solo cargo y un solo `request_id`.

curl -X POST https://api.singula.mx/app/judicial-moral \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Ferretería El Tornillo",
  "legal_name": "Comercializadora Tornillo, S.A. de C.V."
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoría, para el estado de cuenta y para guardarla después como cliente.

dataobject

Idéntico a la variante por customer: misma forma, mismos campos, mismo detalle.

data.queriesstring[]

Los nombres que efectivamente se enviaron al buscador, en orden: la razón social primero y el nombre comercial después. Dos elementos cuando los dos nombres difieren; uno solo cuando mandaste un nombre o cuando ambos normalizan al mismo. El resto de `data` no cambia.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "status": "clean",
    "risk_level": "none",
    "queries": ["Comercializadora Tornillo, S.A. de C.V.", "Ferretería El Tornillo"],
    "intelligence": "(misma forma que la variante por customer, con filters_applied.tipo = persona_moral)"
  }
}

Búsqueda judicial de una persona física (por customer)

GET/app/judicial/customer/:id

Toma el nombre guardado del customer (nombre + apellido paterno + apellido materno) y busca sus casos en el Boletín Judicial, devolviendo veredicto, riesgo y la inteligencia completa por persona.

ToolType getJudicialRecordsOfPerson), sobreescribible por organización vía pricing_config. Requiere créditos suficientes o responde 402. Síncrono: devuelve todo en `data` de inmediato (sin polling ni webhook), pero puede tardar hasta ~60 s en nombres muy comunes. El nombre buscado se toma del customer guardado (name + last_name + mothers_last_name); no se envía en la petición. Errores: 409 si el customer no existe; 401 si la API key es inválida; 403 en producción si tu cuenta no tiene el KYB aprobado (el motivo viene en `message`); 503 si el servicio judicial no está configurado o falla el proveedor. Con API key de sandbox/dev devuelve un mock fijo ('clean'). Sin KYB, los antecedentes de persona corren sólo dentro de un flujo KYC, después de un paso de identidad aprobado de esa corrida y si el documento verificado nombra al cliente.

Request
Authorizationstringheaderrequired

Bearer <API_KEY>. Tu llave de API de Singula. Determina también el entorno: una llave de sandbox/dev devuelve datos mock.

idstring (uuid)pathrequired

ID del customer previamente creado en Singula. El nombre a buscar se arma con los campos guardados del customer (name + last_name + mothers_last_name); no se envía el nombre en la petición.

curl -X GET https://api.singula.mx/app/judicial/customer/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

ID del LogRequest de esta consulta (para auditoría / soporte).

data.statusstring

'records_found' si hubo al menos una persona con coincidencia exacta; 'clean' si no.

data.total_recordsnumber

Suma de casos judiciales de todas las personas con coincidencia exacta.

data.risk_levelstring

Riesgo agregado: 'high' (coincidencia perfecta y >=5 casos), 'medium' (hay coincidencias exactas), 'none' (sin coincidencias).

data.summarystring

Resumen en español legible para humanos del hallazgo.

data.sources_checkedstring[]

Fuentes consultadas. Siempre ['Boletin Judicial — Inteligencia de personas'].

data.queriesstring[]

Los nombres que efectivamente se enviaron al buscador. En persona física siempre trae un solo elemento: el nombre completo que se buscó.

data.checked_atstring (ISO-8601)

Fecha/hora en que se ejecutó la consulta.

data.intelligenceobject | null

Payload completo de inteligencia del buscador judicial (null si el proveedor no devolvió nada).

data.intelligence.querystring

Texto de nombre que efectivamente se buscó.

data.intelligence.total_recordsnumber

Total de registros que existen en el índice para esa consulta (antes de filtrar).

data.intelligence.fetched_recordsnumber

Cuántos registros se trajeron para analizar (tope 200).

data.intelligence.relevant_recordsnumber

Registros que superaron el umbral de relevancia.

data.intelligence.discarded_low_scorenumber

Registros descartados por baja puntuación.

data.intelligence.score_topnumber

Mejor puntuación de coincidencia encontrada.

data.intelligence.score_thresholdnumber

Umbral de corte de puntuación usado en esta búsqueda.

data.intelligence.search_msnumber

Duración de la búsqueda en milisegundos.

data.intelligence.filters_appliedobject

Filtros aplicados: { tipo, tipo_resolved, entidad, estado_residencia }. Para este endpoint tipo='persona_fisica'.

data.intelligence.summaryobject

{ message, type } — mensaje y tipo de hallazgo (p.ej. 'found_one', 'no_results').

data.intelligence.personas_exactasobject[]

Personas con coincidencia exacta y todo su dossier: person_id, match_type, match_label, nombre_canonico, aliases_vistos, name_signature, total_casos, total_acuerdos, rango_fechas, entidades, categorias, top_contrapartes, is_homonym, homonym_count y expedientes[].

data.intelligence.personas_exactas[].expedientesobject[]

Expedientes por persona: expediente, juzgado, entidad, categoria, actor, demandado, estado_caso, primer/ultimo_acuerdo, duracion_dias, num_acuerdos y acuerdos[] (cada acuerdo con fecha, tipo_evento, texto_limpio, doc_id, etc.).

data.intelligence.coincidencias_parcialesobject[]

Personas con coincidencia parcial (mismo shape que personas_exactas).

data.intelligence.registros_excluidos_similaresobject[]

Nombres similares descartados: [{ nombre, veces }].

data.intelligence.estadisticas_globalesobject

Agregados: por_categoria[], por_entidad{}, personas_exactas_count, coincidencias_parciales_count, registros_excluidos.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "status": "records_found",
    "total_records": 2,
    "risk_level": "medium",
    "summary": "Encontramos persona: Juan Perez Gomez (2 casos)",
    "sources_checked": ["Boletin Judicial — Inteligencia de personas"],
    "checked_at": "2026-09-03T18:22:11.123Z",
    "intelligence": {
      "query": "JUAN PEREZ GOMEZ",
      "total_records": 2491105,
      "fetched_records": 200,
      "relevant_records": 12,
      "discarded_low_score": 188,
      "score_top": 93.12,
      "score_threshold": 23.28,
      "search_ms": 3216,
      "filters_applied": { "tipo": "persona_fisica", "tipo_resolved": "person", "entidad": null, "estado_residencia": null },
      "summary": { "message": "Encontramos persona: Juan Perez Gomez (2 casos)", "type": "found_one" },
      "personas_exactas": [
        {
          "person_id": "p_4a1f7e8f",
          "match_type": "perfect",
          "match_label": "Coincidencia exacta",
          "is_entity": false,
          "missing_tokens": [],
          "match_warning": null,
          "nombre_canonico": "Juan Perez Gomez",
          "aliases_vistos": ["Perez Gomez Juan"],
          "name_signature": ["GOMEZ", "JUAN", "PEREZ"],
          "total_casos": 2,
          "total_acuerdos": 5,
          "rango_fechas": { "min": "2021-08-12", "max": "2021-09-27" },
          "entidades": { "Ciudad de México": 2 },
          "categorias": { "MERCANTIL_EJECUTIVO": 2 },
          "top_contrapartes": [{ "nombre": "Banco Demo SA de CV", "veces": 2 }],
          "geo_signal": null,
          "is_homonym": false,
          "homonym_count": 1,
          "expedientes": [
            {
              "expediente_id": "39c10757",
              "expediente": "689/2021",
              "juzgado": "JUZGADOS DE LO CIVIL",
              "entidad": "Ciudad de México",
              "categoria": { "codigo": "MERCANTIL_EJECUTIVO", "label": "Mercantil ejecutivo", "icon": "⚡", "rama": "Mercantil" },
              "actor": "Banco Demo SA de CV",
              "demandado": "Perez Gomez Juan",
              "estado_caso": { "estado": "DESCONOCIDO", "label": "Sin determinar", "color": "gray", "grupo": "OTROS", "evento_decisivo": null },
              "primer_acuerdo": "2021-08-12",
              "ultimo_acuerdo": "2021-09-27",
              "duracion_dias": 46,
              "num_acuerdos": 4,
              "tab": "",
              "juzgado_codigo": "",
              "acuerdos": [
                { "fecha": "2021-08-12", "tipo_evento": "TRAMITE", "evento_label": "Trámite", "evento_icon": "📌", "evento_clave": false, "todos_eventos": [], "texto_raw": "...", "texto_limpio": "Se admite la demanda...", "was_corrected": false, "fuente_confidence": 0.65, "fecha_promocion": null, "doc_id": "eba9d824e4cdf603" }
              ]
            }
          ]
        }
      ],
      "coincidencias_parciales": [],
      "registros_excluidos_similares": [{ "nombre": "JUAN PEREZ", "veces": 23 }],
      "estadisticas_globales": {
        "por_categoria": [{ "codigo": "MERCANTIL_EJECUTIVO", "label": "Mercantil ejecutivo", "icon": "⚡", "rama": "Mercantil", "casos": 2 }],
        "por_entidad": { "Ciudad de México": 2 },
        "personas_exactas_count": 1,
        "coincidencias_parciales_count": 0,
        "registros_excluidos": 90
      }
    }
  }
}

Búsqueda judicial de una persona moral (por customer)

GET/app/judicial-moral/customer/:id

Igual que la búsqueda de persona física pero para empresas: toma la razón social (legal_name) y el nombre comercial (name) guardados del customer y busca casos judiciales de esa persona moral en el Boletín Judicial.

ToolType getJudicialRecordsMoral), sobreescribible por organización. Síncrono (sin polling ni webhook). Gotcha clave: si el customer NO es de tipo 'moral' responde 400 'Customer is not a persona moral'. Otros errores: 409 si el customer no existe; 401 API key inválida; 402 sin créditos; 403 en producción si tu cuenta no tiene el KYB aprobado (el motivo viene en `message`); 503 si el servicio judicial no está configurado o falla el proveedor. La búsqueda usa los dos nombres guardados del customer: la razón social (legal_name) primero y el nombre comercial (name) después. Si difieren se corren las dos por dentro y el resultado llega fusionado en un solo data, con una sola llamada, un solo cargo y un solo request_id; si normalizan al mismo nombre (se ignoran acentos, puntuación y la forma legal) se busca una sola vez. data.queries dice exactamente qué se buscó. Sandbox/dev devuelve mock fijo ('clean').

Request
Authorizationstringheaderrequired

Bearer <API_KEY>. Tu llave de API de Singula. Determina también el entorno (sandbox/dev = mock).

idstring (uuid)pathrequired

ID del customer. DEBE ser un customer de tipo 'moral'; si no, responde 400. La búsqueda usa la razón social (legal_name) y el nombre comercial (name) guardados del customer: si difieren se buscan los dos en la misma llamada, con un solo cargo. No hay nada que mandar en la petición.

curl -X GET https://api.singula.mx/app/judicial-moral/customer/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

ID del LogRequest de esta consulta.

dataobject

Mismo shape que la búsqueda de persona física: status, total_records, risk_level, summary, sources_checked, checked_at, queries, intelligence. Diferencia: data.intelligence.filters_applied.tipo = 'persona_moral' y las personas pueden venir con is_entity = true.

data.queriesstring[]

Los nombres que efectivamente se enviaron al buscador, en orden: la razón social primero y el nombre comercial después. Dos elementos cuando los dos nombres guardados difieren; uno solo cuando el customer tiene un solo nombre o cuando ambos normalizan al mismo.

data.statusstring

'records_found' o 'clean'.

data.risk_levelstring

'high' | 'medium' | 'none' (misma lógica de agregación).

data.intelligenceobject | null

Payload completo de inteligencia (mismo shape que persona física); ver ese endpoint para el detalle de personas_exactas[], expedientes[], acuerdos[] y estadisticas_globales.

Example
{
  "request_id": "6733a9b2d2e3f4a5b6c7d8e9",
  "data": {
    "status": "clean",
    "total_records": 0,
    "risk_level": "none",
    "summary": "No se encontraron registros judiciales relevantes.",
    "sources_checked": ["Boletin Judicial — Inteligencia de personas"],
    "checked_at": "2026-09-03T18:25:44.001Z",
    "queries": ["CONSTRUCTORA DEMO SA DE CV", "DEMO FERRETERIAS"],
    "intelligence": {
      "query": "CONSTRUCTORA DEMO SA DE CV",
      "total_records": 154302,
      "fetched_records": 200,
      "relevant_records": 0,
      "discarded_low_score": 200,
      "score_top": 18.4,
      "score_threshold": 23.28,
      "search_ms": 2044,
      "filters_applied": { "tipo": "persona_moral", "tipo_resolved": "entity", "entidad": null, "estado_residencia": null },
      "summary": { "message": "Sin resultados relevantes.", "type": "no_results" },
      "personas_exactas": [],
      "coincidencias_parciales": [],
      "registros_excluidos_similares": [],
      "estadisticas_globales": {
        "por_categoria": [],
        "por_entidad": {},
        "personas_exactas_count": 0,
        "coincidencias_parciales_count": 0,
        "registros_excluidos": 0
      }
    }
  }
}
2 endpoints

Email Lookup

Averigua en qué plataformas y redes sociales (Instagram, Twitter, Spotify, GitHub, etc.) está registrado el correo guardado de un customer, para medir su huella digital.

Síncrono. La petición se resuelve en la misma llamada y devuelve el resultado completo de una vez (el motor revisa ~120 plataformas y puede tardar entre 10 y 30 segundos, por lo que el servidor permite hasta 90s de timeout). NO hay cola, NO hay webhook y NO hay que hacer polling a ningún /status. El request_id que regresa es solo el identificador del registro (LogRequest) para tu historial/auditoría, no un handle para consultar después. (Nota: la anotación de Swagger de la ruta dice "Production: async — result delivered via webhook", pero el código real del servicio corre síncrono y devuelve el resultado inline; documentamos el comportamiento real.)

Email Lookup (consulta directa)

POST/app/email-lookup

Revisa en qué plataformas existe una dirección de correo mandándola en el cuerpo, sin crear un customer.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningún customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si después quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta. Síncrono, pero el motor revisa ~120 plataformas y puede tardar de 10 a 30 s: configura un timeout amplio. Errores: 400 correo inválido, 401, 402, 503 si el motor no responde.

Request
emailstringbodyrequired

Dirección de correo a revisar.

curl -X POST https://api.singula.mx/app/email-lookup \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "[email protected]"
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoría, para el estado de cuenta y para guardarla después como cliente.

dataobject

Idéntico a la variante por customer: misma forma, mismos campos, mismo detalle.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "email": "[email protected]",
    "total_found": 3,
    "total_checked": 4,
    "platforms": "(mismo arreglo que la variante por customer)",
    "checked_at": "2026-09-14T12:00:00.000Z"
  }
}

Email Lookup de un customer

GET/app/email-lookup/customer/:id

Toma el email ya guardado en el customer indicado y devuelve en qué plataformas/redes sociales existe una cuenta asociada a ese correo, junto con cuántas se encontraron.

Requiere créditos suficientes o devuelve 402 (Payment Required). Sandbox vs Producción: si tu API key es de desarrollo (is_dev), devuelve una respuesta MOCK de ejemplo al instante, sin llamar al motor real; en producción llama al motor real de forma síncrona. El resultado se persiste y se pasa por el motor de riesgo internamente. Gotchas: (1) el email se toma del customer (datameta.email), no del body — asegúrate de que el customer tenga email guardado antes de llamar; (2) puede tardar 10–30s, configura tu cliente con un timeout amplio; (3) si el motor externo falla devuelve 503 (Service Unavailable). Errores comunes: 400 (customer sin email o entrada inválida), 401 (API key inválida/ausente), 402 (créditos insuficientes), 409 (customer no encontrado), 503 (motor no disponible).

Request
Authorizationstringheaderrequired

Autenticación Bearer con tu API key. Formato: 'Bearer sk_live_...'. Requerido en todos los endpoints.

idstringpathrequired

ID del customer (creado previamente en tu cuenta) cuyo correo se va a analizar. IMPORTANTE: el email NO se envía en esta petición; se lee del email guardado en el customer (datameta.email). Si el customer no existe devuelve 409; si el customer no tiene email guardado devuelve 400.

curl -X GET https://api.singula.mx/app/email-lookup/customer/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

ID del registro de la consulta (LogRequest) en tu historial. Sirve para auditoría; NO se usa para consultar el resultado después (el resultado ya viene en esta misma respuesta).

dataobject

Objeto con el resultado completo del análisis del correo.

data.emailstring

El correo que se analizó (el que estaba guardado en el customer).

data.total_foundnumber

Cantidad de plataformas donde SÍ se encontró una cuenta registrada con ese correo (las que tienen exists=true).

data.total_checkednumber

Cantidad total de plataformas que se revisaron en esta consulta.

data.platformsarray<object>

Lista de plataformas revisadas con el resultado por cada una.

data.platforms[].namestring

Nombre de la plataforma o red social revisada (p. ej. 'instagram', 'twitter', 'spotify', 'github').

data.platforms[].existsboolean

true si existe una cuenta registrada con ese correo en esa plataforma; false si no.

data.platforms[].urlstring | null

URL del perfil cuando la plataforma la expone; típicamente null (la mayoría de plataformas solo confirman existencia, no el perfil).

data.checked_atstring (ISO 8601)

Fecha y hora UTC en que se hizo la consulta.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "email": "[email protected]",
    "total_found": 3,
    "platforms": [
      { "name": "twitter", "exists": true, "url": null },
      { "name": "instagram", "exists": true, "url": null },
      { "name": "spotify", "exists": true, "url": null },
      { "name": "github", "exists": false, "url": null }
    ],
    "total_checked": 4,
    "checked_at": "2026-09-03T12:00:00.000Z"
  }
}
4 endpoints

Phone Lookup

Valida un telefono ya guardado en un customer y devuelve validez, pais, operador, tipo de linea y un Singula Score de riesgo; el tier premium agrega huella digital, presencia (WhatsApp/redes) y senales de brecha/SMS pumping.

Sincrono. La respuesta llega de inmediato en el mismo request, con el objeto `data` (el resultado del lookup) y el `request_id`. No hay polling ni webhook: no existe endpoint `/status/:requestId` para esta herramienta. El telefono NO se envia en la llamada: se toma de `datameta.phone` del customer identificado por `:id`, que debe haberse creado antes con un telefono.

Consultar telefono — basico (consulta directa)

POST/app/phone-lookup

Valida un telefono mandandolo en el cuerpo, sin crear un customer: validez, pais, operador, tipo de linea y Singula Score.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningun customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si despues quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta. Sincrono: sin polling ni webhook. Errores: 400 telefono invalido, 401, 402. Desde 1.113.0, un número que resulta inválido (`valid: false`) se cobra igual que uno válido cuando su consulta tuvo costo; una consulta que falla (termina `error`) no se cobra.

Request
phonestringbodyrequired

Telefono a validar. Acepta formato E.164 (+525512345678) o 10 digitos nacionales.

curl -X POST https://api.singula.mx/app/phone-lookup \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "phone": "+525512345678"
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoria, para el estado de cuenta y para guardarla despues como cliente.

dataobject

Identico a la variante por customer: misma forma, mismos campos, mismo detalle.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "phone": "+525512345678",
    "valid": true,
    "country": "MX",
    "carrier": "Telcel",
    "line_type": "mobile",
    "tier": "basic",
    "assessment": { "score": 84, "band": "high", "recommendation": "approve" }
  }
}

Consultar telefono — premium (consulta directa)

POST/app/phone-lookup-premium

El tier premium sin customer: agrega huella digital, presencia en WhatsApp y redes, y senales de brecha o SMS pumping.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningun customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si despues quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta. Cobra el cargo del tier premium, el mismo que su variante por customer. Sincrono. Desde 1.113.0, un número que resulta inválido (`valid: false`) se cobra igual que uno válido cuando su consulta tuvo costo; una consulta que falla (termina `error`) no se cobra.

Request
phonestringbodyrequired

Telefono a validar. Acepta formato E.164 (+525512345678) o 10 digitos nacionales.

curl -X POST https://api.singula.mx/app/phone-lookup-premium \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "phone": "+525512345678"
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoria, para el estado de cuenta y para guardarla despues como cliente.

dataobject

Identico a la variante por customer: misma forma, mismos campos, mismo detalle.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "phone": "+525512345678",
    "valid": true,
    "tier": "premium",
    "assessment": { "score": 84, "band": "high", "recommendation": "approve" },
    "digital": "(mismos bloques premium que la variante por customer)"
  }
}

Consultar telefono — basico (con customer)

GET/app/phone-lookup/customer/{id}

Valida el telefono guardado en el customer y devuelve validez + pais + operador + tipo de linea + Singula Score (tier basico).

Requiere que el customer exista y tenga telefono: sin customer -> 409 'Customer not found'; sin telefono -> 400 'Customer has no phone'. En sandbox (API key de dev) responde con datos mock, no consulta real. Sincrono: no hay polling ni webhook. Desde 1.113.0, un número que resulta inválido (`valid: false`) se cobra igual que uno válido cuando su consulta tuvo costo; una consulta que falla (termina `error`) no se cobra.

Request
idstringpathrequired

ID del customer previamente creado (el telefono a validar se lee de su campo datameta.phone; el customer debe existir en el mismo entorno —sandbox/produccion— de tu API key y tener telefono).

Authorizationstringheaderrequired

Bearer API key: encabezado `Authorization: Bearer <TU_API_KEY>`.

curl -X GET https://api.singula.mx/app/phone-lookup/customer/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

ID de la peticion (LogRequest) que quedo registrada; sirve para conciliacion/soporte y billing.

data.phonestring

Telefono consultado, normalizado a formato E.164 (ej. +525512345678).

data.validboolean

true si el numero es un telefono valido/marcable.

data.countrystring | null

Codigo ISO del pais del numero (ej. MX). null si no se pudo determinar.

data.country_namestring | null

Nombre del pais (ej. Mexico). null si no se pudo determinar.

data.carrierstring | null

Operador/compania telefonica (ej. Telcel). null si no se pudo determinar.

data.line_typestring | null

Tipo de linea: mobile, landline, voip, etc. null si se desconoce.

data.regionstring | null

Region/ciudad asociada al numero (ej. Mexico City). null si se desconoce.

data.checked_atstring (ISO 8601)

Fecha y hora UTC en que se realizo la consulta.

data.tierstring

Tier del resultado: 'basic' en este endpoint.

data.assessment.scorenumber

Singula Score del telefono (0-100): mayor = mas confiable.

data.assessment.bandstring

Banda cualitativa del score: high / medium / low.

data.assessment.recommendationstring

Recomendacion sugerida: approve / review / reject.

data.assessment.reason_codesstring[]

Codigos que explican el score (ej. R17_CARRIER_MOBILE_REAL = operador movil real).

Example
{
  "data": {
    "phone": "+525512345678",
    "valid": true,
    "country": "MX",
    "country_name": "Mexico",
    "carrier": "Telcel",
    "line_type": "mobile",
    "region": "Mexico City",
    "checked_at": "2026-09-03T18:30:12.000Z",
    "tier": "basic",
    "assessment": {
      "score": 84,
      "band": "high",
      "recommendation": "approve",
      "reason_codes": ["R17_CARRIER_MOBILE_REAL"]
    }
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8"
}

Consultar telefono — premium (con customer)

GET/app/phone-lookup-premium/customer/{id}

Todo lo del basico + huella digital, presencia (WhatsApp/redes), nombres vinculados y senales de brecha / SMS pumping, con un Singula Score enriquecido (tier premium).

Mismos prerequisitos que el basico: customer existente (si no, 409) y con telefono (si no, 400). Los campos risk/presence/names solo aparecen en premium. En sandbox responde mock. Sincrono: sin polling ni webhook. Desde 1.113.0, un número que resulta inválido (`valid: false`) se cobra igual que uno válido cuando su consulta tuvo costo; una consulta que falla (termina `error`) no se cobra.

Request
idstringpathrequired

ID del customer previamente creado (el telefono a validar se lee de su campo datameta.phone; el customer debe existir en el mismo entorno de tu API key y tener telefono).

Authorizationstringheaderrequired

Bearer API key: encabezado `Authorization: Bearer <TU_API_KEY>`.

curl -X GET https://api.singula.mx/app/phone-lookup-premium/customer/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

ID de la peticion (LogRequest) registrada; para conciliacion/soporte y billing.

data.phonestring

Telefono consultado en formato E.164.

data.validboolean

true si el numero es valido/marcable.

data.countrystring | null

Codigo ISO del pais (ej. MX) o null.

data.country_namestring | null

Nombre del pais o null.

data.carrierstring | null

Operador telefonico o null.

data.line_typestring | null

Tipo de linea (mobile/landline/voip) o null.

data.regionstring | null

Region/ciudad o null.

data.checked_atstring (ISO 8601)

Fecha/hora UTC de la consulta.

data.tierstring

Tier del resultado: 'premium' en este endpoint.

data.assessment.scorenumber

Singula Score enriquecido (0-100).

data.assessment.bandstring

Banda del score: high / medium / low.

data.assessment.recommendationstring

Recomendacion: approve / review / reject.

data.assessment.reason_codesstring[]

Codigos que explican el score (ej. R20_LINE_ACTIVE, R10_FOOTPRINT_RICH).

data.riskobject

Solo premium. Senales de riesgo del numero, ej. { sms_pumping_risk: number, breached: boolean, breach_count: number }.

data.presenceobject

Solo premium. Presencia en apps/redes, ej. { whatsapp: { registered: true }, accounts: { instagram: true } }.

data.namesarray

Solo premium. Nombres vinculados al numero (>=2 fuentes = grado-decision); [] cuando no hay coincidencias.

Example
{
  "data": {
    "phone": "+525512345678",
    "valid": true,
    "country": "MX",
    "country_name": "Mexico",
    "carrier": "Telcel",
    "line_type": "mobile",
    "region": "Mexico City",
    "checked_at": "2026-09-03T18:30:12.000Z",
    "tier": "premium",
    "assessment": {
      "score": 92,
      "band": "high",
      "recommendation": "approve",
      "reason_codes": ["R17_CARRIER_MOBILE_REAL", "R20_LINE_ACTIVE", "R10_FOOTPRINT_RICH"]
    },
    "risk": { "sms_pumping_risk": 8, "breached": true, "breach_count": 2 },
    "presence": { "whatsapp": { "registered": true }, "accounts": { "instagram": true } },
    "names": []
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8"
}
4 endpoints

Huella digital (persona o empresa)

Huella digital y Huella digital Premium (antes «Inteligencia»; las rutas siguen siendo `/app/intel` y `/app/intel-premium`, directas y `/customer/:id`, y no cambia ningún id). Investigan la huella pública de una persona o de una empresa en la web, las noticias, las imágenes y las redes sociales (LinkedIn y otras); Huella digital Premium agrega una síntesis narrativa del perfil. Cada resultado web y cada nota dicen si hablan de quien consultaste y, si reportan un hecho adverso, su tipo y el papel del sujeto; lo que es de alguien o algo más con el mismo nombre se quita del reporte y se cuenta. No incluye antecedentes judiciales, listas de sanciones ni consultas al SAT: son productos aparte. Como paso de un flujo corre en un KYC, sobre la persona, y en un KYB, sobre la empresa.

Asíncrono vía webhook. En PRODUCCIÓN (API key live) el POST responde de inmediato con `request_id` y `data: {}`; el resultado completo se entrega después por el webhook configurado de tu organización: el evento firmado `intelligence.completed` —desde 1.117.0, el de Huella digital; documentos y e.firma tienen el suyo— con `{ request_id, tool_type, status }`, donde `tool_type` es `getOsintReport` en los dos tiers (el registro de la consulta conserva `getPersonIntelligence` / `getPersonIntelligencePremium`). Si la investigación falla llega el mismo evento firmado, con `status: "error"` (desde 1.113.0; antes era un POST sin firma con `{ type, request_id, customer_id }`). COBRO: lo entregado se cobra como siempre, y una empresa se cobra igual que una persona; una investigación que falla después de una consulta con costo se cobra su cargo base, aunque la falla sea nuestra; si falla antes, o si ninguna fuente contestó, no se cobra. No existe endpoint de polling dedicado para Huella digital — el único `GET /app/status` es un health-check, no consulta resultados. En SANDBOX (API key de desarrollo / is_dev) la respuesta es inmediata y ya trae el `data` completo —un reporte de persona o de empresa, según `subject` o el tipo del cliente, con las mismas llaves que producción— y además dispara el webhook, con el mismo `tool_type: "getOsintReport"` desde 1.117.0 (antes mandaba `getPersonIntelligence` / `getPersonIntelligencePremium`). El shape del resultado es idéntico en ambos entornos y para persona o empresa. Los campos de 1.117.0 (`subject`, `about_subject`, `subject_kind`, `adverse`, `excluded`) son aditivos: ningún campo existente cambió de tipo, y un reporte anterior no los trae.

Huella digital (consulta directa, persona o empresa)

POST/app/intel

Investiga a una persona o a una empresa con sus datos en el cuerpo, sin crear un customer. `subject` decide cuál: `person` (default) o `company`.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningún customer. Cobra exactamente lo mismo que la variante por customer (una empresa, igual que una persona), aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock —de persona o de empresa, según `subject`— y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si después quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta (una consulta de empresa crea una persona moral). A diferencia de la variante por customer, los datos de contexto NO se guardan en ningún datameta: valen sólo para esta consulta. EMPRESA: `{ "subject": "company", "name": "Ferretería El Tornillo", "legal_name": "Comercializadora Tornillo, S.A. de C.V.", "rfc": "CTR150101AB1", "website": "example.com", "city": "Guadalajara", "state": "Jalisco" }`; del contexto, a una empresa sólo le sirven `city`, `state`, `country`, `email`, `phone` y `address` (`last_name`, `mothers_last_name`, `company`, `job_title` y `nationality` son de persona: en una empresa no se leen). CONTEXTO: `company`, `job_title`, `city`, `state`, `country`, `email`, `phone`, `address` y `nationality` se recortan a sus primeros 300 caracteres —nunca dan 400— y lo que se busca y se guarda es lo recortado. En producción el POST responde con { request_id, data: {} } y el resultado llega por webhook; en sandbox la respuesta es inmediata y completa. Los campos summary/linkedin/work_history/education/companies/digital_footprint/sources no se llenan en este tier.

Request
subjectstring (enum: person|company)body

De quién es la investigación: `person` (default) o `company`. Con `company`, `name` es el nombre comercial, `last_name` no se pide y se pueden mandar `legal_name`, `rfc` (12 caracteres) y `website`. Mismo cargo para persona y empresa. Otro valor → 400 «subject must be one of the following values: person, company».

namestringbodyrequired

Persona: sólo el nombre o los nombres de pila, SIN apellidos; los apellidos van en `last_name` (obligatorio) y `mothers_last_name`. Desde 1.115.0 el nombre completo aquí se tolera: un apellido con el que `name` ya termina —sin importar mayúsculas, acentos ni espacios extra— no se busca dos veces. Empresa (`subject: "company"`): el nombre comercial.

last_namestringbody

Apellido paterno. Obligatorio para una persona: la búsqueda directa necesita nombre y apellido paterno, aunque `name` ya lo traiga. Una empresa no lo lleva.

mothers_last_namestringbody

Apellido materno (sólo persona). Va aquí, no en `name`.

legal_namestringbody

Razón social (sólo empresa), hasta 200 caracteres. En una persona se ignora.

rfcstringbody

RFC: 13 caracteres una persona, 12 una empresa. Con `subject: "company"`, un RFC de persona → 400 «rfc must be a 12-character company RFC (a person RFC has 13)».

websitestringbody

Sitio web o dominio de la empresa (sólo empresa), con o sin `https://`, hasta 200 caracteres. Vacío cuenta como no enviado; uno que no es URL ni dominio → 400 «website must be a URL or a domain». En una persona se ignora.

companystringbody

Empresa donde trabaja la persona (sólo persona).

job_titlestringbody

Puesto o cargo (sólo persona).

citystringbody

Ciudad de residencia (persona) o donde opera (empresa).

statestringbody

Estado o región.

countrystringbody

Codigo de pais ISO-2 (MX, AR, US, ...). Default semantico: MX.

emailstringbody

Correo conocido de la persona o la empresa.

phonestringbody

Teléfono conocido.

addressstringbody

Dirección conocida.

nationalitystringbody

Nacionalidad (sólo persona).

curl -X POST https://api.singula.mx/app/intel \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Juan",
  "last_name": "Perez",
  "mothers_last_name": "Gomez",
  "company": "ACME S.A. de C.V.",
  "city": "Guadalajara",
  "country": "MX"
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoria, para el estado de cuenta y para guardarla despues como cliente.

dataobject

Idéntico a la variante por customer: misma forma, mismos campos, mismo detalle. `data.subject` dice si el reporte es de una persona o de una empresa.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "tier": "basic",
    "subject": "person",
    "web_results": "(misma forma que la variante por customer)",
    "news": "(...)",
    "images": "(...)",
    "linkedin_profiles": "(...)",
    "excluded": { "namesakes": 3, "brands_or_companies": 1, "other": 0 },
    "sentiment_summary": { "positive": 0, "neutral": 2, "negative": 1, "overall": "negative", "unconfirmed_adverse": 0 },
    "summary": null
  }
}

Huella digital Premium (consulta directa, persona o empresa)

POST/app/intel-premium

El mismo cuerpo que Huella digital —persona o empresa—, con la síntesis narrativa del perfil y la investigación a fondo del tier Premium.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningún customer. Cobra exactamente lo mismo que la variante por customer (una empresa, igual que una persona), aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock —de persona o de empresa, según `subject`— y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si después quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta (una consulta de empresa crea una persona moral). El cuerpo y sus reglas son los de Huella digital (consulta directa): `subject`; la empresa con `name`, `legal_name`, `rfc` de 12 y `website`; del contexto, a una empresa sólo le sirven `city`, `state`, `country`, `email`, `phone` y `address` (`last_name`, `mothers_last_name`, `company`, `job_title` y `nationality` son de persona: en una empresa no se leen); y `company`, `job_title`, `city`, `state`, `country`, `email`, `phone`, `address` y `nationality` se recortan a sus primeros 300 caracteres —nunca dan 400— y lo que se busca y se guarda es lo recortado. El tool_type del registro y el cargo son los del tier Premium, separados del básico para analítica y facturación.

Request
subjectstring (enum: person|company)body

De quién es la investigación: `person` (default) o `company`. Con `company`, `name` es el nombre comercial, `last_name` no se pide y se pueden mandar `legal_name`, `rfc` (12 caracteres) y `website`. Mismo cargo para persona y empresa. Otro valor → 400 «subject must be one of the following values: person, company».

namestringbodyrequired

Persona: sólo el nombre o los nombres de pila, SIN apellidos; los apellidos van en `last_name` (obligatorio) y `mothers_last_name`. Desde 1.115.0 el nombre completo aquí se tolera: un apellido con el que `name` ya termina —sin importar mayúsculas, acentos ni espacios extra— no se busca dos veces. Empresa (`subject: "company"`): el nombre comercial.

last_namestringbody

Apellido paterno. Obligatorio para una persona: la búsqueda directa necesita nombre y apellido paterno, aunque `name` ya lo traiga. Una empresa no lo lleva.

mothers_last_namestringbody

Apellido materno (sólo persona). Va aquí, no en `name`.

legal_namestringbody

Razón social (sólo empresa), hasta 200 caracteres. En una persona se ignora.

rfcstringbody

RFC: 13 caracteres una persona, 12 una empresa. Con `subject: "company"`, un RFC de persona → 400 «rfc must be a 12-character company RFC (a person RFC has 13)».

websitestringbody

Sitio web o dominio de la empresa (sólo empresa), con o sin `https://`, hasta 200 caracteres. Vacío cuenta como no enviado; uno que no es URL ni dominio → 400 «website must be a URL or a domain». En una persona se ignora.

companystringbody

Empresa donde trabaja la persona (sólo persona).

job_titlestringbody

Puesto o cargo (sólo persona).

citystringbody

Ciudad de residencia (persona) o donde opera (empresa).

statestringbody

Estado o región.

countrystringbody

Codigo de pais ISO-2 (MX, AR, US, ...). Default semantico: MX.

emailstringbody

Correo conocido de la persona o la empresa.

phonestringbody

Teléfono conocido.

addressstringbody

Dirección conocida.

nationalitystringbody

Nacionalidad (sólo persona).

curl -X POST https://api.singula.mx/app/intel-premium \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "subject": "company",
  "name": "Ferretería El Tornillo",
  "legal_name": "Comercializadora Tornillo, S.A. de C.V.",
  "rfc": "CTR150101AB1",
  "website": "example.com",
  "city": "Guadalajara",
  "state": "Jalisco"
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoria, para el estado de cuenta y para guardarla despues como cliente.

dataobject

Idéntico a la variante por customer: misma forma, mismos campos, mismo detalle. `data.subject` dice si el reporte es de una persona o de una empresa.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "tier": "premium",
    "subject": "company",
    "web_results": [
      { "title": "Ferretería El Tornillo — sitio oficial", "url": "https://example.com", "snippet": "Herramienta y ferretería en Guadalajara...", "domain": "example.com", "about_subject": "yes", "subject_kind": "company", "sentiment": "neutral", "adverse": null }
    ],
    "news": "(misma forma que la variante por customer)",
    "summary": "(síntesis narrativa, igual que la variante por customer)",
    "digital_footprint": "medium",
    "sources": "(...)",
    "excluded": { "namesakes": 1, "brands_or_companies": 2, "other": 0 },
    "sentiment_summary": { "positive": 0, "neutral": 1, "negative": 0, "overall": "neutral", "unconfirmed_adverse": 0 }
  }
}

Huella digital (sobre un cliente, persona o empresa)

POST/app/intel/customer/:id

Investiga la huella digital pública del customer —resultados web, noticias, imágenes y redes— con los datos de su expediente. Un customer persona moral se investiga como empresa.

El CreditGuard exige saldo suficiente o devuelve 402 'Insufficient credits'. Async: en producción el POST responde YA con { request_id, data: {} } y el resultado llega por webhook; en sandbox la respuesta es inmediata y completa. Un customer persona moral se investiga como empresa —su nombre comercial, su razón social y su RFC, nunca columnas de apellido, CURP ni nacimiento— y el reporte lo dice en `subject`; del body, a una empresa sólo le sirven `city`, `state`, `country`, `email`, `phone` y `address`. Gotchas: (1) el customer debe existir en el mismo entorno de la key o da 409; (2) el body es 100% opcional — si lo mandas, sus campos se guardan (merge) en el datameta del customer y sirven para consultas futuras; si no lo mandas, se usan el datameta persistido y los datos del customer; (3) un campo del body de más de 300 caracteres se acepta y se usan sus primeros 300 (nunca da 400); (4) los campos summary/linkedin/work_history/education/companies/digital_footprint/sources y phone_lookup NO se llenan en este tier (usa Huella digital Premium para eso); (5) si el `name` guardado de una persona ya termina con sus apellidos, no se buscan dos veces (desde 1.115.0).

Request
Authorizationstringheaderrequired

API key como Bearer token: `Authorization: Bearer <tu_api_key>`. Una key de desarrollo apunta a Sandbox; una key live apunta a Produccion.

idstringpathrequired

ID del customer que se va a investigar. Debe existir previamente (creado con los endpoints de customers) y en el MISMO entorno que la API key; si no existe se devuelve 409 'Customer not found'. Los atributos de búsqueda se toman de ese customer: de una persona física, nombre, apellidos, CURP, RFC, fecha de nacimiento y género; de una persona moral, que se investiga como empresa, su nombre comercial (`name`; sin él, la razón social), su razón social (`legal_name`) y su RFC.

companystringbody

Empresa donde trabaja la persona (sólo persona). Ayuda a desambiguar y a enriquecer la búsqueda.

job_titlestringbody

Puesto o cargo de la persona (sólo persona).

citystringbody

Ciudad de residencia (persona) o donde opera (empresa). Ayuda a separar a quien se consulta de otros con el mismo nombre.

statestringbody

Estado o región. Ayuda a separar a quien se consulta de otros con el mismo nombre.

countrystringbody

Codigo de pais ISO-2 (MX, AR, US...). Default semantico: MX.

emailstringbody

Correo de la persona o la empresa (contexto adicional para la búsqueda).

phonestringbody

Teléfono de la persona o la empresa. En el tier básico sólo aporta contexto; el lookup de teléfono (phone_lookup) sólo se corre en Premium.

addressstringbody

Domicilio (contexto adicional).

nationalitystringbody

Nacionalidad de la persona (ej. 'Mexicana'; sólo persona).

curl -X POST https://api.singula.mx/app/intel/customer/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "company": "ACME S.A. de C.V.",
  "job_title": "Gerente de Ventas",
  "city": "Guadalajara",
  "state": "Jalisco",
  "country": "MX",
  "email": "[email protected]",
  "phone": "3312345678",
  "address": "Calle Reforma 123, Col. Centro",
  "nationality": "Mexicana"
}'
Response
request_idstring

ID de la solicitud (LogRequest). Se devuelve de inmediato y es el mismo id que llega en el webhook cuando el resultado esta listo.

dataobject

Resultado OSINT. En produccion viene vacio ({}) en la respuesta inmediata y se llena via webhook; en sandbox ya viene completo. Contiene los campos de abajo.

data.tierstring

'basic' para este endpoint.

data.subjectstring

De quién es el reporte: `person` o `company`. En la consulta directa lo decide `subject`; sobre un cliente, una persona moral es `company`. Reportes anteriores a 1.117.0 no lo traen: son de una persona.

data.web_resultsarray<object>

Resultados web. Cada item: { title, url, snippet, domain, about_subject, subject_kind, sentiment, adverse }.

data.web_results[].about_subjectstring

Si el resultado habla de la persona o empresa consultada: `yes`, `no` (habla de otra) o `unknown` (no se pudo saber). Es texto, no booleano. Lo que se identifica como de alguien o algo más no llega en el reporte: se quita y sólo se cuenta en `excluded`. Reportes anteriores a 1.117.0 no lo traen.

data.web_results[].subject_kindstring

Opcional: de qué habla el resultado, `person`, `company`, `brand` u `other`. Puede no venir.

data.web_results[].sentimentstring

Tono del resultado hacia la persona o empresa consultada: `negative` si reporta un hecho adverso suyo (`adverse`: acusada, detenida, sentenciada o demandada); si no, `positive`, `negative` o `neutral` según cómo habla de ella, cuando ese tono es claro y el resultado no habla de alguien más; en cualquier otro caso, `neutral`. Es la misma regla que en `news`. Reportes anteriores a 1.117.0 no lo traen.

data.web_results[].adverseobject|null

Igual que en `news[].adverse`: `{ category, role }` con las mismas listas cerradas, o `null` si el resultado no reporta un hecho adverso de la persona o empresa consultada. Es adverso sólo cuando el sujeto es `accused`, `detained`, `convicted` o `sued`. Reportes anteriores a 1.117.0 no lo traen.

data.newsarray<object>

Noticias. Cada item: { position, title, source, date, date_utc, url, snippet, sentiment (positive|neutral|negative), relevance_score (0..1), matches_query, about_subject, adverse }. Desde 1.117.0 `sentiment`, `relevance_score` y `matches_query` conservan su nombre y su tipo pero se calculan distinto, así que sus valores pueden cambiar respecto de reportes anteriores.

data.news[].sentimentstring

Tono de la nota hacia la persona o empresa consultada: `negative` si reporta un hecho adverso suyo (`adverse`: acusada, detenida, sentenciada o demandada); si no, `positive`, `negative` o `neutral` según cómo habla de ella, cuando ese tono es claro y el resultado no habla de alguien más; en cualquier otro caso, `neutral`.

data.news[].about_subjectstring

Igual que en `web_results`: `yes`, `no` o `unknown`, como texto. Reportes anteriores a 1.117.0 no lo traen.

data.news[].adverseobject|null

El hecho adverso que la nota reporta de la persona o empresa consultada, `{ category, role }`, o `null` si no reporta ninguno. Una nota es adversa sólo cuando el sujeto es `accused`, `detained`, `convicted` o `sued`; como víctima, autoridad, testigo o sólo mencionado, `adverse` es `null`. Reportes anteriores a 1.117.0 no lo traen.

data.news[].adverse.categorystring

Tipo de hecho, lista cerrada de 11: `fraud` (fraude), `corruption` (corrupción), `money_laundering` (lavado de dinero), `drug_trafficking` (narcotráfico), `violence` (violencia), `sexual_offense` (delito sexual), `theft` (robo), `other_crime` (otro delito), `civil_or_commercial_suit` (demanda civil o mercantil), `tax` (fiscal) y `regulatory` (regulatorio).

data.news[].adverse.rolestring

Papel del sujeto en el hecho, lista cerrada de 8: `accused` (acusado), `detained` (detenido), `convicted` (sentenciado), `sued` (demandado), `victim` (víctima), `authority` (autoridad), `witness` (testigo) y `mentioned` (mencionado). `adverse` sólo llega con los cuatro primeros.

data.imagesarray<object>

Imágenes públicas encontradas, en básico y Premium (el básico las trae desde 1.117.0). Cada item: { url, source, title, domain }.

data.linkedin_profilesarray<object>

Perfiles de LinkedIn detectados en la busqueda. Cada item: { title, url, snippet, about_subject }.

data.linkedin_profiles[].about_subjectstring

Si el perfil es de la persona o empresa consultada: `yes`, `no` (es de otra) o `unknown` (no se pudo saber). Es texto, no booleano. Un perfil que se identifica como de alguien más no llega: sólo se cuenta en `excluded`. Reportes anteriores a 1.117.0 no lo traen.

data.social_profilesarray<object>

Perfiles en otras redes. Cada item: { title, url, snippet, platform, about_subject }.

data.social_profiles[].about_subjectstring

Si el perfil es de la persona o empresa consultada: `yes`, `no` (es de otra) o `unknown` (no se pudo saber). Es texto, no booleano. Un perfil que se identifica como de alguien más no llega: sólo se cuenta en `excluded`. Reportes anteriores a 1.117.0 no lo traen.

data.knowledge_graphobject|null

Ficha pública de referencia sobre el sujeto, si existe; si no, null.

data.excludedobject

Cuántos resultados se dejaron fuera del reporte y por qué, en números: `namesakes` (de otra persona con el mismo nombre), `brands_or_companies` (de una marca o una empresa; si el sujeto es una empresa, incluye a otra empresa con el mismo nombre) y `other` (cualquier otra cosa que se dejó fuera). Lo excluido no llega en ningún arreglo del reporte. Reportes anteriores a 1.117.0 no lo traen.

data.sentiment_summaryobject

El tono hacia la persona o empresa consultada, resumido: `{ positive, neutral, negative, overall, unconfirmed_adverse }`. Los tres primeros números cuentan sólo los resultados web y las notas con `about_subject: "yes"`, según su `sentiment`. `overall`: `none` si ninguno habla de ella; `negative` si alguno de ellos trae un hecho adverso (`adverse`); si no, el que más se repite entre `positive` y `negative`, `mixed` si empatan, y `neutral` si no hay ninguno de los dos. `unconfirmed_adverse` cuenta aparte los resultados web y las notas con un hecho adverso que traen su nombre pero de los que no se pudo confirmar que hablen de ella (`about_subject: "unknown"`): no entran en los otros números ni en `overall`, y conviene revisarlos. Reportes anteriores a 1.117.0 no lo traen.

data.summarystring|null

Sintesis narrativa de perfil. Siempre null en el tier basico (solo se llena en Premium).

data.linkedinobject|null

Perfil de LinkedIn principal. null en básico (sólo Premium).

data.work_historyarray<object>

Historial laboral inferido. [] en basico (solo Premium).

data.educationarray<object>

Educacion inferida. [] en basico (solo Premium).

data.companiesarray<object>

Empresas relacionadas. [] en basico (solo Premium).

data.digital_footprintstring|null

Nivel de huella digital (none|low|medium|high). null en basico (solo Premium).

data.sourcesarray<string>

Fuentes de la síntesis narrativa. [] en básico (sólo Premium).

statusstring

Opcional; puede venir en sandbox/sync con 'success'. La respuesta inmediata del servicio devuelve { data, request_id }.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "tier": "basic",
    "subject": "person",
    "web_results": [
      { "title": "Juan Perez - Perfil publico", "url": "https://example.com/perfil", "snippet": "Gerente de Ventas en ACME...", "domain": "example.com", "about_subject": "yes", "subject_kind": "person", "sentiment": "neutral", "adverse": null }
    ],
    "news": [
      { "position": 1, "title": "ACME anuncia nueva planta", "source": "El Universal", "date": "10 ene 2026", "date_utc": "2026-01-10T00:00:00.000Z", "url": "https://example.com/nota", "snippet": "Resumen de la nota...", "sentiment": "neutral", "relevance_score": 0.5, "matches_query": true, "about_subject": "yes", "adverse": null },
      { "position": 2, "title": "Demandan a Juan Perez por un contrato incumplido", "source": "Diario Demo", "date": "3 feb 2026", "date_utc": "2026-02-03T00:00:00.000Z", "url": "https://example.com/demanda", "snippet": "Resumen de la nota...", "sentiment": "negative", "relevance_score": 0.8, "matches_query": true, "about_subject": "yes", "adverse": { "category": "civil_or_commercial_suit", "role": "sued" } }
    ],
    "images": [
      { "url": "https://example.com/img.jpg", "source": "https://example.com", "title": "Foto", "domain": "example.com" }
    ],
    "linkedin_profiles": [
      { "title": "Juan Perez - LinkedIn", "url": "https://linkedin.com/in/juan-perez", "snippet": "Gerente de Ventas en ACME", "about_subject": "yes" }
    ],
    "social_profiles": [
      { "title": "Juan Perez", "url": "https://instagram.com/juanperez", "snippet": null, "platform": "instagram", "about_subject": "yes" }
    ],
    "knowledge_graph": null,
    "excluded": { "namesakes": 3, "brands_or_companies": 1, "other": 0 },
    "sentiment_summary": { "positive": 0, "neutral": 2, "negative": 1, "overall": "negative", "unconfirmed_adverse": 0 },
    "summary": null,
    "linkedin": null,
    "work_history": [],
    "education": [],
    "companies": [],
    "digital_footprint": null,
    "sources": []
  }
}

Huella digital Premium (sobre un cliente: dossier narrativo + investigación a fondo)

POST/app/intel-premium/customer/:id

Mismo flujo que Huella digital —un customer persona moral se investiga como empresa— pero agrega una síntesis narrativa del perfil con investigación a fondo (summary, LinkedIn principal, historial laboral, empresas, huella digital) y, si hay teléfono, phone_lookup. Útil para un dossier tipo background check.

El CreditGuard exige saldo o devuelve 402 'Insufficient credits'. Async: en producción el POST responde YA con { request_id, data: {} } y el resultado (con la síntesis y la investigación a fondo) llega por webhook; en sandbox es inmediato y completo. Mismo contrato que Huella digital: mismos path/query/body y mismo shape de respuesta —persona o empresa, `subject` incluido— y la única diferencia es que Premium llena los campos de la síntesis (summary, linkedin, work_history, education, companies, digital_footprint, sources) y agrega phone_lookup. Gotchas: (1) el customer debe existir en el mismo entorno de la key o da 409; (2) el body es opcional y se hace merge en el datameta del customer; a una empresa sólo le sirven `city`, `state`, `country`, `email`, `phone` y `address`, y un campo de más de 300 caracteres se acepta y se usan sus primeros 300 (nunca da 400); (3) phone_lookup solo aparece si el customer/body trae telefono.

Request
Authorizationstringheaderrequired

API key como Bearer token: `Authorization: Bearer <tu_api_key>`. Una key de desarrollo apunta a Sandbox; una key live apunta a Produccion.

idstringpathrequired

ID del customer que se va a investigar. Debe existir previamente y en el MISMO entorno que la API key; si no existe se devuelve 409 'Customer not found'. Los atributos de búsqueda se toman de ese customer: de una persona física, nombre, apellidos, CURP, RFC, fecha de nacimiento y género; de una persona moral, que se investiga como empresa, su nombre comercial (`name`; sin él, la razón social), su razón social (`legal_name`) y su RFC.

companystringbody

Empresa donde trabaja la persona (sólo persona). Ayuda a la síntesis de perfil y a la búsqueda.

job_titlestringbody

Puesto o cargo de la persona (sólo persona).

citystringbody

Ciudad de residencia (persona) o donde opera (empresa). Ayuda a separar a quien se consulta de otros con el mismo nombre.

statestringbody

Estado o región. Ayuda a separar a quien se consulta de otros con el mismo nombre.

countrystringbody

Codigo de pais ISO-2 (MX, AR, US...). Default semantico: MX.

emailstringbody

Correo de la persona o la empresa (contexto adicional para el enriquecimiento).

phonestringbody

Teléfono de la persona o la empresa. Si se proporciona (o ya está en el customer), Premium corre el phone_lookup y lo incluye en la respuesta.

addressstringbody

Domicilio (contexto adicional).

nationalitystringbody

Nacionalidad de la persona (ej. 'Mexicana'; sólo persona).

curl -X POST https://api.singula.mx/app/intel-premium/customer/{id} \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "company": "ACME S.A. de C.V.",
  "job_title": "Gerente de Ventas",
  "city": "Guadalajara",
  "state": "Jalisco",
  "country": "MX",
  "email": "[email protected]",
  "phone": "3312345678",
  "address": "Calle Reforma 123, Col. Centro",
  "nationality": "Mexicana"
}'
Response
request_idstring

ID de la solicitud (LogRequest). Se devuelve de inmediato y es el mismo id que llega en el webhook cuando el resultado esta listo.

dataobject

Resultado OSINT enriquecido. En produccion viene vacio ({}) en la respuesta inmediata y se llena via webhook; en sandbox ya viene completo.

data.tierstring

'premium' para este endpoint.

data.subjectstring

De quién es el reporte: `person` o `company`. En la consulta directa lo decide `subject`; sobre un cliente, una persona moral es `company`. Reportes anteriores a 1.117.0 no lo traen: son de una persona.

data.web_resultsarray<object>

Resultados web. Cada item: { title, url, snippet, domain, about_subject, subject_kind, sentiment, adverse }.

data.web_results[].about_subjectstring

Si el resultado habla de la persona o empresa consultada: `yes`, `no` (habla de otra) o `unknown` (no se pudo saber). Es texto, no booleano. Lo que se identifica como de alguien o algo más no llega en el reporte: se quita y sólo se cuenta en `excluded`. Reportes anteriores a 1.117.0 no lo traen.

data.web_results[].subject_kindstring

Opcional: de qué habla el resultado, `person`, `company`, `brand` u `other`. Puede no venir.

data.web_results[].sentimentstring

Tono del resultado hacia la persona o empresa consultada: `negative` si reporta un hecho adverso suyo (`adverse`: acusada, detenida, sentenciada o demandada); si no, `positive`, `negative` o `neutral` según cómo habla de ella, cuando ese tono es claro y el resultado no habla de alguien más; en cualquier otro caso, `neutral`. Es la misma regla que en `news`. Reportes anteriores a 1.117.0 no lo traen.

data.web_results[].adverseobject|null

Igual que en `news[].adverse`: `{ category, role }` con las mismas listas cerradas, o `null` si el resultado no reporta un hecho adverso de la persona o empresa consultada. Es adverso sólo cuando el sujeto es `accused`, `detained`, `convicted` o `sued`. Reportes anteriores a 1.117.0 no lo traen.

data.newsarray<object>

Noticias. Cada item: { position, title, source, date, date_utc, url, snippet, sentiment, relevance_score (0..1), matches_query, about_subject, adverse }. Desde 1.117.0 `sentiment`, `relevance_score` y `matches_query` conservan su nombre y su tipo pero se calculan distinto, así que sus valores pueden cambiar respecto de reportes anteriores.

data.news[].sentimentstring

Tono de la nota hacia la persona o empresa consultada: `negative` si reporta un hecho adverso suyo (`adverse`: acusada, detenida, sentenciada o demandada); si no, `positive`, `negative` o `neutral` según cómo habla de ella, cuando ese tono es claro y el resultado no habla de alguien más; en cualquier otro caso, `neutral`.

data.news[].about_subjectstring

Igual que en `web_results`: `yes`, `no` o `unknown`, como texto. Reportes anteriores a 1.117.0 no lo traen.

data.news[].adverseobject|null

El hecho adverso que la nota reporta de la persona o empresa consultada, `{ category, role }`, o `null` si no reporta ninguno. Una nota es adversa sólo cuando el sujeto es `accused`, `detained`, `convicted` o `sued`; como víctima, autoridad, testigo o sólo mencionado, `adverse` es `null`. Reportes anteriores a 1.117.0 no lo traen.

data.news[].adverse.categorystring

Tipo de hecho, lista cerrada de 11: `fraud` (fraude), `corruption` (corrupción), `money_laundering` (lavado de dinero), `drug_trafficking` (narcotráfico), `violence` (violencia), `sexual_offense` (delito sexual), `theft` (robo), `other_crime` (otro delito), `civil_or_commercial_suit` (demanda civil o mercantil), `tax` (fiscal) y `regulatory` (regulatorio).

data.news[].adverse.rolestring

Papel del sujeto en el hecho, lista cerrada de 8: `accused` (acusado), `detained` (detenido), `convicted` (sentenciado), `sued` (demandado), `victim` (víctima), `authority` (autoridad), `witness` (testigo) y `mentioned` (mencionado). `adverse` sólo llega con los cuatro primeros.

data.imagesarray<object>

Imágenes públicas encontradas, en básico y Premium (el básico las trae desde 1.117.0). Cada item: { url, source, title, domain }.

data.linkedin_profilesarray<object>

Perfiles de LinkedIn detectados en la busqueda. Cada item: { title, url, snippet, about_subject }.

data.linkedin_profiles[].about_subjectstring

Si el perfil es de la persona o empresa consultada: `yes`, `no` (es de otra) o `unknown` (no se pudo saber). Es texto, no booleano. Un perfil que se identifica como de alguien más no llega: sólo se cuenta en `excluded`. Reportes anteriores a 1.117.0 no lo traen.

data.social_profilesarray<object>

Perfiles en otras redes. Cada item: { title, url, snippet, platform, about_subject }.

data.social_profiles[].about_subjectstring

Si el perfil es de la persona o empresa consultada: `yes`, `no` (es de otra) o `unknown` (no se pudo saber). Es texto, no booleano. Un perfil que se identifica como de alguien más no llega: sólo se cuenta en `excluded`. Reportes anteriores a 1.117.0 no lo traen.

data.knowledge_graphobject|null

Ficha pública de referencia sobre el sujeto, si existe; si no, null.

data.excludedobject

Cuántos resultados se dejaron fuera del reporte y por qué, en números: `namesakes` (de otra persona con el mismo nombre), `brands_or_companies` (de una marca o una empresa; si el sujeto es una empresa, incluye a otra empresa con el mismo nombre) y `other` (cualquier otra cosa que se dejó fuera). Lo excluido no llega en ningún arreglo del reporte. Reportes anteriores a 1.117.0 no lo traen.

data.sentiment_summaryobject

El tono hacia la persona o empresa consultada, resumido: `{ positive, neutral, negative, overall, unconfirmed_adverse }`. Los tres primeros números cuentan sólo los resultados web y las notas con `about_subject: "yes"`, según su `sentiment`. `overall`: `none` si ninguno habla de ella; `negative` si alguno de ellos trae un hecho adverso (`adverse`); si no, el que más se repite entre `positive` y `negative`, `mixed` si empatan, y `neutral` si no hay ninguno de los dos. `unconfirmed_adverse` cuenta aparte los resultados web y las notas con un hecho adverso que traen su nombre pero de los que no se pudo confirmar que hablen de ella (`about_subject: "unknown"`): no entran en los otros números ni en `overall`, y conviene revisarlos. Reportes anteriores a 1.117.0 no lo traen.

data.summarystring|null

Síntesis narrativa del perfil (investigación a fondo). Poblado en Premium.

data.linkedinobject|null

Perfil de LinkedIn principal: { url, title, company, photo_url }. Puede ser null si no se encontró.

data.work_historyarray<object>

Historial laboral inferido. Cada item tipicamente: { company, title, period }.

data.educationarray<object>

Educacion inferida.

data.companiesarray<object>

Empresas relacionadas. Cada item tipicamente: { name, role, status, source_url }.

data.digital_footprintstring|null

Nivel de huella digital: none|low|medium|high.

data.sourcesarray<string>

Fuentes de la síntesis narrativa.

data.phone_lookupobject|null

Lookup de telefono. Presente SOLO en Premium: object cuando se paso un telefono, o null. Ausente en basico.

statusstring

Opcional; puede venir en sandbox/sync con 'success'. La respuesta inmediata del servicio devuelve { data, request_id }.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "tier": "premium",
    "subject": "person",
    "web_results": [
      { "title": "Juan Perez - Perfil publico", "url": "https://example.com/perfil", "snippet": "Gerente de Ventas en ACME...", "domain": "example.com", "about_subject": "yes", "subject_kind": "person", "sentiment": "neutral", "adverse": null }
    ],
    "news": [
      { "position": 1, "title": "ACME anuncia nueva planta", "source": "El Universal", "date": "10 ene 2026", "date_utc": "2026-01-10T00:00:00.000Z", "url": "https://example.com/nota", "snippet": "Resumen de la nota...", "sentiment": "neutral", "relevance_score": 0.62, "matches_query": true, "about_subject": "yes", "adverse": null }
    ],
    "images": [
      { "url": "https://example.com/img.jpg", "source": "https://example.com", "title": "Foto", "domain": "example.com" }
    ],
    "linkedin_profiles": [
      { "title": "Juan Perez - LinkedIn", "url": "https://linkedin.com/in/juan-perez", "snippet": "Gerente de Ventas en ACME", "about_subject": "yes" }
    ],
    "social_profiles": [
      { "title": "Juan Perez", "url": "https://instagram.com/juanperez", "snippet": null, "platform": "instagram", "about_subject": "yes" }
    ],
    "knowledge_graph": null,
    "excluded": { "namesakes": 2, "brands_or_companies": 0, "other": 1 },
    "sentiment_summary": { "positive": 0, "neutral": 2, "negative": 0, "overall": "neutral", "unconfirmed_adverse": 0 },
    "summary": "Persona con actividad digital moderada. Se desempena como Gerente de Ventas en ACME S.A. de C.V. desde 2022. Sin hallazgos negativos relevantes.",
    "linkedin": { "url": "https://linkedin.com/in/juan-perez", "title": "Gerente de Ventas", "company": "ACME S.A. de C.V.", "photo_url": null },
    "work_history": [ { "company": "ACME S.A. de C.V.", "title": "Gerente de Ventas", "period": "2022-presente" } ],
    "education": [],
    "companies": [ { "name": "ACME S.A. de C.V.", "role": "Gerente", "status": "active", "source_url": "" } ],
    "digital_footprint": "medium",
    "sources": [ "https://example.com/perfil", "https://linkedin.com/in/juan-perez" ],
    "phone_lookup": null
  }
}
9 endpoints

Motor de Documentos

Sube cualquier documento (PDF o imagen) directo a S3 y el motor clasifica su tipo, extrae sus datos en un modelo estructurado, evalua su autenticidad/anti-fraude y —cuando aplica— lo coteja contra el SAT o, en un pasaporte o una tarjeta de residente, verifica su zona MRZ. Ademas devuelve `requirements` (el checklist de alta del tipo, ya evaluado) y, en un acta constitutiva, la tabla de beneficiario controlador (`ubo`). Los umbrales de recencia y las reglas del checklist son configurables por organizacion (ver Politica de verificacion): cada respuesta trae `policy` con la que se aplico. El estatus trae ademas `decision` (`accept` · `review` · `reject` · `pending`, con su `reason`): la decision que el documento sostiene, leida de todo lo anterior con las reglas de decision de tu organizacion (`documents.decision_rules` de la Politica de verificacion; sin tocarlas, la misma regla para todos).

Todo es asincrono y el archivo va directo a S3 en tres pasos: reservas (/init, /csf/init o /poder/init, directa o por customer), subes el archivo con el POST prefirmado y confirmas con POST /app/document/request/:requestId/complete, que responde de inmediato con {request_id, status:"processing"}; nunca trae el dato extraido en esa misma respuesta. El resultado final llega de DOS formas, usa la que prefieras: (1) webhook a la URL configurada en tu organizacion, o (2) polling con GET /app/document/status/:requestId. El campo status recorre pending -> processing -> success/completed | error. Eventos de webhook: document.summary (el resumen ya esta listo aunque falten piezas), document.transactions (lista de movimientos lista, docs de 2 fases como estados de cuenta), document.validated (verdicto del SAT listo, para CSF/CFDI). Un documento de una sola fase cierra en un solo webhook, document.summary, igual que una falla (status: "error"). Desde 1.117.0 cada uno tiene su propio evento (antes salían como intelligence.completed), que sólo llega a un webhook suscrito a él explícitamente; uno suscrito a intelligence.completed o a todos los eventos los sigue recibiendo firmados como intelligence.completed, por una compatibilidad que se retirará con fecha anunciada: suscríbelo a estos eventos. Autenticacion en todas las rutas: header Authorization: Bearer <API_KEY> (sk_live_ = produccion, sk_test_ = sandbox). El :customerId debe existir en el mismo ambiente que tu llave. Las subidas multipart en una sola llamada (POST /app/document/extract, /app/document/csf, /app/document/poder y /app/document/customer/:customerId, /csf, /poder) estan obsoletas: contestan con los encabezados Deprecation y Sunset hasta el 30 de octubre de 2026 y despues se apagan.

How it works

Two stages: COLLECT (read any document —certificate, CFDI, 32-D opinion, payslip, bank statement, INE, proof of address, deed— and return its data linked to the source) and VERIFY (return an authenticity verdict and, where possible, cross-check against the official source: the certificate and CFDIs against the SAT). It runs in two phases: a fast summary with a document.summary webhook, then the movement list, forensics and SAT cross-check in async jobs. You get the result by webhook or by polling status/:requestId. The status also carries decision (accept, review, reject or pending, with a stable reason): the decision the document supports; your verification policy moves it through the requirements it adds and through its decision rules, which say what to do with a forged or suspicious document, a failed requirement, a PDF with no metadata, a scan or a SAT that does not confirm. Every signal and every requirement also carries params, its structured values, so you can build the sentence in your own language. On a bank statement, the CFDI that gets checked is the bank’s fee invoice: it confirms that receipt exists at the SAT, not the balances, the movements or who owns the account.

Extraer documento (consulta directa) — reserva la subida (1/3)

POST/app/document/init

Reserva la solicitud sin customer y devuelve un POST S3 prefirmado para subir el archivo DIRECTO a S3 (la API nunca toca los bytes).

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningun customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si despues quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta. La confirmacion NO es por customer: sube el archivo con el POST prefirmado y llama POST /app/document/request/{requestId}/complete, que inicia el analisis. content_type no soportado -> 400. El cargo se aplica cuando la extraccion termina —no al reservar ni al subir— y solo si el archivo se llego a leer (las reglas, en «Confirmar la subida»). Los tres pasos, con codigo, estan en «Archivos: directo a S3» (arriba, en Fundamentos). La subida multipart en una sola llamada (POST /app/document/extract) esta obsoleta: contesta con los encabezados Deprecation y Sunset hasta el 30 de octubre de 2026 y despues se apaga.

Request
filenamestringbodyrequired

Nombre original del archivo; su extension se conserva en la llave S3.

content_typestringbodyrequired

MIME del archivo: application/pdf, image/jpeg, image/png, image/webp, image/heic o image/tiff.

curl -X POST https://api.singula.mx/app/document/init \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "filename": "estado-de-cuenta.pdf",
  "content_type": "application/pdf"
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoria, para el estado de cuenta y para guardarla despues como cliente.

uploadobject

El POST S3 prefirmado: { url, fields }. Sube el archivo con multipart/form-data a upload.url incluyendo TODOS los pares de upload.fields y el campo file al FINAL.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "upload": {
    "url": "https://singula-documents.s3.amazonaws.com/",
    "fields": { "key": "documents/incoming/6622f8a1c1d2e3f4a5b6c7d8.pdf", "Content-Type": "application/pdf", "Policy": "<REDACTED>", "X-Amz-Signature": "<REDACTED>" }
  }
}

Verificar CSF (consulta directa) — reserva la subida (1/3)

POST/app/document/csf/init

La URL S3 prefirmada para subir una Constancia de Situacion Fiscal sin customer: se lee y se coteja contra el registro publico del SAT.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningun customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si despues quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta. Cierra igual que las demas cargas directas: POST /app/document/request/{requestId}/complete. El resultado agrega un objeto validation (veredicto vs SAT) y dispara el evento document.validated. Espejo exacto de /app/document/customer/{customerId}/csf/init. Los tres pasos, con codigo, estan en «Archivos: directo a S3» (arriba, en Fundamentos). La subida multipart en una sola llamada (POST /app/document/csf) esta obsoleta: contesta con los encabezados Deprecation y Sunset hasta el 30 de octubre de 2026 y despues se apaga.

Request
filenamestringbodyrequired

Nombre original del archivo.

content_typestringbodyrequired

MIME del archivo, de la misma lista soportada.

curl -X POST https://api.singula.mx/app/document/csf/init \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "filename": "constancia.pdf",
  "content_type": "application/pdf"
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoria, para el estado de cuenta y para guardarla despues como cliente.

uploadobject

El POST S3 prefirmado: { url, fields }.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "upload": { "url": "https://singula-documents.s3.amazonaws.com/", "fields": { "key": "documents/incoming/6622f8a1c1d2e3f4a5b6c7d8.pdf" } }
}

Verificar poder (consulta directa) — reserva la subida (1/3)

POST/app/document/poder/init

La URL S3 prefirmada para subir un poder notarial sin customer: apoderado, facultades y vigencia.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningun customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si despues quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta. Cierra igual: POST /app/document/request/{requestId}/complete. Se factura como verifyPoder. El resultado clasifica el documento como document_type "poder_notarial" (category "power_of_attorney") y agrega requirements con las cinco reglas del poder. Las facultades viajan en lists.facultades y llegan INLINE con el resumen: el poder es de UNA sola fase, asi que su respuesta NO trae transactions_status y no hay un segundo webhook que esperar. Los tres pasos, con codigo, estan en «Archivos: directo a S3» (arriba, en Fundamentos). La subida multipart en una sola llamada (POST /app/document/poder) esta obsoleta: contesta con los encabezados Deprecation y Sunset hasta el 30 de octubre de 2026 y despues se apaga.

Request
filenamestringbodyrequired

Nombre original del archivo.

content_typestringbodyrequired

MIME del archivo, de la misma lista soportada.

curl -X POST https://api.singula.mx/app/document/poder/init \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "filename": "poder-notarial.pdf",
  "content_type": "application/pdf"
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoria, para el estado de cuenta y para guardarla despues como cliente.

uploadobject

El POST S3 prefirmado: { url, fields }.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "upload": { "url": "https://singula-documents.s3.amazonaws.com/", "fields": { "key": "documents/incoming/6622f8a1c1d2e3f4a5b6c7d8.pdf" } }
}

Extraer documento — reserva la subida (1/3)

POST/app/document/customer/{customerId}/init

Reserva la solicitud y devuelve un POST S3 prefirmado para que subas el archivo DIRECTO a S3 (la API nunca toca los bytes).

content_type no soportado -> 400. El limite de 50 MB va horneado en la politica del POST S3. El CreditGuard valida saldo antes de aceptar. El cargo se aplica cuando la extraccion termina —no al reservar ni al subir— y solo si el archivo se llego a leer (las reglas, en «Confirmar la subida»). Luego sube el archivo con el POST prefirmado y llama POST /app/document/request/{requestId}/complete. Los tres pasos, con codigo, estan en «Archivos: directo a S3» (arriba, en Fundamentos). La subida multipart en una sola llamada (POST /app/document/customer/{customerId}) esta obsoleta: contesta con los encabezados Deprecation y Sunset hasta el 30 de octubre de 2026 y despues se apaga.

Request
customerIdstringpathrequired

Id del customer, en el mismo ambiente que tu API key.

filenamestringbodyrequired

Nombre original del archivo. Su extension se conserva en la llave S3. Ej: "acta-constitutiva.pdf".

content_typestringbodyrequired

MIME del archivo. Debe ser soportado: application/pdf, image/jpeg, image/png, image/webp, image/heic, image/tiff. El POST prefirmado fija exactamente este content-type.

curl -X POST https://api.singula.mx/app/document/customer/{customerId}/init \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "filename": "acta-constitutiva.pdf",
  "content_type": "application/pdf"
}'
Response
request_idstring

Id de la solicitud. Pasalo a /request/{requestId}/complete despues de subir.

uploadobject

El POST S3 prefirmado. Sube el archivo con multipart/form-data a upload.url incluyendo TODOS los pares de upload.fields, y el campo file al FINAL. Es de corta vida.

upload.urlstring

URL del bucket S3 a la que haces el POST multipart.

upload.fieldsobject

Campos obligatorios del formulario S3 (key, Content-Type, Policy, X-Amz-Signature, etc.). Envialos todos antes del campo file.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "upload": {
    "url": "https://singula-documents.s3.amazonaws.com/",
    "fields": {
      "key": "documents/incoming/6622f8a1c1d2e3f4a5b6c7d8.pdf",
      "Content-Type": "application/pdf",
      "Policy": "<REDACTED>",
      "X-Amz-Signature": "<REDACTED>"
    }
  }
}

Verificar CSF — reserva la subida (1/3)

POST/app/document/customer/{customerId}/csf/init

Igual que /init pero para el producto CSF: la solicitud queda marcada como verifyCsf (extraccion + validacion SAT) y se factura como tal.

Mismo flujo que /init: sube el archivo a S3 con el POST prefirmado y luego llama /app/document/request/{requestId}/complete. El resultado agrega un objeto validation (veredicto vs SAT) y dispara el evento document.validated. La subida multipart en una sola llamada (POST /app/document/customer/{customerId}/csf) esta obsoleta: contesta con los encabezados Deprecation y Sunset hasta el 30 de octubre de 2026 y despues se apaga.

Request
customerIdstringpathrequired

Id del customer, en el mismo ambiente que tu API key.

filenamestringbodyrequired

Nombre original de la Constancia. La extension se conserva.

content_typestringbodyrequired

MIME soportado (application/pdf o imagen jpeg/png/webp/heic/tiff).

curl -X POST https://api.singula.mx/app/document/customer/{customerId}/csf/init \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "filename": "constancia-situacion-fiscal.pdf",
  "content_type": "application/pdf"
}'
Response
request_idstring

Id de la solicitud. Pasalo a /request/{requestId}/complete tras subir.

uploadobject

POST S3 prefirmado (url + fields), idem /init.

Example
{
  "request_id": "6710ab22ccddeeff00112233",
  "upload": {
    "url": "https://singula-documents.s3.amazonaws.com/",
    "fields": {
      "key": "documents/incoming/6710ab22ccddeeff00112233.pdf",
      "Content-Type": "application/pdf",
      "Policy": "<REDACTED>",
      "X-Amz-Signature": "<REDACTED>"
    }
  }
}

Verificar poder notarial — reserva la subida (1/3)

POST/app/document/customer/{customerId}/poder/init

Igual que /init pero para el producto poder: la solicitud queda marcada como verifyPoder y se factura como tal.

Mismo flujo que /init: sube el archivo a S3 con el POST prefirmado y luego llama /app/document/request/{requestId}/complete. Se factura como verifyPoder. El resultado clasifica el documento como document_type "poder_notarial" (category "power_of_attorney") y agrega requirements con las cinco reglas del poder. Las facultades viajan en lists.facultades y llegan INLINE con el resumen: el poder es de UNA sola fase, asi que su respuesta NO trae transactions_status y no hay un segundo webhook que esperar. La subida multipart en una sola llamada (POST /app/document/customer/{customerId}/poder) esta obsoleta: contesta con los encabezados Deprecation y Sunset hasta el 30 de octubre de 2026 y despues se apaga.

Request
customerIdstringpathrequired

Id del customer, en el mismo ambiente que tu API key.

filenamestringbodyrequired

Nombre original del poder. La extension se conserva en la llave S3.

content_typestringbodyrequired

MIME soportado (application/pdf o imagen jpeg/png/webp/heic/tiff).

curl -X POST https://api.singula.mx/app/document/customer/{customerId}/poder/init \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "filename": "poder-notarial.pdf",
  "content_type": "application/pdf"
}'
Response
request_idstring

Id de la solicitud. Pasalo a /request/{requestId}/complete tras subir el archivo.

uploadobject

POST S3 prefirmado (url + fields), idem /init.

Example
{
  "request_id": "6710ab22ccddeeff00112244",
  "upload": {
    "url": "https://singula-documents.s3.amazonaws.com/",
    "fields": {
      "key": "documents/incoming/6710ab22ccddeeff00112244.pdf",
      "Content-Type": "application/pdf",
      "Policy": "<REDACTED>",
      "X-Amz-Signature": "<REDACTED>"
    }
  }
}

Confirmar la subida e iniciar el procesamiento (3/3)

POST/app/document/request/{requestId}/complete

Confirma que el objeto ya esta en S3 y encola la extraccion; sirve para extractDocument, verifyCsf y verifyPoder (el producto se fijo al reservar). Idempotente.

El body es OPCIONAL (puedes mandarlo vacio). Verifica con HeadObject que el archivo si aterrizo en S3 y revalida su tamano: si aun no subes -> 409; archivo vacio -> 400; supera 50 MB -> 413; request_id inexistente/ajeno -> 404. Idempotente: si ya se completo antes, regresa {status:"processing"} sin re-encolar ni re-cobrar. Aqui se cobra el documento, al terminar: extractDocument por pagina (un PDF o un TIFF de varias paginas suma sus paginas extra; `response.page_count` dice cuantas); verifyCsf y verifyPoder, a cargo fijo. Un archivo que no pudimos procesar antes de cualquier lectura con costo —no es imagen ni PDF, protegido sin su contrasena, vacio o demasiado grande— NO se cobra; uno que leimos, aunque salga ilegible, SI se cobra. Los dos terminan `completed` con `response.status: failed` y su `error_code`. Si el documento ya se leyo y despues falla el proceso (termina `error`, tambien con `response.status: failed` y su `error_code`), se cobra su cargo base; si falla antes de leerse, NO se cobra.

Request
requestIdstringpathrequired

El request_id que te devolvio la reserva (/init, /csf/init o /poder/init, directa o por customer).

passwordstringbody

Contrasena para abrir un PDF cifrado. Se reenvia al motor, nunca se guarda.

declaredobjectbody

Objeto JSON con valores conocidos a cotejar (rfc, curp, birthdate, holder, address{street,zip,city}). En un poder el mas util es holder = el nombre del apoderado que esperas: alimenta consistency.holder y el requisito poder_apoderado_matches_declared.

analyze_tamperbooleanbody

Opcional y sin efecto: se acepta por compatibilidad y no cambia nada. El analisis del archivo corre en todo documento, lo pidas o no; `tamper` viene en el resultado cuando ese analisis corrio (el sandbox no corre ninguno).

mock_kindstringbody

Solo sandbox (en produccion se ignora): el documento de prueba con el que contesta. `passport` (o `pasaporte`) = la pagina de datos de un pasaporte y `residentCard` (o `tarjeta_residente`) = una tarjeta de residente del INM, las dos `category: id_document` y con su bloque `mrz` (`consistent` y vigente); `speiReceipt` = un comprobante SPEI; tambien `bankStatement`, `opinion`, `acta`, `verifyCsf` y `verifyPoder`. Sin el, el documento de la herramienta (un recibo de luz en extractDocument). Un valor fuera de la lista -> 400.

curl -X POST https://api.singula.mx/app/document/request/{requestId}/complete \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "password": "hunter2",
  "declared": { "holder": "MARIA LOPEZ", "address": { "zip": "06600" } },
  "analyze_tamper": false
}'
Response
request_idstring

El mismo id de la solicitud.

statusstring

"processing": ya se encolo (o ya estaba en curso). El resultado llega por webhook / GET .../status.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "status": "processing"
}

Consultar estado + datos extraidos

GET/app/document/status/{requestId}

Devuelve el snapshot de la solicitud: estado del flujo; dentro de response, el tipo de documento clasificado y los datos extraidos (vista publica, sin metadatos internos); y junto a response, la `decision` que el documento sostiene.

Sin costo (consultar tu propia solicitud). Vista PUBLICA: se ocultan los metadatos de procesamiento (motores, ruta, metodo de clasificacion, senales de clasificacion, overlay de lectura, refs internas, latencia) y de `tamper` sale solo el resumen de riesgo del archivo. Mientras response = {} el trabajo sigue en curso y `decision` dice pending (`awaiting_upload` si todavia no confirmas la carga, `processing` si ya esta en proceso). Para estados de cuenta espera transactions_status:'ready'; para CSF/CFDI espera validation_status:'ready' (o el webhook document.validated). Solo devuelve solicitudes de tipo extractDocument, verifyCsf o verifyPoder de tu organizacion (404 si no). 503 = en ese momento no se pudieron leer las reglas de decision de tu organizacion: vuelve a consultar en unos segundos. Al documento no le pasa nada, y nunca se decide con los defaults en lugar de tus reglas.

Request
requestIdstringpathrequired

El request_id de la solicitud. Solo puedes consultar solicitudes de tu propia organizacion.

curl -X GET https://api.singula.mx/app/document/status/{requestId} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

Id de la solicitud.

statusstring

Estado del flujo: pending | processing | success | completed | error. `completed` con `response.status: failed` es un archivo que no se pudo procesar (`response.error_code` dice por que): uno que no pudimos procesar antes de cualquier lectura con costo NO se cobra; uno que leimos, aunque salga ilegible, SI se cobra. `error` es un proceso que fallo (tambien con `response.status: failed` y su `error_code`): si el documento ya se habia leido se cobra su cargo base; si fallo antes de leerse, NO se cobra.

responseobject

Salida del motor. Vacio ({}) hasta que el trabajo asincrono termina. Ver campos abajo.

response.document_typestring

Tipo clasificado, uno de: recibo_cfe, recibo_telmex, recibo_totalplay, recibo_izzi, recibo_megacable, recibo_agua, recibo_predial, recibo_nomina, constancia_situacion_fiscal, opinion_cumplimiento, estado_cuenta_bancario, comprobante_pago_spei_cep, cfdi_factura, ine_ife, pasaporte, tarjeta_residente, acta_constitutiva, poder_notarial, contrato u otro. Un pasaporte trae en `data` document_number, surnames, given_names, nationality, issuing_country, birthdate, sex, issue_date, expiry_date, curp y mrz_lines; una tarjeta de residente, card_type (temporal | permanente), surnames, given_names, nationality, country, birthdate, sex, curp, nue, work_permit, issue_date, expiry_date (null en una permanente), document_number y mrz_lines. Su zona MRZ se verifica sin consultar a nadie: los digitos verificadores, el codigo del documento (P< en un pasaporte; I< con MEX en una tarjeta de residente), lo impreso contra el MRZ (numero, fecha de nacimiento, nombre y nacionalidad) y la vigencia, leida del vencimiento impreso (una tarjeta permanente no vence; en la tarjeta, el campo de vencimiento del MRZ trae la fecha de expedicion).

response.categorystring

Categoria de alto nivel del tipo, una de: proof_of_address (los recibos de servicios y el predial), payroll_receipt, bank_statement, tax_id_certificate (la constancia), tax_compliance_opinion (la opinion 32-D), payment_receipt (el comprobante SPEI/CEP), invoice (el CFDI), id_document (INE, pasaporte y tarjeta de residente), incorporation_act, power_of_attorney, contract u other.

response.issuerstring

Emisor detectado (ej: banco, CFE, SAT). Puede omitirse.

response.subtypestring

Subtipo dinamico dentro del tipo. Puede omitirse.

response.document_datestring

Fecha propia del documento (cuando aplica), para juzgar recencia.

response.document_age_daysnumber

Antiguedad del documento en dias (ej: un comprobante de domicilio suele exigirse < 90 dias).

response.type_confidencenumber

Confianza (0..1) de la clasificacion del tipo.

response.person_type_hintstring

Indicio del tipo de persona del sujeto: fisica | moral (derivado de RFC/CURP/nombre).

response.extraction_availableboolean

true si hubo extraccion de campos; false si la extraccion de campos no estuvo disponible.

response.dataobject

Modelo rigido por tipo: llaves fijas -> valor (string) o null. Es la forma estable en la que integrar.

response.fieldsarray

Los mismos datos con provenance: cada item {key, value, confidence}. (El hint interno source se omite en la vista publica.)

response.listsobject

Listas por tipo, ej. transactions para un estado de cuenta, socios para un acta o facultades para un poder. {} si no aplica.

response.requirementsarray

Checklist de alta del tipo, ya evaluado. SIEMPRE presente (arreglo vacio en los tipos sin reglas). Cada item: { code, ok, detail, params? }. `ok` es TRIESTADO: true cumple, false NO cumple, null todavia no evaluable (falta la fase asincrona o no declaraste nada). codes: csf_current_year, csf_issued_within_90d, csf_activity_page_present, csf_taxpayer_active, csf_not_in_sat_lists, csf_sat_record_matches (constancia); poa_issued_within_90d, poa_legible, poa_holder_matches_declared, poa_address_matches_declared (comprobante de domicilio, y TAMBIEN un estado de cuenta bancario: se acepta como comprobante y contesta los MISMOS cuatro codigos, con la recencia leida de `period_end`); opinion_positive, opinion_issued_within_30d (opinion de cumplimiento 32-D); acta_has_notarial_cover, acta_has_ownership, acta_ubo_identified, acta_company_not_expired, acta_not_loose_clauses, acta_has_registry_evidence, acta_is_testimonio_or_certified_copy, acta_has_notary_seal_or_signature (acta); poder_has_notarial_data, poder_faculties_legible, poder_not_expired, poder_apoderado_matches_declared, poder_is_testimonio_or_certified_copy (poder); y dos que solo corren cuando tu organizacion configura lo que miden (con la politica por defecto ninguno se evalua): doc_issued_within_max_age (un recibo de nomina, una factura o un comprobante de pago mas viejo que el tope que le pusiste a su familia en `documents.max_age_days`) y doc_digital_provenance (con `documents.require_digital_provenance` encendido y SOLO en documentos que se emiten en digital —estado de cuenta, constancia, opinion 32-D, factura CFDI, recibo de nomina, comprobante SPEI—: el archivo es un escaneo o una foto, sin origen digital verificable). csf_sat_record_matches responde al cotejo contra el registro del SAT: genuine -> true, discrepancy (el detalle nombra los campos que no cuadran) y not_found -> false, inconclusive -> null. acta_has_notary_seal_or_signature sale null cuando la extraccion no puede decir si el sello esta o no: no verlo no es que falte. poder_not_expired trata un poder SIN fecha de fin como otorgado indefinidamente, o sea vigente (true), no como pendiente. opinion_positive contesta true solo si el `sentido` de la opinion es POSITIVO, y null si el documento no trae uno legible; opinion_issued_within_30d mide desde la fecha de emision contra `documents.max_age_days.opinion_cumplimiento` (30 dias por defecto, no 90: el SAT la reemite al momento y la contraparte pide la del mes). `params` son los valores ESTRUCTURADOS detras del `detail` en ingles (numeros, fechas, nombres), para armar la frase en tu idioma: sus llaves dependen del `code`, una llave falta cuando esa variante no la dice y `reason` nombra la variante solo donde hace falta. Es aditivo —una respuesta guardada antes no lo trae—: lee `params` cuando venga y nunca parsees `detail`. Ejemplos: las reglas de antiguedad (csf_issued_within_90d, opinion_issued_within_30d, poa_issued_within_90d, doc_issued_within_max_age) traen siempre `max_age_days` y, cuando el documento trae fecha, `age_days` y `date` —`{ "age_days": 120, "max_age_days": 90, "date": "2026-05-10" }`; sin `age_days` no hay fecha legible y un `age_days` negativo es una fecha futura—; csf_taxpayer_active trae `{ "situacion": "SUSPENDIDO" }`, o `{ "reason": "sat_check_failed" }` cuando la consulta al SAT termino sin respuesta.

response.requirements_okboolean|null

Rollup ESTRICTO del checklist: false si ALGUNA regla falla, true SOLO si TODAS pasaron, null mientras quede alguna sin evaluar (y para la lista vacia). null significa "sigue consultando", NO "va bien".

response.policyobject

La politica de verificacion de TU organizacion con la que se evaluo este documento: { version, max_age_days, disabled_rules, require_digital_provenance? }. SIEMPRE presente, en produccion y en sandbox. Se consulta con GET /organization/verification-policy y se cambia con PUT. Con la politica por defecto el resultado es identico al de siempre.

response.policy.max_age_daysnumber|null

El umbral de recencia (en dias) que goberno ESTE tipo de documento: la constancia de situacion fiscal, el comprobante de domicilio, la opinion de cumplimiento 32-D, un estado de cuenta bancario (el de `bank_statement` si tu organizacion le puso uno; si no, el del comprobante de domicilio) o un recibo de nomina, una factura o un comprobante de pago cuya familia topaste. null cuando el tipo no tiene regla de antiguedad (un acta, un poder, o una de esas tres familias sin tope, que es como nacen). El codigo de la regla NO cambia de nombre aunque muevas el umbral — el `detail` dice el limite real, ej. "issued 120 days ago (limit 150)".

response.policy.disabled_rulesarray<string>

Los codigos de ESTE tipo que tu organizacion apago. Una regla apagada NO se evalua: se OMITE de `requirements` y no cuenta para `requirements_ok` — nunca se contesta true en silencio.

response.policy.require_digital_provenanceboolean

Presente —y en true— SOLO cuando tu organizacion exige origen digital verificable (`documents.require_digital_provenance`): entonces se evaluo doc_digital_provenance. Ausente = apagado.

response.uboarray

Beneficiario controlador del acta constitutiva, ordenado de mayor a menor participacion. Cada item: { name, percent (0..100 o null), is_controller, person_type: fisica|moral|null, nationality }. is_controller = percent >= 25; una participacion que no se pudo calcular sale percent:null y NUNCA cuenta como controladora. AUSENTE cuando no hay accionariado que reportar (nunca un arreglo vacio); en un acta llega con la fase 2, junto con lists.socios.

response.extraction_notesstring

Notas de la extraccion (vacio normalmente; "llm_unavailable" si no corrio el modelo).

response.consistencyobject

Cotejo de lo declarado vs lo extraido (solo si mandaste declared). Sub-campos holder/zip/street {declared, extracted, match} + overall: match|partial|mismatch|unknown. Es informativo, no reprueba el trabajo.

response.authenticityobject

Verdicto anti-fraude determinista, SIEMPRE presente: {verdict: genuine|suspicious|forged, score: 0..1 (mayor = mas probable falso), signals: [{code, severity: low|medium|high, detail, params?}]}. `signals[].code` es un codigo publico y estable sobre el que puedes ramificar; entre ellos: statement_image_only (un estado de cuenta entregado solo como imagenes de pagina, sin origen digital verificable), spellcheck_on_institutional_doc (marcas del corrector ortografico de un procesador de textos en un documento que emite una institucion: lo reescribieron o lo editaron), cfdi_qr_mismatch (el QR del CFDI impreso en el documento nombra un folio o un receptor distinto al del texto impreso), statement_balance_isr_incoherent (el ISR retenido que reporta el estado de cuenta no es coherente con el saldo promedio impreso) page_count_mismatch (medium y SOLO en estados de cuenta: el estado imprime su total de paginas —«Pagina X de N»— y el archivo trae menos de N: le faltan hojas) y mrz_printed_mismatch (medium y SOLO en un pasaporte o una tarjeta de residente: su zona MRZ contradice lo impreso; `params.fields` dice que dato. Manda a revision, nunca hace forged por si sola). Cada señal puede traer `params`, igual que `requirements`: los valores estructurados detras de su `detail` (aditivo; lee `params` y nunca parsees `detail`). Ej.: page_count_mismatch trae `{ "declared_pages": 10, "file_pages": 8 }`.

response.mrzobject

SOLO en un `pasaporte` o una `tarjeta_residente` (ausente en los demas tipos): la zona MRZ cotejada sin consultar a nadie. { status, found, format: TD1 | TD3 | null, document_code, issuing_state, document_code_ok, check_digits_ok, fields: [{ field, printed, mrz, match: match | mismatch | unknown }], expiry: { date, indefinite, expired, days_remaining } }. `status`: consistent = un MRZ de ESTE documento (`P<` en un TD3 para el pasaporte; `I<` con MEX en un TD1 para la tarjeta) con todos sus digitos verificadores bien, el numero o la fecha de nacimiento impresos de acuerdo y el nombre impreso, si se leyo, el del MRZ; inconsistent = un dato impreso contradice un MRZ valido (agrega la senal `mrz_printed_mismatch`); unverifiable = no hay MRZ en el archivo (una tarjeta subida solo de frente), es de otro documento, falla un digito, no hay con que compararlo o el nombre impreso no coincide claramente con el del MRZ (sin senal: el nombre solo nunca lo hace inconsistent). `fields`: document_number, birthdate, name, nationality y la fecha del MRZ —`expiry_date` en el pasaporte; `issue_date` en la tarjeta, porque el INM escribe ahi la fecha de expedicion—; un `mismatch` exige que el lado del MRZ pase su digito y que la diferencia no sea un tropiezo de lectura (un digito, dia y mes cambiados, un caracter del numero); lo demas es `unknown`, nunca evidencia en contra. `expiry` (la vigencia): en un pasaporte, la fecha del MRZ cuando pasa su digito verificador (la impresa solo si no); en la tarjeta, el vencimiento IMPRESO (nunca la fecha del MRZ) y una permanente sale `indefinite: true`, `expired: false`; `days_remaining` cuenta dias de Mexico (0 = el ultimo dia valido, negativo = vencido). Sin checklist ni regla de antiguedad, como la INE. Una identificacion leida aqui no llena ninguna casilla de los expedientes KYC/KYB: la identidad la da la verificacion de identidad (con prueba de vida) o, de una INE, su lectura.

response.tamperobject

El resumen de riesgo del ARCHIVO y nada mas: { risk: low|medium|high, score: 0..1 (mas alto = mas probable que el archivo este manipulado), image_only: true si el PDF esta hecho solo de imagenes de pagina, sin capa de texto (null si no es un PDF o no se pudo saber), producer / created / modified: los metadatos del propio archivo (el programa que lo escribio al final y sus fechas) }. Viene cuando corrio el analisis del archivo, que corre en todo documento pidas o no `analyze_tamper`; el sandbox no corre ninguno, asi que ahi no viene. Las señales individuales del analisis no se publican: lo que concluyen llega en `authenticity`.

response.validationobject

Solo CSF (verifyCsf) / CFDI: verdicto vs SAT {kind:'csf', status: genuine|discrepancy|inconclusive|not_found, situacion: ACTIVO/SUSPENDIDO/..., fields:[{field, extracted, official, match}], official:{...}}. En un CFDI (`kind: 'cfdi'`: una factura, un recibo o el CFDI que trae embebido una nomina o un estado de cuenta) `status` es vigente | cancelado | not_found. En un estado de cuenta ese CFDI es la factura de comisiones del banco: `vigente` confirma que ESE comprobante existe ante el SAT; no valida los saldos, los movimientos ni la titularidad de la cuenta, y por si solo no prueba que el estado de cuenta sea autentico.

response.validation.sat_listsobject

Solo CSF, tras el evento document.validated: busqueda del contribuyente en los padrones que publica el SAT. { checked, art69: [], art69b: [], error? }. Cada hit: { source: sat_art69|sat_mexico, name, certainty (0-100), tier: definitive|strong|possible, topics, favorable, listed_on }. favorable:true marca lo ya resuelto a favor del contribuyente (condonado, desvirtuado, sentencia favorable). checked:false + error = el chequeo NO corrio: es fail-open, nunca un "limpio" inventado. NO altera el veredicto de authenticity — estar publicado en un padron es reputacion del contribuyente, no evidencia de que el papel sea falso.

response.transactions_statusstring

Docs de 2 fases (estados de cuenta): pending = resumen listo, faltan movimientos (vuelve a consultar); ready = movimientos incluidos; failed = fallo la fase 2. Ausente en docs de una fase.

response.validation_statusstring

Docs con validacion SAT (CSF/CFDI): pending = extraccion lista, validacion en curso; ready = validation incluido; failed = fallo el cotejo. Ausente si no aplica.

response.page_countnumber

Paginas del archivo cuando es un PDF o un TIFF de varias paginas: la cuenta con la que extractDocument cobra sus paginas extra. No viene cuando el archivo no se pudo procesar (`response.status: failed`).

filesobject

files.document = URL S3 firmada (10 min) del documento original, para renderizarlo en tu UI.

decisionobject

La decision que este documento sostiene: { verdict, reason }. SIEMPRE presente y va JUNTO a `response`, nunca dentro. Se lee de `response` con las reglas de decision de tu organizacion (`documents.decision_rules` de /organization/verification-policy) en el momento en que consultas: si cambias las reglas, un documento ya procesado se vuelve a decidir con las nuevas y su `response` no cambia. Sin configurarlas, los defaults deciden lo mismo que siempre, igual para todos. Tu politica tambien llega a ella por los requisitos que agrega (un tope de antiguedad, un origen digital exigido) y por los que apaga. Las reglas solo mueven la decision del documento suelto: el paso de documento de un flujo y los expedientes KYB y KYC leen como siempre.

decision.verdictstring

accept | review | reject | pending. reject = el SAT dice que el documento no es lo que dice ser (`validation` discrepancy, forged o cancelado, o un not_found de una constancia) —SIEMPRE: ninguna regla lo mueve, salvo que una discrepancy de una constancia con csf_sat_record_matches apagado no rechaza por si sola— o una condicion que tu organizacion puso en reject (por defecto: `authenticity` forged o un requisito que fallo). Un reject se reporta en cuanto se sabe, aunque un `*_status` siga en pending; el de sat_unconfirmed espera mientras validation_status siga en pending, porque el SAT se esta consultando de nuevo. pending = nada decisivo todavia y la carga, el proceso o una fase asincrona siguen corriendo: vuelve a consultar. review = una condicion que tu organizacion puso en review (por defecto: `authenticity` suspicious, o el SAT no confirmo: un not_found blando de una opinion 32-D o de un CFDI, o una consulta al SAT que termino sin respuesta y dejo el checklist sin roll-up), `requirements_ok` null por otra causa (el tipo no tiene checklist o aun puede llegar algo), el archivo no se pudo procesar o tu retencion anonimizo esta consulta directa. accept = todo lo demas. Si se cumplen varias condiciones, gana la accion mas severa (reject > review > accept).

decision.reasonstring

Codigo estable sobre el que puedes ramificar; nombra en que se decidio, nunca como se midio. Son 20: ok · validation_discrepancy · validation_forged · validation_cancelado · validation_not_found · authenticity_forged · requirement_failed / requirement_failed:<code> (el primer requisito que fallo, ej. requirement_failed:doc_issued_within_max_age) · awaiting_upload · processing · validation_pending · transactions_pending · authenticity_suspicious · no_requirements (el tipo no tiene checklist) · validation_failed (la consulta al SAT termino sin respuesta) · requirements_pending (aun puede llegar algo) · validation_not_found_soft · purged (consulta directa anonimizada por retencion) · processing_failed · missing_metadata · scanned_document. Una condicion conserva su codigo sea cual sea la accion que le diste: authenticity_suspicious puede venir con reject, y authenticity_forged o requirement_failed:<code> con review. missing_metadata y scanned_document solo salen cuando tu organizacion pone esas condiciones en review o reject. Un reject se reporta en cuanto se sabe; mientras algun *_status siga en pending, vuelve a consultar.

created_atstring

Fecha de creacion de la solicitud (ISO), o null.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "status": "success",
  "response": {
    "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
    "status": "completed",
    "document_type": "recibo_cfe",
    "category": "proof_of_address",
    "issuer": "CFE",
    "document_date": "2026-07-15",
    "document_age_days": 50,
    "type_confidence": 0.98,
    "person_type_hint": "fisica",
    "extraction_available": true,
    "data": {
      "holder": "MARIA LOPEZ GARCIA",
      "address_street": "AV REFORMA",
      "address_ext_number": "123",
      "address_zip": "06600",
      "address_state": "CIUDAD DE MEXICO"
    },
    "fields": [
      { "key": "holder", "value": "MARIA LOPEZ GARCIA", "confidence": 0.97 },
      { "key": "address_zip", "value": "06600", "confidence": 0.99 }
    ],
    "lists": {},
    "extraction_notes": "",
    "requirements": [
      { "code": "poa_issued_within_90d", "ok": true, "detail": "issued 50 days ago (limit 90)",
        "params": { "age_days": 50, "max_age_days": 90, "date": "2026-07-15" } },
      { "code": "poa_legible", "ok": true, "detail": "18 field(s) extracted (minimum 3)",
        "params": { "fields": 18, "min_fields": 3 } },
      { "code": "poa_holder_matches_declared", "ok": true,
        "detail": "declared \"MARIA LOPEZ\" vs extracted \"MARIA LOPEZ GARCIA\"",
        "params": { "declared": "MARIA LOPEZ", "extracted": "MARIA LOPEZ GARCIA" } },
      { "code": "poa_address_matches_declared", "ok": true, "detail": "1/1 declared address field(s) agree",
        "params": { "agreeing": 1, "declared_fields": 1 } }
    ],
    "requirements_ok": true,
    "policy": {
      "version": 1,
      "max_age_days": 90,
      "disabled_rules": []
    },
    "consistency": {
      "holder": { "declared": "MARIA LOPEZ", "extracted": "MARIA LOPEZ GARCIA", "match": true },
      "zip": { "declared": "06600", "extracted": "06600", "match": true },
      "overall": "match"
    },
    "authenticity": { "verdict": "genuine", "score": 0.0, "signals": [] },
    "tamper": { "risk": "low", "score": 0.15, "image_only": false, "producer": null, "created": "2026-07-15T06:12:00-06:00", "modified": "2026-07-15T06:12:00-06:00" },
    "page_count": 1
  },
  "files": {
    "document": "https://singula-documents.s3.amazonaws.com/documents/incoming/6622f8a1c1d2e3f4a5b6c7d8.pdf?X-Amz-Signature=<REDACTED>"
  },
  "decision": { "verdict": "accept", "reason": "ok" },
  "created_at": "2026-09-03T18:22:10.000Z"
}

Descargar el documento original (URL S3 firmada)

GET/app/document/request/{requestId}/file

Devuelve una URL S3 prefirmada de corta vida (15 min) para bajar los bytes del documento que subiste, directo de S3 (la API no proxea el archivo).

Sin costo (recuperar tu propio documento). Llamalo despues de que el procesamiento termine (cuando recibes el webhook document.*). Documento inexistente/ajeno -> 404; archivo aun no disponible (subiendo o carga fallida) -> 409. Alcance por organizacion.

Request
requestIdstringpathrequired

El request_id de la carga. Solo puedes descargar documentos de tu propia organizacion.

curl -X GET https://api.singula.mx/app/document/request/{requestId}/file \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

El id de la solicitud.

urlstring

URL S3 GET prefirmada; baja los bytes directo de S3.

expires_innumber

Vigencia de la URL en segundos (900 = 15 min).

filenamestring

Nombre del objeto almacenado (request_id + extension).

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "url": "https://singula-documents.s3.amazonaws.com/documents/incoming/6622f8a1c1d2e3f4a5b6c7d8.pdf?X-Amz-Signature=<REDACTED>",
  "expires_in": 900,
  "filename": "6622f8a1c1d2e3f4a5b6c7d8.pdf"
}
3 endpoints

e.firma (FIEL) y sellos digitales

Consulta los certificados que el SAT tiene registrados para un RFC —e.firma (FIEL) y sellos digitales (CSD)— con su vigencia, los dias que les quedan y un veredicto de si el contribuyente puede firmar hoy.

Sincrono con red de seguridad. La llamada encola el trabajo y espera hasta 60 s; en la gran mayoria de los casos el veredicto viene en esa misma respuesta. Si el SAT tarda mas, la respuesta llega con status "pending" + timed_out:true y el resultado se recupera con GET /app/fiel/status/{requestId} (o por el webhook fiel.checked, con tool_type checkFiel: evento nuevo en 1.117.0 que sólo llega a un webhook suscrito a él explícitamente; uno suscrito a intelligence.completed o a todos los eventos lo sigue recibiendo firmado como intelligence.completed, como antes, hasta que esa compatibilidad se retire con fecha anunciada: suscríbelo a fiel.checked). Autenticacion: header Authorization: Bearer <API_KEY>.

Consultar la e.firma de un RFC (consulta directa)

POST/app/fiel

Manda el RFC en el cuerpo y recibe sus certificados, el activo que vence al final y el veredicto. Sin customer.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningun customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si despues quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta. El seguimiento no cambia: GET /app/fiel/status/{requestId} (nunca fue por customer). El umbral de `expiring` sigue saliendo de la politica de verificacion de tu organizacion. Si la consulta falla antes de cualquier llamada con costo, no se cobra (en una cuenta en prueba, lo apartado regresa); si falla después de una llamada con costo (termina `error`), se cobra su cargo base, aunque la falla sea nuestra (desde 1.113.0).

Request
rfcstringbodyrequired

RFC a consultar: 12 caracteres en persona moral, 13 en fisica. Se manda en mayusculas.

curl -X POST https://api.singula.mx/app/fiel \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "rfc": "AAA010101AAA"
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoria, para el estado de cuenta y para guardarla despues como cliente.

dataobject

Identico a la variante por customer: misma forma, mismos campos, mismo detalle. Vacio ({}) mientras la consulta sigue en curso; se completa por GET /app/fiel/status/{requestId} o por webhook.

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "rfc": "AAA010101AAA",
    "verdict": "ok",
    "latest_active": "(mismo objeto que la variante por customer)",
    "certificates": "(la lista cruda completa)",
    "checked_at": "2026-09-14T18:30:00.000Z"
  }
}

Consultar la e.firma de un customer

POST/app/fiel/customer/{customerId}

Devuelve todos los certificados del RFC, el activo que vence al final y el veredicto. Responde en la misma llamada cuando el Worker aterriza dentro de la ventana de espera.

El cuerpo es OPCIONAL (puedes mandarlo vacio). La llamada espera hasta 60 s a que el Worker resuelva; si no aterriza en esa ventana responde { data: {}, status: "pending", timed_out: true } y el MISMO request_id se lee despues en GET /app/fiel/status/{requestId}. El veredicto es fail-closed y solo mira la e.firma: un activo con fecha ilegible cuenta como expired, nunca como ok, y un CSD vivo no vuelve ok a un RFC sin e.firma vigente. Errores: 400 si el RFC no tiene formato valido, 402 sin saldo, 404 si el customer no existe en ese ambiente. Si la consulta falla antes de cualquier llamada con costo, no se cobra (en una cuenta en prueba, lo apartado regresa); si falla después de una llamada con costo (termina `error`), se cobra su cargo base, aunque la falla sea nuestra (desde 1.113.0).

Request
customerIdstringpathrequired

Id del customer, en el mismo ambiente que tu API key.

rfcstringbody

RFC a consultar. OPCIONAL: por defecto se usa el del customer. Mandalo para revisar OTRO (p. ej. el de la empresa cuando el customer guarda el del representante legal). 12 caracteres en persona moral, 13 en fisica; se manda en mayusculas.

curl -X POST https://api.singula.mx/app/fiel/customer/{customerId} \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "rfc": "SIN230101AB1"
}'
Response
dataobject

El veredicto. Vacio ({}) mientras la consulta sigue en curso.

data.rfcstring

El RFC consultado.

data.certificatesarray

TODOS los certificados emitidos al RFC, e.firma y sellos: { serial, type: FIEL|CSD|unknown, status: active|revoked|expired|unknown, valid_from, valid_to, days_remaining }. La lista es cruda a proposito (es la evidencia para un revisor); el veredicto de arriba solo mira los type:FIEL. days_remaining son dias calendario hasta valid_to en hora de Mexico (0 = vence hoy y hoy todavia vale; negativo = ya vencio). Un renglon que no se pudo leer llega con su celda en null en vez de un valor inventado.

data.latest_activeobject|null

La e.firma activa con el valid_to mas lejano QUE SE PUDO FECHAR, o null cuando no hay ninguna demostrablemente vigente. Solo se consideran los renglones type:FIEL — un CSD activo JAMAS sale aqui — y un activo sin fecha legible no califica.

data.verdictstring

SOLO sobre los certificados type:FIEL (los CSD son sellos de facturacion: se listan, pero no son e.firma ni la sustituyen, y nunca cuentan para el veredicto). ok = e.firma vigente con holgura; expiring = vigente pero a menos de 15 dias (el umbral es `policy.expiring_days`, configurable por organizacion); expired = ninguna e.firma demostrablemente vigente (incluye "activa con fecha ilegible", y una FIEL vencida NO la rescata un CSD activo); not_found = el SAT no lista NINGUNA e.firma para ese RFC, aunque liste CSD.

data.checked_atstring

Fecha ISO de la consulta al SAT.

data.policyobject

La politica de verificacion de TU organizacion bajo la que se emitio este veredicto: { version, expiring_days }. `expiring_days` es la holgura (en dias) por debajo de la cual una e.firma vigente se reporta como `expiring`; 15 por defecto. SIEMPRE presente, en produccion y en sandbox. Se cambia con PUT /organization/verification-policy.

request_idstring

Id de la solicitud (LogRequest). Sirve para GET /app/fiel/status/{requestId} y para casar el webhook.

statusstring

pending -> success | completed | error.

timed_outboolean

Solo presente cuando expiro la espera sincrona; el trabajo sigue corriendo y el resultado se recupera en /status.

Example
{
  "data": {
    "rfc": "SIN230101AB1",
    "certificates": [
      {
        "serial": "00001000000512345678",
        "type": "FIEL",
        "status": "active",
        "valid_from": "2024-05-02T00:00:00.000Z",
        "valid_to": "2028-05-01T00:00:00.000Z",
        "days_remaining": 604
      },
      {
        "serial": "00001000000498765432",
        "type": "CSD",
        "status": "expired",
        "valid_from": "2020-04-30T00:00:00.000Z",
        "valid_to": "2024-04-29T00:00:00.000Z",
        "days_remaining": -858
      }
    ],
    "latest_active": {
      "serial": "00001000000512345678",
      "type": "FIEL",
      "status": "active",
      "valid_from": "2024-05-02T00:00:00.000Z",
      "valid_to": "2028-05-01T00:00:00.000Z",
      "days_remaining": 604
    },
    "verdict": "ok",
    "checked_at": "2026-09-04T18:20:00.000Z",
    "policy": { "version": 1, "expiring_days": 15 }
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "status": "success"
}

Leer una consulta de e.firma por request_id

GET/app/fiel/status/{requestId}

Recupera el veredicto cuando expiro la espera sincrona de la llamada original.

Sin cargo propio: liquida el de la llamada original. 400 si el id no mide 24 caracteres; 404 si la solicitud es de otra organizacion o no es una consulta de e.firma.

Request
requestIdstringpathrequired

El request_id que devolvio POST /app/fiel/customer/{customerId}. Solo puedes leer solicitudes de tu propia organizacion.

curl -X GET https://api.singula.mx/app/fiel/status/{requestId} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
dataobject

El mismo veredicto de la llamada original (rfc, certificates, latest_active, verdict, checked_at, policy). Vacio ({}) mientras sigue en curso.

request_idstring

Eco del id consultado.

statusstring

pending | success | completed | error.

Example
{
  "data": {
    "rfc": "SIN230101AB1",
    "certificates": [],
    "latest_active": null,
    "verdict": "not_found",
    "checked_at": "2026-09-04T18:20:00.000Z",
    "policy": { "version": 1, "expiring_days": 15 }
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "status": "completed"
}
6 endpoints

Rastreo SPEI y Validador CLABE

Rastreo SPEI confirma con Banxico si una transferencia SPEI existe y en qué estado está —liquidada, en proceso, devuelta, rechazada, cancelada o no encontrada— y, con el pago liquidado, quién la envió y quién la recibió (nombre, RFC o CURP, cuenta y banco), con el Comprobante Electrónico de Pago (CEP) en XML y PDF. Mandas los datos del comprobante y, si quieres, a quién esperas; la respuesta compara el monto, la cuenta y esos nombres. El Validador CLABE revisa sin cargo el dígito verificador y el banco de una CLABE. Los bancos son los participantes de SPEI del catálogo de Banxico (GET /catalogs/spei-banks).

Síncrono con red de seguridad, como la e.firma: la llamada espera hasta 60 s y casi siempre la respuesta llega en esa misma llamada. Si Banxico tarda más, llega con status "pending" + timed_out: true y el resultado se recupera con GET /app/spei/status/{requestId} o por el webhook spei.checked ({ request_id, tool_type: "checkSpei", status }; nuevo en 1.123.0). Nació con su propio evento: llega a un webhook suscrito a spei.checked o a todos los eventos, nunca firmado como intelligence.completed, y sandbox y producción lo disparan igual. El Validador CLABE y el catálogo de bancos contestan en la misma llamada y no tienen webhook. Autenticación: header Authorization: Bearer <API_KEY> (sk_live_ = producción, sk_test_ = sandbox); el catálogo de bancos no pide llave.

Rastrear una transferencia SPEI (consulta directa)

POST/app/spei

Manda los datos del comprobante en el cuerpo y recibe el estado del pago en Banxico, el ordenante, el beneficiario, las coincidencias y si existe el comprobante (CEP). Sin customer. Responde en la misma llamada cuando el rastreo llega dentro de la ventana de espera.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningún customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook (spei.checked). En sandbox (sk_test_) devuelve un mock con la forma exacta de producción —el pago que mandaste, liquidado, con lo que esperabas en match— y no consume saldo, pero no guarda archivos: files viene en false y la descarga contesta 404. En el registro queda `source: "direct"` con `customer_id: null`. Si después quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId: el cliente se crea con el BENEFICIARIO del comprobante (nombre, y CURP o RFC; un RFC de 12 crea una persona moral; nunca un RFC genérico). Cargo: cada consulta que Banxico contesta (encontrado, no encontrado, en proceso o devuelto son respuestas). Un dato que no pasa la validación —400: fecha futura o anterior a 2012-02-24, clave o referencia con otro formato, el mismo banco de los dos lados, un código fuera del catálogo, una CLABE con el dígito verificador mal o de un banco que no existe, un monto con más de dos decimales— no llega a Banxico y no se cobra. Si la consulta falla antes de cualquier llamada con costo, no se cobra; si falla después de una (termina `error`), se cobra su cargo base, aunque la falla sea nuestra. Los códigos de banco se validan contra el catálogo vigente: un pago desde o hacia un banco que ya salió de SPEI responde 400. Errores: 400 dato inválido, 402 sin saldo.

Request
fechastringbodyrequired

Fecha del pago (día calendario en la Ciudad de México), YYYY-MM-DD. No puede ser futura ni anterior a 2012-02-24, la primera fecha que Banxico rastrea.

criterio_tipostringbodyrequired

Cómo buscas el pago: clave_rastreo (la clave de rastreo del comprobante) | referencia (la referencia numérica).

criteriostringbodyrequired

La clave de rastreo (1 a 30 letras y números, tal como la dio el banco) o la referencia (1 a 7 dígitos), según criterio_tipo.

emisorstringbodyrequired

Código del banco EMISOR (de donde salió el pago), del catálogo GET /catalogs/spei-banks (p. ej. 40012 = BBVA MEXICO). Distinto del receptor: SPEI mueve dinero entre dos instituciones. Manda el código vigente; para un pago anterior a un cambio de código, Singula resuelve el que aplicaba ese día.

receptorstringbody

Código del banco RECEPTOR, del mismo catálogo. Opcional cuando cuenta es una CLABE: sus tres primeros dígitos dicen el banco. Si lo mandas con una CLABE, debe ser el banco de esa CLABE.

cuentastringbodyrequired

Cuenta del BENEFICIARIO: CLABE de 18 dígitos (se revisan su dígito verificador y su banco), tarjeta de débito de 16 (dígito verificador) o celular de 10. Los espacios y guiones se ignoran.

montostring | numberbodyrequired

Monto del pago en pesos: mayor a 0 y con máximo dos decimales ("1500.00" o 1500.5). Las comas de miles se ignoran.

pago_a_bancobooleanbody

true cuando el pago fue AL BANCO receptor mismo (el pago de una tarjeta de crédito o de un crédito en ese banco), no a la cuenta de un tercero. Default false.

expectedobjectbody

A quién esperas en la transferencia: { beneficiario_nombre (hasta 200 caracteres), beneficiario_rfc (RFC de 12 o 13, o CURP de 18), ordenante_nombre (hasta 200) }, todos opcionales. Sólo alimenta matches: no cambia la respuesta de Banxico ni el cargo.

curl -X POST https://api.singula.mx/app/spei \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "fecha": "2026-10-15",
  "criterio_tipo": "clave_rastreo",
  "criterio": "SINGULA202610150001",
  "emisor": "40012",
  "cuenta": "014180000123456780",
  "monto": "1500.00",
  "expected": {
    "beneficiario_nombre": "GRUPO SINGULA, S.A. DE C.V.",
    "beneficiario_rfc": "GSI230101AB0"
  }
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para leerla en /status, para descargar el comprobante, para casar el webhook y para guardarla después como cliente.

statusstring

pending -> success (Banxico identificó el pago, en el estado que sea) | completed (no lo encontró) | error. success y completed son respuestas y se cobran.

timed_outboolean

Sólo presente cuando venció la espera síncrona (60 s); el rastreo sigue y el resultado se recupera en GET /app/spei/status/{requestId} o por el webhook spei.checked.

dataobject

La respuesta del rastreo. Vacío ({}) mientras sigue en curso; se completa en GET /app/spei/status/{requestId} o por webhook.

data.foundboolean

Banxico identificó el pago.

data.statusstring

Estado del pago en Banxico: liquidado | en_proceso | en_proceso_devolucion | devuelto | retornado | cancelado | no_liquidado | rechazado | no_encontrado | unknown.

data.status_labelstring|null

El estado tal como lo escribe Banxico ("Liquidado"); null cuando no se encontró.

data.verdictstring

El estado agrupado: confirmed (liquidado: el dinero llegó) | in_process | returned (devuelto, retornado o en proceso de devolución) | cancelled (cancelado o no liquidado) | rejected | not_found | unknown.

data.cep_availableboolean

Banxico ya emitió el Comprobante Electrónico de Pago (CEP). Sólo existe para un pago liquidado, y el banco receptor tiene unos minutos para confirmarlo: un pago recién liquidado puede llegar con false, sin nombres ni RFC; vuelve a consultarlo más tarde.

data.paymentobject|null

{ operation_date (YYYY-MM-DD), credited_at (hora del abono, ISO con el huso de la Ciudad de México), tracking_key, reference, payment_type (el tipo de pago SPEI que reporta Banxico; 1 = de tercero a tercero), amount y vat (pesos, como texto decimal), concept }. Sin comprobante sólo trae tracking_key, reference y amount; null cuando no se encontró.

data.senderobject|null

Ordenante (quien envió el pago): { name, rfc_curp, account, account_type (clabe | card | phone | other), bank, bank_code }. name y rfc_curp salen del comprobante y son null mientras no exista; rfc_curp puede traer una CURP, o null cuando Banxico no tiene el dato. null cuando no se encontró.

data.beneficiaryobject|null

Beneficiario (quien recibió el pago), con la misma forma que sender.

data.matchesobject|null

El pago contra lo que mandaste: amount y account (match | mismatch), y contra expected: beneficiary_name, beneficiary_rfc y sender_name (match | partial | mismatch | not_checked; not_checked cuando no lo mandaste o el comprobante no lo trae). null cuando no se encontró.

data.account_checkobject

La cuenta que mandaste: { kind (clabe | card | phone), valid, bank_code, bank_name }.

data.filesobject

{ xml, pdf }: true cuando ese archivo del comprobante quedó guardado; se descarga con GET /app/spei/{requestId}/file?format=pdf|xml. En sandbox, siempre false.

data.checked_atstring

Fecha ISO de la consulta.

data.errorstring

Sólo con status error: un texto genérico (falla temporal o timeout). Vuelve a intentarlo.

Example
{
  "data": {
    "found": true,
    "status": "liquidado",
    "status_label": "Liquidado",
    "verdict": "confirmed",
    "cep_available": true,
    "payment": {
      "operation_date": "2026-10-15",
      "credited_at": "2026-10-15T12:30:05-06:00",
      "tracking_key": "SINGULA202610150001",
      "reference": "1234567",
      "payment_type": 1,
      "amount": "1500.00",
      "vat": "0.00",
      "concept": "PAGO DE PRUEBA"
    },
    "sender": {
      "name": "MARIA LOPEZ GARCIA",
      "rfc_curp": "LOGM900101ABA",
      "account": "012180001234567899",
      "account_type": "clabe",
      "bank": "BBVA MEXICO",
      "bank_code": "40012"
    },
    "beneficiary": {
      "name": "GRUPO SINGULA, S.A. DE C.V.",
      "rfc_curp": "GSI230101AB0",
      "account": "014180000123456780",
      "account_type": "clabe",
      "bank": "SANTANDER",
      "bank_code": "40014"
    },
    "matches": {
      "beneficiary_name": "match",
      "beneficiary_rfc": "match",
      "sender_name": "not_checked",
      "amount": "match",
      "account": "match"
    },
    "account_check": {
      "kind": "clabe",
      "valid": true,
      "bank_code": "40014",
      "bank_name": "SANTANDER"
    },
    "files": {
      "xml": true,
      "pdf": true
    },
    "checked_at": "2026-10-16T18:20:00.000Z"
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "status": "success"
}

Rastrear una transferencia SPEI y archivarla en un customer

POST/app/spei/customer/{customerId}

El mismo rastreo que la consulta directa: los datos de la transferencia van en el cuerpo y la consulta queda en el expediente del customer. Misma respuesta, mismo cargo, mismo webhook.

La llamada espera hasta 60 s a que el rastreo termine; si no llega en esa ventana responde { data: {}, status: "pending", timed_out: true } y el MISMO request_id se lee después en GET /app/spei/status/{requestId}. Cargo: cada consulta que Banxico contesta (encontrado, no encontrado, en proceso o devuelto son respuestas). Un dato que no pasa la validación —400: fecha futura o anterior a 2012-02-24, clave o referencia con otro formato, el mismo banco de los dos lados, un código fuera del catálogo, una CLABE con el dígito verificador mal o de un banco que no existe, un monto con más de dos decimales— no llega a Banxico y no se cobra. Si la consulta falla antes de cualquier llamada con costo, no se cobra; si falla después de una (termina `error`), se cobra su cargo base, aunque la falla sea nuestra. Los códigos de banco se validan contra el catálogo vigente: un pago desde o hacia un banco que ya salió de SPEI responde 400. Errores: 400 dato inválido, 402 sin saldo, 404 si el customer no existe en ese ambiente (uno de otra organización contesta igual que uno que no existe).

Request
customerIdstringpathrequired

Id del customer, en el mismo ambiente que tu API key. Sólo dice en qué expediente queda la consulta: los datos del pago van siempre en el cuerpo.

fechastringbodyrequired

Fecha del pago (día calendario en la Ciudad de México), YYYY-MM-DD. No puede ser futura ni anterior a 2012-02-24, la primera fecha que Banxico rastrea.

criterio_tipostringbodyrequired

Cómo buscas el pago: clave_rastreo (la clave de rastreo del comprobante) | referencia (la referencia numérica).

criteriostringbodyrequired

La clave de rastreo (1 a 30 letras y números, tal como la dio el banco) o la referencia (1 a 7 dígitos), según criterio_tipo.

emisorstringbodyrequired

Código del banco EMISOR (de donde salió el pago), del catálogo GET /catalogs/spei-banks (p. ej. 40012 = BBVA MEXICO). Distinto del receptor: SPEI mueve dinero entre dos instituciones. Manda el código vigente; para un pago anterior a un cambio de código, Singula resuelve el que aplicaba ese día.

receptorstringbody

Código del banco RECEPTOR, del mismo catálogo. Opcional cuando cuenta es una CLABE: sus tres primeros dígitos dicen el banco. Si lo mandas con una CLABE, debe ser el banco de esa CLABE.

cuentastringbodyrequired

Cuenta del BENEFICIARIO: CLABE de 18 dígitos (se revisan su dígito verificador y su banco), tarjeta de débito de 16 (dígito verificador) o celular de 10. Los espacios y guiones se ignoran.

montostring | numberbodyrequired

Monto del pago en pesos: mayor a 0 y con máximo dos decimales ("1500.00" o 1500.5). Las comas de miles se ignoran.

pago_a_bancobooleanbody

true cuando el pago fue AL BANCO receptor mismo (el pago de una tarjeta de crédito o de un crédito en ese banco), no a la cuenta de un tercero. Default false.

expectedobjectbody

A quién esperas en la transferencia: { beneficiario_nombre (hasta 200 caracteres), beneficiario_rfc (RFC de 12 o 13, o CURP de 18), ordenante_nombre (hasta 200) }, todos opcionales. Sólo alimenta matches: no cambia la respuesta de Banxico ni el cargo.

curl -X POST https://api.singula.mx/app/spei/customer/{customerId} \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "fecha": "2026-10-15",
  "criterio_tipo": "clave_rastreo",
  "criterio": "SINGULA202610150001",
  "emisor": "40012",
  "cuenta": "014180000123456780",
  "monto": "1500.00",
  "expected": {
    "beneficiario_nombre": "GRUPO SINGULA, S.A. DE C.V.",
    "beneficiario_rfc": "GSI230101AB0"
  }
}'
Response
dataobject

Idéntico a la consulta directa: found, status, status_label, verdict, cep_available, payment, sender, beneficiary, matches, account_check, files y checked_at. Vacío ({}) mientras sigue en curso.

request_idstring

Id de la solicitud (LogRequest). Sirve para GET /app/spei/status/{requestId}, para descargar el comprobante y para casar el webhook.

statusstring

pending -> success (Banxico identificó el pago, en el estado que sea) | completed (no lo encontró) | error. success y completed son respuestas y se cobran.

timed_outboolean

Sólo presente cuando venció la espera síncrona (60 s); el rastreo sigue y el resultado se recupera en GET /app/spei/status/{requestId} o por el webhook spei.checked.

Example
{
  "data": {
    "found": true,
    "status": "liquidado",
    "status_label": "Liquidado",
    "verdict": "confirmed",
    "cep_available": true,
    "payment": {
      "operation_date": "2026-10-15",
      "credited_at": "2026-10-15T12:30:05-06:00",
      "tracking_key": "SINGULA202610150001",
      "reference": "1234567",
      "payment_type": 1,
      "amount": "1500.00",
      "vat": "0.00",
      "concept": "PAGO DE PRUEBA"
    },
    "sender": {
      "name": "MARIA LOPEZ GARCIA",
      "rfc_curp": "LOGM900101ABA",
      "account": "012180001234567899",
      "account_type": "clabe",
      "bank": "BBVA MEXICO",
      "bank_code": "40012"
    },
    "beneficiary": {
      "name": "GRUPO SINGULA, S.A. DE C.V.",
      "rfc_curp": "GSI230101AB0",
      "account": "014180000123456780",
      "account_type": "clabe",
      "bank": "SANTANDER",
      "bank_code": "40014"
    },
    "matches": {
      "beneficiary_name": "match",
      "beneficiary_rfc": "match",
      "sender_name": "not_checked",
      "amount": "match",
      "account": "match"
    },
    "account_check": {
      "kind": "clabe",
      "valid": true,
      "bank_code": "40014",
      "bank_name": "SANTANDER"
    },
    "files": {
      "xml": true,
      "pdf": true
    },
    "checked_at": "2026-10-16T18:20:00.000Z"
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "status": "success"
}

Leer un rastreo SPEI por request_id

GET/app/spei/status/{requestId}

Recupera la respuesta cuando venció la espera síncrona de la llamada original.

Sin cargo propio: liquida el de la llamada original. 400 si el id no mide 24 caracteres; 404 si la consulta es de otra organización o no es un rastreo SPEI.

Request
requestIdstringpathrequired

El request_id que devolvió POST /app/spei o /app/spei/customer/{customerId}. Sólo puedes leer consultas de tu propia organización.

curl -X GET https://api.singula.mx/app/spei/status/{requestId} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
dataobject

La misma respuesta de la llamada original. Vacío ({}) mientras sigue en curso.

request_idstring

Eco del id consultado.

statusstring

pending | success | completed | error.

Example
{
  "data": {
    "found": false,
    "status": "no_encontrado",
    "status_label": null,
    "verdict": "not_found",
    "cep_available": false,
    "payment": null,
    "sender": null,
    "beneficiary": null,
    "matches": null,
    "account_check": {
      "kind": "clabe",
      "valid": true,
      "bank_code": "40014",
      "bank_name": "SANTANDER"
    },
    "files": {
      "xml": false,
      "pdf": false
    },
    "checked_at": "2026-10-16T18:20:00.000Z"
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "status": "completed"
}

Descargar el comprobante (CEP) de un rastreo

GET/app/spei/{requestId}/file?format=pdf

Un enlace temporal (5 minutos) al PDF o al XML del Comprobante Electrónico de Pago que Banxico emitió para esa transferencia.

Sin cargo propio. El archivo existe sólo cuando data.files.pdf o data.files.xml del rastreo vienen en true; si no, 404. 400 si format no es pdf ni xml o si el id no mide 24 caracteres; 404 si la consulta es de otra organización o no es un rastreo SPEI. En sandbox no se guardan archivos: siempre 404.

Request
requestIdstringpathrequired

El request_id del rastreo. Sólo la organización que hizo la consulta puede descargar su comprobante.

formatstringqueryrequired

pdf | xml.

curl -X GET https://api.singula.mx/app/spei/{requestId}/file?format=pdf \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
data.urlstring

Enlace temporal de descarga del archivo.

data.expires_innumber

Segundos que vale el enlace (300).

Example
{
  "data": {
    "url": "https://…",
    "expires_in": 300
  }
}

Validar una CLABE (Validador CLABE, sin cargo)

POST/app/clabe/validate

Revisa una CLABE de 18 dígitos —su dígito verificador y que sus tres primeros dígitos sean de un banco de SPEI— y devuelve el banco y la plaza. Reconoce también una tarjeta de débito (16 dígitos, dígito verificador) y un celular (10 dígitos, sólo formato). Contesta en la misma llamada.

Sin cargo, en sandbox y en producción, con la misma respuesta en los dos: es un cálculo, no hay mock. Sólo directa: no hay variante por customer. Una cuenta inválida no es un error: responde con valid: false y su reason (por ejemplo, 012180001234567895 → check_digit_mismatch con expected_check_digit 9). Un cuerpo sin account, o con más de 40 caracteres, es un 400.

Request
accountstringbodyrequired

La cuenta a validar, hasta 40 caracteres: una CLABE de 18 dígitos, una tarjeta de débito de 16 o un celular de 10. Los espacios y guiones se ignoran.

curl -X POST https://api.singula.mx/app/clabe/validate \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "account": "014180000123456780"
}'
Response
data.validboolean

La cuenta puede recibir un SPEI: dígito verificador correcto y, si es CLABE, un banco que existe.

data.kindstring|null

clabe (18) | card (tarjeta de débito, 16) | phone (celular, 10); null si la longitud no es ninguna.

data.bank_codestring|null

Código del banco de la CLABE, del catálogo GET /catalogs/spei-banks; null si no es CLABE o el banco no existe.

data.bank_namestring|null

El nombre con el que Banxico lista ese banco.

data.plazastring|null

Dígitos 4 a 6 de la CLABE. Informativa: nunca invalida una CLABE.

data.check_digitnumber|null

El último dígito tal como viene (CLABE o tarjeta); null para un celular.

data.expected_check_digitnumber|null

El dígito verificador que calculan los demás dígitos; null para un celular.

data.reasonstring|null

null cuando es válida. not_digits (trae algo que no son dígitos) | invalid_length (no tiene 18, 16 ni 10 dígitos) | check_digit_mismatch (el dígito verificador no corresponde: un dígito mal tecleado) | unknown_bank (una CLABE de un banco que no existe en SPEI).

request_idstring

Id del registro: la consulta queda en tu actividad como consulta directa, sin cargo.

Example
{
  "data": {
    "valid": true,
    "kind": "clabe",
    "bank_code": "40014",
    "bank_name": "SANTANDER",
    "plaza": "180",
    "check_digit": 0,
    "expected_check_digit": 0,
    "reason": null
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8"
}

Catálogo de bancos de SPEI

GET/catalogs/spei-banks

Los participantes de SPEI con su código de Banxico —el emisor y el receptor de un rastreo— y el prefijo CLABE de cada uno. Público: no pide llave.

El código de un banco puede cambiar con el tiempo: manda siempre el vigente de esta lista y, para un pago anterior al cambio, Singula resuelve el que aplicaba ese día. Un banco que ya salió de SPEI no está en la lista, y un rastreo con su código responde 400.

Request

No parameters.

curl -X GET https://api.singula.mx/catalogs/spei-banks \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
data[].codestring

Código de Banxico del participante (emisor o receptor de POST /app/spei), p. ej. 40012.

data[].namestring

El nombre con el que Banxico lo lista.

data[].clabe_prefixstring

Los tres primeros dígitos de las CLABE de ese banco (los últimos tres de su código).

as_ofstring

Fecha del catálogo de Banxico del que sale la lista (YYYY-MM-DD).

Example
{
  "data": [
    {
      "code": "40012",
      "name": "BBVA MEXICO",
      "clabe_prefix": "012"
    },
    {
      "code": "40014",
      "name": "SANTANDER",
      "clabe_prefix": "014"
    }
  ],
  "as_of": "2026-10-16"
}
3 endpoints

Verificacion de correo (OTP)

Manda un codigo de 6 digitos al correo del cliente para probar que ese buzon es suyo y, de paso, perfila el dominio: si puede recibir correo, si es gratuito, desechable o institucional, y desde cuando existe.

Sincrono de punta a punta: no hay cola, ni Worker, ni webhook. /send responde con el perfil del dominio y el estado del codigo; /verify responde con el resultado. Autenticacion en ambas: header Authorization: Bearer <API_KEY> (sk_live_ = produccion, sk_test_ = sandbox).

Enviar el codigo a un correo (consulta directa)

POST/app/email/otp/send

Perfila el dominio, genera el codigo de 6 digitos y lo envia con la marca de tu organizacion, sin crear un customer.

Consulta directa: el dato viaja en el cuerpo y no se crea ni se toca ningun customer. Cobra exactamente lo mismo que la variante por customer, aparece igual en tu estado de cuenta y en el consumo por servicio, y dispara el mismo webhook; en sandbox (sk_test_) devuelve el mismo mock y no consume saldo. En el registro queda `source: "direct"` con `customer_id: null` y NO corre el motor de riesgo (no hay expediente sobre el cual correrlo). Si despues quieres conservarla, manda su `request_id` a POST /app/customers/from-request/:requestId y se crea el customer con los datos de esa respuesta. La verificacion del codigo NO cambia y nunca fue por customer: POST /app/email/otp/verify con el request_id y el codigo. En sandbox el codigo es siempre 000000 y no se manda correo real. Si no se pudo enviar responde 503 y no se aplica el cargo.

Request
emailstringbodyrequired

Direccion a la que mandar el codigo. Aqui es OBLIGATORIA: no hay customer del cual tomarla.

curl -X POST https://api.singula.mx/app/email/otp/send \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "[email protected]"
}'
Response
request_idstring

Id del registro de esta consulta (LogRequest), con `source: "direct"` y `customer_id: null`. Sirve para auditoria, para el estado de cuenta y para guardarla despues como cliente.

dataobject

Identico a la variante por customer: misma forma, mismos campos, mismo detalle. { email, domain, otp: { sent, expires_at } }.

statusstring

"success" cuando el codigo salio.

Example
{
  "data": {
    "email": "[email protected]",
    "domain": { "mx": true, "free_provider": false, "disposable": false, "institutional": true, "age_days": 4552 },
    "otp": { "sent": true, "expires_at": "2026-09-14T18:40:00.000Z" }
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "status": "success"
}

Enviar el codigo al correo del customer

POST/app/email/otp/customer/{customerId}/send

Perfila el dominio, genera un codigo de 6 digitos y lo envia con la marca de tu organizacion. Sincrono: no hay cola ni Worker.

El cuerpo es OPCIONAL. El correo sale con la marca de tu organizacion (nombre y color). Si NO se pudo enviar, responde 503 y no se aplica el cargo. En sandbox (sk_test_) el codigo es siempre 000000 y no se manda correo real. Del codigo solo se guarda su hash con sal — nunca el codigo. Errores: 400 si no hay correo ni en el cuerpo ni en el customer, 402 sin saldo, 404 si el customer no existe en ese ambiente.

Request
customerIdstringpathrequired

Id del customer, en el mismo ambiente que tu API key.

emailstringbody

Direccion a la que mandar el codigo. OPCIONAL: por defecto se usa el correo guardado del customer. Cuando viene, SOBRESCRIBE el guardado solo para esta llamada.

curl -X POST https://api.singula.mx/app/email/otp/customer/{customerId}/send \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "[email protected]"
}'
Response
data.emailstring

La direccion a la que se mando el codigo.

data.domainobject

Perfil del dominio, independiente del codigo: { mx (publica registros MX, o sea puede recibir correo), free_provider (buzon de consumo: gmail, hotmail, outlook, yahoo, icloud, proton...), disposable (proveedor de buzon temporal), institutional (ni gratuito ni desechable), created_at (fecha de registro del dominio via RDAP, null si RDAP no contesto), age_days }.

data.otpobject

{ sent, expires_at }. El codigo vive 10 minutos y admite 5 intentos.

request_idstring

Pasalo a POST /app/email/otp/verify junto con el codigo.

statusstring

"success" cuando el codigo salio.

Example
{
  "data": {
    "email": "[email protected]",
    "domain": {
      "mx": true,
      "free_provider": false,
      "disposable": false,
      "institutional": true,
      "created_at": "2014-03-19T00:00:00.000Z",
      "age_days": 4552
    },
    "otp": { "sent": true, "expires_at": "2026-09-04T18:30:00.000Z" }
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "status": "success"
}

Verificar el codigo recibido

POST/app/email/otp/verify

Comprueba el codigo de 6 digitos contra el reto que abriste con /send. No aplica cargo.

Sin cargo: el producto se cobro al enviar el codigo. Un codigo equivocado gasta un intento; al quinto el reto queda quemado y responde attempts_exhausted (hay que volver a /send). Un envio que fallo responde send_failed y nunca se cobra, aunque el reto venza o se agoten los intentos. 404 si el request_id no existe o es de otra organizacion.

Request
request_idstringbodyrequired

El request_id que devolvio /send.

codestringbodyrequired

Los 6 digitos que recibio el usuario en su correo.

curl -X POST https://api.singula.mx/app/email/otp/verify \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "code": "481920"
}'
Response
dataobject

El mismo objeto de /send (email, domain, otp) mas verified y verified_at cuando el codigo fue correcto.

verifiedboolean

true si el codigo coincidio dentro de su vigencia.

reasonstring

Por que fallo: invalid_code | expired | attempts_exhausted | send_failed. `send_failed` (desde 1.113.0) = ese codigo nunca salio: su envio no se cobro ni se cobrara, y el reto no se puede contestar; manda otro con /send. Ausente cuando verified es true.

attempts_remainingnumber

Intentos que quedan antes de quemar el reto (empieza en 5).

request_idstring

Eco del id verificado.

statusstring

Estado de la solicitud.

Example
{
  "data": {
    "email": "[email protected]",
    "domain": {
      "mx": true,
      "free_provider": false,
      "disposable": false,
      "institutional": true,
      "created_at": "2014-03-19T00:00:00.000Z",
      "age_days": 4552
    },
    "otp": { "sent": true, "expires_at": "2026-09-04T18:30:00.000Z" },
    "verified": true,
    "verified_at": "2026-09-04T18:24:11.000Z"
  },
  "verified": true,
  "attempts_remaining": 5,
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "status": "success"
}
1 endpoints

Expediente KYB (persona moral)

Cruza entre si los documentos que ya le procesaste a una empresa —constancia, acta, poder, identificacion del representante (su INE o, desde MasterApi 1.130.0, su verificacion de identidad con INE, pasaporte o tarjeta de residente), comprobante, e.firma y correo verificado— y responde si todos hablan de la MISMA empresa y del MISMO representante.

Sincrono: una sola llamada, sin cola y sin webhook. Solo lee LogRequests de TU organizacion y de TU ambiente, asi que un expediente de sandbox nunca mezcla solicitudes de produccion. Autenticacion: header Authorization: Bearer <API_KEY>.

Armar el expediente KYB de un customer

POST/app/kyb/expediente/customer/{customerId}

Sincrono. No consulta a ningun proveedor: lee las respuestas que ya pagaste y las compara entre si.

Los controles y su criticidad se configuran POR ORGANIZACION (GET/PUT /organization/verification-policy); por defecto se aplica el perfil de alta de persona moral. Son 30: los 16 cotejos originales (entre documentos y contra su fuente), los 8 que leen lo que cada papel dijo de SI MISMO (su checklist y su veredicto de fraude), los 2 anclajes de identidad y los 4 que leen las señales que la empresa ya pago (listas, judicial de persona moral, RFC ante el SAT e identidad del representante). Las casillas son ONCE, pero la vara por default siguen siendo los SIETE documentos: exigir por default una señal que el tenant nunca compro habria mandado a review todo expediente que hoy aprueba. Por DEFAULT la vara la pone la politica y no la llamada (`kyb.lock_required`), y un control que no se pudo evaluar retiene el pass (`kyb.strict_nulls`); un checklist cuyas UNICAS reglas pendientes son comparaciones contra lo que TU declaraste (`declared` es opcional) NO cuenta como pendiente y sale en verde diciendo que no habia nada que comparar. No encola nada y no pega a proveedores: se factura a traves de las tools que lee, no por si mismo. Un documento que falta deja su regla en null, NUNCA en false — no tener evidencia no es tener evidencia en contra — y aparece en `missing`, que es lo que impide el pass. El comprobante de domicilio tiene precedencia por origen: un recibo (CFE, agua, predial, telefonia) gana la casilla sobre un estado de cuenta bancario aunque el estado sea mas nuevo, porque el estado no trae bloque de domicilio y sin el la regla de domicilio nunca se corre. La comparacion de razon social tolera las formas societarias (S.A. de C.V. = Sociedad Anonima de Capital Variable). El cuerpo es OPCIONAL. Errores: 400 si un request_id no mide 24 caracteres o mandas mas de 30, 404 si el customer no existe en ese ambiente. DESDE 1.114.0: (a) `ine_renapo_match` coteja la INE contra RENAPO con la CURP que la INE IMPRIME: la CURP, la fecha de nacimiento, el sexo y el nombre con el umbral de persona (tolerante a un apellido que la lectura no vio), y el `detail` nunca repite lo que contesto RENAPO. Coincide → true (un KYB coherente aprueba solo); se contradice → false critico (fail); RENAPO no tiene esa CURP, o la CURP impresa no puede ser una (una letra O donde va un cero, un caracter de menos) → false no critico (review); la consulta fallo o no contesto en 2 minutos, o la INE no trae CURP legible → null que SI retiene (review): un KYB nunca aprueba sin cruzar a la persona contra RENAPO. En el cierre de una corrida esa consulta va incluida en la INE, sin cargo extra; esta ruta suelta solo reusa una que ya exista. (b) Los nombres de PERSONA —el apoderado contra la INE y contra el acta, quien firma contra el poder— se comparan con `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 queda en null (review). (c) `representative_identity_verified`: dentro de una corrida solo cuentan sus representantes; cada firmante con identidad aprobada tiene que ser uno de los apoderados del poder, y una KYC de firmante rechazada es false. Sigue NO critico por default (subelo con `kyb.checks.representative_identity_verified.critical: true`). (d) Un acta sin RFC —lo normal— ya no retiene la aprobacion cuando su denominacion es IDENTICA a la razon social de la constancia, sin contar la forma juridica del final ("GRUPO SINGULA, S.A. DE C.V." = "GRUPO SINGULA"): `csf_vs_acta_rfc` queda en null con `holds_approval: false`. Parecida no basta —dos sociedades de un mismo grupo solo se distinguen por el RFC— y sigue reteniendo. (e) Cada control trae `holds_approval`.

Request
customerIdstringpathrequired

Id del customer (la persona moral), en el mismo ambiente que tu API key.

flow_idstring (uuid)query

Lee la COBERTURA de ese flujo en vez de armar el expediente. Con él la respuesta es `{ flow_id, coverage: { reused, missing }, next }` y se resuelve con las MISMAS reglas del reuso (`reuse_evidence`): qué pasos ya tienen una consulta fresca de este cliente y cuáles no. **No cruza nada, no escribe `LogRequest` y no cobra.** `next` vale `"run"` cuando todo lo que falta lo corre la plataforma sola y `"open_session"` cuando alguien tiene que aparecer. La ventana de frescura que aplica es la de la **VERSIÓN PUBLICADA**, no la del borrador que estés editando: es la que va a usar la corrida, y leer la del borrador contestaba «ya está cubierto» sobre algo que la corrida volvía a cobrar. Un `flow_id` que no sea un flujo **publicado**, de tu organización y de este tipo responde 404 `{ code: "flow_not_found" }`, y un `customer` de otra organización también: la cobertura se resuelve con el inquilino, nunca con el ambiente solo. Sin él, el expediente es el de siempre.

request_idsarray<string>body

Los request_id con los que armar el expediente. OPCIONAL: por defecto se toma la ultima solicitud exitosa del customer por producto. Fijalos para incluir un documento archivado bajo OTRO customer — tipicamente la INE del representante legal, guardada en su propio registro de persona fisica. Maximo 30, y siempre acotados a tu organizacion y ambiente.

required_componentsarray<string>body

Las casillas que el expediente DEBE traer para poder aprobar. Hay ONCE: los siete documentos (csf, acta, poder, ine, comprobante, fiel, email) y las cuatro señales que la misma empresa pudo haber pagado aparte (blacklist, judicial, rfc, identity). OPCIONAL. OJO: mientras tu politica tenga `kyb.lock_required` encendido (el DEFAULT) este campo se IGNORA — la vara la pone la organizacion y la respuesta te lo dice en `policy.request_override_ignored: true`. Apaga el candado (PUT /organization/verification-policy con `kyb.lock_required: false`) para que el request vuelva a mandar y puedas hacer un onboarding por etapas (p. ej. ["csf","acta"] aprueba la mitad fiscal). Si lo OMITES aplica siempre la politica (GET /organization/verification-policy; las SIETE mientras no la cambies): un alta que aprueba una empresa con 2 de 7 controles no es un alta. Lo que se exija y no este llega en `missing`. Si lo mandas, debe traer al menos un nombre y solo de las once validas: una lista vacia o un nombre desconocido responden 400 (una errata nunca baja la vara en silencio). Las cuatro casillas nuevas NO se exigen por default: si las pides, su ausencia sale en `missing` y bloquea el pass.

curl -X POST https://api.singula.mx/app/kyb/expediente/customer/{customerId} \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "request_ids": [
    "6622f8a1c1d2e3f4a5b6c7d8",
    "6622f8a1c1d2e3f4a5b6c7d9"
  ]
}'
Response
data.overallstring

pass = todo lo evaluable concuerda, estan TODAS las casillas exigidas Y —con `policy.strict_nulls`, encendido por default— ningun control de un componente exigido quedo sin evaluar; fail = un control CRITICO se contradice; review = cualquier otra cosa, incluido un expediente vacio o INCOMPLETO (aprobar exige evidencia, y un control que nunca se corrio no puede estar en verde). Si falta un documento exigido el expediente cae en review, nunca en pass.

data.checksarray

Los 30 controles (los que tu politica deje encendidos), cada uno { code, ok, detail, sources, critical, holds_approval }. `ok` es TRIESTADO: true concuerda, false se contradice, null no evaluable (falta el documento). 16 son los cotejos originales del expediente —unos cruzan un documento contra otro y otros lo cotejan contra su fuente (SAT, Lista Nominal)—: csf_vs_acta_razon_social, csf_vs_acta_rfc, csf_vs_acta_objeto_actividad, csf_sat_record_matches, csf_active_not_listed, poder_poderdante_vs_csf_razon_social, poder_apoderado_vs_ine_name, poder_apoderado_in_acta, ine_vigente_y_listanominal, ine_image_quality, comprobante_holder_vs_razon_social, comprobante_recent_90d, comprobante_address_vs_csf, fiel_rfc_vs_csf_rfc, fiel_active_not_expiring_15d, email_verified_institutional. Los otros 10 leen lo que cada documento ya dijo de SI MISMO: su checklist (csf_requirements_ok, acta_requirements_ok, poder_requirements_ok, comprobante_requirements_ok) y su veredicto de fraude (csf_authenticity, acta_authenticity, poder_authenticity, comprobante_authenticity: `forged` reprueba, `suspicious` manda a revision aunque tu politica lo marque critico), mas los dos anclajes de identidad: ine_renapo_match (la INE contra el registro de RENAPO, con la CURP que la propia INE imprime —la empresa no tiene CURP—: el cierre de una corrida KYB obtiene ese registro incluido en la INE, sin cargo, y esta ruta suelta solo REUSA una validacion de esa CURP que ya exista; ver notas) y fiel_latest_issued_active (la e.firma emitida al final debe estar activa). Los ultimos 4 leen las SEÑALES que la misma empresa ya pago, cuando estan en el expediente: blacklist_company_clear (critico; el cotejo de listas hecho con la razon social de la constancia o el acta — si se busco otro nombre queda en `null` diciendo a quien si se busco), judicial_company_clear (critico; el judicial de persona moral, con la misma regla de nombre), rfc_registered (no critico; validateRfc del RFC de la constancia) y representative_identity_verified (no critico; la identidad aprobada de quien firma —representante legal o apoderado— tiene que ser de uno de los apoderados del poder o del acta, por CURP o por nombre con los dos apellidos: uno que no lo es da false, una persona que no se puede amarrar da null y una KYC de firmante RECHAZADA da false; dentro de una corrida solo cuentan los representantes de ESA corrida). Sus cuatro casillas son OPCIONALES por default, asi que en un expediente que no las trae quedan en `null` y no bloquean nada. `sources` trae los request_id de los que salio el veredicto. Solo un `false` con `critical:true` tumba el expediente a fail; un `false` no critico (p. ej. comprobante_address_vs_csf cuando el domicilio trae varios grupos de 5 digitos y ninguno etiquetado como C.P.) lo deja en review para que lo lea una persona.

data.checks[].holds_approvalboolean

Desde 1.114.0: si ESTE control es lo que impide que `overall` sea "pass", decidido con la MISMA regla que `overall`. true en todo `ok: false` (uno critico reprueba el expediente; cualquier otro lo manda a review) y en un `ok: null` que retiene bajo `policy.strict_nulls` (cada casilla que lee se exigio y el expediente no lo excusa); false en `ok: true` y en un null que no retiene nada (p. ej. csf_vs_acta_rfc de un acta sin RFC cuya denominacion es identica a la razon social de la constancia). Leelo en vez de recalcularlo: sin ningun control que retenga, nada en `missing` y al menos un control evaluado, `overall` es "pass". Viaja en esta respuesta, en la del expediente KYC y en el detalle de la consulta del expediente (`GET /dashboard/request/:id` con su `request_id`, en `response.checks[]`) — el `expediente.request_id` de una corrida abre ese detalle. Un expediente guardado antes de 1.114.0 no lo trae.

data.missingarray<string>

Las casillas EXIGIDAS que el expediente no trae, en orden de expediente. Vacio = expediente completo. Un `missing` no vacio es exactamente el motivo por el que un expediente limpio contesta review y no pass.

data.flow_idstring | null

SOLO con `?flow_id=`: el flujo cuya cobertura se leyo. Con el la respuesta NO trae el expediente — trae `coverage` y `next`.

data.coverage{ reused, missing } | null

SOLO con `?flow_id=`. `reused: [{ step_key, request_id, request_date }]` son los pasos de ese flujo que ya tienen una consulta fresca de esta empresa —las mismas reglas de `reuse_evidence`: terminal, mismo cliente, mismo ambiente, misma herramienta y, en el paso generico de documentos, el mismo tipo de documento, dentro de `evidence_max_age_hours`—; `missing: [{ step_key, tool, how }]` son los que faltan, con `how: "auto"` (lo corre la plataforma) o `"person"` (necesita el link o el SDK).

data.nextstring (enum: run|open_session) | null

SOLO con `?flow_id=`: `run` cuando todo lo que falta es `auto`; `open_session` cuando alguien tiene que aparecer.

data.flows{ id, name, kind }[]

SIN `flow_id`: los flujos PUBLICADOS de tu organizacion de este tipo, para contestar «cual quieres correr» cuando tienes varios. Si el listado truena, el expediente igual contesta.

data.missing_required{ component, how }[]

SIN `flow_id`: lo mismo que `missing`, pero con `how` por componente (`auto` o `person`).

data.requiredarray<string>

Las casillas que se exigieron: las que fije la politica de verificacion de tu organizacion (los siete DOCUMENTOS por defecto, de las once que existen), o lo que hayas mandado en `required_components` cuando el candado `kyb.lock_required` esta apagado.

data.representativeobject|null

Quien firmara por la empresa: { name, curp, rfc, matches_poder, matches_ine }. null cuando no hay ni poder ni INE en el expediente. Desde 1.114.0 la `curp` sale de la INE solo cuando la INE confirmo a ese apoderado: nunca se publica el nombre del poder con la CURP de la credencial de otra persona.

data.ine_sourcestring|null

Desde MasterApi 1.130.0: de donde salio la casilla `ine`, la identificacion del representante. `ine_extraction` = una lectura de INE propia (siempre gana). `identity_session` = sin INE leida aparte y con la casilla exigida por tu politica, la sesion de identidad del expediente ES la identificacion: la INE, el pasaporte o la tarjeta de residente que el representante mostro a la camara, con prueba de vida, solo si la sesion esta VERIFICADA y es de la persona que nombran el poder o el acta: una en revision o rechazada, o claramente de otra persona (otra CURP, un nombre que no se parece al del apoderado), deja `ine` en `missing` (revision). Cada control de la INE juzga entonces ese documento —el nombre contra el poder (tambien el de su MRZ), la vigencia en lugar de la Lista Nominal en un pasaporte o una tarjeta, la calidad de imagen como AVISO (`ine_image_quality` en false manda a revision, nunca reprueba) y RENAPO con la CURP que imprime, incluido— y su `detail` lo nombra; un pasaporte expedido fuera de Mexico que no imprime CURP, de un titular que no es de nacionalidad mexicana, deja `ine_renapo_match` en `null` sin retener. `inputs.ine` apunta a la sesion de identidad. null = no hay identificacion en el expediente o la casilla no se exige. El cierre de una corrida KYB espera la Lista Nominal de su sesion de identidad (solo con `ine` exigido).

data.uboarray

Beneficiarios controladores segun el acta: { name, percent, is_controller, person_type, nationality }. Vacio cuando no hay acta.

data.inputsobject

Que solicitud alimento cada casilla, las once: { csf, acta, poder, ine, comprobante, fiel, email, blacklist, judicial, rfc, identity }. null = el expediente no tiene esa pieza.

data.policyobject

La politica de verificacion de TU organizacion bajo la que se juzgo este expediente: { version, required, disabled_checks, critical_overrides, proof_of_address_max_age_days, fiel_expiring_days, strict_nulls, lock_required, request_override_ignored }. SIEMPRE presente, en produccion y en sandbox; con la politica por defecto el veredicto es identico al de siempre. Los controles y su criticidad se configuran por organizacion (GET/PUT /organization/verification-policy); por defecto se aplica el perfil de alta de persona moral.

data.policy.disabled_checksarray<string>

Los controles que tu organizacion apago. Un control apagado NO se corre: se OMITE de `checks` y no cuenta para `overall` — no puede reprobar ni mandar a revision.

data.policy.critical_overridesobject

Los controles cuya criticidad movio tu organizacion, { code: critical }. `critical:false` hace que incumplirlo mande el expediente a review en vez de a fail; `critical:true`, al reves. El `critical` que ves en cada item de `checks` ya es el efectivo. Excepcion: un veredicto de autenticidad `suspicious` se queda en review aunque marques el control como critico — la capa de fraude nunca condena por una sola señal.

data.policy.strict_nullsboolean

true (DEFAULT) = un control de un componente EXIGIDO que no se pudo evaluar (`ok:null`) retiene el `pass` y deja el expediente en `review`: se aprueba con evidencia, y una regla que nunca corrio no es evidencia. Un control de un componente que NO exiges no bloquea nada. false = los `null` se ignoran. Se cambia con PUT /organization/verification-policy (`kyb.strict_nulls`).

data.policy.lock_requiredboolean

true (DEFAULT) = la vara de completitud es la de tu organizacion y el `required_components` que mande un request se IGNORA. false = el request gana, un expediente a la vez. Se cambia con PUT /organization/verification-policy (`kyb.lock_required`).

data.policy.request_override_ignoredboolean

true = ESTA llamada SI mando `required_components` y el candado lo dejo fuera: el expediente se juzgo con la vara de la organizacion, no con la que pediste. Nunca falla en silencio — si ves un `missing` que no esperabas, mira este campo primero.

request_idstring

Id del LogRequest de este expediente (queda registrado y es auditable).

statusstring

Siempre "success": el expediente es sincrono.

Example
{
  "data": {
    "overall": "review",
    "checks": [
      {
        "code": "csf_vs_acta_rfc",
        "ok": true,
        "detail": "RFC constancia SIN230101AB1 vs RFC acta SIN230101AB1",
        "sources": ["6622f8a1c1d2e3f4a5b6c7d8", "6622f8a1c1d2e3f4a5b6c7d9"],
        "critical": false,
        "holds_approval": false
      },
      {
        "code": "acta_authenticity",
        "ok": true,
        "detail": "el acta constitutiva no muestra señales de alteración",
        "sources": ["6622f8a1c1d2e3f4a5b6c7d9"],
        "critical": false,
        "holds_approval": false
      },
      {
        "code": "poder_apoderado_vs_ine_name",
        "ok": null,
        "detail": "no hay INE del representante",
        "sources": [],
        "critical": false,
        "holds_approval": true
      }
    ],
    "representative": {
      "name": "MARIA LOPEZ GARCIA",
      "curp": "LOGM900101MDFPRR03",
      "rfc": "LOGM900101AB1",
      "matches_poder": true,
      "matches_ine": null
    },
    "missing": ["poder", "ine", "comprobante", "fiel", "email"],
    "required": ["csf", "acta", "poder", "ine", "comprobante", "fiel", "email"],
    "ubo": [
      { "name": "ANA RUIZ", "percent": 70, "is_controller": true, "person_type": "fisica", "nationality": "mexicana" },
      { "name": "INVERSIONES BETA SA DE CV", "percent": 30, "is_controller": true, "person_type": "moral", "nationality": "mexicana" }
    ],
    "inputs": {
      "csf": "6622f8a1c1d2e3f4a5b6c7d8",
      "acta": "6622f8a1c1d2e3f4a5b6c7d9",
      "poder": null,
      "ine": null,
      "comprobante": null,
      "fiel": null,
      "email": null,
      "blacklist": null,
      "judicial": null,
      "rfc": null,
      "identity": null
    },
    "policy": {
      "version": 1,
      "required": ["csf", "acta", "poder", "ine", "comprobante", "fiel", "email"],
      "disabled_checks": [],
      "critical_overrides": {},
      "proof_of_address_max_age_days": 90,
      "fiel_expiring_days": 15,
      "strict_nulls": true,
      "lock_required": true,
      "request_override_ignored": false
    }
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7da",
  "status": "success"
}
1 endpoints

Expediente KYC (persona física)

Cruza entre sí las consultas que ya le corriste a una persona —verificación de identidad con prueba de vida, INE y Lista Nominal, CURP ante RENAPO, RFC ante el SAT, comprobante de domicilio, correo verificado por código, teléfono, listas de sanciones y judicial— y contesta lo que ninguna consulta suelta contesta: si todas hablan de la MISMA persona, y si esa persona está limpia. Es el espejo del expediente KYB para persona física, y es con lo que cierra una corrida de un flujo KYC.

Síncrono: una sola llamada, sin cola y sin webhook. Sólo lee LogRequests de TU organización y de TU ambiente, así que un expediente de sandbox nunca mezcla solicitudes de producción. Autenticación: header Authorization: Bearer <API_KEY>. En el MCP es la tool `kycExpediente`.

Armar el expediente KYC de un customer

POST/app/kyc-expediente/customer/{customerId}

Síncrono. No consulta a ningún proveedor ni encola nada: lee las respuestas que ya pagaste y las compara entre sí. Cargo 0.

Lo que hace distinto a este expediente: las listas de sanciones y el judicial se juzgan contra la identidad VERIFICADA —el nombre que confirmaron la INE y RENAPO—, nunca contra el que la persona tecleó. Si la consulta se corrió con otro nombre, el control queda en `null` y dice a quién sí se buscó: un `clear` sobre otro nombre no absuelve a esta persona, y un `high` sobre otro nombre tampoco la condena. Un documento que falta deja su regla en `null`, NUNCA en `false` — no tener evidencia no es tener evidencia en contra — y aparece en `missing`, que es lo que impide el `pass` (desde MasterApi 1.130.0, la CURP de un extranjero con pasaporte —expedido fuera de México, sin CURP impresa y de nacionalidad no mexicana— sale en `excused`, no en `missing`; una persona con INE que dejó la CURP vacía sigue con `curp` en `missing`: la identidad no copia su CURP al cliente). La antigüedad del comprobante usa el MISMO umbral que el motor de documentos (`documents.max_age_days.proof_of_address`, 90 días por defecto): un solo dial mueve los dos. No encola nada, no pega a proveedores y no cobra: se factura a través de las consultas que lee. La red de los tres controles de IP no viaja en el cuerpo —no se acepta—: sale de la sesión de identidad. Desde MasterApi 1.112.0, un expediente con identidad y sin INE aparte cuyo documento contradice a RENAPO (otra CURP, o un nombre de verdad distinto), o cuya INE ya venció o la Lista Nominal no la da por vigente (dada de baja, no encontrada, duplicada o robada, datos que no coinciden), sale `fail`: los tres controles de la INE son críticos por default. Una Lista Nominal que no concluye (mantenimiento, un modelo que no se puede consultar) deja ine_vigente en `null`, nunca en contra. Desde MasterApi 1.128.0 un pasaporte vencido —y desde 1.130.0 una tarjeta de residente vencida— también sale `fail` (identity_doc_vigente es crítico por default), aunque estuviera vigente el día de la captura: la vigencia se mide al armar el expediente. Si prefieres revisión en vez de rechazo, ponlos en `critical: false` en `kyc.checks` (PUT /organization/verification-policy). Errores: 400 si un request_id no mide 24 caracteres, si mandas más de 30, o si `required_components` va vacío o con un nombre que no existe; 404 si el customer no existe en ese ambiente; 409 si el customer es persona MORAL —un expediente KYC es de una persona física; el de una empresa es POST /app/kyb/expediente/customer/:customerId—, y en ese caso no se escribe ningún registro.

Request
customerIdstringpathrequired

Id del customer (la persona física), en el mismo ambiente que tu API key.

flow_idstring (uuid)query

Lee la COBERTURA de ese flujo en vez de armar el expediente. Con él la respuesta es `{ flow_id, coverage: { reused, missing }, next }` y se resuelve con las MISMAS reglas del reuso (`reuse_evidence`): qué pasos ya tienen una consulta fresca de este cliente y cuáles no. **No cruza nada, no escribe `LogRequest` y no cobra.** `next` vale `"run"` cuando todo lo que falta lo corre la plataforma sola y `"open_session"` cuando alguien tiene que aparecer. La ventana de frescura que aplica es la de la **VERSIÓN PUBLICADA**, no la del borrador que estés editando: es la que va a usar la corrida, y leer la del borrador contestaba «ya está cubierto» sobre algo que la corrida volvía a cobrar. Un `flow_id` que no sea un flujo **publicado**, de tu organización y de este tipo responde 404 `{ code: "flow_not_found" }`, y un `customer` de otra organización también: la cobertura se resuelve con el inquilino, nunca con el ambiente solo. Sin él, el expediente es el de siempre.

request_idsarray<string>body

Los request_id con los que armar el expediente. OPCIONAL: por defecto toma la última consulta exitosa del cliente POR HERRAMIENTA (identidad, INE, CURP, RFC, documento, correo verificado, teléfono básico y premium, listas y judicial de persona). Fíjalos para juzgar exactamente una tanda de evidencia —es como cierra una corrida de un flujo: con los folios de ESA corrida y de ninguna otra—. Máximo 30, siempre acotados a tu organización y ambiente, y cada uno de 24 caracteres.

required_componentsarray<string>body

Las casillas que el expediente DEBE traer para poder aprobar, de las nueve que existen: identity, ine, curp, rfc, comprobante, email, phone, blacklist, judicial. OPCIONAL. OJO: mientras tu política tenga `kyc.lock_required` encendido (el DEFAULT) este campo se IGNORA — la vara la pone la organización y la respuesta te lo dice en `policy.request_override_ignored: true`. Apaga el candado (PUT /organization/verification-policy con `kyc.lock_required: false`) para que el request vuelva a mandar y puedas hacer un alta por etapas. Si lo OMITES aplica siempre la política: por defecto identity, curp y blacklist —identidad verificada, la foto de RENAPO detrás de ella y las listas—, que es lo que hace que un KYC sea un KYC. Lo que se exija y no esté llega en `missing`. Si lo mandas, debe traer al menos un nombre y sólo de los nueve válidos: una lista vacía o un nombre desconocido responden 400.

curl -X POST https://api.singula.mx/app/kyc-expediente/customer/{customerId} \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "request_ids": [
    "6622f8a1c1d2e3f4a5b6c7d8",
    "6622f8a1c1d2e3f4a5b6c7d9"
  ]
}'
Response
data.overallstring

pass = todo lo evaluable concuerda, están TODAS las casillas exigidas Y —con `policy.strict_nulls`, encendido por default— ningún control de un componente exigido quedó sin evaluar; fail = un control CRÍTICO se contradice (la evidencia describe a personas distintas, la identidad salió rechazada, una lista marca a la persona); review = cualquier otra cosa, incluido un expediente vacío o INCOMPLETO. Aprobar exige evidencia: un control que nunca corrió no puede estar en verde.

data.checksarray

Los 19 controles (los que tu política deje encendidos), cada uno { code, ok, detail, sources, critical, holds_approval }. `ok` es TRIESTADO: true concuerda, false se contradice, null no evaluable (falta la consulta, o la respuesta no trae el campo — el `detail` dice cuál). Ocho son CRÍTICOS por default: identity_verdict (la sesión de identidad salió approved), identity_doc_vs_ine (el documento de esa sesión y la INE leída son de la misma persona), ine_name_vs_renapo (el nombre de la INE contra el registro de RENAPO, con umbral difuso), ine_curp_vs_renapo (la CURP impresa contra la de RENAPO), ine_vigente (credencial vigente y en Lista Nominal), identity_doc_vigente (el pasaporte o la tarjeta de residente de la sesión de identidad no ha vencido), blacklist_clear y judicial_clear. Sin una lectura de INE aparte, esos tres de la INE leen la SESIÓN DE IDENTIDAD: la CURP y el nombre de su documento contra el registro de RENAPO del expediente —el nombre con `kyc.name_match_threshold`, tolerante a un apellido que la lectura no vio; sólo si el expediente no trae RENAPO decide el cotejo que corrió la propia sesión— y, para ine_vigente, la Lista Nominal que la sesión consultó y la vigencia de su documento; entonces `sources` cita la sesión. Un pasaporte mexicano sí imprime la CURP: si la lectura la vio, ine_curp_vs_renapo la coteja con RENAPO; si no la vio, o el pasaporte es extranjero, queda en `null` sin retener la aprobación. Su nombre se coteja con RENAPO dos veces —el impreso y el del MRZ— y gana el que más coincide; y como un pasaporte no tiene Lista Nominal, ine_vigente queda en `null` sin retener la aprobación. Desde MasterApi 1.130.0 una tarjeta de residente se lee igual: imprime la CURP (la de una persona extranjera trae la entidad NE), su nombre se coteja impreso y del MRZ, y tampoco tiene Lista Nominal. Con una INE aparte, mandan la INE y su Lista Nominal. Desde MasterApi 1.128.0, dos controles juzgan el PASAPORTE de la sesión de identidad —y desde 1.130.0 su TARJETA DE RESIDENTE—: identity_doc_vigente, crítico —se mide el día que se arma el expediente; en un pasaporte, con la fecha de vencimiento que respalda el dígito verificador de su MRZ (una que el MRZ no respalda deja el control en `null`, nunca en contra); en una tarjeta de residente, con el vencimiento impreso, porque el campo de vencimiento de su MRZ trae la fecha de expedición (un vencimiento que no es posterior a la expedición impresa es una lectura dudosa: queda en `null` y no se juzga), y una permanente no vence; un documento vencido reprueba el expediente— e identity_doc_dob_vs_renapo, no crítico —su fecha de nacimiento contra la del registro de RENAPO del expediente o, si el expediente no lo trae, el cotejo que corrió la propia sesión; si no coincide, el expediente va a revisión—. Con una INE los dos quedan en `null` y no retienen nada: su vigencia y su fecha de nacimiento las juzgan ine_vigente e ine_curp_vs_renapo. No tienen casilla, así que un `null` suyo nunca retiene el veredicto y un `false` siempre lo hace. Los otros once no son críticos: identity_doc_dob_vs_renapo, form_vs_renapo (lo que la persona tecleó contra RENAPO; desde 1.114.0, dentro de una corrida, lo que capturó en ESA corrida o lo que el registro ya tenía, nunca lo que el paso de CURP o la INE copiaron al registro), rfc_registered, rfc_vs_curp (las 4 iniciales y la fecha del RFC contra la CURP), comprobante_holder_vs_person, comprobante_recency, email_verified, phone_valid y los tres de RED, que leen desde dónde se conectó la persona: ip_country_vs_document (el país de cada IP contra el del documento de identidad o de la INE; con un pasaporte extranjero valen su país y México, y con una tarjeta de residente, México y el país de su nacionalidad), ip_not_tor (ninguna IP es salida de Tor) e ip_not_hosting (ninguna es de nube, hosting o VPN conocida; un iCloud Private Relay no cuenta). En `false` los de red mandan a revisión, nunca rechazan, y nunca son lo único que aprueba: sin otro control evaluable el expediente queda en `review` aunque la red salga limpia. No tienen casilla, así que sin IP quedan en `null` y nunca retienen el veredicto. En esta ruta suelta leen la red de la sesión de identidad de la casilla `identity`; al cierre de una verificación, la de la corrida y la de su sesión de identidad. `sources` trae los request_id de los que salió cada veredicto, y `critical` sólo llega en true en un control que además está en `ok: false`: la criticidad se reporta por RESULTADO, porque sólo un `false` puede tumbar el expediente.

data.checks[].holds_approvalboolean

Desde 1.114.0: si ESTE control es lo que impide que `overall` sea "pass", decidido con la MISMA regla que `overall`. true en todo `ok: false` (uno crítico reprueba el expediente; cualquier otro lo manda a review) y en un `ok: null` que retiene bajo `policy.strict_nulls` (cada casilla que lee se exigió); false en `ok: true` y en un null que no retiene nada —un control de red sin IP, los dos del pasaporte cuando la sesión leyó una INE, o uno que lee una casilla que tu política no exige, como la CURP de un pasaporte extranjero con la política por default—. Léelo en vez de recalcularlo: sin ningún control que retenga, nada en `missing` y al menos un control evaluado, `overall` es "pass". Viaja en esta respuesta y en el detalle de la consulta del expediente (`GET /dashboard/request/:id` con su `request_id`, en `response.checks[]`), que es lo que abre el `expediente.request_id` de una corrida. Un expediente guardado antes de 1.114.0 no lo trae.

data.missingarray<string>

Las casillas EXIGIDAS que el expediente no trae, en orden de expediente. Vacío = completo. Un `missing` no vacío es exactamente el motivo por el que un expediente limpio contesta review y no pass.

data.excusedarray<object>

Desde MasterApi 1.130.0: las casillas exigidas que el expediente no trae y que NO le faltan, porque la persona no puede tenerlas: `[{ slot, reason }]`. Hoy sólo `curp`, cuando la sesión de identidad leyó un pasaporte expedido fuera de México que no imprime CURP y cuyo titular no es de nacionalidad mexicana (un extranjero no tiene CURP; un pasaporte mexicano, o uno que la imprime, la sigue pidiendo). Nunca sale en `missing`, y un control que sólo lee esa casilla no retiene la aprobación. Siempre presente (vacío si no hay nada excusado). Una tarjeta de residente sí trae CURP: nunca se excusa.

data.flow_idstring | null

SÓLO con `?flow_id=`: el flujo cuya cobertura se leyó. Con él la respuesta NO trae el expediente — trae `coverage` y `next`.

data.coverage{ reused, missing } | null

SÓLO con `?flow_id=`. `reused: [{ step_key, request_id, request_date }]` son los pasos de ese flujo que ya tienen una consulta fresca de este cliente —las mismas reglas de `reuse_evidence`: terminal, mismo cliente, mismo ambiente, misma herramienta, mismo tipo de documento, dentro de `evidence_max_age_hours`—; `missing: [{ step_key, tool, how }]` son los que faltan, con `how: "auto"` (lo corre la plataforma) o `"person"` (necesita el link o el SDK).

data.nextstring (enum: run|open_session) | null

SÓLO con `?flow_id=`: `run` cuando todo lo que falta es `auto` y la corrida puede cerrarse sola; `open_session` cuando alguien tiene que aparecer.

data.flows{ id, name, kind }[]

SIN `flow_id`: los flujos PUBLICADOS de tu organización de este tipo, para contestar «¿cuál quieres correr?» cuando tienes varios. Si el listado truena, el expediente igual contesta.

data.missing_required{ component, how }[]

SIN `flow_id`: lo mismo que `missing`, pero con `how` por componente (`auto` o `person`), para saber de un vistazo qué se puede resolver sin molestar a nadie.

data.requiredarray<string>

Las casillas que se exigieron: las que fije `kyc.required_components` de tu política (identity, curp y blacklist por defecto), o lo que hayas mandado en `required_components` cuando el candado `kyc.lock_required` está apagado.

data.subjectobject

La persona tal como la describe la EVIDENCIA, no el formulario: { name, curp, rfc, birth_date, identity_verified }. RENAPO gana sobre la INE y la INE sobre lo tecleado; `identity_verified` es true cuando la sesión de identidad salió approved, y null cuando no hay sesión.

data.inputsobject

Qué solicitud alimentó cada casilla: { identity, ine, curp, rfc, comprobante, email, phone, blacklist, judicial }. null = el expediente no tiene esa pieza. Un `extractDocument` que el motor NO etiquetó comprobante de domicilio (una nómina, un acta) no llena casilla: juzgar la regla de domicilio sobre un papel sin domicilio sería un verde que nadie midió.

data.policyobject

La política de verificación de TU organización bajo la que se juzgó este expediente: { version, required, lock_required, strict_nulls, request_override_ignored }. SIEMPRE presente, en producción y en sandbox. Se consulta y se cambia en GET/PUT /organization/verification-policy, sección `kyc`.

data.policy.strict_nullsboolean

true (DEFAULT) = un control de un componente EXIGIDO que no se pudo evaluar (`ok:null`) retiene el `pass` y deja el expediente en `review`. Un control de un componente que NO exiges no bloquea nada: si lee varias casillas, retiene sólo cuando tu política exige TODAS. Por eso un control cuya evidencia nunca se recolectó —ine_vigente sin INE ni Lista Nominal, la CURP de un pasaporte extranjero— queda en `null` sin retener, salvo que tu política exija sus casillas. false = los `null` se ignoran.

data.policy.lock_requiredboolean

true (DEFAULT) = la vara de completitud es la de tu organización y el `required_components` que mande un request se IGNORA. false = el request gana, un expediente a la vez.

data.policy.request_override_ignoredboolean

true = ESTA llamada SÍ mandó `required_components` y el candado lo dejó fuera. Nunca falla en silencio — si ves un `missing` que no esperabas, mira este campo primero.

request_idstring

Id del LogRequest de este expediente (queda registrado y es auditable).

statusstring

Siempre "success": el expediente es síncrono.

Example
{
  "data": {
    "overall": "review",
    "checks": [
      {
        "code": "identity_verdict",
        "ok": true,
        "detail": "identidad verificada (liveness y face match)",
        "sources": ["6622f8a1c1d2e3f4a5b6c7d8"],
        "critical": false,
        "holds_approval": false
      },
      {
        "code": "ine_curp_vs_renapo",
        "ok": true,
        "detail": "CURP INE LOGM900101MDFPRR02 vs CURP RENAPO LOGM900101MDFPRR02",
        "sources": ["6622f8a1c1d2e3f4a5b6c7d9", "6622f8a1c1d2e3f4a5b6c7da"],
        "critical": false,
        "holds_approval": false
      },
      {
        "code": "blacklist_clear",
        "ok": null,
        "detail": "falta la consulta de listas",
        "sources": [],
        "critical": false,
        "holds_approval": true
      }
    ],
    "missing": ["blacklist"],
    "required": ["identity", "curp", "blacklist"],
    "subject": {
      "name": "MARIA LOPEZ GARCIA",
      "curp": "LOGM900101MDFPRR02",
      "rfc": "LOGM900101ABA",
      "birth_date": "1990-01-01",
      "identity_verified": true
    },
    "inputs": {
      "identity": "6622f8a1c1d2e3f4a5b6c7d8",
      "ine": "6622f8a1c1d2e3f4a5b6c7d9",
      "curp": "6622f8a1c1d2e3f4a5b6c7da",
      "rfc": null,
      "comprobante": null,
      "email": null,
      "phone": null,
      "blacklist": null,
      "judicial": null
    },
    "policy": {
      "version": 1,
      "required": ["identity", "curp", "blacklist"],
      "lock_required": true,
      "strict_nulls": true,
      "request_override_ignored": false
    }
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7db",
  "status": "success"
}
2 endpoints

Politica de verificacion

Que tan estrictos son para TI los productos de documentos, e.firma y los DOS expedientes. Los umbrales de recencia, las reglas del checklist, los 30 controles del expediente KYB y los 19 del KYC dejaron de ser constantes nuestras: viven por organizacion. Cambiarla NO hace que nada rebote — los datos siempre se devuelven, lo que se mueve es el ESTATUS (`requirements` / `requirements_ok` de un documento —y con ellos su `decision`—, `overall` del expediente). Con `documents.decision_rules` dices ademas que hace la `decision` de un documento suelto con cada condicion: un documento falso o sospechoso, un requisito que falla, un PDF sin metadatos, un escaneo o un SAT que no confirma. Aqui vive tambien la retencion de las consultas directas: cuanto tiempo conservas el contenido de una consulta que nunca se guardo como cliente.

Sincrono. Autenticacion: header Authorization: Bearer <API_KEY> (o la sesion del dashboard). Los cambios aplican a partir de la siguiente verificacion; las respuestas ya emitidas no se recalculan, salvo la `decision` de un documento, que se lee con las `decision_rules` vigentes cada vez que consultas su estatus.

Consultar la politica de verificacion

GET/organization/verification-policy

Devuelve la politica que tus productos aplican hoy, lo que cambiaste tu, y los defaults de la plataforma.

Lo puede leer cualquier credencial de la organizacion: la llave de API (sandbox o produccion) o la sesion del dashboard. `policy` y `defaults` vienen recortados en el ejemplo por espacio; la respuesta real trae completos los 27 codigos de reglas, las seis `decision_rules`, los 30 controles del expediente KYB y los 19 del KYC. 401 sin credencial.

Request

No parameters.

curl -X GET https://api.singula.mx/organization/verification-policy \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
data.policyobject

La politica RESUELTA (defaults + tus cambios): lo que los productos aplican. { version, documents: { max_age_days, rules, require_digital_provenance, decision_rules }, fiel: { expiring_days }, kyb: { required_components, strict_nulls, lock_required, checks }, kyc: { required_components, strict_nulls, lock_required, checks, name_match_threshold }, retention: { direct_days } }.

data.policy.documents.max_age_daysobject

Umbral de recencia por familia de documento, en dias: { proof_of_address, constancia_situacion_fiscal, opinion_cumplimiento }. 90, 90 y 30 por defecto — la opinion 32-D vive un mes porque el SAT la reemite al momento. Trae ademas cuatro familias OPCIONALES que nacen en null: bank_statement, payroll_receipt, invoice y payment_receipt. En las tres ultimas null = sin regla de antiguedad y un numero topa la familia: un recibo de nomina, una factura o un comprobante de pago mas viejo incumple doc_issued_within_max_age. OJO: `bank_statement: null` no es «sin regla» — el estado de cuenta sigue bajo el umbral de proof_of_address, como siempre, y un numero le da un tope propio.

data.policy.documents.rulesobject

Cada uno de los 27 codigos del checklist -> true (se evalua) o false (no se evalua y no aparece en `requirements`). Todos en true por defecto; doc_issued_within_max_age y doc_digital_provenance solo corren cuando configuras lo que miden (un tope en payroll_receipt, invoice o payment_receipt; `require_digital_provenance` en true). Los codigos NO cambian de nombre aunque muevas un umbral.

data.policy.documents.require_digital_provenanceboolean

false por defecto. true exige origen digital verificable SOLO en los documentos que se emiten en digital (estado de cuenta bancario, constancia de situacion fiscal, opinion 32-D, factura CFDI, recibo de nomina y comprobante SPEI): un escaneo o una foto de esos incumple doc_digital_provenance. Nunca aplica a actas, poderes, INE ni recibos de servicios, cuyo escaneo es su forma normal. El `policy` que trae cada documento lo dice con `require_digital_provenance: true`.

data.policy.documents.decision_rulesobject

Que hace la `decision` de un documento suelto (GET /app/document/status/:id) cuando se cumple cada condicion. Son seis, con sus acciones y su default (el default es la decision de siempre): forged = `authenticity` forged (reject | review; default reject); suspicious = `authenticity` suspicious (accept | review | reject; default review); requirement_failed = `requirements_ok` false (review | reject; default reject); missing_metadata = un PDF cuyo analisis del archivo no encontro ni el programa que lo escribio ni su fecha de creacion; nunca una imagen, un documento sin analisis —el sandbox no corre ninguno— ni un analisis que no se pudo completar (accept | review | reject; default accept); scanned_document = un documento que se emite en digital —estado de cuenta, constancia, opinion 32-D, factura CFDI, recibo de nomina, comprobante SPEI— que llego como imagenes: un PDF hecho solo de imagenes o una foto (accept | review | reject | null; default null = automatico); sat_unconfirmed = el SAT no confirmo lo que hacia falta: un not_found blando de una opinion 32-D o de un CFDI, o una consulta al SAT que termino sin respuesta con un requisito que lee al SAT todavia abierto (accept | review | reject | null; default null = automatico). Un documento falso o un requisito que falla nunca se aceptan: relajar un requisito es apagarlo en `rules`. En null (automatico) nadie decide sobre el escaneo y un estado de cuenta escaneado sigue contando como sospechoso, como siempre; con accept, review o reject lo decide scanned_document y el escaneo deja de contar en suspicious. Igual con sat_unconfirmed con una accion explicita (accept, review o reject): el not_found blando lo decide esa regla y no suspicious; en null (automatico, el default) sigue contando en suspicious, como siempre. En accept, sat_unconfirmed solo perdona los requisitos que leen al SAT: si queda otro abierto, el documento va a review. Lo que el SAT CONTRADICE (una discrepancy —salvo con csf_sat_record_matches apagado—, un sello falso, un CFDI cancelado, un not_found de una constancia) siempre rechaza y no se configura. Gana la accion mas severa (reject > review > accept). Lo que devuelve este GET se puede mandar de vuelta tal cual al PUT: decide exactamente lo mismo.

data.policy.fiel.expiring_daysnumber

Holgura en dias por debajo de la cual una e.firma vigente se reporta como `expiring`. 15 por defecto.

data.policy.kyb.required_componentsarray<string>

Las casillas que el expediente KYB debe traer para poder aprobar. Existen ONCE —los siete documentos (csf, acta, poder, ine, comprobante, fiel, email) y las cuatro señales que la empresa pudo pagar aparte (blacklist, judicial, rfc, identity)— y por defecto se exigen los SIETE documentos.

data.policy.kyb.strict_nullsboolean

true por defecto: un control de un componente EXIGIDO que no se pudo evaluar (`ok:null`) retiene el `pass` y deja el expediente en `review`. false = los `null` se ignoran.

data.policy.kyb.lock_requiredboolean

true por defecto: `required_components` es decision de la organizacion y el que mande un request a kybExpediente se IGNORA (la respuesta lo dice en `policy.request_override_ignored`). false = el request gana, una llamada a la vez.

data.policy.kyb.checksobject

Cada uno de los 30 controles del expediente KYB -> { enabled, critical }. `enabled:false` lo saca de `checks` y del veredicto; `critical:false` hace que incumplirlo mande a revision en vez de reprobar. Por defecto todos encendidos, con la criticidad del perfil de alta de persona moral (de los cuatro nuevos, listas y judicial son criticos; RFC e identidad del representante, no).

data.policy.kyc.required_componentsarray<string>

Las casillas que el expediente KYC debe traer para poder aprobar, de las nueve que existen (identity, ine, curp, rfc, comprobante, email, phone, blacklist, judicial). Por defecto identity, curp y blacklist: identidad verificada, la foto de RENAPO detras de ella y las listas.

data.policy.kyc.strict_nullsboolean

true por defecto, misma regla que en el KYB: un control de una casilla EXIGIDA que no se pudo evaluar (`ok:null`) retiene el `pass` del expediente KYC.

data.policy.kyc.lock_requiredboolean

true por defecto: `required_components` es decision de la organizacion y el que mande un request a kycExpediente se IGNORA (la respuesta lo dice en `policy.request_override_ignored`).

data.policy.kyc.checksobject

Cada uno de los 19 controles del expediente KYC -> { enabled, critical }. Criticos por defecto: identity_verdict, identity_doc_vs_ine, ine_name_vs_renapo, ine_curp_vs_renapo, ine_vigente, identity_doc_vigente, blacklist_clear y judicial_clear. No criticos: identity_doc_dob_vs_renapo, form_vs_renapo, rfc_registered, rfc_vs_curp, comprobante_holder_vs_person, comprobante_recency, email_verified, phone_valid y los tres de RED (ip_country_vs_document, ip_not_tor, ip_not_hosting), que leen desde donde se conecto la persona: en false mandan a revision, nunca rechazan ni aprueban solos, y sin IP quedan en null sin retener nada. ine_name_vs_renapo, ine_curp_vs_renapo e ine_vigente leen la INE o, sin lectura de INE aparte, el documento y la Lista Nominal de la sesion de identidad. Desde MasterApi 1.128.0, identity_doc_vigente (el pasaporte de la sesion de identidad —desde 1.130.0 tambien la tarjeta de residente— no ha vencido; uno vencido reprueba y una tarjeta permanente no vence) e identity_doc_dob_vs_renapo (su fecha de nacimiento contra RENAPO; si no coincide, revision) juzgan ese documento, y con una INE quedan en null sin retener nada.

data.policy.kyc.name_match_thresholdnumber

Que tanto se tienen que parecer dos nombres para ser la MISMA persona en el expediente KYC. 0.8 por defecto —el mismo umbral que usa la verificacion de identidad para cotejar la INE contra RENAPO— y admite de 0.5 a 1. Un solo dial mueve todas las comparaciones de nombre del expediente y, desde 1.114.0, tambien las de PERSONA del expediente KYB: el apoderado contra la INE y contra el acta, y quien firma contra el poder. El cotejo que corrio la propia sesion de identidad contra RENAPO (umbral fijo de 80 %) solo decide cuando el expediente no trae el registro de RENAPO.

data.policy.retention.direct_daysnumber|null

Dias que conservas el CONTENIDO de una consulta directa (`source: "direct"`) que nunca se guardo como cliente. `null` por defecto = apagado, no se anonimiza nada. Con un numero, un trabajo diario reemplaza el request y el response de las mas viejas que N dias por { purged: true, purged_at }: la fila NUNCA se borra, conserva folio, herramienta, fecha y cargo, y tu estado de cuenta no cambia. Las consultas por customer y las directas que guardaste como cliente quedan fuera.

data.overridesobject

SOLO lo que tu organizacion cambio. {} = todo por default.

data.defaultsobject

La base de la plataforma, con la misma forma que `policy`. Sirve para pintar en una UI que campo esta cambiado y cual no.

Example
{
  "data": {
    "policy": {
      "version": 1,
      "documents": {
        "max_age_days": { "proof_of_address": 150, "constancia_situacion_fiscal": 90, "opinion_cumplimiento": 30, "bank_statement": null, "payroll_receipt": null, "invoice": null, "payment_receipt": null },
        "rules": { "csf_current_year": false, "csf_issued_within_90d": true },
        "require_digital_provenance": false,
        "decision_rules": { "forged": "reject", "suspicious": "review", "requirement_failed": "reject", "missing_metadata": "reject", "scanned_document": null, "sat_unconfirmed": null }
      },
      "fiel": { "expiring_days": 30 },
      "kyb": {
        "required_components": ["csf", "acta", "poder", "ine", "comprobante", "fiel", "email"],
        "strict_nulls": true,
        "lock_required": true,
        "checks": { "email_verified_institutional": { "enabled": false, "critical": false } }
      },
      "retention": { "direct_days": 90 }
    },
    "overrides": {
      "documents": { "max_age_days": { "proof_of_address": 150 }, "rules": { "csf_current_year": false }, "decision_rules": { "missing_metadata": "reject" } },
      "fiel": { "expiring_days": 30 },
      "kyb": { "checks": { "email_verified_institutional": { "enabled": false } } },
      "retention": { "direct_days": 90 }
    },
    "defaults": { "version": 1, "documents": { "max_age_days": { "proof_of_address": 90, "constancia_situacion_fiscal": 90, "opinion_cumplimiento": 30, "bank_statement": null, "payroll_receipt": null, "invoice": null, "payment_receipt": null }, "require_digital_provenance": false }, "retention": { "direct_days": null } }
  }
}

Cambiar la politica de verificacion

PUT/organization/verification-policy

Manda un PARCIAL: solo lo que mandes se guarda, el resto queda en el default. El cuerpo REEMPLAZA lo guardado, asi que PUT {} restablece todo.

Requiere una llave de PRODUCCION o una sesion de Admin/SU del dashboard: la politica es de la ORGANIZACION, no del ambiente, asi que una llave de sandbox responde 403. Manda SOLO el delta: el cuerpo reemplaza lo guardado, no se mezcla con ello, y PUT {} restablece todos los defaults. Las secciones validas son `documents`, `fiel`, `retention`, `kyb` y `kyc`. Validacion estricta — una seccion de primer nivel desconocida (`kycs` por `kyc`, o `retencion` por `retention`), una llave desconocida dentro de una seccion, un codigo de regla o de control que no existe (incluido uno del OTRO expediente), un umbral fuera de rango o no entero, un `name_match_threshold` fuera de [0.5, 1], un `strict_nulls`/`lock_required`/`require_digital_provenance` que no sea booleano, un null en proof_of_address, constancia_situacion_fiscal u opinion_cumplimiento, una condicion o una accion de `decision_rules` que no existe (incluidos `forged` o `requirement_failed` en accept y un null fuera de `scanned_document` y `sat_unconfirmed`), y un componente no reconocido responden 400 con el detalle; cuando hay 400 NO se escribe nada y tus overrides quedan como estaban. Una errata nunca debe PARECER aplicada.

Request
documents.max_age_daysobjectbody

{ proof_of_address?, constancia_situacion_fiscal?, opinion_cumplimiento? } en dias, entero de 1 a 3650 (esas tres no aceptan null: su regla se apaga en `rules`). Acepta ademas las familias opcionales { bank_statement?, payroll_receipt?, invoice?, payment_receipt? }: null (el default) o un entero de 1 a 3650. En payroll_receipt, invoice y payment_receipt, null = sin regla de antiguedad y un numero topa la familia: un recibo de nomina, una factura o un comprobante de pago mas viejo incumple doc_issued_within_max_age. En bank_statement, null deja al estado de cuenta bajo el umbral de proof_of_address, como siempre, y un numero le da un tope propio. Un documento que no reconocemos o un valor fuera de rango responde 400.

documents.require_digital_provenancebooleanbody

Default false. true exige origen digital verificable SOLO en los documentos que se emiten en digital (estado de cuenta, constancia, opinion 32-D, factura CFDI, recibo de nomina, comprobante SPEI): un escaneo o una foto de esos incumple doc_digital_provenance; nunca aplica a actas, poderes, INE ni recibos de servicios. Un valor que no sea booleano responde 400.

documents.decision_rulesobjectbody

{ <condicion>: <accion> }, que hace la `decision` de un documento suelto cuando se cumple cada condicion (que mide cada una: ver `data.policy.documents.decision_rules` en GET). forged: reject | review; suspicious: accept | review | reject; requirement_failed: review | reject; missing_metadata: accept | review | reject; scanned_document: accept | review | reject | null (null = automatico); sat_unconfirmed: accept | review | reject | null (null = automatico; una accion explicita, incluso review, le gana a `suspicious` cuando lo unico sospechoso es que el SAT no encontro el documento). Defaults (la decision de siempre): forged reject, suspicious review, requirement_failed reject, missing_metadata accept, scanned_document null, sat_unconfirmed null; una condicion que no mandes queda en su default. Una condicion o una accion que no existe, `forged: "accept"`, `requirement_failed: "accept"`, un null fuera de scanned_document y sat_unconfirmed o un valor que no sea objeto responden 400 con el motivo. Como el cuerpo REEMPLAZA lo guardado, un PUT sin `decision_rules` las regresa todas al default: si las usas, mandalas en cada escritura. La `policy.documents.decision_rules` que devuelve el GET se puede mandar tal cual: decide lo mismo.

documents.rulesobjectbody

{ <codigo del checklist>: boolean }. false = la regla NO se evalua, no aparece en `requirements` y no retiene `requirements_ok`. Codigos validos: csf_current_year, csf_issued_within_90d, csf_activity_page_present, csf_taxpayer_active, csf_not_in_sat_lists, csf_sat_record_matches, acta_has_notarial_cover, acta_has_ownership, acta_ubo_identified, acta_company_not_expired, acta_not_loose_clauses, acta_has_registry_evidence, acta_is_testimonio_or_certified_copy, acta_has_notary_seal_or_signature, poder_has_notarial_data, poder_faculties_legible, poder_not_expired, poder_apoderado_matches_declared, poder_is_testimonio_or_certified_copy, poa_issued_within_90d, poa_legible, poa_holder_matches_declared, poa_address_matches_declared, opinion_positive, opinion_issued_within_30d, doc_issued_within_max_age, doc_digital_provenance.

fiel.expiring_daysnumberbody

Entero de 0 a 365. Es la holgura bajo la cual la e.firma sale `expiring`; 0 = solo reprobar una ya vencida.

kyb.required_componentsarray<string>body

Subconjunto NO vacio de las once casillas [csf, acta, poder, ine, comprobante, fiel, email, blacklist, judicial, rfc, identity]. Es la vara de completitud del expediente KYB; el default son los siete DOCUMENTOS. Con `kyb.lock_required` encendido (el default) es la UNICA vara: el `required_components` de una llamada a kybExpediente se ignora.

kyb.strict_nullsbooleanbody

Default true: un control de un componente EXIGIDO que no se pudo evaluar (`ok:null`) retiene el `pass` y deja el expediente en `review` — se aprueba con evidencia, y una regla que nunca corrio no es evidencia. Ponlo en false para que los `null` se ignoren. Los controles de un componente que NO exiges nunca bloquean.

kyb.lock_requiredbooleanbody

Default true: la vara la pone esta politica y el `required_components` que mande un request se IGNORA (la respuesta lo avisa con `policy.request_override_ignored: true`). Ponlo en false para que una llamada pueda bajar la vara — un onboarding por etapas, un expediente a la vez.

retention.direct_daysnumber|nullbody

Entero de 1 a 3650 dias, o `null` para apagarlo (el default). Es la retencion del CONTENIDO de las consultas directas sin cliente: pasados esos dias, un trabajo diario deja la fila con { purged: true, purged_at } en lugar del request y el response, y conserva folio, herramienta, fecha y cargo. No borra filas, no toca las consultas por customer y no toca las directas que ya guardaste como cliente.

kyc.required_componentsarray<string>body

Subconjunto NO vacio de las nueve casillas del expediente KYC [identity, ine, curp, rfc, comprobante, email, phone, blacklist, judicial]. Default: identity, curp, blacklist. Con `kyc.lock_required` encendido (el default) es la UNICA vara y el `required_components` de una llamada a kycExpediente se ignora.

kyc.strict_nullsbooleanbody

Default true, misma regla que el KYB: un control de una casilla EXIGIDA que quedo en `ok:null` retiene el `pass` del expediente KYC y lo deja en `review`.

kyc.lock_requiredbooleanbody

Default true: la vara la pone esta politica y el `required_components` que mande un request a kycExpediente se IGNORA (la respuesta lo avisa con `policy.request_override_ignored: true`).

kyc.name_match_thresholdnumberbody

Numero entre 0.5 y 1 (default 0.8, el mismo umbral que usa la verificacion de identidad para cotejar la INE contra RENAPO). Es cuanto se tienen que parecer dos nombres para ser la misma persona en TODAS las comparaciones del expediente KYC y, desde 1.114.0, en las de persona del expediente KYB (apoderado contra INE y contra acta, firmante contra poder). El piso de 0.5 es a proposito: por debajo de la mitad de los tokens dos nombres no son la misma persona bajo ninguna lectura. Fuera de rango, no numerico o NaN responden 400.

kyc.checksobjectbody

{ <codigo del control>: { enabled?: boolean, critical?: boolean } }. Son 19 codigos validos: identity_verdict, identity_doc_vs_ine, ine_name_vs_renapo, ine_curp_vs_renapo, ine_vigente, identity_doc_vigente, identity_doc_dob_vs_renapo, blacklist_clear, judicial_clear, form_vs_renapo, rfc_registered, rfc_vs_curp, comprobante_holder_vs_person, comprobante_recency, email_verified, phone_valid, ip_country_vs_document, ip_not_tor, ip_not_hosting. Son criticos por default identity_verdict, identity_doc_vs_ine, ine_name_vs_renapo, ine_curp_vs_renapo, ine_vigente, identity_doc_vigente, blacklist_clear y judicial_clear. Los dos del pasaporte (desde MasterApi 1.128.0; desde 1.130.0 tambien de la tarjeta de residente) leen ese documento de la sesion de identidad y con una INE quedan en null sin retener nada: `{ "identity_doc_vigente": { "critical": false } }` manda un pasaporte o una tarjeta vencidos a revision en vez de rechazarlos. Los tres de red (desde donde se conecto la persona) nacen encendidos y no criticos: `{ "ip_not_hosting": { "enabled": false } }` quita ese control del expediente. ine_name_vs_renapo, ine_curp_vs_renapo e ine_vigente leen la INE o, sin lectura de INE aparte, el documento y la Lista Nominal de la sesion de identidad: con `{ "critical": false }` un desacuerdo manda a revision en vez de rechazar. Un codigo del OTRO expediente (p. ej. blacklist_company_clear, que es del KYB) responde 400: los catalogos no se mezclan.

kyb.checksobjectbody

{ <codigo del control>: { enabled?: boolean, critical?: boolean } }. Son 30 codigos validos. Cotejos originales (entre documentos y contra su fuente): csf_vs_acta_razon_social, csf_vs_acta_rfc, csf_vs_acta_objeto_actividad, poder_poderdante_vs_csf_razon_social, poder_apoderado_vs_ine_name, poder_apoderado_in_acta, comprobante_holder_vs_razon_social, comprobante_recent_90d, comprobante_address_vs_csf, fiel_rfc_vs_csf_rfc, fiel_active_not_expiring_15d, ine_vigente_y_listanominal, ine_image_quality, csf_sat_record_matches, csf_active_not_listed, email_verified_institutional. Por documento y anclajes de identidad: csf_requirements_ok, csf_authenticity, acta_requirements_ok, acta_authenticity, poder_requirements_ok, poder_authenticity, comprobante_requirements_ok, comprobante_authenticity, ine_renapo_match, fiel_latest_issued_active. Señales que la empresa ya pago (sus casillas son opcionales por default): blacklist_company_clear, judicial_company_clear, rfc_registered, representative_identity_verified. OJO con los de autenticidad: `critical` gobierna el veredicto `forged`; un `suspicious` se queda en revision aunque lo marques critico.

curl -X PUT https://api.singula.mx/organization/verification-policy \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "documents": {
    "max_age_days": { "proof_of_address": 150, "opinion_cumplimiento": 15 },
    "rules": { "csf_current_year": false },
    "decision_rules": { "missing_metadata": "reject", "scanned_document": "review" }
  },
  "fiel": { "expiring_days": 30 },
  "kyb": {
    "lock_required": false,
    "checks": { "email_verified_institutional": { "enabled": false } }
  },
  "kyc": {
    "required_components": ["identity", "curp", "ine", "blacklist"],
    "name_match_threshold": 0.85
  },
  "retention": { "direct_days": 90 }
}'
Response
data.policyobject

La politica resuelta despues del cambio — misma forma que en GET.

data.overridesobject

Eco de lo que quedo guardado (exactamente lo que mandaste).

data.defaultsobject

La base de la plataforma, sin cambios.

Example
{
  "data": {
    "policy": { "version": 1, "documents": { "max_age_days": { "proof_of_address": 150, "constancia_situacion_fiscal": 90, "opinion_cumplimiento": 30 } }, "fiel": { "expiring_days": 30 } },
    "overrides": {
      "documents": { "max_age_days": { "proof_of_address": 150 }, "rules": { "csf_current_year": false }, "decision_rules": { "missing_metadata": "reject", "scanned_document": "review" } },
      "fiel": { "expiring_days": 30 },
      "kyb": { "lock_required": false, "checks": { "email_verified_institutional": { "enabled": false } } },
      "retention": { "direct_days": 90 }
    },
    "defaults": { "version": 1, "fiel": { "expiring_days": 15 }, "kyb": { "strict_nulls": true, "lock_required": true }, "retention": { "direct_days": null } }
  }
}
3 endpoints

Clientes

Un cliente es el expediente sobre el que corren las herramientas: una persona física o una empresa. Una empresa y sus personas quedan RELACIONADAS con su rol —representante legal, apoderado, socio o beneficiario controlador—: una empresa tiene varias personas y una persona puede representar a varias empresas. La relación la crea sola la corrida KYC hija de un KYB, o se da de alta a mano, y el expediente KYB suelto la lee para resolver el control de la identidad del representante; el que cierra una corrida lee, desde 1.114.0, sólo a las hijas de ESA corrida. El `gender` de una persona (H, M o X) es `null` cuando no se capturó: nunca se asume uno. Con su CURP validada, el sexo que vale es el de RENAPO (`curp_validation.persona.sexo`); sin sexo, calcular su CURP (GET /app/curp/customer/:id) responde 409.

Síncronas. Autenticación: `Authorization: Bearer <API_KEY>` o el JWT de un usuario del panel, la misma familia que `POST /customer`. Las tres rutas trabajan dentro de tu organización y de tu ambiente: un cliente de otra organización o del otro ambiente responde 404.

Personas y empresas relacionadas

GET/customer/:id/relations

Las relaciones de un cliente. Sobre una empresa devuelve sus personas; sobre una persona, las empresas que representa. La misma ruta, y lo que cambia es el tipo del cliente que preguntaste.

No cobra. El expediente KYB suelto (`POST /app/kyb/expediente/customer/:id`) lee esta MISMA lista para el control `representative_identity_verified`, que no es crítico por default; el que cierra una corrida lee, desde 1.114.0, sólo a los representantes de ESA corrida —una persona relacionada por otra corrida, de cualquier fecha, no cuenta—. En los dos, desde 1.114.0, cada firmante (representante legal o apoderado) con `identity.verified` en `true` tiene que ser uno de los apoderados del poder o del acta, por CURP o por nombre con los dos apellidos: si no lo es, el control da `false` (revisión), y una KYC de firmante rechazada también da `false`.

Request
idstringpathrequired

El `customer_id`. Si es `type: "moral"` la respuesta viene en `data.people`; si es `type: "physical"`, en `data.companies`.

curl -X GET https://api.singula.mx/customer/{id}/relations \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
data.peopleobject[]

Sólo cuando preguntaste por una empresa (`type: "moral"`): las personas relacionadas con ella.

data.companiesobject[]

Sólo cuando preguntaste por una persona física (`type: "physical"`): las empresas que representa. Mismo shape de item que `data.people`.

data.relation_idstring

El id de la relación (campo de cada item de `data.people`/`data.companies`). Es el que se manda a `DELETE`; no es el id de ningún cliente.

data.customer_idstring

El cliente del otro lado de la relación: la persona (en `data.people`) o la empresa (en `data.companies`).

data.namestring

El nombre ya resuelto de ese cliente, para no tener que pedirlo aparte.

data.rolestring (enum: representante_legal|apoderado|socio|ubo)

Con qué papel está relacionada esa persona con esa empresa.

data.sourcestring (enum: flow|manual)

`flow` cuando la creó una corrida KYC hija de un KYB; `manual` cuando la diste de alta tú.

data.flow_session_idstring | null

La corrida hija que creó la relación. `null` en las manuales.

data.createdstring (ISO 8601)

Cuándo se creó la relación.

data.identityobject | null

La última verificación de identidad de esa persona: `{ verified, request_id, checked_at }`. `null` cuando nunca corrió una. Es lo que lee el control `representative_identity_verified` del expediente KYB suelto.

Example
{
  "data": {
    "people": [
      {
        "relation_id": "9a1c4f30-2b76-4d58-8e01-7c3f5a9d2e64",
        "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": "66f1a2b3c4d5e6f708192ac7",
          "checked_at": "2026-09-18T12:09:41.000Z"
        }
      }
    ]
  }
}

Relacionar una persona con una empresa

POST/customer/:id/relations

Da de alta a mano la relación entre una empresa y una de sus personas. Es para las altas que no llegaron por un flujo: las que sí, la crean solas.

No cobra. La terna `(empresa, persona, rol)` es ÚNICA: repetirla no duplica, actualiza la que ya estaba. 400 `{ code: "bad_role", allowed: [...] }` cuando `role` no es uno de `representante_legal|apoderado|socio|ubo`; 400 `{ code: "bad_relation" }` cuando los dos clientes no son de la misma organización y del mismo ambiente, cuando el de la ruta no es `type: "moral"` o cuando el del cuerpo no es `type: "physical"`. La misma persona puede quedar relacionada con varias empresas, y la misma empresa con varias personas.

Request
idstringpathrequired

El `customer_id` de la EMPRESA (`type: "moral"`).

person_customer_idstringbodyrequired

El `customer_id` de la persona física, de tu misma organización y del mismo ambiente.

rolestring (enum: representante_legal|apoderado|socio|ubo)bodyrequired

Con qué papel queda relacionada.

curl -X POST https://api.singula.mx/customer/{id}/relations \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "person_customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "role": "apoderado"
}'
Response
data.relation_idstring

El id de la relación recién creada (o de la que ya existía, si repetiste la misma terna).

data.customer_idstring

El `customer_id` de la persona (el `person_customer_id` que mandaste).

data.namestring

El nombre ya resuelto de esa persona.

data.rolestring (enum: representante_legal|apoderado|socio|ubo)

El rol que mandaste.

data.sourcestring

Siempre `manual` por esta ruta.

data.flow_session_idstring | null

Siempre `null` por esta ruta: la relación no la creó una corrida.

data.createdstring (ISO 8601)

Cuándo se creó la relación.

data.identityobject | null

La última verificación de identidad de esa persona: `{ verified, request_id, checked_at }`. `null` cuando nunca corrió una.

Example
{
  "data": {
    "relation_id": "b7e2b6b0-9a3c-4a0d-9f4e-5678c9d0e1f2",
    "customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
    "name": "MARIA LOPEZ GARCIA",
    "role": "apoderado",
    "source": "manual",
    "flow_session_id": null,
    "created": "2026-09-18T12:10:00.000Z",
    "identity": null
  }
}

Quitar una relación

DELETE/customer/:id/relations/:relationId

Deshace la relación entre una empresa y una persona. No borra a ninguno de los dos clientes ni sus consultas.

No cobra y no borra clientes: los dos expedientes se quedan con todo lo que ya pagaron. Lo que cambia es el expediente KYB de esa empresa, que deja de leer la identidad de esa persona en el control del representante. 404 si la relación no existe o no es de esa empresa.

Request
idstringpathrequired

El `customer_id` de la empresa.

relationIdstringpathrequired

El `relation_id` que devolvió el listado. No es el `customer_id` de la persona.

curl -X DELETE https://api.singula.mx/customer/{id}/relations/{relationId} \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
data.deletedboolean

`true` cuando la relación se quitó.

Example
{
  "data": {
    "deleted": true
  }
}
1 endpoints

Guardar como cliente

Convierte una consulta directa que ya hiciste en un cliente de tu organizacion: toma los datos de esa respuesta (CURP, RFC, nombre, correo, telefono...), crea el customer y le pone el customer_id a ese mismo registro. No cobra: la consulta ya se cobro cuando la hiciste.

Sincrono. Autenticacion: header Authorization: Bearer <API_KEY>, la misma llave con la que corriste la consulta.

Guardar una consulta directa como cliente

POST/app/customers/from-request/:requestId

Crea el customer con los datos extraidos de la respuesta de una consulta directa y lo liga a ese registro. Es el puente entre «solo queria el dato» y «esto es un expediente».

NO cobra. El motor de riesgo procesa esa consulta UNA sola vez, justo aqui (una consulta directa no lo corre por diseno). Lo unico que cambia en el registro es el `customer_id`: el `source` se queda en "direct", el cargo ya aplicado no se toca y el estado de cuenta no se recalcula. Como la fila deja de estar sin cliente, tampoco entra en la retencion de consultas directas (`retention.direct_days` de la politica de verificacion). Errores: 404 si el `request_id` no existe, 403 si existe pero es de otra organizacion, 409 si esa consulta ya tiene cliente, y 400 en cuatro casos — `request_id` mal formado, la consulta es de OTRO ambiente que tu llave (una de sandbox no puede guardar una de produccion, ni al reves), la consulta todavia no termina, y la respuesta no trae datos suficientes para crear un cliente (pasalos en `overrides`).

Request
requestIdstringpathrequired

El `request_id` que devolvio la consulta directa. Debe ser de tu organizacion, del mismo ambiente que tu API key, tener `source: "direct"` y no tener cliente todavia.

typestring (enum: physical|moral)body

Que clase de cliente crear. Opcional: por defecto se deduce de la consulta (un RFC de 12 caracteres, una busqueda de persona moral o una investigación de inteligencia de empresa —`subject: "company"`, aunque no traiga razón social ni RFC— son empresa).

overridesobjectbody

Campos que quieres fijar o corregir a mano sobre lo que se extrajo: name, last_name, mothers_last_name, curp, rfc, email, phone... Lo que mandes gana sobre lo extraido de la respuesta.

curl -X POST https://api.singula.mx/app/customers/from-request/{requestId} \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "type": "physical",
  "overrides": { "email": "[email protected]" }
}'
Response
dataobject

El customer creado, con la misma forma que devuelve el endpoint de customers: id, type y los campos que se pudieron llenar desde la consulta.

data.idstring

Id del cliente. Usalo en las rutas .../customer/:id de cualquier producto.

request_idstring

El mismo `request_id` que mandaste. Ese registro ahora trae `customer_id`; su `source` sigue siendo "direct" porque asi se consulto, y eso no se reescribe.

Example
{
  "data": {
    "id": "6622f8a1c1d2e3f4a5b6c7d9",
    "type": "physical",
    "name": "Juan",
    "last_name": "Perez",
    "mothers_last_name": "Gomez",
    "curp": "PEGJ850412HDFRMN09",
    "created": "2026-09-14T18:35:00.000Z"
  },
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8"
}