Skip to main content

Web SDK

Package: @assayra/sdk-web version 1.0.0
Runtime: modern browsers and Node.js 20+ (Node.js 22 LTS recommended for backend services)

1. Download and install

  1. Download assayra-sdk-web-1.0.0.tgz.
  2. Verify it against SHA256SUMS.
  3. Create a vendor directory inside your application and place the tarball there.
  4. Install the pinned local file with your package manager.

With npm:

npm install ./vendor/assayra-sdk-web-1.0.0.tgz

With Yarn:

yarn add file:./vendor/assayra-sdk-web-1.0.0.tgz

With pnpm:

pnpm add ./vendor/assayra-sdk-web-1.0.0.tgz
  1. Commit the tarball and lockfile, or mirror the tarball in your approved private package registry.
  2. Confirm the installed version:
npm ls @assayra/sdk-web

Do not use npm install @assayra/sdk-web yet; public npm registry publication is not enabled for this release.

2. Configure your backend

Create these server-only variables. Never prefix the API key with NEXT_PUBLIC_, VITE_, REACT_APP_ or any other client-exposed prefix.

ASSAYRA_BASE_URL=https://your-assayra-origin.example
ASSAYRA_API_KEY=pl_sandbox_REPLACE_WITH_YOUR_SECRET
ASSAYRA_WORKFLOW_ID=wf_REPLACE_WITH_PUBLISHED_WORKFLOW

Create the key in Client Admin Portal → Developers → API keys with workflows:read, applications:read and applications:write. Copy the secret when it is shown; only its prefix remains visible later.

Create a single server-side client:

// server/assayra.ts — never import this module into browser code
import { Assayra } from "@assayra/sdk-web";

export const assayra = new Assayra({
baseUrl: process.env.ASSAYRA_BASE_URL!,
token: process.env.ASSAYRA_API_KEY!,
});

3. Create an applicant session

The following Express route is shared by all three Web modes:

import crypto from "node:crypto";
import express from "express";
import { assayra } from "./assayra.js";

const app = express();
app.use(express.json());

app.post(
"/api/verification/start",
requireSignedInCustomer,
async (req, res) => {
const customer = req.customer;
const created = await assayra.applications.create(
{
type: "individual",
workflowId: process.env.ASSAYRA_WORKFLOW_ID!,
channel: "web",
externalReference: customer.id,
recipientEmail: customer.email,
sendEmail: false,
person: {
givenName: customer.givenName,
familyName: customer.familyName,
dateOfBirth: customer.dateOfBirth,
nationality: customer.nationality,
email: customer.email,
},
},
{ idempotencyKey: crypto.randomUUID() },
);

// Store this mapping before returning the URL.
await db.verifications.insert({
customerId: customer.id,
assayraApplicationId: created.application.id,
});

res.status(201).json({
applicationId: created.application.id,
applicantUrl: created.invitation.applicantUrl,
expiresAt: created.invitation.expiresAt,
});
},
);

Use an idempotency key tied to one logical start attempt if your framework can retry requests. Persist the Assayra application ID against your own customer/reference before returning success.

Option A — hosted redirect

Choose this for the quickest and lowest-maintenance integration.

