The whole API,
in one place.

Fifteen Mexican identity and document verification products. Every call explained: what you send, what comes back, and copy-paste code.

Your first call
curl -X POST https://api.singula.mx/app/curp \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "curp": "GAHM900315MDFRRL09" }'
base api.singula.mxauth Bearer sk_live_…Swagger

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 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.

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, OSINT, the document engine, e.firma and the email code. Still customer-only: the risk engine, the KYB dossier and identity verification — they do not exist without a file.

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.

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.

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).

Obtener CURP (datos sueltos, sin customer)

POST/app/curp

Síncrono: el CURP viene en la respuesta al instante. para eso usa /app/curp/validate). 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.

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 obtenido de 18 caracteres. En sandbox (sk_test_) devuelve el valor fijo 'TEST_CURP'.

request_idstring

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

Example
{
  "data": "PEGJ900101HJCRZN01",
  "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.

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 (H|M)

(Resultado final) Sexo registrado en RENAPO.

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. Shape: { confidence, passed, fields{...}, validated_at }.

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. 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"
}
3 endpoints

INE (extracción)

Extrae por OCR 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.

Asíncrono. El POST responde de inmediato con { request_id, status: "processing" } antes de que corra cualquier OCR o validación. El Worker descarga las imágenes, hace OCR en identity-verification-api y (cuando el modelo inferido es C/D/E/F/G/H y pasan los filtros de confianza/calidad) valida contra listanominal.ine.mx vía Bright Data. Cuando la cadena termina se dispara UN webhook a la URL configurada por la organización. 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.

Enviar INE para extracción (consulta directa)

POST/app/ine-extraction

Sube el anverso y el reverso de una INE/IFE por multipart sin crear un customer; devuelve el request_id de inmediato y el resultado se consulta o llega por webhook igual que siempre.

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. Es multipart/form-data, no JSON. El estado y el resultado se leen con el MISMO endpoint de siempre: GET /app/ine-extraction/status/:requestId (nunca fue por customer). Errores: 400 si falta ine_front o ine_back, 401, 402, 500 si falla la subida a S3.

Request
ine_frontfile (binary)formrequired

Imagen del ANVERSO (frente) de la credencial INE/IFE. JPEG/PNG.

ine_backfile (binary)formrequired

Imagen del REVERSO (atrás) de la credencial INE/IFE. JPEG/PNG.

min_ocr_confidencenumberform

Umbral 0..1 de confianza del OCR por debajo del cual NO se consulta la Lista Nominal. Default 0.5; manda 0 para desactivar el filtro.

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

statusstring

Siempre 'processing': el resultado final llega por webhook y por GET /app/ine-extraction/status/:requestId.

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

Enviar INE para extracción (con customer)

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

Sube el anverso y el reverso de una INE/IFE por multipart; encola el trabajo de OCR + validación en Lista Nominal y devuelve un request_id de inmediato.

El CreditGuard verifica saldo por adelantado (402 'Insufficient credits' si no alcanza); Errores: 400 si falta ine_front o ine_back; 404 si el customer no existe en ese entorno; 401 API key inválida; 500 si falla la subida a S3 o el encolado (el LogRequest queda en error). Es multipart/form-data, no JSON.

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_frontfile (binary)formrequired

Imagen del ANVERSO (frente) de la credencial INE/IFE. Campo de archivo en el multipart. JPEG/PNG.

ine_backfile (binary)formrequired

Imagen del REVERSO (atrás) de la credencial INE/IFE. Campo de archivo en el multipart. JPEG/PNG.

min_ocr_confidencenumberform

Umbral 0..1 de confianza del OCR por debajo del cual NO se hace la consulta a Lista Nominal (se ahorra el scrape). Default 0.5. Envía 0 para desactivar el filtro. Valores no numéricos se ignoran y se usa el default.

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

ID de la solicitud (id del LogRequest). Úsalo para consultar el estado en GET .../status/:requestId o para casar el webhook.

statusstring

Siempre 'processing' en esta respuesta. El estado final llega por webhook y por GET .../status.

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: datos de OCR, 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 extracción se envía con /customer/:id, 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 devuelto por el POST de envío. 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 por el Worker. Vacío ({}) hasta que completa. Trae 'extract' (salida del OCR) 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' = OCR demasiado ambiguo.

response.extract.extracted_dataobject

Campos crudos leídos por OCR (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 }.

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, detail }] }. 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.

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.submit_groupstring|null

