Identity verification

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

1. Register your domains

With no origins, the widget will not start.

2. Create the session

On your server, with your API key.

3. Load the loader

A ~10 KB script in the browser.

4. Read the verdict

On your server: webhook or status.

Security model

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.

Request
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.
Response
json
{
  "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"
}
A single server-to-server step: mint a short-lived token and hand it to the widget.

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.

A · No library
Link hosted by Singula

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. 1.Create the session
  2. 2.Send the link
  3. 3.Read the verdict on your server
Nothing to install · no allowed origins
B · With the library
Widget inside your app

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. 1.Register your domain
  2. 2.Create the session
  3. 3.Install and open the widget
  4. 4.Read the verdict on your server
npm or CDN with SRI · allowed origins required
Install the library (way B only)
npmnpm install @singula_systems/identity-js@0.8.0
CDNThe same UMD bundle from jsDelivr, pinned and with integrity: the full tag is under "Embed the widget".
Side by side
A · Hosted linkB · Widget in your app
Who shows the captureSingula, on app.singula.mxYour app, in an iframe served by Singula
What the person getsverification_url (link or QR)Nothing: you open the widget with verification_token
InstallNonenpm install @singula_systems/identity-js or CDN with SRI
Allowed originsNot neededRequired (Settings › Branding)
Desktop → phoneQR built into the pageQR built into the widget
How the verdict arrivesWebhook or GET statusWebhook or GET status; onComplete is UX only
No code at allYes: the link is generated from the dashboardNo
You can mix them

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.

Request
# ── 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.
Both ways use the same create and the same server-side verdict. Only who shows the capture changes.

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.

Attributes
FieldTypeDescription
request_idstringThe session identifier, the same one create returned.
session_statusstring
pendingsubmittedcompletedcancelledexpired
The state of the SESSION, not of the verdict: pending (live link, no photos), submitted (uploaded, verdict pending), completed, cancelled, expired.
decisionstring | null
approvedreviewdeclined
The public verdict — the only thing your system should route on. null while there is no verdict.
scorenumber | nullThe aggregate confidence of the session, 0 to 1 (the result’s overall_confidence). How it is computed.
verdictstring | null
verifiedpartialrejectedfailed
The raw pipeline verdict decision is derived from. Useful for audit; do not branch on it.
resultobject | nullThe full verification object: status, document_type, overall_confidence, extracted_data, checks[] and face_result. null until there is a verdict.
external_idstring | nullThe id you sent when creating the session.
metadataobject | nullThe flat object you sent when creating the session.
submitted_atstring | nullWhen the person submitted their photos (ISO 8601).
completed_atstring | nullWhen the verdict landed (ISO 8601).
networkobject | nullWhere 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.
verificationobjectThe raw session document (token, status, s3_keys, challenge, device_info, opens…). Kept unchanged for consumers that already read it.
verdict → decision
decisionverdictDescription
approvedverifiedEvery stage passed. The face is live and matches the document.
reviewpartial · failedIt came back uncertain (partial) or the pipeline produced no verdict (failed). Send it to a human: failed is never a rejection.
declinedrejectedA critical check failed: liveness, face match or an MRZ check digit. A rejection, regardless of the score.
failed is review, never a rejection

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.

Response
json
{
  "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…)" }
}
The same session view returned by status, result and the event stream.

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.

The key goes in the header

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.

Request
http
# Production — verifies for real, consumes balance
Authorization: Bearer sk_live_xxx

# Sandbox — test verdict, consumes no balance
Authorization: Bearer sk_test_xxx

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

What happens when it is missing
origin_not_configuredThe organization has no origins at all. The widget fails immediately, without waiting the 5 s.
origin_not_allowedThe embedding origin is not on the list. The error is sent to THAT origin.
origin_unknownNo init handshake arrived within 5 s and the referrer did not identify a listed origin.
The direct link needs no list

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.

