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, and recommended_decision so your backend can route without an introspection round-trip. verification.completed still 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

HeaderDescription
Content-TypeAlways application/json.
X-VerifyHuman-EventEvent type (e.g., verification.completed). Branch on this without parsing the body.
X-VerifyHuman-Signaturesha256=<hex> HMAC over {timestamp}.{body} (dot-joined) using the webhook signing secret. Verify before processing.
X-VerifyHuman-TimestampUnix seconds. 5-minute skew tolerance recommended on your side.
X-VerifyHuman-Idempotency-KeyThe verification's session id. Use for dedup; don't 4xx duplicates — that triggers another retry.
X-VerifyHuman-Attempt1-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 to EXHAUSTED.
  • Idempotency: use X-VerifyHuman-Idempotency-Key to 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.timestamp if 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}/deliveries

Returns 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.