SDK

Embed the verify widget on a page. Four integration paths, identical verification behavior — pick what fits your stack. See SDK integration contracts for the canonical comparison + invariants every path holds. All paths are server-integrated as of v0.5.1 (2026-05-21) — every path (HTML drop-in, programmatic VH.mount, and the React component) submits to /public/verify before resolving, producing a server-signed JWT, a verification_logs row, and a webhook delivery. v0.5.3 (2026-05-22) extends the resolved VH.mount result with the full server decision payload: per-component scores (liveness, uniqueness, authenticity, overall), failureReasons, structured fraudSignals, and the Track A risk breakdown. v0.5.4 adds per-project maxRetries enforcement on the public flow. v0.5.9 ships typed error codes (DUPLICATE_DETECTED, LIVENESS_FAILED, AUTHENTICITY_FAILED, FRAUD_SIGNAL) on every rejected verification. v0.5.10 surfaces duplicateMatch (matched prior session + respondent id) when a dedupe rejection fires. v0.5.12 adds a circuit breaker for VH-outage resilience and server-echoed demographics + glassesDetected on the mount result. v0.5.13–0.5.15 make the SDK honor every per-project setting from the dashboard (widget mode, theme, style, avatar, interaction, and white-label branding) and fix a silent 404 that broke framework-bundled integrations if you use Next.js / Vite / Webpack / CRA, read that section first. v0.5.16 stops the widget from flashing a premature ✓ before the server confirms (it now shows “Verifying with server…” until the verdict is in). v0.5.17 turns a Content-Security-Policy that blocks the API host into an actionable error naming the exact directives to add (instead of an opaque “Failed to fetch”). v0.5.18 surfaces the server's full error envelope — a rejected verification now names the exact field instead of [object Object]. v0.5.19 normalizes demographics.skinLuminance to the [0,1] the server requires (it was sent raw 0-255, 422-ing every camera-path verification with a sampled face). v0.5.20 dedups the ~15MB ML-model download across mounts, moves the optional demographics models off the first-paint critical path (no data loss), and preconnects to the model CDN. v0.5.21 makes a blocked or busy camera surface a distinct, actionable message (“Camera access needed”) instead of a misleading “Verification failed” — a camera-permission problem is no longer indistinguishable from a real liveness verdict. v0.5.22 auto-recovers from a blocked camera: grant permission via the browser's address-bar control and the widget restarts verification on its own — no second click. v0.5.23 fixes thesmile and raise eyebrows challenges (the neutral-confirmation gate could deadlock on jittery cameras so a real person never passed them) and adds an actionable hint when a challenge can't be read (lighting / a bigger expression). v0.5.24 adds per-signal enforcement (block vs report-only): a duplicate is no longer treated as “not a human” — set uniqueness to report-only and a returning real human passes with isDuplicate + duplicateMatch on the result so you can show “welcome back”. v0.5.25 auto-recovers from a rare degenerate face-detector state (a collapsed landmark mesh that froze the widget and timed out every challenge) by rebuilding the detector in-flow instead of forcing a retry. v0.5.26 adds an optional linkId that the VH Hosted Verification page forwards on session create, so survey-platform integrations can associate a session with a stored verification link. v1.16.2 surfaces the server's unifiedVerdict (a single allow/review/block disposition + the assurance tier that produced it) on the mount result — previously the server emitted it on every verification but the SDK dropped it, so you had to re-derive the disposition from the per-signal scores. See Full VHMountResult shape below. v1.17.1 exposes VHLite.startQuestionObserver(...) on the VH-Lite probe bundle — the per-question observer was previously a full-SDK-only export, so per-question integrations loading only vh-lite.iife.js could not attach it.

Install