Grupo del formulario de Lista Nominal usado (C/D/E).

response.listanominal.messagestring

Resumen legible del veredicto.

response.listanominal.queried_atstring

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

response.listanominal.duration_msnumber|null

Duración del round-trip a Bright Data. null cuando se rechazó por filtro previo (no se consultó).

filesobject

URLs firmadas (TTL 10 min) de las fotos subidas para mostrarlas en UI: { ine_front, ine_back }. Puede venir vacío ({}) si S3 falla o faltan las llaves.

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", "detail": "specular highlight over 12% of the frame" }] },
        "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",
      "submit_group": "E",
      "message": "Tu credencial es vigente",
      "queried_at": "2026-09-03T18:30:12Z",
      "duration_ms": 28341
    }
  },
  "files": {
    "ine_front": "https://s3.amazonaws.com/singula-kyc/...signed...",
    "ine_back": "https://s3.amazonaws.com/singula-kyc/...signed..."
  },
  "created_at": "2026-09-03T18:29:40.000Z"
}
11 endpoints

Verificación de identidad (KYC)

Verifica la identidad de una persona con su INE o pasaporte más una selfie con prueba de vida, y regresa un veredicto (verified / partial / rejected / failed) con los datos extraídos del documento.

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. 3) El veredicto llega por el webhook identity.completed (sobre firmado { id, type, created, data }, con un follow-up informativo identity.listanominal.completed), 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
}

Enviar documentos (público, por token)

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

Endpoint público que recibe las imágenes de la verificación (multipart/form-data), las sube a S3, encola el procesamiento y responde de inmediato status: 'processing'. Se autentica con el token del link, no con API key.

El tipo de documento se infiere del set completo (este envío + lo pre-subido con upload/:token): INE requiere ine_front + ine_back; pasaporte requiere passport_image; la selfie siempre es obligatoria. Es idempotente: un doble envío solo encola una vez (el segundo recibe 409). Errores: 400 si falta un archivo requerido, 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.

Request
tokenstringpathrequired

verification_token del link creado.

ine_frontfile (image)form

Frente de la INE. Requerido para verificación tipo INE (a menos que ya se haya subido antes vía upload/:token).

ine_backfile (image)form

Reverso de la INE. Requerido para verificación tipo INE.

passport_imagefile (image)form

Imagen del pasaporte. Requerido para verificación tipo PASSPORT (alternativa a la INE).

selfiefile (image)form

Selfie de la persona. Siempre requerida (aquí o en un upload previo).

liveness_framesfile[] (hasta 30)form

Cuadros de prueba de vida que responden al reto (challenge) de la sesión.

curl -X POST https://api.singula.mx/app/identity-verification/submit/{token} \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d 'multipart/form-data:
  ine_front=@ine_front.jpg
  ine_back=@ine_back.jpg
  [email protected]
  liveness_frames=@frame_0.jpg
  liveness_frames=@frame_1.jpg'
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"
}

Subida progresiva de assets (público, por token)

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

Endpoint público que permite subir cada foto a medida que se captura (INE, selfie, cuadros de liveness) para que el submit final sea casi instantáneo.

Es idempotente y libre de huérfanos: reenviar un asset sobrescribe el mismo objeto en S3 y los liveness_frames no se duplican. el trabajo solo se lanza en submit/:token. Errores: 400 si no se envía ningún archivo, 404 token no encontrado, 409 ya enviado, 410 token expirado. Se autentica por token en la URL, no con API key.

Request
tokenstringpathrequired

verification_token del link creado.

frame_indexnumber (string en form)body

Índice global del primer liveness_frame de esta llamada (para numerar los cuadros correctamente entre subidas). Default 0.

ine_frontfile (image)form

Frente de la INE.

ine_backfile (image)form

Reverso de la INE.

passport_imagefile (image)form

Imagen del pasaporte.

selfiefile (image)form

Selfie de la persona.

liveness_framesfile[] (hasta 30)form

Uno o más cuadros de prueba de vida.

curl -X POST https://api.singula.mx/app/identity-verification/upload/{token} \
  -H "Authorization: Bearer $SINGULA_API_KEY" \
  -H "Content-Type: application/json" \
  -d 'multipart/form-data:
  frame_index=0
  ine_front=@ine_front.jpg
  liveness_frames=@frame_0.jpg
  liveness_frames=@frame_1.jpg'
Response
uploadedstring[]

