Sekyro AIDocs

Embedded web SDK

Run liveness, face match and the full verification flow inside your own page with @sekyro/web and @sekyro/react.

Embed Sekyro AI's liveness + face-match verification inside your own page, with your branding and your copy, instead of sending users to the hosted flow.

PackageRoleStatus
@sekyro/nodeServer: create sessions, read results, verify webhooks0.1
@sekyro/webBrowser: the framework-free engine — camera, gate, upload, attestation, checks, result0.1
@sekyro/reactBrowser: <LivenessCapture> + <SekyroThemeProvider> on top of @sekyro/web; @sekyro/react/verification: <SekyroVerification> and the profile v2 step components0.1 preview

Every code block below is a file under snippets/ and is typechecked against the real SDK surfaces (the built @sekyro/web, @sekyro/react and @sekyro/node) by node docs/partner/snippets/check.mjs.


1. Overview — hosted vs embedded

AspectHosted flowEmbedded SDK
Where the user isverify.sekyro.ai (link, redirect or popup)Your page, your domain
UISekyro AI'sYours (@sekyro/react themeable component, or fully headless)
What you buildSession creation + result readSession creation + result read + the capture page
SetupAPI keyAPI key + registered origins, CSP, model assets
GuideQuickstartThis document

Both paths run the same checks on the same session and produce the same server-side result. Pick embedded when the verification must feel like part of your product; pick hosted when you want zero front-end work.

Which steps the embedded SDK renders. The app's eKYC profile decides the steps, not your page: the SDK renders the session's plan. Since @sekyro/web 0.1.0-rc.8 the SDK can render every profile v2 step the hosted page renders:

Aspect<LivenessCapture> (@sekyro/react), §4.1–4.3<SekyroVerification> (@sekyro/react/verification) or verification.run() (headless), §4.4–4.5
Phone OTP (WhatsApp or SMS, the person's choice; prefilled or typed number)—yes
Email OTP (prefilled or typed address)—yes
"How do you want to verify?" (method choice)—yes
National number: bound by your serveryesyes
National number: typed by the user, read from the document, re-entry on a likely typo—yes
Document photo, any issuing country (identity card, residence permit, passport data page)—yes
Liveness + face matchyesyes
ID photo (ICAO portrait, then face match against it), for an ID-document profile—yes
National eID (Houwiyeti in Mauritania): national number, then the code from the provider's app—yes (<EidStep>, loaded on demand)
Result and stop screens (approved, review, rejected, expired, policy stop, cooldown, attempts exhausted)your ownyes
Chip (NFC) reading—needs a native SDK (see below)

Chip (NFC) reading needs a native SDK. On the web, a preferred chip step is skipped by the server, and a mandatory one ends the session with the platform_unsupported result ("This verification needs a phone app.").

The rest of §3–§4.3 uses an app with the Mauritania: registry eKYC profile (your API key belongs to the app; the profile, set in the dashboard, decides the steps): population_registry (the NNI, server-bound) → liveness (a 2.5 s clip) → face_match (the clip's keyframe against the registry portrait).


2. Install

npm install @sekyro/node          # server
npm install @sekyro/web           # browser, headless
npm install @sekyro/react         # browser, React component (0.1 preview; peer-depends on @sekyro/web, react >=18.2)

The profile v2 step components are on a second entry, @sekyro/react/verification (+ @sekyro/react/verification.css), which needs @sekyro/web 0.1.0-rc.8 or later (the plan runner, verification.run()). For the full profile:

npm install @sekyro/web@next @sekyro/react@next   # @sekyro/web >= 0.1.0-rc.8
import "@sekyro/react/styles.css";        // the liveness step (shared with <LivenessCapture>)
import "@sekyro/react/verification.css";  // the OTP, method, NNI, document and result steps

Use the ESM build (what Next, Vite, webpack 5 and esbuild resolve by default). In ESM both entries share one copy of the common code, and the document camera view is a separate chunk loaded only when a document step shows. With CJS (require) each entry is a self-contained bundle with its own copy of the shared modules, so components and providers are not shared across the two entries and the code is downloaded twice if you import both.

Requirements: Node.js ≥ 20 on the server; a secure context (HTTPS, or localhost) in the browser — getUserMedia refuses plain HTTP (error.kind === 'insecure').

Pre-releases ship under the npm dist-tag next (npm install @sekyro/web@next); stable versions under latest. Every version is published with an npm provenance attestation from the public source repository (npm audit signatures verifies it) and ships a CycloneDX SBOM on its GitHub release. The packages are proprietary (UNLICENSED) — see each package's LICENSE: installable from npm, usable under your Sekyro AI customer agreement.

Environments:

AspectSandboxProduction
@sekyro/node baseUrlhttps://api-staging.sekyro.ai/api/v2https://api.sekyro.ai/api/v2 (default)
@sekyro/web apiOriginhttps://api-staging.sekyro.aihttps://api.sekyro.ai (default)
API keysk_test_…sk_live_…
RegistrySeeded test NNIs only (see the quickstart)Live registry

3. Server — create a session with a bound NNI

Your server owns the API key and the identity. Create the session with input.nni; the browser never sees or sends the NNI.

const created = await sekyro.sessions.create(
  { locale: "ar", input: { nni } },
  { idempotencyKey: `kyc:${userId}:${attempt}` },
);
// created → { id, clientToken, status, hostedUrl, steps, expiresAt }
  • clientToken is returned once. Hand it to the verified user's page and nothing else: do not log it, do not put it in a URL or cookie.
  • Server-bound NNI. A session minted with input.nni is locked to that identity. The SDK submits population_registry with an empty input and the server uses the bound value. A browser that sends a different NNI gets 409 nni_locked at request level — no attempt is consumed, nothing is billed.
  • nni goes under input. Raw-HTTP callers sending nni (or phone, fullName, birthDate) at the top level of the body get 400 input_field_misplaced instead of an unbound session; the typed @sekyro/node input already nests it.
  • Idempotency. Same key + same body within 24 h returns the same session; same key + different body is 409 idempotency_key_collision. Retry SekyroApiError.retryable failures with the same key.
  • Persist created.id against your user before returning to the page: a webhook can arrive before the page reports back.

Full Server Action, including the one place a verdict is decided (decideVerification): snippets/server-create.ts.


4. Browser

4.1 <LivenessCapture> (@sekyro/react, 0.1 preview)

import { createVerification } from "@sekyro/web";
import { LivenessCapture, SekyroThemeProvider } from "@sekyro/react";
import "@sekyro/react/styles.css";

// `autoRetry` belongs to the verification (default true), not to the component.
const verification = createVerification({ clientToken, locale: "ar", autoRetry: true });

<SekyroThemeProvider theme={theme}>
  <LivenessCapture
    verification={verification}
    locale="ar"
    dir="rtl"
    strings={{ "capture.start": "ابدأ التحقق", "capture.securityNote": "صورك مشفّرة وتُستخدم لهذا التحقق فقط" }}
    notice={serviceBusy ? { tone: "warning", text: "الخدمة مزدحمة حالياً" } : undefined}
    startDisabled={cooldown > 0}
    startLabel={cooldown > 0 ? `حاول مجدداً بعد ${cooldown} ث` : undefined}
    voice={{ clips: voiceClipsFromYourBackend, speechFallback: ["ar", "fr"] }} // or omit: Sekyro AI clips from the session + device voice
    slots={{ header, footer, errorPanel: (err, retry) => … }}
    assetBaseUrl="/models-v3"
    onCaptured={(artifacts) => …}
    onResult={(result) => …}
    onError={(err) => …}
    onGuidance={(key) => …}
  />
</SekyroThemeProvider>

Full page: snippets/page-liveness.tsx. Variants (early preload, autoStart, custom clips, voice={false}, custom intro): snippets/precapture.tsx.

PropTypeNotes
verification?Verification from createVerificationOmit for capture-only mode (the component runs createLivenessCapture and nothing is uploaded — pair it with your own submission).
locale'ar' | 'fr' | 'en'Picks the built-in dictionary.
dir?'rtl' | 'ltr'Defaults to rtl for ar, ltr otherwise; the component uses logical CSS only.
strings?Partial<Strings>Overrides merged on top of the built-in copy. The keys are the hosted catalog's: capture.start, capture.recordAgain, capture.switchCamera, capture.preparing.{title,hint}, capture.ready, capture.checking, capture.checkingHint, capture.securityNote, capture.cameraOff, capture.previewAlt, capture.retryCoaching.{title,light,eyeLevel,head}, capture.tips.{title,light,uncover,eyeLevel,straight}, capture.tips.{summary,listen,stop} (the spoken pre-capture summary, the "Listen" button label and its label while playing), capture.permission.{granted,prompt,denied,unknown}, capture.cameraLabel, capture.facing.{user,environment}, capture.voiceGuidance (pre-capture screen and voice), capture.attemptsRemaining.{one,other} (+ optional zero,two,few,many — CLDR plural categories, {count} placeholder), common.retry, fcv2.err.{permission,notfound,inuse,insecure,unsupported,generic} (camera errors), fcv2.err.models (detector download failed), fcv3.{no_face,come_closer,move_back,center_face,straighten,more_light,too_bright,hold_still,steady_camera} (gate hints — also the text the device voice speaks), reasons.<reason code>, errors.{attestation_invalid,network_error,session_expired,nni_unbound}. Strings / StringKey are exported; hintStringKey(), captureErrorStringKey() and reasonStringKey() map codes to keys; attemptsRemainingText(dictionary, locale, count) renders the pluralised attempts line.
slots?{ header?, footer?, intro?(start, context), stageOverlay?(videoRef), voicePrompt?(hint, text, key, voice), errorPanel?(err, retry) }intro replaces the built-in pre-capture screen (§4.3) and receives an IntroContext (assets, retryAssets, permission, facing, setFacing, canChooseFacing, voice); call start() from your click handler. stageOverlay renders inside the stage box above the ring (measurement overlays, watermarks) and receives the ref of the component-owned <video>. voicePrompt replaces the built-in voice chip in the stage corner; it receives the hint code (GateHint | null), the resolved text, its string key and the shared VoiceControl. The component keeps speaking — if your slot plays audio, pass voice={false}. errorPanel replaces the default camera-error panel; call retry() to restart.
autoStart?boolean | "when-permitted", default falseSkip the pre-capture screen — at most once per mount, the first time it is armed while the capture is idle. true arms it (flip it once your own preconditions are met); "when-permitted" arms it only when the camera permission is already granted (until the permission probe answers, the screen shows its loader instead of a Start that would vanish a moment later). Either form waits for the detector preload (§4.3): meanwhile the screen shows its loader, or Retry if the download failed.
notice?ReactNode | { tone: 'info' | 'warning' | 'danger', text }Your message above the pre-capture screen (busy service, previous failure, maintenance). Pre-capture only; it also renders above a custom slots.intro.
startDisabled?boolean, default falseDisable the built-in Start (e.g. your own cooldown). It stays focusable (aria-disabled). It gates only the pre-capture Start — not the error panel's Retry, "record again" or the retake button.
startLabel?ReactNodeReplace the Start label (e.g. "Retry in 12 s").
tips?false | (BuiltInTipId | IntroTip)[], default DEFAULT_TIPSThe tips card. Built-in ids: light, uncover, eyeLevel, straight; mix in your own { id, text, icon? }. false hides the card. Custom tips (or false) also hide the "Listen to the instructions" button unless voice.clips has a capture.tips.summary clip (§4.3).
tipsSummary?stringThe spoken summary of your custom tips (no recorded clip): the "Listen to the instructions" button reads it with the device voice. <SekyroVerification> passes the ICAO tips summary on an ID-document session.
coach?string | nullYour coaching line, shown instead of the built-in guidance while the camera runs (not spoken). <SekyroVerification> uses it for the ICAO coaching of a one-capture live photo.
defaultFacing?'user' | 'environment', default 'user'The camera the screen preselects (and the attempt opens with).
voice?false | VoiceOptions, default onSpoken guidance (§4.3). false = no speech, no voice switch, no stage chip.
onGuidance?(key: StringKey) => voidTelemetry: once per change of the displayed fcv3.* guidance key.
assetBaseUrl?string, default /models-v3Where you host the detector assets (§8).
className?stringExtra class on the root element.
onCaptured(a: CapturedArtifacts) => voidFires before any upload.
onResult(r: VerificationResult) => voidDisplay only — see §5.
onError(e: CaptureError) => voidCamera / engine failures (permission, notfound, inuse, insecure, unsupported, generic).
onStateChange(s: CaptureState) => voidEvery capture-state transition (starting … done), for host chrome (hide a description while the stage is live, telemetry).

Retry behaviour is not a component prop: createVerification({ autoRetry }) (default true) re-arms the capture on a retryable reason while attempts remain and surfaces it as result.status === 'retry'; autoRetry: false makes the first refusal final for that capture. createVerification({ rearm }) (default 'auto') picks how a retry verdict re-arms: 'auto' restarts the camera immediately while the component shows the reason, the coaching and the remaining attempts; 'manual' settles the handle at done so you can show the coaching first — the component then offers a focused "Start a new attempt" button (or call retry() on the handle yourself).

Theming. SekyroThemeProvider writes CSS variables on a wrapper element: --sekyro-color-{brand,brand-fg,fg,fg-muted,bg,surface,ring,danger,success,warning}, --sekyro-radius, --sekyro-font. Pass theme={{ colors: { brand, brandFg, fg, fgMuted, bg, surface, ring, danger, success }, radius, fontFamily }}. The defaults pass WCAG AAA (7:1) for text on surface; keep that bar when you override (§10).

Lifecycle. Create the verification once per mount (useMemo on the client token) and call verification.destroy() on unmount — that stops the camera. Never reuse a Verification across sessions.

4.2 Headless (@sekyro/web only)

No React, no CSS, no copy — you render everything from events. The SDK still owns the camera, the gate, the upload, the Turnstile attestation and the check submissions.

import {
  createVerification, getLivenessAssetsStatus, preloadLivenessAssets, queryCameraPermission,
  releaseLivenessAssets, subscribeLivenessAssets,
} from "@sekyro/web";

// As early as possible (app shell, the step before): warm the detector.
void preloadLivenessAssets({ assetBaseUrl: "/models-v3", idle: true }); // never rejects

// "Get ready" screen: gate your Start button on the preload.
renderStart(getLivenessAssetsStatus("/models-v3"));                     // idle | loading | ready | failed
const unsubscribe = subscribeLivenessAssets(renderStart, "/models-v3"); // no emit on subscribe
showPermission(await queryCameraPermission());                          // granted | prompt | denied | unknown

const v = createVerification({ clientToken, locale: "fr" /* apiOrigin?, autoRetry?, rearm? */ });
v.on("result", (r) => …);                 // { sessionId, status, reason?, attemptsRemaining? }
v.on("view", (view) => …);                // every hosted-view update (steps, attemptsRemaining…)

const cap = v.liveness({ video, assetBaseUrl: "/models-v3" /* facing? */ });
cap.on("state", (s) => …);                // idle→starting→gating→recording→captured→uploading→checking→done | error
cap.on("gate", ({ progress, hint }) => …);// progress 0..1 drives your ring; hint is a code or null
cap.on("captured", (a) => …);             // before any upload — keyframe, clip, width/height, clipMs, engine, cameraReadyMs / faceAcquireMs / captureMs
cap.on("error", ({ kind }) => …);

await v.consent();                  // records consent once (no-op if already recorded)
void v.startRegistry();             // early population check, in parallel with the camera
await cap.start({ facing: "user" }); // asks for this camera, starts gating (reuses the warm detector)
// cap.retry() / cap.switchCamera() / v.destroy()
// Leaving the flow in an SPA (no capture mounted): unsubscribe(); releaseLivenessAssets("/models-v3");

Full example with hint/state/error/permission copy tables and a preload-gated Start: snippets/headless.ts.

Pre-capture building blocks (all safe during server-side rendering, none throws):

ExportWhat it does
preloadLivenessAssets({ assetBaseUrl?, idle?, respectSaveData? })Downloads and initialises the face detector ahead of the capture. Idempotent per assetBaseUrl, never rejects; resolves the settled status: 'ready', 'failed' (call it again to retry) or 'idle' = nothing was preloaded (SSR, no camera API, Data-Saver with respectSaveData: true) — start normally. idle: true defers the load to browser idle time; a later non-idle call, or a capture that needs the detector, starts it at once.
getLivenessAssetsStatus(assetBaseUrl?) / subscribeLivenessAssets(listener, assetBaseUrl?)The status store (useSyncExternalStore contract: read first, the subscription does not emit on subscribe).
releaseLivenessAssets(assetBaseUrl?)Frees the warmed detector (or cancels a pending idle load). For SPAs leaving the flow; never while a capture is mounted.
queryCameraPermission() / subscribeCameraPermission(listener)'granted' | 'prompt' | 'denied' | 'unknown' (unknown where the Permissions API cannot tell). The subscription emits the current state first, then every change.
hasMultipleCameras()Whether a front/back choice makes sense (phones and tablets always; desktops with two or more cameras).
handle.start({ facing })Opens that camera for this attempt; retry() keeps it, switchCamera() still flips it. start() with no options uses LivenessCaptureOptions.facing (default front).
LivenessCaptureOptions.keepScreenAwake? (boolean, default true)Holds a Screen Wake Lock while the camera is live (starting / gating / recording) so the phone does not dim or lock mid-capture; released on captured, error and destroy(), re-taken on retry() and when the page becomes visible again. Best-effort: where the API is missing or the request is refused the capture runs unchanged. In an iframe, the embedding page must allow it (allow="camera; screen-wake-lock", see §7). false never requests the lock.

Every capture with the same assetBaseUrl reuses the preloaded detector automatically — no wiring — and destroying or retrying a capture never frees it. The preload warms the blaze (MediaPipe) tier with a generous init deadline, so a slow first download no longer races the capture's start-up deadline.

What happens after captured (automatic, in order): Turnstile token → presign → PUT the clip to the media bucket with the signed headers → liveness submit (carrying input.capture: engine, stream dimensions, clip length and the face-capture timing — cameraReadyMs camera request → preview live, faceAcquireMs camera ready → first accepted face, captureMs first accepted face → gate 100 %; whole ms, no media, no tokens) → presign → PUT the keyframe → face_match submit → result. The hosted view is re-read while a verdict is still processing (bounded: 10 polls × 1.5 s).

Gate hint codes: center, closer, farther, light, still, hold (hold is emitted while the clip records). You own the copy.

The capture constants are calibration-pinned and not configurable: 1280×720 ideal, 24 fps, MP4-first mime ladder, 2.5 s clip, mirrored JPEG keyframe. The liveness model was calibrated on exactly this pipeline.

createLivenessCapture({ video, facing?, assetBaseUrl?, keepScreenAwake? }) is also exported: the same handle without upload or submission, for a capture-only UI.

4.3 Pre-capture screen & voice guidance (@sekyro/react)

The built-in screen

While the capture is idle, <LivenessCapture> renders a "get ready" screen:

  1. your notice, when set ({ tone, text } or any node);
  2. a tips card (DEFAULT_TIPS: light, uncover, eyeLevel, straight), followed by a "Listen to the instructions" button that reads them aloud (see Spoken summary below);
  3. the camera-permission row: allowed / the browser will ask / blocked, with the fix / unknown;
  4. a front/back camera choice, only on devices that have both;
  5. the voice guidance switch, only where voice can actually play for the locale;
  6. the security note;
  7. one Start control gated on the detector preload. It is busy while the models download and ready once they are. If the download failed, it is replaced by a danger notice (fcv2.err.models) and a Retry. Without a camera API the preload is skipped and Start is ready at once.

The component kicks preloadLivenessAssets on mount. Call it yourself earlier (the step before, your app shell) so Start is ready by the time the user gets there — on a slow 3G phone the first download takes a while:

import { preloadLivenessAssets } from "@sekyro/web";

useEffect(() => {
  void preloadLivenessAssets({ assetBaseUrl: "/models-v3", idle: true });
}, []);

autoStart (both forms) waits for that preload: the screen shows its loader until the detector is ready, or Retry on failure, then starts. autoStart="when-permitted" skips the screen only for people who already allowed the camera — a returning user goes straight to the stage.

Spoken summary ("Listen to the instructions")

Below the tips, a button plays a short spoken summary of them — voice key and string capture.tips.summary ("Stand in a well-lit area…"). Its label is capture.tips.listen and switches to capture.tips.stop while it plays; a second tap stops it.

  • Never auto-plays. Only the tap starts it. The tap is a user gesture, so it also unlocks audio on iOS.

  • Same clip pipeline as the guidance. Your voice.clips → the session's Sekyro AI set → the device voice reading capture.tips.summary, within speechFallback.

  • Stops when Start is tapped (the capture's guidance takes over, so they never overlap), when the screen unmounts, and when the voice switch is turned off. Turning the switch on does not cut it.

  • Shown when all of these hold:

    1. voice is not false;
    2. a clip or a usable device voice exists for the summary in the current locale;
    3. the tips shown are the built-in four, in any order.

    There is one exception to the third rule: a capture.tips.summary clip for the locale in your voice.clips shows the button with any tips, including false. The voice switch does not hide the button: the switch controls automatic guidance, and the tap is an explicit request.

Custom tips with your own summary (your voice.clips replaces the Sekyro AI set, so the guidance uses your other clips or the device voice):

<LivenessCapture
  verification={verification}
  locale="fr"
  tips={["light", { id: "hat", text: "Retirez votre chapeau" }]}
  strings={{ "capture.tips.summary": "Avant de commencer : placez-vous face à la lumière et retirez votre chapeau." }}
  voice={{ clips: { fr: { "capture.tips.summary": "https://cdn.example.com/voice/v1/fr/tips_summary.mp3" } } }}
/>

slots.intro replaces the whole screen, button included.

Host notice and cooldown

<LivenessCapture
  verification={verification}
  locale="ar"
  notice={serviceBusy ? { tone: "warning", text: "الخدمة مزدحمة حالياً — قد يستغرق التحقق وقتاً أطول" } : undefined}
  startDisabled={cooldown > 0}
  startLabel={cooldown > 0 ? `حاول مجدداً بعد ${cooldown} ث` : undefined}
  tips={["light", "uncover", "eyeLevel", { id: "glasses", text: "انزع النظارة الشمسية" }]}
/>

startDisabled keeps Start focusable (aria-disabled) so a keyboard user does not lose their place when your cooldown ends. It gates only this Start: the error panel's Retry, "record again" and the retake button stay active (replace the error surface with slots.errorPanel if your cooldown must cover them). notice shows only before the capture starts.

Your own screen (slots.intro)

slots.intro(start, context) replaces the whole screen (the notice still renders above it). context is an IntroContext:

FieldTypeUse
assets'loading' | 'ready' | 'failed'Gate your Start on 'ready'. Also 'loading' while autoStart="when-permitted" waits for the first permission answer.
retryAssets() => voidRe-run a failed preload.
permissionCameraPermissionStateYour permission row.
facing / setFacing / canChooseFacingCameraFacing / setter / booleanYour camera choice; show it only when canChooseFacing.
voiceVoiceControl ({ supported, enabled, setEnabled, toggle })Your voice switch; show it only when supported.

Call start() from the click handler: it opens context.facing and unlocks audio inside the gesture (iOS). Full example: CustomIntroCapture in snippets/precapture.tsx.

Voice guidance

On by default. During the capture the component speaks the displayed guidance — the nine fcv3.* gate hints, debounced, once per change. VOICE_KEYS lists those nine plus capture.tips.summary, which is spoken only by the pre-capture "Listen" button. It plays a pre-recorded clip for the key and locale when one exists. Otherwise it uses the device voice (speechSynthesis) with the same text as the on-screen line. A stage chip (aria-pressed) and the screen's switch toggle it. The viewer's choice is stored in localStorage per browser (storageKey, default "sekyro.voiceGuidance"; null = do not persist).

VoiceOptionsDefaultNotes
defaultEnabled?trueUsed until the viewer makes a choice.
clips?verification mode: the Sekyro AI clip set delivered with the session; capture-only: none (device voice)A VoiceClipSource: a map { [locale]: { [voiceKey]: url } } or a resolver (key, locale) => url | null. Yours replaces the Sekyro AI clips entirely. A missing URL, or a resolver that throws, means "no clip" → device voice.
speechFallback?truefalse, or the locales where the device voice may speak. A device with no voice for the language stays silent rather than read Arabic with a French voice.
storageKey?"sekyro.voiceGuidance"See above.

Clips from your backend — the map form. The map is the integration point for clip URLs that arrive with your data. Pass a new object when the URLs change (object identity is the change signal):

const voice = useMemo<VoiceOptions>(
  () => ({ clips: voiceClipsFromYourBackend, speechFallback: ["ar", "fr"] }), // { ar: { "fcv3.center_face": "https://…" } }
  [voiceClipsFromYourBackend],
);
<LivenessCapture verification={verification} locale="ar" voice={voice} />

Sekyro AI clips (the default). In verification mode, when you pass no voice.clips, the component speaks the recorded ar/fr clip set that arrives with the session (verification.voiceClips() from @sekyro/web). The clips are served from https://assets.sekyro.ai (sandbox/staging: https://assets-staging.sekyro.ai) — add that origin to media-src (§7). Until the set arrives, when the session has none, or when a clip is blocked or cannot load, the device voice speaks instead; nothing breaks. With your own voice.clips, or voice={false}, the Sekyro AI set is not fetched at all. Integrations that already pass voice.clips need no change: yours take precedence. In capture-only mode (no verification) there are no default clips — pass your own or rely on the device voice.

A resolver fits a predictable layout of your own:

const voice: VoiceOptions = {
  clips: (key, locale) =>
    locale === "en" ? null : `https://cdn.example.com/sekyro-voice/v3/${locale}/${key.slice(key.indexOf(".") + 1).replace(/\./g, "_")}.mp3`,
  speechFallback: ["en"], // device voice for English only
};

Self-hosted clips in a <base>/<locale>/<name>.mp3 layout, named like the Sekyro AI set, take a 3-line resolver. <name> is the key without its first segment, with the remaining dots turned into _: fcv3.center_face becomes center_face, and capture.tips.summary becomes tips_summary.

const base = "https://cdn.example.com/voice/v1";
const clips: VoiceClipResolver = (key, locale) =>
  `${base}/${locale}/${key.slice(key.indexOf(".") + 1).replace(/\./g, "_")}.mp3`;
<LivenessCapture verification={verification} locale="fr" voice={{ clips }} />

No voice. voice={false}: no speech, no switch, no chip, no "Listen" button.

Speaking from slots.voicePrompt yourself? Since this release the component speaks the guidance itself. Pass voice={false}, or the person hears every hint twice. A development build warns once (console.warn) when slots.voicePrompt is set and voice is undefined; pass voice={{}} to keep the built-in voice with your own chip and silence it. The slot still gets the hint, text and key.

iOS. Safari only plays audio after a user gesture. The Start tap unlocks it, and so does turning on the voice switch or the chip. With autoStart there is no Start tap: the audio stays locked until the person's first activating touch, click or key press inside the component — touchend, click, keydown (not Escape) or a mouse/pen pointerup. A touch pointerdown does not count (WebKit grants no user activation for it), and an event without activation leaves the component waiting for the next one. It unlocks once, and the voice clips are prefetched in that same gesture (only while voice is on). Guidance due before that is silent on iOS, so the person can tap the speaker chip to hear the next hints. The on-screen line is always there.

4.4 Full profile — <SekyroVerification> (@sekyro/react/verification)

One component renders every step of the session's plan, in order, from the profile: phone OTP, email OTP, method choice, national number, document, liveness + face match, the ICAO portrait, and the result. Create the session without input.nni when the profile asks the user for the number (or reads it from the document); bind it (§3) when you already know it.

const verification = useMemo(
  () => createVerification({ clientToken, locale, rearm: "manual" }),
  [clientToken, locale],
);
useEffect(() => () => verification.destroy(), [verification]);

<SekyroVerification
  verification={verification}
  locale={locale}
  theme={theme}                       // wins over the app's dashboard branding
  strings={{ "otp.sendTitle": "Confirmez votre numéro" }}
  assetBaseUrl="/models-v3"
  onLivenessCaptured={(artifacts) => …}
  onResult={(result) => …}            // display only — decide on your server (§5)
  slots={{ result: (step) => <YourResultScreen display={step.display} /> }}
/>

Full component: snippets/full-profile.tsx.

PropTypeNotes
verificationVerificationRequired. The component calls verification.run() itself. Use rearm: "manual" (recommended): after a retryable liveness refusal the step shows the coaching and a "Start a new attempt" button instead of restarting the camera at once.
locale'ar' | 'fr' | 'en'Picks the built-in step copy (generated from the hosted page's catalog).
dir?'rtl' | 'ltr'Defaults to rtl for ar. Logical CSS only.
strings?Partial<SekyroStrings>Overrides for any step key (otp.*, emailOtp.*, population.*, doc.*, dcv2.*, session.*, result.*, …) and the liveness keys of §4.1. resolveSekyroStrings(locale, overrides) and arSteps / frSteps / enSteps are exported.
theme?SekyroThemeMerged over the app's branding from the dashboard (session.branding), key by key. Same tokens as §4.1.
assetBaseUrl?stringDetector assets for the liveness and portrait steps (§8).
voice?false | VoiceOptionsLiveness voice guidance (§4.3).
livenessProps?the <LivenessCapture> props except the ones abovee.g. { autoStart: true }, tips, slots.intro.
slots?{ header?, footer?, result?(step) }header replaces the "requested by" header, footer renders under every step, result replaces the result screen and receives the result step (display, result, failureReason, retryAfter).
onStep?(step: VerificationStep) => voidEvery step change (telemetry, your own progress bar).
onLivenessCaptured?(a: CapturedArtifacts) => voidThe liveness clip, before any upload.
onResult?(r: VerificationResult) => voidDisplay only (§5).
className?stringExtra class on the root.

The single step components are exported too, for a page that places the steps itself: <PhoneOtpStep step>, <EmailOtpStep step>, <MethodChoice step>, <NniEntry step>, <DocumentCapture step>, <PortraitCapture step> and <VerificationResultScreen step>, each taking the matching handle from verification.on("step") (§4.5), inside a <StepI18nProvider locale>.

<SekyroFlow> (same props, plus hostedUrl? — the session's hosted_url) adds the page shell of the hosted page: inside an app's in-app browser (WhatsApp, Instagram, Facebook, TikTok, …) it first asks the person to open the link in their browser (copy link, or continue anyway), and on a desktop a live photo that needs a phone shows a QR code of hostedUrl (else this page's address) and follows the phone. isInAppBrowser(userAgent?) is exported.

Behaviour you get without writing it: the one-line consent with its terms link, one progress segment per section (contact, identity, live photo), the phone-or-email choice, the identity summary to confirm before the live photo, the ICAO tips and coaching on an ID-document session, input checks before anything is sent (E.164 phone, 6-digit code, the 10-digit NNI with its check digit), one "Send code by …" button per offered OTP channel (WhatsApp first) and the resend cooldown the server sets, the issuer and document-type pickers, the document camera loaded only when that step shows (with tap-to-focus and the manual shutter fallback), the processing screen with a Refresh button once a verdict stalls, and aria-live announcements on every screen (axe AA; §10).

4.5 Headless steps — verification.run() (@sekyro/web)

The same plan without React: run() starts the plan runner and resolves the first step; on("step") delivers every next one as a typed step handle. Call one action on it, the SDK submits (same idempotency, replay and consent recovery as the liveness flow), re-reads the session and emits the next step. You own every screen and every word.

const verification = createVerification({ clientToken, locale: "fr", rearm: "manual" });
verification.on("step", (step) => void render(step));
const first = await verification.run?.();   // `run` exists from @sekyro/web 0.1.0-rc.8
if (first !== undefined) await render(first);

// in render(step):
switch (step.kind) {
  case "phone_otp": step.phase === "send" ? await step.send({ phone, channel }) : await step.verify(code); break;
  case "email_otp": step.phase === "send" ? await step.send({ email }) : await step.verify(code); break;
  case "method":    await step.select("document_photo"); break;   // or "national_eid"
  case "eid":       step.phase === "identifier" ? await step.start(nni) : await step.verify(code); break;
  case "registry":  if (step.source === "typed") await step.submitNni(nni); break;
  case "document":  step.documentType === null
                      ? step.selectDocument(issuer, type)
                      : await step.capture({ video: rearVideo }).start(); break;
  case "liveness":  await step.capture({ video: frontVideo }).start(); break;
  case "portrait":  if (step.phase === "capture") await step.capture({ video: frontVideo }).start(); break;
  case "result":    showResult(step.display); break; // display only
}

Full example with every step kind and error handling: snippets/headless-steps.ts.

ICAO background check (ID-document sessions only). While the ICAO gate runs (the portrait step and the one-capture live photo), the SDK also checks that the background around the head and shoulders is plain: the MediaPipe selfie segmenter (selfie_segmenter.tflite, §8) removes the person, a few times per second, on the downscaled analysis frame, and the colour spread, edge/texture density and brightness gradient of what is left are measured (without the segmenter, or when it is too slow on the device: two strips beside the face, above the shoulders). A busy background holds the shot with "Stand in front of a plain, light wall" (portrait.coach.background) for at most timeoutMs, then the shot proceeds — the server is the final judge. The measured numbers ride the portrait_icao gate.backgroundCheck block (numbers only). Thresholds come from capture.icao.backgroundCheck when the server sends them; defaults: enabled: true, hz: 3, maxColorStd: 28 (RMS of the RGB standard deviations, 0–255), maxEdgeDensity: 0.08, edgeLuma: 24, maxGradient: 0.18 (0–1), minPixels: 300, maskThreshold: 0.5, segmenterTimeoutMs: 8000, segmenterBudgetMs: 120, timeoutMs: 4000.

Flow model (2026-10-23): every handle carries section — contact (Step 1), identity (Step 2) or live_photo (Step 3), null on the result — and context.sections lists the sections of this session in order: draw one progress segment per section instead of one per check. Consent is implicit: show one line ("By continuing, you agree to the terms.", with a link to the terms) on the first screen; the SDK records the consent right before the person's first action.

step.kindDataActions
contact_choiceoptions (phone, email), selected, locked — a contact.mode: one_of session asks this before any codeselect("phone" | "email")
phone_otpphase (send / verify), needsPhone, numberSource, channels (offered, WhatsApp first), channel (used), sentToMasked, codeExpiresAt, resendAvailableAt, canSwitch / switchChannel (a one_of session before a contact step passed), reason, attemptsRemainingsend({ phone?, channel? }), verify(code), switchTo()
email_otpphase, needsEmail, emailSource, sentToMasked, codeExpiresAt, resendAvailableAt, canSwitch / switchChannel, reason, attemptsRemainingsend({ email? }), verify(code), switchTo()
methodoptions (national_eid first when offered, then national_number / document_photo), nationalEid ({ provider, providerName, country, afterPass }, e.g. MR.houwiyeti / "Houwiyeti" / MR / end)select(method)
eidprovider, providerName, country, challenge (app_code), phase (identifier / code), identifierBound, codeExpiresAt, resendAvailableAt, resendsRemaining, codeAttemptsRemaining, canUseCamera, cameraMethod, afterPass (end / continue: a live photo follows — say so, e.g. "then a quick selfie"), reason, attemptsRemainingstart(identifier?), verify(code), resend(), useCamera()
registrysource (typed / bound / document), recheck, canUseDocument, reason, attemptsRemainingsubmitNni(nni), confirm(), useDocument()
documentside, acceptedSides, documents (accepted types per issuer), issuer, documentType, captureSide, reasonselectDocument(issuer, type), capture({ video })
identity_summarysummaryKind (full: document / chip, with the photo; partial: registry from a typed NNI — the short name "Mohamed S." (label it "Name"), birthYear, no photo), source (registry / document / chip), fullName, birthDate, birthYear, nationality, documentType, documentNumberMasked, nniMasked, expiryDate (each only when read; masks keep the last 4 characters — show them left to right, never log them), portraitAvailable, retakeAvailable, retakeTarget (document / national_number)portrait() → Blob | null (show it with URL.createObjectURL, revoke it after), confirm(), retake()
livenessmode (liveness / selfie), requiresMobile, instructions (standard / icao: show the ICAO tips — glasses off, no hat, neutral face, plain light background…), livePhoto ({ icao, still } — one capture: with icao the gate also waits for the ICAO conditions and the SDK sends the full-resolution still with the liveness submission), context.returning, reason, attemptsRemainingcapture({ video }) (the liveness handle of §4.2), onLivePhoto(handler) (ICAO coaching while the camera runs: { holding, update }, show portrait.coach.<update.coach> while !holding)
portraitphase (capture / match), report (the failed ICAO criteria), review, retakeOnly (the live check passed — only the photo needs another still), uploadFailed, reason, attemptsRemainingcapture({ video, assetBaseUrl? }) (front camera, strict ICAO gate; the SDK then submits face_match with the accepted portrait), resubmit() after an upload failure
mobile_requiredcheckTyperefresh()
processingstalled (after 30 s of re-reads)refresh()
unsupportedcheckType, reason (platform_unsupported / update_required)—
resultdisplay, result, failureReason, retryAfter— (display only; decide on your server)

Flow model 2026-10-23 (quickstart). The session view adds steps[].section (contact / identity / live_photo) to group steps into the three sections, otpChoice (one_of contact mode: POST /hosted/contact { channel }), and identitySummary (summary and confirm: POST /hosted/identity/confirm { decision: "confirm" | "retake" }, portrait from GET /hosted/identity/portrait with the session-token header; fetch it once and keep it in memory: it allows 10 requests a minute per session and 60 per public IP address (shared behind carrier NAT), then 429 rate_limit_exceeded with scope portrait and a Retry-After header). The live photo is one capture: capture.livePhoto asks for a full-resolution still during the liveness recording, sent with the liveness submission (input.portraitIcao when the ICAO portrait is on, else the face-match bundle); an ICAO-only failure is retaken with POST checks/portrait_icao and a new still, without a new clip. The step handles for these land in a later @sekyro/web release; until then the runner is unchanged for profiles that leave the new settings out. identitySummary exists only on this session-token view: never in the partner API or webhooks. identitySummary.kind is full (document or chip: every field, and the portrait) or partial (an NNI the person typed: fullName is the first name and last initial, birthYear instead of birthDate, the masked NNI, no portrait). It is absent (no confirm, no wait) when the NNI was bound at create and no document was read, the identity was supplied, or after a national eID.

Every handle also carries key and context (sessionId, orgName, branding, consentRecorded, returning, expiresAt). verification.currentStep() returns the latest handle.

Rules the runner follows, and that your screens should too:

  • Input errors are local. send, verify and submitNni reject with InvalidInputError (field: phone / code / nni; kind: format / check_digit) or OtpInputError (field: channel / email) before any network call — nothing was submitted or billed. Ask for the same field again.
  • OTP channel: the person chooses. When channels lists more than one, show one button per channel and pass the choice as send({ channel }); the SDK never picks for them (OtpInputError("channel", "required") without one; "unavailable" for a channel not offered). channels is [] on a session created before channel choice: call send() without a channel. Offer a resend only after resendAvailableAt.
  • Re-reads, not guesses. After each action the SDK reads the session and emits the step the server asks for: a wrong code comes back as phone_otp(verify) with reason: "otp_mismatch" and fewer attemptsRemaining; a likely NNI typo as registry with recheck: true.
  • Processing. While a verdict is pending the step is processing and the SDK re-reads it (1 + 2 + 4 + 8 + 15 s); after that stalled is true and the polling stops — offer refresh().
  • Never auto-retry a rejected action (a network error, 429, a HostedApiError): show the problem and let the user act again.
  • Document picker, any issuing country. acceptedIssuers(step.documents) lists the accepted issuers (ISO 3166-1 alpha-2, sorted by code — sort them by their localised name for display, e.g. with Intl.DisplayNames) and acceptedTypesFor(step.documents, issuer) the types accepted for one (carte_identite, residence_permit, passport; DOCUMENT_TYPE_ORDER is the display order, flagOf(iso2) the flag). Pick the issuer first, then the type; with one choice you may preselect it. selectDocument throws a RangeError for a pair the profile does not accept, and re-emits the document step with documentType set. A server refusal document_type_not_accepted reopens the picker.
  • Live camera only. The document step takes photos from the rear camera (no file upload). One capture() covers both faces of a card: either face first, then the scanner asks to turn the card over (scan event, phase: "flipCue") and the step moves from recto to verso. A passport is one data page (captureSide: "single"). A retryable refusal (unreadable, nationality_unreadable, document_authenticity, …) re-opens the same side for a clearer photo.
  • National eID. Offer it as "Verify with {providerName}". In the identifier phase, start(nni) asks the provider for a code (on a session bound to an NNI, call start() with no number; a different number answers 409 nni_locked). In the code phase the person opens the provider's app and types the 6-digit code: verify(code). Offer resend() only after resendAvailableAt (one code per 60 s, 3 per session). A wrong code comes back with fewer codeAttemptsRemaining (5 in all, then eid_locked). With canUseCamera, offer useCamera() to switch to the camera path. A passed eID ends the session: the next step is result. <SekyroVerification> renders all of this (<EidStep>, the same screens as the hosted page; its view and copy load on demand). The 0.1 <LivenessCapture> drop-in never offers the eID. Under the handle: the session view's eid_auth step carries eid (provider, providerName, country, challenge, awaiting: identifier / code, identifierBound, expiresAt, resendAvailableAt, resendsRemaining, codeAttemptsRemaining), and the check is submitted like every other, with input { action: "start", identifier? }, { action: "verify", code } or { action: "resend" }. The method is chosen with { method: "national_eid" } on the method route. The view never holds the number or the code.
  • Chip (NFC). Never rendered on the web: a preferred chip step is skipped by the server; a mandatory one ends with display: "platform_unsupported" ("This verification needs a phone app.").
  • Desktop. A profile that requires a phone for the biometric step shows mobile_required on a desktop browser; send the user to their phone, then refresh().

run() is opt-in: without it the Verification behaves exactly as in §4.2.

4.6 Document capture on its own — createDocumentCapture (@sekyro/web)

The guided rear-camera scanner the document step uses, as a standalone handle — for your own upload pipeline. It uploads nothing.

const capture = createDocumentCapture({ video, documentType: "carte_identite", issuer: "SN" /* side?, haptics?, keepScreenAwake? */ });
capture.on("state", (s) => …);                  // idle → starting → scanning → captured | error
capture.on("feedback", ({ key, reticle }) => …);// "too_far", "glare", "hold_still", … — you own the copy
capture.on("gate", ({ progress, quad }) => …);  // progress 0..1; quad in frame coordinates
capture.on("scan", (scan) => …);                // phase ("flipCue" = turn the card over), manualShutter, torchAvailable, notice
capture.on("captured", (photo) => …);           // upload-ready JPEG: image, side, width, height, previewDataUrl
capture.on("error", ({ kind, retryable }) => …);
await capture.start();
// capture.shutter() (manual fallback) · capture.focusAt(x, y) · capture.setTorch(on) · capture.setSide(side) · capture.retry() · capture.destroy()

The scanner locks on its own: an identity card is measured inside the on-screen guide (a hand or a patterned table around the card does not block it), a passport on its MRZ band. When nothing locks — 6 s without a capture, or a document seen for a while without locking — scan.manualShutter turns true: show a shutter button that calls capture.shutter() (the server still checks the photo). focusAt(x, y) is tap-to-focus where the camera supports it; the scanner otherwise opens the main back lens, refocuses before the photo and refuses an out-of-focus auto-capture (feedback key focus_far).

Full example: snippets/document-capture.ts. The engine (scanner and MRZ detector) is loaded on first start(), so the root bundle does not grow; warmDocumentCapture() starts that download early. Nothing in the scanner is specific to one country: identity cards (ID-1 or ID-2), residence permits and passport data pages of any issuer.

Live MRZ (on-device reading of the machine-readable zone)

Inside a verification (the document step, §4.5, or <SekyroVerification>, §4.4), the scanner can read the MRZ on the device, frame by frame, on the face that carries it (the passport page, the back of an identity card or residence permit). It takes the photo only once every check digit validates on two reads in a row, then uploads it with a small text-free hint (where the MRZ is in the photo). Card fronts lock on their text layout. The server still reads and decides every photo; the device read only chooses when to shoot.

  • Server-controlled. Sekyro AI turns it on per app (staged rollout). With it off — or on any device or browser that cannot run it (no WebAssembly SIMD, no module workers, low memory, a slow or Save-Data connection) — the scanner is exactly the one described above.

  • Your part: serve the worker. The reader runs in a Web Worker. Pass where your bundler serves it:

    step.capture({ video, liveMrz: { workerUrl: new URL("@sekyro/web/core/ocr/worker", import.meta.url) } });
    // React: <SekyroVerification documentLiveMrz={{ workerUrl }} … />
    // A bundler without worker URLs: copy node_modules/@sekyro/web/dist/core/ocr/worker.bundle.js
    // (self-contained) to your static folder and pass workerUrl: "/sekyro/ocr-worker.js".

    Without workerUrl the reader stays off (nothing else changes). liveMrz: { mode: "off" } opts out explicitly.

  • Events. capture.on("mrz", ({ state, format, coach, reason }) => …): searching → reading (with coach: moveCloser, turnSideways, glare, holdStill) → valid; mismatch (a passport MRZ when a card was picked, or the reverse — the photo is still taken, the server decides); fallback (timeout, slow, unsupported, download, integrity, error, off — the regular scanner continues). Events never carry MRZ text.

  • Assets. The worker downloads a ~3.5 MB wasm engine and ~5.2 MB of models once (then cached), and verifies each file's sha256 before using it — see §8.


5. Results — display vs authoritative

onResult / v.on('result') is for the screen. The verdict that counts is read by your server with sessions.get(id) and kept current by webhooks. Never grant anything from a browser event.

result.statusMeaningreason
approvedSession completed, outcome approved—
rejectedSession failed / outcome rejected, or a non-retryable refusal of this capturethe deciding check's reason (§9)
reviewParked for human review (up to 48 h); not a rejectionthe review-band reason, when present
retryRetryable reason with attempts remaining and autoRetry on (createVerification option, default true) — the capture already re-armed itself (rearm: 'auto') or waits for your retry() (rearm: 'manual'); tell the user why. attemptsRemaining carries the budget leftno_face, media_unreadable, clip_too_short, no_face_source, …
unavailablev2 could not run this capture. Treat as "not completed", offer to try again laterno_recorder (browser has no MediaRecorder; nothing was submitted), attestation (Turnstile failed after a retry), network (API/upload failure), registry_unavailable (registry outage after the built-in retry), nni_unbound (the session was created without input.nni, so the SDK cannot submit population_registry — mint the session with the NNI, or complete that step yourself before the capture); no reason when the session expired

approved/rejected/review fire once per session; retry and unavailable fire per capture. The registry can settle a session before any capture (e.g. nni_not_found → rejected while the camera is still starting) — handle result from the moment you call startRegistry().

Integration recipe

  1. Server creates the session with the bound NNI and returns clientToken to its page (§3).
  2. Page mounts <LivenessCapture verification={createVerification({ clientToken })}> (or the headless equivalent). The SDK runs consent, the early registry check, the capture, liveness and face_match.
  3. On onResult, the page calls your backend, which calls sekyro.sessions.get(id) and decides from status + outcome (decideVerification in server-create.ts). Render that.
  4. Webhooks update your record on async transitions: the tab was closed mid-flow, session.review_decided lands hours later, the session expired. Notify the user out of band.
sequenceDiagram
    participant U as User's browser
    participant P as Your page
    participant S as Your server
    participant K as Sekyro AI API
    participant R as Registry / ML

    S->>K: sessions.create({input:{nni}})  (app key, idempotencyKey)
    K-->>S: {id, clientToken, expiresAt}
    S-->>P: clientToken (once)
    P->>K: consent  ·  population_registry (empty input → bound NNI)
    K->>R: NNI lookup
    U->>P: camera · gate · 2.5 s clip
    P->>K: Turnstile token · presign · PUT clip · liveness
    P->>K: presign · PUT keyframe · face_match
    K->>R: liveness + face_match model runs
    K-->>P: result {status, reason?}   (display)
    P->>S: "done" (sessionId)
    S->>K: sessions.get(id)
    K-->>S: {status, outcome, steps}
    S-->>P: your decision
    K-->>S: webhook session.completed | failed | review_pending | review_decided | expired
    S->>K: sessions.get(id) → reconcile

Session fields your server decides on:

  • status: created | in_progress | review_pending | completed | expired | failed
  • outcome: null until terminal, then approved | review | rejected
  • steps[]: { checkType, state, reason?, calibratedScore? } — state is pending | input_required | processing | passed | failed | review | skipped. Gate on the session status, never on a step's state alone: a session can be failed with an earlier step still in review.

6. Webhooks

Moved to Webhooks.

7. Origins, CORS, attestation and rate limits

Registered origins

Give your Sekyro AI contact the exact origins your capture page runs on (scheme + host + port, e.g. https://app.example.com, http://localhost:3000 for development). Staff register them on your organisation; within 60 s, on every API instance, that one registration unlocks both of:

  1. API CORS — the browser may call api.sekyro.ai (staging: api-staging.sekyro.ai) with the client token;
  2. Bot attestation — a Cloudflare Turnstile widget scoped to your hostnames.

Media uploads need nothing from you. The browser PUTs the clip and keyframe straight to a presigned upload URL (https://*.digitaloceanspaces.com); the media bucket's CORS rule is platform-wide (AllowedOrigins: * for PUT/GET/HEAD), so any page origin passes the preflight. Access control is the presigned signature itself — one object key, one method, one content-type/length, a short expiry — not CORS. Only the API origin registration above gates your integration.

An unregistered origin fails at the first API call (CORS) — the SDK surfaces it as result { status: 'unavailable', reason: 'network' }.

Attestation (Turnstile)

Before the liveness submit the SDK runs one invisible Turnstile challenge (execution: 'execute') and sends the token with the check. The script https://challenges.cloudflare.com/turnstile/v0/api.js is loaded lazily, once, at that moment. In sandbox (no widget key on the session) no Turnstile call is made. A failed challenge is retried once, then result { status: 'unavailable', reason: 'attestation' }.

Content-Security-Policy

Minimum directives for a page that embeds the SDK (merge into your policy):

script-src   'self' 'wasm-unsafe-eval' https://challenges.cloudflare.com
frame-src    https://challenges.cloudflare.com
connect-src  'self' https://api.sekyro.ai https://*.digitaloceanspaces.com https://challenges.cloudflare.com
worker-src   'self' blob:
media-src    'self' blob: https://assets.sekyro.ai
img-src      'self' blob: data:
  • media-src 'self' blob: https://assets.sekyro.ai covers the Sekyro AI voice clips (verification mode, §4.3) and the iOS audio unlock (a short silent sound played as a blob: URL). The SDK never uses data: media. Without https://assets.sekyro.ai, the Sekyro AI clips are blocked and the device voice speaks instead — nothing breaks. assets.sekyro.ai serves only immutable public audio. Use https://assets-staging.sekyro.ai for sandbox builds. Clips of your own from another origin (your CDN, voice.clips URLs from your backend) need that origin in media-src too. All clips load through plain media elements, not fetch, so no clip host needs CORS headers and nothing goes in connect-src.

  • 'wasm-unsafe-eval' permits WebAssembly compilation (the face detector); without it the capture dies with error.kind === 'generic'.

  • worker-src 'self' blob: is needed when your bundler offloads detection to a worker (@sekyro/web/core/v3/detect-worker).

  • Use https://api-staging.sekyro.ai in connect-src for sandbox builds.

  • If you serve the model assets from another origin (§8), add that origin to both script-src and connect-src; voice clips from another origin go in media-src (above).

  • Also send Permissions-Policy: camera=(self) — a stricter policy on your page (or on an embedding iframe) blocks getUserMedia and the SDK reports error.kind === 'permission'.

  • The capture holds a Screen Wake Lock while the camera is live (keepScreenAwake, default on). Inside an iframe it needs the screen-wake-lock permission too: <iframe allow="camera; screen-wake-lock">, and no Permissions-Policy: screen-wake-lock=() on your page. Without it the capture still works — the screen may just dim during a long gating phase.

Rate limits

Browser calls carry the client token and are throttled per token (30 requests per minute) and per your organisation × client IP (120 per minute by default, multiplied by a per-organisation factor staff can raise to 50×). If many of your users share one egress IP (carrier NAT, corporate proxies), ask for the factor to be raised during onboarding. A 429 carries X-RateLimit-* headers; the SDK surfaces it as unavailable / network.


8. Model assets (assetBaseUrl)

The face detector is MediaPipe tasks-vision running in the browser. The SDK never fetches it from a CDN: you host the five files and point the SDK at them (assetBaseUrl, default /models-v3, i.e. https://your-page/models-v3/…).

FileSource
vision_wasm_internal.jsnode_modules/@mediapipe/tasks-vision/wasm/ (installed with @sekyro/web, pinned 0.10.35)
vision_wasm_internal.wasmsame (~11 MB)
vision_wasm_nosimd_internal.jssame
vision_wasm_nosimd_internal.wasmsame (~11 MB)
blaze_face_short_range.tflitehttps://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite (~229 KB)
face_landmarker.taskID-document apps only (the ICAO portrait gate): https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task (~3.7 MB, sha256 64184e229b263107bc2b804c6625db1341ff2bb731874b0bcc2fe6544e0bc9ff)
selfie_segmenter.tfliteID-document apps only (the ICAO background check, flow model 2026-10-23): https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/1/selfie_segmenter.tflite (~250 KB, sha256 191ac9529ae506ee0beefa6b2c945a172dab9d07d1e802a290a4e4038226658b). Missing or slow → the SDK falls back to a simpler check beside the face.

Expected sha256 (@mediapipe/[email protected] + the float16 v1 model) — verify after copying:

e7fd9858e8e8f221d9b96eddc11f8e077f263e0b7bbd79d3cbe882b134274f8c  vision_wasm_internal.js
6a5c64584c2ab61c763b6e204afbdbc7ce1caf7f5216187322bca8df94f646bc  vision_wasm_internal.wasm
438d1fe8ff7f4d946025bc211c291543c037d8a3785ed4eee60f1f521b236296  vision_wasm_nosimd_internal.js
8a3092d34c79d3f57e6ba8592105e8a90f6b07c27891ffecd14cca428bfd3e31  vision_wasm_nosimd_internal.wasm
b4578f35940bf5a1a655214a1cce5cab13eba73c1297cd78e1a04c2380b0152f  blaze_face_short_range.tflite

Option A — copy into your static folder at build time, checking every file against the pins (fail the build on a mismatch — the same check Sekyro AI's own hosted page runs):

mkdir -p public/models-v3
cp node_modules/@mediapipe/tasks-vision/wasm/vision_wasm_*internal.{js,wasm} public/models-v3/
curl -fsSL -o public/models-v3/blaze_face_short_range.tflite \
  https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite

cat > public/models-v3/manifest.sha256 <<'EOF'
b4578f35940bf5a1a655214a1cce5cab13eba73c1297cd78e1a04c2380b0152f  blaze_face_short_range.tflite
e7fd9858e8e8f221d9b96eddc11f8e077f263e0b7bbd79d3cbe882b134274f8c  vision_wasm_internal.js
6a5c64584c2ab61c763b6e204afbdbc7ce1caf7f5216187322bca8df94f646bc  vision_wasm_internal.wasm
438d1fe8ff7f4d946025bc211c291543c037d8a3785ed4eee60f1f521b236296  vision_wasm_nosimd_internal.js
8a3092d34c79d3f57e6ba8592105e8a90f6b07c27891ffecd14cca428bfd3e31  vision_wasm_nosimd_internal.wasm
EOF
(cd public/models-v3 && shasum -a 256 -c manifest.sha256)   # or: sha256sum -c manifest.sha256

Then assetBaseUrl="/models-v3" (the default). Serve .wasm as application/wasm, compressed (gzip/brotli) and with a long Cache-Control — uncompressed, the 11 MB wasm takes minutes on a 3G phone.

Option B — a CDN or asset host. Put the same files at https://assets.example.com/models-v3/ and pass assetBaseUrl="https://assets.example.com/models-v3". The host must send CORS Access-Control-Allow-Origin for your page origin, and your CSP must list it in script-src and connect-src (§7).

Do not rename, re-version or re-quantise the files: the SDK pins the tasks-vision version it was calibrated with.

OCR assets for live MRZ (liveMrz.assetBaseUrl)

The live MRZ reader (§4.6) fetches six files from <assetBaseUrl>/<fp12>/<name>, where <fp12> is a fingerprint of the exact set this SDK version ships (the SDK embeds every file's size and sha256 and refuses any other bytes — a mismatch is a silent fallback, never a wrong read):

FileSizeSource
sekyro-ocr.wasm3.55 MBthe Sekyro AI OCR engine (WebAssembly SIMD)
det.param, det.bin4.7 MBtext detector (PP-OCR, Apache-2.0)
mrz-rec.param, mrz-rec.bin, mrz_dict.txt0.5 MBMRZ-only recognizer
  • Default: the Sekyro AI assets CDN (https://assets.sekyro.ai/sdk/web/ocr, assets-staging for sandbox API origins). Rollout note: until Sekyro AI announces it for your app, self-host (below).
  • Self-host: copy the set listed by the SDK build's manifest into public/sekyro-ocr/<fp12>/ and pass liveMrz: { assetBaseUrl: "/sekyro-ocr" }. Serve .wasm as application/wasm, compressed, with Cache-Control: public, max-age=31536000, immutable (the directory name changes with the bytes).
  • CSP: script-src 'wasm-unsafe-eval' (already required, §7), worker-src 'self' for the worker, and the asset host in connect-src when it is not your origin (that host must send CORS Access-Control-Allow-Origin).

Voice clips

Sekyro AI hosts the voice clips; there is nothing to copy into your app. In verification mode the session delivers the clip URLs on https://assets.sekyro.ai — allow it in media-src (§7).

  • Versioned and immutable. A published clip set is never overwritten: a new voice or any new text ships as a new set with new URLs, so the Sekyro AI host caches clips with Cache-Control: public, max-age=31536000, immutable and a cached clip can never be stale.
  • English never has clips — it always uses the device voice. A key or locale the set does not cover goes straight to the device voice, with no request and no 404.
  • Bring your own. Pass voice.clips — a map or a resolver (§4.3, including the 3-line resolver for a <base>/<locale>/<name>.mp3 layout). Yours replace the Sekyro AI set entirely. Add your clip origin to media-src; no CORS is needed. If you host them, serve each published set under its own versioned path with the same immutable caching.
  • Capture-only mode (no verification): no default clips — your voice.clips or the device voice. voice={false} opts out of voice in either mode.

9. Errors and reason codes

Moved to Errors and reason codes.

10. Accessibility

The @sekyro/react component ships with, and is tested for:

  • WCAG AA by default and AAA (7:1 text contrast, 3:1 UI) on the capture screen with the default theme — keep that bar when you override colours (a contrast test is part of the package);
  • every interactive element ≥ 48 px; visible focus ring (outline: 3px solid var(--sekyro-color-ring));
  • guidance announced through aria-live="polite"; hints are short and change only when the instruction changes;
  • prefers-reduced-motion disables the ring / stage animations, and stops the Start spinner (the busy state stays announced through aria-busy); forced-colors (Windows High Contrast) keeps every control visible;
  • logical CSS only — dir="rtl" just works; inline SVG icons, no emoji.

On the pre-capture screen:

  • the voice switch is a button with role="switch" and aria-checked; the stage voice chip is a toggle button (aria-pressed);
  • the "Listen to the instructions" button is named by its visible text, which changes to "Stop" while the summary plays. It has no aria-pressed, so the state is not announced twice. Its speaker icon is decorative;
  • the front/back choice is a fieldset of two native radios (legend "Camera"), so arrow keys and "1 of 2" announcements work unchanged;
  • one visually hidden role="status" line announces "Preparing secure capture…" and then "Ready — start when you are". The Start label change itself is not a live region, so nothing is announced twice. The permission row becomes a status only once the permission is known, so it is not read out on page load;
  • the loading or startDisabled Start is aria-disabled, not disabled. It stays focusable and in the tab order, and becomes the ready Start in place, so focus never drops to the page;
  • when the detector download fails, focus moves to Retry, but only if focus was already inside the component. The screen never pulls focus out of your page.

RTL. With locale="ar" the component defaults to dir="rtl". The tips, permission row, segmented camera choice and switch all mirror through logical properties. Pass dir explicitly when your page's direction differs from the locale's default. The spoken guidance follows locale, not dir.

If you go headless, carry the same bar: make the hint element aria-live, keep the retry button ≥ 48 px, use aria-disabled (not disabled) on a Start that waits for the preload, and offer a non-camera path (the hosted link) for users who cannot complete a capture.


11. Billing

One session of the biometric flow meters at most three checks:

MeterCounted when
population_registryEach terminal attempt that produced an answer (pass, nni_not_found, nni_invalid, cache hit). A registry_unavailable outage is not billed, nor is a 409 nni_locked.
livenessEach model run. Pre-inference rejections (media_unreadable, clip_too_short) are not billed; a run that ends in no_face, spoof_detected, review_band or pass is.
face_matchEach model run. Reference-side failures that never reach the model (no_face_reference, low_quality_reference) and subject_revoked are not billed.

A session that uses the national eID meters eid_auth instead, once per code sent: the first send and each successful resend (6.00 MRU each, pricing; waived inside a free session). A failed send (person not enrolled, invalid number, provider outage, rate limit) and entering the code (right, wrong or expired) are not billed.

With autoRetry on, every re-capture that reaches a model is a run. A capture the SDK reports as unavailable with no_recorder, attestation or network submitted nothing and costs nothing.


12. Versioning

The SDKs are 0.x: breaking changes bump the minor version (0.1 → 0.2), fixes bump the patch. Everything documented here and in the packages' etc/*.api.md reports is the public contract; subpaths @sekyro/web/internal and anything marked @internal carry no guarantees. Each package has its own CHANGELOG.md. Pin a minor ("@sekyro/web": "~0.1.0") and read the changelog before moving.

The profile v2 surface is additive. Code written for the 0.1 liveness flow runs unchanged on @sekyro/web 0.1.0-rc.8 and later: the plan runner starts only when you call run(), and without it createVerification makes the same calls and emits the same events as before. @sekyro/react keeps its main entry as it was (<LivenessCapture>, its strings, theme and styles.css) and adds @sekyro/react/verification + verification.css next to it.

@sekyro/react is a 0.1 preview: the props in §4.1 are the contract we are shipping, but expect additive changes (new slots, new string keys) before 0.2. Behaviour changes in this preview line are called out in the changelog — for example, autoStart now waits for the detector preload (a host that renders its own intro outside the component and mounts it with autoStart now sees the built-in screen's loader meanwhile — pass slots.intro, e.g. a loader driven by context.assets, or move your intro into slots.intro), and the component now speaks the guidance itself (pass voice={false} if your voicePrompt slot already does).


Questions, origin registration, rate-limit factors: your Sekyro AI contact at Ibtikar Technologies.

On this page