npm: npm install @verifyhuman/sdk (current version 1.27.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 bundlePublic site key for client-side VH.mount / <VerifyHuman>. Safe to embed in the build because origin validation gates abuse.
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. Only optional for the CDN <script> drop-in, where the SDK auto-detects it from the script origin.
VH_API_KEYServer / backend onlySecret vf_live_* / vf_test_* key. NEVER expose to the browser. Pair with GET /api/v1/api-keys/current at boot to self-discover the configured project_id instead of hardcoding it.
// 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 single most common first-integration failure, and the symptom is confusing: local liveness runs perfectly (you see the challenges pass with full scores), then every server call 404s and the widget reports “Verification failed” — and nothing shows up in your VerifyHuman dashboard.

Why this happens

The CDN <script> drop-in auto-detects the API base URL from the origin of the script tag that loaded it (https://vhuman.riwi.com/... → API at https://vhuman.riwi.com/api/v1). When the SDK is instead bundled into your app, the only script origin it can see at load time is your bundle's URL — e.g. http://localhost:3100/_next/static/chunks/… in dev, or your production domain. Auto-detect can't reach the VerifyHuman API from there, so the SDK falls back to the production endpoint and emits a one-time console warning:

[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

As of v0.5.15 the fallback keeps the SDK working against production even if you forget the prop — but you should still set it explicitly so you (a) control which environment (prod vs. staging vs. self-hosted) you talk to, and (b) never depend on the fallback silently pointing at production.

The canonical pattern

'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.productionhttps://vhuman.riwi.com/api/v1). The ?? 'https://vhuman.riwi.com/api/v1' fallback in the prop keeps local builds working before the env var is wired.

Verifying it works

Open DevTools → Network and complete a verification. You should see POST /api/v1/public/sessions and POST /api/v1/public/verify hitting vhuman.riwi.com (or your configured host) — not your own dev-server origin. A successful verification then appears in your project's Verifications log in the dashboard. If those calls are 404ing against localhost or your app domain, apiEndpoint isn't set.

The CDN drop-in (an actual <script> tag pointed at vhuman.riwi.com) does not need this — auto-detect works there. This section is specifically for the bundled npm install path.

Content Security Policy

If your site sends a Content-Security-Policy header (and a security-conscious site should), you must allow-list the hosts and capabilities the SDK uses — otherwise the browser silently blocks them. This is the most common failure after framework bundling: local liveness runs with full scores, then the API call dies with E030: Network error: Failed to fetch and nothing reaches your dashboard. The browser refuses the request before it leaves the page — so it never appears as a 404 or a CORS error, just a blocked connect-src in the console.

As of v0.5.17 the SDK detects a CSP block and replaces the opaque error with the exact directives to add — but you can configure them up front. The required set:

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 API calls — /public/sessions, /public/verify, /public-config, /origin-check. Use your own host here if you pass a custom apiEndpoint.
connect-src https://cdn.jsdelivr.netThe face-detection model weights + manifests are fetched from jsDelivr.
script-src https://cdn.jsdelivr.net 'wasm-unsafe-eval'MediaPipe loads its loader/runtime JS from jsDelivr and compiles a WebAssembly module — both blocked without these.
worker-src blob:MediaPipe / face-api spin up Web Workers from blob: URLs for off-main-thread inference.
img-src data: blob: · media-src blob:The camera preview + per-frame canvas captures are blob: / data: URIs. (No image is ever uploaded — these are local-only.)

Note: the model-CDN hosts (cdn.jsdelivr.net) are where the open-source MediaPipe + face-api assets are served from today. If you mirror them on your own CDN, point these directives there instead. The API host is the only VerifyHuman-owned origin in the list.

Widget props reference

Beyond siteKey / apiKey, every path (HTML drop-in, VH.mount, React component) accepts the same configuration knobs. The full set:

PropTypeWhat it does
widgetMode'invisible' | 'captcha' | 'standard'Verification flow. standard shows full camera + challenges; captcha is a lighter UX; invisible uses passive signals only. Overrides the project default.
autoStartboolean (default false)Begin verifying on mount instead of waiting for the respondent to click. You do not need this for invisible — that mode has no control to click, so it always starts on its own (SDK 1.20.4+). Use it only to opt captcha / standard out of requiring a click; note that the click is where those modes surface the consent disclosure, so if you set this you are responsible for obtaining consent yourself.
escalateOnSuspicionboolean (default false)Smooth-mode escalation — only affects invisible. Off by default: a low/suspicious passive score hard-fails and the camera is never opened. When true, a passing passive score still never opens the camera, but a sub-pass score escalates to a hidden-camera liveness + challenge check instead of failing — so a genuine human who looked borderline on passive signals gets a second, definitive proof. Can also be set per-project server-side.
interaction
(React prop: interactionType; HTML: data-interaction)
'checkbox' | 'slider'How the user triggers the verify flow. The key differs by path: data-interaction on the HTML drop-in, 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'Visual treatment — classic geometric vs. softer rounded avatar.
avatarStyle'friendly' | 'minimal' | 'neon'Avatar visual style during challenges.
useDeepfakeDetectionbooleanPhase 3 opt-in. Runs an additional deepfake detector on the sampled frames. Adds <50ms typical; produces result.deepfake on the mount.
previousSessionIdstringUsed on retry mounts — see retry pattern.
degradedMode'FAIL_CLOSED' | 'FAIL_OPEN_FLAGGED' | 'FAIL_OPEN_TRUSTED'Per-mount override of the project policy — see degraded mode.

Common patterns

Verify once, skip the gate next time (cookie)

Set a short-lived cookie on successful verification so repeat visits skip the widget 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 a real defence-in-depth flow your server should also call POST /api/v1/token/verify with the cookie value periodically — the signed JWT survives token forgery attempts in a way a bare cookie does not.

Skip-ahead after N failures

Pair the typed error codes with a per-respondent retry counter to gate manual review.

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 a stale .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'. next/dynamic is unnecessary — the SDK guards its own browser-only code.
  • "Critical dependency: require function is used..." pointing at face-api.esm.js — non-fatal webpack warning from the optional demographics analyzer. To silence, add the following to your next.config.js:
    module.exports = {
      webpack: (config) => {
        config.ignoreWarnings = [
          ...(config.ignoreWarnings ?? []),
          { module: /@vladmandic\/face-api/ },
        ];
        return config;
      },
    };

API endpoint resolution

The SDK auto-detects the API base URL 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. Mirror the bundle on your own CDN and the SDK uses your domain's /api/v1 path (if you also reverse- proxy the API there).

Two ways to override the detected default:

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

Customer-passed apiEndpoint always wins over the auto-detected default — useful when you serve the SDK from one CDN but route API calls through your own backend.

Integration paths

1. HTML drop-in (auto-init)

Zero JS. The script auto-discovers elements with data-sitekey on page load and mounts a server-integrated widget. Use data-study-id / data-respondent-id / data-metadata for cross-reference; the values echo back on token / webhook responses.

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

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

<script>
  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,
      }),
    });
  }
