SDK

siteKey is your Project ID — the UUID shown on the project page in the dashboard. It is not a Secret key. A vf_test_… / vf_live_… value passed as siteKey is rejected with 403, and that 403 deliberately does not say why (a site that is not on your list of allowed domains gets the same response — we never confirm which Project IDs exist). So if you see a 403, check both: the value you passed and your allowed domains.

// Correct — siteKey is the project UUID from the dashboard
await VH.mount(document.getElementById('vh'), {
  siteKey: '6115db72-00d0-4645-a9a8-27055b9329c7',
});

// WRONG — this is a Secret key; it belongs in `apiKey`, not `siteKey`
await VH.mount(el, { siteKey: 'vf_test_QZW1...' });  // → 403

The short-lived key from POST /public/projects/{id}/demo-key is a test secret key, used when a server starts the session (the apiKey option or the X-API-Key header). It is not a Project ID and never goes in siteKey. That response also echoes the Project ID back as siteKey plus a usage line, so you can read which value goes where without guessing.

Put the check on your page. There are four ways to add it, and they all behave the same — pick the one that fits your site. See SDK integration contracts for how the four compare and what each one guarantees. Every way (the HTML snippet, VH.mount and the React component) sends the check to /public/verify before it finishes, so you always get a token signed by our server, a record in your dashboard and a webhook event. The result includes unifiedVerdict: one allow/review/block answer plus the level of check that produced it.

Before you start: if you bundle the SDK into a Next.js, Vite, Webpack or CRA app, read Framework integrations first. If your site sends a Content-Security-Policy, read Content Security Policy. What changed in each version is in the SDK's CHANGELOG.md, included in the npm package.

Install

npm: npm install @verifyhuman/sdk (current version 1.30.1).
CDN: <script src="https://vhuman.riwi.com/sdk/v1/verifyhuman.umd.js" async defer></script>

Configuration via environment variables

For real apps you almost never want a literal siteKey/apiKey in source. Recommended conventions:

VariableWhereWhat for
NEXT_PUBLIC_VH_SITE_KEYNext.js / Vite / browser bundleYour Project ID (public) for VH.mount / <VerifyHuman> in the browser. This is the Project ID (a UUID) — see above; never a Secret key. Safe to include in your build, because it only works on your allowed domains.
NEXT_PUBLIC_VH_API_ENDPOINT (required for bundled apps)Next.js / Vite / Webpack / CRAThe API base URL. Required whenever the SDK is bundled into your app (the common npm install path) — see Framework integrations for why. Optional only for the HTML <script> snippet, where the SDK works it out from where the script was loaded.
VH_API_KEYServer / backend onlyYour Secret key (vf_live_*) or Test secret key (vf_test_*). NEVER send it to the browser. Call GET /api/v1/api-keys/current when your server starts to look up its project_id instead of writing it into your code.
// Next.js app — client component
import { VerifyHuman } from '@verifyhuman/sdk/react';

export default function GatePage() {
  return (
    <VerifyHuman
      siteKey={process.env.NEXT_PUBLIC_VH_SITE_KEY!}
      // Required for bundled apps — see "Framework integrations" below.
      apiEndpoint={process.env.NEXT_PUBLIC_VH_API_ENDPOINT
                   ?? 'https://vhuman.riwi.com/api/v1'}
      onVerify={(token) => /* forward to your backend */}
    />
  );
}

// Next.js — server route discovering its own project at boot
const r = await fetch('https://vhuman.riwi.com/api/v1/api-keys/current', {
  headers: { 'X-API-Key': process.env.VH_API_KEY! },
});
const { projectId, projectName } = await r.json();

Framework integrations (Next.js, Vite, Webpack, CRA)

If you install the SDK with npm install @verifyhuman/sdk and bundle it into a React / Next.js / Vite / Webpack / CRA app, you must pass apiEndpoint explicitly. This is the most common problem in a first integration, and it is confusing: the camera check runs perfectly in the browser (you see the challenges pass), then every call to our server returns 404, the check says “Verification failed”, and nothing shows up in your VerifyHuman dashboard.

Why this happens

