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.
| Package | Role | Status |
|---|---|---|
@sekyro/node | Server: create sessions, read results, verify webhooks | 0.1 |
@sekyro/web | Browser: the framework-free engine — camera, gate, upload, attestation, checks, result | 0.1 |
@sekyro/react | Browser: <LivenessCapture> + <SekyroThemeProvider> on top of @sekyro/web; @sekyro/react/verification: <SekyroVerification> and the profile v2 step components | 0.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
| Aspect | Hosted flow | Embedded SDK |
|---|---|---|
| Where the user is | verify.sekyro.ai (link, redirect or popup) | Your page, your domain |
| UI | Sekyro AI's | Yours (@sekyro/react themeable component, or fully headless) |
| What you build | Session creation + result read | Session creation + result read + the capture page |
| Setup | API key | API key + registered origins, CSP, model assets |
| Guide | Quickstart | This 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 server | yes | yes |
| 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 match | yes | yes |
| 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 own | yes |
| 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.8import "@sekyro/react/styles.css"; // the liveness step (shared with <LivenessCapture>)
import "@sekyro/react/verification.css"; // the OTP, method, NNI, document and result stepsUse 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 underlatest. Every version is published with an npm provenance attestation from the public source repository (npm audit signaturesverifies it) and ships a CycloneDX SBOM on its GitHub release. The packages are proprietary (UNLICENSED) — see each package'sLICENSE: installable from npm, usable under your Sekyro AI customer agreement.
Environments:
| Aspect | Sandbox | Production |
|---|---|---|
@sekyro/node baseUrl | https://api-staging.sekyro.ai/api/v2 | https://api.sekyro.ai/api/v2 (default) |
@sekyro/web apiOrigin | https://api-staging.sekyro.ai | https://api.sekyro.ai (default) |
| API key | sk_test_… | sk_live_… |
| Registry | Seeded 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 }clientTokenis 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.nniis locked to that identity. The SDK submitspopulation_registrywith an empty input and the server uses the bound value. A browser that sends a different NNI gets409 nni_lockedat request level — no attempt is consumed, nothing is billed. nnigoes underinput. Raw-HTTP callers sendingnni(orphone,fullName,birthDate) at the top level of the body get400 input_field_misplacedinstead of an unbound session; the typed@sekyro/nodeinput already nests it.- Idempotency. Same key + same body within 24 h returns the same session;
same key + different body is
409 idempotency_key_collision. RetrySekyroApiError.retryablefailures with the same key. - Persist
created.idagainst 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.
| Prop | Type | Notes |
|---|---|---|
verification? | Verification from createVerification | Omit 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 false | Skip 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 false | Disable 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? | ReactNode | Replace the Start label (e.g. "Retry in 12 s"). |
tips? | false | (BuiltInTipId | IntroTip)[], default DEFAULT_TIPS | The 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? | string | The 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 | null | Your 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 on | Spoken guidance (§4.3). false = no speech, no voice switch, no stage chip. |
onGuidance? | (key: StringKey) => void | Telemetry: once per change of the displayed fcv3.* guidance key. |
assetBaseUrl? | string, default /models-v3 | Where you host the detector assets (§8). |
className? | string | Extra class on the root element. |
onCaptured | (a: CapturedArtifacts) => void | Fires before any upload. |
onResult | (r: VerificationResult) => void | Display only — see §5. |
onError | (e: CaptureError) => void | Camera / engine failures (permission, notfound, inuse, insecure, unsupported, generic). |
onStateChange | (s: CaptureState) => void | Every 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):
| Export | What 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:
- your
notice, when set ({ tone, text }or any node); - 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); - the camera-permission row: allowed / the browser will ask / blocked, with the fix / unknown;
- a front/back camera choice, only on devices that have both;
- the voice guidance switch, only where voice can actually play for the locale;
- the security note;
- 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 readingcapture.tips.summary, withinspeechFallback. -
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:
- voice is not
false; - a clip or a usable device voice exists for the summary in the current locale;
- the tips shown are the built-in four, in any order.
There is one exception to the third rule: a
capture.tips.summaryclip for the locale in yourvoice.clipsshows the button with anytips, includingfalse. The voice switch does not hide the button: the switch controls automatic guidance, and the tap is an explicit request. - voice is not
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:
| Field | Type | Use |
|---|---|---|
assets | 'loading' | 'ready' | 'failed' | Gate your Start on 'ready'. Also 'loading' while autoStart="when-permitted" waits for the first permission answer. |
retryAssets | () => void | Re-run a failed preload. |
permission | CameraPermissionState | Your permission row. |
facing / setFacing / canChooseFacing | CameraFacing / setter / boolean | Your camera choice; show it only when canChooseFacing. |
voice | VoiceControl ({ 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).
VoiceOptions | Default | Notes |
|---|---|---|
defaultEnabled? | true | Used 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? | true | false, 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.
| Prop | Type | Notes |
|---|---|---|
verification | Verification | Required. 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? | SekyroTheme | Merged over the app's branding from the dashboard (session.branding), key by key. Same tokens as §4.1. |
assetBaseUrl? | string | Detector assets for the liveness and portrait steps (§8). |
voice? | false | VoiceOptions | Liveness voice guidance (§4.3). |
livenessProps? | the <LivenessCapture> props except the ones above | e.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) => void | Every step change (telemetry, your own progress bar). |
onLivenessCaptured? | (a: CapturedArtifacts) => void | The liveness clip, before any upload. |
onResult? | (r: VerificationResult) => void | Display only (§5). |
className? | string | Extra 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.kind | Data | Actions |
|---|---|---|
contact_choice | options (phone, email), selected, locked — a contact.mode: one_of session asks this before any code | select("phone" | "email") |
phone_otp | phase (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, attemptsRemaining | send({ phone?, channel? }), verify(code), switchTo() |
email_otp | phase, needsEmail, emailSource, sentToMasked, codeExpiresAt, resendAvailableAt, canSwitch / switchChannel, reason, attemptsRemaining | send({ email? }), verify(code), switchTo() |
method | options (national_eid first when offered, then national_number / document_photo), nationalEid ({ provider, providerName, country, afterPass }, e.g. MR.houwiyeti / "Houwiyeti" / MR / end) | select(method) |
eid | provider, 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, attemptsRemaining | start(identifier?), verify(code), resend(), useCamera() |
registry | source (typed / bound / document), recheck, canUseDocument, reason, attemptsRemaining | submitNni(nni), confirm(), useDocument() |
document | side, acceptedSides, documents (accepted types per issuer), issuer, documentType, captureSide, reason | selectDocument(issuer, type), capture({ video }) |
identity_summary | summaryKind (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() |
liveness | mode (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, attemptsRemaining | capture({ video }) (the liveness handle of §4.2), onLivePhoto(handler) (ICAO coaching while the camera runs: { holding, update }, show portrait.coach.<update.coach> while !holding) |
portrait | phase (capture / match), report (the failed ICAO criteria), review, retakeOnly (the live check passed — only the photo needs another still), uploadFailed, reason, attemptsRemaining | capture({ video, assetBaseUrl? }) (front camera, strict ICAO gate; the SDK then submits face_match with the accepted portrait), resubmit() after an upload failure |
mobile_required | checkType | refresh() |
processing | stalled (after 30 s of re-reads) | refresh() |
unsupported | checkType, reason (platform_unsupported / update_required) | — |
result | display, 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,verifyandsubmitNnireject withInvalidInputError(field:phone/code/nni;kind:format/check_digit) orOtpInputError(field:channel/email) before any network call — nothing was submitted or billed. Ask for the same field again. - OTP channel: the person chooses. When
channelslists more than one, show one button per channel and pass the choice assend({ channel }); the SDK never picks for them (OtpInputError("channel", "required")without one;"unavailable"for a channel not offered).channelsis[]on a session created before channel choice: callsend()without a channel. Offer a resend only afterresendAvailableAt. - 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)withreason: "otp_mismatch"and fewerattemptsRemaining; a likely NNI typo asregistrywithrecheck: true. - Processing. While a verdict is pending the step is
processingand the SDK re-reads it (1 + 2 + 4 + 8 + 15 s); after thatstalledistrueand the polling stops — offerrefresh(). - Never auto-retry a rejected action (a network error,
429, aHostedApiError): 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. withIntl.DisplayNames) andacceptedTypesFor(step.documents, issuer)the types accepted for one (carte_identite,residence_permit,passport;DOCUMENT_TYPE_ORDERis the display order,flagOf(iso2)the flag). Pick the issuer first, then the type; with one choice you may preselect it.selectDocumentthrows aRangeErrorfor a pair the profile does not accept, and re-emits thedocumentstep withdocumentTypeset. A server refusaldocument_type_not_acceptedreopens 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 (scanevent,phase: "flipCue") and the step moves fromrectotoverso. 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
identifierphase,start(nni)asks the provider for a code (on a session bound to an NNI, callstart()with no number; a different number answers409 nni_locked). In thecodephase the person opens the provider's app and types the 6-digit code:verify(code). Offerresend()only afterresendAvailableAt(one code per 60 s, 3 per session). A wrong code comes back with fewercodeAttemptsRemaining(5 in all, theneid_locked). WithcanUseCamera, offeruseCamera()to switch to the camera path. A passed eID ends the session: the next step isresult.<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'seid_authstep carrieseid(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
preferredchip step is skipped by the server; amandatoryone ends withdisplay: "platform_unsupported"("This verification needs a phone app."). - Desktop. A profile that requires a phone for the biometric step shows
mobile_requiredon a desktop browser; send the user to their phone, thenrefresh().
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
workerUrlthe reader stays off (nothing else changes).liveMrz: { mode: "off" }opts out explicitly. -
Events.
capture.on("mrz", ({ state, format, coach, reason }) => …):searching→reading(withcoach: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.status | Meaning | reason |
|---|---|---|
approved | Session completed, outcome approved | — |
rejected | Session failed / outcome rejected, or a non-retryable refusal of this capture | the deciding check's reason (§9) |
review | Parked for human review (up to 48 h); not a rejection | the review-band reason, when present |
retry | Retryable 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 left | no_face, media_unreadable, clip_too_short, no_face_source, … |
unavailable | v2 could not run this capture. Treat as "not completed", offer to try again later | no_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
- Server creates the session with the bound NNI and returns
clientTokento its page (§3). - Page mounts
<LivenessCapture verification={createVerification({ clientToken })}>(or the headless equivalent). The SDK runs consent, the early registry check, the capture, liveness and face_match. - On
onResult, the page calls your backend, which callssekyro.sessions.get(id)and decides fromstatus+outcome(decideVerificationinserver-create.ts). Render that. - Webhooks update your record on async transitions: the tab was closed
mid-flow,
session.review_decidedlands hours later, the sessionexpired. 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) → reconcileSession fields your server decides on:
status:created | in_progress | review_pending | completed | expired | failedoutcome:nulluntil terminal, thenapproved | review | rejectedsteps[]:{ checkType, state, reason?, calibratedScore? }—stateispending | input_required | processing | passed | failed | review | skipped. Gate on the sessionstatus, never on a step's state alone: a session can befailedwith an earlier step still inreview.
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:
- API CORS — the browser may call
api.sekyro.ai(staging:api-staging.sekyro.ai) with the client token; - 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.aicovers the Sekyro AI voice clips (verification mode, §4.3) and the iOS audio unlock (a short silent sound played as ablob:URL). The SDK never usesdata:media. Withouthttps://assets.sekyro.ai, the Sekyro AI clips are blocked and the device voice speaks instead — nothing breaks.assets.sekyro.aiserves only immutable public audio. Usehttps://assets-staging.sekyro.aifor sandbox builds. Clips of your own from another origin (your CDN,voice.clipsURLs from your backend) need that origin inmedia-srctoo. All clips load through plain media elements, notfetch, so no clip host needs CORS headers and nothing goes inconnect-src. -
'wasm-unsafe-eval'permits WebAssembly compilation (the face detector); without it the capture dies witherror.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.aiinconnect-srcfor sandbox builds. -
If you serve the model assets from another origin (§8), add that origin to both
script-srcandconnect-src; voice clips from another origin go inmedia-src(above). -
Also send
Permissions-Policy: camera=(self)— a stricter policy on your page (or on an embedding iframe) blocksgetUserMediaand the SDK reportserror.kind === 'permission'. -
The capture holds a Screen Wake Lock while the camera is live (
keepScreenAwake, default on). Inside an iframe it needs thescreen-wake-lockpermission too:<iframe allow="camera; screen-wake-lock">, and noPermissions-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/…).
| File | Source |
|---|---|
vision_wasm_internal.js | node_modules/@mediapipe/tasks-vision/wasm/ (installed with @sekyro/web, pinned 0.10.35) |
vision_wasm_internal.wasm | same (~11 MB) |
vision_wasm_nosimd_internal.js | same |
vision_wasm_nosimd_internal.wasm | same (~11 MB) |
blaze_face_short_range.tflite | https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite (~229 KB) |
face_landmarker.task | ID-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.tflite | ID-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.tfliteOption 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.sha256Then 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):
| File | Size | Source |
|---|---|---|
sekyro-ocr.wasm | 3.55 MB | the Sekyro AI OCR engine (WebAssembly SIMD) |
det.param, det.bin | 4.7 MB | text detector (PP-OCR, Apache-2.0) |
mrz-rec.param, mrz-rec.bin, mrz_dict.txt | 0.5 MB | MRZ-only recognizer |
- Default: the Sekyro AI assets CDN (
https://assets.sekyro.ai/sdk/web/ocr,assets-stagingfor 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 passliveMrz: { assetBaseUrl: "/sekyro-ocr" }. Serve.wasmasapplication/wasm, compressed, withCache-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 inconnect-srcwhen it is not your origin (that host must send CORSAccess-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, immutableand 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>.mp3layout). Yours replace the Sekyro AI set entirely. Add your clip origin tomedia-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 — yourvoice.clipsor 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-motiondisables the ring / stage animations, and stops the Start spinner (the busy state stays announced througharia-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
buttonwithrole="switch"andaria-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
fieldsetof 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
startDisabledStart isaria-disabled, notdisabled. 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:
| Meter | Counted when |
|---|---|
population_registry | Each 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. |
liveness | Each 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_match | Each 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.