</script>

2. HTML + JS API

Programmatic mount when the auto-init element isn't present in your DOM at load time. The CDN bundle installs the mount surface at window.VH (note: not window.VerifyHuman.VH — that's a stale reference that doesn't exist). window.VH.mount(...) returns a Promise that resolves with the verification result envelope.

<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_SITE_KEY',
      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 resolved VH.mount() Promise carries. Stable shape — populate from this in your integration; new fields land additively, never breaking changes within the v1 major.

// What you get from:  await VH.mount(el, { siteKey: '...' })
{
  // Core (always present)
  "status":     "pass" | "fail",          // server verdict — canonical
  "sessionId":  "sess_<24 chars>",        // mint a unique key on
  "token":      "eyJ...JWT...",           // forward to /token/verify
  "success":    true,                     // legacy boolean alias of status

  // Per-component server scores (always on pass; on fail when
  // the decision engine ran 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                  // weighted composite
  },

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

  // Structured fraud signals (populated when decision engine flagged
  // anti-fraud signals — present on PASS too, useful for routing)
  "fraudSignals": [
    {
      "type":        "rapid_response",
      "severity":    "low" | "medium" | "high",
      "category":    "obvious" | "sophisticated",
      "description": "Response times under typical human range",
      "confidence":  0.32                 // 0-1
    }
  ],

  // Unified-signal Track A risk breakdown — null when the
  // v2 risk aggregator is in shadow mode (default for new projects).
  // When live, gives you a single 0-1 risk score + per-tier breakdown.
  // Device-layer signals include impossible_travel (slice 3.x,
  // 2026-05-28): same device fingerprint surfacing in different
  // countries within a short window. Conservative starting weight
  // 0.5 — tunable per-project on the Fraud Detection settings page.
  "risk": {
    "overall":   0.18,                    // 0-1, higher = more risk
    "device":    0.15,                    // network + device-fingerprint signals
    "biometric": 0.20,                    // face / behavioral risk
    "contributions": null,                // populated only with
                                          // X-VH-Diagnostics + key-level
                                          // diagnostics_enabled
    "droppedSignals":  null,
    "degradedSignals": null
  },

  // Local widget telemetry (kept for diagnostics + UX)
  "livenessScore":      0.92,             // local aggregate score
  "confidence":         0.92,             // widget confidence
  "challengesPassed":   3,                // verified challenges (0..N)
  "challengesTotal":    3,                // total presented
  "step":               1 | 2,            // 1=passive, 2=challenges fired
  "deviceId":           "<short hash>",   // local device signature
  "locale":             "en",
  "timestamp":          1779435547524,    // ms

  // Behavioral signals (always populated when widget collected them)
  "behavioralSignals": {
    "mouseScore":             0.80,
    "keyboardScore":          0.00,
    "scrollScore":            0.00,
    "touchScore":             0.00,
    "signalCount":            42,
    "deviceConsistencyScore": 0.85,
    "lieCount":               0
  },

  // Phase 3 / P3-S3-D2 opt-in (only present when widget was
  // constructed with useDeepfakeDetection: true)
  "deepfake"?: {
    "score":              0.12,           // 0-1, higher = likely spoof
    "verdict":            "ok" | "suspicious" | "flagged",
    "flags":              [],
    "frameCount":         18,
    "hasSufficientData":  true
  },

  // v0.5.12+ — server-confirmed face-api.js detection. ONLY present
  // when the server received + persisted the value. 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+ (F-CRIT-67) — per-signal flat flags, ALWAYS set by a
  // current server (even on a pass), sourced from the server response.
  // Let you act on a signal your project set to "report only" (e.g.
  // uniqueness): a returning real human passes, but isDuplicate is true.
  "isDuplicate"?:   true,                 // matched a prior fingerprint
  "lowQuality"?:    false,                // capture/evidence quality low
  "highFraudRisk"?: false,                // HIGH-severity fraud signal
                                          // v1.25.1+ (F-CRIT-130): a device-only
                                          // session (camera blocked/unavailable)
                                          // NEVER sets this. It never measures
                                          // liveness, and missing evidence is not
                                          // evidence of fraud.

  // v1.25.1+ (F-CRIT-130) — WHY the camera path failed, echoed by the server.
  // Present only when the camera path failed; diagnostic ONLY, never a verdict.
  // Lets you tell "no camera on the device" from "the user refused" from
  // "the integration broke" after the fact.
  "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: a prior match that fell OUTSIDE the
  // project's re-eligibility window, so it did NOT block. Present on a
  // clean pass (e.g. a monthly tracker letting someone back). Distinct
  // from duplicateMatch (the in-window, blocking match).
  "priorParticipation"?: {
    "fingerprintId": "fp_…",
    "similarity":    0.98,
    "sessionId":     "sess_last_wave",
    "respondentId":  "rsp_…",
    "lastSeenAt":    1800000000          // unix ts of the prior match
  },
  // Assurance taxonomy — screen on WHAT happened, not just pass/fail.
  // assuranceLevel: how strong the verification that ran was.
  //   biometric    — camera liveness + biometric dedup (in-context, broke out,
  //                  or via the cross-device QR handoff). The trustworthy verdict.
  //   device_only  — no camera; behavioral + device signals only (weaker).
  //   none         — no verification evidence.
  "assuranceLevel"?: "biometric",
  // verificationOutcome: WHY — separates "wouldn't" from "couldn't".
  //   completed            — ran to a verdict.
  //   unverified_technical — camera blocked by the environment (e.g. an in-app
  //                          WebView). Not a humanness judgment.
  //   declined_by_user     — the respondent was offered a way to verify (QR /
  //                          open-in-browser) and chose to skip. A decision, not
  //                          a failure — they still complete the survey.
  "verificationOutcome"?: "completed",
  // v1.16.2+ — the server's UNIFIED verdict: a single allow/review/block
  // disposition + the assurance TIER that produced it. Emitted on every
  // verification; screen on this when you want ONE server disposition instead
  // of re-deriving it from the per-signal scores above. Undefined against
  // servers predating the field.
  //   verdict         — "allow" | "review" | "block".
  //   assuranceLevel  — "none" | "device_only" | "behavioral" | "biometric".
  //                     Note the extra "behavioral" tier — this is a DISTINCT
  //                     taxonomy from the top-level assuranceLevel above.
  "unifiedVerdict"?: {
    "verdict":        "allow",
    "assuranceLevel": "biometric"
  }
}

