Webhooks
VerifyHuman sends signed HTTP POST requests to the URLs you add to a project. Set the URLs and event types in the dashboard, on each project's Webhooks tab, or in code through the MCP server.
Event types
verification.completed— the final result of a check (pass or fail). New webhooks get this event by default.verification.passed— sent only on a pass.verification.failed— sent only on a fail. On an Adaptive project whose camera check could not run, the fail also carriesrecommended_decision(revieworblock, per your settings). The silent step that hands over to the camera sends no webhook; only the final result does.verification.scored— sent instead of passed/failed for projects that return a score rather than a pass or fail. It carries the result (status: "scored"or"score_blocked"),risk_scoreandrecommended_decision, so your backend can act without asking us for more.verification.completedis still sent as well.monitoring.flagged— something unusual happened during the session, above the project's threshold.question.flagged— the answer to a single question fell below the project's quality threshold.
Event body — version 2 (recommended)
Set payloadVersion: "v2" when you create the webhook. Version 2 has every field, including the respondent ID, your own 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 check ran
// (device-only result, or the
// camera was blocked)
"uniqueness": 0.98, // null when no face comparison ran
// (no face captured, or the
// option doesn't compare faces)
"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+ — why it failed,
// most important first
"summary": "Verification passed. Real human detected, no prior participation detected in this study.",
// v0.5.11+ — the kind of failure. Present only on fails.
// The same values the SDK uses for its 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+ — age and gender estimated in the browser (face-api.js).
// Null for device-only results, when the face model failed to load,
// and on 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
},
// Simple yes/no flags (version 2) — ALWAYS present, even on a pass, so
// your backend can act on them without reading the scores.
// Version 1 webhooks leave out all three.
// is_duplicate — this person or device took part before (on a pass
// this is for information only — e.g. a returning
// participant after the waiting period → "welcome back").
// low_quality — the capture or check was low quality.
// high_fraud_risk — a high-risk finding was detected.
"is_duplicate": false,
"low_quality": false,
"high_fraud_risk": false,
// Which level of check ran (version 2) — act on WHAT happened, not just
// pass/fail. assuranceLevel: biometric (camera check and repeat check,
// including via the QR code on a phone) | device_only (no camera, weaker) | none.
// verificationOutcome: completed | unverified_technical (camera blocked) |
// declined_by_user (the respondent chose to skip; the check still finishes).
"assuranceLevel": "biometric",
"verificationOutcome": "completed"
// "risk": { ... } — present only when the risk breakdown
// is turned on for the project.
// "duplication": { ... } — the repeat-check breakdown; present
// under the same condition as risk. Shape:
// { overall, deviceMatch,
// biometricMatch, matchedDeviceId?,
// matchedSessionId?,
// matchMethod: 'exact'|'fuzzy'|
// 'biometric'|'none' }
// Version 2 only; version 1 leaves it out.
// v0.5.27+ — repeat-check scope. Version 2 only; version 1 leaves out both.
// "data_collector": "interviewer-7" — the session's data-collector
// label (when set), so you can match a
// result to a collector or wave.
// "prior_participation": { — a RETURNING participant: an earlier match
// "fingerprintId": "fp_…", after the project's waiting period, so it
// "similarity": 0.98, did NOT block. Present on a clean pass
// "sessionId": "sess_last_wave", (e.g. a monthly tracker). Not
// "respondentId": "rsp_…", the same as duplicate_match (an earlier
// "lastSeenAt": 1800000000 match within the period, which blocks).
// }
}
}Handling a version 2 event
The same decisions as the SDK's error handling, on your backend:
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);
}Event body — version 1 (older webhooks)
Version 1 leaves out these fields, so handlers written before version 2 keep working: respondent_id, metadata, timestamp, fraud_signals, failure_reasons, summary, risk, failure_category, duplicate_match, demographics, and the yes/no flags is_duplicate, low_quality, high_fraud_risk. Existing version 1 webhooks keep receiving version 1 until you upgrade them; new webhooks use version 2.
Empty (null) scores apply to version 1 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 only change made to version 1 after it was fixed, because sending a number for a check that never ran would be misleading. Check for null before comparing either field to a threshold: in many languages null < 0.5 is true, which would treat a check that never ran as a failed one. On version 2 you can also use assuranceLevel, which says which level of check ran.
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. Check it before you act on the event. |
X-VerifyHuman-Timestamp | Unix seconds. We recommend accepting up to 5 minutes of clock difference. |
X-VerifyHuman-Idempotency-Key | The check's session ID. Use it to ignore repeat deliveries, and answer a repeat with a 2xx status, not an error. |
X-VerifyHuman-Attempt | Attempt number, starting at 1. 1 = first delivery; 2 or more = a retry. Helps you tell "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
Every event is saved and delivered from a queue, so a short outage on your side does not lose it. Each delivery moves through these states until it ends:
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 it is", so retrying would only add load on your side. The delivery moves toEXHAUSTED. - Repeats: use
X-VerifyHuman-Idempotency-Keyto ignore repeat deliveries. Answer a repeat with a 2xx status; an error status marks the delivery as failed, and some error statuses are retried. - Order: not guaranteed. Because of retries, a later event for a session can arrive before a retry of an earlier one. Sort on
data.timestampif order matters.
Delivery history
See every attempt on each project's Webhooks tab in the dashboard, or in code:
GET /api/v1/projects/{project_id}/webhooks/{webhook_id}/deliveriesReturns the status code, response body, duration, attempt number and final state of each attempt, so you can find out why deliveries fail without searching your own server logs.
Webhook secret format
A random 32-byte value, written in hex. We create it when you create the webhook and show it to you ONCE in the dashboard. If you lose it, replace it in the dashboard or with the MCP update_webhook tool. The old secret stops working immediately.
Test deliveries
The dashboard's "Test delivery" button (or the MCP test_webhook tool) sends a sample verification.completed event to your URL with valid signature headers. Use it to test your signature check and repeat handling before you go live.