Search the docs or ask a question /
SDK · sandbox

Quickstart

Open a verification session, hand over its link and read the outcome

Get started with the Hira SDK

terminal
$ npm install @hira/sdk

added @hira/sdk 0.2.0
$ _

A verification session is one person or one business walking a program’s flow on Hira’s hosted page. The integrator’s server creates the session with an API key, hands the session’s url to the person once, and reads the outcome back through the API or a webhook. This page runs that path end to end against a sandbox account, where simulate stands in for the person walking the link.

Server-side only. An API key is a secret. new Hira() throws on a hira_sk_ key where a DOM or a web worker is present, and no /v1 door answers CORS.

Before starting

  • A sandbox account. Every live account is born with a sandbox account named after it with · Sandbox on the end. It holds the same people, its own data and its own test keys. Administration › Developer › Sandbox › Open sandbox switches to it. See Sandbox.
  • An active verification program in the sandbox account. A session opens on a hosted program, the kind that runs a verification flow; a program that subscribes to streams answers 404 program_not_found. Every step of the program’s flow needs a policy that judges it, else the create answers 409 program_not_active. See Programs.
  • The program’s id. With the program open in the console, the id is the segment after active-programs/ in the address.
  • Node 20 or later. The SDK reads the Web Crypto global, which Node 18 hides behind a flag.

Test key

In the sandbox account, open Administration › Developer › API keys and choose New key. The New API key dialog asks for a Name, a Source app and Scopes. Tick Identity verification. A scope is a product the key reaches; scopes are fixed once the key exists and a rotated key keeps them. A key without Identity verification answers 403 api_key_scope_missing on every session door.

The page reads “Sandbox mode. Keys created here start with hira_sk_test_ and reach only the data of this sandbox account.” The key is shown once, in the Copy the API key dialog: hira_sk_test_, 43 base62 characters, an underscore and a 6-character checksum. A live account’s keys start hira_sk_live_.

Install

terminal
npm install @hira/sdk

The package is @hira/sdk, version 0.2.0, with ESM and CommonJS builds and TypeScript types. Once the package is published to npm, this line is the install.

Local build, before the npm release. Until @hira/sdk is on npm, build it from the repository’s sdk folder and install the tarball. npm pack packs the dist folder and does not build it, so the build runs first.
terminal
# in the hira repository
cd sdk
npm install
npm run build
npm pack                # writes hira-sdk-0.2.0.tgz

# in the app
npm install /path/to/hira/sdk/hira-sdk-0.2.0.tgz
The local API answers at https://localhost:7255, so HIRA_ENDPOINT=https://localhost:7255. Node trusts it once the .NET development certificate is exported as PEM and named in NODE_EXTRA_CA_CERTS; curl takes the same file with --cacert.
terminal
dotnet dev-certs https --export-path ./hira-dev.pem --format PEM
export NODE_EXTRA_CA_CERTS="$PWD/hira-dev.pem"
A local API mints applicant links on http://localhost:5173/verify#…, the console’s dev server. On a sandbox account, an API running in Development accepts a webhook endpoint at http://localhost on any port, provided every address the name resolves to is loopback (the setting Webhooks:AllowLocalhost, on in appsettings.Development.json).

Client

hira.mjs
import { Hira } from "@hira/sdk";

export const hira = new Hira({
  apiKey: process.env.HIRA_API_KEY,
  endpoint: process.env.HIRA_ENDPOINT,   // unset, the SDK uses https://api.hira.xyz
});

Each call sends Authorization: Bearer <key> and x-hira-sdk: ts/0.2.0. new Hira() throws without an apiKey, and on an endpoint that is not https (plain http is accepted for localhost, 127.0.0.1 and [::1] alone) or that carries a user name or password.

Create a session

