Sekyro AIDocs

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

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

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

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

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

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

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

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

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> {}

On this page