The HTML <script> snippet works out the API address from where its script file was loaded (https://vhuman.riwi.com/... → API at https://vhuman.riwi.com/api/v1). When the SDK is bundled into your app instead, the only address it can see is your bundle's — for example http://localhost:3100/_next/static/chunks/… in development, or your own domain. The VerifyHuman API is not there, so the SDK uses our production address and shows this warning once in the console:

[VerifyHuman] Auto-detected API origin "http://localhost:3100" is
not a VerifyHuman host. The SDK is using the production fallback
(https://vhuman.riwi.com/api/v1) but if you bundled the SDK into your
own app (Next.js, Vite, etc.), pass `apiEndpoint` explicitly.
See https://vhuman.riwi.com/docs/sdk#framework-integrations

Since v0.5.15, this keeps the SDK working against production even if you forget the prop. Still set it yourself, so that you (a) choose which environment you talk to (production, staging or self-hosted), and (b) never rely on a silent switch to production.

The recommended setup

'use client';
import { VerifyHuman } from '@verifyhuman/sdk/react';

export function Gate() {
  return (
    <VerifyHuman
      siteKey={process.env.NEXT_PUBLIC_VH_SITE_KEY!}
      apiEndpoint={process.env.NEXT_PUBLIC_VH_API_ENDPOINT
                   ?? 'https://vhuman.riwi.com/api/v1'}
      widgetMode="standard"
      onVerify={(token, result) => {
        // token is the server-signed JWT; forward it to your backend.
      }}
      onError={(err) => {
        // err.code is a typed VerifyError code (see Typed error codes).
      }}
    />
  );
}

Set NEXT_PUBLIC_VH_API_ENDPOINT per environment (.env.development → your staging API, .env.production → https://vhuman.riwi.com/api/v1). The ?? 'https://vhuman.riwi.com/api/v1' part keeps local builds working before the environment variable is set.

Checking it works

Open DevTools → Network and complete a check. You should see POST /api/v1/public/sessions and POST /api/v1/public/verify going to vhuman.riwi.com (or the host you set), not to your own development server. The check then appears on your project's Verifications page in the dashboard. If those calls return 404 from localhost or your own domain, apiEndpoint isn't set.

The HTML snippet (a <script> tag loaded from vhuman.riwi.com) does not need this; it finds the address by itself. This section is only for the bundled npm install setup.

Content Security Policy

If your site sends a Content-Security-Policy header (and a careful site should), you must allow the hosts and features the SDK uses — otherwise the browser blocks them without telling you. This is the most common problem after bundling: the camera check runs in the browser, then the call to our server fails with E030: Network error: Failed to fetch and nothing reaches your dashboard. The browser stops the request before it leaves the page, so it never shows as a 404 or a CORS error, only as a blocked connect-src in the console.

Since v0.5.17 the SDK notices a CSP block and its error names the exact directives to add — but you can set them up front. You need:

Content-Security-Policy:
  connect-src 'self' https://vhuman.riwi.com https://cdn.jsdelivr.net;
  script-src  'self' https://cdn.jsdelivr.net 'wasm-unsafe-eval';
  worker-src  'self' blob:;
  img-src     'self' data: blob:;
  media-src   'self' blob:;
DirectiveWhy the SDK needs it
connect-src https://vhuman.riwi.comAll calls to our API — /public/sessions, /public/verify, /public-config, /origin-check. Use your own host here if you pass a custom apiEndpoint.
connect-src https://rtt.vhuman.riwi.com https://rtt-blr1.vhuman.riwi.com https://rtt-sgp1.vhuman.riwi.com https://rtt-nyc3.vhuman.riwi.comPage script only (Server API projects, v1.28.0 and later): a connection-timing reading once per page (one connection and at most five small requests per host) to each of these VerifyHuman hosts. If one is blocked, only that reading is lost. Turn it off with data-latency-sample-rate="0".
connect-src https://cdn.jsdelivr.netThe face-detection model files are downloaded from jsDelivr.
script-src https://cdn.jsdelivr.net 'wasm-unsafe-eval'MediaPipe loads its JavaScript from jsDelivr and compiles a WebAssembly module — both are blocked without these.
worker-src blob:MediaPipe and face-api start Web Workers from blob: URLs so face analysis doesn't slow the page.
img-src data: blob: · media-src blob:The camera preview and frame captures are blob: / data: URIs. (No image is ever uploaded — they stay in the browser.)

Note: cdn.jsdelivr.net is where the open-source MediaPipe and face-api files are served from today. If you host copies on your own CDN, point these directives there instead. The API host (and, for the page script, the timing hosts) are the only VerifyHuman addresses in the list.

Settings reference (props)

Beyond siteKey (the Project ID) / apiKey (a vf_… Secret key, only when your server starts the session), every way (HTML snippet, VH.mount, React component) accepts the same settings. The full list:

PropTypeWhat it does
widgetMode'invisible' | 'captcha' | 'standard'Which check runs. standard (Full camera check) shows the camera and short challenges; captcha (Quick camera check) is a lighter camera check; invisible (Silent) uses no camera and asks nothing of the respondent. Overrides the project setting.
autoStartboolean (default false)Start the check as soon as it loads instead of waiting for the respondent to click. You do not need this for invisible — it has nothing to click, so it always starts by itself (SDK 1.20.4 and later). Use it only to stop captcha / standard waiting for a click. The click is where those checks show the consent notice, so if you set this you must get consent yourself.
escalateOnSuspicionboolean (default false)Lets a silent check step up to a camera check. Only affects invisible. Off by default: a low score fails and the camera never opens. When true, a passing score still never opens the camera, but a score below the pass mark moves on to a camera check with short challenges instead of failing — so a real person who looked borderline gets a second chance to prove it. Can also be set in the project settings.
interaction
(React prop: interactionType; HTML: data-interaction)
'checkbox' | 'slider'How the respondent starts the check. The name differs by way: data-interaction on the HTML snippet, interaction on VH.mount, and interactionType on the React component. Passing interactionType to VH.mount is silently ignored.
widgetTheme'light' | 'dark' | 'auto'Color theme. auto follows the user's prefers-color-scheme.
widgetStyle'classic' | 'friendly'Look — classic shapes or a softer, rounded avatar.
avatarStyle'friendly' | 'minimal' | 'neon'Avatar visual style during challenges.
useDeepfakeDetectionbooleanOptional. Runs an extra deepfake check on the captured frames. Usually adds less than 50 ms; adds result.deepfake to the result.
previousSessionIdstringUsed on retry mounts — see retry pattern.
degradedMode'FAIL_CLOSED' | 'FAIL_OPEN_FLAGGED' | 'FAIL_OPEN_TRUSTED'Overrides the project's outage setting for this check — see when VerifyHuman can't be reached.

Common patterns

Verify once, skip the gate next time (cookie)

Set a short-lived cookie after a successful check so repeat visits skip the check entirely.

// Client: after a successful VH.mount, set a 7-day cookie
const result = await VH.mount(el, { siteKey });
if (result.status === 'pass') {
  document.cookie =
    `vh_verified=${result.token}; max-age=${60 * 60 * 24 * 7}; path=/; SameSite=Lax; Secure`;
}

// Server: check the cookie before serving the gate page
// (Next.js middleware example)
import { NextResponse } from 'next/server';
export function middleware(req) {
  const token = req.cookies.get('vh_verified')?.value;
  if (token && req.nextUrl.pathname === '/verify') {
    return NextResponse.redirect(new URL('/', req.url));
  }
}

For real protection, your server should also check the cookie value with POST /api/v1/token/verify from time to time — a signed token cannot be forged the way a plain cookie can.

Skip-ahead after N failures

Combine the error codes with a retry count per respondent, and send them to manual review after too many failures.

const MAX_RETRIES = 2;
let attempts = 0;

while (attempts <= MAX_RETRIES) {
  try {
    const result = await VH.mount(el, {
      siteKey,
      previousSessionId: lastSessionId,
    });
    return acceptRespondent(result);
  } catch (err) {
    if (err.code === ErrorCode.DUPLICATE_DETECTED) {
      // Final — they've already participated. Don't loop.
      return showAlreadyParticipated();
    }
    if (++attempts > MAX_RETRIES) {
      return showFinalRejection();
    }
    lastSessionId = err.context?.sessionId;
  }
}

Next.js troubleshooting

  • "Package path ./react is not exported from package @verifyhuman/sdk" on first run — usually an out-of-date .next/ cache. Stop the dev server, rm -rf .next, and restart. Use the documented static import inside a 'use client' file: import { VerifyHuman } from '@verifyhuman/sdk/react'. You don't need next/dynamic — the SDK only runs its browser code in the browser.
  • "Critical dependency: require function is used..." pointing at face-api.esm.js — a harmless webpack warning from the optional age and gender estimate. To hide it, add this to your next.config.js:
    module.exports = {
      webpack: (config) => {
        config.ignoreWarnings = [
          ...(config.ignoreWarnings ?? []),
          { module: /@vladmandic\/face-api/ },
        ];
        return config;
      },
    };

How the SDK finds the API

The SDK works out the API address from the src attribute of the script tag that loaded it. Load the bundle from vhuman.riwi.com and the SDK calls https://vhuman.riwi.com/api/v1 automatically. Host a copy of the script on your own CDN and the SDK uses your domain's /api/v1 path (if you also forward the API from there).

Two ways to set a different address:

  • HTML snippet: <div data-sitekey="..." data-api-endpoint="https://your-api.example.com/api/v1"></div>
  • In code / React: VH.mount(el, { siteKey, apiEndpoint: 'https://your-api.example.com/api/v1' })

An apiEndpoint you pass always wins over the detected address — useful when you load the SDK from one CDN but send API calls through your own backend.

Ways to add it

1. HTML snippet (starts by itself)

No JavaScript to write. When the page loads, the script finds elements with data-sitekey and starts the check in them. Use data-study-id / data-respondent-id / data-metadata to add your own IDs; they come back to you on the token and in webhook events.

<script src="https://vhuman.riwi.com/sdk/v1/verifyhuman.umd.js" async defer></script>

<div
  data-sitekey="YOUR_PROJECT_ID"
  data-study-id="YOUR_STUDY_ID"
  data-respondent-id="YOUR_RESPONDENT_ID"
  data-metadata='{"panel":"prolific"}'
  data-theme="light"
  data-callback="onVerify"
></div>

<script>
  // Fires on a PASS only. Required: confirm result.token on your server
  // with /token/verify before trusting it.
  function onVerify(result) {
    // result.sessionId, result.token, result.scores, ...
    fetch('/your-app/verified', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        sessionId: result.sessionId,
        token: result.token,
      }),
    });
  }

  // A failed check or a setup problem (e.g. this domain is not on the
  // allowed list) arrives as a 'vh:error' event, not through data-callback.
  document.addEventListener('vh:error', function (event) {
    console.warn('Verification did not pass:', event.detail.code, event.detail.message);
  });
