@singula_systems/identity-js
v0.8.0
Published
Singula Identity — embeddable JS SDK for the identity verification widget. iframe-based, zero dependencies.
Maintainers
Readme
@singula_systems/identity-js
Embeddable JavaScript SDK for the Singula Identity verification widget.
The SDK is a thin, open-source loader (17.7 kB minified, 6.4 kB gzipped — dist/singula-identity.umd.min.js, decimal kB, measured on every build by scripts/check-umd.mjs) that mounts an iframe pointing to Singula's hosted verification page. Camera, OCR, liveness, biometric face-match and adjudication all run inside that iframe, on Singula's servers — never in this package. You integrate once and never have to ship an SDK update when the verification flow gains new steps.
- iframe-based — your CSP, your DOM and your app stay clean
- zero dependencies
- one SDK for both products: identity verification and Flujos
- modal and inline modes
- vanilla JS with an optional jQuery wrapper
- versioned, strictly-validated
postMessageprotocol - MIT licensed
What's in this repo, and what isn't. This package is only the loader: it mounts the iframe and relays a validated message channel between your page and Singula. The proprietary verification logic — capture, OCR, liveness / presentation-attack detection, face-match and decisioning — lives server-side in the hosted iframe and is never bundled here. Open-sourcing the loader is safe by design, and lets you audit exactly what runs in your users' browsers.
Full documentation: https://singula.mx/en/docs/identity (in Spanish: https://singula.mx/docs/identidad)
Install
Distribution status. The package is on npm (
@singula_systems/identity-js), so the npm-backed CDNs serve every published version — the pinned jsDelivr snippet under Pin it with Subresource Integrity works today. Thecdn.singula.mxalias does not resolve yet: it goes live with the public launch, and its snippets below are the shapes it will activate, not live URLs.
CDN — recommended for static sites and quick integrations:
<script src="https://cdn.singula.mx/identity-js/v1/singula-identity.umd.min.js"></script>
<!-- exposes window.Singula.Identity — that single key, nothing else -->npm — for bundled apps:
npm install @singula_systems/identity-jsimport { Identity } from '@singula_systems/identity-js';Once the package is on npm, the npm-backed CDNs serve the same UMD file as
the cdn.singula.mx alias: package.json sets both unpkg and jsdelivr to
./dist/singula-identity.umd.min.js, so a bare package URL on either resolves to
that bundle. Any of the three is a valid <script src> — pick one and pin it.
Pin it with Subresource Integrity
A third-party <script> is code you delegate to a CDN. Pin the exact version
and let the browser verify the bytes, so a compromised or mis-served CDN object
fails closed instead of running:
<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>integrity is a hash of one exact file, so it is only valid with a pinned
version — never with a moving /v1/ alias, and never across a rebuild (the
banner carries the version, so every release produces a different hash). Compute
it yourself from the artifact you are about to load:
openssl dgst -sha384 -binary dist/singula-identity.umd.min.js | openssl base64 -Ascripts/release.sh prints the value for the build it just made, right before it
uploads, and the CI publish run prints it as a notice — copy that line, don't
reuse an older one.
Allowed origins (required)
Before you integrate, register the origin of the page that will embed the widget. The hosted page refuses to load for an organization that has not configured its allowed origins: an empty list is a misconfiguration, not a wildcard. Add the origin in the dashboard under Settings › Branding → Allowed origins — scheme + host + port of the embedding page, one per line, no path:
https://acme.com
https://checkout.acme.com
http://localhost:5173The origin the page checks is the browser-stamped event.origin of the SDK's
init message, so it is the real embedding origin and a page cannot forge it
(see Security model). With nothing configured the widget fails
closed with onError({ code: 'origin_not_configured' }); with an origin missing
from the list, origin_not_allowed. In both cases the capture never starts.
Error codes
Everything that reaches onError carries one of these codes. In modal mode
a fatal error also closes the widget with onClose('error'); inline stays
mounted so your page can render its own fallback.
| code | Raised by | Meaning / fix |
|-------------------------|-------------|---------------|
| origin_not_configured | hosted page | The organization has no Allowed origins configured. Add the embedding origin 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 handshake arrived within 5 s and the referrer did not identify a listed origin — the parent could not be verified. Usually a raw <iframe src="embed_url"> without this SDK, or a page suppressing its referrer. |
| session_not_found | hosted page | The token was never issued, or it belongs to the other environment: loading the session got a 404. Retrying cannot help — mint a new session. |
| config_failed | hosted page | The embed configuration could not be loaded (offline, a 5xx, a deploy mid-flight). Nothing about the session is decided, so it is worth another try: reopen the widget. Only the identity page hands this to the SDK; the flow page keeps a transient config failure on screen with its own Retry rather than failing the widget out from under it. |
| session_cancelled | hosted page | The verification session was cancelled server-side. |
| session_expired | hosted page | The session expired before the run finished. Mint a new token. It arrives in three shapes, all the same code: an error from the identity page, an error from the flow runner (the run ended expired, or loading the session got a 410), or a completed { decision: 'expired' }. |
| session_abandoned | hosted page | The person left and the session was closed server-side. Same shapes as session_expired, minus the 410. |
| invalid_token | SDK | The token is a shareable link (flk_…). Links are opened directly in the browser, never embedded: nothing is mounted and no request is made. Embed a session token (flw_…) instead — see Shareable links. |
| verdict_timeout | SDK | No completed arrived within verdictTimeoutMs (default 35 min) of the submit. See The verdict deadline. |
| load_timeout | SDK | The iframe never reported ready within loadTimeoutMs (default 20 s). |
| iframe_load_error | SDK | The iframe element fired error: the embed URL could not be loaded at all (network, CSP frame-src, wrong embedBaseUrl). |
Neither session_expired nor session_abandoned is ever a verdict: they reach
onError, never onComplete, and the local state machine goes to failed.
A flow that dies without a verdict. The hosted flow runner reports it instead of going quiet: an
errorwithsession_expired/session_abandoned(FlowRunner→FlowEmbed), so the widget closes ononErrorrather than sitting out itsverdictTimeoutMs. The SDK also accepts the other two shapessrc/messages.tsfixes for the same situation — acompletedwhosedecisionis'expired'/'abandoned'(raised as the same error codes), and the neutralcompleted { decision: 'withheld' }when the organization keeps even that out of the browser.
Quick start
Verification always begins with a short-lived verification token. Your backend mints it by calling the Singula API with your secret API key (sk_live_*). The API key never touches the browser — only the token does. Treat the token as opaque: today the API returns a uuid v4, and the SDK accepts any URL-safe string up to 512 characters (the vt_… placeholders below are just placeholders).
1. Mint a token (server-side)
curl -X POST https://api.singula.mx/app/identity-verification/customer/{customerId}/create \
-H "Authorization: Bearer sk_live_xxx"
# → { request_id, verification_token, embed_url, expires_at }2. Open the widget (client-side)
<button id="verify">Verify identity</button>
<script src="https://cdn.singula.mx/identity-js/v1/singula-identity.umd.min.js"></script>
<script>
document.getElementById('verify').addEventListener('click', () => {
Singula.Identity.open({
token: 'vt_abc123...', // from step 1
onStage: ({ stage, status }) => console.log(stage, status),
onComplete: ({ decision, requestId }) => {
// decision: 'approved' | 'declined' | 'review' | 'withheld'
console.log('done', decision, requestId);
},
onError: (err) => console.error(err),
});
});
</script>With a bundler, the same call is an import:
import { Identity } from '@singula_systems/identity-js';
const widget = Identity.open({
token: 'vt_abc123...',
mode: 'modal',
language: 'es',
theme: {
primaryColor: '#ff6600',
secondaryColor: '#111827',
logoUrl: 'https://acme.com/logo.svg',
brandName: 'Acme',
},
onStage: (u) => console.log(u.stage, u.status, u.score),
onComplete: ({ decision, score }) => console.log('decision', decision, score),
onError: (err) => console.error(err),
onClose: (reason) => {},
});
widget.retryStage('liveness'); // RESERVED — the hosted page ignores it today
widget.close(); // programmatic closeModal accessibility
The overlay is a real dialog: the sheet carries role="dialog" + aria-modal="true", Escape closes it (captured before the host page can swallow the key), focus moves to the close button on open and is handed back to whatever had it when the modal closes.
Two honest limits, both inherent to a cross-origin frame: focus is not trapped inside the sheet (the parent cannot see or manage focus inside the iframe, so the hosted page owns the tab order once focus enters it), and Escape only works while focus is in your page — a keydown inside the iframe is delivered to the iframe's document, never to yours. The hosted page renders its own close affordance for that case.
Inline mode
Mount the widget inside your own layout instead of a full-screen overlay:
<div id="kyc-mount"></div>
<script>
Singula.Identity.open({
token: 'vt_abc123...',
mode: 'inline',
container: '#kyc-mount',
});
</script>jQuery
The optional jQuery wrapper ships as an ESM/CJS side-effect module (@singula_systems/identity-js/jquery), so it's used through a bundler — importing it attaches $.fn.singulaIdentity, which takes the same options as Identity.open(). Calling it on a selector uses the matched element as the inline container automatically.
import '@singula_systems/identity-js/jquery';
$('#kyc-mount').singulaIdentity({ token: 'vt_abc123...', mode: 'inline' });Options
| Option | Type | Default | Description |
|-----------------|--------------------------------|--------------------------|-------------|
| token | string | — | Required. Verification token minted by your backend. URL-safe characters, ≤512 chars. A shareable-link token (flk_…) is refused with onError('invalid_token') — see Shareable links. |
| kind | 'identity' \| 'flow' | inferred from token | Which hosted session the token opens. A flw_… token opens a flow on its own; pass this when your token has no prefix. An explicit value wins over the prefix; anything else throws. |
| mode | 'modal' \| 'inline' | 'modal' | How to render the widget. |
| container | string \| HTMLElement | — | Required for inline. CSS selector or element. |
| embedBaseUrl | string | https://app.singula.mx | Override for self-hosted / staging environments. Must be https: (http: only for localhost); anything else throws. |
| language | 'es' \| 'en' | org default | Force a UI language. An unknown value is dropped, not forwarded. |
| theme | ThemeOverrides | org branding | Per-call white-label overrides (see Branding). |
| loadTimeoutMs | number | 20000 | Deadline for the iframe's ready. On expiry: onError('load_timeout'). 0 disables. |
| verdictTimeoutMs | number | 2100000 (35 min) | Deadline for the verdict after submit. On expiry: onError('verdict_timeout'). 0 disables. |
| onReady | () => void | — | Iframe loaded and ready. |
| onStage | (u: StageUpdate) => void | — | A stage changed status. { stage, tool?, status, score?, reason? }. Identity: the five fixed stages. Flow: the flow's own step keys, plus status: 'skipped'. reason is optional and normally absent — never build UI on it. |
| onComplete | (r: CompletedResult) => void | — | Flow finished. { decision, score?, requestId?, sessionId? } — decision is 'approved' \| 'declined' \| 'review' \| 'withheld'. sessionId is flow-only. |
| onError | (e: ErrorEvent) => void | — | { code, message }. Fatal errors only. |
| onClose | (reason) => void | — | Reason: 'user' \| 'success' \| 'error' \| 'programmatic'. |
| onRetake | (i: RetakeInfo) => void | — | The hosted capture asked the person to retake one document photo: { side, reason, attempt, detectedDocumentType? }. Informational — the run goes on. Hosted page Saas-App ≥ 1.43.0 (wrong_document / detectedDocumentType ≥ 1.44.0). See Document photo retakes. |
| onStepChange | (e) => void | — | Deprecated (v1). { step, current, total, document_type } |
| onCapture | (e) => void | — | Deprecated (v1). { kind: 'doc_front' \| 'doc_back' \| 'selfie' } |
| onSuccess | (e) => void | — | Deprecated (v1). Use onComplete. |
Identity.open() returns a WidgetHandle:
| Member | Description |
|---------------------|-------------|
| close() | Close programmatically (fires onClose('programmatic')). |
| isReady() | true once the iframe emitted ready. |
| retryStage(stage) | Reserved. Posts retry_stage and advances the local state machine; the hosted page does not act on it yet (no-op on the iframe side). On a flow session it is a full no-op — see Flujos. |
| getSnapshot() | Current immutable FlowSnapshot of the state machine. |
Callbacks & lifecycle
Compatibility.
onStageandonCompleteare driven by the v2stage_update/completedmessages, which the hosted page emits from Saas-App 0.84.0 onward. Against an older hosted page only the legacyonSuccessfires (withrequestId) — the SDK stays fully functional, it just never learns the decision.onReady,onErrorandonClosework against every version.
The iframe drives a staged verification pipeline and reports progress one stage at a time. Wire onStage to render your own progress UI, and onComplete for the final decision:
Identity.open({
token: 'vt_...',
onStage: ({ stage, status, score }) => {
// stage: 'received' | 'liveness' | 'face_match' | 'document' | 'decision'
// status: 'pending' | 'running' | 'passed' | 'failed'
renderProgress(stage, status);
if (status === 'failed' && stage === 'liveness') showRetry();
},
onComplete: ({ decision, score }) => {
// decision: 'approved' | 'declined' | 'review' | 'withheld'
route(decision);
},
});The verdict may be withheld
allStagesPassed()is not a success signal under the default. With the verdict withheld the hosted page never reports thedecisionstage (it cannot claimpassedfor a verdict it does not know), soallStagesPassed(snapshot)staysfalseeven thoughonComplete({ decision: 'withheld' })fired andisTerminal(snapshot.state)istrue. Drive your UI fromonComplete/isTerminal, and read the real verdict server-side.
decision is not always a verdict. An organization can decide not to expose
the decision in the browser — and that is the default — in which case the
hosted page emits completed { decision: 'withheld', requestId } with no
score. It means the flow finished, not that it failed: the local FSM goes
to completed, the modal closes normally, and onComplete fires as always.
Read the real decision server-side, keyed by requestId:
GET /app/identity-verification/status/:requestIdwith yoursk_live_*key, or- the
identity.completedwebhook (HMAC-SHA256,X-Singula-Signature).
onComplete: async ({ decision, requestId }) => {
if (decision === 'withheld') {
// Nothing to route on yet — ask YOUR backend, which holds the API key.
const real = await fetch(`/api/kyc/${requestId}`).then((r) => r.json());
return route(real.decision);
}
route(decision); // the org exposes the verdict; still only UX (see below)
},An older hosted page never sends withheld, so a handler for it is additive —
but write it anyway: it is what a freshly-configured organization emits.
The verdict deadline
Against a v2 hosted page the legacy success message arrives when the user
submits, and the verdict (completed) lands when adjudication finishes —
minutes later, over a channel that can die (the session gets cancelled or expires
server-side, the hosted poller stops, a background tab is throttled). The SDK
therefore arms a backstop as soon as it learns the payload was submitted (the
legacy success from a v2 peer, or the first stage_update) and clears it on
completed or on any error. When it expires:
onError({ code: 'verdict_timeout', message })fires once;- the local FSM goes to
failed; - modal mode closes with
onClose('error'); inline stays mounted so your page can render its own fallback.
Default 35 minutes (verdictTimeoutMs), 0 disables it. Without this a widget
whose verdict never arrived stayed mounted forever and fired no terminal callback
at all.
Document photo retakes (onRetake)
Compatibility.
onRetakeis driven by thedocument_retakemessage, which the hosted page emits from Saas-App 1.43.0 onward. An older hosted page never sends it, so the callback simply never fires and nothing else changes. In the other direction, an SDK older than 0.7.0 ignores the message like any unknowntype.The
wrong_documentreason anddetectedDocumentTypearrive from Saas-App 1.44.0 (SDK 0.8.0). An SDK older than 0.8.0 still relays that retake, but reportswrong_documentas'unreadable'and never sees the detected document.
Each document photo is checked on Singula's side as soon as it is uploaded, while
the person keeps going. If a photo would not hold up in the verdict — glare over
the card, data that cannot be read, the other side of the document, another
document altogether — the hosted page asks for that one photo again, right after the liveness check and before
submitting (the liveness check is never repeated). onRetake tells your page it
happened:
Identity.open({
token: 'vt_...',
onRetake: ({ side, reason, attempt, detectedDocumentType }) => {
// side: 'front' | 'back' (a passport's data page is 'front')
// reason: one of the nine reasons below
// attempt: 1-based — 1 means the first photo of that side was flagged
// detectedDocumentType: 'ine' | 'passport' | 'resident_card' — only on a
// 'wrong_document' whose document was recognized; otherwise absent
analytics.track('kyc_document_retake', { side, reason, attempt, detectedDocumentType });
},
});| reason | The photo… |
|---|---|
| glare | has a reflection over the document |
| blurry | is out of focus or moved |
| dark | is too dark |
| cropped | leaves part of the document out of the frame |
| low_resolution | shows the document too small to read |
| unreadable | could not be read and no clearer cause was found — also what any reason this SDK does not know arrives as |
| wrong_side | shows the other side of the same document |
| wrong_document | shows a different identity document than the one being captured — an INE where the passport goes, a resident card where the INE goes… detectedDocumentType names it ('ine', 'passport' or 'resident_card') when it was recognized. Saas-App ≥ 1.44.0 |
| no_document | does not show an identity document at all |
- Informational only. The run is still capturing: the state machine does not move, no deadline starts or stops, and no other callback fires. It is not an error — there is nothing to handle; the hosted page owns the retake screen.
reasonis a closed list. A code this SDK does not know yet (sent by a newer hosted page) arrives as'unreadable', so an exhaustiveswitchkeeps working. In TypeScript, 0.8.0 widenedRetakeReason(wrong_document) andDocumentType(resident_card): aswitchwith aneverdefault needs the new case to compile.detectedDocumentTypeis optional, even on awrong_document. It is there only when the platform recognized the other document, and only with that reason; a value this SDK does not know drops the field, never the retake itself.attemptis the pre-check attempt of that side — the same numberGET /app/identity-verification/upload/:token/precheckreports for it. At most three photos are taken per side; after the third the run continues and the verdict decides, exactly as without a retake.- The payload is those three fields only, plus
detectedDocumentTypeon awrong_document— no photo, no personal data. - Both session kinds accept it; on a flow it carries
kind: 'flow'like every message of a flow run. Whether a flow relays the retakes of its identity step is up to the hosted runner, so treat the callback as optional on both.
Building your own capture screens instead of embedding? The same check is exposed
over the API: confirm the upload, then poll
GET /app/identity-verification/upload/:token/precheck and offer a retake for a
slot with retake_suggested: true.
Legacy step vocabulary (v1)
onStepChange reports the hosted page's own step, one of — in flow order:
intro → doc_selection → doc_capture_front → doc_capture_back (INE and
resident card; a passport has one photo) → liveness_intro → liveness →
verifying → completed
Its document_type is the identity document being captured: 'ine',
'passport', 'resident_card' (the Mexican resident card issued by the INM —
hosted page Saas-App ≥ 1.44.0), or null.
A step_changed carrying anything else is rejected as malformed and never
reaches your callback. That includes an SDK older than 0.8.0 meeting
document_type: 'resident_card': it drops the message, so the deprecated
onStepChange stays silent for a resident-card run (every other callback is
unaffected) — upgrade before your sessions offer the resident card. The selfie is captured inside the liveness steps; it is
not a step of its own, it is reported by the deprecated onCapture as
{ kind: 'selfie' }.
Message protocol
Parent ↔ iframe communication is a versioned postMessage channel. Every message travels in an envelope { source: 'singula', channel: 'identity', v, type, … }, and the SDK strictly validates event.origin and event.source before trusting any payload. The protocol is additive and backward-compatible — a peer on an older version silently ignores unknown types.
A flow run speaks the same protocol on the same channel and adds exactly one optional envelope field, kind: 'flow', which switches the stage vocabulary from the fixed five to "any step key, plus skipped". No kind ⇒ 'identity', so an identity page's messages are unchanged byte for byte. src/messages.ts carries the exact envelope a runner page must emit, including how a step's server-side status + outcome map onto the five browser statuses.
Iframe → parent (inbound):
| type | Payload | Maps to |
|----------------|--------------------------------------|---------|
| ready | — (version travels in the envelope's v) | onReady |
| stage_update | { stage, status, score?, reason? } — flow: + tool?, and stage is a step key. reason is optional and normally absent | onStage |
| completed | { decision, score?, requestId? } — decision may be 'withheld'; flow: + sessionId?. A decision of 'expired' / 'abandoned' is a lifecycle end, not a verdict: it raises onError | onComplete |
| document_retake | { side, reason, attempt, detected_document_type? } — side is 'front' \| 'back', attempt an integer 1–10, reason one of the nine retake reasons (any other string arrives as 'unreadable'); anything else is malformed and dropped. detected_document_type (Saas-App ≥ 1.44.0) is 'ine' \| 'passport' \| 'resident_card', lower-case, only with reason: 'wrong_document'; it reaches onRetake as detectedDocumentType, and any other value is dropped from the payload — the message still arrives. Both kinds. Hosted page Saas-App ≥ 1.43.0 | onRetake (informational: no FSM step, no other callback) |
| error | { code, message } | onError |
| resize | { height } | inline autosize (clamped to 320–8000 px) |
| close | — | onClose('user') |
| step_changed, capture, success | v1 legacy | onStepChange / onCapture / onSuccess |
Parent → iframe (outbound):
| type | Payload | Sent when |
|---------------|------------------------------------------|-----------|
| init | { protocolVersion, theme?, language? } | right after ready (authoritative branding channel) |
| retry_stage | { stage } | handle.retryStage(stage) — reserved, the hosted page does not handle it yet; never sent on a flow |
| cancel | — | reserved |
State machine
The SDK ships a tiny, dependency-free finite state machine (< 150 lines) modelling the whole flow: idle → permission → capturing → uploading → verifying(per-stage) → completed | failed. Both the hosted front-end and your own host UI consume the same model. The widget keeps one internally (read it via handle.getSnapshot()), or drive your own:
import { createFlowMachine, isTerminal, allStagesPassed } from '@singula_systems/identity-js';
const m = createFlowMachine();
m.subscribe((snap) => console.log(snap.state, snap.stages));
m.send({ type: 'STAGE_UPDATE', stage: 'liveness', status: 'passed', score: 0.98 });
m.send({ type: 'COMPLETE', decision: 'approved' });
isTerminal(m.get().state); // trueFlowSnapshot is { state, kind, stages: Record<string, { status, tool?, score?, reason? }>, decision?, score?, reason? }.
Guard helpers: isTerminal(state), canRetry(snap), allStagesPassed(snap).
stages is keyed by string because a flow names its own steps. An identity
snapshot is still seeded with the five fixed stages, all pending, exactly as
before; a flow snapshot starts empty and grows one key per step the runner
reports — so allStagesPassed() on a flow means "every step reported so far
passed", and is false while nothing has been reported (never a vacuous true).
initialSnapshot() with no argument is the identity snapshot; initialSnapshot('flow')
is the empty one.
Flujos (KYC/KYB flows)
A flujo is a verification pipeline your organization assembles in the Singula dashboard — a data form, then any sequence of steps (INE, KYC with liveness, CURP, RFC against the SAT, a document upload, an email OTP, a blacklist or PEP lookup) with per-step rules for what happens on a failure. The same SDK opens it: one extra route, the same modal/inline modes, the same callbacks.
1. Open a flow session (server-side)
curl -X POST https://api.singula.mx/app/flows/$FLOW_ID/sessions \
-H "Authorization: Bearer $SINGULA_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "customer_id": "cus_123", "external_id": "order-9981" }'
# → { session_id, request_id, token: "flw_…", url, embed_url, expires_at,
# flow_version, estimate_centavos }The flow must be published, and the token is short-lived (expires_at). Mint
it on your backend — never with an API key in the browser — and hand the
token to the page.
2. Open the widget (client-side)
Identity.open({
token: 'flw_…', // the `token` from the call above
// kind: 'flow', // only needed if your token has no `flw_` prefix
onStage: ({ stage, tool, status }) => {
// stage: the STEP KEY of your published flow — 'datos', 'ine', 'rfc_sat', …
// tool: the tool that step runs — 'form', 'ineValidation', 'verifyRfc', …
// status: 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
renderStep(stage, tool, status);
},
onComplete: ({ decision, sessionId, requestId }) => {
// decision: 'approved' | 'declined' | 'review' | 'withheld'
route(decision, sessionId);
},
onError: ({ code, message }) => report(code, message),
});That mounts https://app.singula.mx/embed/flow/<token>. Everything else is
unchanged: mode, container, theme, language, loadTimeoutMs,
verdictTimeoutMs, onReady, onClose and handle.close() all behave exactly
as they do for an identity session. A flow carries an appearance of its own
(settings.ui, published with the flow); theme overrides it for one session —
see Theme precedence.
Shareable links (flk_)
A shareable link (POST /app/flows/:id/links) is hosted only. Its flk_…
token names the link, not a run: every visit to the link's url (/l/flk_… on
the hosted app) opens a new run on Singula's page. Send people to that URL
or show its QR — there is nothing to embed.
Identity.open refuses a flk_ token whatever kind says: it mounts nothing
(no iframe, no request; a widget already open stays as it was) and reports
onError({ code: 'invalid_token' }) right after open() returns — plus
onClose('error') in modal mode, as for any fatal error. To run a flow inside
your page, mint one session per person with POST /app/flows/:id/sessions and
open its flw_… token.
Representatives (KYB)
A KYB flow can carry a representatives step (interaction: 'people'): the
hosted page collects the people, and the platform opens one child KYC run
per person, each with its own session token and link. Through the SDK that
step reports stage_update like any other — stage is the step key, and
status only moves to passed / failed / skipped once every child run
has closed. The child runs are never reported through the parent's
channel: they are separate sessions, opened by the people themselves from
their own links. An integrator that wants their individual outcome reads the
parent session detail (steps[].children) or the flow.completed webhook
(children[]) from its backend.
onStage: ({ stage, status }) => {
// stage === your `representatives` step key; status resolves only once
// every child KYC run has closed. The children themselves never arrive
// here — read them from steps[].children, or `children[]` on
// flow.completed, from your backend.
},What is different in a flow
| | Identity | Flow |
|---|---|---|
| Route | /embed/:token | /embed/flow/:token |
| Token | uuid from POST /identity-verification/sessions | flw_ + uuid from POST /app/flows/:id/sessions |
| onStage().stage | the five fixed stages | your step keys (^[a-z0-9_]{2,40}$), dynamic |
| onStage().tool | never sent | the tool the step runs, or 'form' |
| onStage().status | pending \| running \| passed \| failed | the same, plus skipped for an optional step the user skipped |
| onComplete() | { decision, score?, requestId? } | { decision, requestId?, sessionId? } — no score |
| getSnapshot().stages | seeded with the five stages | starts empty, one key per reported step |
| onStage().reason | optional, rarely sent | optional, never sent |
| onRetake() | from the document capture (Saas-App ≥ 1.43.0) | the same payload, stamped kind: 'flow', when the runner relays its identity step's retakes |
| retryStage() | posts retry_stage (reserved) | full no-op |
retryStage() does nothing on a flow by design. The hosted runner owns each
step's retry affordance, has no retry_stage handler, and the session's real
state lives on the server — rewinding the local snapshot would only desync it.
reason is optional on stage_update and normally absent: internal reason
codes (blacklist_high, …) do not travel into the end user's browser, and the
flow runner never sends one. Render your own copy from stage + status; do not
build UI that depends on reason being there. It is clamped to 2 KB if it ever
arrives.
The decision
decision means the same four things it does for identity, and 'withheld' is
still the default: the organization decides whether the browser gets to see
the verdict (settings.expose_decision_to_user). 'review' is a finished run
waiting on a human, not a failure — the local FSM goes to completed, not
failed.
onComplete is UX, never authorization (see below).
Read the run of record server-side, with your API key:
onComplete: async ({ decision, sessionId }) => {
const run = await fetch(`/api/flows/${sessionId}`).then((r) => r.json());
route(run.decision); // your backend called GET /app/flows/sessions/:session_id
},…or take the flow.completed webhook (HMAC-SHA256, X-Singula-Signature), which
is what a flow that ends in review will fire again once a reviewer decides.
Resubmissions
A reviewer can send a run that ended in review or declined back to the
person to redo specific steps (POST /app/flows/sessions/:id/resubmit). The run
becomes resubmitted and the same flw_… token opens it again (its expiry
is extended): listen for the flow.resubmitted webhook and, when the person
comes back, open the widget with that token — the runner asks the person to
redo those steps.
So one token can produce more than one onComplete. Each Identity.open
fires it at most once, but the reopened run fires it again when it closes (and
opening the widget on a run that already ended reports its verdict again too);
onStage reports the reopened steps as pending / running again, and
flow.completed arrives a second time carrying resubmission_count.
Treat every one of these events as idempotent: key on sessionId / requestId
(session_id / request_id in webhooks — both stay the same across rounds), let
the latest status win, and never read a second onComplete as a duplicate to
drop or as a new run.
Progress UI without a callback
getSnapshot() reflects the flow's own steps, so a host that prefers polling or
a store subscription gets the same data:
const handle = Identity.open({ token: 'flw_…', mode: 'inline', container: '#kyc' });
// { state: 'verifying', kind: 'flow', stages: {
// datos: { status: 'passed', tool: 'form' },
// ine: { status: 'running', tool: 'ineValidation' },
// rfc_sat: { status: 'failed', tool: 'verifyRfc' },
// } }
handle.getSnapshot();Identity inside a KYC over the API
A flow run can be opened on the API channel (channel: 'api'), where the
integrator's own front end runs the capture steps instead of our hosted page:
it asks the run what comes next, calls the tool on its customer and attaches the
folio. Liveness is the one step you do not reimplement — you hand it to this
SDK, exactly as in an ordinary identity session.
GET /app/flows/sessions/:id/next names it for you: the identity step comes back
with how: 'sdk' and an identity_start route.
1. Start the step (server-side). The route authenticates with the run's
flw_… token in the URL — it carries no API key:
curl -X POST \
https://api.singula.mx/app/flow-embed/$SESSION_TOKEN/steps/$STEP_KEY/identity/start
# → { verification_token, embed_url, expires_at }2. Open it (client-side). verification_token is an ordinary identity token,
not a flw_ one, so there is nothing special to pass:
Identity.open({
token: verificationToken, // from identity/start — never your API key
onComplete: ({ requestId }) => {
// The flow advances BY ITSELF. Do not attach this requestId: that record is
// born INSIDE the step, so the runner moves on the moment the check closes.
// Just ask the run what is next.
pollNextStep();
},
onError: ({ code }) => report(code),
});3. There is no third step. Every other step of an API-channel run is attached
by the integrator's backend (POST /app/flows/sessions/:id/steps/:key/attach);
this one is not — it resolves without an attach. onComplete here is UX like
everywhere else: the verdict of record is read server-side, and the run's own
verdict arrives when the backend calls POST /app/flows/sessions/:id/close.
Embedding still requires the organization's allowed origins (see
above): an empty list fails closed, exactly as it
does for a hosted run. The full walkthrough of the API channel —next,
attach, close, the guards and the billing— is at
https://singula.mx/docs/kyc-api.
Branding (white-label)
The widget reads branding from the org's Singula dashboard (logo, colors, copy, language, allowed origins). The theme option overrides those fields for a single invocation, so one integration can serve multiple end-brands:
| Field | Type | Description |
|------------------|----------|-------------|
| primaryColor | string | Primary brand color. Any valid CSS color; a value carrying ;, {, }, url(, @import or a comment is rejected. |
| secondaryColor | string | Accent color for gradients / secondary controls. Same validation. |
| logoUrl | string | Absolute HTTPS URL of the header logo. http:, data: and javascript: throw. |
| brandName | string | Display name used in copy, title and a11y labels. <, > and control characters are stripped; clamped to 64 chars. |
Identity.open({
token: 'vt_...',
language: 'en',
theme: {
primaryColor: '#ff6600',
secondaryColor: '#111827',
logoUrl: 'https://acme.com/logo.svg',
brandName: 'Acme',
},
});Overrides are forwarded to the iframe twice: as URL params (for instant first paint) and via the authoritative init postMessage once the iframe is ready. To restrict which domains may embed your widget, set the Allowed origins list in Settings → Branding.
Theme precedence
Three layers decide how a run looks, and each one only overrides the fields it actually sets:
| Layer | Set in | Applies to |
|---|---|---|
| Organization | Settings → Branding in the dashboard | every run of the organization |
| Flow (settings.ui) | the flow editor's Apariencia panel, frozen into the published version | every run of that flow |
| Identity.open({ theme }) | your page, per call | that one session |
Later layers win — organization < flow < Identity.open({ theme }). Nothing
is all-or-nothing: a theme carrying only primaryColor repaints the primary
color and leaves the flow's logo, layout and copy exactly as published. Omit
theme entirely and the run looks like the flow, or like the organization when
the flow sets no appearance of its own.
A flow's settings.ui covers more than the four fields above (layout, per-screen
copy, the default document types) because it is published with the flow. The
SDK's theme is deliberately the narrow per-session slice — colors, logo, brand
name — and is the only layer that can differ between two runs of the same flow.
Hosted-page requirement. The
initmessage has always carriedtheme; applying it is what the hosted pages gained in 0.5.0. Against an older hosted page the message is still sent and still validated — the page simply kept rendering the organization's branding. Nothing changes on your side when it is upgraded, which is why this is a minor and not a breaking release.
Per-session theme on a flow — one integration, one published flow, one brand per tenant:
// `token` came from POST /app/flows/:id/sessions on your backend.
Identity.open({
token: 'flw_…',
language: 'en',
theme: {
primaryColor: tenant.primaryColor, // wins over the flow's settings.ui
logoUrl: tenant.logoUrl, // absolute https URL
brandName: tenant.name,
},
onComplete: ({ decision, sessionId }) => route(decision, sessionId),
});The same object is accepted, validated and forwarded identically for both kinds
of session — an identity token and a flw_… flow token take the exact same
route through the SDK.
Security model
- The proprietary verification logic never ships to the integrator. Camera, OCR, liveness, biometric face-match and adjudication all run inside the hosted iframe on Singula's origin. What you embed — and what this repository contains — is only a small loader whose entire job is to mount the iframe and relay a validated message channel.
- The SDK never sees your API key. The backend mints
vt_*tokens withsk_live_*; only the short-lived token reaches the browser. - The iframe origin and the verification page are served from Singula. Camera permissions are granted to Singula's domain, not yours.
- Result delivery is via webhooks signed with HMAC-SHA256 (
X-Singula-Signature). Verify the signature on your server. - How the parent origin is established. The dashboard's Allowed origins list is enforced at the
/embed/config/<token>lookup and again at thepostMessageboundary — and on the hosted page the origin it checks is theevent.originof the SDK'sinitmessage. That value is stamped by the browser on delivery: a page cannot forge it, and it identifies the real embedding origin regardless of what the page claims about itself. The sequence isready(broadcast, carries no data) → the hosted page waits forinit→ every later message is addressed to that one origin, never'*'. The allowlist is required: an organization that configured none getserror origin_not_configuredand the widget never loads (an empty list is a misconfiguration, not a wildcard). An unlisted origin getsorigin_not_allowed, and if noinitarrives within 5 s the page fails closed withorigin_unknown. In all three cases the capture never starts — see Allowed origins (required). - The iframe's
referrerPolicyisstrict-origin-when-cross-origin— theReferercarries the integrator's origin only, never the path or query string, so a page URL holding PII is not disclosed to Singula. The SDK sets it explicitly instead of relying on the browser default (the same policy in every current engine) so the guarantee does not depend on the embedding page's ownReferrer-Policy. A stricterno-referrerwas tried while developing 0.3.0 and dropped before release: it blanksdocument.referrerinside the iframe, which the hosted page uses as a fallback hint when no SDKinitidentifies the parent. The referrer is only ever a hint; the authoritative, non-forgeable origin is theinitevent.originabove. - Inbound messages are validated twice before any callback runs:
event.originmust equal the embed origin andevent.sourcemust be exactly the widget'scontentWindow— both non-null, because a detached iframe has anullcontentWindowand a message from a closed context has anullsource, andnull === nullwould otherwise have satisfied a bare equality check. Then the envelope and the specific payload's fields are type-checked against runtime allow-lists. A malformed or hostile payload never reaches your code. - Inbound free text and geometry are bounded.
error.messageandstage_update.reasonare clamped to 2 KB,codeandstage_update.toolto 128 chars,requestId/sessionIdto 256, and an inlineresizeto 320–8000 px. A flow'sstagemust match^[a-z0-9_]{2,40}$(the same shape MasterApi enforces on a step key when the flow is published), so "dynamic" never means "unbounded". Adocument_retakecarries no free text at all:sidemust befront/back,attemptan integer from 1 to 10,reasonis mapped onto its closed nine-value list (anything else becomesunreadable), so the raw string never reachesonRetake, anddetected_document_typeis kept only when it is one of the three document types (otherwise the field is dropped). The iframe is ours, but "ours" is not something this SDK can assert while running on your page: a compromised or merely buggy hosted page must not be able to hand your error reporting a megabyte string or your layout a billion pixels. - Every option you pass is validated before a single DOM node is created, and a rejected option throws without leaving any chrome mounted:
embedBaseUrlmust behttps:(http:only forlocalhost/127.0.0.1/[::1]), with a real host and no embedded credentials. This is the one that matters most:new URL()parsesjavascript:alert(document.domain)//happily and reports its origin as the opaque string'null', and aniframe.srcstarting withjavascript:executes in the embedding page's own realm. If you deriveembedBaseUrlfrom anything you do not fully control — a query string, a CMS field, a tenant record — that used to be an XSS primitive against your page.data:andfile:are opaque the same way; plainhttp:would downgrade the channel to cleartext.tokenmust be URL-safe (A–Z a–z 0–9 . _ ~ : + = -) and at most 512 chars, so it cannot escape its path segment. The SDK never puts the token in an error message, a console call or the iframetitle— it is a bearer credential.theme.primaryColor/secondaryColormust be real CSS colors (validated withCSS.supports, with a grammar fallback) and are rejected outright if they carry;,{,},url(,@importor a comment — a value likered; background:url(//evil)never reaches a stylesheet.theme.logoUrlmust be an absolute https URL (javascript:,data:andhttp:are rejected).theme.brandNameis stripped of<,>and control characters and clamped to 64 chars.languagemust beesoren; anything else is dropped rather than forwarded.
- The iframe delegates
cameraonly.microphonewas removed in 0.3.0: everygetUserMediacall on the hosted page passesaudio: false, so the delegation was unused — and a permission the flow never needs is a prompt your end user should never see. - No
sandboxattribute, deliberately. The hosted page needs same-origin storage and camera access to run the flow, so any useful sandbox would have to includeallow-same-origin allow-scripts— which, on a cross-origin frame, buys nothing a separate origin does not already give you. The isolation here is the origin boundary plus the validated message channel, not a sandbox token list. - What the SDK cannot do, by construction: it makes no network request of its own. The only destination in the bundle is the iframe pointed at
embedBaseUrl, andscripts/security-check.mjsasserts that on every build, along with the absence ofeval,new Function,fetch,XMLHttpRequest,localStorage,document.cookieanddocument.write. - The shipped CDN bundle is minified and identifier-mangled (esbuild
minify, source maps off). Its entire global footprint is one key —window.Singula.Identity, asserted on every build byscripts/check-umd.mjs; SDK internals (the state machine, the message helpers) are reachable only through the npm ESM/CJS builds, which keep source maps for debugging, as does the non-min UMD. - Pin the CDN bundle with Subresource Integrity — see Pin it with Subresource Integrity.
onComplete is UX, not authorization
Never grant anything on the strength of a browser callback. onStage,
onComplete and getSnapshot() all describe what the page in front of this user
says happened. They exist so you can render progress and route the user to the
right screen — not so you can decide that an identity was verified.
This is also why withheld exists, and why it is the default: the organization
can simply decline to publish the verdict to a channel it does not control. Your
integration should not behave differently — a withheld result and an
'approved' one both mean ask your backend.
Three concrete reasons, all demonstrable:
- The whole channel lives in a browser the end user controls. Anyone can open
devtools and call your
onCompletehandler with{ decision: 'approved' }. The SDK's origin / source / payload checks stop a third party from forging messages; they cannot stop the person holding the keyboard. - Even against the genuine hosted page,
stage_updatemessages are independent of the verdict. Fivestatus: 'passed'reports makeallStagesPassed(snapshot)true with nocompletedmessage and no decision anywhere in the snapshot (asserted inscripts/security-check.mjs). "All green" is not "approved". - The verdict may not even be in the message: with the default configuration
completedcarriesdecision: 'withheld'and noscore, precisely so the browser is never the place a decision is published (asserted inscripts/security-check.mjs).
The verdict of record is server-side: the
webhook signed with HMAC-SHA256 (X-Singula-Signature, verified on your
server), or a read of GET /app/identity-verification/status/{requestId} with
your sk_live_* key. Use requestId from onComplete to correlate — then ask
your own backend.
onComplete: async ({ requestId }) => {
showSpinner();
// YOUR server, holding YOUR API key, asks Singula what actually happened.
const { decision } = await fetch(`/api/kyc/${requestId}`).then((r) => r.json());
route(decision); // authorization happened on the server, not here
},Versioning
Once live (see Install), the CDN exposes both a moving /v1/ alias (auto-tracks the latest 1.x) and pinned versions like /v1.2.3/. Pin in production if you need byte-for-byte stability:
<script src="https://cdn.singula.mx/identity-js/v0.8.0/singula-identity.umd.min.js"></script>The widget UI lives on Singula's servers and rolls forward independently — new vision steps land without an SDK upgrade.
Upgrading from 0.3.x to 0.4.0
0.4.0 is additive: Identity.open / close / version, every callback and
every identity message behave exactly as they did, and an identity integration
needs no change at runtime. Three types widened, which only matters if you
annotate them yourself:
| Was (0.3.x) | Is (0.4.0) | Why |
|---|---|---|
| StageUpdate.stage: Stage | StageName = Stage \| (string & {}) | a flow's stages are its own step keys |
| StageUpdate.status: StageStatus | FlowStageStatus = StageStatus \| 'skipped' | an optional flow step the user skipped |
| FlowSnapshot.stages: Record<Stage, …> | Record<string, …> | same reason as stage |
Stage and StageStatus are still exported, unchanged, and a
Record<Stage, StageSnapshot> annotation still compiles (an index signature
satisfies it). Two things do not:
1. An exhaustive switch with a never fallthrough. 'skipped' now reaches
the default branch:
switch (u.status) {
case 'pending': case 'running': case 'passed': case 'failed': return …;
// case 'skipped': ← add this
default: return assertNever(u.status); // 0.4.0: no longer `never`
}2. Passing a widened value into a narrow parameter. f(u.stage) where
f(s: Stage), or g(u.status) where g(s: StageStatus), stops compiling — widen
the parameter to StageName / FlowStageStatus, or narrow at the call site.
Also additive: FlowSnapshot gained kind, and a stage entry gained an optional
tool. An identity snapshot is still seeded with the five fixed stages, and
its entries are the same { status, score, reason } they were in 0.3.2 — tool
is written only for a flow step that has one.
Publishing (CI)
Every push to main runs .github/workflows/publish.yml: the quality gate
(typecheck + build + check-umd + security-check + check-flow), then — only if
package.json#version is not on npm yet — npm publish --access public, a
v<version> git tag, and a purge of jsDelivr's cache for the moving major alias
(@singula_systems/identity-js@0). A push that does not bump the version stays green
and publishes nothing, so a release is exactly: bump version, write the
CHANGELOG entry (the pre-commit hook demands both), push to main.
The workflow needs one repository secret, NPM_TOKEN: an npm Automation
token (bypasses 2FA for CI) or a granular token with publish rights on the
@singula_systems scope, added under Settings → Secrets and variables → Actions. It is
never committed and never pasted anywhere else; if it leaks, revoke it in
npmjs.com → Access Tokens and add a fresh one. scripts/release.sh remains as
the alternative path for a self-hosted CDN bucket.
Build & develop
npm install
npm run build # ESM + CJS + UMD into dist/ (postbuild: the three checks below)
npm run dev # watch mode
npm run typecheck # tsc --noEmit
npm run test # check-umd + security-check + check-flow, against dist/
npm run security # adversarial security suite (also runs as postbuild)
npm run demo # serve demo/ on localhost:4321
npm run size # show bundle sizesbuild then runs three suites over the built artifacts — check-umd
(the <script> bundles load and expose one global), security-check (28
adversarial cases) and check-flow (52 flow, retake + back-compat cases). All three
accept SINGULA_DIST=<path to another build>/index.js, which is how a case is
proven to actually fail against the code it was written for.
build runs scripts/check-umd.mjs afterwards: it evaluates both UMD bundles as
classic <script>s inside a vm context with a stub window / document —
the only way to catch load-time breakage, since require()-ing a bundle from
Node gives it its own scope and never exercises the global wiring. It fails the
build unless window.Singula.Identity.open is a function and
Object.keys(window.Singula) is exactly ['Identity'].
Demo flow: build, open http://localhost:4321, and paste a verification token. Set embedBaseUrl to your local SaaS-App (e.g. http://localhost:5173) to test against a dev backend.
Adding an identity document type (maintainers)
The SDK's vocabularies are closed lists: a step_changed whose document_type is not in DOCUMENT_TYPES
(src/messages.ts) is dropped, and a retake reason it does not know arrives as 'unreadable'. So a new document
(as resident_card in 0.8.0) means: the value in DocumentType (src/types.ts) and in the DOCUMENT_TYPES
allow-list, new retake reasons in RetakeReason and their list, check-flow cases that fail against the previous
build, the README tables (document_type, retake reasons, protocol), a minor version (src/version.ts,
package.json) and its CHANGELOG entry — and a release on npm before the platform starts offering the document.
The platform-wide checklist lives in Singula's internal control-plane repo (MasterApi/docs/agregar-documento.md).
License
MIT © 2026 Singula. The verification service itself is proprietary and runs on Singula's infrastructure; this package is only the open-source loader.
