Server API

Complete REST reference for backend integration. Snippets below are runnable — replace placeholder values with your real keys. For dashboard-rendered snippets pinned to a specific project, sign in and visit your project's Integrate page.

This reference covers two backend surfaces:

  • Server-Side scoring (fraud_gate) — one server-to-server call per respondent, no page code, no camera. Where a legacy quality vendor (GRL / Research Defender) used to run. See Verification modes for how it fits the assurance ladder.
  • Widget token & admin — verify the signed JWT the SDK widget produces, provision projects, read analytics. Everything below the Server-Side section.

Base URL

https://vhuman.riwi.com/api/v1

Authentication

  • Public endpoints (/public/sessions, /public/verify, /public/projects/{id}/demo-key) — no API key. Origin is validated against the project's allowed-domains list.
  • Server-side endpoints (/token/verify, /token/verify-batch, /projects/provision, /projects/{id}/analytics) — send your secret API key in the X-API-Key header. Manage keys at your dashboard.

Two key tiers — project vs organization

VerifyHuman uses the same two-tier credential model as Stripe (Restricted Keys vs account secret), Twilio (scoped API Keys vs Subaccount Auth Token), Auth0 (Application keys vs Tenant Management), and SendGrid (restricted vs full-access). One tier is data-plane, the other is management.

TierPrefixVerifies tokensCan provision projectsWhere minted
Project (default)vf_live_* / vf_test_*This project onlyNo (403)Dashboard → Project → API Keys
Organizationvf_org_live_* / vf_org_test_*Any project in the orgYesDashboard → Org API Keys (owner-only)

Project keys (CAPTCHA-style)

Mint a project key for a single embed on a single site. It verifies only tokens minted by its own project — cross-project verify in the same org fails with error: "WRONG_PROJECT". This matches reCAPTCHA, hCaptcha, Turnstile, and Friendly Captcha per-site secret semantics: leak = one project at risk, not the whole org.

Organization keys (platform integrations)

Mint an org key when your backend creates VerifyHuman projects per study/tenant/panel, OR when you need ONE credential that verifies tokens across many projects. Org keys can call /projects/provision (project keys cannot — that endpoint returns 403 INSUFFICIENT_SCOPE). They also accept tokens from any project in the same org without the per-project match check.

Privilege bar: minting an org key is owner-only on the dashboard (stricter than project key minting, which allows admins). Treat the secret like a root credential — secrets manager, backend-only, rotate quarterly.

Expiry & rotation

Any key can carry an optional expiry. Pass expiresInDays (1–365) when you mint it (create body, or the expiry selector on the dashboard); omit it and the key never expires (the default). The absolute expiresAt is echoed on the key-list endpoints and on GET /api-keys/current. An expired key is rejected at the auth layer exactly like an invalid one — same INVALID_KEY / 401, no signal that the key ever existed.

Rotate in place without a redeploy window:

# Project key:
curl -X POST https://vhuman.riwi.com/api/v1/projects/{projectId}/api-keys/{keyId}/rotate \
  -H "X-API-Key: $VERIFYHUMAN_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "expiresInDays": 90 }'      # optional — inherits the old expiry when omitted

# Organization key:
curl -X POST https://vhuman.riwi.com/api/v1/organizations/{orgId}/api-keys/{keyId}/rotate \
  -H "X-API-Key: $VERIFYHUMAN_ORG_SECRET_KEY"

# → { "apiKey": "vf_live_…", "keyId": "…", "expiresAt": "2026-10-27T00:00:00Z", ... }
#   The replacement inherits the original scope, name, scopes, rate limits, and
#   test/live family; the NEW plaintext is returned exactly ONCE. The old key is
#   revoked immediately — overlap it in your secret store if you need zero-downtime.

Rotation is a write-tier action (RBAC: keys:write — admin+ on projects, owner on the org). A cross-org or unknown keyId returns nothing rotatable (no existence leak). On the dashboard, each API-key screen shows an Expires column (expired / expires-soon badges) and a Rotate action with copy-once reveal.

Raise a key's rate limit in place

Scaling up (e.g. a launch ramping from a sample to full fleet)? You can raise a key's perMinute / perDay ceilings without swapping the key — the secret is preserved, so a live server-to-server integration keeps running:

# Project key:
curl -X PATCH https://vhuman.riwi.com/api/v1/projects/{projectId}/api-keys/{keyId} \
  -H "X-API-Key: $VERIFYHUMAN_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "rateLimits": { "perMinute": 500, "perDay": 500000 } }'

# Organization key:
curl -X PATCH https://vhuman.riwi.com/api/v1/organizations/{orgId}/api-keys/{keyId} \
  -H "X-API-Key: $VERIFYHUMAN_ORG_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "rateLimits": { "perMinute": 500, "perDay": 500000 } }'

# → the response echoes the new  rateLimit: { perMinute, perDay }

A write-tier action (admin+ on projects, owner on the org), same as rotate. Only the ceilings change — scope, name, and the secret stay put (rotate for those). A cross-org or unknown keyId returns 404 (no existence leak). New limits take effect immediately on the next request.

Legacy keys (pre-2026-05-21)

Keys minted before the scope split are seeded as scope=organization at migration time so they keep verifying tokens across the whole org. No behavior change at deploy. You can continue using them indefinitely; we recommend migrating to scoped keys at your next rotation cycle for the operational benefits (per-property audit, independent revoke).

