Sekyro AIDocs

@sekyro/node

Server SDK: create sessions, read results and verify webhooks.

Server SDK for Sekyro AI identity verification. Zero runtime dependencies, Node.js ≥ 20, ESM + CommonJS, typed.

Two jobs, nothing else:

  1. Sessions — create a verification session from your backend and read its result. The API key never leaves your server.
  2. Webhooks — verify the x-sekyro-signature header on deliveries (the legacy x-ibtikar-signature name is still sent) and get a typed envelope back.

The SDK does not orchestrate checks or retry for you; the partner owns the retry policy (SekyroApiError.retryable tells you when a retry is sensible).

Integration guide: docs/partner/web-sdk.md (server-bound NNI, display vs authoritative results, webhook receivers for Next.js and Express, billing). The hosted-flow path is in the quickstart.

Install

npm install @sekyro/node

Sessions

import { Sekyro } from "@sekyro/node";

const sekyro = new Sekyro({
  apiKey: process.env.SEKYRO_API_KEY!,
  // baseUrl: "https://api.sekyro.ai/api/v2", // default; use the staging origin for sandbox work
  // timeoutMs: 30_000,                        // default
});
// Throws `SekyroConfigError` (a TypeError; `code` = 'missing_api_key' | 'invalid_base_url')
// when apiKey is missing or baseUrl is not https: — plain http: is accepted only for
// localhost / 127.0.0.1 / [::1], since the API key rides on every request.

// Create — returns the one-time client token your front end hands to @sekyro/web.
// `input.nni` BINDS the session to that identity: the browser never sends the
// NNI (a different one is refused with 409 nni_locked).
const created = await sekyro.sessions.create(
  { locale: "ar", input: { nni: "1357924632" } },
  { idempotencyKey: `signup:${userId}` },
);
created.id;          // persist against your user before returning to the page
created.clientToken; // give this to the browser; it is returned ONCE
created.hostedUrl;   // the hosted-flow link, if you use that path instead
created.expiresAt;   // ISO 8601

// Read — the whitelisted session view (never contains the client token).
const session = await sekyro.sessions.get(created.id);
session.status;  // 'created' | 'in_progress' | 'review_pending' | 'completed' | 'expired' | 'failed'
session.outcome; // 'approved' | 'review' | 'rejected' | null
session.steps;   // [{ checkType, state, reason?, calibratedScore? }]

There is no recipe argument. Your API key belongs to an app, and the app's eKYC profile (set in the Sekyro AI dashboard) decides which checks run. Every field is optional:

fieldmeaning
localear, fr or en: pins the hosted-flow language
ttlMinutessession lifetime, within the profile's link range (400 ttl_out_of_policy otherwise); omit for the profile default
inputthe step anchors: nni, phone, fullName, birthDate, all strings (an NNI is a 10-digit string, never a number), plus any further keys; input.nni binds the session to that identity
deliver{ channel: "sms" | "whatsapp", to: "+222…" }: also send the hosted link to the person

0.2 breaking change. flow and templateId are gone. Create an app in the dashboard, mint an app API key, and drop flow from your calls.

sekyro.sessions is a SessionsApi — exported as a type only; you get the instance from the client, never by constructing one.

Partner-supplied identity data (supplied mode)

If Sekyro AI set your environment to the supplied mode, you send the person's identity data (biographic record and photo) with the session in input.suppliedIdentity. Sekyro AI treats it as identity data supplied by your organization: it makes no registry call, and it never receives or stores any registry key. If your data comes from your own ANRPTS call, registryFromAnrpts() converts that answer locally into the record shape:

import { registryFromAnrpts } from "@sekyro/node";

const anrpts = await myAnrptsClient.getPersonne(nni); // your call, your key
const receivedAt = new Date();
await sekyro.sessions.create({
  // fetchedAt: when YOUR system received this answer (required; > 24 h old is refused)
  input: { nni, suppliedIdentity: registryFromAnrpts(anrpts, { fetchedAt: receivedAt }) },
});

suppliedIdentity.nni must equal input.nni. Refusals: 403 supplied_identity_not_entitled (the environment is not in supplied mode), 422 supplied_identity_required (the environment is in supplied mode and the app verifies identity, but input.suppliedIdentity was not sent), 400 supplied_identity_nni_mismatch, 422 supplied_identity_stale (obtained more than 24 h ago) and 422 supplied_identity_photo_unusable (not a JPEG or PNG of at most 1 MB within the size bounds).

