Liveness SDK Guides /

Web Integration

Web Integration

Current web SDK version: 1.3.4. New in 1.3.4: on phones the check fills the screen, including the result screens; and on desktop the whole camera shows around the hand square. 1.3.1 added longer checks (allow evidence bodies up to 1 MiB on your pass-through route).

Browser distribution of the VeryAI Liveness SDK. A check opens a camera dialog on your page and asks the user to complete a short hand-motion challenge: touch their thumb to a highlighted fingertip (Touch) or copy a hand gesture (Gesture). Hand tracking and model inference run in the browser; no camera image or video is uploaded. No enrollment, no user identity.

How it works#

  1. Your page calls check(). The SDK asks your server for a session while the intro is shown, so the saved instruction and privacy notice can appear before camera access.
  2. Your server creates the session with VeryAI using your app's server secret and returns it. The secret never reaches the browser.
  3. The user completes the challenge. The SDK sends the derived hand data to VeryAI through your server.
  4. VeryAI evaluates it and signs its decision. Your server fetches the decision, checks the signature and only then acts on it.

1. Load the SDK#

The web SDK is served from VeryAI's CDN. Import it from its versioned URL; there is nothing to install or host:

import { check } from "https://assets.very.org/liveness-web/1.3.4/index.js";

index.js loads the engine (very-liveness.<hash>.wasm, about 7 MB compressed; a browser downloads one of two builds), the fonts and the tutorial images from the same folder, and checks each engine file against the SHA-256 it carries before running it. Keep the version in the URL fixed in production, and change it when you upgrade. The current version is shown at the top of this page and in your app's Setup guide in the Developer Portal. Third-party notices for each version are at https://assets.very.org/liveness-web/<version>/THIRD_PARTY_NOTICES.md.

The module exports check() and supportedLanguages, the list of language codes the SDK can display. The SDK's API calls still go to your own server (see Pass the SDK's requests through), not to the CDN.

2. Requirements#

Setting Value
Browsers Safari 16.4, Chrome 91 or Firefox 89 or later: camera access (getUserMedia) and WebAssembly SIMD
Secure context HTTPS (localhost also works for development). Without one the camera API is missing and the check reports camera_unavailable
Camera permission The browser asks when the camera starts, after the user presses Start. If it is denied, the check reports camera_permission_denied. Your page's Permissions-Policy must allow camera (for example camera=(self)). If the check runs in an iframe, give it allow="camera"
Your server Any backend that can make HTTPS requests and verify an Ed25519 signature. The examples use Node.js 20+ with Express. See Set up your server

Content Security Policy#

If your page sets a CSP, it must allow what the SDK loads:

  • script-src https://assets.very.org 'wasm-unsafe-eval': the SDK module from the CDN and compiling the engine
  • worker-src blob:: the engine's worker. Without it the engine runs on the main thread
  • connect-src 'self' https://assets.very.org: the engine file from the CDN and the SDK's API calls to your server; add the origin of apiBaseUrl if it is another origin
  • font-src https://assets.very.org and img-src https://assets.very.org data:: the dialog's fonts, tutorial images and icons
  • style-src 'self' 'unsafe-inline': the SDK injects its own stylesheet

Phone and desktop#

On phones (windows up to 650 px wide) and short landscape windows (up to 500 px high) the check fills the screen. On larger windows it opens as a centered dialog that you can size with modalSize. The SDK prefers the rear camera and falls back to any available camera, so a laptop with one webcam works; a Switch camera button appears when the browser reports a rear camera.

3. Run a liveness check#

import { check } from "https://assets.very.org/liveness-web/1.3.4/index.js";

const button = document.querySelector("#verify");

button.addEventListener("click", async () => {
  button.disabled = true;
  try {
    const scan = await check({
      mode: "touch",       // "touch" (default) or "gesture"
      themeMode: "light",  // "light" (default) or "dark"
      language: "en",      // optional, default: browser language
      mirrorPreview: true, // show the front camera like a mirror
      apiBaseUrl: "/liveness/", // where your server passes the SDK's requests through

      // Ask your server for a session; return it unchanged.
      createSession: async (request) => {
        const response = await fetch("/liveness/session", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(request),
        });
        if (!response.ok) throw new Error("Session failed");
        return response.json();
      },
    });

    if (scan.code === "cancelled") {
      // user closed the dialog
    } else if (scan.result === true) {
      // finished: ask your server to confirm before acting
      const confirmation = await fetch("/liveness/result", { method: "POST" });
      const { result: passed } = await confirmation.json();
      // apply your own access policy only after server confirmation
    } else {
      // see scan.failureReason and scan.errorMessage
    }
  } finally {
    button.disabled = false;
  }
});