For OAuth-based integrations the credential is OAuth 2.1 client credentials, not an API key. See the MCP guide.

Server-Side scoring (fraud_gate)

A stateless server-to-server risk API you call once per respondent — no client JavaScript, no camera, no face data. It returns a 0–100 risk score and an allow / review / block recommendation from network, device, velocity, duplicate, behavior, and history signals. Use it when you can't put code on the respondent's page (panel / supply routing) or want the lightest possible integration. For the product overview and how it sits on the assurance ladder, see Verification modes → Server-Side.

Availability: Server-Side is enabled per organization. If POST /api/v1/lite/score returns 404, ask your VerifyHuman contact to enable it for your org.

POST /lite/score

Auth: your project API key as Authorization: Bearer vf_live_… (or X-API-Key). clientIp is required — VerifyHuman is not in the request path, so you pass the respondent's real IP.

FieldRequiredWhat it's for
sessionIdYesYour id for this scoring event; echoed back on the response.
clientIpYesThe respondent's real IP — you pass it; VH is not in the traffic path.
userAgentNoRespondent user-agent string; sharpens the device axis.
sourceId / subSourceIdNoSupplier / offerwall id (and sub-id) — the unit per-source thresholds condition on. Must be stable across sessions: it namespaces the velocity, duplicate and history axes. Never send the sessionId here — a per-session value puts every session in its own bucket, so repeat-taker detection can never fire.
studyIdNoThe questionnaire this session belongs to — not the supplier. It is the scope within which duplicate and velocity accumulate, so one project can host many studies without pooling their duplicates: a respondent legitimately taking two of your studies is not reported as a duplicate. Respondent history stays project-wide by design — a person's reputation follows the person across your studies. Must be stable across sessions; never send the sessionId here. Omit it and scoping falls back to sourceId, exactly as before this field existed.
declaredCountryNoISO-3166 alpha-2 country the supplier claims for the respondent.
respondentIdNoYour person id (panelist / appuser) — it identifies the human, not the visit. Must be stable across sessions: it drives the history axis, per-respondent velocity, and the multi-identity duplicate signal. Never send the sessionId here — “stable” means stable between visits, and a session id is stable only within one. If it equals the sessionId, every session is a brand-new person, so those axes can never accumulate; we detect it, ignore the respondent for scoring, and return respondent_id_equals_session_id in degradedSignals. Omit it entirely if you have no stable person id (e.g. programmatic traffic) — fully supported, no warning. Send the salted hash — see identity hashing.
ipPrefixHash, deviceHashNoSalted SHA-256 hashes of the IP-network prefix / your device id — see identity hashing. VH never receives raw identifiers.
respondentStatsNoOptional history bootstrap prior about this respondent (attempts/completes/reconciliation-rate), used only when native history is thin. Every field is per-respondent — never source-level aggregates. Formerly supplierStats; the old key is accepted for a deprecation window and returns supplier_stats_deprecated_alias in degradedSignals.
webBotAuthNoRFC 9421 / Ed25519 HTTP Message Signature material for a known, accountable automated agent (Web Bot Auth). When it verifies against a trusted key the verdict is classed verified_agent instead of automation.
POST https://vhuman.riwi.com/api/v1/lite/score
Authorization: Bearer $VERIFYHUMAN_API_KEY
Content-Type: application/json

{
  "sessionId":  "your-scoring-id",
  "clientIp":   "203.0.113.9",
  "userAgent":  "Mozilla/5.0 …",
  "sourceId":   "supplier_42",
  "studyId":    "study_2026_wave3",     // optional — per-study duplicate scope
  "respondentId": "<salted-sha256-hash>"
}

# → 200
{
  "score":          18,                 // RISK 0-100, higher = riskier. Excludes duplication.
  "duplicateScore": 0,                  // DUPLICATION 0-100 — INDEPENDENT of "score".
                                        //   Reported only; never moves risk,
                                        //   recommendation or grlCompat.
  "duplicate": {                         // the same axis, in an actionable shape
    "score": 0,                          //   same number as duplicateScore
    "band": "none",                      //   none | possible | likely | confirmed
    "measured": true,                    //   false => score/band mean nothing
    "reasons": [],                       //   the duplicate-axis reasons only
    "evidence": {                        //   null = NOT MEASURED, not zero
      "deviceSessionsDay": 1, "deviceRespondentsDay": 1,
      "respondentDay": 1, "respondentDevicesWeek": null
    }
  },
  "independence": {                      // SAMPLE INDEPENDENCE — note the INVERTED
    "score": 100,                        //   direction: 100 = fully independent.
    "band": "independent",               //   independent | shared | concentrated
    "grade": null,                       //   direct | linked, when concentrated
    "factors": []                        //   what moved it, in plain language
  },                                     //   Reported only; never moves risk.
  "declaredCountryMatch": null,          // ELIGIBILITY — does the declared country
                                         //   agree with the country seen from the
                                         //   address? true | false | null.
                                         //   null = NOT MEASURED (you sent no
                                         //   declaredCountry, or no geo resolution).
                                         //   null is NOT false. Reported only;
                                         //   never moves risk.
  "recommendation": "allow",            // allow | review | block | step_up — from RISK only
  "verdictClass":   "human",            // human | fraud | verified_agent | unknown
  "categories": {                        // six axes, each 0-100
    "network": 0, "device": 0, "velocity": 0,
    "duplicate": 0, "behavior": 0, "history": 18
  },
  "reasons": [                           // structured codes + severity
    { "code": "history_reconciliation", "severity": "low", "detail": "…",
      "scored": true }                   //   scored=false => it counted for NOTHING
  ],
  "evidence": {                          // the same findings, sorted by what they
    "summary": "…",                      //   can PROVE. Read this when you have to
    "against": [],                       //   argue about a respondent with someone
    "for":     [],                       //   else. A finding that contributed
    "notes":   []                        //   nothing to the score is never
  },                                     //   listed under "against".
  "degradedSignals": [],                 // signal sources that were unavailable
  "grlCompat":       { "forensicMaxScore": 0, "categories": { … } },
  "assuranceAvailable": "can_step_up_to_biometric",
  "stepUpIntensity": "none",             // none | light | heavy
  "sessionId":       "your-scoring-id",
  "receiptId":       "rcpt_…"            // signed audit reference
}