</script>

2. HTML + JavaScript

Start the check in code when the element isn't on the page when it loads. The script adds window.VH (note: not window.VerifyHuman.VH — that doesn't exist). window.VH.mount(...) returns a Promise that resolves with the result of the check.

<div id="vh-container"></div>

<script>
  // window.VH is installed by the UMD bundle (see /sdk/v1/verifyhuman.umd.js).
  // window.VerifyHuman.VerifyHuman, window.VerifyHuman.createVerifyWidget,
  // and window.VerifyHuman.init also exist, but VH.mount is the customer
  // surface for server-integrated mounts (matches the React/npm path).
  window.VH.mount(
    document.getElementById('vh-container'),
    {
      siteKey: 'YOUR_PROJECT_ID',
      studyId: 'YOUR_STUDY_ID',
      respondentId: 'YOUR_RESPONDENT_ID',
      metadata: { panel: 'prolific' },
      // Default API endpoint is production. For staging, override:
      // apiEndpoint: 'https://vhuman.riwi.com/api/v1',
    }
  ).then((result) => {
    console.log('verified', result.sessionId, result.token);
    // result.status:  'pass' | 'fail'      (canonical pass/fail)
    // result.success: boolean              (legacy boolean alias)
    // result.token:   string               (JWT — forward to /token/verify)
    // result.livenessScore: number         (0-1)
    // result.confidence: number            (0-1)
    if (result.status === 'pass') {
      // grant access; forward result.token to your backend for
      // server-side validation
    }
  });
</script>

Full VHMountResult shape (v0.5.3+)

Every field the VH.mount() Promise returns. The shape is stable — build on it; new fields are only added, and nothing breaks within version 1.