check() opens the dialog and resolves once the user finishes: Continue after a pass, or Close at any time. It resolves with a result for every outcome (permission denied, network failure, rejection, cancel); it only rejects for invalid options, a check that is already running, or a dialog that cannot be set up. See Errors and cancellation.

createSession is required. With the default intro, the SDK calls it as soon as the intro opens. Start reuses that session if enough time remains; otherwise the SDK requests a fresh one. Retries and camera switches also request a new session. Return the full response unchanged, including its optional appearance, so the SDK receives your app's saved text. Pass request to your server, which validates the requested mode and sets the subject from your signed-in user as shown below. The SDK and VeryAI handle the evidence format automatically; you do not configure a profile. VeryAI applies your app's configured number of taps automatically. If session setup still fails after Start, the dialog shows an error and onEvent reports session_failed. An initial intro request can fail and recover on Start without reporting a failed attempt.

scan.result === true means the SDK verified a passed backend result. Confirm that signed result on your server before you unlock or approve anything.

4. Set up your server#

Your server does two jobs: it creates a session for every attempt, and it confirms the result before you act on it. It proves who it is with your app's server secret, which never goes to a browser or app.

Get your credentials#

Web liveness is currently a limited pilot enabled only for explicitly approved apps. A Liveness app, SDK access approval or a valid server secret does not enable the new flow by itself. Other apps receive 403 LIVENESS_NOT_ENABLED; their existing integrations are unchanged. Contact VeryAI before starting the migration.

In the Developer Portal, create a Liveness app (your organization needs SDK access) or open your existing SDK app. Owners and admins find both credentials on the app's API keys page.

Credential Sent as What it does
Server secret veryai_srv_... Authorization: Bearer Proves the request comes from your backend. Shown once when you create it. Keep up to two active so you can rotate without downtime
SDK key veryai_sdk_... X-VeryAI-SDK-Key Names your app. It is not secret (mobile apps ship it), so it cannot create a session on its own. The browser SDK never uses it

Keep the server secret on your server. Put it in an environment variable or secrets manager, never in a page, an app, a repository or a log. A secret only works for the app whose SDK key you send with it.

Create a session#

Call this each time createSession runs. Send it from your server only and omit the Origin header. Requests from an unapproved origin are refused; the server secret authenticates your backend.

POST https://api.very.org/v1/sdk/liveness/sessions
Authorization: Bearer veryai_srv_...
X-VeryAI-SDK-Key: veryai_sdk_...
Accept: application/vnd.veryai.liveness-result.v1+json
Content-Type: application/json

{
  "mode": "touch",
  "subject": "user-1234"
}
Field Required Description
mode Yes "touch" or "gesture". Use the SDK's request.mode
subject Yes Your own opaque reference for the user or action, 1 to 128 UTF-8 bytes, no surrounding spaces or control characters. The browser can see it, so don't use an email address or other personal data

VeryAI sets the capture time from your app's number of taps: 15 seconds per tap, so 15, 30, 45 or 60 seconds for 1 to 4 taps. Checks over 30 seconds (3 and 4 taps) need web SDK 1.3.1 or later and a pass-through route that accepts evidence bodies up to 1 MiB. It is one window for positioning the hand and every tap, and it starts once the camera is ready, so loading does not use it up. The session itself expires 180 seconds after creation.

Unknown fields, or a field sent twice, are a 400. The number of taps (App settings) and the Touch hold time (set by VeryAI for your app) come from your app's configuration, not from this request.

A 201 Created answer is the session. Give it to the browser exactly as you got it: the SDK checks it before it opens the camera. Your server needs these fields:

Field Description
id The session ID (32 hex characters)
uploadToken The session's ticket. The browser uses it for this session's own requests, and you use it to fetch the result. VeryAI stores only its hash, so this answer is the only time you see it
nonce Random value that the signed decision repeats
expiresAt Unix time in seconds, 180 seconds after the session was created. Setup, the check and the result must all happen before it
subject, tenant Your subject and your app ID