Request
txt
# 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.
The origin compared is the event.origin of the SDK’s init message: the browser stamps it and a page cannot forge it.

Create the verification

POST/app/identity-verification/customer/{customerId}/create

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

Body (optional)
FieldTypeRequiredDescription
redirect_urlstringOptionalWhere 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_idstringOptionalYour own id for this session (order, file, ticket). 128 characters max. Echoed back on every read and on the webhook.
metadataobjectOptionalFlat 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_typesstring[]
inepassportresident_card
OptionalThe 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.
languagestring
esen
OptionalLanguage of the hosted page. Defaults to your organization’s branding language.
expires_in_hoursnumberOptionalLink lifetime, 1 to 720 hours. Defaults to 168 (7 days).
Response
FieldTypeDescription
request_idstringThe verification session identifier. Use it to read status and to match every webhook.
verification_tokenstringThe 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_urlstringThe full page the person opens in their own tab (email, SMS, WhatsApp). Needs no allowed origins.
embed_urlstringThe same capture, iframe-ready and already carrying the token. This is what the SDK mounts.
expires_atstringWhen the link expires (ISO 8601). Derived from expires_in_hours, or from 168 hours.
reusedbooleanPresent 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_urlstring | nullExactly as stored.
external_idstring | nullExactly as stored.
metadataobject | nullExactly as stored.
document_typesstring[]The documents the capture will offer, already resolved (all three when you did not narrow them).
languagestring | nullnull = the page uses the branding language.
One pending session per customer

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

Included: RENAPO and the Lista Nominal

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.