// On REJECTED verifications the SDK throws VerifyError instead.
// err.context carries the equivalent server payload:
{
  // 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 on dedupe failures
    "fingerprintId": "fp_abc...",
    "similarity":    1.00,
    "sessionId":     "sess_prior_match",
    "respondentId":  "rsp_prior_match"
  }
}

In-app browsers (Instagram, Facebook, Telegram…). Many WebViews block the camera. The SDK handles this automatically — a cross-device QR handoff to reach biometric, or a labeled device_only fallback — and never dead-ends the respondent. See In-app browsers & the camera for the behavior, the onCameraBlocked opt-out, and the minimum-assurance policy.

Client-side routing pattern. A canonical decision tree before posting your survey response / granting access:

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

// 1. Server-verified gate (NEVER trust local-only)
if (result.status !== 'pass') {
  return showError(result.failureReasons?.[0] ?? 'Verification failed');
}

// 2. Per-component routing (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. Forward token to YOUR backend → /token/verify for the
// authoritative cross-check (signatures, session-store)
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();

Deduplication scope & data collectors (v0.5.27+)

By default a respondent is unique within a project. You can change who counts as a duplicate per project in Settings → Deduplication scope: scope to the project or a cross-project dedup group, optionally partition per data collector (use a collector as a wave or a continuous tracker), and set a re-eligibility window (1 week … 1 year, or forever) — how long a prior match keeps blocking a respondent. After the window they're allowed back and surface as a returning participant rather than a duplicate.