Pass the SDK's requests through#

After createSession, the SDK sends its other requests to apiBaseUrl (your own origin by default), not to VeryAI. Forward each one to the same path under https://api.very.org/v1/sdk/liveness, as shown. The browser sends the session's ticket as the Authorization header; pass it on and never add your server secret to these.

SDK request Forward to Sent with
GET api/public-key GET /public-key Nothing
POST api/operations/{id}/evidence POST /sessions/{id}/check The ticket
GET api/operations/{id} GET /sessions/{id} The ticket
POST api/operations/{id}/telemetry POST /sessions/{id}/telemetry The ticket

Send the evidence body on exactly as received, and accept bodies up to 1 MiB as in the example: VeryAI signs its digest, and the SDK rejects the result if the bytes changed on the way. Preserve the SDK's Accept header on key, check and result requests. Send the same fixed Accept header when you create sessions; it selects the current response format.

The whole server side, with the result check from the next step:

import express from "express";
import { livenessPassed } from "./verify.mjs";

const API = "https://api.very.org/v1/sdk/liveness";

// Server only. Never send either value to a browser or app.
const issuer = {
  Authorization: `Bearer ${process.env.VERY_SERVER_SECRET}`, // veryai_srv_...
  "X-VeryAI-SDK-Key": process.env.VERY_SDK_KEY,              // veryai_sdk_...
  Accept: "application/vnd.veryai.liveness-result.v1+json",
};

const app = express();

// Called when the intro opens, and again when a fresh session is needed.
app.post("/liveness/session", express.json(), async (req, res) => {
  const user = await signedInUser(req); // your own sign-in
  const { mode } = req.body;
  if (mode !== "touch" && mode !== "gesture") return res.sendStatus(400);

  const response = await fetch(`${API}/sessions`, {
    method: "POST",
    headers: { ...issuer, "Content-Type": "application/json" },
    body: JSON.stringify({
      mode,
      subject: user.id, // your own opaque reference, 1 to 128 UTF-8 bytes
    }),
  });
  const session = await response.json();
  if (!response.ok) return res.status(response.status).json(session);

  // Keep what you need to fetch the result later.
  await saveSession(user.id, {
    id: session.id,
    ticket: session.uploadToken,
    nonce: session.nonce,
    mode: session.mode,
    challengeSha256: session.challengeSha256,
    evidencePolicySha256: session.evidencePolicySha256,
    expiresAt: session.expiresAt,
  });
  res.status(201).json(session); // hand the session to the browser unchanged
});

// The SDK's other requests. Pass them through as they are: the browser sends
// this session's ticket, and the evidence must keep its exact bytes.
// Evidence uploads can be up to 1 MiB; telemetry is small.
const evidence = express.raw({ type: "application/json", limit: 1048576 });
const telemetry = express.raw({ type: "application/json", limit: 16384 });

async function pass(req, res, path) {
  const response = await fetch(`${API}${path}`, {
    method: req.method,
    headers: {
      ...(req.headers.authorization && { Authorization: req.headers.authorization }),
      ...(req.headers.accept && { Accept: req.headers.accept }),
      ...(req.method === "POST" && { "Content-Type": "application/json" }),
    },
    body: req.method === "POST" ? req.body : undefined,
  });
  res
    .status(response.status)
    .set("Cache-Control", "no-store")
    .type("application/json")
    .send(await response.text());
}

app.param("id", (req, res, next, id) => (/^[a-f0-9]{32}$/.test(id) ? next() : res.sendStatus(404)));
app.get("/liveness/api/public-key", (req, res) => pass(req, res, "/public-key"));
app.get("/liveness/api/operations/:id", (req, res) => pass(req, res, `/sessions/${req.params.id}`));
app.post("/liveness/api/operations/:id/evidence", evidence, (req, res) => pass(req, res, `/sessions/${req.params.id}/check`));
app.post("/liveness/api/operations/:id/telemetry", telemetry, (req, res) => pass(req, res, `/sessions/${req.params.id}/telemetry`));

// The browser calls this when check() ends. Its own "success" is not enough.
app.post("/liveness/result", async (req, res) => {
  const user = await signedInUser(req);
  res.json({ result: await livenessPassed(user) });
});

Confirm the result on your server#