Lista de slots recibidos y almacenados en esta llamada (p. ej. ['ine_front', 'liveness_frames']).

Example
{
  "uploaded": ["ine_front", "liveness_frames"]
}

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.

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.extracted_dataobject

Los datos leídos del documento: nombre, apellidos, CURP, clave de elector, fecha de nacimiento, vigencia (INE) o los campos del MRZ (pasaporte).

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.

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.

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": "JUAN",
      "apellido_paterno": "PEREZ",
      "apellido_materno": "LOPEZ",
      "curp": "PELJ900101HDFRPN07",
      "fecha_nacimiento": "1990-01-01",
      "vigencia": "2030"
    },
    "checks": [
      {
        "check_name": "curp_validation",
        "passed": true,
        "confidence": 1.0
      },
      {
        "check_name": "mrz_check_digits",
        "passed": true,
        "confidence": 1.0
      }
    ],
    "face_result": {
      "match_score": 0.82,
      "match_decision": true,
      "liveness_score": 0.91,
      "liveness_decision": true
    }
  },
  "external_id": "ord_123",
  "metadata": {
    "plan": "pro"
  },
  "submitted_at": "2026-09-11T12:00:00.000Z",
  "completed_at": "2026-09-11T12:03:00.000Z",
  "verification": {
    "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.

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.extracted_dataobject

Los datos leídos del documento: nombre, apellidos, CURP, clave de elector, fecha de nacimiento, vigencia (INE) o los campos del MRZ (pasaporte).

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.

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.

filesobject (slot → url)

Mapa de slot a URL firmada: ine_front, ine_back, passport_image, 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.

data.filesobject (slot to url)

Mapa de slot a URL firmada: ine_front, ine_back, passport_image, 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 | 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. Errores: 400 cuerpo inválido, 401 llave inválida, 402 sin saldo, 503 si el servicio de listas no está disponible.

Request
namestringbodyrequired

Nombre(s) de pila de la persona, o el nombre comercial / razón social cuando cribas a una empresa.

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.

mothers_last_namestringbody

Apellido materno.

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); 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) Requiere JUDICIAL_SEARCH_BASE_URL configurado en el backend; si falta, responde 503. (5) Errores: 401 sin/mal token, 402 sin créditos, 409 'Customer not found', 503 servicio no disponible.

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 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'). Errores: 400 nombre vacío, 401, 402, 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.

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; 503 si el servicio judicial no está configurado o falla el proveedor. Con API key de sandbox/dev devuelve un mock fijo ('clean').

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; 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 (Holehe) 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.

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.

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.

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.

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

Intel (Inteligencia de persona)

Corre una busqueda OSINT sobre la huella digital publica de una persona (web, noticias, imagenes, LinkedIn y otras redes) y, en el tier Premium, agrega una sintesis narrativa de perfil tipo background check.