No face processing runs at create. The face on the supplied photo is checked after the person consents, at the face match: a photo without one usable face fails that step with no_face_reference (or low_quality_reference). That step runs after liveness, so check that the photo shows exactly one clear face before you send it.

Partner-supplied identity data is not billed: the identity step in supplied mode records no usage event (other steps, such as the face match, bill as usual).

Errors

Every non-2xx response, network failure and timeout throws SekyroApiError:

fieldmeaning
statusHTTP status (0 for network / timeout)
codeAPI error code, or network / timeout / http_error / malformed_response
messagehuman-readable, from the API when available
requestIdthe API's request_id, quote it to support
retryabletrue for network, timeout, 429, 5xx and idempotency_in_progress
import { SekyroApiError } from "@sekyro/node";

try {
  await sekyro.sessions.create({ input: { nni } });
} catch (err) {
  if (err instanceof SekyroApiError && err.retryable) {
    // back off and retry with the SAME idempotencyKey
  }
}

Subjects

A subject is the same person across all of your apps. Reads need the subjects:read scope; revoke and delete need subjects:write (keys minted from the dashboard carry both — mint a new key if an older one answers 403 insufficient_scope).

const subject = await sekyro.subjects.get(subjectId);
subject.status;         // 'active' | 'revoked'
subject.reference;      // { source: 'registry' | 'doc_registry' | 'chip' | 'document', capturedAt } | null
subject.assurance;      // 'registry_biometric' | 'chip_biometric' | 'document_biometric' | 'registry_only' | 'document_only' | null
subject.returningReady; // a returning-user session can be created now

const [byRef] = await sekyro.subjects.list({ externalRef: userId });

// Returning user: liveness + face match against the stored reference photo.
// Name exactly one of subjectId, externalRef or nni.
await sekyro.sessions.create({ subject: { reuse: { subjectId } } });

await sekyro.subjects.revoke(subjectId, { reason: "account closed" }); // idempotent
await sekyro.subjects.delete(subjectId); // photos, face templates and identifiers; cannot be undone

subject.enrolled (first reference photo stored), subject.updated (a linked session finished, or status: 'revoked') and subject.deleted webhooks are typed on the envelope.

Webhooks

Verify with the raw request body (bytes as received, before any JSON parsing or re-serialisation) and the signature header: x-sekyro-signature (primary), falling back to the legacy x-ibtikar-signature while both are sent.

import { verifyWebhook, signatureFromHeaders, SekyroWebhookError } from "@sekyro/node";

app.post("/webhooks/sekyro", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const envelope = verifyWebhook(
      req.body.toString("utf8"),
      signatureFromHeaders(req.headers), // x-sekyro-signature, else the legacy x-ibtikar-signature
      process.env.SEKYRO_WEBHOOK_SECRET!,
    );
    // The envelope is discriminated on `event`: narrowing it types `data`.
    switch (envelope.event) {
      case "session.completed":
      case "session.failed":
        envelope.data; // SessionLifecycleEventData — { sessionId, status, outcome, steps }
        break;
      case "session.review_decided":
        envelope.data; // SessionReviewDecidedEventData — { sessionId, outcome: 'pass' | 'fail', reason }
        break;
      case "session.step.completed":
        envelope.data; // SessionStepCompletedEventData — { sessionId, checkType, state }
        break;
    }
    res.sendStatus(200);
  } catch (err) {
    if (err instanceof SekyroWebhookError) return res.sendStatus(400); // invalid_signature | stale | malformed
    throw err;
  }
});
  • The header is t=<unix seconds>,v1=<hex>[,v1=<hex>]. During a secret rotation Sekyro AI signs with both the current and the previous secret, so the delivery is accepted when any v1 matches the secret you hold.
  • Header names. Deliveries carry the signature in x-sekyro-signature (SEKYRO_SIGNATURE_HEADER) and, for the transition, the identical legacy x-ibtikar-signature (LEGACY_SIGNATURE_HEADER). signatureFromHeaders() takes a Fetch Headers or a Node headers record and returns the branded header, else the legacy one. The event id/type travel the same way: x-sekyro-event-id / x-sekyro-event-type alongside their x-ibtikar-* twins.
  • Each v1 is HMAC-SHA256(secret, "<t>.<rawBody>"), compared in constant time.
  • Deliveries older or newer than toleranceSec (default 300 s) are rejected as stale; pass { now } to inject a clock in tests.
  • Retried deliveries carry the same envelope id; de-duplicate on it.

Compatibility

Only node:crypto and the global fetch are used. You can inject a fetch implementation via new Sekyro({ fetch }) for tests or custom agents.

On this page