Server API
The complete REST reference for connecting your backend. The snippets below run as they are — replace the placeholder values with your real keys. For snippets already filled in for one project, sign in and open your project's Integrate page.
This reference covers two parts:
- Server API scoring — one call from your server per respondent, no code on the page, no camera. It sits at the same point in your flow as any other quality-check vendor. See Verification modes for how it fits with the other levels of check.
- On your page tokens & admin — check the signed token the SDK produces on your page, create projects, read analytics. Everything below the Server API scoring section.
Base URL
https://vhuman.riwi.com/api/v1
Authentication
- Public endpoints (
/public/sessions,/public/verify,/public/projects/{id}/demo-key) — no Secret key. The calling site must be on the project's list of allowed domains. - Endpoints for your server (
/token/verify,/token/verify-batch,/projects/provision,/projects/{id}/analytics) — send your Secret key in theX-API-Keyheader. Manage keys in your dashboard.
Two kinds of Secret key — project and organisation
VerifyHuman has two kinds of Secret key, like Stripe, Twilio, Auth0 and SendGrid: a project key for everyday checks, and an organisation key that can also manage projects.
| Kind | Starts with | Checks tokens from | Can create projects | Where to create it |
|---|---|---|---|---|
| Project (default) | vf_live_* / vf_test_* | This project only | No (403) | Dashboard → Project → Secret keys |
| Organization | vf_org_live_* / vf_org_test_* | Any project in the org | Yes | Dashboard → Org secret keys (owner-only) |
Project Secret keys (one per site, like reCAPTCHA)
Create a project Secret key for one integration on one site. It checks only tokens from its own project — a token from another project in the same organisation fails with error: "WRONG_PROJECT". This works like the per-site keys of reCAPTCHA, hCaptcha, Turnstile and Friendly Captcha: if a key leaks, only one project is at risk, not the whole organisation.
Organisation keys (for platforms)
Create an organisation key when your backend creates a VerifyHuman project per study, customer or panel, OR when you need ONE key that checks tokens across many projects. Organisation keys can call /projects/provision (project keys cannot — that endpoint returns 403 INSUFFICIENT_SCOPE). They also accept tokens from any project in the same organisation.
Who can create one: only the organisation owner can create an organisation key in the dashboard (admins can create project keys). Treat it like a master password: keep it in a secrets manager, only on your backend, and replace it every three months.
Expiry & rotation
Any key can have an expiry date. Pass expiresInDays (1–365) when you create it (in the request, or with the expiry option in the dashboard); leave it out 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 exactly like an invalid one — the same INVALID_KEY / 401, with no sign that the key ever existed.
Replace a key without redeploying:
# 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.Replacing a key needs write access (keys:write — admin or owner on projects, owner on the organisation). A key from another organisation, or an unknown keyId, gives nothing to replace (we never reveal whether it exists). In the dashboard, each Secret keys page shows an Expires column (expired / expires-soon labels) and a Rotate action that shows the new key once.
Raise a key's rate limit in place
Growing (for example from a test sample to full traffic)? You can raise a key's perMinute / perDay limits without changing the key — the key stays the same, so a live 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 }Needs write access (admin or owner on projects, owner on the organisation), like replacing a key. Only the limits change — the scope, name and key stay the same. A key from another organisation, or an unknown keyId, returns 404 (we never reveal whether it exists). New limits apply from the next request.
Older keys
Keys created before project keys existed are treated as scope=organization, so they keep checking tokens across the whole organisation. You can keep using them; we recommend moving to project keys next time you replace them, so you can see and revoke each site separately.
Integrations that sign in with OAuth use OAuth 2.1 client credentials, not a Secret key. See the MCP guide.
Server API scoring
A risk API your server calls once per respondent — no JavaScript on the page, no camera, no face data. It returns a 0–100 risk score and an allow / review / block recommendation, based on the respondent's network, device, speed and volume of sessions, repeat visits, behaviour and history. Use it when you can't put code on the respondent's page (for example panel or sample routing), or want the lightest possible integration. For an overview and how it fits with the other levels of check, see Verification modes → Server API.
Availability: Server API scoring is enabled per organization. If POST /api/v1/lite/score returns 404, ask your VerifyHuman contact to enable it for your org.
One model for every client
Every customer is scored by the same model and the same integration: a score built from the findings in reasons[] / evidence, with review from 50 and block from 75. The only difference between customers is what each integration sends — a check whose data never arrives does not run (and is never counted against the respondent). Send what you have:
| You send | What it adds |
|---|---|
respondentId | history and repeat checks: the same person across sessions, devices and networks; buyer reversals on earlier work; repeat participation |
studyId | study re-entry: the same browser, device or address entering the same study twice |
studyCountry | the study-country check |
entryIp | the entry-network check: the connection changing between your entry link and the score call |
exitIp (second call on return) | the exit-network check: the respondent coming back from the survey on a different connection |
buyerId | buyer-level re-entry: the same address in another study of the same buyer (reported) |
| the page script | device, browser and connection checks, and checks for automated browsers |
| the page script's final send when the respondent leaves the page | checks on how the respondent behaves on the page |
clientIp + userAgent | the network checks every client gets: hidden addresses, shared and crowded networks, non-browser clients |
Project settings still apply on top of the score: the repeat policy, hiddenAddressPolicy, what to do when nothing could be checked, the setting for new respondents, the verified-agent policy and probeGuaranteed.
POST /lite/score
Auth: your project Secret key as Authorization: Bearer vf_live_… (or X-API-Key). clientIp is required — the respondent never connects to VerifyHuman directly, so you pass their real IP.
| Field | Required | What it's for |
|---|---|---|
sessionId | Yes | Your id for this scoring event; echoed back on the response. |
clientIp | Yes | The respondent's real IP — you pass it, because the respondent never connects to us directly. |
userAgent | No | The respondent's browser user-agent; improves the device checks. |
sourceId / subSourceId | No | Supplier / offerwall ID (and sub-ID) — what per-source thresholds are set by. Must stay the same across sessions: volume, repeat and history checks are counted per source. Never send the sessionId here — a value that changes every session puts each session in its own group, so repeat takers can never be found. |
studyId | No | The questionnaire this session belongs to — not the supplier. Repeat and volume checks are counted within it, so one project can hold many studies without mixing them: a respondent who honestly takes two of your studies is not reported as a repeat. 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. Leave it out and counting falls back to sourceId. |
declaredCountry | No | ISO-3166 alpha-2 country the supplier claims for the respondent. |
studyCountry | Recommended for single-country studies | ISO-3166 alpha-2 country the study is fielded in. A respondent whose address is in another country is blocked (study_country_mismatch, studyCountryMatch: false). Omit it for a study open to any country. With a studyId and no studyCountry, the study's country may be inferred from its own traffic. |
entryIp | Recommended when your gates run after your entry link | The address that first reached your entry link, before your gates ran. If it and clientIp are in different countries, or one is a hosting / datacenter / VPN network and the networks differ, the session is blocked (entry_network_changed, entryNetworkChanged: true). A same-country change between two ordinary home or mobile networks is not flagged. |
exitIp | Recommended: on a second call when the respondent returns | The address that came back from the survey (your complete link). Score the same sessionId again before recording the complete, with clientIp still the address that passed your gate. A return in another country on another network, or through a VPN abroad, is blocked (exit_network_changed, exitNetworkChanged: true): do not record the complete. An ordinary same-country change is not flagged. |
respondentId | Yes, for panel / survey traffic | Your person id (panelist / appuser) — it identifies the person, not the visit. Must stay the same across sessions: the history, per-respondent volume and several-identities-on-one-device checks depend on it. 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 checks can never add up; we notice this, ignore the respondent for scoring, and return respondent_id_equals_session_id in degradedSignals. It is also not a per-entry id: a panel's transaction / response / click id (Cint RID, Dynata PSID, Qualtrics ResponseID) is new on every entry and belongs in sessionId — sent here it makes every returning person look new. Send it on every call for panel and survey traffic: without it the per-person checks (repeat takers, multiple accounts, respondent history) cannot run, and we return respondent_id_missing in degradedSignals. Only if you genuinely have no stable person id (programmatic traffic), omit it rather than sending a per-session value. If you aggregate several supplier panels in one project, scope the id by supplier (hash supplierId + "|" + supplierRespondentId): the same person on the same panel must map to the same value; it does not need to match across panels. Send the salted hash — see identity hashing. |
ipPrefixHash, deviceHash | No | Salted SHA-256 hashes of the IP-network prefix / your device ID — see identity hashing. We never receive the raw values. |
respondentStats | No | Optional history you already hold about this respondent (attempts, completes, reconciliation rate), used only while we have little history of our own. Every field is about this one respondent — never totals for a source. Formerly supplierStats; the old name is still accepted for now and returns supplier_stats_deprecated_alias in degradedSignals. |
webBotAuth | No | RFC 9421 / Ed25519 HTTP Message Signature material for a known, accountable automated agent (Web Bot Auth). When it checks out against a trusted key, the session 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
"buyerId": "buyer_acme", // optional — same-IP re-entry across this buyer's studies
"respondentId": "<salted-sha256-hash>" // your stable panelist id — send on every call
}
# → 200
{
"score": 18, // RISK 0-100, higher = riskier. Excludes duplication.
"measured": true, // true ⟺ the page script's data arrived (scoringBasis "full").
// false whenever it did not — do not enforce
// on that, and do not read a 0 there as clean.
"duplicateScore": 0, // REPEAT 0-100 — SEPARATE from "score".
// Never moves risk or grlCompat; changes the
// recommendation only through duplicatePolicy.
"decisionAxis": "risk", // which side decided the recommendation:
// "risk" | "duplicate"
"duplicatePolicy": "excluded", // policy applied to this call:
// "study_scoped" (studyId sent) |
// "form_gate" | "excluded" (panel) | …
"duplicate": { // the repeat check, ready to act on
"score": 0, // same number as duplicateScore
"band": "none", // none | possible | likely | confirmed
"measured": true, // false => score/band mean nothing;
// also false when the device
// fingerprint is shared by many
// identical devices (evidence.fingerprintClass
// true, evidence.fingerprintClassSource
// profile | crowd | both, reason
// duplicate_fingerprint_class):
// the device-keyed repeat checks
// were NOT evaluated — a 0 here is
// "not measured", never "unique"
// With a studyId: a re-entry into
// the SAME study grades confirmed
// (same browser id, reason
// study_reentry_browser) or likely
// (same device + same IP, reason
// study_reentry_device_ip);
// evidence.studyReentry = { matched,
// priorEntriesBrowser,
// priorEntriesDeviceIp, windowDays }.
// Absent without a studyId.
"isRepeat": false, // THREE values — has this person taken
// part within the project's waiting period?
// true = this respondentId already
// took part in this repeat
// scope, in another session,
// within the period
// false = no earlier session within
// the period (after the period
// it reads false: allowed back)
// null = could not be checked (no
// stable respondentId, or our
// records unavailable) — never
// enforce on null
"reasons": [], // the repeat-check 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.
"studyCountryMatch": null, // address in the study's country?
// false = blocked; null = not measured.
"entryNetworkChanged": null, // network changed between entryIp and
// clientIp? true = blocked.
"exitNetworkChanged": null, // came back from the survey (exitIp) on
// a different network? true = blocked.
"recommendation": "allow", // allow | review | block | step_up — from RISK only
"verdictClass": "human", // human | fraud | verified_agent | unknown
"categories": { // six categories, 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
"pattern": { "primary": "none", // listed under "against".
"farm": "none", // WHAT KIND of problem: is this a farm?
"patterns": [], "statement": "…" },
"farmTier": { "breadth": 0, // present ONLY when the farm tier fired:
"knot": 0, // the session was served in the block
"raisedFrom": 0, // band on that one finding (score
"to": 75 } // raisedFrom -> to). Absent otherwise.
},
"degradedSignals": [], // checks we could not run (setup issues
// and our own data sources)
"probe": "present", // present | late | missing — did the page
// script's data arrive for this result?
// present ⟺ scoringBasis "full". Missing
// adds no risk unless the project set
// probeGuaranteed (then step_up).
"grlCompat": { "forensicMaxScore": 0, "categories": { … } },
"assuranceAvailable": "can_step_up_to_biometric",
"stepUpIntensity": "none", // none | light | heavy
"sessionId": "your-scoring-id",
"receiptId": "rcpt_…", // signed audit reference
"device": { // OPT-IN: absent unless the project
"model": "SM-A065F", // turned on deviceDetailsEnabled
"os": "Android", "osVersion": "16", // (see "Device details" below)
"browser": "Chrome", "browserVersion": "154.0.7390.54",
"deviceType": "phone"
}
}Rejecting respondents? Only act on measured: true. A result made without the page script's data is always measured: false. Its recommendation still follows its score like any other result (a score in the block range reads block; use measured / scoringBasis if you want to hold those back). Once a session has a full result, a later call without page data returns that same result again (full_verdict_reserved) instead of a weaker one. If you score the same session several times, block on the final result — the one with refinable: false — not on an earlier one.
recommendation is the decision: it follows the risk score, and only the repeat policy can raise it further. block at or above the block line, review at or above the review line, allow below. Anything that changes the decision for a risk reason (a known fraud pattern, or the project's nothing-checked, hidden-connection or verified-agent setting) moves the score, so the two never disagree. The one exception is the repeat policy (see duplicate): it can raise the recommendation without touching the score, and then decisionAxis reads duplicate and the reason duplicate_policy_applied says so.
Read recommendation, then take your own action. The lines are yours to set (see thresholds). The lines applied to score are the ones in force when the call is scored — your project's Scoring configuration page shows them — so do not apply your own lines to score; recommendation already uses the right ones. step_up appears only when the project has turned on the camera step-up — it recommends a camera check for this respondent instead of blocking. grlCompat maps max(categories) onto four fixed bands (0 / 50 / 75 / 100), for systems built around an older provider's banded score, so VerifyHuman can take its place without changes.
evidence — what each finding can prove
reasons[] lists every finding, 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 an honest session, by its nature rather than because of a chosen threshold. You may say “this cannot happen”.record— already decided by someone with authority (the buyer reversed payment). You may say “this happened”.correlate— a measured statistical link. 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". This is checked against what each finding actually added at scoring time, so it stays true when you change a setting. Repeat participation is in notes too: it is a question of eligibility 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.
What kind of problem — is this a farm?
class says what a finding may prove and strength how much it is worth; neither says what sort of fraud it points to. Every item carries a pattern, and evidence.pattern groups the findings that counted against the respondent by it: primary (the pattern of the strongest finding, "none" when nothing counted against them), farm (the strongest coordinated-farm finding, or "none" — the direct answer to “is this a farm?”), patterns[] (every pattern present, strongest first, each with its codes and a one-sentence claim) and a statement naming the problem or saying there is none.
coordinated_farm— several identities or sessions tied to one network or device cluster.automation— a script, an instrumented browser, or interaction a person does not produce.identity_misrepresentation— who or where the respondent claims to be does not hold together.network_anonymization— the connection hides its true origin (VPN, proxy, datacenter, Tor).duplicate_participation— the same person or device again, on the risk side.identity_reuse— one respondent identity seen from many networks, devices or places (a shared, sold or rotated account).adverse_history— the respondent's own record, already decided by the buyer or panel.
A pattern never outranks its findings: farm: "moderate" on a correlate means elevated risk of coordinated participation, not proof of a farm — the item's own class remains the limit of what you may assert. It is derived from against only, so a session with nothing against it always reads "none".
Coordinated reuse — evidence.farmTier
When a session is part of coordinated reuse at farm scale, it is served in the block band — score becomes the project's block threshold and recommendation is "block". It only ever raises a score; it never lowers one. It is visible in three places: evidence.farmTier (raisedFrom, to), coordinated_reuse_breadth in reasons[] with scored: true, and an item under pattern coordinated_farm in evidence.against. If it concentrates on one supplier, read the per-source split in the dashboard and take it up with the supplier.
Shared office and mobile-carrier networks can also produce high counts at small scale. The reason's detail names the counts behind the finding, so a specific session can be contested on its own numbers.
Fetching the evidence again, later
GET /api/v1/lite/score/{receiptId}/evidence — with the same Secret key that scored the session.
The evidence object above comes with the score response, so on its own you can read it only at that moment. 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.
Every receiptId we returned resolves — including a repeat score whose result was exactly the same as the previous call, which is stored on that result's row: the reply carries that call's own receiptId, scoredAt and figures. On GET /lite/scores each row carries scoreCalls, the number of calls it stands for; a changed result is listed at once and a repeat's count is added to its row within about 70 seconds of the session's last call.
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 contains no scoring weights, thresholds or calculations.
duplicate — repeat participation
Has this person already taken this study? That is a question about eligibility, not fraud, so it is scored separately and never moves score, verdictClass or grlCompat. A legitimate repeat panellist is not a fraudster.
Two scores, one decision. recommendation combines risk with eligibility under the project's repeat policy, and decisionAxis says which one decided it:
- Survey / study-scoped (you send
studyId; policystudy_scoped, the default): the same browser or the same IP address re-entering the same study →block; the same device on the same network →review. Send the optionalbuyerId(one stable value per end client) and the same-IP check also runs across all of that buyer's studies. - Form gate (set
liteConfig.duplicatePolicy: "form_gate"): aconfirmedduplicate within the rolling 24-hour window →block. - Panel (no
studyId; policyexcluded, the default): reported only — the decision is the risk band.
The policy only ever raises a decision and never acts on duplicate.measured: false. A session with no device identity (no JavaScript, no page script, no deviceHash) reads measured: false — not checked, not “unique”. Other values: excluded, review_lean, enforced (the panel conjunction below).
Read band rather than the raw number, so if we move the cut points later, your rule does not change without you knowing. 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”.
On a panel, use it in conjunction, never alone (or set duplicatePolicy: "enforced" to have the decision apply the rule below). 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:
score >= 50andduplicate.bandoflikelyorconfirmed— catches more than a higher score cut-off alone, at about the same cost in wrongly-flagged completions. This is the recommended rule.score >= 60with 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 is linked 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 passed a check that the links are real. 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.
respondentId stability — what breaks if it is per session
respondentId is the person. Every check about a respondent counts or remembers by it, so the same panelist must arrive with the same value on every session. Two setups break that without any error. We have seen both:
- No
respondentId: every per-person count stays empty, so none of the checks about a person can run. - A new
respondentIdevery session: no respondent ever appears to come back, even when their device clearly does. Respondent history, repeat devices, repeat participation and the other checks about a person can never fire — only network and session checks remain — and nothing says why.
For illustration: with a stable panel ID, roughly as many sessions come from a returning respondent as from a returning device; with an ID that changes every session, returning respondents read zero while returning devices do not. A stable ID comes back about as often as the device does; an ID that changes every session never comes back.
What we do about it. We check each project's recent sessions: missing when at least half carry no id; per_session when respondents never return that day while devices do; inconclusive when neither returns; unmeasured when there are too few sessions — never OK by default. The result is returned as integrationHealth.respondentId on GET /api/v1/projects/{projectId} (status, a readable detail, a fix, the measured shares), shown as a banner on the project overview and settings pages, and added to every score of the project as respondent_id_per_session in degradedSignals (respondent_id_missing covers the absent case). It never changes a score.
What to send: a salted hash of your stable panelist / appuser id (below), the same value for the same person on every session. A transaction / response / click id belongs in sessionId. The warning clears itself at the next check once stable IDs arrive.
Setup check — is the data you collect actually used?
Page data that arrives is not always page data that is used. The page script sends in stages, the last one when the respondent leaves the page, and /lite/score uses whatever has arrived when you call it. If you call it once at submit, every session is scored before its last stage exists: the behaviour data is collected and never used. GET /api/v1/projects/{projectId} returns integrationHealth.wiring: your recent sessions matched against which stages of page data arrived. status is ok · watch · broken · unmeasured (too few sessions, never read as OK) · not_integrated (no page script installed; not judged on page-script figures). Each metrics[] entry — probe_joined, page_exit_unscored, browser_id_storage, score_calls, degraded, probe_after_score, probe_collect_time, score_never_called, probe_page_no_interaction — carries its value, numerator / denominator, the thresholds it was judged against, a readable detail and a fix. A broken status shows a banner on the project overview and settings pages. It never changes a score. Fix for page_exit_unscored: score the session again after the page script's last send has arrived. Fix for probe_page_no_interaction: put the page script on a page where the respondent answers questions, not on a page they only pass through.
What it cannot see: errors the page script may cause on your page, conflicts with your other scripts or vendors, or page data that never reached our server. Watch those in your own website monitoring.
Identity hashing (no personal data sent to VerifyHuman)
You hash the three optional identifiers yourself: sha256( utf8(salt) + "|" + utf8(value) ) (lowercase hex). VerifyHuman never receives the salt and cannot reverse the hash, but the same identity always gives the same value — so history you shared with us earlier is used from the first score, without ever exposing a raw panelist ID, IP or device ID.
POST /api/v1/lite/events — report outcomes back to us
Outcomes you learn after entry — a survey complete, a speeder or disqualification, a buyer reject, a reconciliation or clawback, a ban — make future scores for the same supply more accurate. 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 an error on our side can never look like a normal repeat. 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.
Page script (under 15 KB) — strongly recommended
The Server API can score from server checks alone, but the device and behaviour checks see nothing without the page script: a clean session then scores 0 in every category and returns verdictClass: "unknown" with degradedSignals: ["client_probe_missing"] — which means "no page data", not "clean". Load it on the page the respondent lands on, before you score. No camera, nothing on screen:
<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 script starts by itself from data-project-key and does nothing without it. Creating the ID in the browser 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 page script sends 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 page data 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.
Thresholds and settings
The recommendation lines and risk settings are in the project's config.liteConfig block — set them in the dashboard or via PATCH /projects/{id}. Every field is optional; a project that sets nothing uses the VerifyHuman defaults. The recommendation lines are fixed (review 50, block 75); reviewLow / blockThreshold apply only to a customer VerifyHuman has kept on the previous model. All except deviceDetailsEnabled are settable per source too (nest under liteConfig.perSource["<sourceId>"]):
| Setting | What it does |
|---|---|
reviewLow / blockThreshold | The score lines: below reviewLow → allow, at or above blockThreshold → block, between → review. |
noProbeRiskScore | Limited extra risk when the page script's data is missing (sessions with no page data are riskier on average). Always below the block line — missing data alone can never block. |
probeGuaranteed | Say that your page always runs the page script before scoring (forms and bot checks). Then missing page data reads step_up (a challenge, never allow) with the reason probe_missing_guaranteed; score is unchanged. Default false (surveys and routers): missing page data adds no risk and probe reports missing / late. |
hiddenAddressPolicy | What to do with a respondent who connects through a VPN, proxy, Tor or datacenter address (the reason hidden_address) and shows nothing else. review (default): the session is sent to review, and is blocked only when another finding of a different kind is present. block: block such respondents on that alone — the score is raised to the block band and the reason hidden_address_policy_applied says your policy did it. |
stepUpEnabled, stepUpThreshold, stepUpIntensityThreshold | Turn on the step_up recommendation, and split it into light (a quick challenge) or heavy (phone or ID) with stepUpIntensity. |
geoRiskWeights | A multiplier per country (ISO-3166 alpha-2) on the network part of the score — a weighting, never a ban (limited; a country weighting on its own can reach review at most). |
dispositiveSignals | Choose findings (confirmed_clawback, device_farm) that may block on their own when confirmed. Empty by default. |
newUserPosture | neutral (default) or review_lean — lean brand-new respondents (no history with us) towards review. Never reaches block. |
verifiedAgentPolicy | How to treat a Web-Bot-Auth-verified agent: score_normally (default, tag only) · allow · review. |
deviceDetailsEnabled | Include device details. false (default): nothing changes. true: every score result for the project carries a device block (see below). Project-level only. Never affects the score. |
Device details (opt-in, off by default)
When the project has deviceDetailsEnabled switched on (dashboard: Scoring configuration → “Include device details”), each score result carries a device block describing the respondent's device, so you can compare it with what the respondent reported in your questionnaire. Off, the block is absent and the response is unchanged. The same block is returned by GET /api/v1/lite/score/{receiptId}/evidence for sessions scored while the setting was on.
| Field | What it is |
|---|---|
model | The device model as the browser reports it (e.g. SM-A065F). Only Chrome-family browsers on Android reveal it; iPhones, iPads, Safari and Firefox never do. |
os / osVersion | Operating system (Android, iOS, Windows, macOS, Linux, ChromeOS) and its version (16 = Android 16, 11 = Windows 11). The version is omitted when the browser does not reveal it reliably. |
browser / browserVersion | The browser and its version. |
deviceType | phone · tablet · desktop · unknown. iPads running iPadOS 13+ present themselves as a Mac, so they read desktop with os: "macOS". |
A field the browser does not reveal is omitted, never guessed. The model and the most precise versions come from the page script; a score made before its data arrives carries what your userAgent reveals (operating system, browser, device type).
Threshold recommender. GET /api/v1/lite/threshold-recommendations returns suggested reviewLow / blockThreshold lines per source, based on the outcomes you have reported, with how much more you would catch and how many more real people you would block. It is only a suggestion — never applied automatically; a person compares it with the current lines. POST /api/v1/lite/simulate shows how the allow / review / block mix would change with different lines before you save them.
The full Server API field reference (every value, how to hash IDs, how to report outcomes, rate limits) is available to the AI assistant in your MCP client through the Server API tools, or from your VerifyHuman contact for panel and sample integrations.
Endpoint reference
POST /public/sessions
Create a session from a Project ID (siteKey). The SDK calls this from the browser; you usually don't call it yourself unless you build your own check screen.
Body fields: siteKey (required), studyId (optional), respondentId (optional, max 256 chars — your respondent ID, returned on /token/verify and in webhook events so you can match them up), 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
Send the check's data. The SDK fills this in from what it captured in the browser. Returns a token signed by our server 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
Check a token on your server. Required header: 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
"uniqueness": 0.98,
"authenticity": 0.92,
"overall": 0.95,
"riskOverall": 0.08, // optional; present when the risk breakdown is on
"riskScore": 12 // 0-100 risk from connection, device and behaviour
// (same scale as /lite/score "score"); absent on
// tokens issued before 2026-10-26
},
"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 means a project Secret key tried to check a token from a different project.
riskOverall — what is it?
Optional [0, 1] float on the scores block; present only when the risk breakdown is on (it is on by default). It reads the opposite way to overall:
riskOverall = 0.0→ clean, no red flags.riskOverall = 1.0→ high risk; several findings.
Use it to block above a risk level with scoreModeConfig.blockAboveRisk (see Project configuration below) — you choose how much risk you accept, separately from the overall pass mark.
Status values (pass / fail / review)
Each score is compared with its own threshold (set in customThresholds or by the Security Level preset). A score passes when signal_score ≥ signal_threshold and fails otherwise.
The overall status combines them:
- All scores pass and
overall ≥ overall_threshold→status: "pass". - Any score fails →
status: "fail". status: "review"applies inverificationMode = 'score'when the result is borderline (for example warning signs without a clear fail). Send these to manual review.
With verificationMode = 'block' you see pass / fail only — a borderline result counts as a fail. With verificationMode = 'score' you get review results so you can send them to manual review.
POST /token/verify-batch
Check up to 100 tokens in one call. Useful when you check saved responses later in bulk, instead of checking each one when it was submitted.
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 checked on its own. A token from another organisation comes back as success: false with WRONG_AUDIENCE, without failing the whole batch.
POST /projects/provision
Create a project from your server. Lets your backend create a VerifyHuman project automatically when, for example, a researcher adds a check to a study — without the researcher ever opening the VerifyHuman dashboard.
Auth: an organisation Secret key in X-API-Key.
All in one call (recommended for new integrations): if you also need a Secret key for the new project (the usual case — the SDK on your page uses siteKey, your backend uses the Secret key for /token/verify), use the MCP tool provision_project_with_key instead. It creates the project AND its key in one call (signed in with OAuth), and tells you if only the first step worked. See the MCP guide → Creating projects from your server.
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 saved on the project so you can match it to your own records. The project's signing secret is created for you and not returned here — call POST /projects/{id}/signing-secret/rotate when you need it.
Getting a project Secret key back
POST /lite/score resolves its project from the Secret key — the request body has no projectId. An organisation key belongs to no single project, so page data sent to /public/lite/probe cannot be matched and the score comes back with measured: false and client_probe_missing. Pass issueApiKey: true to get a lasting project Secret key for the project you just created:
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 check tokens and score for it, and it cannot create more projects or keys — only the organisation key you used can.
Calling again is safe. Calling again with the same externalId returns the existing project with HTTP 200 and does not create 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 created before this option existed has no project key yet, so the first call with issueApiKey: true creates one.
A key is created only if you ask: 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
Your project's analytics, for your server. The same figures as the dashboard's analytics page; useful for putting them in your own 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
Tells you about the Secret key you call it with: its ID, scope, project (for a project key), whether it is a test or demo key, and its rate limit. So your code can find out which project a vf_live_* key belongs to without opening 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": "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:
- Your backend looks up its
project_idwhen it starts — together withNEXT_PUBLIC_VH_SITE_KEYin the browser, so you don't write the ID in two places. - AI assistants using our hosted MCP can answer “which project am I using?” without searching the dashboard.
- A check before going live — refuse to start if
isTest === truein production.
POST /sessions/{id}/retry
Create a new session as a retry of a failed one. Useful when a respondent only just failed and you want to give them one more try before screening them out.
Auth: a Secret key in X-API-Key from the organisation that owns the failed session. A key from another organisation gets 404 (we never reveal whether it exists).
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 first session has not finished yet.
- The first session did not fail (no point retrying a pass).
- The retry would go over
config.maxRetrieson the project (default 3).
Keeping track of retries: the new session's metadata has vh_original_session_id (the first session) and vh_retry_count (the first retry is 1). Use them to follow a respondent's attempts in your own logs and in the webhook events.
A stronger check for the retry: the new session uses the project's current widgetMode. To use a stronger check for the retry (e.g. captcha → standard), change the project with PATCH first, then call retry. Choosing the check per call is not available yet.
POST /public/projects/{id}/demo-key
Create a short-lived Test secret key to try out your integration. Limited to about 10 a minute. Demo checks are counted separately (is_demo) and are not billed or counted against your allowance.
The key returned is a Test secret key, not a Project ID. It goes in the SDK's apiKey option / the X-API-Key header. The SDK's siteKey option takes the Project ID — the response echoes it back under siteKey alongside a usage line so there is nothing to guess. Passing the vf_test_… key as siteKey returns 403, and that 403 deliberately does not say whether the Project ID is unknown or the site is not on your allowed domains.
{
"key": "vf_test_QZW1...", // → apiKey / X-API-Key
"expiresAt": 1789234567,
"siteKey": "6115db72-...", // → the SDK's siteKey option
"usage": "Pass `siteKey` (the project id) to the SDK's siteKey option; pass `key` as the apiKey / X-API-Key header. They are not interchangeable."
}Scores
Scores in scores are numbers from 0 to 1. The four main scores are always present on a camera check that passes:
liveness— how confident we are that a live person is in front of the camera (higher = more likely).nullon device-only / camera-blocked passes (no camera ran) — check for null before any calculation or comparison.uniqueness— how different this respondent is from others already seen in the same study. 1.0 = clearly a different person, 0.0 = the same person again. Alsonullwhen no face comparison ran (a device-only check, an option that doesn't compare faces, or a preview session) — we never send a made-up 1.0 for a check that did not happen, so check it for null exactly likeliveness.authenticity— how confident we are that the video came from a real camera, not a replay or a fake source.overall— the combined score that decidesstatus.
Two more scores are optional — present on newer responses, missing on older ones and on version 1 webhooks — so check for null before reading them:
quality— capture quality (higher = better).fraud— risk score (0.0 = clean, 1.0 = high risk).
Project configuration
Every project has a config block and a few top-level settings that control how checks behave. 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 matches one setting in the dashboard.
Core verification
| Field | Type | Default | What it controls |
|---|---|---|---|
verificationMode | 'block' | 'score' | block | How results are decided. block = the check passes or fails on the page. score = the SDK calls onResult with the result and your backend decides. |
config.widgetMode | 'invisible' | 'captcha' | 'standard' | standard | The protection level: Silent, Quick camera check or Full camera check. Tells the SDK what to show and what to check. (See /docs/sdk for the three kinds of “mode”.) |
config.challengeEnabled | boolean | true | Whether the SDK may add short camera challenges when the first camera check is not convincing. |
config.maxRetries | integer (0–10) | 3 | How many retries a respondent gets before the session finally fails. |
Thresholds & security levels
Two ways to set thresholds:
- Per-signal thresholds on the
configblock —config.livenessThreshold(default 0.65) andconfig.uniquenessThreshold(default 0.85). One pass mark per score. - Full custom thresholds on the top-level
customThresholdsobject — sets all four 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 shortcut that sets customThresholds for you:
- Relaxed — the fewest false rejections; for brand studies and low-risk samples.
- Balanced (default) — for most consumer research.
- Strict — for clinical studies and high-incentive panels.
- Maximum — for identity-critical work; the strictest, and the most likely to turn away a real respondent.
Setting customThresholds via API bypasses the preset — your values are used as they are. Each score is compared with its own threshold; any score below its threshold fails the check.
Settings for score projects
When verificationMode = 'score', the project's scoreModeConfig object sets where respondents are sent after the result, and when to block outright:
{
"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 to work — they are used by GET /public/sessions/{id}/redirect, which appends a signed vh_token query parameter you can check in the browser with our public keys (JWKS). URLs MUST be HTTPS in production (HTTP is allowed for localhost while developing).
Consent disclosure
The BIPA biometric consent notice is always shown on every check (it cannot be turned off). The rest of the wording can be changed:
userDisclosure | What the user sees |
|---|---|
NEUTRAL (default) | Standard camera-permission wording, with nothing added. |
TRANSPARENT | A fuller explanation: why VerifyHuman is running, what is captured, where it is processed (in the browser), and how long it is kept. |
CUSTOM | Your text from userDisclosureCustomText (max 280 chars, plain text; HTML-escaped at render). CUSTOM mode REQUIRES a non-empty userDisclosureCustomText. |
White-label branding
You can replace our look with your own through the top-level branding object. What you can change depends on your plan:
{
"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
logoUrlandhidePoweredBy.
Allowed domains (taken from the organisation)
Two layers — set whichever fits your scale:
- Organisation
settings.allowedDomains— set ONCE for your organisation in the dashboard Settings, or withPUT /organizations/{org_id}. Every project without its own list uses it. A change applies to all existing projects at once (nothing to redeploy). - Per-project
config.allowedDomains— when it is not empty, this is the ONLY list for the project (it is NOT combined with the organisation's list; the project's wins). Use it to limit one study more tightly than the rest of the organisation.
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 for platforms: set settings.allowedDomains on the organisation once with all the domains your respondents use. Leave project lists empty. Every project you create automatically uses the organisation's list — nothing to set per project.
Where customers set this:
- When you sign up — asked in the first step of setup (with the organisation name), so new customers never see the “Origin not permitted” error.
- Existing dashboard customers — Organization → Settings → Allowed Domains.
- From your backend (no dashboard) —
PATCH /organizations/me/settingswith an organisation Secret key inX-API-Key. See below.
PATCH /organizations/me/settings
Lets you manage your organisation's allowed domains (and future organisation settings) entirely from your backend — no dashboard sign-in needed. me in the URL means the organisation of the Secret key you use.
Auth: X-API-Key with organisation scope (a vf_org_live_* or vf_org_test_* key). Project keys get 403 INSUFFICIENT_SCOPE, because these settings affect every project in the organisation.
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"]}Only the fields you send change; the rest stay as they are. To empty the list, send "allowedDomains": [] (then every site is refused again, as by default).
GET counterpart: GET /organizations/me/settings returns the same shape — useful for reading the current list before you add to it.
# 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.yourplatform.com",
"*.yourplatform.com",
"https://your-app.example.com"
]
}
}'Refused by default: if both lists are empty, every /public/sessions request for that project is refused with 403 ORIGIN_NOT_ALLOWED. (A local development server allows every site when nothing is set.)
Setting all of this in one call
The POST /projects/provision endpoint accepts most of the settings above when it creates a project — including per-study thresholds, redirect URLs, and allowed domains through allowedOrigins. Note it does not accept a branding object; set white-label branding with a follow-up PATCH /projects/{id}. Useful when your backend creates a project per study and you want to set it up in one call. See the /projects/provision reference above.
Webhooks
See Webhooks for what each event contains, how retries work (5 attempts, spaced further apart each time; no retry after a permanent 4xx error), the key for ignoring repeats, and the version 1 / version 2 event shapes. Version 2 carries respondent_id, metadata, an ISO timestamp, findings and the risk breakdown.
Rate limits & quotas
- Rate limit (429 RATE_LIMIT_EXCEEDED): per project and site. Response includes
Retry-After,X-RateLimit-Remaining,X-RateLimit-Reset. - Allowance (429 QUOTA_EXCEEDED): your monthly number of checks. The response includes
limit,usageandresetDate. Applies to checks on your page and, on a trial, to the Server APIPOST /api/v1/lite/scoreas well; paid plans keep scoring on overage. You are emailed at 80% and 100% of the allowance. - Trial ended (402 TRIAL_EXPIRED): a separate code, so your screens can ask to upgrade rather than say "wait until next month".
JWKS (offline token verification)
Redirect tokens (vh_token) for score projects are signed with Ed25519. To check them without calling us:
GET https://vhuman.riwi.com/api/v1/.well-known/jwks.jsonThe keys change from time to time: keep the JWKS for 1 hour, and fetch it again when a signature check fails. Note that it is under /api/v1/, not at the root as usual.
OpenAPI
Full machine-readable spec at /api/v1/openapi.json. Swagger UI at /api/v1/docs.
SDK
For checks on your page, see SDK. The HTML snippet passes studyId / respondentId via data-study-id / data-respondent-id attributes; React/Next.js components accept studyId / respondentId / metadata as props.