@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.3.2 |
| 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). |
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). |
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": "JUAN",
"apellido_paterno": "PEREZ",
"apellido_materno": "LOPEZ",
"curp": "PELJ900101HDFRPN07",
"fecha_nacimiento": "1990-01-01",
"vigencia": "2030"
},
"checks": [
{ "check_name": "curp_validation", "passed": true, "confidence": 1.0 },
{ "check_name": "mrz_check_digits", "passed": true, "confidence": 1.0 }
],
"face_result": {
"match_score": 0.82, "match_decision": true,
"liveness_score": 0.91, "liveness_decision": true
}
},
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"submitted_at": "2026-09-11T12:00:00.000Z",
"completed_at": "2026-09-11T12:03:00.000Z",
"verification": { "…": "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[] inepassport | Optional | The documents the selection screen may offer. Both 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 (both 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).
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.3.2 |
| CDN | https://cdn.jsdelivr.net/npm/@singula_systems/identity-js@0.3.2/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? }. |
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-/O+VSG1LzU++WQbBdiin/yCYW7jUNMFETY9+SnB9XK20YR8xYge8WoLp1N91fmAl"
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 or passport 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);
},
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'
},
});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.
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": "JUAN",
"apellido_paterno": "PEREZ",
"apellido_materno": "LOPEZ",
"curp": "PELJ900101HDFRPN07",
"fecha_nacimiento": "1990-01-01",
"vigencia": "2030"
},
"checks": [
{ "check_name": "curp_validation", "passed": true, "confidence": 1.0 },
{ "check_name": "mrz_check_digits", "passed": true, "confidence": 1.0 }
],
"face_result": {
"match_score": 0.82, "match_decision": true,
"liveness_score": 0.91, "liveness_decision": true
}
},
"external_id": "ord_123",
"metadata": { "plan": "pro" },
"submitted_at": "2026-09-11T12:00:00.000Z",
"completed_at": "2026-09-11T12:03:00.000Z",
"verification": { "…": "raw session document (token, status, s3_keys, device_info, opens…)" }
}Result and photos
/app/identity-verification/result/{requestId}Everything status returns plus files: each slot (ine_front, ine_back, passport_image, 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 | Informational follow-up of the Lista Nominal cross-check. Same request_id; it does not invalidate the verdict. |
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.
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. Same response shape as production, so what you integrate in testing does not change when you go live.
# 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": "JUAN", "apellido_paterno": "PEREZ",
"curp": "PELJ900101HDFRPN07" }
}
}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.