Asincrono via webhook. En PRODUCCION (API key live) el POST responde de inmediato con `request_id` y `data: {}`; el resultado completo se entrega despues por el webhook configurado de tu organizacion (con `{ request_id, tool_type, status }`). No existe endpoint de polling dedicado para Intel — el unico `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 (datos mock con el mismo shape que produccion), y ademas dispara el webhook. El shape del resultado es identico en ambos entornos.

Intel basico (consulta directa)

POST/app/intel

Corre la busqueda OSINT sobre una persona mandando su nombre en el cuerpo, 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. A diferencia de la variante por customer, los datos de contexto NO se guardan en ningun datameta: valen solo para esta consulta. En produccion 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
namestringbodyrequired

Nombre(s) de pila de la persona a investigar.

last_namestringbodyrequired

Apellido paterno. Obligatorio: la busqueda directa necesita nombre y apellido paterno.

mothers_last_namestringbody

Apellido materno.

companystringbody

Empresa donde trabaja la persona.

job_titlestringbody

Puesto o cargo.

citystringbody

Ciudad de residencia.

statestringbody

Estado o region.

countrystringbody

Codigo de pais ISO-2 (MX, AR, US, ...). Default semantico: MX.

emailstringbody

Correo conocido de la persona.

phonestringbody

Telefono conocido.

addressstringbody

Direccion conocida.

nationalitystringbody

Nacionalidad.

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

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

Example
{
  "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
  "data": {
    "tier": "basic",
    "web_results": "(misma forma que la variante por customer)",
    "news": "(...)",
    "images": "(...)",
    "linkedin_profiles": "(...)",
    "summary": null
  }
}

Intel Premium (consulta directa)

POST/app/intel-premium

El mismo cuerpo que Intel basico, con la sintesis narrativa de perfil (deep research) del tier Premium.

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 tool_type del registro y el cargo son los del tier Premium, separados del basico para analitica y facturacion.

Request
namestringbodyrequired

Nombre(s) de pila de la persona a investigar.

last_namestringbodyrequired

Apellido paterno. Obligatorio: la busqueda directa necesita nombre y apellido paterno.

mothers_last_namestringbody

Apellido materno.

companystringbody

Empresa donde trabaja la persona.

job_titlestringbody

Puesto o cargo.

citystringbody

Ciudad de residencia.

statestringbody

Estado o region.

countrystringbody

Codigo de pais ISO-2 (MX, AR, US, ...). Default semantico: MX.

emailstringbody

Correo conocido de la persona.

phonestringbody

Telefono conocido.

addressstringbody

Direccion conocida.

nationalitystringbody

Nacionalidad.

curl -X POST https://api.singula.mx/app/intel-premium \
  -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."
}'
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": {
    "tier": "premium",
    "summary": "(sintesis narrativa, igual que la variante por customer)",
    "work_history": "(...)",
    "digital_footprint": "medium",
    "sources": "(...)"
  }
}

Intel basico (huella digital publica)

POST/app/intel/customer/:id

Corre una busqueda OSINT basica (solo busqueda / Serper) sobre la huella digital publica del customer: resultados web, noticias con sentimiento, imagenes y perfiles de redes sociales.

puede tener descuento por organizacion). El CreditGuard exige saldo suficiente o devuelve 402 'Insufficient credits'. Async: en produccion el POST responde YA con { request_id, data: {} } y el resultado llega por webhook; en sandbox la respuesta es inmediata y completa. 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) los campos summary/linkedin/work_history/education/companies/digital_footprint/sources y phone_lookup NO se llenan en este tier (usa Intel Premium para eso).

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 busqueda (nombre, apellidos, CURP, RFC, fecha de nacimiento, genero) se toman de ese customer.

companystringbody

Empresa donde trabaja la persona. Ayuda a desambiguar y a enriquecer la busqueda.

job_titlestringbody

Puesto o cargo de la persona.

citystringbody

Ciudad de residencia. Mejora la desambiguacion de homonimos en noticias.

statestringbody

Estado o region. Mejora la desambiguacion de homonimos en noticias.

countrystringbody

Codigo de pais ISO-2 (MX, AR, US...). Default semantico: MX.

emailstringbody

Correo de la persona (contexto adicional para la busqueda).

phonestringbody

Telefono de la persona. En el tier basico solo aporta contexto; el lookup de telefono (phone_lookup) solo se corre en Premium.

addressstringbody

Domicilio de la persona (contexto adicional).

nationalitystringbody

Nacionalidad de la persona (ej. 'Mexicana').

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.web_resultsarray<object>

Resultados web generales. Cada item: { title, url, snippet, domain }.

data.newsarray<object>

Noticias detectadas. Cada item: { position, title, source, date, date_utc, url, snippet, sentiment (positive|neutral|negative), relevance_score (0..1), matches_query }.

data.imagesarray<object>

Imagenes encontradas. Cada item: { url, source, title, domain }.

data.linkedin_profilesarray<object>

Perfiles de LinkedIn detectados en la busqueda. Cada item: { title, url, snippet }.

data.social_profilesarray<object>

Perfiles en otras redes. Cada item: { title, url, snippet, platform }.

data.knowledge_graphobject|null

Knowledge graph de Google si existe, si no null.

data.summarystring|null

Sintesis narrativa de perfil. Siempre null en el tier basico (solo se llena en Premium).

data.linkedinobject|null

LinkedIn enriquecido por IA. null en basico (solo 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>

Citas/fuentes de la sintesis IA. [] en basico (solo 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",
    "web_results": [
      { "title": "Juan Perez - Perfil publico", "url": "https://example.com/perfil", "snippet": "Gerente de Ventas en ACME...", "domain": "example.com" }
    ],
    "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 }
    ],
    "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" }
    ],
    "social_profiles": [
      { "title": "Juan Perez", "url": "https://instagram.com/juanperez", "snippet": null, "platform": "instagram" }
    ],
    "knowledge_graph": null,
    "summary": null,
    "linkedin": null,
    "work_history": [],
    "education": [],
    "companies": [],
    "digital_footprint": null,
    "sources": []
  }
}

Intel Premium (dossier narrativo + deep research)

POST/app/intel-premium/customer/:id

Mismo flujo que Intel basico pero agrega una sintesis narrativa de perfil con deep research (summary, LinkedIn enriquecido, historial laboral, empresas, huella digital) y, si hay telefono, phone_lookup. Util para un dossier tipo background check.

puede tener descuento por organizacion). El CreditGuard exige saldo o devuelve 402 'Insufficient credits'. Async: en produccion el POST responde YA con { request_id, data: {} } y el resultado (con la sintesis + deep research) llega por webhook; en sandbox es inmediato y completo. Mismo contrato que Intel basico: mismos path/query/body y mismo shape de respuesta — la unica diferencia es que Premium llena los campos enriquecidos (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; (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 busqueda (nombre, apellidos, CURP, RFC, fecha de nacimiento, genero) se toman de ese customer.

companystringbody

Empresa donde trabaja la persona. Ayuda a la sintesis de perfil y a la busqueda.

job_titlestringbody

Puesto o cargo de la persona.

citystringbody

Ciudad de residencia. Mejora la desambiguacion de homonimos.

statestringbody

Estado o region. Mejora la desambiguacion de homonimos.

countrystringbody

Codigo de pais ISO-2 (MX, AR, US...). Default semantico: MX.

emailstringbody

Correo de la persona (contexto adicional para el enriquecimiento).

phonestringbody

Telefono de la persona. Si se proporciona (o ya esta en el customer), Premium corre el phone_lookup y lo incluye en la respuesta.

addressstringbody

Domicilio de la persona (contexto adicional).

nationalitystringbody

Nacionalidad de la persona (ej. 'Mexicana').

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.web_resultsarray<object>

Resultados web generales. Cada item: { title, url, snippet, domain }.

data.newsarray<object>

Noticias detectadas. Cada item: { position, title, source, date, date_utc, url, snippet, sentiment, relevance_score (0..1), matches_query }.

data.imagesarray<object>

Imagenes encontradas. Cada item: { url, source, title, domain }.

data.linkedin_profilesarray<object>

Perfiles de LinkedIn detectados en la busqueda. Cada item: { title, url, snippet }.

data.social_profilesarray<object>

Perfiles en otras redes. Cada item: { title, url, snippet, platform }.

data.knowledge_graphobject|null

Knowledge graph de Google si existe, si no null.

data.summarystring|null

Sintesis narrativa de perfil generada por IA (deep research). Poblado en Premium.

data.linkedinobject|null

LinkedIn enriquecido por IA: { url, title, company, photo_url }. Puede ser null si no se encontro.

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>

Citas/fuentes de la sintesis IA.

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",
    "web_results": [
      { "title": "Juan Perez - Perfil publico", "url": "https://example.com/perfil", "snippet": "Gerente de Ventas en ACME...", "domain": "example.com" }
    ],
    "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 }
    ],
    "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" }
    ],
    "social_profiles": [
      { "title": "Juan Perez", "url": "https://instagram.com/juanperez", "snippet": null, "platform": "instagram" }
    ],
    "knowledge_graph": null,
    "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
  }
}
15 endpoints

Motor de Documentos

Sube cualquier documento (PDF o imagen) 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. 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.

Todo es asincrono. La carga (POST de proxy) o el paso /complete (carga directa) responden de inmediato con {request_id, status:"processing"}; nunca traen 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 simple de una sola fase cierra en un solo webhook de exito sin sub-evento. 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.

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.

Extraer documento (consulta directa)

POST/app/document/extract

Sube el archivo en una sola llamada multipart, sin customer: el motor lo clasifica y extrae sus datos de forma asincrona.

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 cargo se aplica cuando la extraccion COMPLETA, no al subir. El estado se consulta con el MISMO GET /app/document/status/{requestId} de siempre. Para archivos grandes o alto volumen usa /app/document/init.

Request
filebinaryformrequired

El documento a analizar: PDF o imagen (jpeg, png, webp, heic, tiff). Hasta 50 MB.

passwordstringform

Contrasena para abrir un PDF cifrado. Se reenvia al motor y nunca se almacena.

declaredstring (JSON)form

JSON en texto con valores que YA conoces del titular para que el motor los coteje. Campos: rfc, curp, birthdate, holder, address{street,zip,city}.

analyze_tamperbooleanform

true corre ademas el analisis forense de manipulacion (ELA/metadatos). Agrega latencia; por defecto false.

curl -X POST https://api.singula.mx/app/document/extract \
  -H "Authorization: Bearer $SINGULA_API_KEY"
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.

statusstring

Siempre "processing": el resultado llega por webhook o por GET /app/document/status/{requestId}.

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

Verificar CSF (consulta directa)

POST/app/document/csf

Sube 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. Espejo exacto de /app/document/customer/{customerId}/csf: mismo cotejo contra el SAT, mismo `validation`, mismo webhook.

Request
filebinaryformrequired

La constancia en PDF o imagen. Hasta 50 MB.

passwordstringform

Contrasena del PDF, si esta cifrado.

declaredstring (JSON)form

Valores que ya conoces del titular para cotejar (rfc, holder, address...).

analyze_tamperbooleanform

Corre ademas el analisis forense de manipulacion.

curl -X POST https://api.singula.mx/app/document/csf \
  -H "Authorization: Bearer $SINGULA_API_KEY"
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.

statusstring

Siempre "processing".

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

Verificar poder (consulta directa)

POST/app/document/poder

Sube 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. Espejo exacto de /app/document/customer/{customerId}/poder.

Request
filebinaryformrequired

El poder en PDF o imagen. Hasta 50 MB.

passwordstringform

Contrasena del PDF, si esta cifrado.

declaredstring (JSON)form

Valores que ya conoces (por ejemplo el apoderado declarado) para cotejar.

analyze_tamperbooleanform

Corre ademas el analisis forense de manipulacion.

curl -X POST https://api.singula.mx/app/document/poder \
  -H "Authorization: Bearer $SINGULA_API_KEY"
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.

statusstring

Siempre "processing".

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

Extraer documento — carga directa (1/2), consulta directa

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. El paso 2/2 no cambia y NO es por customer: POST /app/document/request/{requestId}/complete confirma la carga e inicia el analisis. content_type no soportado -> 400.

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 — carga directa (1/2), consulta directa

POST/app/document/csf/init

La URL S3 prefirmada para subir una constancia 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. Cierra igual que las demas cargas directas: POST /app/document/request/{requestId}/complete.

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 — carga directa (1/2), consulta directa

POST/app/document/poder/init

La URL S3 prefirmada para subir un poder notarial 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. Cierra igual: POST /app/document/request/{requestId}/complete.

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 (carga directa por proxy)

POST/app/document/customer/{customerId}

Sube el archivo en una sola llamada multipart; el motor lo clasifica y extrae sus datos de forma asincrona.

El cargo se aplica cuando la extraccion COMPLETA, no al subir. Ideal para archivos chicos / integracion de una sola llamada; para archivos grandes o alto volumen usa el flujo de carga directa /init + /complete (la API no proxea los bytes). El CreditGuard valida saldo antes de aceptar.

Request
customerIdstringpathrequired

Id del customer (persona/empresa a la que pertenece el documento) que ya creaste en tu organizacion. Debe existir en el mismo ambiente de tu API key (sandbox vs produccion).

filebinaryformrequired

El documento a analizar: PDF o imagen (jpeg, png, webp, heic, tiff). Hasta 50 MB.

passwordstringform

Contrasena para abrir un PDF cifrado. Se reenvia al motor y nunca se almacena.

declaredstring (JSON)form

JSON en texto con valores que YA conoces del titular, para que el motor los coteje contra lo extraido. Campos aceptados: rfc, curp, birthdate, holder, address{street,zip,city}. Ej: {"holder":"MARIA LOPEZ","address":{"zip":"06600"}}.

analyze_tamperbooleanform

Si es true (tambien acepta "true" o "1"), corre ademas el analisis forense de manipulacion (ELA/metadatos). Agrega latencia; por defecto false.

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

Id de la solicitud (LogRequest). Usalo para consultar GET .../status o para casar el webhook.

statusstring

Siempre "processing": el resultado final llega por webhook / GET .../status.

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

Verificar CSF (carga directa por proxy)

POST/app/document/customer/{customerId}/csf

Producto enfocado: sube una Constancia de Situacion Fiscal y el motor la extrae Y la valida contra el validador publico del SAT (verdicto + situacion).

El resultado agrega un objeto validation (verdicto vs SAT) y dispara el evento document.validated. No expone analyze_tamper (solo password y declared).

Request
customerIdstringpathrequired

Id del customer, en el mismo ambiente que tu API key.

filebinaryformrequired

La Constancia de Situacion Fiscal (PDF o imagen). Hasta 50 MB.

passwordstringform

Contrasena para un PDF cifrado. Se reenvia al motor, nunca se guarda.

declaredstring (JSON)form

JSON en texto con valores conocidos a cotejar (rfc, curp, holder, address{...}). Mismo formato que en el extractor generico.

curl -X POST https://api.singula.mx/app/document/customer/{customerId}/csf \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

Id de la solicitud. Consulta GET .../status o espera el webhook.

statusstring

Siempre "processing".

Example
{
  "request_id": "6710ab22ccddeeff00112233",
  "status": "processing"
}

Verificar poder notarial (carga directa por proxy)

POST/app/document/customer/{customerId}/poder

Producto enfocado: sube un poder notarial (escritura) y el motor lo clasifica, extrae quien otorga, quien recibe y con que facultades, y devuelve su checklist de alta.

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. No expone analyze_tamper (solo password y declared).

Request
customerIdstringpathrequired

Id del customer (normalmente la empresa poderdante), en el mismo ambiente que tu API key.

filebinaryformrequired

El poder notarial / escritura (PDF o imagen). Hasta 50 MB.

passwordstringform

Contrasena para un PDF cifrado. Se reenvia al motor, nunca se guarda.

declaredstring (JSON)form

JSON en texto con valores conocidos a cotejar. En un poder el mas util es holder = el nombre del apoderado que esperas: alimenta consistency.holder y el requisito poder_apoderado_matches_declared.

curl -X POST https://api.singula.mx/app/document/customer/{customerId}/poder \
  -H "Authorization: Bearer $SINGULA_API_KEY"
Response
request_idstring

Id de la solicitud. Consulta GET /app/document/status/{requestId} o espera el webhook.

statusstring

Siempre "processing".

Example
{
  "request_id": "6710ab22ccddeeff00112244",
  "status": "processing"
}

Extraer documento — carga directa (1/2): obtener URL S3 firmada

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.

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 — carga directa (1/2): obtener URL S3 firmada

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 de dos pasos que /init; Luego llama /request/{requestId}/complete.

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 — carga directa (1/2): obtener URL S3 firmada

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 de dos pasos que /init: sube el archivo a S3 con el POST prefirmado y luego llama /app/document/request/{requestId}/complete.

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>"
    }
  }
}

Carga directa (2/2): confirmar carga e iniciar procesamiento

POST/app/document/request/{requestId}/complete

Confirma que el objeto ya esta en S3 y encola la extraccion; sirve tanto para extractDocument como para verifyCsf (el producto se fijo en /init). 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.

Request
requestIdstringpathrequired

El request_id que te devolvio /init o /csf/init.

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}).

analyze_tamperbooleanbody

Si true, corre analisis forense de manipulacion. Por defecto false. (Aplica al extractor generico.)

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 y, dentro de response, el tipo de documento clasificado y los datos extraidos (vista publica, sin metadatos internos).

Sin costo (consultar tu propia solicitud). Vista PUBLICA: se ocultan los metadatos de procesamiento (motores, ruta, metodo de clasificacion, senales de clasificacion, overlay OCR, refs internas, latencia). Mientras response = {} el trabajo sigue en curso. 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).

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.

responseobject

Salida del motor. Vacio ({}) hasta que el trabajo asincrono termina. Ver campos abajo.

response.document_typestring

Tipo clasificado (ej: proof_of_address, constancia_situacion_fiscal, estado_cuenta_bancario, cfdi_factura, opinion_cumplimiento, ine_ife, nomina...).

response.categorystring

Categoria de alto nivel del tipo (ej: proof_of_address, fiscal, financial).

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 el LLM no estaba 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 }. `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). 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).

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 }. 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 (un estado de cuenta bancario incluido) o la opinion de cumplimiento 32-D. null cuando el tipo no tiene regla de antiguedad (un acta, un poder). 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.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}]}.