When the browser finishes, call livenessPassed(user) on your server. It fetches VeryAI's decision for the session you saved for the signed-in user and returns a boolean:

// On your server, using the signed-in user.
const passed = await livenessPassed(user);
if (passed === true) {
  // Continue this user's action.
}

The helper returns true only after confirming:

  1. VeryAI signed it. The decision is authentic.
  2. It belongs to this attempt. The app, signed-in user and saved session all match.
  3. It can be used. The challenge passed, the decision has not expired and the attempt has not already been used.

A successful confirmation uses the attempt once. Call the helper once and reuse its returned boolean. The server example above already calls it in /liveness/result.

Copy the required server verification helper (verify.mjs)

Save this helper as verify.mjs and keep its verification checks intact. Set VERY_APP_ID on your server to your app's ID: in the Developer Portal, open your app's API keys page and copy App ID (it is also the tenant of every session you create). Supply these two functions from your application's storage code:

  • loadSession(user.id) loads the session saved for that signed-in user when you created the attempt.
  • markSessionUsed(session.id) atomically changes an unused attempt to used and returns true only to its first successful caller.
import { createPublicKey, verify } from "node:crypto";

const API = "https://api.very.org/v1/sdk/liveness";

// Call this when the browser says the check is over. Returns true only for a pass.
export async function livenessPassed(user) {
  const session = await loadSession(user.id); // saved when you created it
  if (!session) return false;

  // 1. Ask VeryAI for the result, with the ticket you were given.
  const response = await fetch(`${API}/sessions/${session.id}`, {
    headers: {
      Authorization: `Bearer ${session.ticket}`,
      Accept: "application/vnd.veryai.liveness-result.v1+json",
    },
  });
  const body = await response.json();
  if (!response.ok || body.id !== session.id || body.status !== "complete" || typeof body.result !== "boolean" || typeof body.receipt?.token !== "string") return false;

  // 2. Check the signature on the decision.
  const parts = body.receipt.token.split(".");
  if (parts.length !== 3) return false;
  const [header, payload, signature] = parts;
  const { kid, publicKeyPem } = await (await fetch(`${API}/public-key`, {
    headers: { Accept: "application/vnd.veryai.liveness-result.v1+json" },
  })).json(); // cache it
  let head, claim, signed;
  try {
    head = JSON.parse(Buffer.from(header, "base64url"));
    claim = JSON.parse(Buffer.from(payload, "base64url"));
    signed = verify(
      null,
      Buffer.from(`${header}.${payload}`),
      createPublicKey(publicKeyPem),
      Buffer.from(signature, "base64url"),
    );
  } catch {
    return false;
  }
  if (!signed || head.alg !== "EdDSA" || head.typ !== "JWT" || head.kid !== kid) {
    return false;
  }

  // 3. Check it is for this app, session and user, and that it passed.
  const ok =
    claim.iss === "veryai" && claim.aud === "liveness" &&
    claim.purpose === "liveness" &&
    claim.tenant === process.env.VERY_APP_ID && // your app ID, server configured
    claim.operationId === session.id &&
    claim.nonce === session.nonce &&
    claim.subject === user.id &&
    claim.mode === session.mode &&
    claim.challengeSha256 === session.challengeSha256 &&
    claim.evidencePolicySha256 === session.evidencePolicySha256 &&
    typeof claim.jti === "string" && /^[a-f0-9]{32}$/.test(claim.jti) &&
    typeof claim.evidenceSha256 === "string" && /^[a-f0-9]{64}$/.test(claim.evidenceSha256) &&
    Number.isInteger(claim.iat) && Number.isInteger(claim.exp) &&
    claim.iat * 1000 <= Date.now() + 5000 &&
    claim.exp > claim.iat && claim.exp - claim.iat <= 120 &&
    claim.exp * 1000 > Date.now() && claim.exp <= session.expiresAt &&
    typeof claim.result === "boolean" && claim.result === body.result;

  // 4. Count it once only, then act. Make this atomic in your database.
  return ok && claim.result === true && (await markSessionUsed(session.id));
}

What the helper verifies

It fetches the saved result directly from VeryAI using the session's ticket. It then checks the signed receipt and its app, user, session and expiry fields. These checks prevent a valid result for a different user or an older attempt from being reused.