Read recommendation, enforce your own action. The bands are yours to set (see calibration). step_up appears only when the project opts into assurance step-up — it recommends escalating this respondent to the biometric widget flow rather than hard-blocking. grlCompat snaps max(categories) onto the legacy GRL forensic bands (0 / 50 / 75 / 100) so VerifyHuman drops into a legacy quality-vendor slot unchanged.

evidence — what each finding is entitled to prove

reasons[] lists every signal that fired, in one flat list where everything reads like an accusation. It cannot say that a finding exonerates a respondent, and it cannot say that a finding counted for nothing. evidence says both.

Each item carries a class — separate from severity, which only grades how alarming a contribution would be:

  • bound — impossible in a legitimate session, bounded by construction rather than by a tuned threshold. You may say “this cannot happen”.
  • record — already adjudicated by someone with authority (the buyer reversed payment). You may say “this happened”.
  • correlate — measured statistical association. You may say “elevated risk”, never “this is fraud”.
  • exculpatory — evidence in the respondent's favour.
  • diagnostic — about our own measurement, your integration, or a project policy. It says nothing about the respondent.

A finding that contributed nothing to score never appears in against — it appears in notes, phrased neutrally, with strength: "none". That is enforced on the contribution actually applied at scoring time, so it stays true when you calibrate a weight. Repeat participation lives in notes too: it is an eligibility question with its own object, and it never moves risk.

summary is always populated, including when there is nothing against the respondent — a clean respondent you cannot defend out loud is the reason this object exists. Every claim is a complete sentence written to be pasted into an email unedited.

Fetching the evidence again, later

GET /api/v1/lite/score/{receiptId}/evidence — same API key that scored the session.

The evidence object above ships on the score response, which means it is readable in that instant and not afterwards. Disputes are not instant. A partner challenges a respondent days later — “your tool cleared this person, ours flagged them” — and that is when you need the sentences we gave you. This route returns them.

Read provenance.faithful before you quote it. We do not keep a copy of the prose; the block is re-rendered from the inputs recorded with the score, so one source of truth owns the wording. The honest cost is that a re-render runs today's templates against an older session, so we tell you which you are holding: true means every finding is sorted here exactly as it was sorted for you — verified against the classification recorded at scoring time, not assumed. false means note explains what differs and the response you received at scoring time is the authoritative one.

A receipt that is not yours and a receipt that does not exist both return the same 404, so the route cannot be used to discover which receipt ids are real. It carries no axis weights, thresholds or fusion arithmetic.

duplicate — repeat participation

Has this person already taken this study? That is a question about eligibility, not fraud, so it is scored on its own axis and never moves score, recommendation, verdictClass or grlCompat. A legitimate repeat panellist is not a fraudster, and your re-eligibility window is yours to set.

Read band rather than the raw integer, so a future recalibration of the cut points does not silently change what your rule does. In evidence, a null count means not measured — it does not mean zero; a rule that treats the two the same reads “no repeat” out of “no data”.

Use it in conjunction, never alone. On its own the duplication score flags too many legitimate repeat panellists to be an enforcement instrument. Combined with risk it becomes the most precise rule available on this response. Measured against a panel partner's own rejection reasons across 56,613 rejections and 36,121 completions:

  • score >= 50 and duplicate.band of likely or confirmed — catches more than score >= 60 alone (8.7% vs 7.5% of rejections) at the same cost in wrongly-flagged completions (0.50%). This is the recommended rule.
  • score >= 60 with the same band condition — the tightest variant, if you would rather trade recall for precision.

Enforce in real time; sweep at end of day. Duplication is asymmetric in time — a respondent's first session is correctly not a duplicate, and the second is what carries the evidence. Blocking in real time therefore rejects the repeat and preserves the original, before you have paid an incentive or consumed quota. What real time cannot see is the near-simultaneous race: two sessions scored seconds apart, neither having yet observed the other. That is the end-of-day sweep's job — re-read duplicate across the day, group by respondent and device, and reconcile. Treat the sweep as a source of quality flags and clawbacks, not as a second enforcement gate. Any session scored measured: false belongs in the sweep rather than in your clean pile.

independence — the second score

A research buyer is not paying for 1,000 responses, they are paying for 1,000 independent opinions. independence answers that question directly, and it is deliberately separate from risk: it never moves score, recommendation, or grlCompat.

The direction is inverted relative to every other number in this response — 100 means fully independent and 0 means heavily concentrated. That is why it arrives as an object carrying a named band rather than a bare integer that could be mistaken for risk.