The SDK's only job is to pass the optional dataCollector label on the session; everything else is server-side project config. On a pass, a respondent who returned after the window appears as result.priorParticipation.

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

// Greet a returning respondent (allowed back after the re-eligibility
// window) without treating them as a blocked duplicate.
if (result.status === 'pass' && result.priorParticipation) {
  showWelcomeBack(result.priorParticipation.respondentId);
}

Typed error codes (v0.5.9+)

Every server-rejected verification throws a VerifyError with a specific code and a structured context carrying the same fields you'd see on a passing VHMountResult. Switch on err.code to drive UX without parsing 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:
      // Passive + challenge signals weren't strong enough.
      // Worth offering a retry with tips (better lighting, no glasses).
      offerRetry(err.context?.failureReasons);
      break;
    case ErrorCode.AUTHENTICITY_FAILED:
      // Screen / photo / virtual-camera flagged — usually screen-out.
      showError('Please use a real camera, not a screen recording');
      break;
    case ErrorCode.FRAUD_SIGNAL:
      // Anti-fraud detector fired high-severity.
      flagForManualReview(err.context?.fraudSignals);
      break;
    case ErrorCode.NETWORK_ERROR:
    case ErrorCode.TIMEOUT:
      // Transport-level issue, not a verdict — retry-safe.
      offerRetryWithBackoff();
      break;
    default:
      showError(err.message);
  }
}

The full VerifyErrorContext shape (always defined on verdict-level errors, undefined on transport 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
  };
}

Cross-referencing duplicates (v0.5.10+)

When a respondent fails with DUPLICATE_DETECTED, the server tells you which prior session/respondent they matched. Use this to cross-reference against your own respondent table without re-querying our dedupe store.

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 fingerprints written before the SDK started passing them; match.fingerprintId and match.similarity are always present.

Retry pattern with per-project cap (v0.5.4+)

Pass the prior failed session's id as previousSessionId on the retry mount so the server enforces 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,  // ← enables the retry budget
    });
    // ... handle retry verdict
  } catch (err) {
    // The raw server code is on err.context.serverCode (recommended);
    // the token is also folded into err.message for back-compat.
    if (err.context?.serverCode === 'RETRY_BUDGET_EXHAUSTED') {
      // Cap hit (a permanent 409 — err.recoverable is false).
      // Screen the respondent out.
      showFinalRejection();
    }
  }
}

Resilience when VerifyHuman is unreachable (v0.5.12+)

When /public/verify is unreachable (network / timeout / 5xx after retries), the SDK applies the project's degradedMode policy instead of rejecting indiscriminately. The policy is set per-project via the dashboard or PATCH /api/projects/{id} with {"config":{"degradedMode":"FAIL_CLOSED"}}:

ModeBehavior on outageUse when
FAIL_CLOSEDThe mount promise rejects with VerifyError. Aligned with reCAPTCHA / Stripe defaults.Fraud-sensitive surveys; high-incentive panels.
FAIL_OPEN_FLAGGED (default)Resolves with result.degraded === true, degradedReason set, status from local widget verdict, risk.degradedSignals contains vh_unreachable.Default for new projects — preserves traffic during VH outages; integrator can route flagged results to manual review.
FAIL_OPEN_TRUSTEDResolves like a normal pass; only risk.degradedSignals distinguishes it. No degraded flag.Lowest-stakes use cases where UX trumps observability.

