@singula_systems/ identity-js integration guide
Register your domains, create the session on your server, load the loader and read the verdict server-side. Capture runs in an iframe served by Singula; you only embed the widget. Nothing to install? Send the link Singula hosts: same session, same verdict.
Overview
The identity widget is a hosted iframe that runs a guided liveness check, reads the document, matches the face and closes on a verdict. There are two ways to get there: the link Singula hosts, or the widget inside your app. The four things below are the widget path; with the link you only do 2 and 4.
With no origins, the widget will not start.
On your server, with your API key.
A ~10 KB script in the browser.
On your server: webhook or status.
Your API key (sk_live_*) never reaches the browser: the server creates the session and only the short-lived token travels to the front-end. The widget accepts it with or without the vt_ prefix. Camera permissions are granted to Singula’s origin, not yours. And the verdict of record lives server-side: onComplete is UX, not authorization.
curl -X POST https://api.singula.mx/app/identity-verification/customer/cus_9f2a7c410b8e/create \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"redirect_url": "https://acme.com/kyc/listo",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"document_types": ["ine"],
"language": "en",
"expires_in_hours": 72
}'
# The body is OPTIONAL: a POST with no body works exactly as before.{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"verification_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"verification_url": "https://app.singula.mx/verify/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"embed_url": "https://app.singula.mx/embed/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expires_at": "2026-09-14T18:45:00.000Z",
"redirect_url": "https://acme.com/kyc/listo",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"document_types": ["ine"],
"language": "en"
}Two ways to integrate
Both start from the same session (the same create) and close the same way (webhook or status on your server). The only thing that changes is who shows the capture: Singula on its page, or your app with the widget.
The default flow. Your server creates the session and you send verification_url to the person by SMS, WhatsApp or email; or generate it from the dashboard with no code at all. The page, in your brand, lives on app.singula.mx and includes the desktop-to-phone handoff with a QR.
- 1.Create the session
- 2.Send the link
- 3.Read the verdict on your server
The capture opens inside your product, as a modal or mounted in your container. You install @singula_systems/identity-js, register your domain, hand verification_token to the front-end and use onComplete for your UX. The verdict of record is still read on your server.
- 1.Register your domain
- 2.Create the session
- 3.Install and open the widget
- 4.Read the verdict on your server
| npm | npm install @singula_systems/identity-js@0.8.0 |
| CDN | The same UMD bundle from jsDelivr, pinned and with integrity: the full tag is under "Embed the widget". |
| A · Hosted link | B · Widget in your app | |
|---|---|---|
| Who shows the capture | Singula, on app.singula.mx | Your app, in an iframe served by Singula |
| What the person gets | verification_url (link or QR) | Nothing: you open the widget with verification_token |
| Install | None | npm install @singula_systems/identity-js or CDN with SRI |
| Allowed origins | Not needed | Required (Settings › Branding) |
| Desktop → phone | QR built into the page | QR built into the widget |
| How the verdict arrives | Webhook or GET status | Webhook or GET status; onComplete is UX only |
| No code at all | Yes: the link is generated from the dashboard | No |
It is the same session: send the link over WhatsApp to someone who is not on your site and open the widget for someone who is. A raw <iframe> with embed_url and no library loads too, but then the init handshake is on you; without it the page closes with origin_unknown.
# ── A) No library: Singula hosts the page (the default flow) ───────
# 1. Your server creates the session
curl -X POST https://api.singula.mx/app/identity-verification/customer/cus_9f2a7c410b8e/create \
-H "Authorization: Bearer sk_live_xxx"
# → { "request_id": "6622f8a1c1d2e3f4a5b6c7d8",
# "verification_url": "https://app.singula.mx/verify/a1b2c3d4-e5f6-7890-abcd-ef1234567890", … }
# 2. Send verification_url to the person (SMS, WhatsApp, email)
# — or generate it from the dashboard, no code at all.
# 3. The verdict reaches your server: identity.completed webhook or GET status.
# Nothing to install, no origins to register.
# ── B) With the library: capture inside your app ───────────────────
# 0. Register your domain under Settings › Branding (required)
# 1. Install the loader
npm install @singula_systems/[email protected]
# (or the same bundle from jsDelivr with SRI: see "Embed the widget")
# 2. Your server creates the session (the SAME create) and hands verification_token to the front-end
# 3. Open the widget on your page
# Singula.Identity.open({ token, onComplete })
# 4. The verdict still reaches your server: webhook or GET status.The verification session
A session (request_id) has TWO axes not to be confused: session_status describes the session and decision the verdict. It is the same object status, result and the event stream return.
| Field | Type | Description |
|---|---|---|
request_id | string | The session identifier, the same one create returned. |
session_status | string pendingsubmittedcompletedcancelledexpired | The state of the SESSION, not of the verdict: pending (live link, no photos), submitted (uploaded, verdict pending), completed, cancelled, expired. |
decision | string | null approvedreviewdeclined | The public verdict — the only thing your system should route on. null while there is no verdict. |
score | number | null | The aggregate confidence of the session, 0 to 1 (the result’s overall_confidence). How it is computed. |
verdict | string | null verifiedpartialrejectedfailed | The raw pipeline verdict decision is derived from. Useful for audit; do not branch on it. |
result | object | null | The full verification object: status, document_type, overall_confidence, extracted_data, checks[] and face_result. null until there is a verdict. |
external_id | string | null | The id you sent when creating the session. |
metadata | object | null | The flat object you sent when creating the session. |
submitted_at | string | null | When the person submitted their photos (ISO 8601). |
completed_at | string | null | When the verdict landed (ISO 8601). |
network | object | null | Where the person connected from: the trusted IPs of the page they opened and of the phone that uploaded the photos, each with country, ASN and whether it is Tor, cloud/hosting/VPN or iCloud Private Relay. Every flag is true, false or null (no data). null until the first visit. It charges nothing and never rides the webhook. |
verification | object | The raw session document (token, status, s3_keys, challenge, device_info, opens…). Kept unchanged for consumers that already read it. |
| decision | verdict | Description |
|---|---|---|
approved | verified | Every stage passed. The face is live and matches the document. |
review | partial · failed | It came back uncertain (partial) or the pipeline produced no verdict (failed). Send it to a human: failed is never a rejection. |
declined | rejected | A critical check failed: liveness, face match or an MRZ check digit. A rejection, regardless of the score. |
When the pipeline could not produce a verdict there is nothing to assert about the person, so the session goes to a human instead of claiming a rejection.
{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"session_status": "completed",
"decision": "approved",
"score": 0.95,
"verdict": "verified",
"result": {
"status": "verified",
"document_type": "INE",
"overall_confidence": 0.95,
"extracted_data": {
"nombre": "MARIA",
"apellido_paterno": "LOPEZ",
"apellido_materno": "GARCIA",
"curp": "LOGM900101MDFPRR02",
"fecha_nacimiento": "1990-01-01",
"vigencia": "2030"
},
"checks": [
{ "check_name": "curp_validation", "passed": true, "confidence": 1.0 },
{ "check_name": "mrz_check_digits", "passed": true, "confidence": 1.0 }
],
"face_result": {
"match_score": 0.82, "match_decision": true,
"liveness_score": 0.91, "liveness_decision": true
}
},
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"submitted_at": "2026-09-11T12:00:00.000Z",
"completed_at": "2026-09-11T12:03:00.000Z",
"network": {
"ips": [
{ "ip": "203.0.113.24", "first_at": "2026-09-11T11:58:10.000Z",
"last_at": "2026-09-11T12:00:00.000Z", "count": 3,
"country": "MX", "asn": 8151, "as_org": "Uninet S.A. de C.V.",
"is_tor": false, "is_hosting": false, "is_relay": false }
],
"countries": ["MX"], "any_tor": false, "any_hosting": false, "any_relay": false
},
"verification": { "…": "raw session document (token, status, s3_keys, device_info, opens…)" }
}Authentication
Everything that creates or reads sessions authenticates with your API key in the Authorization header. sk_test_* for testing, sk_live_* for production. The public routes (the capture and the status the hosted page polls) authenticate with the session token itself.
No identity route accepts the API key as a URL parameter, the event stream included: a key in a URL leaks through logs and referrers.
# Production — verifies for real, consumes balance
Authorization: Bearer sk_live_xxx
# Sandbox — test verdict, consumes no balance
Authorization: Bearer sk_test_xxxAllowed origins (required)
Before integrating, register the origin of the page that embeds the widget under Settings › Branding › Domains and continuity. Inside an iframe the verification only starts when the origin is on the list: an empty list is a misconfiguration, not a wildcard. For local development http://localhost and http://127.0.0.1 with their port are accepted; everything else goes over https, with no path.
origin_not_configured | The organization has no origins at all. The widget fails immediately, without waiting the 5 s. |
origin_not_allowed | The embedding origin is not on the list. The error is sent to THAT origin. |
origin_unknown | No init handshake arrived within 5 s and the referrer did not identify a listed origin. |
The list governs framing. verification_url opens as its own tab (email, SMS, WhatsApp) and has no parent to authorize. In all three failures above, the capture never starts.
# Settings › Branding › Domains and continuity
# "Allowed origins · one per line" — scheme + host + port, no path.
https://acme.com
https://checkout.acme.com
http://localhost:5173
# http only for localhost / 127.0.0.1 / [::1] (testing); https everywhere else.
# No trailing slash or path: https://acme.com/kyc never matches and is rejected with 400.
# An empty list is a misconfiguration, NOT a wildcard: the widget fails closed.Create the verification
/app/identity-verification/customer/{customerId}/createCall this endpoint from your server. It returns the session request_id, the verification_token you hand to the widget and both capture links. The body is optional: it is there to tie the session to your own flow.
| Field | Type | Required | Description |
|---|---|---|---|
redirect_url | string | Optional | Where to send the person once the capture finishes on their phone. https required, 2048 characters max. request_id and status are appended; the token never travels. |
external_id | string | Optional | Your own id for this session (order, file, ticket). 128 characters max. Echoed back on every read and on the webhook. |
metadata | object | Optional | Flat bag of string, number or boolean values; 2 KB max once serialized. Echoed back on every read and on the webhook. Not a place to park blobs. |
document_types | string[] inepassportresident_card | Optional | The documents the selection screen may offer: INE, passport or resident card. All three by default; with a single one the flow goes straight to capturing it. |
language | string esen | Optional | Language of the hosted page. Defaults to your organization’s branding language. |
expires_in_hours | number | Optional | Link lifetime, 1 to 720 hours. Defaults to 168 (7 days). |
| Field | Type | Description |
|---|---|---|
request_id | string | The verification session identifier. Use it to read status and to match every webhook. |
verification_token | string | The short-lived token you pass to the widget in the browser. It comes back as a bare UUID; the widget accepts it with or without the vt_ prefix. The only thing that reaches the front-end — and a bearer credential. |
verification_url | string | The full page the person opens in their own tab (email, SMS, WhatsApp). Needs no allowed origins. |
embed_url | string | The same capture, iframe-ready and already carrying the token. This is what the SDK mounts. |
expires_at | string | When the link expires (ISO 8601). Derived from expires_in_hours, or from 168 hours. |
reused | boolean | Present and true when the customer already had a pending session: THAT one is returned, nothing is charged again and its original values are preserved (an already-shared link is never repointed at a new redirect_url). |
redirect_url | string | null | Exactly as stored. |
external_id | string | null | Exactly as stored. |
metadata | object | null | Exactly as stored. |
document_types | string[] | The documents the capture will offer, already resolved (all three when you did not narrow them). |
language | string | null | null = the page uses the branding language. |
If the customer already had a pending verification, THAT one is returned with reused: true: nothing is charged again and its options are NOT changed. To start over, cancel the previous one with POST …/customer/{customerId}/cancel (cancelling is free).
With an INE, a passport or a resident card and a customer that has a CURP, the session checks the document’s CURP, name, date of birth and sex against RENAPO at no extra charge: it reuses the CURP validation that already exists for that CURP and, when there is none, runs one internally when the verification is submitted, without charging it (it shows up in your activity with bundled_with). A CURP validation you ask for — POST /app/curp/validate or a CURP step in a flow — is charged. The INE is also checked against the Lista Nominal: it arrives after the verdict, on the same request_id, with the identity.listanominal.completed webhook. The same rule holds over the API, the dashboard, the SDK, links and flows.
curl -X POST https://api.singula.mx/app/identity-verification/customer/cus_9f2a7c410b8e/create \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"redirect_url": "https://acme.com/kyc/listo",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"document_types": ["ine"],
"language": "en",
"expires_in_hours": 72
}'
# The body is OPTIONAL: a POST with no body works exactly as before.{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"verification_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"verification_url": "https://app.singula.mx/verify/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"embed_url": "https://app.singula.mx/embed/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expires_at": "2026-09-14T18:45:00.000Z",
"redirect_url": "https://acme.com/kyc/listo",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"document_types": ["ine"],
"language": "en"
}Embed the widget
The loader ships on npm and, from there, on the npm-backed CDNs. In modal it opens over your page; in inline it mounts into your own container.
| npm | npm install @singula_systems/identity-js@0.8.0 |
| CDN | https://cdn.jsdelivr.net/npm/@singula_systems/identity-js@0.8.0/dist/singula-identity.umd.min.js |
A third-party <script> is code you delegate to a CDN. Pin the exact version and let the browser verify the bytes with integrity (see the panel): a mis-served object then fails closed instead of running. The hash covers ONE file, so it is useless with a moving alias or across versions.
| Field | Type | Required | Description |
|---|---|---|---|
token | string | Required | The verification_token your backend minted. URL-safe characters, up to 512; the bare UUID or the vt_-prefixed form, both work. |
mode | string modalinline | Optional | How the widget renders. Defaults to "modal". |
container | string | Element | Optional | Required for inline mode: the selector or element to mount into. |
embedBaseUrl | string | Optional | Override for the hosted page’s origin (staging). Must be https: — http: only for localhost; anything else throws. |
language | string esen | Optional | Force the UI language. An unknown value is dropped, not forwarded. |
theme | object | Optional | Override the branding for this call (see White-label). |
loadTimeoutMs | number | Optional | Deadline for the iframe’s ready. Defaults to 20,000; on expiry, onError with load_timeout. 0 disables it. |
verdictTimeoutMs | number | Optional | Deadline for the verdict after submit. Defaults to 2,100,000 (35 min); on expiry, onError with verdict_timeout. 0 disables it. |
onReady | function | Optional | The iframe loaded and is ready. |
onStage | function | Optional | A stage changed status. Receives { stage, status, score?, reason? }. |
onRetake | function | Optional | The page asked for another document photo (since 0.7.0). Receives { side, reason, attempt } and, since 0.8.0, detectedDocumentType with reason wrong_document. See Photo pre-check. |
onComplete | function | Optional | The flow finished. Receives { decision, score?, requestId? }, with decision = approved | declined | review | withheld. |
onError | function | Optional | Fatal error. Receives { code, message }. |
onClose | function | Optional | The widget closed. Receives the reason: user, success, error or programmatic. |
<!-- jsDelivr, pinned version + SRI (the browser verifies the bytes) -->
<script
src="https://cdn.jsdelivr.net/npm/@singula_systems/[email protected]/dist/singula-identity.umd.min.js"
integrity="sha384-U9pyNZmbLyz/rgC73Qy0+uabd4Vv8BLwkrEP2xgwrR2KwzHTX7Dz1EQRq3VZcy+m"
crossorigin="anonymous"
></script>
<!-- exposes window.Singula.Identity — that key, nothing else -->
<button id="verify">Verify identity</button>
<script>
document.getElementById('verify').addEventListener('click', () => {
Singula.Identity.open({
token: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
onComplete: ({ decision, requestId }) => {
// decision: 'approved' | 'declined' | 'review' | 'withheld'
askYourBackend(requestId);
},
});
});
</script>The five stages
The verification moves through five stages and each returns its result as it finishes. onStage fires on every transition.
received | The session is received and ready to capture. |
liveness | The multi-frame liveness check: guided head gestures that confirm a live person. |
face_match | The captured face is matched against the document photo. |
document | The INE, passport or resident card is read and its data extracted. |
decision | The previous stages are aggregated into a single verdict. |
Stages and verdict are independent: five passed stages imply no decision, and with the verdict withheld the decision stage never arrives at all. Drive your UI from onComplete, and read the verdict on your server.
// onStage fires once per stage transition (iframe → parent):
{ "stage": "received", "status": "passed" }
{ "stage": "liveness", "status": "running" }
{ "stage": "liveness", "status": "passed", "score": 0.91 }
{ "stage": "face_match", "status": "passed", "score": 0.82 }
{ "stage": "document", "status": "passed" }
{ "stage": "decision", "status": "passed" }
// With the verdict withheld (the DEFAULT) the 'decision' stage never
// arrives — the page cannot report 'passed' for a verdict it does not know.Callbacks
Wire onStage to render your own progress and onComplete to route the user. onError is for fatal errors only; onClose tells you why it closed.
approvedEvery stage passed.
reviewUncertain, or no verdict at all.
declinedA critical check failed.
withheldIt finished, but your organization does not publish the verdict to the browser (the default).
Never grant anything on the strength of a browser callback: it describes what the page in front of that person says happened, and whoever holds the keyboard can call your handler with anything. Use requestId to ask YOUR backend, which holds the API key.
The verdict lands minutes after the submit, over a channel that can die (the tab gets throttled, the session is cancelled). The SDK arms a backstop as soon as it sees the submit: if nothing arrives within verdictTimeoutMs (35 min) it fires verdict_timeout once. retryStage() is reserved: the hosted page does not act on it today.
Identity.open({
token: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
loadTimeoutMs: 20000, // deadline for the iframe's 'ready' → load_timeout
verdictTimeoutMs: 2100000, // 35 min deadline after submit → verdict_timeout
onReady: () => renderStart(),
onStage: ({ stage, status, score, reason }) => {
// stage: 'received' | 'liveness' | 'face_match' | 'document' | 'decision'
// status: 'pending' | 'running' | 'passed' | 'failed'
renderProgress(stage, status);
},
onRetake: ({ side, reason, attempt, detectedDocumentType }) => {
// SDK >= 0.7.0. side: 'front' | 'back' · reason: 'glare' | 'blurry' | …
// SDK >= 0.8.0: with reason 'wrong_document', detectedDocumentType says
// which document the photo shows: 'ine' | 'passport' | 'resident_card'.
// Informative: the hosted page already asks for the new photo itself.
track('document_retake', { side, reason, attempt, detectedDocumentType });
},
onComplete: async ({ decision, score, requestId }) => {
// decision: 'approved' | 'declined' | 'review' | 'withheld'
// Never authorize here. Ask YOUR backend, which holds the API key.
const real = await fetch(`/api/kyc/${requestId}`).then((r) => r.json());
route(real.decision);
},
onError: ({ code, message }) => console.error(code, message),
onClose: (reason) => {
// reason: 'user' | 'success' | 'error' | 'programmatic'
},
});Photo pre-check
/app/identity-verification/upload/{token}/precheckEvery document photo is checked as soon as it is confirmed, while the person carries on with the capture. If one will not do — a glare, a blurry shot, the wrong side, another document — the hosted page asks for it again once the liveness check ends and before the submit: that photo only, with the same on-screen guide and without repeating the liveness check. With the widget there is nothing to do (onRetake tells you, if you want to measure it). If you capture with your own screens, the same check is read from this route.
- 1.Upload and confirm each document photo as soon as it is taken (presign → S3 → confirm). The confirm starts its check —
ine_front,ine_back,passport_image,resident_frontorresident_back—; its response does not change and a check never fails an upload. - 2.Carry on with the rest: the other side, the selfie and the liveness check. By the time they are done, the check has usually finished.
- 3.Before
submit, readGET …/upload/{token}/precheck. Withreadyfalse, read again every 500 ms with a cap of a few seconds; whatever is stillpendingafter 20 s comes backunavailable. - 4.For each photo with
retake_suggested: true, show your screen with the copy for itsreasonand upload THAT photo again: presign of the samekind→ S3 → confirm. It goes back topendingwith itsattempt+ 1: read it again. - 5.With no
retake_suggestedleft, start the verification withsubmit/{token}— if the person switched documents, name it in the body withdocument_type. Once it is sent, this route answers 409.
| Field | Type | Description |
|---|---|---|
max_attempts | number | The photos checked per side: 3. |
ready | boolean | true when no photo is still pending. On false, read again in half a second. |
slots | object | One entry per photo already confirmed of the session’s current document: ine_front and ine_back, passport_image, or resident_front and resident_back. Those of a document the person left behind neither show up nor hold ready (since MasterApi 1.130.0). The selfie and the liveness frames are never checked. |
slots.<slot>.status | string pendingokretakeunavailable | How that photo’s check came out (table below). |
slots.<slot>.reason | string | null glareblurrydarkcroppedlow_resolutionunreadablewrong_sidewrong_documentno_document | What is wrong with the photo. Only with retake; null otherwise. |
slots.<slot>.detected_document_type | string | null INEPASSPORTRESIDENT_CARD | The document the photo shows instead of the expected one. Only with retake and reason wrong_document; null otherwise. |
slots.<slot>.attempt | number | How many photos of that side have been confirmed, from 1. Confirming the same photo again does not add one. |
slots.<slot>.retake_suggested | boolean | true = ask for another photo of that side: status retake and attempt below max_attempts. On the third photo it is false even when status is retake. |
pending | It is being checked. Read again in half a second. |
ok | The photo will do. In sandbox, always, with nothing checked. |
retake | The photo would make the verdict worse and reason says why: an INE front with no CURP that reads in full with a correct check digit; the back of an INE or a resident card, or a passport, whose MRZ zone cannot be read or has a check digit that does not add up; the other side; another identity document; or a photo with no document. A sharp, fully read photo is never retake over a quality detail. |
unavailable | No information: the check could not run, was still pending after 20 s, or this is the fourth photo of that side or later. It is not an error: carry on as usual and never hold the person back over it. |
| reason | What happened | Suggested copy |
|---|---|---|
glare | A reflection covers part of the document. | There is a glare on your {doc}Tilt it a little or move away from direct light. |
blurry | The photo is shaken or out of focus. | The photo came out blurryHold your {doc} steady and wait for it to focus. |
dark | Not enough light. | The photo came out too darkFind a spot with more light. |
cropped | Part of the document is missing. | Your {doc} is not fully in the shotKeep all four corners inside the frame. |
low_resolution | The document is too far away or too small. | Your {doc} is too far awayMove it closer so it fills the frame. |
unreadable | The data could not be read and the photo shows no clear cause. On the INE front: | We could not read your detailsShow the whole front, with no fingers or shadows. |
unreadable | The same, on the back of the INE or the resident card and on the passport: | The lines at the bottom cannot be readMake sure the lines with "<<<" are complete and sharp. |
wrong_side | It is the other side of the same document. | Looks like that is the other sideHere we need the front (or the back) of your {doc}. |
wrong_document | It is another identity document; detected_document_type says which. | This photo looks like your {detected}Here we need your {doc}. |
no_document | It is not an identity document, or it has almost no text. | We could not find your {doc} in the photoPlace it inside the frame, on a flat surface. |
Copy for your screens: about the photo, never about the person. {doc} is "INE", "passport" or "resident card". {detected} comes from detected_document_type in the same words, and the hosted page also offers "Use my {detected}" when the session offers that document. If a photo has several problems, one of them arrives: glare before blurry, dark, cropped and too far.
With wrong_document the photo shows ANOTHER ID: detected_document_type says which one. The hosted page puts it this way — "This photo looks like your resident card" / "Here we need the back of your INE" — with "Take it again" and, only when the session also offers the detected document, "Use my resident card": it switches documents and opens that capture from its first side, keeps the liveness check (it is never repeated) and, once its last side is taken, goes straight to the submit. On the new document's first side, "Back" undoes the switch. When the page does not recognize the document, it says "This photo looks like another document" and only offers to take it again. With your own screens, switching documents means confirming the new one's photos and naming it on submit: since MasterApi 1.130.0 submit accepts { "document_type": "resident_card" } (ine, passport or resident_card), which wins over the inference — without it, the session verifies the document of the LAST document photo confirmed, and a slow confirm of the abandoned document may arrive later —. It has to be one the session offers, with its photos; a photo of a document the session does not offer answers 400 document_type_not_offered.
Up to three photos of each side are checked (max_attempts). On the third, retake_suggested comes back false even if the photo is still poor: start the verification, and a fourth photo comes back unavailable. Never repeat the liveness check: only the photo that was asked for. The verdict decides as always: a document that could not be read comes back failed (review), a check digit that fails on a poor-quality photo comes back partial (review) with the retake_suggested check, and whatever is rejected today is still rejected.
Confirming the SAME photo again neither counts as a new photo nor checks it again. The route authenticates with the token, like presign and confirm: 404 when it does not exist, 409 once the session was sent, 410 when it expired; 300 reads per minute per session and 3000 per IP (since MasterApi 1.130.0), and it never returns S3 keys. The other capture routes have their own per-minute ceilings: presign, confirm and the multipart upload, 600 per session and 3000 per IP; submit, 60 and 600. Past a ceiling they answer 429 with Retry-After, the seconds to wait before retrying. Neither the check nor the retaken photos are charged: the verification is charged as always, once, with its verdict. In sandbox it answers ok with nothing checked, and the deprecated multipart upload has no pre-check.
With @singula_systems/identity-js 0.7.0 or later, onRetake({ side, reason, attempt }) fires every time the page asks for another photo: side is front or back (the passport data page is front), reason is one of the nine codes above and attempt is 1 on the first retake of that side and 2 on the second. Since 0.8.0, wrong_document also carries detectedDocumentType: ine, passport or resident_card (lower case; detected_document_type in the message), absent when the page did not recognize the document. A 0.7.0 SDK receives wrong_document as unreadable, and with 0.7.0 or earlier a capture with a resident card stops sending onStepChange (it drops the document it does not know): upgrade to 0.8.0. It travels as the document_retake message. It is informative only — the page already asks for the photo itself — and an older SDK ignores it.
Apart from the pre-check, the final result may carry the retake_suggested check, false: an MRZ check digit failed on a poor-quality photo, so the session landed on partial (review) instead of rejected, or the bottom zone on the back of an INE could not be read, so the session landed on partial instead of verified. It is informative, does not count towards the score and its details says what to do: ask for another photo. See the hard rule in How the score is computed.
# 1. Each document photo, as soon as it is taken: presign → S3 → confirm
curl -X POST https://api.singula.mx/app/identity-verification/upload/a1b2c3d4-e5f6-7890-abcd-ef1234567890/confirm \
-H "Content-Type: application/json" \
-d '{ "assets": [{ "kind": "ine_back" }] }'
# → the pre-check of ine_back starts on its own
# 2. Before submit: does any photo ask for another one?
curl https://api.singula.mx/app/identity-verification/upload/a1b2c3d4-e5f6-7890-abcd-ef1234567890/precheck
# No API key: the token in the path is the authentication.{
"max_attempts": 3,
"ready": true,
"slots": {
"passport_image": {
"status": "retake",
"reason": "wrong_document",
"detected_document_type": "INE",
"attempt": 1,
"retake_suggested": true
}
}
}Status and verdict
/app/identity-verification/status/{requestId}The full session view, with an API key: session_status, decision, score, verdict and the result with checks[], extracted_data and face_result. This is the verdict of record, the one your system decides with.
The raw verification object is kept unchanged: anyone already reading from it changes nothing. 404 when that requestId is not yours.
network carries the session’s trusted IPs — the one our server saw, never a client X-Forwarded-For — with country, ASN and whether they are Tor, cloud/hosting/VPN or iCloud Private Relay. It is a signal: it charges nothing and never rides the webhook. The IP of whoever uploads the photos counts too: if your backend uploads them from a cloud with POST /app/identity-verification/submit/:token, that IP is hosting and, in a KYC dossier, ip_not_hosting sends it to review (never to a decline). Upload them from the person’s device — the hosted link or the widget — or switch kyc.checks.ip_not_hosting off (and ip_country_vs_document if your server is outside Mexico) in the verification policy. Country and ASN come from an open IP geolocation database. If you show them on your screens, include the data credits listed at singula.mx/en/credits.
curl https://api.singula.mx/app/identity-verification/status/6622f8a1c1d2e3f4a5b6c7d8 \
-H "Authorization: Bearer sk_live_xxx"{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"session_status": "completed",
"decision": "approved",
"score": 0.95,
"verdict": "verified",
"result": {
"status": "verified",
"document_type": "INE",
"overall_confidence": 0.95,
"extracted_data": {
"nombre": "MARIA",
"apellido_paterno": "LOPEZ",
"apellido_materno": "GARCIA",
"curp": "LOGM900101MDFPRR02",
"fecha_nacimiento": "1990-01-01",
"vigencia": "2030"
},
"checks": [
{ "check_name": "curp_validation", "passed": true, "confidence": 1.0 },
{ "check_name": "mrz_check_digits", "passed": true, "confidence": 1.0 }
],
"face_result": {
"match_score": 0.82, "match_decision": true,
"liveness_score": 0.91, "liveness_decision": true
}
},
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"submitted_at": "2026-09-11T12:00:00.000Z",
"completed_at": "2026-09-11T12:03:00.000Z",
"network": {
"ips": [
{ "ip": "203.0.113.24", "first_at": "2026-09-11T11:58:10.000Z",
"last_at": "2026-09-11T12:00:00.000Z", "count": 3,
"country": "MX", "asn": 8151, "as_org": "Uninet S.A. de C.V.",
"is_tor": false, "is_hosting": false, "is_relay": false }
],
"countries": ["MX"], "any_tor": false, "any_hosting": false, "any_relay": false
},
"verification": { "…": "raw session document (token, status, s3_keys, device_info, opens…)" }
}How the score is computed
The score is the verification’s aggregate confidence, from 0 to 1: the result’s overall_confidence (the score field of “The session”). The checks[] fall into four categories; each one is rated from 0 to 1 and contributes its weight. The score picks the verdict band, but a hard rule comes first: when a critical check fails, it is a rejection whatever the score.
| Category | Weight | What it covers |
|---|---|---|
| Critical | 50% | Face match, liveness and the MRZ check digits. If any of them fails, the hard rule below applies. |
| RENAPO | 25% | The document’s CURP, name, date of birth and sex against the official record for that CURP. On the INE, the passport and the resident card, and only when the customer has a CURP whose RENAPO record describes them (match.passed: true, the same match GET /app/curp/customer/:id/validate returns). It is included at no extra charge: the session reuses the CURP validation that already exists and, when there is none, runs one internally. Without that record, this category does not apply. |
| Internal consistency | 15% | The document agrees with itself: CURP ↔ MRZ ↔ printed data ↔ voter key (clave de elector) — date of birth, sex and state. |
| Quality | 10% | Format of the CURP, the voter key and the CIC; a readable MRZ; not expired; of legal age; and image quality, which lowers the score but never rejects on its own. |
A category with no checks in that verification does not count as zero: its weight is split proportionally across the ones that are present, so a missing signal does not drag the score down. The usual case is RENAPO: on an INE with no RENAPO record (the customer has no CURP, or RENAPO did not confirm it) the weights become 66.7% critical, 20% internal consistency and 13.3% quality (each divided by 0.75). A passport and a resident card are scored with the same rule over their own checks: the printed data and the CURP against their MRZ count as internal consistency, and with a customer that has a CURP they carry the RENAPO category too.
score ≥ 0.85No critical check failed and the evidence is solid.
0.60 ≤ score < 0.85No critical check failed, but a cross-check or a field is not perfect: review it before accepting.
score < 0.60Not enough evidence to assert anything. failed also comes back, with score 0, when the document could not be read: ask for a new capture.
What each decision means is in the verdict → decision table of “The session”, along with a full example session (score 0.95, verified → approved).
If liveness, the face match or an MRZ check digit fails, the verdict is rejected (decision: declined) whatever the score. It is checked before the bands: the score is still computed and returned, but it cannot rescue a rejection. A score of 0.87 with a failed liveness check is still a rejection. On a passport the check digits of its number, the date of birth, the expiry and the composite count as critical; the optional personal number’s does not. An expired passport or an impossible date of birth is never verified: at most partial (decision: review), even with a score above 0.85. And one whose MRZ could not be read comes back failed. On a resident card the check digits of the document number, the date of birth, its MRZ expiry field and the composite are critical. A card that has expired, has an impossible date of birth or was not issued by Mexico is never verified, and neither is a temporary one whose printed expiry could not be read (or does not fall between its issue date and four years later), one whose MRZ is not an INM card’s, nor one whose front does not carry the MRZ name or whose printed issue date is not the MRZ’s: another person’s front; a permanent one does not expire. With no readable MRZ, it comes back failed. One exception, on the INE, the passport and the resident card: a check digit that fails on a poor-quality photo — with no failed face match or liveness — does not reject: the session comes back partial (decision: review) with the retake_suggested check, because on that photo the digit may simply have been misread. On a good photo it is still a rejection. And an INE whose bottom zone on the back (the MRZ) could not be read is never verified: at most partial (decision: review), also with retake_suggested.
// score = Σ weight × rating (from 0 to 1)
// INE of a customer with a CURP: there is a
// RENAPO record and all four count
critical 0.50 × 1.00 = 0.500
RENAPO 0.25 × 1.00 = 0.250
consistency 0.15 × 0.90 = 0.135
quality 0.10 × 0.75 = 0.075
score = 0.960
→ verified → approved
// INE with no RENAPO record (no CURP on the
// customer): each weight is divided by 0.75
critical 0.667 × 1.00 = 0.667
consistency 0.200 × 0.90 = 0.180
quality 0.133 × 0.75 = 0.100
score = 0.947
→ verified → approved
// Hard rule: liveness failed. The score is
// still computed, but it cannot rescue it.
{
"verdict": "rejected",
"decision": "declined",
"score": 0.87
}Result and photos
/app/identity-verification/result/{requestId}Everything status returns plus files: each slot (ine_front, ine_back, passport_image, resident_front, resident_back, selfie and the liveness_frame_<i>) with its signed URL, and files_expires_at. It returns 200 even before there is a verdict.
| Field | Type | Required | Description |
|---|---|---|---|
files_ttl | number | Optional | Signed-URL lifetime in seconds. Clamped to [60, 86,400]; defaults to 600. |
include_frames | boolean | Optional | true also signs the liveness_frame_<i> (up to 30 more URLs). Defaults to false: only the documents and the selfie are signed. |
They are audit evidence and up to 30 extra signatures, so they are not signed by default. Ask for them with include_frames=true when you are actually going to review them.
curl "https://api.singula.mx/app/identity-verification/result/6622f8a1c1d2e3f4a5b6c7d8?files_ttl=600" \
-H "Authorization: Bearer sk_live_xxx"
# The up-to-30 liveness frames are signed separately:
curl "https://api.singula.mx/app/identity-verification/result/6622f8a1c1d2e3f4a5b6c7d8?include_frames=true" \
-H "Authorization: Bearer sk_live_xxx"{
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"session_status": "submitted",
"decision": null,
"score": null,
"verdict": null,
"result": null,
"files": {
"ine_front": "https://s3…/ine_front.jpg?X-Amz-Signature=REDACTED",
"ine_back": "https://s3…/ine_back.jpg?X-Amz-Signature=REDACTED",
"selfie": "https://s3…/selfie.jpg?X-Amz-Signature=REDACTED"
},
"files_expires_at": "2026-09-11T12:10:00.000Z"
}Session events (SSE)
/app/identity-verification/events/{requestId}A text/event-stream to follow one session without polling it yourself. It emits session on connect and on every session_status or decision change, keepalive every 15 s, and end when it closes.
session | The session view without result (the heavy payload is read from status or result). |
keepalive | A 15-second heartbeat with { at } so no proxy cuts the stream. |
end | { reason: "terminal" | "timeout" }. terminal = completed, cancelled or expired. |
end { reason: "timeout" } is THAT stream’s 15-minute cap with the session still open: reconnect, or read status/{requestId}. Only terminal closes the story. A verification is completed by a person: it takes as long as it takes.
curl -N https://api.singula.mx/app/identity-verification/events/6622f8a1c1d2e3f4a5b6c7d8 \
-H "Authorization: Bearer sk_live_xxx" \
-H "Accept: text/event-stream"
# The key goes in the header. This route accepts no api_key query param.event: session
data: {"request_id":"6622f8a1c1d2e3f4a5b6c7d8","session_status":"submitted","decision":null,
"score":null,"verdict":null,"external_id":"ord_123","metadata":{"plan":"pro"},
"submitted_at":"2026-09-11T12:00:00.000Z","completed_at":null}
event: keepalive
data: {"at":"2026-09-11T12:00:15.000Z"}
event: session
data: {"request_id":"6622f8a1c1d2e3f4a5b6c7d8","session_status":"completed",
"decision":"approved","score":0.95,"verdict":"verified",
"completed_at":"2026-09-11T12:03:00.000Z"}
event: end
data: {"reason":"terminal"}Webhooks
The delivery that does not depend on the browser staying open. Every event travels in the same signed envelope as the rest of the tools: the payload is data, not the body.
| event | Description |
|---|---|
identity.session.created | The session and its link were created. Carries request_id, customer_id, verification_token and expires_at. |
identity.completed | The session closed with its verdict: decision, verdict, score, document_type, external_id and metadata. |
identity.listanominal.completed | The INE’s Lista Nominal check, included at no extra charge. It arrives AFTER the verdict, with the same request_id, and does not change it. Its status is the verification’s, and listanominal_outcome says what the Lista Nominal answered (null when there was no query); the session’s record keeps it in response.listanominal. In a KYC, the dossier reads it for ine_vigente. |
X-Singula-Signature arrives as t=<unix>,v1=<hmac>, where the HMAC-SHA256 is computed over `${t}.${rawBody}` with your webhook secret. Compute it on the RAW body, before parsing. The event also rides in X-Singula-Event.
With a webhook registered in the dashboard you get the { id, type, created, data } envelope, signed and retried: read body.data. On the fallback route (no registered webhook) the same object arrives flat and unsigned. Sandbox sends exactly the same payload, so what you integrate against tests works in production.
// Singula → your endpoint. The same signed envelope every tool uses:
// POST https://your-server.com/webhooks/singula
// X-Singula-Signature: t=1757596980,v1=<hmac-sha256("<t>.<raw body>", secret)>
// X-Singula-Event: identity.completed
// X-Singula-Delivery-Id: <delivery id>
{
"id": "6b1f…", // delivery id
"type": "identity.completed",
"created": 1757596980, // unix, in seconds
"data": {
"event": "identity.completed",
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"customer_id": "cus_9f2a7c410b8e",
"tool_type": "identityVerification",
"status": "success",
"session_status": "completed",
"decision": "approved", // approved | review | declined | null
"verdict": "verified", // verified | partial | rejected | failed
"score": 0.95, // 0..1
"document_type": "INE",
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"completed_at": "2026-09-11T12:03:00.000Z"
}
}Who sees the verdict
Showing the result to the verified person is your organization’s decision, and it ships OFF. With the default the widget receives decision: "withheld" and the final screen is neutral: the verdict is read server-side.
withheld | Off (default) | The widget receives decision: "withheld" with no score, and the public status returns decision: null, score: null and verdict_withheld: true even when the session is already completed. |
approved · review · declined | On | The hosted page and the widget may show the verdict. Turn it on under Settings › Branding › Verification result. |
The gate is browser-only. status and result with an API key and the identity.completed webhook keep delivering the full verdict whether you expose it or not. Handle withheld from day one: it is what a freshly configured organization emits.
// Settings › Branding › Verification result → Off (the DEFAULT)
// 1. What the browser gets (GET /app/embed/status/:token, token-auth):
{
"data": {
"request_id": "6622f8a1c1d2e3f4a5b6c7d8",
"session_status": "completed",
"decision": null,
"score": null,
"verdict_withheld": true,
"redirect_url": "https://acme.com/kyc/listo",
"started_on_desktop": false
}
}
// 2. What the widget emits:
onComplete({ decision: 'withheld', requestId: '6622f8a1c1d2e3f4a5b6c7d8' }) // no score
// 3. Where the verdict of record lives (API key / webhook):
GET /app/identity-verification/status/6622f8a1c1d2e3f4a5b6c7d8 → decision: "approved"Desktop → phone
Someone who opens the verification on a computer sees a QR code to continue on their phone. The QR carries your organization’s desktop URL with the hand-off appended, and the desktop screen follows the progress on its own.
?token=…&request_id=… | What we append to your desktop URL in the QR. Use {token} / {request_id} in the URL to place them yourself; with no URL configured the QR points at Singula’s page. |
redirect_url | Where the person lands after finishing on the phone, with ?request_id=…&status=submitted. The token does NOT travel: with request_id your server already reads the result with its API key. |
started_on_desktop | true = there was a device hand-off. The final screen says "go back to your computer" and does not redirect. A tablet that opens and captures on its own does not count: it jumped nowhere. |
A page that remounts after the submit does not render "link already used": the verdict is missing, so it keeps reading the status and shows "we received your verification". The dead-link screen is only for completed, cancelled and expired.
// Desktop → phone, without the token travelling in the address bar.
// 1. QR: your organization's desktop URL with the hand-off appended
https://acme.com/kyc/continue-on-your-phone?token=a1b2c3d4-e5f6-7890-abcd-ef1234567890&request_id=6622f8a1c1d2e3f4a5b6c7d8
// (or use {token} / {request_id} in the URL to place them yourself)
// 2. While the QR is on screen, the page reads the public status
GET /app/embed/status/a1b2c3d4-e5f6-7890-abcd-ef1234567890 // every 3 s for 5 min, then every 10 s
// 3. When the phone finishes, with a redirect_url configured:
https://acme.com/kyc/done?request_id=6622f8a1c1d2e3f4a5b6c7d8&status=submitted
// The token does NOT travel: with request_id your server already reads the
// result with its API key. If there was a device hand-off
// (started_on_desktop: true) the screen says "go back to your computer"
// and does not redirect.White-label
The widget takes its branding from your organization. The theme field overrides it per call, so one integration serves several brands.
| Field | Type | Required | Description |
|---|---|---|---|
primaryColor | string | Optional | Primary brand color. Any valid CSS color; a value carrying ;, {, }, url( or @import is rejected. |
secondaryColor | string | Optional | Accent color for gradients and secondary controls. Same validation. |
logoUrl | string | Optional | Absolute HTTPS URL of the header logo. http:, data: and javascript: throw. |
brandName | string | Optional | The name used in copy, title and accessibility labels. <, > and control characters are stripped, and it is clamped to 64. |
Overrides travel twice: as URL params, for the first paint, and over the init message once the iframe is ready, which is the authoritative channel. A rejected option throws without leaving anything mounted.
Three layers, each overriding only the fields it carries: your ORGANIZATION’s branding (Settings › Branding) is the base; a FLOW may bring its own in settings.ui, frozen into the published version; and theme, on the call, wins over both and applies to that session only. With no theme, the session looks like the flow — or like the organization, when the flow fixed no appearance. The object is sent the same way for an identity verification and for a flow: it is the same Identity.open.
Identity.open({
token: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
language: 'en',
theme: {
primaryColor: '#ff6600',
secondaryColor: '#111827',
logoUrl: 'https://acme.com/logo.svg', // absolute HTTPS
brandName: 'Acme',
},
});
// One integration, many end-brands: pass a different theme per call.Sandbox
With an sk_test_ key the session runs in sandbox end to end: the capture needs no real ID, the verdict is a test one (approved, score 0.95, with sample extracted data), it consumes no balance and the webhook goes to your sandbox URL.
The capture is public (the token is the auth), so the environment is not decided there: it is inherited from the key that created the verification. From the dashboard its mode fixes it: in «Sandbox» it sends X-Singula-Env: sandbox and the verification is born a test one, free, as with sk_test_. Same response shape as production, so what you integrate in testing does not change when you go live.
The sandbox’s identity, INE, CURP against RENAPO and verified email all describe the same sample person: MARIA LOPEZ GARCIA, CURP LOGM900101MDFPRR02, born 1990-01-01. The sanction lists and the judicial search do answer with the name actually searched — your customer’s — and return it in queries. That is deliberate: a test KYC dossier that crossed two different people could never approve.
# Sandbox: the same response shape, with no real ID and no charge.
curl -X POST https://api.singula.mx/app/identity-verification/customer/cus_9f2a7c410b8e/create \
-H "Authorization: Bearer sk_test_xxx"
# The session inherits the environment of the key that created it: a session
# from sk_test_ returns a test verdict and its webhook goes to your
# organization's sandbox URL.{
"session_status": "completed",
"decision": "approved",
"verdict": "verified",
"score": 0.95,
"result": {
"status": "verified",
"document_type": "INE",
"extracted_data": { "nombre": "MARIA", "apellido_paterno": "LOPEZ",
"apellido_materno": "GARCIA",
"curp": "LOGM900101MDFPRR02" }
}
}Errors
onError only fires on fatal errors, when the verification cannot continue. Each one carries a code and a message. In modal the widget also closes with onClose("error"); in inline it stays mounted so you can render your own fallback.
| code | raised by | Description |
|---|---|---|
origin_not_configured | hosted page | The organization has no allowed origins configured. Add them in Settings › Branding. |
origin_not_allowed | hosted page | The embedding origin is not on the list. Add it exactly as the browser reports it (scheme + host + port). |
origin_unknown | hosted page | No init arrived within 5 s and the referrer did not identify a listed origin. Usually a raw iframe without the SDK. |
session_cancelled | hosted page | The session was cancelled server-side. |
session_expired | hosted page | The session expired before the flow finished. Create a new one. |
verdict_timeout | SDK | No verdict arrived within verdictTimeoutMs (35 min by default) of the submit. |
load_timeout | SDK | The iframe never reported ready within loadTimeoutMs (20 s by default). |
iframe_load_error | SDK | The iframe fired error: the URL could not be loaded at all (network, CSP frame-src, wrong embedBaseUrl). |
// onError — fatal errors only. The flow cannot continue.
{
"code": "origin_not_configured",
"message": "This organization has no allowed origins configured."
}
// In modal mode a fatal error also closes the widget with onClose('error').
// In inline mode the widget stays mounted so your page renders its fallback.