It falls when the device behind a response shares a connected group with other panellist accounts, or when the device's own history looks operated rather than used — accounts changing hands in seconds, a working day spanning most of the clock, accounts used once and never again. factors spells out in plain language whatever moved it.

grade is how much the finding rests on inference. direct means one device carried many accounts — no chaining, no assumption. linked means the group was connected through shared devices and survived a cohesion check. Groups that held together only through one shared machine are never scored at all: the honest finding there is about the machine, and sharing a computer is not evidence of anything.

This is a structural measure, not an accusation. A low score says these responses are not independent of each other; it does not say anyone did anything wrong.

Identity hashing (no PII to VerifyHuman)

The three optional identifiers are pre-hashed by you: sha256( utf8(salt) + "|" + utf8(value) ) (lowercase hex). VerifyHuman never receives the salt and cannot reverse the digest, but a seeded identity hashes to the same value at score time — so historical reputation warm-starts without ever exposing a raw panelist id, IP, or device id.

POST /api/v1/lite/events — evidence (closed learning loop)

Post-entry outcomes you already know — a survey complete, a speeder / DQ, a buyer reject, a reconciliation / clawback, a ban — become training labels that tighten future scores for the same supply. One request carries up to 1000 events across any number of sessions, so a reconciliation run posts as a single batch:

POST https://vhuman.riwi.com/api/v1/lite/events
Authorization: Bearer $VERIFYHUMAN_API_KEY
Content-Type: application/json

{
  "events": [
    { "sessionId": "sess-abc", "type": "complete",
      "occurredAt": "2026-07-28T12:00:00Z" },
    { "sessionId": "sess-def", "type": "buyer_reject",
      "occurredAt": "2026-07-28T13:00:00Z",
      "reasonClass": "fraud" }                 // fraud | fit | quality | unknown
  ]
}

# → { "received": 2, "accepted": 2, "duplicates": 0,
#     "rejected": 0, "sessions": 2 }

Idempotent per (sessionId, type, occurredAt), so re-posting a rolling window is safe — a replay comes back as duplicates, never double-counted. rejected is reported separately so a storage error can never look like a healthy replay. Send occurredAt as the time the outcome happened, not the upload time.

If you already emit outcomes one at a time, the per-session form POST /api/v1/lite/sessions/{sessionId}/events is unchanged (1–100 events, respondentId / reasonClass inside detail) and writes through the same idempotent path.

Event type herring_result · dq · speeder · buyer_reject · reconciliation · ban · quality_term · complete · custom. A buyer_reject is reason-aware: only reasonClass = "fraud" (duplicate / security) raises risk. A screenout / ineligible / quota-full reject is a real person who didn't match the audience — classed fit and never counted as fraud. Absent reason data ⇒ unknown ⇒ neutral.

On-page probe (<15KB) — strongly recommended

Server-Side can score from server signals alone, but the device and behavioral axes read nothing without the probe: a clean session then scores 0 on every axis and returns verdictClass: "unknown" with degradedSignals: ["client_probe_missing"] — which is "we measured nothing", not "clean". Load it on the page the respondent lands on, before you score. No camera, no UI:

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

Both attributes are required: the bundle auto-runs off data-project-key and is a silent no-op without it. Minting the id client-side instead? Call VHLite.run({ projectKey, sessionId }) — it resolves { sessionId, sent }.

The one rule: data-session-id must be byte-identical to the sessionId you send to /lite/score. The probe posts to POST /api/v1/public/lite/probe and is stored under (projectKey, sessionId), then joined at score time by that exact pair. If the two differ — a prefix, a case change, URL-encoding — the probe is stored and never found, and the score reports client_probe_missing exactly as if you had never installed it. Generate the id once, server-side, and use the same string in both places.

Calibration & thresholds

The recommendation bands and risk knobs live on the project's config.liteConfig block — set them in the dashboard or via PATCH /projects/{id}. Every field is optional and defaults to a global; an unset project scores on the VerifyHuman defaults. All are settable per source too (nest under liteConfig.perSource["<sourceId>"]):

KnobWhat it does
reviewLow / blockThresholdThe score band edges: below reviewLow → allow, at/above blockThreshold → block, between → review.
noProbeRiskScoreCapped risk weight for a missing probe (the no-signal cohort skews fraud). Capped below the block band — absence alone can never block.
stepUpEnabled, stepUpThreshold, stepUpIntensityThresholdOpt into the step_up recommendation and split it into light (captcha) vs heavy (phone/ID) via stepUpIntensity.
geoRiskWeightsPer-country (ISO-3166 alpha-2) multiplier on the network axis — weight by base rate, never a ban (bounded; a weighted lone geo signal still maxes at review).
dispositiveSignalsWhitelist a narrow set (confirmed_clawback, device_farm) allowed to block on a single confirmed signal. Empty by default.
newUserPostureneutral (default) or review_lean — nudge brand-new respondents (no native VH history) toward review. Capped below block.
verifiedAgentPolicyHow to treat a Web-Bot-Auth-verified agent: score_normally (default, tag only) · allow · review.

Threshold recommender. GET /api/v1/lite/threshold-recommendations returns fitted, per-source reviewLow / blockThreshold suggestions from your accumulated ground-truth events, with projected catch-rate / false-block-rate deltas. It's a suggestion — never auto-applied; a human reviews it against the current lines. POST /api/v1/lite/simulate previews the recommendation mix at hypothetical band edges before you commit.