Caller can override per-mount via VH.mount(el, { siteKey, degradedMode: "FAIL_CLOSED" }). If /public-config itself is unreachable, the SDK defaults to FAIL_OPEN_FLAGGED rather than cascading the outage into a hard reject. Verdict failures (status:fail with real scores) ALWAYS throw typed errors — the circuit breaker only fires on true transport failures.

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

if (result.degraded) {
  // VerifyHuman was unreachable; the verdict here is based on
  // the widget's LOCAL liveness call, not the server's decision
  // engine. The result.risk.degradedSignals carries the trigger
  // code.
  logIncident({
    sessionId:       result.sessionId,
    degradedReason:  result.degradedReason,
    degradedSignals: result.risk?.degradedSignals,
  });
  routeToManualReview(result);
} else {
  // Normal server-verified pass
  acceptRespondent(result);
}

Demographics + glasses (v0.5.12+) — server-echoed

Every camera-path verification runs face-api.js MLDemographicsAnalyzer client-side and detects whether the user is wearing glasses. Both values flow through the platform end-to-end:

  1. Widget detects locally (age, gender, skin-tone, glasses).
  2. SDK forwards them in the /public/verify envelope.
  3. Server validates and persists to verification_logs.demographics + verification_logs.glasses_detected.
  4. Server echoes the persisted values back on the verify response.
  5. SDK populates VHMountResult.demographics + VHMountResult.glassesDetected from the server's echo.

This means a value present on result.demographicsis a server-confirmed record — the platform has it on file, not just the widget. A server-side parse failure leaves the field undefined on the result, which is the integrator's signal that the data didn't make it through.