GET https://api.very.org/v1/sdk/liveness/sessions/{id}
Authorization: Bearer <the session's uploadToken>
Accept: application/vnd.veryai.liveness-result.v1+json

// before the evidence arrives
{ "id": "a293e187b9406ee50f209d4ab1176f8d", "status": "created" }

// after the check
{
  "id": "a293e187b9406ee50f209d4ab1176f8d",
  "status": "complete",
  "result": true,
  "receipt": { "algorithm": "Ed25519", "token": "<header>.<payload>.<signature>", ... }
}

The receipt has three parts, header.payload.signature. Its Ed25519 signature is checked with the key from GET /public-key. The signed payload looks like this:

{
  "iss": "veryai",
  "aud": "liveness",
  "purpose": "liveness",
  "result": true,
  "operationId": "a293e187b9406ee50f209d4ab1176f8d",
  "nonce": "...",
  "tenant": "<your app ID>",
  "subject": "user-1234",
  "mode": "touch",
  "challengeSha256": "...",
  "evidencePolicySha256": "...",
  "evidenceSha256": "...",
  "jti": "...",
  "iat": 1793000000,
  "exp": 1793000120
}
  • The header must say alg: "EdDSA", typ: "JWT" and the kid of the public key. The signed iss, aud and purpose identify a VeryAI liveness result.
  • tenant must match your server-configured app ID (VERY_APP_ID in the example); operationId, nonce and subject must match the session you created for this user.
  • exp is at most 120 seconds after VeryAI signed the decision, and the session itself ends 180 seconds after it was created, so check soon after the check ends.
  • A session counts once. Mark it used in one atomic step, so a repeated request cannot reuse a pass.

Errors and limits#

Errors come back as { "code": "...", "message": "..." }, except SDK key errors, whose code is a number, and missing required headers, which return { "msg": "..." }.

Status Code Meaning
400, 413, 415 INVALID_SESSION, INVALID_EVIDENCE The body is not one JSON object with Content-Type: application/json, or a field is missing, unknown, repeated or out of range. A missing X-VeryAI-SDK-Key or Authorization header is a 400 too
406, 409 RESULT_FORMAT_UNSUPPORTED, RESULT_FORMAT_UNAVAILABLE The Accept header was changed, or was missing when the session was created. Send the fixed header shown above on every request, then create a new session
401 BROKER_AUTH_REQUIRED An Authorization header was supplied, but the server secret is malformed, revoked, unknown or belongs to another app
401 5008, 5009 The SDK key is unknown (5008) or has been revoked (5009)
401 SESSION_AUTH_REQUIRED The session ticket does not match
403 ORIGIN_DENIED The request carried a browser Origin header that VeryAI does not accept. Send these requests from your server, which sends none
403 LIVENESS_NOT_ENABLED This app is not enabled for the limited web-liveness pilot. Credentials alone do not grant access
403 NONCE_MISMATCH The evidence belongs to another session. Create a new session and check again
404 NOT_FOUND Web liveness is not available at this address. Check the URL, or contact us
409 EVIDENCE_IMMUTABLE Different evidence was already sent for this session. Create a new one
410 SESSION_EXPIRED The session is over or unknown. Create a new one
429 AUTH_RATE_LIMITED Too many requests with invalid credentials. Fix the credential and retry after Retry-After seconds
503 STORE_UNAVAILABLE, CONFIG_UNAVAILABLE, SIGNER_UNAVAILABLE, RANDOM_UNAVAILABLE A temporary problem on VeryAI's side. Retry with a new session

Rotate the server secret#

Create a second secret, deploy it to your server, then revoke the first. A revoked secret stops working on the next request. The portal shows when each secret was last used, so you can see that nothing still sends the old one.

Number of taps#

The check can ask the user to touch their thumb to 1 to 4 fingertips in order. The dialog shows one fingertip at a time: the user touches it, holds, then separates their fingers and moves to the next. More taps make the check longer for the user. The default is 1.

Owners and admins set it in the Developer Portal, on the app's App settings page, under Number of taps. The setting applies to sessions created after you change it; a session already issued keeps its number. It only affects Touch; Gesture checks ignore it.

Change the setting in the portal; no code change is needed. VeryAI puts the fingertips in each session, and the SDK shows them in order.

All the fingertips are part of the session you get back, and the SDK shows them one by one. The capture time grows with the number of taps: 15 seconds per tap.