response.tamperobject

Forense de pixel/metadatos del sidecar. Solo presente si pediste analyze_tamper=true.

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:{...}}.

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

filesobject

files.document = URL S3 firmada (10 min) del documento original, para renderizarlo en tu UI.

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": "proof_of_address",
    "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",
      "street": "AV REFORMA 123",
      "zip": "06600",
      "city": "CIUDAD DE MEXICO"
    },
    "fields": [
      { "key": "holder", "value": "MARIA LOPEZ GARCIA", "confidence": 0.97 },
      { "key": "zip", "value": "06600", "confidence": 0.99 }
    ],
    "lists": {},
    "extraction_notes": "",
    "requirements": [
      { "code": "poa_issued_within_90d", "ok": true, "detail": "issued 50 day(s) ago" },
      { "code": "poa_legible", "ok": true, "detail": "18 non-null field(s) extracted" },
      { "code": "poa_holder_matches_declared", "ok": true, "detail": "declared holder matches" },
      { "code": "poa_address_matches_declared", "ok": true, "detail": "declared zip matches" }
    ],
    "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": [] },
    "page_count": 1
  },
  "files": {
    "document": "https://singula-documents.s3.amazonaws.com/documents/incoming/6622f8a1c1d2e3f4a5b6c7d8.pdf?X-Amz-Signature=<REDACTED>"
  },
  "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 con tool_type checkFiel). 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.

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.

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"
}
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). 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. 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, INE del representante, 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 26: los 16 cotejos originales (entre documentos y contra su fuente) mas los 8 que leen lo que cada papel dijo de SI MISMO (su checklist y su veredicto de fraude) y los 2 anclajes de identidad. 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.

