npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@singula_systems/identity-js

v0.8.0

Published

Singula Identity — embeddable JS SDK for the identity verification widget. iframe-based, zero dependencies.

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 postMessage protocol
  • 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. The cdn.singula.mx alias 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-js
import { 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 -A

scripts/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:5173

The 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 error with session_expired / session_abandoned (FlowRunner → FlowEmbed), so the widget closes on onError rather than sitting out its verdictTimeoutMs. The SDK also accepts the other two shapes src/messages.ts fixes for the same situation — a completed whose decision is 'expired' / 'abandoned' (raised as the same error codes), and the neutral completed { 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 close

Modal 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. onStage and onComplete are driven by the v2 stage_update / completed messages, which the hosted page emits from Saas-App 0.84.0 onward. Against an older hosted page only the legacy onSuccess fires (with requestId) — the SDK stays fully functional, it just never learns the decision. onReady, onError and onClose work 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 the decision stage (it cannot claim passed for a verdict it does not know), so allStagesPassed(snapshot) stays false even though onComplete({ decision: 'withheld' }) fired and isTerminal(snapshot.state) is true. Drive your UI from onComplete / 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/:requestId with your sk_live_* key, or
  • the identity.completed webhook (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. onRetake is driven by the document_retake message, 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 unknown type.

The wrong_document reason and detectedDocumentType arrive from Saas-App 1.44.0 (SDK 0.8.0). An SDK older than 0.8.0 still relays that retake, but reports wrong_document as '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.
  • reason is a closed list. A code this SDK does not know yet (sent by a newer hosted page) arrives as 'unreadable', so an exhaustive switch keeps working. In TypeScript, 0.8.0 widened RetakeReason (wrong_document) and DocumentType (resident_card): a switch with a never default needs the new case to compile.
  • detectedDocumentType is optional, even on a wrong_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.
  • attempt is the pre-check attempt of that side — the same number GET /app/identity-verification/upload/:token/precheck reports 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 detectedDocumentType on a wrong_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); // true

FlowSnapshot 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 init message has always carried theme; 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 with sk_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 the postMessage boundary — and on the hosted page the origin it checks is the event.origin of the SDK's init message. 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 is ready (broadcast, carries no data) → the hosted page waits for init → every later message is addressed to that one origin, never '*'. The allowlist is required: an organization that configured none gets error origin_not_configured and the widget never loads (an empty list is a misconfiguration, not a wildcard). An unlisted origin gets origin_not_allowed, and if no init arrives within 5 s the page fails closed with origin_unknown. In all three cases the capture never starts — see Allowed origins (required).
  • The iframe's referrerPolicy is strict-origin-when-cross-origin — the Referer carries 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 own Referrer-Policy. A stricter no-referrer was tried while developing 0.3.0 and dropped before release: it blanks document.referrer inside the iframe, which the hosted page uses as a fallback hint when no SDK init identifies the parent. The referrer is only ever a hint; the authoritative, non-forgeable origin is the init event.origin above.
  • Inbound messages are validated twice before any callback runs: event.origin must equal the embed origin and event.source must be exactly the widget's contentWindow — both non-null, because a detached iframe has a null contentWindow and a message from a closed context has a null source, and null === null would 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.message and stage_update.reason are clamped to 2 KB, code and stage_update.tool to 128 chars, requestId / sessionId to 256, and an inline resize to 320–8000 px. A flow's stage must 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". A document_retake carries no free text at all: side must be front / back, attempt an integer from 1 to 10, reason is mapped onto its closed nine-value list (anything else becomes unreadable), so the raw string never reaches onRetake, and detected_document_type is 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:
    • embedBaseUrl must be https: (http: only for localhost / 127.0.0.1 / [::1]), with a real host and no embedded credentials. This is the one that matters most: new URL() parses javascript:alert(document.domain)// happily and reports its origin as the opaque string 'null', and an iframe.src starting with javascript: executes in the embedding page's own realm. If you derive embedBaseUrl from 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: and file: are opaque the same way; plain http: would downgrade the channel to cleartext.
    • token must 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 iframe title — it is a bearer credential.
    • theme.primaryColor / secondaryColor must be real CSS colors (validated with CSS.supports, with a grammar fallback) and are rejected outright if they carry ;, {, }, url(, @import or a comment — a value like red; background:url(//evil) never reaches a stylesheet. theme.logoUrl must be an absolute https URL (javascript:, data: and http: are rejected). theme.brandName is stripped of <, > and control characters and clamped to 64 chars. language must be es or en; anything else is dropped rather than forwarded.
  • The iframe delegates camera only. microphone was removed in 0.3.0: every getUserMedia call on the hosted page passes audio: false, so the delegation was unused — and a permission the flow never needs is a prompt your end user should never see.
  • No sandbox attribute, deliberately. The hosted page needs same-origin storage and camera access to run the flow, so any useful sandbox would have to include allow-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, and scripts/security-check.mjs asserts that on every build, along with the absence of eval, new Function, fetch, XMLHttpRequest, localStorage, document.cookie and document.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 by scripts/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:

  1. The whole channel lives in a browser the end user controls. Anyone can open devtools and call your onComplete handler 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.
  2. Even against the genuine hosted page, stage_update messages are independent of the verdict. Five status: 'passed' reports make allStagesPassed(snapshot) true with no completed message and no decision anywhere in the snapshot (asserted in scripts/security-check.mjs). "All green" is not "approved".
  3. The verdict may not even be in the message: with the default configuration completed carries decision: 'withheld' and no score, precisely so the browser is never the place a decision is published (asserted in scripts/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 sizes

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