Migrate from the SDK key#

Earlier integrations created sessions with a credential issued by VeryAI, plus the app's SDK key. Each partner now uses a server secret of their own that only their backend holds. Agree the pilot activation and switch-over timing with VeryAI first. Existing native SDK endpoints are unchanged.

  1. Create a server secret. In the Developer Portal, open your app's API keys page, find the Server secrets card and choose New secret. Copy the value when it is shown; you cannot see it again.
  2. Put it on your server only. Use an environment variable or secrets manager. It does not belong in a page, an app, a repository or a log.
  3. Switch session creation to it. In your create session request, replace the Authorization value. The URL, the body and the SDK key header stay the same:
    - Authorization: Bearer <VeryAI-issued credential>
    + Authorization: Bearer veryai_srv_...
      X-VeryAI-SDK-Key: veryai_sdk_...
  4. Confirm it is being used. The secret's Last used time on the card moves (it updates at most once a minute). If it still says Never used, requests are not using it yet. A 401 BROKER_AUTH_REQUIRED means the secret is wrong or belongs to another app.
  5. Rotate the SDK key. On the same page, under SDK API keys, create a new key, deploy it as your server's X-VeryAI-SDK-Key, then revoke the old one. A revoked key stops working at once. If a mobile app uses the same key, ship its update first.

check() options#

Option Type Description
createSession Function Required. Returns a session from your server. See Create a session
mode String "touch" (default): touch the thumb to a fingertip; "gesture": copy a hand gesture
container HTMLElement Mount element. Default document.body
themeMode String "light" (default) or "dark"
language String BCP 47 tag. Default follows the browser. See Languages
modalSize Object { width } in CSS pixels. See Dialog size
privacyMessageFontSize Number CSS pixels, 12 to 24. Default 13
privacyMessageColor String CSS color for the disclosure text
customStrings Object Text overrides: connectDots and switchCamera. Set the scan instruction in the Developer Portal
intro Boolean Default true. false skips the explanation screen
guideStyle String "outline" (default) or "corners"
mirrorPreview Boolean Default false. Mirrors the preview for front cameras (a rear camera is never mirrored). Set it to true, as in the example, so the preview moves the way the user does
signal AbortSignal Abort to cancel the check; check() resolves with "cancelled"
apiBaseUrl String Where your server passes the SDK's requests through. Your site's root by default. See Pass the SDK's requests through
onEvent Function Lifecycle events. See Events
const controller = new AbortController();

await check({
  // ...createSession and the options above...
  signal: controller.signal,  // abort() closes the dialog
  modalSize: { width: 420 },  // CSS px; desktop and tablet only
  onEvent: (name, properties) => {
    analytics.track(`liveness_${name}`, properties);
  },
});
  • intro: the default intro requests a session and loads the saved text before camera access. Start waits for that response; if it is slow or fails, the SDK keeps the intro visible while resolving the session. A refreshed session with different text requires another Start. The camera and capture timer start after Start; session expiry is measured from issuance, including time spent reading the intro. "auto" is accepted for compatibility and behaves like true.
  • customStrings: plain text. connectDots sets the Touch prompt and switchCamera the accessible label of the camera switch. Set the positioning instruction in the Developer Portal. Blank values and other keys are ignored; pass already-localized text.

Dialog size#

modalSize.width is rounded and clamped to 360 to 960. The camera view keeps its shape, so the height follows the width, and the dialog is narrower when the window is too small for it. Phones and short landscape windows keep the full-screen layout and ignore it. Omit it for the default size, which follows the window and SDK stage layout rather than the camera's resolution. A width that is not a number above 0 rejects check() with a TypeError.

Privacy notice#

The privacy notice and the scan instruction are set in the Developer Portal: open your Liveness app, then App settings, SDK appearance, Text. The Instruction is plain text, up to 80 characters. The Privacy notice is up to 1000 characters and takes no line breaks; use <br> instead. VeryAI sends the saved text with each new session. Without saved text there is no notice, and the instruction is the SDK's default.

The notice appears in the intro before Start and below the camera during capture, and long text scrolls. It allows http(s) links, <b>/<strong>, <br> and <font color="#RRGGBB">; other markup is escaped. privacyMessageFontSize and privacyMessageColor still style it, and privacyMessageColor is used only if it is opaque and readable on the theme, otherwise the theme text color applies.