async function startVerification() {
const response = await fetch("/api/verification/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) throw new Error("Could not start verification");

const { applicantUrl } = await response.json();
window.location.assign(applicantUrl);
}

Implementation checklist:

  1. Put startVerification behind an authenticated Verify identity button.
  2. Show a loading state and prevent a double click while the request runs.
  3. Redirect only to the exact applicantUrl returned by your backend.
  4. Configure a branded pending/return page in your tenant experience settings.
  5. Receive and verify the Assayra webhook on your backend.
  6. Fetch final application state and update your customer record idempotently.

Do not treat a browser return, query string or completion screen as an approval.

Option B — drop-in hosted

This renders the same managed journey inside a secure iframe. Your page owns the outer layout; Assayra owns the verification screens.

Add a container with an explicit height:

<div id="assayra-verification" style="min-height: 720px"></div>

Then start and mount the journey:

import { mountAssayraHostedVerification } from "@assayra/sdk-web";

let unmountAssayra: (() => void) | undefined;

export async function mountVerification() {
const response = await fetch("/api/verification/start", { method: "POST" });
if (!response.ok) throw new Error("Could not start verification");
const { applicantUrl } = await response.json();

const container = document.querySelector<HTMLElement>(
"#assayra-verification",
);
if (!container) throw new Error("Assayra container was not found");

unmountAssayra = mountAssayraHostedVerification({
container,
verificationUrl: applicantUrl,
onReady(event) {
console.info("Assayra ready", event.step);
},
onStep(event) {
updateOuterProgress(event.step);
},
onComplete(event) {
window.location.assign(
`/verification/pending?reference=${encodeURIComponent(event.reference)}`,
);
},
onError(event) {
showSafeError(event.code);
},
});
}

export function leaveVerificationRoute() {
unmountAssayra?.();
unmountAssayra = undefined;
}

The mount function adds embed=1 and your exact parent origin, creates a sandboxed iframe, limits camera access, validates the message origin/window/protocol, and returns a cleanup function. Call cleanup whenever the component or route unmounts.

Add production headers using your actual Assayra origin:

Content-Security-Policy: default-src 'self'; frame-src https://your-assayra-origin.example
Permissions-Policy: camera=(self "https://your-assayra-origin.example")

Do not use * for frame-src, disable iframe sandboxing, or put the applicant URL in analytics/session replay.

Option C — headless

Headless mode is for teams that will build and own every applicant screen. The tenant API key remains on the backend; the browser receives only the scoped invitation token.

Extract the token from the issued URL on your backend and return it only to the signed-in customer who owns that journey:

function issuedToken(applicantUrl: string): string {
const path = new URL(applicantUrl).pathname;
const match = path.match(/^\/verify\/([^/]+)$/);
if (!match) throw new Error("Unexpected Assayra applicant URL");
return decodeURIComponent(match[1]);
}

res.status(201).json({
applicationId: created.application.id,
verificationToken: issuedToken(created.invitation.applicantUrl),
expiresAt: created.invitation.expiresAt,
});

Create the browser client:

import { AssayraApplicant } from "@assayra/sdk-web";

const applicant = new AssayraApplicant({
baseUrl: "https://your-assayra-origin.example",
verificationToken,
});

const session = await applicant.status<{
expiresAt: string;
privacyNoticeVersion: string;
privacyNoticeUrl: string;
application: {
currentStep: string;
completedSteps: string[];
status: string;
};
}>();

Render only session.application.currentStep. A normal individual flow is:

  1. submitIdentity(identity)
  2. uploadDocument({ bytes, fileName })
  3. livenessChallenge()
  4. Repeated observeLiveness({ nonce, frame }) calls for live guidance
  5. submitLiveness(...) with exactly three fresh full-quality frames
  6. submit(session.privacyNoticeVersion, true)

Document upload example:

const file = documentInput.files?.[0];
if (!file) throw new Error("Select a document image or PDF");

const result = await applicant.uploadDocument({
bytes: file,
fileName: file.name,
});

Liveness observation example:

const challenge = await applicant.livenessChallenge<{
nonce: string;
challengeKind: "turn_left" | "turn_right" | "look_up" | "hold_still";
captureMode: "active_liveness" | "face_match_only";
expiresAt: string;
}>();

const observation = await applicant.observeLiveness<{
ready: boolean;
state: string;
guidance: string;
face?: { pose: { yaw: number; pitch: number } };
}>({ nonce: challenge.nonce, frame: reducedPreviewJpeg });

showGuidance(observation.guidance);

Observation frames are for transient positioning guidance; they are not a biometric pass. Auto-capture three fresh evidence frames only when the challenge-specific state is stable, then submit them with monotonic capture timestamps:

await applicant.submitLiveness({
nonce: challenge.nonce,
capturedAt: captureTimes[2],
captureTimestamps: captureTimes,
device: collectAllowedDeviceSignals(),
frames: [centreFrame, challengeFrame, returnFrame],
});

Never add a local “skip”, “passed” or “approved” control. On refresh, interruption or retry, call status() again and follow the server-returned step.

Webhooks and final state

Subscribe to at least application.submitted and application.decided. Verify the timestamp and HMAC signature against the raw request body, store the delivery ID to prevent replay, then read the application with assayra.applications.get(id). See Verify webhooks.

Errors and operational handling

Authenticated SDK failures throw AssayraError with status, message and optional details.

  • 400: fix the request; do not retry unchanged input.
  • 401/403: verify environment, key and scopes; do not expose details to the applicant.
  • 404: invitation/application is absent or outside the tenant boundary.
  • 409: refresh current state; the requested transition may already be complete.
  • 429: retry with bounded exponential backoff and jitter.
  • 5xx: retry safe/idempotent operations; show a resumable service message.

Do not branch on English message text. Log the application ID, your external reference and safe error code—never API keys, applicant URLs, document data or biometric frames.

Upgrade or remove

To upgrade, download the new version, verify its checksum, replace the tarball, run the package-manager install command again, test in Sandbox and commit the updated lockfile.

To remove:

npm uninstall @assayra/sdk-web