// What you get from:  await VH.mount(el, { siteKey: '...' })
{
  // Core (always present)
  "status":     "pass" | "fail",          // our server's result — use this
  "sessionId":  "sess_<24 chars>",        // unique ID of this check
  "token":      "eyJ...JWT...",           // forward to /token/verify
  "success":    true,                     // older true/false copy of status

  // Scores from our server (always on a pass; on a fail when the
  // check got far enough to compute them)
  "scores": {
    "liveness":     0.94,                 // 0-1, live-person confidence
    "uniqueness":   1.00,                 // 0-1, 1.0 = unique
    "authenticity": 0.92,                 // 0-1, real-camera confidence
    "overall":      0.95,                 // combined score
    // v1.30.1+ — the risk score from the respondent's internet connection,
    // device and on-page behaviour: 0-100, higher = more risk, the same
    // scale as the Server API's score. Sent on every check where it was
    // computed, pass or not; absent when it was not. If the page script
    // could not collect device or behaviour data, the score is from the
    // internet connection alone. On a camera check the camera decides the
    // result, so the verdict can be Allow with a high riskScore.
    "riskScore":    12
  },

  // Failure context (populated when status === 'fail').
  // Human-readable strings for display/logging; do not parse.
  "failureReasons": [
    "Liveness below required threshold"
  ],

  // Findings (filled when the check found warning signs — present on
  // a pass too, useful for deciding what to do)
  "fraudSignals": [
    {
      "type":        "rapid_response",
      "severity":    "low" | "medium" | "high",
      "category":    "obvious" | "sophisticated",
      "description": "Response times under typical human range",
      "confidence":  0.32                 // 0-1
    }
  ],

  // Risk breakdown — null unless the risk breakdown is turned on for
  // the project (off for new projects). When on, gives you one 0-1
  // risk score plus a score per layer.
  "risk": {
    "overall":   0.18,                    // 0-1, higher = more risk
    "device":    0.15,                    // network + device-fingerprint signals
    "biometric": 0.20,                    // face / behavioral risk
    "contributions": null,                // filled only when you send
                                          // X-VH-Diagnostics and the key
                                          // has diagnostics_enabled
    "droppedSignals":  null,
    "degradedSignals": null
  },

  // Measured in the browser (for troubleshooting and display)
  "livenessScore":      0.92,             // local aggregate score
  "confidence":         0.92,             // confidence in the browser
  "challengesPassed":   3,                // verified challenges (0..N)
  "challengesTotal":    3,                // total presented
  "step":               1 | 2,            // 1=no challenges, 2=challenges shown
  "deviceId":           "<short hash>",   // local device signature
  "locale":             "en",
  "timestamp":          1779435547524,    // ms

  // On-page behaviour (always filled when the check collected it)
  "behavioralSignals": {
    "mouseScore":             0.80,
    "keyboardScore":          0.00,
    "scrollScore":            0.00,
    "touchScore":             0.00,
    "signalCount":            42,
    "deviceConsistencyScore": 0.85,
    "lieCount":               0
  },

  // Optional (only present when the check was started with
  // useDeepfakeDetection: true)
  "deepfake"?: {
    "score":              0.12,           // 0-1, higher = likely spoof
    "verdict":            "ok" | "suspicious" | "flagged",
    "flags":              [],
    "frameCount":         18,
    "hasSufficientData":  true
  },

  // v0.5.12+ — age and gender estimate (face-api.js), confirmed by our
  // server. ONLY present when the server received and saved it. See the
  // "Demographics + glasses" section below.
  "demographics"?: {
    "ageEstimate":      28.6,
    "ageRange":         { "min": 25, "max": 32 },
    "gender":           "male" | "female" | "unknown",
    "genderConfidence": 0.88,
    "fitzpatrickType":  3,
    "skinLuminance":    0.62
  },
  "glassesDetected"?: true,

  // v0.5.24+ — simple yes/no flags, ALWAYS set by our server (even on
  // a pass). Let you act on a check your project set to "report only"
  // (e.g. uniqueness): a returning real person passes, but isDuplicate
  // is true.
  "isDuplicate"?:   true,                 // took part before
  "lowQuality"?:    false,                // capture quality was low
  "highFraudRisk"?: false,                // a high-risk finding
                                          // v1.25.1+: a device-only check
                                          // (camera blocked or missing) NEVER
                                          // sets this. Missing evidence is not
                                          // evidence of fraud.

  // v1.25.1+ — WHY the camera check failed, confirmed by our server.
  // Present only when the camera check failed; for troubleshooting ONLY,
  // never a result. Lets you tell "no camera on the device" from "the
  // respondent refused" from "the integration broke" afterwards.
  "cameraFailureReason"?: "no_face_capture",  // | "unknown_capability"
                                              // | "mediapipe_load_failed"
                                              // | a DOMException name
                                              //   (e.g. "NotAllowedError")
  "duplicateMatch"?: {                    // present when isDuplicate (pass OR fail)
    "fingerprintId": "fp_…",
    "similarity":    1.0,
    "sessionId":     "sess_prior",
    "respondentId":  "rsp_…"
  },
  // v0.5.27+ — RETURNING participant: an earlier match AFTER the
  // project's waiting period, so it did NOT block. Present on a clean
  // pass (e.g. a monthly tracker letting someone back). Not the same as
  // duplicateMatch (an earlier match within the period, which blocks).
  "priorParticipation"?: {
    "fingerprintId": "fp_…",
    "similarity":    0.98,
    "sessionId":     "sess_last_wave",
    "respondentId":  "rsp_…",
    "lastSeenAt":    1800000000          // unix ts of the prior match
  },
  // Which level of check ran — act on WHAT happened, not just pass/fail.
  // assuranceLevel: how strong the check that ran was.
  //   biometric    — camera check of a live person who hasn't taken part
  //                  before (on the page, in the phone's browser, or via
  //                  the QR code). The most reliable result.
  //   device_only  — no camera; device and on-page behaviour only (weaker).
  //   none         — nothing could be checked.
  "assuranceLevel"?: "biometric",
  // verificationOutcome: WHY — separates "wouldn't" from "couldn't".
  //   completed            — finished with a result.
  //   unverified_technical — the app or browser blocked the camera (e.g. an
  //                          app's built-in browser). Says nothing about
  //                          whether they are human.
  //   declined_by_user     — the respondent was offered a way to verify (QR /
  //                          open in browser) and chose to skip. A choice, not
  //                          a failure — they still complete the survey.
  "verificationOutcome"?: "completed",
  // v1.16.2+ — our server's single answer: allow/review/block plus the
  // level of check that produced it. Sent on every check; use this when
  // you want ONE answer instead of working it out from the scores above.
  //   verdict         — "allow" | "review" | "block".
  //   assuranceLevel  — "none" | "device_only" | "behavioral" | "biometric".
  //                     Note the extra "behavioral" level — this list is
  //                     DIFFERENT from the top-level assuranceLevel above.
  //   riskScore       — v1.30.1+: the same 0-100 score as scores.riskScore.
  "unifiedVerdict"?: {
    "verdict":        "allow",
    "assuranceLevel": "biometric",
    "riskScore":      12
  },
  // v1.30.0+ — Adaptive projects (/docs/modes#adaptive): when the camera
  // step could not run, status is "fail" (no token) and verdict is
  // "review" (or "block", per the project). The SDK runs the camera step
  // itself; you receive one result per respondent.
  // v1.28.2+ — "When the camera check can't finish" fields (confirmed by our
  // server, in every option). See /docs/modes#verdict-policy for the table.
  //   verdictPolicy      — "default" (use the camera result as it is) |
  //                        "camera_pilot" (fall back to the silent check):
  //                        the project's setting when this result was made.
  //   cameraStageOutcome — how the CAMERA check ended: "passed",
  //                        "failed_liveness", "failed_challenge",
  //                        "failed_fraud_signal", "not_run_permission_denied",
  //                        "not_run_environment_blocked", "not_run_no_camera",
  //                        "not_run_camera_in_use", "not_run_model_load_failed",
  //                        "not_run_no_face", "not_run_timeout",
  //                        "not_run_unknown", "declined", "not_attempted".
  //   decisionPath       — what produced the FINAL status: "camera",
  //                        "lite_fallback", "camera_fraud_signal",
  //                        "degraded_mode", "policy_gate", "floor_tighten",
  //                        "invisible_floor".
  "verdictPolicy"?:      "default",
  "cameraStageOutcome"?: "passed",
  "decisionPath"?:       "camera"
}