The full field-level Server-Side reference (enum tables, identity-hashing recipe, the closed-loop learning contract, rate limits) is available to the LLM in your MCP client via the Server-Side tools, or from your VerifyHuman contact for panel/supply integrations.

Endpoint reference

POST /public/sessions

Create a verification session from a site key. The SDK calls this from the browser; you typically don't call it yourself unless you're building a custom widget.

Body fields: siteKey (required), studyId (optional), respondentId (optional, max 256 chars — your respondent identifier, echoed back on /token/verify and on webhook payloads for cross-reference), metadata (optional object).

curl -X POST https://vhuman.riwi.com/api/v1/public/sessions \
  -H "Content-Type: application/json" \
  -d '{
    "siteKey": "proj_abc123",
    "studyId": "study_001",
    "respondentId": "resp_456",
    "metadata": {"panel": "prolific"}
  }'

# → { "sessionId": "sess_...", "clientSecret": "...", "expiresAt": 1710853200, ... }

POST /public/verify

Submit the verification envelope. The SDK populates this body from local capture data. Returns a server-signed JWT in verificationToken that you can validate via /token/verify from your backend.

Required header: X-Client-Secret — the client secret returned by POST /public/sessions when the session was created. The request is rejected without it.

POST /token/verify

Server-side token validation. Required headers: X-API-Key: vf_live_….

curl -X POST https://vhuman.riwi.com/api/v1/token/verify \
  -H "X-API-Key: $VERIFYHUMAN_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"token": "TOKEN_FROM_CLIENT"}'

Success response:

{
  "success": true,
  "sessionId": "sess_abc123",
  "status": "pass",
  "scores": {
    "liveness": 0.95,       // null on device-only / camera-blocked passes (F-CRIT-112)
    "uniqueness": 0.98,
    "authenticity": 0.92,
    "overall": 0.95,
    "riskOverall": 0.08     // optional; populated when VH_RISK_AGGREGATOR_V2_ENABLED
  },
  "studyId": "study_001",
  "respondentId": "resp_456",
  "issuedAt": 1710849600,
  "expiresAt": 1710853200
}

Status values: "pass", "fail", or "review". See Status tiers below for how each is computed.

On failure, success: false with error ∈ { EXPIRED, INVALID, WRONG_AUDIENCE, WRONG_PROJECT, MALFORMED }. WRONG_PROJECT fires when a project-scope API key tries to verify a token minted by a different project.

riskOverall — what is it?

Optional [0, 1] float on the scores block; present only when the unified-signal risk aggregator is enabled (it's on by default in production). Semantics are inverse of overall:

  • riskOverall = 0.0 → clean, no red flags.
  • riskOverall = 1.0 → high-risk; multiple signals fired.

Use this for hard-block fences via scoreModeConfig.blockAboveRisk (see Project configuration above) — you decide the threshold for your fraud tolerance separately from the overall pass gate.

Status values (pass / fail / review)

Each signal score is compared against its per-signal threshold (set on customThresholds or via the Security Level preset). A signal passes when signal_score ≥ signal_threshold and fails otherwise.

Project-level status aggregates the per-signal outcomes:

  • All signals pass + overall ≥ overall_threshold status: "pass".
  • Any signal fails → status: "fail".
  • status: "review" applies in verificationMode = 'score' when the envelope is borderline (e.g. fraud signals trip without a hard fail). Customers route these to a manual queue.

Customers using verificationMode = 'block' see pass / fail only — the widget short-circuits any borderline outcome as fail. Customers using verificationMode = 'score' receive review verdicts explicitly so they can route to a manual queue.

POST /token/verify-batch (new — VH-05, 2026-05-19)

Validate up to 100 tokens in one round-trip. Useful for data-pipeline re-verification jobs where you've queued responses without a per-response server check at submit time.

curl -X POST https://vhuman.riwi.com/api/v1/token/verify-batch \
  -H "X-API-Key: $VERIFYHUMAN_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tokens": ["TOKEN_A", "TOKEN_B", "TOKEN_C"]}'

# → { "results": [ TokenVerifyResponse, TokenVerifyResponse, ... ] }

Each token is verified independently. Cross-org tokens land as success: false with WRONG_AUDIENCE rather than failing the whole batch.

POST /projects/provision (new — VH-01, 2026-05-19)

Server-to-server project creation. Lets you auto-provision a VerifyHuman project from your own backend when (e.g.) a researcher adds a verification step to a study, without the researcher ever opening the VH dashboard.

Auth: X-API-Key scoped to the calling organization.

One-shot alternative (recommended for new integrations): if you also need an API key for the new project (the common case — embedded SDK uses siteKey, your backend uses the api key for /token/verify), use the MCP tool provision_project_with_key instead. It creates the project AND mints a key in one OAuth-authed round-trip, with partial-success handling. See the MCP guide Server-to-server provisioning.

curl -X POST https://vhuman.riwi.com/api/v1/projects/provision \
  -H "X-API-Key: $VERIFYHUMAN_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Study",
    "description": "Pilot for fall cohort",
    "externalId": "your-study-id-here"
  }'

# → {
#   "projectId":   "proj_...",
#   "siteKey":     "proj_...",   // same value — use as SDK siteKey
#   "externalId":  "your-study-id-here",
#   "name":        "My Study",
#   "organizationId": "org_...",
#   "createdAt":   "2026-05-19T..."
# }

externalId is preserved on the project record for cross-reference. Signing material is bootstrapped server-side and not exposed by this endpoint — rotate it via POST /projects/{id}/signing-secret/rotate when you need the PSK.