create.mjs
const session = await hira.verificationSessions.create({
  programId: process.env.HIRA_PROGRAM_ID,
  customerRef: "user-1042",        // the app's own opaque id for the person; required
  clientReference: "signup-77",     // optional: the app's reference for this session
  expiresIn: 86400,                // optional: seconds the link lasts, 600 to 604800
  metadata: { plan: "pro" },        // optional: up to 16 string pairs, no personal data
});

session.id;    // "vsn_…": store it against the user
session.url;   // hand it to the person once

programId and customerRef are required. customerRef is the account’s own identifier for the party: 1 to 120 letters, digits and . _ : / -, never an email address or a phone number. It names the party in Hira’s directory, so every session created for user-1042 lands on one party record.

The applicant link is the session’s url. It is a bearer link: whoever holds it walks the session, so it goes to the person it is for and nobody else. It comes back on a fresh create and on createLink alone; get and list never carry it. It lasts 72 hours unless expiresIn says otherwise, between 10 minutes and 7 days.

An idempotency key makes a retried create safe. The SDK sends an Idempotency-Key with every create, generated per call unless { idempotencyKey } is passed as the second argument, and the same key rides every retry of that call. A replay answers the session the first send made, with replayed: true and no url; createLink(id) mints another link. The same key with a different body answers 422 idempotency_key_reused. Keys are kept for 24 hours.

The same request over HTTP:

terminal
curl "$HIRA_ENDPOINT/v1/verification-sessions" \
  -H "Authorization: Bearer $HIRA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-77-attempt-1" \
  -d '{"programId":"<program id>","customerRef":"user-1042","clientReference":"signup-77"}'

A fresh create answers 201 with the session; a replay answers 201 with the header Idempotent-Replayed: true.

201 Created
{
  "id": "vsn_8f3a…",                 // vsn_ and 32 hexadecimal characters
  "object": "verification_session",
  "livemode": false,                 // false on a sandbox account
  "status": "created",
  "decision": null,
  "assurance": null,
  "seq": 0,
  "origin": "api",
  "programId": "…",
  "flowRevision": 3,
  "customerRef": "user-1042",
  "clientReference": "signup-77",
  "partyId": "…",
  "returnUrl": null,
  "metadata": null,
  "redaction": null,
  "createdAt": "2026-10-12T04:15:22.123456Z",
  "startedAt": null,
  "completedAt": null,
  "expiresAt": "2026-10-15T04:15:22.123456Z",
  "canceledAt": null,
  "url": "https://…/verify#…"          // on a fresh create and on /links alone
}

returnUrl is refused today: any well-formed value answers 400 return_url_origin_not_allowed, and a malformed one 400 return_url_invalid.

Read the session

read.mjs
const walked = await hira.verificationSessions.get(session.id);

walked.status;        // "created" | "in_progress" | "completed" | "expired" | "abandoned" | "canceled" | "error"
walked.decision;      // null while open; then "approved" | "declined" | "in_review"
walked.assurance;     // "measured" | "simulated" | null
walked.steps;         // each concluded step: stepKey, outcome, assurance, attempt, concludedAt, notChecked
walked.pendingSteps;  // step keys still due

The status says where the walk stands. created until the person starts, in_progress once a step lands, completed when every step has concluded. Past expiresAt, a sweep that runs every minute stamps a session that never started expired and one that started abandoned; until then a read shows created or in_progress. createLink reopens either. canceled and error are final. A completed session can reopen to in_progress, with verification_session.reopened, when an officer asks the person for more or a decline of type retry is reached before the deadline. The decision is the program’s answer on a completed walk:

decisionApprovals statuscheck() verdictMeaning
in_reviewpendingHoldA person on the account owes an answer.
approvedapprovedAllowProceed.
declineddeclinedDenyDo not proceed; decisionDetail.declineType (retry or final) says whether asking again can help. A retry decline reached before the deadline reopens the session instead.

The read carries decisionDetail, errorDetail, expectedDetailsSupplied, applicant, steps, pendingSteps, cases, consent and riskRating beside the fields of the create. It never carries the url, the expected details, a name, a date of birth, a document number, an image or a consent’s text. The session object lists every field.