// On a REJECTED check the SDK throws VerifyError instead.
// err.context carries the same fields from our server:
{
  // err.context (VerifyErrorContext, v0.5.9+)
  "scores":         { ... },                // same shape as above
  "failureReasons": ["Duplicate detected (similarity: 1.00)"],
  "fraudSignals":   [ ... ],
  "risk":           null,                   // shape per the success-case block above
  "sessionId":      "sess_failed_id",
  "duplicateMatch": {                       // ← only when rejected as a repeat
    "fingerprintId": "fp_abc...",
    "similarity":    1.00,
    "sessionId":     "sess_prior_match",
    "respondentId":  "rsp_prior_match"
  },
  // v1.30.1+ — Review or Block, with the 0-100 score, so you can tell them
  // apart without reading the message.
  "unifiedVerdict": { "verdict": "review", "assuranceLevel": "behavioral", "riskScore": 65 }
}

In-app browsers (Instagram, Facebook, Telegram…). Many apps' built-in browsers block the camera. The SDK handles this automatically — the respondent can finish on their phone with a QR code (giving biometric), or continue without the camera (labelled device_only) — and is never stuck. See In-app browsers & the camera for how it works, how to handle it yourself with onCameraBlocked, and how to require a minimum level of check.

Deciding what to do in the browser. A recommended order of checks before you save the survey response or grant access:

const result = await VH.mount(el, { siteKey });

// 1. Our server's result (NEVER trust the browser alone)
if (result.status !== 'pass') {
  return showError(result.failureReasons?.[0] ?? 'Verification failed');
}

// 2. Individual scores (uniqueness, authenticity, risk)
// scores.liveness and scores.uniqueness are null when that check did not
// run (a device-only / camera-blocked pass) — compare only real numbers,
// and use assuranceLevel to decide how to treat "not measured".
if (typeof result.scores?.uniqueness === 'number' &&
    result.scores.uniqueness < 0.5) {
  return routeToManualReview('Possible duplicate');
}
if (result.risk && result.risk.overall > 0.7) {
  return routeToManualReview('Elevated risk score');
}

// 3. Send the token to YOUR backend → /token/verify, which gives
// the final confirmation (signature and session)
const verified = await fetch('/your-backend/verify-vh-token', {
  method: 'POST',
  body: JSON.stringify({ token: result.token }),
}).then(r => r.json());

if (!verified.ok) {
  // server-side rejected even though client got status:pass — should
  // be rare but always re-check before granting access.
  return showError('Verification could not be confirmed');
}

acceptSurveyResponse();

Who counts as a repeat, and data collectors (v0.5.27+)

By default a respondent may take part once per project. You can change who counts as a repeat for each project in Settings → Deduplication scope: check repeats within the project or across a group of projects, optionally separately for each data collector (use a collector for a wave or an ongoing tracker), and set a waiting period (1 week … 1 year, or forever) — how long an earlier match keeps blocking a respondent. After that they're allowed back and show as a returning participant, not a repeat.

The SDK's only job is to pass the optional dataCollector label on the session; everything else is a project setting. On a pass, a respondent who came back after the waiting period appears as result.priorParticipation.

const result = await VH.mount(el, {
  siteKey: 'YOUR_PROJECT_ID',
  respondentId: 'panel-resp-123',
  dataCollector: 'interviewer-7',   // or a wave id, field site, etc.
});

// Greet a returning respondent (allowed back after the waiting
// period) without treating them as a blocked repeat.
if (result.status === 'pass' && result.priorParticipation) {
  showWelcomeBack(result.priorParticipation.respondentId);
}

Error codes (v0.5.9+)

Every check our server rejects throws a VerifyError with a specific code and a context with the same fields you'd see on a passing VHMountResult. Use err.code to decide what to show, without reading the message.

import { VerifyError, ErrorCode } from '@verifyhuman/sdk';

try {
  const result = await VH.mount(el, { siteKey });
  // ... handle success
} catch (err) {
  if (!(err instanceof VerifyError)) {
    throw err;
  }
  switch (err.code) {
    case ErrorCode.DUPLICATE_DETECTED:
      // Respondent is HUMAN but already participated in this study.
      // err.message: "You have already participated in this study"
      // err.context.duplicateMatch carries the prior session/respondent.
      showAlreadyParticipated(err.context?.duplicateMatch);
      break;
    case ErrorCode.LIVENESS_FAILED:
      // The camera check and challenges weren't convincing enough.
      // Worth offering a retry with tips (better lighting, no glasses).
      offerRetry(err.context?.failureReasons);
      break;
    case ErrorCode.AUTHENTICITY_FAILED:
      // A screen, photo or virtual camera — usually screen out.
      showError('Please use a real camera, not a screen recording');
      break;
    case ErrorCode.FRAUD_SIGNAL:
      // A high-risk finding.
      flagForManualReview(err.context?.fraudSignals);
      break;
    case ErrorCode.NETWORK_ERROR:
    case ErrorCode.TIMEOUT:
      // A connection problem, not a result — safe to retry.
      offerRetryWithBackoff();
      break;
    default:
      showError(err.message);
  }
}