Languages#

The dialog ships 37 languages; the supportedLanguages export lists their codes. Chinese tags are matched by script and region, other tags by primary language. Arabic, Persian and Hebrew render right-to-left. Localization covers on-screen and assistive text only: result.errorMessage stays in English, and your privacy notice is shown exactly as you wrote it.

Result#

Field Type Description
code String "success", "rejected", "error" or "cancelled"
result Boolean true only after a signed backend pass and matching saved-result verification; false for rejection, error or cancellation
isSuccess Boolean Compatibility alias for result
failureReason, stage, blocker String Why the last attempt failed, how far it got and what held it back. See Errors and cancellation
errorMessage String? English detail when the check ended on an error; for your logs, not for display
durationMs Number Time from the check() call to the result
serverDecision Object? The backend's verified { result: boolean }, or null if no decision was reached. receiptSignatureVerification says whether the SDK verified its signature

The result carries further fields (runtime, frame counts, model hashes and a receipt) for debugging and verification. Your server must still verify the signed result and session binding before applying your own access policy.

  • "success": the backend passed the attempt and the SDK verified the decision's signature.
  • "rejected": the backend evaluated the attempt and did not pass it, including when the capture time ran out. The dialog offers Retry; Close returns the result of the last attempt.
  • "error": the check could not reach a verified decision. See failureReason and errorMessage.
  • "cancelled": the user closed the dialog before an outcome, or signal was aborted.

VeryAI checks the hand movements and model reports against the issued challenge. Confirm the signed result on your server before applying your application's access rules. See Confirm the result on your server.

Errors and cancellation#

check() rejects only before any camera or session work: with a TypeError for an invalid option (a missing createSession, a bad mode, modalSize and so on), or an Error if another check is already running or the dialog cannot be set up. Every other outcome resolves with a result.

Failure reason Meaning
none The attempt passed
user_cancelled The user closed the dialog, or signal was aborted
timeout Setup, capture or the final check ran out of time
camera_permission_denied Camera access was denied
camera_unavailable No camera, camera in use, or no camera API (insecure page or unsupported browser)
poor_quality The hand was never positioned well enough to start the challenge
lost_hand The hand left the frame during the challenge
liveness_failed The backend did not pass the attempt for another reason
network_error Creating the session, or a request to VeryAI, failed in transport or with a server error
sdk_error Invalid session, or a model, WebAssembly or worker failure
unknown Anything else

background is part of the shared type but is never emitted on the web. stage is one of setup, positioning, liveness or submitting. blocker is one of none, setup, no_palm, alignment, liveness_lost_hand, liveness_background, liveness_incomplete or unknown.

Cancel and close#

The dialog has a Close button, and signal closes it from your code. Either way check() resolves with the result of what was reached: code: "cancelled" and failureReason: "user_cancelled" if there was no outcome yet. After a failed attempt the dialog offers Retry, which calls createSession again; one check() call can therefore cover several attempts. Don't treat cancellation as a fraud signal.

Events#

onEvent mirrors the check's lifecycle. Every event carries checkId (shared by all attempts of one check() call) and attempt. Events hold only enum names, counts and durations.

Event When Extra properties
session_requested A capture attempt begins using or requesting its session; the intro may already have called createSession mode
session_failed Creating the session failed failureReason, errorCode
capture_started Camera and models are ready and capture begins None
liveness_started The hand-motion challenge starts None
attempt_finished An attempt ended: pass, failure or cancel failureReason, stage, blocker, errorCode, durationMs, timedOut
check_finished check() is about to resolve code, failureReason, stage, blocker, durationMs

Privacy#

Stays on the device: camera frames are processed in the browser. The SDK sends no image, crop, video, palm template or enrollment payload.

Sent to VeryAI: bounded derived quality values, model output summaries and the 21 normalized hand landmarks (x, y and relative depth) over time. These are sensitive biometric observations; this is not a promise that no biometric data leaves the device. Each session also sends one non-biometric outcome record (failure reason, step, counters, durations and browser and OS family) that never changes the result.

Disclose this to your users in the privacy notice (see Privacy notice). You own its accuracy, legal wording and localization. For example: "Camera images stay on your device. Derived hand landmarks and model summaries are sent to VeryAI."

VeryAI

Get the VeryAI app

Scan the QR code to download the app