// Shape surfaced on VHMountResult AND in the dashboard detail view
{
  "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 verification used device-only mode (no camera), face-api.js failed to load (CDN blocked or slow), the server rejected the envelope shape, or the integrator is on a pre-v0.5.12 SDK. Always check the typeof before reading sub-fields.

Three result shapes — read carefully

The word “status” appears on three different objects with slightly different enums. Picking the wrong one is the #2 integration footgun (after the three modes).

WhereFieldValues
VH.mount() resolves withresult.status'pass' | 'fail'
onResult(dispatch) in SCORE modedispatch.status'pass' | 'fail' | 'scored' | 'score_blocked'
Backend POST /token/verify responsebody.status'pass' | 'fail' | 'review'

For the canonical “did the widget pass?” check use result.status === 'pass' on the mount return. For server-side enforcement, validate result.token via /token/verify on your backend and trust THAT response's status — the SDK-side scores can be tampered with after the callback fires.

Globals exposed by the UMD bundle:

  • window.VH.mount(selector, opts) — server-integrated mount; returns a Promise <VHMountResult>. This is the recommended customer surface.
  • window.VH.session(opts) — start a session without auto-mounting; gives you the lifecycle handle.
  • window.VH.verifyToken(jwt) — JWKS-backed client-side token verifier (Ed25519 redirect tokens).
  • window.VerifyHuman.init() — re-run the auto-discovery scan for data-sitekey elements.
  • window.VerifyHuman.createVerifyWidget(config) — legacy local-only widget; produces no server session. Available for offline demos; not the recommended production path.

DOM events (listener-based integration)

The auto-init HTML drop-in bubbles CustomEvents so a listener-based integration can react to the verdict without wiring a named global data-callback. Each event's payload is on event.detail. Attach the listener before the bundle runs (or on a stable ancestor, since the events bubble).

EventFires onDispatched fromevent.detail
vh:verifieda data-sitekey mount resolves successfullythe mounted element (bubbles)the VHMountResult
vh:errora data-sitekey mount rejectsthe mounted element (bubbles)the Error / VerifyError
vh:integrity-erroranother script squatted the window.VH global before the SDK could harden it (tamper signal)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_SITE_KEY"
      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

Same React component with the 'use client' directive at the top of the file. Works with the App Router and the Pages Router.

5. Vanilla TypeScript

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

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

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

Three layers of “mode” — read carefully

The word “mode” appears in three distinct places. Mixing them up is the #1 source of integration confusion.

LayerWhere it's setValuesWhat it controls
1. Server widget policyDashboard → Project → Settings (or config.widgetMode via PATCH /projects/{id})invisible / captcha / standardBehavior. Tells the widget how aggressively to verify. The SDK reads the project default from /projects/{siteKey}/public-config at mount time, and the widgetMode mount option / React prop overrides it when you pass one (opts.widgetMode ?? publicConfig.widgetMode). The HTML drop-in has no data-widget-mode attribute, so a pure data-sitekey embed takes the mode from the project config only.
2. SDK visual variantSDK option: VH.mount(el, { widgetStyle: 'classic' })classic / friendlyAppearance. Cosmetic theme variant. Doesn't change which checks run.
3. Low-level gatesSDK-internal gatesbooleansOverride. Normally driven by the server widget policy. Use only when you need to force a specific UI state for testing.

Server widget-policy values (Layer 1)

  • Invisible — no UI, behavioral + device signals only. Escalates to camera challenge if signals are weak.
  • CAPTCHA — one-click verify, escalates to camera only when needed.
  • Standard — full camera-driven flow with liveness checks. Strongest signal.

SDK visual-variant values (Layer 2)

  • classic — minimal monochrome theme.
  • friendly — softer colors + larger interaction target. Default when the avatar style is friendly.

Dispatch modes

The verificationMode on the project (dashboard → Settings, or via PATCH /projects/{id}) toggles between BLOCK and SCORE. Within SCORE mode, the SDK options you set determine which sub-flavor fires.

  • BLOCK — the widget gates pass / fail locally; your onVerify callback gets the JWT and you forward it to your backend for /token/verify. Default behavior.
  • SCORE — callback — non-blocking; the SDK fetches a signed score envelope from /public/sessions/{id}/result and fires your onResult(dispatch). Use this when you want client-side decisions before submitting.
  • SCORE — redirect — server-signed redirect URL handoff. Validate the embedded vh_token client-side via JWKS for offline verification.

SCORE — callback envelope shape

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:

  • Client-side logic — read envelope.scores directly. The SDK has already verified the signature, so the scores are trustworthy within the current page session. Use for immediate UX decisions (“score < 0.3 → screen out without submitting”).
  • Server-side enforcement — forward envelope.token to your backend and call /token/verify. Required for trust at submit time — client-side scores can be tampered with after the callback fires.

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

Server-side token verification

Whichever embed path you pick, validate the JWT server-side before granting access to your protected content. Pattern:

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.

VH-Lite probe — per-question observer (v1.17.1)

The lightweight VH-Lite probe (vh-lite.iife.js, global VHLite) is the zero-UI, sub-15KB script that server-side (fraud-gate) integrations load to collect device + micro-behavior signals. As of v1.17.1 it also exposes VHLite.startQuestionObserver(target, options) — the same per-question observer the full SDK ships — so a per-question integration that loads only the lite bundle can attach it without pulling in the full widget. It attaches passive, capture-phase listeners to a question element and returns a handle you snapshot and stop; question text never leaves the browser unless you opt in with captureText.

<script src="https://vhuman.riwi.com/sdk/lite/v1/vh-lite.iife.js"
        data-project-key="pk_..." 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

// Forward snapshot alongside the probe sessionId to your S2S score call.

Auto-run data-* attributes

When the lite bundle loads with a data-project-key on its <script> tag it auto-fires the probe once, no JS required. The script tag honors four attributes:

AttributeRequiredMeaning
data-project-keyyesyour VH-Lite project key (pk_…); absent ⇒ no-op
data-session-idnoyour session id to join the probe to; auto-generated (UUID) when omitted
data-endpointnooverride the probe ingest endpoint (defaults to VH prod)
data-timeout-msnonetwork timeout for the probe POST in milliseconds

On completion the auto-run dispatches a vh-lite:probe CustomEvent on document (bubbles), carrying { sessionId, sent } on event.detail — so a listener can capture the resolved (possibly auto-generated) sessionId to forward to your server-side score call.

<script src="https://vhuman.riwi.com/sdk/lite/v1/vh-lite.iife.js"
        data-project-key="pk_..."
        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, click Integrate. The page renders snippets pre-filled with your site key, allowed origins, and your project's configured widget mode.

Configuration nuances

For project-level thresholds, retry / mode escalation, the SCORE envelope shape, and custom branding, see the configuration guide in the public repo.