The full VerifyErrorContext shape (always set when the check was rejected, undefined on connection errors):

interface VerifyErrorContext {
  scores?: { liveness: number; uniqueness: number;
             authenticity: number; overall: number };
  failureReasons?: string[];
  fraudSignals?: Array<{ type: string; severity: string;
                         category: string; description: string;
                         confidence: number }>;
  risk?: { overall: number; device: number; biometric: number };
  sessionId?: string;
  duplicateMatch?: {
    fingerprintId: string;
    similarity: number;
    sessionId?: string;        // ← matched PRIOR session
    respondentId?: string;     // ← matched PRIOR respondent
  };
}

Finding the earlier respondent (v0.5.10+)

When a respondent fails with DUPLICATE_DETECTED, our server tells you which earlier session and respondent they matched. Use it to look them up in your own respondent records without asking us again.

if (err.code === ErrorCode.DUPLICATE_DETECTED) {
  const match = err.context?.duplicateMatch;
  if (match?.respondentId) {
    flagForManualReview({
      currentSession: err.context.sessionId,
      existingRespondent: match.respondentId,    // ← from your DB
      existingSession: match.sessionId,
      similarity: match.similarity,              // 1.0 = exact
    });
  }
  showAlreadyParticipated();
}

match.respondentId and match.sessionId may be undefined for older records saved before the SDK started sending them; match.fingerprintId and match.similarity are always present.

Retries, with a limit per project (v0.5.4+)

Pass the failed session's ID as previousSessionId when you start the retry, so our server applies your project's maxRetries (set via PATCH /api/projects/{id} with {"config":{"maxRetries":3}}, range 0–10, default 3).

const result = await VH.mount(el, { siteKey });

if (result.status === 'fail' && allowRetry) {
  try {
    const retry = await VH.mount(el, {
      siteKey,
      previousSessionId: result.sessionId,  // ← counts towards the retry limit
    });
    // ... handle retry verdict
  } catch (err) {
    // The server's code is on err.context.serverCode (recommended);
    // it is also in err.message, for older code.
    if (err.context?.serverCode === 'RETRY_BUDGET_EXHAUSTED') {
      // Limit reached (a permanent 409 — err.recoverable is false).
      // Screen the respondent out.
      showFinalRejection();
    }
  }
}

Resilience when VerifyHuman is unreachable (v0.5.12+)

When /public/verify can't be reached (a network error, a time-out, or a 5xx error after retries), the SDK applies the project's outage setting (degradedMode) instead of rejecting everyone. Set it for each project in the dashboard or with PUT /api/v1/projects/{id} with {"config":{"degradedMode":"FAIL_CLOSED"}}:

SettingWhat happens during an outageUse when
FAIL_CLOSEDThe VH.mount promise rejects with VerifyError. The same default as reCAPTCHA and Stripe.Surveys where fraud is costly; panels with high rewards.
FAIL_OPEN_FLAGGED (default)Resolves with result.degraded === true, degradedReason set, the status from the check in the browser, and vh_unreachable in risk.degradedSignals.The default for new projects — respondents keep getting through during a VerifyHuman outage, and you can send the flagged results to manual review.
FAIL_OPEN_TRUSTEDResolves like a normal pass; only risk.degradedSignals shows the difference. No degraded flag.Low-stakes uses where a smooth experience matters more than knowing which results were unchecked.

You can override it for one check with VH.mount(el, { siteKey, degradedMode: "FAIL_CLOSED" }). If /public-config can't be reached either, the SDK uses FAIL_OPEN_FLAGGED rather than turning the outage into a rejection. A real failed check (status: fail with scores) ALWAYS throws an error with a code — the outage setting only applies when our server truly can't be reached.

const result = await VH.mount(el, { siteKey });

if (result.degraded) {
  // VerifyHuman couldn't be reached; this result comes from the
  // check in the BROWSER, not from our server. The reason code is
  // in result.risk.degradedSignals.
  logIncident({
    sessionId:       result.sessionId,
    degradedReason:  result.degradedReason,
    degradedSignals: result.risk?.degradedSignals,
  });
  routeToManualReview(result);
} else {
  // A normal pass confirmed by our server
  acceptRespondent(result);
}

When the camera check can't finish (v1.28.2+)

