@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:
- Sessions — create a verification session from your backend and read its result. The API key never leaves your server.
- Webhooks — verify the
x-sekyro-signatureheader on deliveries (the legacyx-ibtikar-signaturename 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/nodeSessions
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:
| field | meaning |
|---|---|
locale | ar, fr or en: pins the hosted-flow language |
ttlMinutes | session lifetime, within the profile's link range (400 ttl_out_of_policy otherwise); omit for the profile default |
input | the 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.
flowandtemplateIdare gone. Create an app in the dashboard, mint an app API key, and dropflowfrom 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:
| field | meaning |
|---|---|
status | HTTP status (0 for network / timeout) |
code | API error code, or network / timeout / http_error / malformed_response |
message | human-readable, from the API when available |
requestId | the API's request_id, quote it to support |
retryable | true 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 undonesubject.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 anyv1matches the secret you hold. - Header names. Deliveries carry the signature in
x-sekyro-signature(SEKYRO_SIGNATURE_HEADER) and, for the transition, the identical legacyx-ibtikar-signature(LEGACY_SIGNATURE_HEADER).signatureFromHeaders()takes a FetchHeadersor 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-typealongside theirx-ibtikar-*twins. - Each
v1isHMAC-SHA256(secret, "<t>.<rawBody>"), compared in constant time. - Deliveries older or newer than
toleranceSec(default 300 s) are rejected asstale; 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.