Simulate an outcome

In the sandbox, simulate stands in for the person: it concludes every remaining step of an open session with the outcome asked for, through the same recorder the hosted page posts to, so the program’s policies judge each step as they judge a real walk.

simulate.mjs
await hira.verificationSessions.simulate(session.id, { outcome: "verified" });   // or "failed", "unable-to-verify"

const done = await hira.verificationSessions.get(session.id);
done.status;      // "completed"
done.assurance;   // "simulated"
done.decision;    // what the program's policies decided

Every simulated step carries assurance simulated, and the party’s record in the console marks it simulated and counts it beside its verified figure, never in it. A face step with no granted consent concludes unable-to-verify whatever outcome was asked for. A session past its deadline, or not created or in_progress, answers 409 session_state_invalid. A create can preset the outcome instead, with simulate: { outcome } in its body; the hosted page then opens each step on that outcome. On a live account simulate answers 400 simulate_not_allowed.

List and cancel

list.mjs
const page = await hira.verificationSessions.list({ customerRef: "user-1042", limit: 25 });
page.object;    // "list"
page.data;      // sessions, newest first, without url
page.hasMore;   // true: pass the last id as startingAfter for the next page

const canceled = await hira.verificationSessions.cancel(session.id);
canceled.status;   // "canceled"

A list takes at most one of customerRef, clientReference and programId, and startingAfter or endingBefore, never both; it also takes createdAfter and limit (1 to 100, 25 unset). Sessions opened in the console or through a program’s link are listed beside API ones. There are no totals.

Cancel ends a session that is created, in_progress, expired or abandoned. A canceled session answers unchanged, so a retried cancel is safe; a completed or error session answers 409 session_state_invalid.

Webhooks

Hira sends five session events to the endpoints an account registers: verification_session.decided, .reopened, .expired, .abandoned and .canceled. Endpoints are registered in the console under Administration › Developer › Webhooks › New endpoint, with a URL, a description and the events to send; the signing secret, hira_whsec_…, is shown once.

The body is thin: the event’s ids and the session’s seq, no personal data and no status.

delivery body
{
  "id": "evt_…",
  "object": "event",
  "type": "verification_session.decided",
  "apiVersion": "2026-10-01",
  "createdAt": "2026-10-12T04:19:03.481220Z",
  "livemode": false,
  "data": { "id": "vsn_8f3a…", "object": "verification_session", "seq": 3 }
}

The receiver keeps one rule: verify the delivery with constructEvent over the raw body, answer 2xx within 10 seconds, then read the session through the API. Deliveries are not ordered and may arrive more than once. seq is the session’s change counter, so an event whose data.seq is below the last one applied for that session is dropped. The example app shows the receiver; Webhooks gives the signature, the retries and the tolerance.

Example app

A whole integration in one Express server: a signed-in user starts a verification, the app stores the session against the user, a status route reads it, a sandbox route simulates the walk, and a webhook route receives the outcome. The app’s own sign-in is stood in for by an x-user-id header, and its records are two in-memory maps.

1. Install. npm install @hira/sdk express in a new folder (before the npm release, the local build installs the SDK).

2. Environment. A .env file beside the server:

.env
HIRA_API_KEY=hira_sk_test_…          # the sandbox test key, scope Identity verification
HIRA_ENDPOINT=https://api.hira.xyz    # the API address; locally https://localhost:7255
HIRA_PROGRAM_ID=…                     # the sandbox program's id
HIRA_WEBHOOK_SECRET=hira_whsec_…       # the endpoint's signing secret, shown once

3 to 6. The server.

server.mjs
import express from "express";
import { Hira, HiraError, constructEvent, HiraSignatureError } from "@hira/sdk";