degradedMode covers VerifyHuman can't be reached. A different question is what decides when the camera check itself can't finish (permission denied, blocked by an app's browser, no face, face model failed to load, or a failed liveness check or challenge). That is the project's setting When the camera check can't finish (verdictPolicy), read from /public-config when the check starts, together with pilotDiagnostics. With Use the camera result as it is (default) nothing changes. With Fall back to the silent check (camera_pilot) the camera result stands when the camera works, and a fake camera is blocked by default — a virtual camera, screen replay or photo in front of a working camera returns status: "fail" with decisionPath: "camera_fraud_signal", whatever the silent check says. Only a camera check that can't finish falls back to the silent check, which blocks only when it says block (decisionPath: "lite_fallback"). The result always carries verdictPolicy, cameraStageOutcome and decisionPath (see the shape above), so you can tell a camera pass from a fake-camera block from a silent-check fallback. The full table is on /docs/modes. When pilotDiagnostics is failed_camera_sessions the SDK also sends a limited number of its own browser log lines (no images, no face data) for camera checks that did not pass, so camera problems can be traced by device and browser.

Camera unavailable vs. no face in view (v1.28.9+; click record from v1.28.10)

A camera that delivers video but never shows a face (a covered or black lens, nobody in front of it) is a failed liveness check: the result is a normal camera result (verificationOutcome: "completed", cameraStageOutcome: "failed_liveness", status: "fail" for projects that pass or fail; a liveness_fail finding for projects that return a score). The outage setting (degradedMode) never applies.

A camera that delivers no usable video (permission denied, no camera, the stream never starts, the model cannot load) shows the respondent "We can't access your camera" with two choices: Try again (re-requests the camera) and My camera isn't working. Only that click sends the session — with cameraUnavailableDeclared: true and cameraDiagnostics (error name, permission state, camera count, frames shown, browser, number of tries) — and only then does the project's outage setting apply (FAIL_OPEN_FLAGGED → pass, flagged, verificationOutcome: "unverified_technical", no token; FAIL_CLOSED → fail). A respondent who closes the page instead submits nothing. The result carries degradedSignals: ["camera_unavailable_declared"]; a camera failure sent without that click (an older SDK, a direct API call) never passes and carries "camera_unavailable_undeclared".

v1.28.10: an unanswered browser permission prompt shows "Please allow camera access to continue", and a camera that stops during the check (turned off, unplugged, permission revoked) shows "Your camera stopped" — both with the same Try again / My camera isn't working choices, never a result. A covered camera (video still arriving, no face) is still a failed liveness check. The click is recorded only from a real click on My camera isn't working and carries cameraDiagnostics.declaredBy: "user_click" plus declaredAt; a cameraUnavailableDeclared without that marker is treated as undeclared (not passed, "camera_unavailable_undeclared").

Age, gender and glasses (v0.5.12+) — confirmed by our server

Every camera check runs face-api.js MLDemographicsAnalyzer in the browser and notices whether the respondent wears glasses. The values travel like this:

  1. The check estimates them in the browser (age, gender, skin tone, glasses).
  2. The SDK sends them in the /public/verify request.
  3. Our server checks and saves them to verification_logs.demographics + verification_logs.glasses_detected.
  4. Our server returns the saved values on the verify response.
  5. The SDK fills VHMountResult.demographics and VHMountResult.glassesDetected from what the server returned.

This means a value present on result.demographicsis confirmed by our server — we have it on file, not just the browser. If our server couldn't read it, the field is left undefined on the result, which tells you the data didn't get through.

// Shape shown on VHMountResult AND on the dashboard detail page
{
  "demographics": {
    "ageEstimate":      28.6,
    "ageRange":         { "min": 25, "max": 32 },
    "gender":           "male" | "female" | "unknown",
    "genderConfidence": 0.88,
    "fitzpatrickType":  3,        // optional, 1-6
    "skinLuminance":    0.62      // optional, 0-1
  },
  "glassesDetected": true          // boolean; absent when not captured
}

undefined when: the check was device-only (no camera), face-api.js failed to load (CDN blocked or slow), our server rejected the values, or you use an SDK older than v0.5.12. Always check the typeof before reading the fields inside.

Three result shapes — read carefully

The word “status” appears on three different objects with slightly different values. Reading the wrong one is the second most common integration mistake (after the three kinds of mode).

WhereFieldValues
VH.mount() resolves withresult.status'pass' | 'fail'
onResult(dispatch) for projects that return a scoredispatch.status'pass' | 'fail' | 'scored' | 'score_blocked'
Backend POST /token/verify responsebody.status'pass' | 'fail' | 'review'

To ask “did the check pass?” in the browser, use result.status === 'pass' on what VH.mount() returns. To enforce it, check result.token with /token/verify on your backend and trust THAT response's status — scores in the browser can be tampered with after the callback runs.

What the script adds to window:

  • window.VH.mount(selector, opts) — starts the check and sends it to our server; returns a Promise <VHMountResult>. This is the one to use.
  • window.VH.session(opts) — start a session without showing the check; gives you a handle to control it.
  • window.VH.verifyToken(jwt) — checks a redirect token (Ed25519) in the browser using our public keys (JWKS).
  • window.VerifyHuman.init() — look for data-sitekey elements on the page again.
  • window.VerifyHuman.createVerifyWidget(config) — older check that runs only in the browser; nothing is sent to our server. For offline demos only; not for live use.

Page events (listen instead of a callback)

The HTML snippet sends CustomEvents, so you can react to the result with an event listener instead of a named data-callback function. Each event's data is on event.detail. Add the listener before the script runs (or on a parent element that stays on the page, since the events bubble).

EventSent whenSent fromevent.detail
vh:verifieda data-sitekey check finishes successfullythe mounted element (bubbles)the VHMountResult
vh:errora data-sitekey check fails or hits a setup problemthe mounted element (bubbles)the Error / VerifyError
vh:integrity-erroranother script took over window.VH before the SDK could protect it (a sign of tampering)document (bubbles)the thrown integrity error
document.addEventListener('vh:verified', (e) => {
  const result = e.detail;          // VHMountResult
  if (result.status === 'pass') submitSurvey(result.token);
});
document.addEventListener('vh:error', (e) => {
  console.warn('VerifyHuman failed', e.detail);
});

3. React

import { VerifyHuman } from '@verifyhuman/sdk/react';

function MyForm() {
  const handleVerify = (token, result) => {
    fetch('/api/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token }),
    });
  };

  return (
    <VerifyHuman
      siteKey="YOUR_PROJECT_ID"
      studyId="YOUR_STUDY_ID"
      respondentId="YOUR_RESPONDENT_ID"
      metadata={{ panel: 'prolific' }}
      widgetMode="standard"       // 'invisible' | 'captcha' | 'standard'
      onVerify={handleVerify}
      onError={(err) => console.error(err)}
    />
  );
}

4. Next.js

The same React component, with 'use client' at the top of the file. Works with the App Router and the Pages Router.

5. Plain TypeScript

import { VH } from '@verifyhuman/sdk';

const result = await VH.mount('#verify-container', {
  siteKey: 'YOUR_PROJECT_ID',
  studyId: 'YOUR_STUDY_ID',
  respondentId: 'YOUR_RESPONDENT_ID',
});

// result.sessionId, result.token, result.scores, ...

Three layers of “mode” — read carefully

The word “mode” is used in three different places. Mixing them up is the most common cause of integration confusion.

KindWhere it's setValuesWhat it controls
1. Protection level (project setting)Dashboard → Project → Settings (or config.widgetMode via PATCH /projects/{id})invisible (Silent) / captcha (Quick camera check) / standard (Full camera check)What runs. Sets how strong the check is. The SDK reads the project setting from /projects/{siteKey}/public-config when the check starts, and the widgetMode option or React prop overrides it when you pass one (opts.widgetMode ?? publicConfig.widgetMode). The HTML snippet has no data-widget-mode attribute, so a plain data-sitekey snippet uses the project setting only.
2. LookSDK option: VH.mount(el, { widgetStyle: 'classic' })classic / friendlyAppearance only. Doesn't change which checks run.
3. Internal switchesInside the SDKtrue / falseOverride. Normally follow the protection level. Use them only to force a particular screen while testing.