Request
customerIdstringpathrequired

Id del customer (la persona moral), en el mismo ambiente que tu API key.

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: csf, acta, poder, ine, comprobante, fiel, email. 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 los siete validos: una lista vacia o un nombre desconocido responden 400 (una errata nunca baja la vara en silencio).

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 26 controles (los que tu politica deje encendidos), cada uno { code, ok, detail, sources, critical }. `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; necesita que hayas corrido validateCurp del cliente antes) y fiel_latest_issued_active (la e.firma emitida al final debe estar activa). `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.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.requiredarray<string>

Las casillas que se exigieron: las que fije la politica de verificacion de tu organizacion (las siete por defecto), 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.

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: { csf, acta, poder, ine, comprobante, fiel, email }. null = el expediente no tiene ese documento.

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": true
      },
      {
        "code": "acta_authenticity",
        "ok": true,
        "detail": "el acta constitutiva no muestra señales de alteración",
        "sources": ["6622f8a1c1d2e3f4a5b6c7d9"],
        "critical": true
      },
      {
        "code": "poder_apoderado_vs_ine_name",
        "ok": null,
        "detail": "no hay INE del representante",
        "sources": [],
        "critical": 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
    },
    "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"
}
2 endpoints

Politica de verificacion

Que tan estrictos son para TI los productos de documentos, e.firma y expediente KYB. Los umbrales de recencia, las reglas del checklist y los 26 controles del expediente 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, `overall` del expediente). 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.

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 los 25 codigos de reglas y los 26 controles completos. 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 }, fiel: { expiring_days }, kyb: { required_components, strict_nulls, lock_required, checks }, 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.

