Webhooks
VerifyHuman sends signed HTTP POSTs to URLs you register per-project. Configure URLs + event types in the dashboard under each project's Webhooks tab, or programmatically via the MCP server.
Event types
verification.completed— terminal outcome of a verification session (pass OR fail). Default event for new webhooks.verification.passed— fires only on pass.verification.failed— fires only on fail.verification.scored— fires for SCORE-mode projects in place of passed/failed. Carries the verdict (status: "scored"or"score_blocked"),risk_score, andrecommended_decisionso your backend can route without an introspection round-trip.verification.completedstill fires alongside it.monitoring.flagged— behavioral monitoring flagged an in-session anomaly above the project's threshold.question.flagged— Phase 3 per-question quality threshold tripped.
Payload — v2 (recommended)
Set payloadVersion: "v2" when creating the webhook. v2 carries the full envelope including the respondent id, your custom metadata, and an ISO timestamp.
{
"event": "verification.completed",
"data": {
"session_id": "sess_abc123",
"project_id": "proj_xyz",
"status": "pass", // "pass" | "fail"
"scores": {
"liveness": 0.95, // null when no camera produced a
// liveness verdict — device-only /
// camera-blocked passes (F-CRIT-112)
"uniqueness": 0.98, // null when no biometric uniqueness
// comparison ran (no face vector, or
// the mode doesn't check uniqueness)
"authenticity": 0.92,
"overall": 0.95
},
"respondent_id": "resp_456",
"metadata": { "panel": "prolific", "sourceId": "src_123" },
"timestamp": "2026-05-22T17:42:00.000Z",
"fraud_signals": [],
"failure_reasons": [], // v0.5.11+ — server failure
// strings, in priority order
"summary": "Verification passed. Real human detected, no prior participation detected in this study.",
// v0.5.11+ — structured failure category. Present only on fails.
// Same enum the SDK uses to switch errors:
// "duplicate" | "liveness" | "authenticity"
// | "fraud_signal" | "fingerprint"
"failure_category": null,
// v0.5.11+ — only when failure_category === "duplicate".
// Identifies the prior session/respondent the current verification
// matched. Cross-reference against your own respondent table.
"duplicate_match": null,
// example on a dedupe rejection:
// "duplicate_match": {
// "fingerprintId": "fp_abc",
// "similarity": 1.00,
// "sessionId": "sess_prior_match",
// "respondentId": "rsp_prior_match"
// }
// v0.5.11+ — client-detected demographics from face-api.js.
// Null on device-only mode + model-load failures + older SDKs.
"demographics": {
"ageEstimate": 28.6,
"ageRange": { "min": 25, "max": 32 },
"gender": "female", // "male" | "female" | "unknown"
"genderConfidence": 0.88,
"fitzpatrickType": 3, // optional, 1-6
"skinLuminance": 0.62 // optional, 0-1
},
// Per-signal flat flags (v2 webhooks) — ALWAYS present, even on a pass, so
// your backend can route on a report-only signal without parsing scores.
// v1 webhooks strip all three.
// is_duplicate — a matching prior fingerprint was found (may be
// report-only on a pass — e.g. a returning participant
// outside the re-eligibility window → "welcome back").
// low_quality — a low-quality capture/signal was flagged.
// high_fraud_risk — a HIGH-severity fraud signal was detected.
"is_duplicate": false,
"low_quality": false,
"high_fraud_risk": false,
// Assurance taxonomy (v2 webhooks) — screen on WHAT happened, not just
// pass/fail. assuranceLevel: biometric (camera + dedup, incl. via the
// cross-device QR handoff) | device_only (no camera, weaker) | none.
// verificationOutcome: completed | unverified_technical (camera blocked) |
// declined_by_user (respondent chose to skip — a decision, still completes).
"assuranceLevel": "biometric",
"verificationOutcome": "completed"
// "risk": { ... } — present only when the Track A v2
// aggregator is enabled per-project.
// "duplication": { ... } — slice 1.2 (2026-05-28). Track B
// unified-signal breakdown, same gate
// as risk. Shape:
// { overall, deviceMatch,
// biometricMatch, matchedDeviceId?,
// matchedSessionId?,
// matchMethod: 'exact'|'fuzzy'|
// 'biometric'|'none' }
// v2 payloads only; v1 webhooks strip it.
// v0.5.27+ — dedup scoping. v2 payloads only; v1 webhooks strip both.
// "data_collector": "interviewer-7" — the session's data-collector
// label (when set), so you can reconcile a
// verdict to a collector / wave.
// "prior_participation": { — a RETURNING participant: a prior match
// "fingerprintId": "fp_…", outside the project's re-eligibility
// "similarity": 0.98, window, so it did NOT block. Present on
// "sessionId": "sess_last_wave", a clean pass (e.g. monthly
// "respondentId": "rsp_…", tracker). Distinct from duplicate_match
// "lastSeenAt": 1800000000 (the in-window, blocking match).
// }
}
}v2 payload routing pattern
Same decision tree as the SDK's typed-error switch, on the backend side:
switch (data.failure_category) {
case undefined: // pass
acceptResponse(data);
break;
case "duplicate": {
const m = data.duplicate_match;
flagAsDuplicateOf({
currentSession: data.session_id,
existingRespondent: m?.respondentId, // ← from your own DB
existingSession: m?.sessionId,
similarity: m?.similarity,
});
break;
}
case "liveness":
offerRetryWithTips(data.failure_reasons);
break;
case "authenticity":
// screen / photo / virtual-camera — usually screen-out
rejectAsSyntheticCapture();
break;
case "fraud_signal":
flagForManualReview(data.fraud_signals);
break;
default:
logUnknownCategory(data);
}Payload — v1 (legacy, default for old webhooks)
v1 strips the following fields for back-compat with handlers written before v2 existed: respondent_id, metadata, timestamp, fraud_signals, failure_reasons, summary, risk, failure_category, duplicate_match, demographics, and the per-signal flat flags is_duplicate, low_quality, high_fraud_risk. Pre-existing v1 webhooks continue to receive v1 payloads until you upgrade them; new webhooks default to v2.
The scores nulls apply to v1 as well. A liveness or uniqueness of null means that check never ran — it is not a score of zero and not a failure. This is the one place v1 changed after it was frozen, because sending a number for a measurement that never happened is worse than the shape change. Check for null before comparing either field to a threshold — in most languages null < 0.5 evaluates true, which would read an unmeasured axis as a failing one. On v2 you can also branch on assuranceLevel, which names what evidence was captured.
Delivery headers
| Header | Description |
|---|---|
Content-Type | Always application/json. |
X-VerifyHuman-Event | Event type (e.g., verification.completed). Branch on this without parsing the body. |
X-VerifyHuman-Signature | sha256=<hex> HMAC over {timestamp}.{body} (dot-joined) using the webhook signing secret. Verify before processing. |
X-VerifyHuman-Timestamp | Unix seconds. 5-minute skew tolerance recommended on your side. |
X-VerifyHuman-Idempotency-Key | The verification's session id. Use for dedup; don't 4xx duplicates — that triggers another retry. |
X-VerifyHuman-Attempt | 1-indexed attempt number. 1 = first delivery; ≥2 = retry. Helps distinguish "first time we've seen this" from "we already saw this." |
Signature verification
HMAC-SHA256 over {timestamp}.{raw_body} using the webhook secret. Reference implementations:
// Node.js
const crypto = require('crypto');
function verifyWebhook(body, secret, signature, timestamp) {
const message = `${timestamp}.${body}`;
const expected = crypto.createHmac('sha256', secret)
.update(message).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}# Python
import hashlib, hmac, secrets as secrets_module
def verify_webhook(body: str, secret: str, signature: str, timestamp: str) -> bool:
message = f"{timestamp}.{body}"
expected = hmac.new(
secret.encode(),
message.encode(),
hashlib.sha256,
).hexdigest()
return secrets_module.compare_digest(signature, expected)Retry behavior
VerifyHuman runs a durable delivery queue. Every fire is persisted as a webhook_delivery_attempts row that walks a state machine until terminal:
PENDING ──► SUCCEEDED (2xx response)
├─► FAILED (timeout / 5xx → retry scheduled)
└─► EXHAUSTED (5 attempts hit OR terminal 4xx)- Timeout: 10 seconds per attempt.
- Success: any 2xx status code.
- Retries: up to 5 attempts total (one initial delivery + four retries) with exponential backoff (~5s, ~30s, ~5min, ~30min).
- Terminal statuses — no retry:
400, 401, 403, 404, 410, 422, 429. These mean "the request will never succeed as-is"; retrying would only amplify your-side problems. The attempt moves toEXHAUSTED. - Idempotency: use
X-VerifyHuman-Idempotency-Keyto dedupe. Returning 2xx for a duplicate is correct — 4xx-ing triggers another retry. - Ordering: best-effort. With retries, a later event for the same session can land before an earlier retry of a prior event. Sequence on
data.timestampif order matters.
Delivery history
Inspect attempts via the dashboard's Webhooks tab on each project, or programmatically:
GET /api/v1/projects/{project_id}/webhooks/{webhook_id}/deliveriesReturns status code, response body, duration, attempt number, and final state per attempt. Useful for diagnosing 4xx loops without grepping your own server logs.
Webhook secret format
32-byte random value, hex-encoded. Generated server-side at webhook creation; shown to you ONCE on the dashboard. If you lose it, rotate via the dashboard or via the MCP update_webhook tool — the old secret stops being accepted immediately.
Test deliveries
The dashboard's "Test delivery" button (or the MCP test_webhook tool) fires a syntheticverification.completed event at your endpoint with valid signature headers. Use this to validate signature verification + handler dedup before going live.