Getting a project-scoped API key back (new — 2026-08-25)

POST /lite/score resolves its project from the API key — the request body carries no projectId. An organization-scoped key resolves to no project, so the probe posted to /public/lite/probe cannot join and the score comes back with measured: false and client_probe_missing. Pass issueApiKey: true to get a durable project-scoped key for the project you just provisioned:

curl -X POST https://vhuman.riwi.com/api/v1/projects/provision \
  -H "X-API-Key: $VERIFYHUMAN_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Study",
    "externalId": "your-study-id-here",
    "issueApiKey": true
  }'

# → {
#   "projectId":     "proj_...",
#   "siteKey":       "proj_...",
#   "apiKey":        "vf_live_...",  // returned ONCE — store it now
#   "apiKeyId":      "key_...",
#   "apiKeyPrefix":  "vf_live_abc12345",
#   "apiKeyIssued":  true
# }

The key is live, never expires, and is not a test or demo key. It is scoped to that one project: it can verify and score for it, and it cannot provision further projects or mint further keys — the org key you authenticated with keeps that authority.

Provisioning stays idempotent. Re-calling with the same externalId returns the existing project with HTTP 200 and does not mint a second key: you get the existing key's apiKeyId and apiKeyPrefix with apiKey: null and apiKeyIssued: false. Key secrets are stored only as a SHA-256 hash and can never be read back, so if you no longer hold the secret, re-call with rotateApiKey: true — that revokes the project's current key and returns a replacement. A project provisioned before this feature existed has no project-scoped key yet, so the first call with issueApiKey: true mints one.

Issuance is opt-in: without issueApiKey the response is unchanged and no key is created, so no caller receives a credential it did not ask for.

GET /projects/{id}/analytics (new — VH-08, 2026-05-19)

Server-to-server analytics for your project. Mirrors the dashboard's analytics page; useful for embedding metrics in customer-facing reports.

curl -G https://vhuman.riwi.com/api/v1/projects/$PROJECT_ID/analytics \
  -H "X-API-Key: $VERIFYHUMAN_SECRET_KEY" \
  --data-urlencode "period=day" \
  --data-urlencode "fromDate=2026-05-01" \
  --data-urlencode "toDate=2026-05-19"

# → {
#   "totalVerifications": 1234,
#   "passedCount": 1100,
#   "failedCount": 134,
#   "passRate": 0.892,        // 0–1 fraction
#   "avgLivenessScore": 0.86,
#   "avgUniquenessScore": 0.95,
#   "avgDurationMs": 4200,
#   "timeSeries": [...],
#   "failureReasons": [...],
#   "devices": [...],
#   "browsers": [...]
# }

GET /api-keys/current (new — F-CRIT-29, 2026-05-22)

Self-introspection for an API key: returns the key id, scope, project (when scoped), test/demo flags, and rate limit for the key on the request. Lets integrators self-discover what project a vf_live_* key is configured for, without round-tripping through the dashboard.

Auth: X-API-Key or Authorization: Bearer .... 401 on missing / invalid / expired / revoked keys.

curl https://vhuman.riwi.com/api/v1/api-keys/current \
  -H "X-API-Key: $VERIFYHUMAN_API_KEY"

# Project-scoped key response:
{
  "keyId":              "0bce...c7e3",
  "organizationId":     "org-uuid",
  "scope":              "project",
  "projectId":          "1cbb84c8-b00a-4230-867c-e9d0dee03d03",
  "projectName":        "CoolTool main panel",
  "isTest":             false,
  "isDemo":             false,
  "diagnosticsEnabled": true,
  "scopes":             ["verifications:write", "analytics:read"],
  "rateLimit":          { "perMinute": 60, "perDay": 50000 }
}

# Organization-scoped key response:
{
  "keyId":              "...",
  "organizationId":     "org-uuid",
  "scope":              "organization",
  "projectId":          null,
  "projectName":        null,
  ...
}

Use cases:

  • Customer backend code self-discovers its project_id at boot — pair with NEXT_PUBLIC_VH_SITE_KEY on the client to avoid hardcoding the UUID twice.
  • AI agents using the hosted MCP can answer “what project am I using?” without dashboard hunting.
  • Pre-flight sanity check before shipping — refuse to start if isTest === true in production env.

POST /sessions/{id}/retry (new — VH-07, 2026-05-21)

Provision a fresh session as a retry of a failed parent. Useful when a respondent fails marginally and you want to give them one more attempt before screening them out.

Auth: X-API-Key on the same organization that owns the parent session. Wrong-org callers get 404 (no existence leak).

curl -X POST https://vhuman.riwi.com/api/v1/sessions/$PARENT_SESSION_ID/retry \
  -H "X-API-Key: $VERIFYHUMAN_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

# Success — same shape as POST /sessions:
# {
#   "sessionId": "sess_abc...",
#   "clientSecret": "...",
#   "expiresAt": 1710853200,
#   "challengeConfig": { ... },
#   "studyConfig": { ... }
# }

Refused with 409 NOT_RETRYABLE when:

  • The parent session is not yet completed.
  • The parent did not end in failure (no point retrying a pass).
  • The retry budget would exceed config.maxRetries on the project (default 3).

Retry chain bookkeeping: the new session's metadata carries vh_original_session_id (root of the chain) and vh_retry_count (1-indexed; first retry is 1). Use these to trace a respondent's attempts across your audit log + the verification webhook payload.

