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:
| Variable | Where | What for |
|---|---|---|
NEXT_PUBLIC_VH_SITE_KEY | Next.js / Vite / browser bundle | Public 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 / CRA | The 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_KEY | Server / backend only | Secret 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-integrationsAs 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.production → https://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:;| Directive | Why the SDK needs it |
|---|---|
connect-src https://vhuman.riwi.com | All 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.net | The 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:
| Prop | Type | What 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. |
autoStart | boolean (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. |
escalateOnSuspicion | boolean (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. |
useDeepfakeDetection | boolean | Phase 3 opt-in. Runs an additional deepfake detector on the sampled frames. Adds <50ms typical; produces result.deepfake on the mount. |
previousSessionId | string | Used 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/dynamicis 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 yournext.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"}}:
| Mode | Behavior on outage | Use when |
|---|---|---|
FAIL_CLOSED | The 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_TRUSTED | Resolves 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:
- Widget detects locally (age, gender, skin-tone, glasses).
- SDK forwards them in the
/public/verifyenvelope. - Server validates and persists to
verification_logs.demographics+verification_logs.glasses_detected. - Server echoes the persisted values back on the verify response.
- SDK populates
VHMountResult.demographics+VHMountResult.glassesDetectedfrom 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).
| Where | Field | Values |
|---|---|---|
VH.mount() resolves with | result.status | 'pass' | 'fail' |
onResult(dispatch) in SCORE mode | dispatch.status | 'pass' | 'fail' | 'scored' | 'score_blocked' |
Backend POST /token/verify response | body.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 fordata-sitekeyelements.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).
| Event | Fires on | Dispatched from | event.detail |
|---|---|---|---|
vh:verified | a data-sitekey mount resolves successfully | the mounted element (bubbles) | the VHMountResult |
vh:error | a data-sitekey mount rejects | the mounted element (bubbles) | the Error / VerifyError |
vh:integrity-error | another 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.
| Layer | Where it's set | Values | What it controls |
|---|---|---|---|
| 1. Server widget policy | Dashboard → Project → Settings (or config.widgetMode via PATCH /projects/{id}) | invisible / captcha / standard | Behavior. 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 variant | SDK option: VH.mount(el, { widgetStyle: 'classic' }) | classic / friendly | Appearance. Cosmetic theme variant. Doesn't change which checks run. |
| 3. Low-level gates | SDK-internal gates | booleans | Override. 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 isfriendly.
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
onVerifycallback 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}/resultand fires youronResult(dispatch). Use this when you want client-side decisions before submitting. - SCORE — redirect — server-signed redirect URL handoff. Validate the embedded
vh_tokenclient-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.scoresdirectly. 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.tokento 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:
| Attribute | Required | Meaning |
|---|---|---|
data-project-key | yes | your VH-Lite project key (pk_…); absent ⇒ no-op |
data-session-id | no | your session id to join the probe to; auto-generated (UUID) when omitted |
data-endpoint | no | override the probe ingest endpoint (defaults to VH prod) |
data-timeout-ms | no | network 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.