const hira = new Hira({ apiKey: process.env.HIRA_API_KEY, endpoint: process.env.HIRA_ENDPOINT });
const programId = process.env.HIRA_PROGRAM_ID;
const webhookSecret = process.env.HIRA_WEBHOOK_SECRET;

// The app's own records: the session opened for each user, and the highest seq applied per session.
const sessionByUser = new Map();
const appliedSeq = new Map();

const app = express();

// Stands in for the app's sign-in. The user id is opaque, so it doubles as the customerRef.
const signedInUser = (req) => req.header("x-user-id");

// 3. Start a verification for the signed-in user and answer the link to hand over.
app.post("/start", async (req, res, next) => {
  const userId = signedInUser(req);
  if (!userId) return res.status(401).json({ error: "Sign in first." });
  try {
    const session = await hira.verificationSessions.create({ programId, customerRef: userId });
    sessionByUser.set(userId, session.id);
    res.json({ sessionId: session.id, status: session.status, url: session.url });
  } catch (err) {
    if (err instanceof HiraError) return res.status(502).json({ status: err.status, code: err.code, error: err.message });
    next(err);
  }
});

// 4. Read where the session stands.
app.get("/status/:id", async (req, res, next) => {
  try {
    const s = await hira.verificationSessions.get(req.params.id);
    res.json({ status: s.status, decision: s.decision, assurance: s.assurance, seq: s.seq, pendingSteps: s.pendingSteps,
      steps: s.steps.map((step) => ({ stepKey: step.stepKey, outcome: step.outcome })) });
  } catch (err) {
    if (err instanceof HiraError) return res.status(502).json({ status: err.status, code: err.code, error: err.message });
    next(err);
  }
});

// 6. Sandbox only: conclude the walk with an outcome, standing in for the person.
app.post("/simulate/:id", express.json(), async (req, res, next) => {
  try {
    const s = await hira.verificationSessions.simulate(req.params.id, { outcome: req.body?.outcome ?? "verified" });
    res.json({ status: s.status, decision: s.decision, assurance: s.assurance, seq: s.seq });
  } catch (err) {
    if (err instanceof HiraError) return res.status(502).json({ status: err.status, code: err.code, error: err.message });
    next(err);
  }
});

// 5. The webhook receiver: verify over the raw body, acknowledge, then read the session through the API.
app.post("/hira/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await constructEvent(req.body, req.header("Hira-Signature") ?? "", webhookSecret);
  } catch (err) {
    return res.sendStatus(err instanceof HiraSignatureError && err.reason !== "bad_tolerance" ? 400 : 500);
  }
  res.sendStatus(200);

  try {
    const { id, seq } = event.data;
    if (id.startsWith("vsn_test_")) return;                 // a console test send names no session
    if (seq < (appliedSeq.get(id) ?? 0)) return;           // older than what was applied
    const s = await hira.verificationSessions.get(id);
    appliedSeq.set(id, Math.max(appliedSeq.get(id) ?? 0, s.seq));
    console.log(event.type, s.customerRef, s.status, s.decision);
  } catch (err) {
    console.error(err);                                   // Express 4 does not catch a rejection after the response
  }
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));

Run it with node --env-file=.env server.mjs (Node 20.6 or later reads the file; on 20.0 to 20.5, export the four variables instead). Register https://<the app's host>/hira/webhook as a webhook endpoint for all five events and put its secret in HIRA_WEBHOOK_SECRET.

The receiver applies the thin-envelope rule: the body carries only ids and seq, so the session’s state comes from get. A duplicate delivery repeats one read and changes nothing, and an event below the highest applied seq is dropped. express.raw keeps the body’s bytes, which is what was signed.

7. What each step answers. Start a verification:

terminal
$ curl -s -X POST localhost:3000/start -H "x-user-id: user-1042"
{"sessionId":"vsn_8f3a…","status":"created","url":"https://…/verify#…"}

The url goes to the person, here by opening it in a browser. Read the session before anyone walks it:

terminal
$ curl -s localhost:3000/status/vsn_8f3a…
{"status":"created","decision":null,"assurance":null,"seq":0,"pendingSteps":[…],"steps":[]}

Simulate a verified walk, then read it again:

terminal
$ curl -s -X POST localhost:3000/simulate/vsn_8f3a… -H "Content-Type: application/json" -d '{"outcome":"verified"}'
{"status":"completed","decision":"approved","assurance":"simulated","seq":1}

The decision is the program’s: approved, declined, or in_review when a case waits on a person in the console. The webhook route then logs verification_session.decided user-1042 completed approved. A sandbox account’s deliveries are tried three times within an hour. A simulate on the same session again answers 409, which the app relays:

terminal
{"status":409,"code":"session_state_invalid","error":"Hira: This session is not open, so its steps cannot be simulated."}

A HiraError carries status, code, param (the refused field), retryable and requestId (the Hira-Request-Id header, req_ and 32 hexadecimal characters). The SDK retries 429, 500, 503 and a network error up to maxRetries with backoff of 1, 2 and 4 seconds, at most 8; every other refusal throws at once with retryable: false.

Refusals

Every refusal answers { "error", "code", "param" }: a sentence, a stable code and, when the refusal is about one request field, that field. A code stays as written; its sentence may change. The ones a first integration meets:

CodeStatusSentence
api_key_invalid401The API key is missing or not valid. Keys are created under Administration › Developer › API keys.
api_key_scope_missing403This API key does not reach Identity verification. A key that does is created under Administration › Developer › API keys.
product_not_enabled403This account does not include Identity verification. Contact Hira to add it.
engine_not_connected403Live verification sessions open once an engine is connected. Sessions are created in the sandbox account with a test key.
field_required400programId is required. · customerRef is required: the account’s own identifier for the party.
customer_ref_invalid400The customer reference may use letters, digits and . _ : / - only, up to 120 characters.
customer_ref_personal_data400The customer reference looks like an email address or a phone number. Use an opaque identifier.
expected_details_required400The flow screens the person, so the session needs expectedDetails with a fullName and dateOfBirth, or a party record that holds them. (A business flow names legalName and an ISO 3166-1 country.)
expires_in_out_of_range400A link lasts between 10 minutes and 7 days.
return_url_origin_not_allowed400The origin … is not on this account’s allowed origins.
unknown_field400The request names a field ‘…’ that is not accepted.
program_not_found404No verification program of this account has that programId.
program_not_active409A session is only created for an active program.
party_kind_mismatch409This program’s flow verifies a person, and the party is a business.
daily_quota_reached409This sandbox account has reached its 500 verification sessions for the UTC day. More can be created from 00:00 UTC.
idempotency_key_reused422This Idempotency-Key was already used with a different request body. Send the same body, or a new key.
session_not_found404No verification session of this account has that id.
session_state_invalid409This session is not open, so its steps cannot be simulated.
simulate_not_allowed400simulate is accepted in the sandbox account alone.
rate_limited429This API key sent more than 300 create requests in one minute. Retry once the seconds in the Retry-After header have passed.

The API reference holds every code.

Configuration

Only apiKey is required.

OptionDefaultDescription
apiKey requiredstringThe account API key, which resolves the account on the backend.
endpoint"https://api.hira.xyz"API base URL; https, or plain http for localhost, 127.0.0.1 and [::1] alone. Trailing slashes are trimmed.
authHeader"Authorization"Sent as Bearer <key>. Any other header name sends the raw key.
maxRetries3Retries of a network error, 429, 500 or 503 for every call; a track() batch also retries any other 5xx and is then dropped.
flushIntervalMs5000track() only: longest an event sits buffered before a flush.
maxBatchSize20track() only: flush when the buffer reaches this many events.
failOpentruetrack() only: never throw into the hot path; drop on permanent failure. Every other call throws.
onError—(err, droppedEvents) => void, called when a track() batch is dropped.
debugfalseVerbose internal logging to the console.