data.policy.documents.rulesobject

Cada uno de los 25 codigos del checklist -> true (se evalua) o false (no se evalua y no aparece en `requirements`). Todos en true por defecto. Los codigos NO cambian de nombre aunque muevas un umbral.

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 debe traer para poder aprobar. Las siete por defecto: csf, acta, poder, ine, comprobante, fiel, email.

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 26 controles -> { 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.

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 },
        "rules": { "csf_current_year": false, "csf_issued_within_90d": true }
      },
      "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 } },
      "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 } }, "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. Validacion estricta — una seccion de primer nivel desconocida (`kyc` por `kyb`, o `retencion` por `retention`), una llave desconocida dentro de una seccion, un codigo de regla o de control que no existe, un umbral fuera de rango o no entero, un `strict_nulls`/`lock_required` que no sea booleano, 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. Un documento que no reconocemos o un valor fuera de rango responde 400.

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.

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 [csf, acta, poder, ine, comprobante, fiel, email]. Es la vara de completitud del expediente. 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.

kyb.checksobjectbody

{ <codigo del control>: { enabled?: boolean, critical?: boolean } }. Son 26 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. 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 }
  },
  "fiel": { "expiring_days": 30 },
  "kyb": {
    "lock_required": false,
    "checks": { "email_verified_institutional": { "enabled": false } }
  },
  "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 } },
      "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 } }
  }
}
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 o una busqueda de persona moral 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"
}