Code samples
Complete, typechecked integration files used by the guides: server session creation, browser capture and webhook receivers.
Each file below is typechecked against the published SDK types on every change.
document-capture.ts
// docs/partner/snippets/document-capture.ts — the guided document scanner on its own
// (`createDocumentCapture`): you upload the photos yourself. Inside a verification,
// use the `document` step handle instead (headless-steps.ts) — it uploads and checks.
// Typechecked by check.mjs against the BUILT @sekyro/web.
import {
createDocumentCapture,
warmDocumentCapture,
type CapturedDocument,
type DocumentFeedbackKey,
} from "@sekyro/web";
// Optional: start fetching the scanner engine (a separate chunk) before the screen shows.
warmDocumentCapture();
export function scanIdentityCard(
video: HTMLVideoElement,
ui: {
coach(key: DocumentFeedbackKey): void;
ring(progress: number): void;
turnOver(): void;
showShutter(visible: boolean): void;
failed(kind: string, retryable: boolean): void;
},
): Promise<CapturedDocument[]> {
// Any issuer: identity card (ID-1 or ID-2), residence permit, or passport data page.
const capture = createDocumentCapture({ video, documentType: "carte_identite", issuer: "SN" });
const photos: CapturedDocument[] = [];
return new Promise((resolve, reject) => {
capture.on("feedback", ({ key }) => ui.coach(key)); // "too_far", "glare", "hold_still", …
capture.on("gate", ({ progress }) => ui.ring(progress));
capture.on("scan", (scan) => {
if (scan.phase === "flipCue") ui.turnOver(); // either face first; then the other one
ui.showShutter(scan.manualShutter); // fallback: call capture.shutter() from your button
});
capture.on("captured", (photo) => {
photos.push(photo); // upload-ready JPEG (`photo.image`), its side and size
if (photos.length === 2) {
capture.destroy();
resolve(photos);
}
});
capture.on("error", (err) => {
ui.failed(err.kind, err.retryable); // capture.retry() restarts after a retryable error
if (!err.retryable) {
capture.destroy();
reject(new Error(err.kind));
}
});
capture.start().catch(reject);
});
}full-profile.tsx
// docs/partner/snippets/full-profile.tsx — the whole eKYC profile with @sekyro/react/verification.
// Flow: your server creates the session (server-create.ts; no NNI needed when the
// profile asks the user for it) → <SekyroVerification> renders every step the
// profile has → `onResult` is for DISPLAY → your server decides (decideVerification).
// Typechecked by check.mjs against the BUILT packages.
"use client";
import { useEffect, useMemo, useState } from "react";
import { createVerification, type VerificationResult } from "@sekyro/web";
import type { SekyroTheme } from "@sekyro/react";
import { SekyroVerification, type SekyroStrings } from "@sekyro/react/verification";
import "@sekyro/react/styles.css";
import "@sekyro/react/verification.css";
import { decideVerification } from "./server-create";
// Your theme wins over the app's dashboard branding (the session's `branding`).
const theme: SekyroTheme = { colors: { brand: "#0c7a3d", brandFg: "#ffffff" }, radius: "12px" };
// Any step key of the generated catalog can be overridden; the rest keeps the built-in copy.
const strings: Partial<SekyroStrings> = { "otp.sendTitle": "Confirmez votre numéro" };
export function FullProfileVerification(props: {
sessionId: string;
clientToken: string;
locale: "ar" | "fr" | "en";
}) {
// One Verification per session; `rearm: "manual"` lets the liveness step show
// the coaching before a new attempt (recommended with <SekyroVerification>).
const verification = useMemo(
() => createVerification({ clientToken: props.clientToken, locale: props.locale, rearm: "manual" }),
[props.clientToken, props.locale],
);
useEffect(() => () => verification.destroy(), [verification]);
const [done, setDone] = useState<VerificationResult | null>(null);
const [decision, setDecision] = useState<string | null>(null);
return (
<SekyroVerification
verification={verification}
locale={props.locale}
theme={theme}
strings={strings}
assetBaseUrl="/models-v3"
onLivenessCaptured={(artifacts) => console.debug("clip", artifacts.clipMs, "ms")}
onResult={(result) => {
// Display only. A terminal status is the cue to ask YOUR server.
if (result.status === "approved" || result.status === "rejected" || result.status === "review") {
setDone(result);
}
}}
slots={{
result: (step) => (
<button
type="button"
disabled={done === null}
onClick={() =>
void decideVerification(props.sessionId).then((d) => setDecision(d.decision))
}
>
{decision ?? (step.display === "approved" ? "Continuer" : "Retour")}
</button>
),
}}
/>
);
}headless-steps.ts
// docs/partner/snippets/headless-steps.ts — the plan runner without React: you own every screen.
// `verification.run()` starts the runner; `on("step")` hands you a typed handle per
// step; each action submits, then the SDK re-reads the session and emits the next step.
// Typechecked by check.mjs against the BUILT @sekyro/web.
import {
acceptedIssuers,
acceptedTypesFor,
createVerification,
InvalidInputError,
OtpInputError,
type AcceptedDocumentType,
type IdentityMethod,
type OtpChannel,
type ResultDisplay,
type VerificationStep,
} from "@sekyro/web";
export interface Screens {
askPhone(): Promise<string>;
/** One button per offered channel (WhatsApp first) — the person chooses. */
askChannel(channels: OtpChannel[]): Promise<OtpChannel>;
askEmail(): Promise<string>;
askCode(maskedTarget: string | undefined): Promise<string>;
askMethod(options: IdentityMethod[]): Promise<IdentityMethod>;
/** `recheck`: the registry asked for the number again (a typo is likely). */
askNni(recheck: boolean): Promise<string>;
/** Issuing countries (ISO 3166-1 alpha-2), sorted by code — sort by localised name to display. */
askIssuer(issuers: string[]): Promise<string>;
askDocumentType(types: AcceptedDocumentType[]): Promise<AcceptedDocumentType>;
rearVideo(): HTMLVideoElement;
mrzState(state: "searching" | "reading" | "valid" | "mismatch" | "fallback"): void;
frontVideo(): HTMLVideoElement;
showMobileRequired(): void;
showProcessing(stalled: boolean): void;
showUnsupported(reason: "platform_unsupported" | "update_required"): void;
/** The national eID number (the NNI in MR, Houwiyeti); `providerName` names the app. */
askEidIdentifier(providerName: string): Promise<string>;
/** The code shown in the provider's app, or the person's choice of a new code / the camera path. */
askEidCode(providerName: string, canResend: boolean, canUseCamera: boolean): Promise<string | "resend" | "camera">;
/**
* Re-ask the same field: `phone` / `code` / `nni` / `identifier` (the national
* eID number), `format` or NNI `check_digit`.
*/
showInvalid(field: InvalidInputError["field"], kind: InvalidInputError["kind"]): void;
/** `channel`: none chosen / not offered; `email`: malformed address. */
showOtpInvalid(field: "channel" | "email", kind: "format" | "required" | "unavailable"): void;
showProblem(error: unknown): void;
/** Flow model 2026-10-23: phone or email (a `contact.mode: one_of` session). */
askContact(options: ("phone" | "email")[]): Promise<"phone" | "email">;
/** Show the identity data read (masked) + the portrait; resolve the person's answer. */
askConfirmIdentity(portrait: Blob | null, canRetake: boolean): Promise<"confirm" | "retake">;
/** Display only — your server decides from `sessions.get` (server-create.ts). */
showResult(display: ResultDisplay): void;
}
export async function runHeadless(clientToken: string, screens: Screens): Promise<() => void> {
const verification = createVerification({ clientToken, locale: "fr", rearm: "manual" });
const act = async (step: VerificationStep): Promise<void> => {
try {
switch (step.kind) {
case "phone_otp":
if (step.phase === "send") {
// `needsPhone` is false when the number was prefilled by your server.
const phone = step.needsPhone ? { phone: await screens.askPhone() } : {};
// `channels` is [] on a session created before channel choice: send without one.
const channel = step.channels.length > 1 ? { channel: await screens.askChannel(step.channels) } : {};
await step.send({ ...phone, ...channel });
} else {
await step.verify(await screens.askCode(step.sentToMasked));
}
break;
case "email_otp":
if (step.phase === "send") await step.send(step.needsEmail ? { email: await screens.askEmail() } : {});
else await step.verify(await screens.askCode(step.sentToMasked));
break;
case "contact_choice":
await step.select(await screens.askContact(step.options));
break;
case "identity_summary": {
// Show the values (never log them); the portrait is a Blob — URL.createObjectURL, revoke after.
const answer = await screens.askConfirmIdentity(await step.portrait(), step.retakeAvailable);
await (answer === "retake" ? step.retake() : step.confirm());
break;
}
case "method":
await step.select(await screens.askMethod(step.options));
break;
case "eid":
// National eID (MR: Houwiyeti). After eid_not_enrolled / eid_locked the
// person may take the camera path instead (`canUseCamera`).
if (step.phase === "identifier") {
if (step.canUseCamera && step.reason !== undefined) await step.useCamera();
else await step.start(step.identifierBound ? undefined : await screens.askEidIdentifier(step.providerName));
} else {
const answer = await screens.askEidCode(step.providerName, (step.resendsRemaining ?? 0) > 0, step.canUseCamera);
if (answer === "resend") await step.resend();
else if (answer === "camera") await step.useCamera();
else await step.verify(answer);
}
break;
case "registry":
if (step.source === "typed") await step.submitNni(await screens.askNni(step.recheck));
else if (step.source === "bound") await step.confirm();
// "document": the number is read from the document; the SDK submits it itself.
break;
case "document":
if (step.documentType === null) {
// Issuer first, then the types that issuer is accepted with.
const issuer = await screens.askIssuer(acceptedIssuers(step.documents));
step.selectDocument(issuer, await screens.askDocumentType(acceptedTypesFor(step.documents, issuer)));
// selectDocument re-emits the same step with `documentType` set.
} else {
// Live rear camera only — no file upload. One capture covers both faces of a card.
// liveMrz (optional): where your bundler serves the OCR worker — the on-device MRZ reader then takes
// the photo on MRZ faces when your session's server tuning enables it (§4.6).
const doc = step.capture({
video: screens.rearVideo(),
liveMrz: { workerUrl: new URL("@sekyro/web/core/ocr/worker", import.meta.url) },
});
doc.on("mrz", (m) => screens.mrzState(m.state)); // searching | reading | valid | mismatch | fallback
await doc.start();
}
break;
case "liveness":
await step.capture({ video: screens.frontVideo() }).start();
break;
case "portrait":
// ID-document profiles: a strict ICAO portrait; the SDK then submits face_match with it.
if (step.uploadFailed) await step.resubmit();
else if (step.phase === "capture") await step.capture({ video: screens.frontVideo() }).start();
break;
case "mobile_required":
screens.showMobileRequired(); // then step.refresh() once the user is back
break;
case "processing":
screens.showProcessing(step.stalled); // the SDK keeps polling; offer step.refresh()
break;
case "unsupported":
screens.showUnsupported(step.reason);
break;
case "result":
screens.showResult(step.display);
break;
}
} catch (err) {
// InvalidInputError: rejected locally (format / NNI check digit) — nothing was submitted.
// Never auto-retry a failed submission: show the problem and let the user act again.
if (err instanceof InvalidInputError) screens.showInvalid(err.field, err.kind);
else if (err instanceof OtpInputError) screens.showOtpInvalid(err.field, err.kind);
else screens.showProblem(err); // network, rate limit, session expired…
}
};
verification.on("step", (step) => void act(step));
const first = await verification.run?.(); // `run` is present from @sekyro/web 0.1.0-rc.8
if (first !== undefined) await act(first);
return () => verification.destroy();
}headless.ts
// Headless integration: @sekyro/web only, no React. You own every pixel and
// every string; the SDK owns the camera, the gate, the upload, the attestation
// and the check submissions. Hints and reasons are CODES — translate them.
import {
createVerification,
getLivenessAssetsStatus,
hasMultipleCameras,
preloadLivenessAssets,
queryCameraPermission,
releaseLivenessAssets,
subscribeLivenessAssets,
type CameraFacing,
type CameraPermissionState,
type CaptureError,
type CaptureState,
type GateHint,
type LivenessAssetsStatus,
type LivenessCaptureOptions,
type VerificationOptions,
type VerificationResult,
} from "@sekyro/web";
/** Your copy for the six gate hints (French shown; ship ar/fr/en as you need). */
const HINT_TEXT: Record<GateHint, string> = {
center: "Centrez votre visage dans le cadre",
closer: "Rapprochez-vous",
farther: "Éloignez-vous un peu",
light: "Trouvez un endroit plus éclairé",
still: "Ne bougez plus",
hold: "Restez ainsi…",
};
const STATE_TEXT: Partial<Record<CaptureState, string>> = {
starting: "Démarrage de la caméra…",
uploading: "Envoi sécurisé…",
checking: "Vérification…",
};
const PERMISSION_TEXT: Record<CameraPermissionState, string> = {
granted: "Accès à la caméra autorisé",
prompt: "Votre navigateur va demander l'accès à la caméra — choisissez Autoriser",
denied: "Caméra bloquée — autorisez-la dans les réglages du navigateur, puis rechargez",
unknown: "Nous demanderons l'accès à la caméra au démarrage",
};
const ERROR_TEXT: Record<CaptureError["kind"], string> = {
permission: "Autorisez l'accès à la caméra pour continuer",
notfound: "Aucune caméra détectée sur cet appareil",
inuse: "La caméra est utilisée par une autre application",
insecure: "Cette page doit être servie en HTTPS",
unsupported: "Ce navigateur ne prend pas en charge la capture",
generic: "La capture a échoué — réessayez",
};
export interface HeadlessOptions {
/** From your server (`sessions.create` → `clientToken`). */
clientToken: string;
/** Your `<video playsinline muted>` element. */
video: HTMLVideoElement;
/** Where the guidance line goes (make it `aria-live="polite"`). */
hint: HTMLElement;
/** Element whose `--progress` custom property drives your ring (0..1). */
ring: HTMLElement;
/** A button the user can press after a camera error. */
retryButton: HTMLButtonElement;
/** Your Start button — gated on the detector preload. */
startButton: HTMLButtonElement;
/** Your camera-permission line on the "get ready" screen. */
permissionLine: HTMLElement;
/** Optional front/back toggle; shown only where a choice makes sense. */
facingToggle?: HTMLButtonElement;
/** Called once per terminal / unavailable result. Then call YOUR server. */
onDone: (result: VerificationResult) => void;
/** Default `https://api.sekyro.ai`; set the staging origin for sandbox work. */
apiOrigin?: string;
/** Where you host the detector assets; default `/models-v3`. */
assetBaseUrl?: string;
}
/**
* Call this as EARLY as possible — app shell, the step before the capture — so
* the ~11 MB detector is downloaded and initialised before the user taps Start.
* Idempotent per `assetBaseUrl`, never rejects; `idle: true` waits for browser
* idle time (a capture that needs the detector meanwhile starts it at once).
*/
export function warmDetector(assetBaseUrl = "/models-v3"): Promise<LivenessAssetsStatus> {
return preloadLivenessAssets({ assetBaseUrl, idle: true, respectSaveData: true });
}
/** Start the flow; returns a teardown function (call it on page leave). */
export function startHeadlessVerification(opts: HeadlessOptions): () => void {
const assetBaseUrl = opts.assetBaseUrl ?? "/models-v3";
// autoRetry defaults to true: a retryable reason with attempts remaining
// re-arms the capture and surfaces as `status: 'retry'`.
const options: VerificationOptions = { clientToken: opts.clientToken, locale: "fr" };
if (opts.apiOrigin !== undefined) options.apiOrigin = opts.apiOrigin;
const verification = createVerification(options);
verification.on("result", (result) => {
if (result.status === "retry") {
// The SDK already restarted the camera; just tell the user why.
opts.hint.textContent = `Reprenons — ${result.reason ?? "réessayez"}`;
return;
}
// approved | rejected | review | unavailable → display only. The verdict
// that counts comes from your server (sessions.get + webhooks).
opts.onDone(result);
});
// The capture reuses the preloaded detector of the same `assetBaseUrl` by itself.
const captureOptions: LivenessCaptureOptions = { video: opts.video, assetBaseUrl };
const capture = verification.liveness(captureOptions);
// ── "Get ready" screen: Start waits for the preload ─────────────────────────
const renderAssets = (status: LivenessAssetsStatus) => {
// 'idle' = nothing to preload (no camera API, Data-Saver): start normally.
opts.startButton.setAttribute("aria-disabled", String(status === "loading"));
opts.startButton.setAttribute("aria-busy", String(status === "loading"));
opts.startButton.textContent =
status === "loading" ? "Préparation…" : status === "failed" ? "Réessayer le chargement" : "Commencer";
};
renderAssets(getLivenessAssetsStatus(assetBaseUrl));
// useSyncExternalStore contract: no emit on subscribe, hence the read above.
const unsubscribeAssets = subscribeLivenessAssets(renderAssets, assetBaseUrl);
void preloadLivenessAssets({ assetBaseUrl }); // kick (or join) the preload
void queryCameraPermission().then((state) => {
opts.permissionLine.textContent = PERMISSION_TEXT[state];
});
let facing: CameraFacing = "user";
const toggle = opts.facingToggle;
if (toggle !== undefined) {
toggle.hidden = true;
void hasMultipleCameras().then((multiple) => {
toggle.hidden = !multiple;
});
toggle.addEventListener("click", () => {
facing = facing === "user" ? "environment" : "user";
toggle.setAttribute("aria-pressed", String(facing === "environment"));
});
}
capture.on("gate", ({ progress, hint }) => {
opts.ring.style.setProperty("--progress", String(progress));
opts.hint.textContent = hint === null ? "" : HINT_TEXT[hint];
});
capture.on("state", (state) => {
const text = STATE_TEXT[state];
if (text !== undefined) opts.hint.textContent = text;
opts.retryButton.hidden = state !== "error";
});
capture.on("error", (error) => {
opts.hint.textContent = ERROR_TEXT[error.kind];
});
opts.retryButton.addEventListener("click", () => capture.retry());
opts.startButton.addEventListener("click", () => {
const status = getLivenessAssetsStatus(assetBaseUrl);
if (status === "loading") return;
if (status === "failed") {
void preloadLivenessAssets({ assetBaseUrl }); // a failed preload retries on a new call
return;
}
opts.startButton.hidden = true;
void (async () => {
await verification.consent(); // recorded once; no-op when already recorded
void verification.startRegistry(); // early population check, in parallel
await capture.start({ facing }); // this camera for the attempt and its retries
})();
});
return () => {
unsubscribeAssets();
verification.destroy();
// SPA: the user left the flow — free the warmed detector. Never while a
// capture is still mounted.
releaseLivenessAssets(assetBaseUrl);
};
}page-liveness.tsx
// app/verify/page.tsx — the React path with @sekyro/react (0.1 preview).
//
// Flow: a Server Action creates the session (server-create.ts) → this client
// component mounts <LivenessCapture> on the client token → the built-in
// pre-capture screen (tips, camera row, voice switch, Start gated on the detector
// preload) → `onResult` is for DISPLAY → the verdict comes from your server
// (`decideVerification`).
//
// Typechecked by check.mjs against the BUILT @sekyro/react + @sekyro/web.
"use client";
import { useEffect, useMemo, useState } from "react";
import { createVerification, type VerificationResult } from "@sekyro/web";
import {
LivenessCapture,
SekyroThemeProvider,
type LivenessNotice,
type SekyroTheme,
type Strings,
type VoiceClipMap,
type VoiceOptions,
} from "@sekyro/react";
import "@sekyro/react/styles.css";
import { decideVerification, type Decision } from "./server-create";
const theme: SekyroTheme = {
colors: {
brand: "#0c7a3d",
brandFg: "#ffffff",
fg: "#13201a",
fgMuted: "#4b5a52",
bg: "#f6f8f7",
surface: "#ffffff",
ring: "#0c7a3d",
danger: "#b42318",
success: "#0c7a3d",
},
radius: "12px",
fontFamily: "'IBM Plex Sans Arabic', system-ui, sans-serif",
};
// Built-in ar/fr/en copy; override any key of `Strings` (keys mirror the hosted
// catalog: capture.*, fcv2.err.* camera errors, fcv3.* gate hints, reasons.*, errors.*).
const strings: Partial<Strings> = {
"capture.start": "ابدأ التحقق",
"capture.securityNote": "صورك مشفّرة وتُستخدم لهذا التحقق فقط",
"fcv3.center_face": "ضع وجهك في منتصف الدائرة",
};
interface Props {
clientToken: string;
sessionId: string;
/**
* Optional voice clips from YOUR backend: `{ ar: { "fcv3.center_face": url, … } }`.
* Omit it to use the Sekyro AI clip set delivered with the session (served from
* https://assets.sekyro.ai — allow it in CSP `media-src`); yours replace it entirely.
*/
voiceClips?: VoiceClipMap;
/** Seconds before the next attempt is allowed (your own rate limit), 0 = none. */
cooldownSeconds?: number;
/** Your status feed says the service is degraded right now. */
serviceBusy?: boolean;
}
export default function VerifyPage({ clientToken, sessionId, voiceClips, cooldownSeconds = 0, serviceBusy = false }: Props) {
const [decision, setDecision] = useState<Decision | null>(null);
const [display, setDisplay] = useState<VerificationResult | null>(null);
const [cooldown, setCooldown] = useState(cooldownSeconds);
useEffect(() => {
if (cooldown <= 0) return;
const timer = setTimeout(() => setCooldown((s) => s - 1), 1000);
return () => clearTimeout(timer);
}, [cooldown]);
// A host message above the pre-capture screen (pre-capture only).
const notice: LivenessNotice | undefined = serviceBusy
? { tone: "warning", text: "الخدمة مزدحمة حالياً — قد يستغرق التحقق وقتاً أطول" }
: undefined;
// Voice is on by default. A map is the integration point for server-provided
// clip URLs: pass a NEW object when the URLs change (identity = change signal).
// The device voice (speechSynthesis) covers keys without a clip.
const voice = useMemo<VoiceOptions>(
() => (voiceClips !== undefined ? { clips: voiceClips, speechFallback: ["ar", "fr"] } : {}),
[voiceClips],
);
// One verification per mount; destroyed on unmount (camera released).
// `autoRetry` (default true) is a verification option: a retryable reason with
// attempts remaining re-arms the capture and surfaces as `status: 'retry'`.
const verification = useMemo(
() => createVerification({ clientToken, locale: "ar", autoRetry: true }),
[clientToken],
);
useEffect(() => () => verification.destroy(), [verification]);
async function handleResult(result: VerificationResult) {
setDisplay(result); // immediate feedback only
if (result.status === "retry") return; // the capture re-armed itself
const { decision: verdict } = await decideVerification(sessionId); // authoritative
setDecision(verdict);
}
if (decision === "approved") return <p>هويتك مؤكدة</p>;
if (decision === "rejected") return <p>لم نتمكن من تأكيد هويتك</p>;
if (decision === "pending" && display?.status === "review") return <p>قيد المراجعة — سنخبرك بالنتيجة</p>;
if (display?.status === "unavailable") return <p>تعذر إكمال التحقق — حاول مرة أخرى لاحقاً</p>;
return (
<SekyroThemeProvider theme={theme}>
<LivenessCapture
verification={verification}
locale="ar"
dir="rtl"
strings={strings}
notice={notice}
startDisabled={cooldown > 0}
startLabel={cooldown > 0 ? `حاول مجدداً بعد ${cooldown} ث` : undefined}
tips={["light", "uncover", "eyeLevel", { id: "glasses", text: "انزع النظارة الشمسية" }]}
voice={voice}
slots={{
header: <h1>التحقق من الهوية</h1>,
footer: <small>تُعالج بياناتك بأمان بواسطة Sekyro AI</small>,
errorPanel: (err, retry) => (
<div role="alert">
<p>{err.kind === "permission" ? "اسمح بالوصول إلى الكاميرا" : "حدث خطأ في الكاميرا"}</p>
<button type="button" onClick={retry}>إعادة المحاولة</button>
</div>
),
}}
assetBaseUrl="/models-v3"
onCaptured={() => setDisplay(null)}
onResult={handleResult}
onError={(err) => console.warn("capture error", err.kind)}
onGuidance={(key) => console.debug("guidance", key)}
/>
</SekyroThemeProvider>
);
}precapture.tsx
// Pre-capture screen & voice guidance recipes for <LivenessCapture> (§4.3).
//
// Each export is an independent variant; pick the one you need. They all
// assume a `Verification` created once per mount, as in page-liveness.tsx.
//
// Typechecked by check.mjs against the BUILT @sekyro/react + @sekyro/web.
"use client";
import { useEffect } from "react";
import { preloadLivenessAssets, releaseLivenessAssets, type Verification } from "@sekyro/web";
import {
LivenessCapture,
VOICE_KEYS,
type IntroContext,
type VoiceClipResolver,
type VoiceOptions,
} from "@sekyro/react";
// ── 1. Preload early ─────────────────────────────────────────────────────────
// Mount this in the step BEFORE the capture (consent, NNI confirmation…) or in
// your app shell. <LivenessCapture> kicks the same preload on mount anyway; an
// earlier call just means Start is ready by the time the user gets there.
export function usePreloadLiveness(assetBaseUrl = "/models-v3"): void {
useEffect(() => {
void preloadLivenessAssets({ assetBaseUrl, idle: true });
}, [assetBaseUrl]);
}
/** SPA: call when the user leaves the verification flow (no capture mounted). */
export function leaveVerificationFlow(assetBaseUrl = "/models-v3"): void {
releaseLivenessAssets(assetBaseUrl);
}
// ── 2. Skip the screen when the camera is already allowed ────────────────────
// `autoStart` waits for the preload; meanwhile the screen shows its loader (or
// Retry on failure). iOS: no Start tap = audio stays locked until the first
// touch / key inside the component.
export function ReturningUserCapture({ verification }: { verification: Verification }) {
return <LivenessCapture verification={verification} locale="fr" autoStart="when-permitted" />;
}
// ── 3. Your own clips ────────────────────────────────────────────────────────
// Without `voice.clips`, verification mode speaks the Sekyro AI clip set that comes
// with the session (served from https://assets.sekyro.ai — add it to CSP
// `media-src`). Passing `clips` replaces that set entirely.
//
// A resolver: return a URL, or null/undefined for "no clip" (→ device voice
// where `speechFallback` allows it). A throwing resolver also means "no clip".
// It is also asked for `capture.tips.summary` (the pre-capture "Listen" summary).
const resolveClip: VoiceClipResolver = (key, locale) =>
locale === "en" ? null : `https://cdn.example.com/sekyro-voice/v3/${locale}/${clipName(key)}.mp3`;
// English has no clips: let the device voice speak it, nothing else.
const customVoice: VoiceOptions = { clips: resolveClip, speechFallback: ["en"] };
export function CustomVoiceCapture({ verification }: { verification: Verification }) {
return <LivenessCapture verification={verification} locale="fr" voice={customVoice} />;
}
// Self-hosted clips in a `<base>/<locale>/<name>.mp3` layout, named like the
// Sekyro AI set: the key minus its first segment, dots → "_" (`fcv3.center_face`
// → center_face, `capture.tips.summary` → tips_summary).
function clipName(key: string): string {
return key.slice(key.indexOf(".") + 1).replace(/\./g, "_");
}
const base = "https://cdn.example.com/voice/v1";
const selfHosted: VoiceClipResolver = (key, locale) => `${base}/${locale}/${clipName(key)}.mp3`;
export const selfHostedVoice: VoiceOptions = { clips: selfHosted };
/** The keys a clip set can cover: the nine `fcv3.*` guidance hints + `capture.tips.summary`. */
export const CLIP_KEYS = VOICE_KEYS;
// Custom tips hide the built-in "Listen to the instructions" button (its
// summary describes the built-in tips). Supply your own summary clip to bring
// it back — and the matching text, which the device voice reads if the clip
// cannot play.
export function CustomTipsWithSummary({ verification }: { verification: Verification }) {
return (
<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" } } }}
/>
);
}
// ── 4. No voice at all — or you speak it yourself ────────────────────────────
// `voice={false}`: no speech, no voice switch, no stage chip. Required if your
// `voicePrompt` slot plays audio, or the person hears every hint twice.
export function SilentCapture({ verification }: { verification: Verification }) {
return (
<LivenessCapture
verification={verification}
locale="fr"
voice={false}
slots={{
// `hint` = the code, `text` = the resolved copy, `key` = its string key.
voicePrompt: (_hint, text, key) => (key === null ? null : <MyAudioCue clipKey={key} label={text} />),
}}
/>
);
}
function MyAudioCue({ clipKey, label }: { clipKey: string; label: string }) {
return <span data-clip={clipKey} aria-hidden="true">{label}</span>;
}
// ── 5. Replace the screen, keep its state ────────────────────────────────────
// `slots.intro(start, context)` replaces the whole pre-capture screen (the
// `notice` still renders above it). Call `start` from the click handler: it
// opens `context.facing` and unlocks audio inside the gesture (iOS).
function MyIntro({ start, ctx }: { start: () => void; ctx: IntroContext }) {
return (
<div>
<p role="status">{ctx.permission === "denied" ? "Caméra bloquée — autorisez-la puis rechargez" : "Prêt ?"}</p>
{ctx.canChooseFacing ? (
<button
type="button"
aria-pressed={ctx.facing === "environment"}
onClick={() => ctx.setFacing(ctx.facing === "user" ? "environment" : "user")}
>
Caméra arrière
</button>
) : null}
{ctx.voice.supported ? (
<button type="button" role="switch" aria-checked={ctx.voice.enabled} onClick={ctx.voice.toggle}>
Guidage vocal
</button>
) : null}
{ctx.assets === "failed" ? (
<button type="button" onClick={ctx.retryAssets}>
Réessayer le chargement
</button>
) : (
<button
type="button"
aria-disabled={ctx.assets === "loading"}
aria-busy={ctx.assets === "loading"}
onClick={() => {
if (ctx.assets === "ready") start();
}}
>
{ctx.assets === "loading" ? "Préparation…" : "Commencer"}
</button>
)}
</div>
);
}
export function CustomIntroCapture({ verification }: { verification: Verification }) {
return (
<LivenessCapture
verification={verification}
locale="fr"
slots={{ intro: (start, ctx) => <MyIntro start={start} ctx={ctx} /> }}
/>
);
}server-create.ts
// Next.js Server Actions (App Router) backed by @sekyro/node.
//
// Two jobs live on YOUR server, never in the browser:
// 1. startVerification — create a session BOUND to the user's NNI and hand
// the page nothing but the one-time client token.
// 2. decideVerification — read the authoritative session and decide.
//
// The API key is a server secret. The client token is the end user's flow
// credential: returned once by `sessions.create`, never logged, never stored
// in a cookie or URL.
"use server";
import { Sekyro, SekyroApiError, type Session } from "@sekyro/node";
const sekyro = new Sekyro({
apiKey: requireEnv("SEKYRO_API_KEY"),
// Production is the default. For the sandbox set
// SEKYRO_API_BASE_URL=https://api-staging.sekyro.ai/api/v2 with an sk_test_ key.
baseUrl: process.env.SEKYRO_API_BASE_URL ?? "https://api.sekyro.ai/api/v2",
});
export interface StartVerificationResult {
sessionId: string;
/** Hand this to the page (`createVerification({ clientToken })`). Returned ONCE. */
clientToken: string;
/** ISO 8601 — the capture must finish before this. */
expiresAt: string;
}
/**
* Create a session bound to `nni`. The browser never sends the NNI: 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`).
*
* `attempt` is your own counter per user: the idempotency key makes a network
* retry of the SAME attempt return the SAME session instead of minting two.
*/
export async function startVerification(
userId: string,
nni: string,
attempt: number,
): Promise<StartVerificationResult> {
try {
const created = await sekyro.sessions.create(
// No recipe here: the API key belongs to an app, and the app's eKYC
// profile decides the steps (e.g. population_registry → liveness →
// face_match for the "Mauritania: registry" profile).
{ locale: "ar", input: { nni } },
{ idempotencyKey: `kyc:${userId}:${attempt}` },
);
// Persist the id BEFORE returning: the webhook can arrive before the page
// reports back, and your handler must be able to find the user by session.
await saveSessionId(userId, created.id);
return {
sessionId: created.id,
clientToken: created.clientToken,
expiresAt: created.expiresAt,
};
} catch (err) {
if (err instanceof SekyroApiError) {
// `retryable` covers network, timeout, 429, 5xx and idempotency_in_progress:
// back off and call again with the SAME idempotencyKey.
throw new Error(
`Sekyro AI ${err.code} (request ${err.requestId ?? "n/a"}), retryable=${err.retryable}`,
);
}
throw err;
}
}
export type Decision = "approved" | "rejected" | "pending" | "expired";
export interface DecideResult {
decision: Decision;
session: Session;
}
/**
* The ONLY place a verdict is decided. Call it when the page reports
* `onResult`, and again from your webhook handler on async transitions.
*/
export async function decideVerification(sessionId: string): Promise<DecideResult> {
const session = await sekyro.sessions.get(sessionId);
return { decision: decide(session), session };
}
function decide(session: Session): Decision {
switch (session.status) {
case "completed":
// `outcome` is `approved | review | rejected`; `review` here means a
// human decided the session is complete but flagged — treat as pending
// until your own policy says otherwise.
if (session.outcome === "approved") return "approved";
if (session.outcome === "rejected") return "rejected";
return "pending";
case "failed":
return "rejected";
case "review_pending":
// Parked for human review (up to 48 h). Wait for `session.review_decided`.
return "pending";
case "expired":
// The user did not finish in time: offer a new session.
return "expired";
case "created":
case "in_progress":
return "pending";
}
}
function requireEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
return value;
}
// Replace with your database write.
async function saveSessionId(_userId: string, _sessionId: string): Promise<void> {}webhook-express.ts
// Express webhook receiver.
//
// The signature covers the raw bytes, so this route MUST be mounted with
// `express.raw(...)` — a global `express.json()` placed before it re-serialises
// the body and every delivery fails with `invalid_signature`.
import express from "express";
import { SekyroWebhookError, signatureFromHeaders, verifyWebhook } from "@sekyro/node";
export const app = express();
app.post(
"/webhooks/sekyro",
express.raw({ type: "application/json" }),
async (req, res) => {
const rawBody = (req.body as Buffer).toString("utf8");
// Primary header (`x-sekyro-signature`) first, legacy `x-ibtikar-signature` as a fallback.
const signature = signatureFromHeaders(req.headers);
// The envelope is discriminated on `event`: each `case` below types `data`.
let envelope;
try {
envelope = verifyWebhook(rawBody, signature, process.env.SEKYRO_WEBHOOK_SECRET ?? "");
} catch (err) {
if (err instanceof SekyroWebhookError) {
res.status(400).send(err.code); // invalid_signature | stale | malformed
return;
}
throw err;
}
// Acknowledge first; a slow handler would make Sekyro AI retry the delivery.
res.sendStatus(200);
if (await alreadyProcessed(envelope.id)) return;
switch (envelope.event) {
case "session.completed":
case "session.failed": {
// Partner-only: the full phone / email the person verified (passed OTP checks).
const contacts = envelope.data.verifiedContacts;
if (contacts?.phone) await saveVerifiedPhone(envelope.data.sessionId, contacts.phone.number);
if (contacts?.email) await saveVerifiedEmail(envelope.data.sessionId, contacts.email.address);
await reconcile(envelope.data.sessionId); // sessions.get → decide
break;
}
case "session.review_decided":
case "session.expired":
await reconcile(envelope.data.sessionId); // sessions.get → decide
break;
case "session.review_pending":
await markUnderReview(envelope.data.sessionId);
break;
default:
break;
}
await markProcessed(envelope.id);
},
);
// Replace the stubs with your own storage / domain code.
async function alreadyProcessed(_eventId: string): Promise<boolean> {
return false;
}
async function markProcessed(_eventId: string): Promise<void> {}
async function reconcile(_sessionId: string): Promise<void> {}
async function markUnderReview(_sessionId: string): Promise<void> {}
async function saveVerifiedPhone(_sessionId: string, _e164: string): Promise<void> {}
async function saveVerifiedEmail(_sessionId: string, _address: string): Promise<void> {}webhook-next.ts
// app/api/sekyro/webhooks/route.ts — Next.js App Router route handler.
//
// Rules that make the signature verify:
// - read the RAW body (`request.text()`), never `request.json()`;
// - run on the Node runtime (`verifyWebhook` uses node:crypto);
// - answer 2xx fast, do the work after (Sekyro AI retries non-2xx).
import { SekyroWebhookError, signatureFromHeaders, verifyWebhook } from "@sekyro/node";
export const runtime = "nodejs";
export async function POST(request: Request): Promise<Response> {
const rawBody = await request.text();
// `x-sekyro-signature` is the primary header; `x-ibtikar-signature` is the
// legacy name still sent for older integrations. The helper accepts either.
const signature = signatureFromHeaders(request.headers);
// The envelope is discriminated on `event`: each `case` below types `data`.
let envelope;
try {
envelope = verifyWebhook(rawBody, signature, process.env.SEKYRO_WEBHOOK_SECRET ?? "");
} catch (err) {
if (err instanceof SekyroWebhookError) {
// invalid_signature | stale | malformed — reject; Sekyro AI will not retry a 4xx.
return new Response(err.code, { status: 400 });
}
throw err;
}
// Deliveries are at-least-once and retries reuse the envelope id.
if (await alreadyProcessed(envelope.id)) return new Response(null, { status: 200 });
switch (envelope.event) {
case "session.completed":
case "session.failed":
case "session.review_decided":
case "session.expired":
// The envelope is a hint. Re-read the session on your server and let
// ONE code path decide (see server-create.ts → decideVerification).
await reconcile(envelope.data.sessionId);
break;
case "session.review_pending":
await markUnderReview(envelope.data.sessionId);
break;
default:
// session.created, session.started, session.step.completed, webhook.test, …
break;
}
await markProcessed(envelope.id);
return new Response(null, { status: 200 });
}
// Replace the three stubs with your own storage / domain code.
async function alreadyProcessed(_eventId: string): Promise<boolean> {
return false;
}
async function markProcessed(_eventId: string): Promise<void> {}
async function reconcile(_sessionId: string): Promise<void> {}
async function markUnderReview(_sessionId: string): Promise<void> {}