Protection levels (kind 1)

  • Silent (invisible) — nothing on screen; device and on-page behaviour only. No camera.
  • Quick camera check (captcha) — one click, then a quick camera liveness check of a few seconds.
  • Full camera check (standard) — a full camera check with liveness checks. The strongest proof.

Looks (kind 2)

  • classic — minimal monochrome theme.
  • friendly — softer colours and a larger button. Default when the avatar style is friendly.

Pass/fail or a score

The project's verificationMode (dashboard → Settings, or PATCH /projects/{id}) chooses between BLOCK (pass or fail) and SCORE (a score you act on). With SCORE, the SDK options you set decide how you receive it.

  • BLOCK — the check passes or fails on the page; your onVerify callback gets the token and you send it to your backend for /token/verify. The default.
  • SCORE — callback — nobody is stopped on the page; the SDK fetches a signed score from /public/sessions/{id}/result and calls your onResult(dispatch). Use this when you want to decide in the browser before submitting.
  • SCORE — redirect — our server sends the respondent on to a signed redirect URL. Check the vh_token in it with our public keys (JWKS); this works without calling us.

SCORE — what the callback receives

The onResult callback receives a VHResultDispatch:

{
  mode: 'score',
  status: 'pass' | 'fail' | 'review',
  envelope: {
    sessionId: 'sess_abc123',
    status: 'pass' | 'fail' | 'review',
    scores: {
      liveness: 0.95,
      uniqueness: 0.98,
      authenticity: 0.92,
      overall: 0.95,
      risk_overall: 0.08      // optional; inverse of overall
    },
    studyId: 'study_001',
    respondentId: 'resp_456',
    metadata: { panel: 'prolific' },
    failureReasons: [],        // populated when status != 'pass'
    token: 'eyJhbGc...'        // signed JWT; pass to /token/verify
                               // for server-side validation
  },
  redirectUrl: null,            // populated only when SCORE-redirect is configured
  reason: null
}

Two scores to act on, two distinct uses:

  • In the browser — read envelope.scores directly. The SDK has already checked the signature, so the scores can be trusted on the current page. Use them for decisions on the spot (“score < 0.3 → screen out without submitting”).
  • On your server — send envelope.token to your backend and call /token/verify. Needed before you trust a submission — scores in the browser can be tampered with after the callback runs.

See Status tiers in the API reference for how status is computed per signal.

Check the token on your server

Whichever way you add the SDK, check the token on your server before you grant access to protected content:

POST https://vhuman.riwi.com/api/v1/token/verify
X-API-Key: YOUR_SECRET_KEY
Content-Type: application/json

{ "token": "TOKEN_FROM_CLIENT" }

# →
{
  "success": true,
  "sessionId": "sess_abc123",
  "status": "pass",
  "scores": { "liveness": 0.95, "uniqueness": 0.98, ... },
  "studyId": "study_001",
  "respondentId": "resp_456"
}

Batch up to 100 tokens via POST /token/verify-batch — see the API reference.

Page script — per-question observer (v1.17.1)

The page script (vh-lite.iife.js, global VHLite) is the small (under 15 KB) script with nothing on screen that Server API projects add to the page to collect device and on-page behaviour. From v1.17.1 it also offers VHLite.startQuestionObserver(target, options) — the same per-question observer as the full SDK — so you can observe single questions without loading the full SDK. It listens quietly on a question element and returns a handle you read and then stop; question text never leaves the browser unless you turn on captureText.

<script src="https://vhuman.riwi.com/sdk/lite/v1/vh-lite.iife.js"
        data-project-key="YOUR_PROJECT_ID" defer></script>

// After the script loads:
const obs = VHLite.startQuestionObserver('#q1', { questionId: 'q1' });
// ... respondent answers Q1 ...
const snapshot = obs.getSnapshot();
//  → { questionId, timeOnQuestionMs, mouseMoveCount, keyCount,
//      pasteCount, focusEntries, focusExits, ... }
obs.stop(); // idempotent

// Send the snapshot, with the page script's sessionId, in your Server API score call.

Auto-run data-* attributes

When the page script loads with a data-project-key on its <script> tag, it runs once by itself — no JavaScript to write. The script tag reads four attributes:

AttributeRequiredMeaning
data-project-keyyesyour Project ID; without it, the script does nothing
data-session-idnoyour session ID, so the page data is linked to it; a random ID (UUID) is made when you leave it out
data-endpointnosend the page data to a different address (default: our production servers)
data-timeout-msnohow long to wait when sending the page data, in milliseconds
data-latency-wait-msnov1.28.12 and later. Only for a page that moves on as soon as vh-lite:probe is sent and is scored once: if the connection-timing reading isn't ready yet, wait for it before sending that event, up to this many milliseconds (suggested 1500). A page that already has the reading doesn't wait. Default 0 — never waits.

When it has finished, the script sends a vh-lite:probe CustomEvent on document (bubbles), with { sessionId, sent } on event.detail — so a listener can pick up the sessionId (possibly the random one) and send it in your Server API score call.

<script src="https://vhuman.riwi.com/sdk/lite/v1/vh-lite.iife.js"
        data-project-key="YOUR_PROJECT_ID"
        data-session-id="your-session-id"
        data-timeout-ms="5000" defer></script>

document.addEventListener('vh-lite:probe', (e) => {
  const { sessionId, sent } = e.detail;   // sent=false on network failure
});

For project-specific snippets

Sign in to the dashboard, open your project and click Integrate. That page shows snippets already filled in with your Project ID, allowed domains and your project's protection level.

More settings

For project thresholds, retries and stepping up to a stronger check, the SCORE result shape, and your own branding, see the configuration guide in the public repo.