Request
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.
Response
json
{
  "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.

Install
npmnpm install @singula_systems/identity-js@0.8.0
CDNhttps://cdn.jsdelivr.net/npm/@singula_systems/identity-js@0.8.0/dist/singula-identity.umd.min.js
Pin the version with SRI

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.

Identity.open() options
FieldTypeRequiredDescription
tokenstringRequiredThe verification_token your backend minted. URL-safe characters, up to 512; the bare UUID or the vt_-prefixed form, both work.
modestring
modalinline
OptionalHow the widget renders. Defaults to "modal".
containerstring | ElementOptionalRequired for inline mode: the selector or element to mount into.
embedBaseUrlstringOptionalOverride for the hosted page’s origin (staging). Must be https: — http: only for localhost; anything else throws.
languagestring
esen
OptionalForce the UI language. An unknown value is dropped, not forwarded.
themeobjectOptionalOverride the branding for this call (see White-label).
loadTimeoutMsnumberOptionalDeadline for the iframe’s ready. Defaults to 20,000; on expiry, onError with load_timeout. 0 disables it.
verdictTimeoutMsnumberOptionalDeadline for the verdict after submit. Defaults to 2,100,000 (35 min); on expiry, onError with verdict_timeout. 0 disables it.
onReadyfunctionOptionalThe iframe loaded and is ready.
onStagefunctionOptionalA stage changed status. Receives { stage, status, score?, reason? }.
onRetakefunctionOptionalThe 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.
onCompletefunctionOptionalThe flow finished. Receives { decision, score?, requestId? }, with decision = approved | declined | review | withheld.
onErrorfunctionOptionalFatal error. Receives { code, message }.
onClosefunctionOptionalThe widget closed. Receives the reason: user, success, error or programmatic.
Request
<!-- 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 widget mounts an iframe served from Singula’s origin. The camera, the capture and the logic run there, never on your page. The integrity hash is only valid with a pinned version: every release produces a different one.

The five stages

The verification moves through five stages and each returns its result as it finishes. onStage fires on every transition.

stage
receivedThe session is received and ready to capture.
livenessThe multi-frame liveness check: guided head gestures that confirm a live person.
face_matchThe captured face is matched against the document photo.
documentThe INE, passport or resident card is read and its data extracted.
decisionThe previous stages are aggregated into a single verdict.
status
pendingrunningpassedfailed
"All green" is not "approved"

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.

Response
json
// 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.
Each stage transition arrives via onStage as it happens.

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.

The onComplete decision
approved

Every stage passed.

review

Uncertain, or no verdict at all.

declined

A critical check failed.

withheld

It finished, but your organization does not publish the verdict to the browser (the default).

onComplete is not authorization

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 deadline

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.

Request
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

GET/app/identity-verification/upload/{token}/precheck

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

With your own screens
  1. 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_front or resident_back —; its response does not change and a check never fails an upload.
  2. 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. 3.Before submit, read GET …/upload/{token}/precheck. With ready false, read again every 500 ms with a cap of a few seconds; whatever is still pending after 20 s comes back unavailable.
  4. 4.For each photo with retake_suggested: true, show your screen with the copy for its reason and upload THAT photo again: presign of the same kind → S3 → confirm. It goes back to pending with its attempt + 1: read it again.
  5. 5.With no retake_suggested left, start the verification with submit/{token} — if the person switched documents, name it in the body with document_type. Once it is sent, this route answers 409.
Response
FieldTypeDescription
max_attemptsnumberThe photos checked per side: 3.
readybooleantrue when no photo is still pending. On false, read again in half a second.
slotsobjectOne 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>.statusstring
pendingokretakeunavailable
How that photo’s check came out (table below).
slots.<slot>.reasonstring | null
glareblurrydarkcroppedlow_resolutionunreadablewrong_sidewrong_documentno_document
What is wrong with the photo. Only with retake; null otherwise.
slots.<slot>.detected_document_typestring | null
INEPASSPORTRESIDENT_CARD
The document the photo shows instead of the expected one. Only with retake and reason wrong_document; null otherwise.
slots.<slot>.attemptnumberHow many photos of that side have been confirmed, from 1. Confirming the same photo again does not add one.
slots.<slot>.retake_suggestedbooleantrue = 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.
status
pendingIt is being checked. Read again in half a second.
okThe photo will do. In sandbox, always, with nothing checked.
retakeThe 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.
unavailableNo 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, and what to tell the person
reasonWhat happenedSuggested copy
glareA reflection covers part of the document.There is a glare on your {doc}Tilt it a little or move away from direct light.
blurryThe photo is shaken or out of focus.The photo came out blurryHold your {doc} steady and wait for it to focus.
darkNot enough light.The photo came out too darkFind a spot with more light.
croppedPart of the document is missing.Your {doc} is not fully in the shotKeep all four corners inside the frame.
low_resolutionThe document is too far away or too small.Your {doc} is too far awayMove it closer so it fills the frame.
unreadableThe 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.
unreadableThe 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_sideIt 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_documentIt is another identity document; detected_document_type says which.This photo looks like your {detected}Here we need your {doc}.
no_documentIt 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.

Another ID in the photo

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.

Three photos per side, and the verdict decides

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.

What does not change

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.

onRetake, in the widget

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.

In the result: retake_suggested

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.

Request
# 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.
Response
json
{
  "max_attempts": 3,
  "ready": true,
  "slots": {
    "passport_image": {
      "status": "retake",
      "reason": "wrong_document",
      "detected_document_type": "INE",
      "attempt": 1,
      "retake_suggested": true
    }
  }
}
Only the document photos already confirmed appear, and reason comes only with retake; detected_document_type, only with wrong_document (null otherwise). retakePhoto is your screen: it asks for the photo again and uploads it like the first one.

Status and verdict

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

Additive

The raw verification object is kept unchanged: anyone already reading from it changes nothing. 404 when that requestId is not yours.

Where the person connected from

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.

Request
curl https://api.singula.mx/app/identity-verification/status/6622f8a1c1d2e3f4a5b6c7d8 \
  -H "Authorization: Bearer sk_live_xxx"
Response
json
{
  "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.

The four categories (INE)
CategoryWeightWhat it covers
Critical50%Face match, liveness and the MRZ check digits. If any of them fails, the hard rule below applies.
RENAPO25%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 consistency15%The document agrees with itself: CURP ↔ MRZ ↔ printed data ↔ voter key (clave de elector) — date of birth, sex and state.
Quality10%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.
If a category does not apply, its weight is redistributed

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.

From score to verdict
score ≥ 0.85
verified → approved

No critical check failed and the evidence is solid.

0.60 ≤ score < 0.85
partial → review

No critical check failed, but a cross-check or a field is not perfect: review it before accepting.

score < 0.60
failed → review

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

Hard rule: a critical check overrides the score

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.

Response
json
// 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
}
Example numbers; the weights, the split by 0.75 and the bands are the real ones.

Result and photos

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

query
FieldTypeRequiredDescription
files_ttlnumberOptionalSigned-URL lifetime in seconds. Clamped to [60, 86,400]; defaults to 600.
include_framesbooleanOptionaltrue also signs the liveness_frame_<i> (up to 30 more URLs). Defaults to false: only the documents and the selfie are signed.
Liveness frames are opt-in

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.

Request
cURL
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"
Response
json
{
  "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"
}
Returns 200 even with no verdict yet: reviewing the photos of a submitted session is exactly the point.

Session events (SSE)

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

event
sessionThe session view without result (the heavy payload is read from status or result).
keepaliveA 15-second heartbeat with { at } so no proxy cuts the stream.
end{ reason: "terminal" | "timeout" }. terminal = completed, cancelled or expired.
timeout is not the end of the session

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.

Request
cURL
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.
Response
json
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"}
end with reason: "timeout" is NOT the end of the session — it is that stream’s 15-minute cap. Reconnect.

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
eventDescription
identity.session.createdThe session and its link were created. Carries request_id, customer_id, verification_token and expires_at.
identity.completedThe session closed with its verdict: decision, verdict, score, document_type, external_id and metadata.
identity.listanominal.completedThe 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.
Verify the signature

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.

Where the payload lands

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.

Response
json
// 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.

Off (recommended) vs. on
withheldOff (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 · declinedOnThe hosted page and the widget may show the verdict. Turn it on under Settings › Branding › Verification result.
The server path does not change

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.

Response
json
// 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.

How the hand-off travels
?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_urlWhere 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_desktoptrue = 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 submitted session is still alive

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.

Response
json
// 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.

theme
FieldTypeRequiredDescription
primaryColorstringOptionalPrimary brand color. Any valid CSS color; a value carrying ;, {, }, url( or @import is rejected.
secondaryColorstringOptionalAccent color for gradients and secondary controls. Same validation.
logoUrlstringOptionalAbsolute HTTPS URL of the header logo. http:, data: and javascript: throw.
brandNamestringOptionalThe name used in copy, title and accessibility labels. <, > and control characters are stripped, and it is clamped to 64.
Validated before anything mounts

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.

Who wins: organization, flow, call

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.

Request
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 environment is fixed by the key that created the session

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 describes ONE single person

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.

Request
cURL
# 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.
Response
json
{
  "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
coderaised byDescription
origin_not_configuredhosted pageThe organization has no allowed origins configured. Add them in Settings › Branding.
origin_not_allowedhosted pageThe embedding origin is not on the list. Add it exactly as the browser reports it (scheme + host + port).
origin_unknownhosted pageNo init arrived within 5 s and the referrer did not identify a listed origin. Usually a raw iframe without the SDK.
session_cancelledhosted pageThe session was cancelled server-side.
session_expiredhosted pageThe session expired before the flow finished. Create a new one.
verdict_timeoutSDKNo verdict arrived within verdictTimeoutMs (35 min by default) of the submit.
load_timeoutSDKThe iframe never reported ready within loadTimeoutMs (20 s by default).
iframe_load_errorSDKThe iframe fired error: the URL could not be loaded at all (network, CSP frame-src, wrong embedBaseUrl).

Verify your first identity

Start in the free test environment.

Response
json
// 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.