Mode escalation: the new session uses the project's current widgetMode setting. If you want to escalate to a stronger mode for the retry (e.g., captchastandard), PATCH the project first then call retry. A per-call mode override may ship in a future revision.

POST /public/projects/{id}/demo-key

Mint a short-lived test key for integration trials. Rate-limited (10/min soft). Demo verifications are tracked in a separate is_demo bucket and don't bill against production quota.

Score envelope

Scores in scores are 0–1 floats. The four core scores are always present on camera-path passes:

  • liveness — passive liveness confidence (higher = more likely a live person). null on device-only / camera-blocked passes (no camera ran, F-CRIT-112) — null-guard before any arithmetic or threshold comparison.
  • uniqueness — how distinct this respondent is from others already seen in the same study. 1.0 = highly unique, 0.0 = exact duplicate. Also null when no biometric uniqueness comparison ran (a device-only submission, uniqueness disabled for the mode, or a preview session) — it is never a synthesized 1.0 for a check that did not happen, so null-guard it exactly like liveness.
  • authenticity — confidence the capture came from a real camera rather than a replayed or synthetic source.
  • overall — weighted aggregate driving the status verdict. Default weights liveness×0.40 + uniqueness×0.30 + authenticity×0.30.

Two additional per-signal scores are optional — present on newer responses, omitted on older ones and on the v1 webhook — so null-guard before reading them:

  • quality — capture / evidence quality (higher = better).
  • fraud — risk score (0.0 = clean, 1.0 = fraud-like).

Project configuration

Every project has a config block + a few top-level knobs that govern verification behavior. The same fields are accepted by POST /projects (dashboard JWT auth) and POST /projects/provision (X-API-Key auth) on create, and PATCH /projects/{id} on update. Each field maps 1:1 to a dashboard control.

Core verification

FieldTypeDefaultWhat it controls
verificationMode'block' | 'score'blockDecision flow. block = widget gates pass / fail locally. score = SDK fires onResult with the envelope and your backend decides.
config.widgetMode'invisible' | 'captcha' | 'standard'standardServer widget policy. Tells the SDK which UI to render and which signals to require. (See /docs/sdk for the three-layer mode model.)
config.challengeEnabledbooleantrueWhether the SDK may escalate to a camera challenge if passive signals are weak.
config.maxRetriesinteger (0–10)3Maximum number of retry attempts a respondent gets before the session goes terminal-fail.

Thresholds & security levels

Two ways to set thresholds:

  1. Per-signal thresholds on the config block — config.livenessThreshold (default 0.65) and config.uniquenessThreshold (default 0.85). A single pass gate per signal.
  2. Full custom thresholds on the top-level customThresholds object — sets all four decision-engine thresholds at once: liveness, uniqueness, authenticity, screenDetection.
{
  "customThresholds": {
    "liveness": 0.75,
    "uniqueness": 0.90,
    "authenticity": 0.80,
    "screenDetection": 0.55
  }
}

The dashboard's “Security Level” selector is a UI shortcut that writes customThresholds for you. Approximate values for each preset (subject to tuning):

PresetLivenessUniquenessAuthenticityScreen detectionUse for
Relaxed0.400.700.550.45Brand studies, low fraud risk
Balanced (default)0.650.850.700.60Most consumer research
Strict0.750.900.800.70Clinical, high-incentive panels
Maximum0.850.950.900.80Identity-critical (highest false-negative)

Setting customThresholds via API bypasses the preset selector — your values are used as-is. The decision engine compares signal scores against these thresholds independently; any signal below its threshold fails the verification.

SCORE-mode configuration

When verificationMode = 'score', the scoreModeConfig object on the project controls the post-verdict dispatch and hard-block fences:

{
  "scoreModeConfig": {
    "blockAboveRisk": 0.85,             // hard block when riskOverall ≥ this
    "blockBelowUniqueness": 0.30,       // hard block when uniqueness ≤ this
    "blockOnFraudSignals": ["screen_detected", "virtual_camera"],
    "minEvidenceQuality": 0.30,         // minimum camera/signal quality
    "exposeBreakdown": false,           // include per-signal contributions in the envelope?
    "redirectPassUrl": "https://your.app/verified",
    "redirectFailUrl": "https://your.app/screened-out"
  }
}

Both redirectPassUrl and redirectFailUrl must be set for the signed-redirect flow to fire — they're used by GET /public/sessions/{id}/redirect, which appends a signed vh_token query param you can verify client-side via JWKS. URLs MUST be HTTPS in production (HTTP allowed for localhost in dev).

Consent disclosure

BIPA biometric consent is shown unconditionally on all verification flows (cannot be disabled). The non-BIPA copy is configurable:

userDisclosureWhat the user sees
NEUTRAL (default)Standard camera-permission copy. No editorial framing.
TRANSPARENTExpanded explanation: why VerifyHuman is running, what signals are captured, where they're processed (client-side), retention policy.
CUSTOMYour text from userDisclosureCustomText (max 280 chars, plain text; HTML-escaped at render). CUSTOM mode REQUIRES a non-empty userDisclosureCustomText.

White-label branding

Customers can override the widget's visual identity via the top-level branding object. Tier gates apply:

{
  "branding": {
    "enabled": true,
    "companyName": "Acme Research",            // shown instead of 'VerifyHuman'
    "primaryColor": "#0066ff",                  // hex; buttons + accents
    "backgroundColor": "#1a1a1a",               // hex; widget background
    "fontFamily": "Inter, sans-serif",          // Professional tier
    "privacyUrl": "https://your.app/privacy",   // Professional tier
    "supportEmail": "[email protected]",         // Professional tier
    "logoUrl": "https://your.cdn/logo.svg",     // Enterprise tier
    "hidePoweredBy": true                       // Enterprise tier
  }
}
  • Free / Starter: companyName, primaryColor, backgroundColor.
  • Professional: above plus privacyUrl, supportEmail, fontFamily.
  • Enterprise: all of the above plus logoUrl and hidePoweredBy.

Allowed origins (inherits from org)

Two layers — set whichever fits your scale:

  • Organization-level settings.allowedDomains — set ONCE per org in dashboard Settings, or via PUT /organizations/{org_id}. Every project in the org with an empty per-project list inherits this at runtime. Updating the org-level list immediately cascades to all existing projects (no migration / re-deploy needed).
  • Per-project config.allowedDomains — when non-empty, this becomes the AUTHORITATIVE list for the project (it does NOT merge with the org default; the project overrides). Use to scope a single study tighter than the org-wide allow-list.

Both layers support the same pattern syntax: exact host (https://app.com), bare host (app.com matches any scheme), wildcard subdomain (*.acme.com), and localhost-with-port-wildcard (localhost:*).

Recommended pattern for platform integrators: set settings.allowedDomains on the org once with all your customer-facing domains. Leave per-project lists empty. Every auto-provisioned project inherits the org defaults at request time — zero per-project config.

Where customers set this:

  • Dashboard signup — captured on the Onboarding step 1 form (alongside the org name), so new customers never see the “Origin not permitted” error.
  • Existing dashboard customers Organization → Settings → Allowed Domains.
  • Integrators (zero-dashboard flow) PATCH /organizations/me/settings with X-API-Key (org scope). See below.

PATCH /organizations/me/settings (new — self-serve, 2026-05-21)

Lets integrators manage their own org-level allow-list (and future org settings) entirely from their backend — no VH dashboard login required. The API key's organization is implicit; me in the URL resolves to it.

Auth: X-API-Key with organization scope (a vf_org_live_* or vf_org_test_* key). Project-scope keys return 403 INSUFFICIENT_SCOPE because settings affect every project in the org.

curl -X PATCH https://vhuman.riwi.com/api/v1/organizations/me/settings \
  -H "X-API-Key: $VERIFYHUMAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "allowedDomains": [
      "https://app.yourplatform.com",
      "*.yourplatform.com",
      "localhost:3003"
    ]
  }'

# Response:
# {"allowedDomains": ["https://app.yourplatform.com", "*.yourplatform.com", "localhost:3003"]}

Partial-update semantics: fields you omit stay at their current value. To clear the allow-list, send "allowedDomains": [] explicitly (which switches back to default-deny in prod/staging).

GET counterpart: GET /organizations/me/settings returns the same shape — useful for reading the current list before patching an addition.

# Set the org-wide allow-list once (run once at onboarding).
# Owner or admin role required.
curl -X PUT https://vhuman.riwi.com/api/v1/organizations/$ORG_ID \
  -H "Authorization: Bearer $DASHBOARD_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "allowedDomains": [
        "https://app.cooltool.com",
        "*.cooltool.com",
        "https://your-app.example.com"
      ]
    }
  }'

Default-deny: if both layers are empty in production/staging, the server rejects every /public/sessions request for that project with 403 ORIGIN_NOT_ALLOWED. (Local dev still allows all origins when unconfigured.)

Setting all of this in one call

The POST /projects/provision endpoint accepts most config fields documented above on creation — including per-study thresholds, redirect URLs, and embed origins via allowedOrigins. Note it does not accept a branding object; set white-label branding with a follow-up PATCH /projects/{id}. Useful when your backend auto-creates projects per study and you want per-study config in one round-trip. See the /projects/provision reference below.

Webhooks

See Webhooks for the full payload contract, retry behavior (5 attempts with exponential backoff, terminal-4xx skips retry), idempotency key, and the v1 / v2 payload shapes. v2 payload (2026-05-19) carries respondent_id, metadata, ISO timestamp, fraud signals, and risk breakdown.

Rate limits & quotas

  • Rate limit (429 RATE_LIMIT_EXCEEDED): per project + origin. Response includes Retry-After, X-RateLimit-Remaining, X-RateLimit-Reset.
  • Quota (429 QUOTA_EXCEEDED): monthly cap on verifications. Body includes limit, usage, resetDate.
  • Trial expiry (402 TRIAL_EXPIRED): distinct code so your UI can prompt for upgrade rather than "wait until next month."

JWKS (offline token verification)

Score-mode redirect tokens (vh_token) are Ed25519-signed. To verify offline:

GET https://vhuman.riwi.com/api/v1/.well-known/jwks.json

Keys rotate; cache the JWKS for 1 hour and refresh on signature failure. Note the non-standard mount under /api/v1/ rather than the root.

OpenAPI

Full machine-readable spec at /api/v1/openapi.json. Swagger UI at /api/v1/docs. The dashboard's TypeScript types are generated from this spec via npm run generate:types.

SDK

For browser integration, see SDK. The HTML drop-in passes studyId / respondentId via data-study-id / data-respondent-id attributes; React/Next.js components accept studyId / respondentId / metadata as props.