Webhooks
VerifyHuman sends real-time HTTP POST notifications to your server when events occur in your account. Use webhooks to automate workflows, sync data, and respond to verifications instantly.
Quick Start
1. Create an Endpoint
Go to Dashboard > Webhooks > Add Endpoint and enter:
- URL — Your HTTPS endpoint (e.g.,
https://your-app.com/webhooks/verifyhuman) - Environment —
LiveorTest(only events from the matching environment are delivered) - Event Subscriptions — Choose "All Events" or select specific event types
2. Save Your Signing Secret
When you create an endpoint, a signing secret (prefixed whsec_) is displayed once. Copy and store it securely — you will need it to verify incoming webhook signatures.
3. Receive Events
VerifyHuman will POST a JSON payload to your URL whenever a subscribed event occurs. Your endpoint should:
- Verify the signature (see below)
- Return a
2xxHTTP status within 10 seconds - Process the event asynchronously if needed
4. Test Your Endpoint
Click Send Test Event on the endpoint detail page to send a test.ping event. This verifies connectivity and signature verification without affecting real data.
Event Envelope
Every webhook delivery sends a JSON body with this structure:
{
"id": "e4b0c3a7-1234-4f5e-8a6b-9c0d1e2f3a4b",
"type": "verification.completed",
"created": "2025-01-15T14:30:00.000000Z",
"environment": "live",
"data": {
"verification_id": "ver_abc123",
"status": "PASS",
"confidence": 92.5,
"product": "verifyhuman"
}
}
| Field | Type | Description |
|---|---|---|
id |
string | Unique event ID (UUID). Stable across retries. |
type |
string | Event type (e.g., verification.completed). |
created |
string | ISO 8601 UTC timestamp of when the event was created. |
environment |
string | live or test. |
data |
object | Event-specific payload data. |
Delivery Headers
Each webhook request includes these headers:
| Header | Example | Description |
|---|---|---|
X-VerifyHuman-Signature |
sha256=abc123... |
HMAC-SHA256 signature for payload verification. |
X-VerifyHuman-Timestamp |
1705312200 |
Unix timestamp (seconds) when the signature was generated. |
X-VerifyHuman-Event |
verification.completed |
The event type. |
X-VerifyHuman-Event-Id |
e4b0c3a7-... |
Unique event ID (matches id in the payload). |
X-VerifyHuman-Delivery-Id |
d1e2f3a4-... |
Unique delivery attempt ID. Changes on retries. |
X-VerifyHuman-Environment |
live |
live or test. |
Content-Type |
application/json |
Always JSON. |
User-Agent |
VerifyHuman-Webhooks/1.0 |
Identifies VerifyHuman as the sender. |
Signature Verification
Every webhook is signed using your endpoint's signing secret. Always verify signatures to ensure the request came from VerifyHuman and has not been tampered with.
How Signing Works
- VerifyHuman creates the signed payload:
{timestamp}.{json_body} - Computes HMAC-SHA256 using your signing secret
- Sends the signature as
sha256={hex_digest}in theX-VerifyHuman-Signatureheader
Python
import hmac
import hashlib
import time
WEBHOOK_SECRET = "whsec_your_secret_here"
MAX_TIMESTAMP_AGE = 300 # 5 minutes
def verify_webhook(request):
signature = request.headers.get('X-VerifyHuman-Signature', '')
timestamp = request.headers.get('X-VerifyHuman-Timestamp', '')
body = request.get_data()
# Reject stale timestamps
try:
ts = int(timestamp)
if abs(time.time() - ts) > MAX_TIMESTAMP_AGE:
return False
except (ValueError, TypeError):
return False
# Reconstruct signed payload
signed_payload = f'{timestamp}.'.encode('utf-8') + body
# Compute expected signature
expected = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
signed_payload,
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(signature, expected)
Node.js / JavaScript
const crypto = require('crypto');
const WEBHOOK_SECRET = 'whsec_your_secret_here';
const MAX_TIMESTAMP_AGE = 300; // 5 minutes
function verifyWebhook(req) {
const signature = req.headers['x-verifyhuman-signature'] || '';
const timestamp = req.headers['x-verifyhuman-timestamp'] || '';
const body = req.rawBody; // Use raw body, not parsed JSON
// Reject stale timestamps
const ts = parseInt(timestamp, 10);
if (isNaN(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_TIMESTAMP_AGE) {
return false;
}
// Reconstruct signed payload
const signedPayload = Buffer.concat([
Buffer.from(`${timestamp}.`, 'utf-8'),
Buffer.from(body)
]);
// Compute expected signature
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(signature, 'utf-8'),
Buffer.from(expected, 'utf-8')
);
}
PHP
<?php
$webhookSecret = 'whsec_your_secret_here';
$maxTimestampAge = 300; // 5 minutes
$signature = $_SERVER['HTTP_X_VERIFYHUMAN_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_VERIFYHUMAN_TIMESTAMP'] ?? '';
$body = file_get_contents('php://input');
// Reject stale timestamps
$ts = intval($timestamp);
if (abs(time() - $ts) > $maxTimestampAge) {
http_response_code(401);
echo json_encode(['error' => 'Stale timestamp']);
exit;
}
// Reconstruct signed payload
$signedPayload = $timestamp . '.' . $body;
// Compute expected signature
$expected = 'sha256=' . hash_hmac('sha256', $signedPayload, $webhookSecret);
// Constant-time comparison
if (!hash_equals($expected, $signature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Signature valid — process the event
$event = json_decode($body, true);
$eventType = $event['type'];
// ... handle event
?>
Event Types
Verification Events
| Event | Trigger | Category |
|---|---|---|
verification.completed |
Any verification reaches a terminal state (PASS, FAIL, or ERROR) | verification |
verification.passed |
A verification completed with PASS status | verification |
verification.failed |
A verification completed with FAIL or ERROR status | verification |
Example payload (verification.completed):
{
"id": "e4b0c3a7-1234-4f5e-8a6b-9c0d1e2f3a4b",
"type": "verification.completed",
"created": "2025-01-15T14:30:00.000000Z",
"environment": "live",
"data": {
"verification_id": "ver_abc123",
"status": "PASS",
"confidence": 92.5,
"product": "verifyhuman",
"user_id": 42
}
}
Compliance / KYC Events
| Event | Trigger | Category |
|---|---|---|
compliance.completed |
A compliance/KYC screening completed (cleared or potential match) | compliance |
compliance.hit_detected |
A compliance screening found a potential AML/PEP/Sanctions match | compliance |
compliance.report_ready |
A KYC/compliance report has been generated and delivered | compliance |
Example payload (compliance.completed):
{
"id": "c8d3e6f1-5678-4a9b-2e0f-3a4b5c6d7e8f",
"type": "compliance.completed",
"created": "2025-01-15T14:30:00.000000Z",
"environment": "live",
"data": {
"verification_id": "ver_abc123",
"status": "PASS",
"verification_type": "kyc_plus",
"confidence": 92.5,
"screening_status": "Cleared",
"total_hits": 0
}
}
Example payload (compliance.hit_detected):
{
"id": "d9e4f7a2-6789-4b0c-3f1a-4b5c6d7e8f9a",
"type": "compliance.hit_detected",
"created": "2025-01-15T14:35:00.000000Z",
"environment": "live",
"data": {
"verification_id": "ver_def456",
"status": "PASS",
"verification_type": "kyc_plus",
"confidence": 88.0,
"screening_status": "Potential Match",
"total_hits": 2
}
}
Example payload (compliance.report_ready):
{
"id": "e0f5a8b3-7890-4c1d-4a2b-5c6d7e8f9a0b",
"type": "compliance.report_ready",
"created": "2025-01-15T14:40:00.000000Z",
"environment": "live",
"data": {
"verification_id": "ver_abc123",
"status": "PASS",
"verification_type": "kyc_plus",
"confidence": 92.5,
"delivery_method": "email",
"delivery_target": "compliance@example.com"
}
}
Stateless v1 Compliance Events (VerifyTrust API)
These events are emitted by the new stateless VerifyTrust API
endpoints (POST /api/compliance/v1/screen and
POST /api/compliance/v1/rescreen). They are delivery
notifications for screening results — they do not create a
compliance workflow, case record, or customer profile inside
VerifyHuman. Webhook payloads are generated from the same public-safe
response returned by the API: they do not include subject payloads,
raw provider responses, or watchlist source records.
| Event | Trigger | Category |
|---|---|---|
compliance.screening.completed |
A POST /api/compliance/v1/screen completed successfully |
compliance |
compliance.screening.failed |
A POST /api/compliance/v1/screen failed after authentication and validation (e.g. provider unavailable) |
compliance |
compliance.rescreen.completed |
A POST /api/compliance/v1/rescreen completed successfully |
compliance |
compliance.rescreen.risk_changed |
A rescreen completed and risk_change is risk_increased or risk_decreased (never for risk_unchanged or unknown_without_prior_result) |
compliance |
Example payload (compliance.screening.completed):
{
"id": "f1a6b9c4-8901-4d2e-5b3c-6d7e8f9a0b1c",
"type": "compliance.screening.completed",
"created": "2026-04-24T10:15:00.000000Z",
"environment": "live",
"data": {
"object": {
"id": "vcmp_01HXX2ABCDEF",
"object": "compliance.screening",
"status": "completed",
"client_reference_id": "customer_123",
"risk": {
"score": 45,
"level": "medium",
"decision": "review",
"reasons": ["pep_possible_match"]
},
"flags": ["PEP_POSSIBLE_MATCH"],
"matches": [],
"data_retention": {
"input_payload_stored": false,
"result_payload_stored": false,
"raw_provider_response_stored": false,
"biometric_media_stored": false,
"retention_model": "ephemeral_processing_only"
}
}
}
}
Example payload (compliance.screening.failed):
{
"id": "a2b7c0d5-9012-4e3f-6c4d-7e8f9a0b1c2d",
"type": "compliance.screening.failed",
"created": "2026-04-24T10:16:00.000000Z",
"environment": "live",
"data": {
"object": {
"object": "compliance.screening",
"status": "failed",
"client_reference_id": "customer_123",
"error": {
"type": "provider_unavailable",
"code": "compliance_provider_unavailable",
"message": "Compliance screening provider is temporarily unavailable."
},
"data_retention": {
"input_payload_stored": false,
"result_payload_stored": false,
"raw_provider_response_stored": false,
"biometric_media_stored": false,
"retention_model": "ephemeral_processing_only"
}
}
}
}
Example payload (compliance.rescreen.completed):
{
"id": "b3c8d1e6-0123-4f4a-7d5e-8f9a0b1c2d3e",
"type": "compliance.rescreen.completed",
"created": "2026-04-24T10:17:00.000000Z",
"environment": "live",
"data": {
"object": {
"id": "vcmp_01HXX9ZYXWVU",
"object": "compliance.screening",
"status": "completed",
"client_reference_id": "customer_123",
"risk": {
"score": 12, "level": "low", "decision": "clear", "reasons": []
},
"flags": [],
"matches": [],
"rescreen": {
"previous_screening_id": "vcmp_01HXX2ABCDEF",
"reason": "periodic_rescreen",
"risk_change": "risk_unchanged"
},
"data_retention": {
"input_payload_stored": false,
"result_payload_stored": false,
"raw_provider_response_stored": false,
"biometric_media_stored": false,
"retention_model": "ephemeral_processing_only"
}
}
}
}
Example payload (compliance.rescreen.risk_changed):
{
"id": "c4d9e2f7-1234-4a5b-8e6f-9a0b1c2d3e4f",
"type": "compliance.rescreen.risk_changed",
"created": "2026-04-24T10:18:00.000000Z",
"environment": "live",
"data": {
"object": {
"id": "vcmp_01HXX9ZYXWVU",
"object": "compliance.screening",
"status": "completed",
"client_reference_id": "customer_123",
"risk": {
"score": 78, "level": "high", "decision": "review",
"reasons": ["sanctions_strong_match"]
},
"flags": ["SANCTIONS_STRONG_MATCH"],
"rescreen": {
"previous_screening_id": "vcmp_01HXX2ABCDEF",
"reason": "regulatory_refresh",
"risk_change": "risk_increased"
},
"data_retention": {
"input_payload_stored": false,
"result_payload_stored": false,
"raw_provider_response_stored": false,
"biometric_media_stored": false,
"retention_model": "ephemeral_processing_only"
}
}
}
}
Replay protection
The X-VerifyHuman-Timestamp header carries the Unix timestamp at the
moment the signature was computed. Reject any webhook event whose
timestamp is older than your configured replay window —
5 minutes is the recommended default. A typical verifier:
import hmac, hashlib, time
REPLAY_WINDOW_SECONDS = 5 * 60
def verify(secret: str, body: bytes, sig_header: str, ts_header: str) -> bool:
# 1. Replay-window check.
try:
ts = int(ts_header)
except (TypeError, ValueError):
return False
if abs(time.time() - ts) > REPLAY_WINDOW_SECONDS:
return False
# 2. Recompute signature over "{timestamp}.{raw_body}".
expected = "sha256=" + hmac.new(
secret.encode("utf-8"),
f"{ts_header}.".encode("utf-8") + body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, sig_header)
Billing Events
| Event | Trigger | Category |
|---|---|---|
quota.exceeded |
A user has exhausted their verification credits | billing |
Example payload:
{
"id": "f5c1d4b8-2345-4a6f-9b7c-0d1e2f3a4b5c",
"type": "quota.exceeded",
"created": "2025-01-15T15:00:00.000000Z",
"environment": "live",
"data": {
"user_id": 42,
"credits_remaining": 0,
"plan": "starter"
}
}
Security Events
| Event | Trigger | Category |
|---|---|---|
throttling.triggered |
Verification throttle activated for an IP or user | security |
Example payload:
{
"id": "a6d2e5c9-3456-4b7a-0c8d-1e2f3a4b5c6d",
"type": "throttling.triggered",
"created": "2025-01-15T15:30:00.000000Z",
"environment": "live",
"data": {
"reason": "rate_limit_exceeded",
"ip_address": "203.0.113.42",
"user_id": 42
}
}
System Events
| Event | Trigger | Category |
|---|---|---|
test.ping |
Synthetic test event sent via the dashboard or API | system |
Example payload:
{
"id": "b7e3f6d0-4567-4c8b-1d9e-2f3a4b5c6d7e",
"type": "test.ping",
"created": "2025-01-15T16:00:00.000000Z",
"environment": "test",
"data": {
"message": "Test webhook delivery"
}
}
Environment Behavior
- Live endpoints only receive events from the live environment
- Test endpoints only receive events from the test environment
- Test events are never delivered to live endpoints and vice versa
- Use
test.pingto verify endpoint connectivity without producing real verification data
Delivery and Retry Behavior
Delivery Flow
- An event occurs (e.g., a verification completes)
- VerifyHuman creates a durable event record and delivery records for each matching endpoint
- The delivery worker picks up pending deliveries and sends signed HTTP POST requests
- Your endpoint should return a
2xxstatus code within 10 seconds - If delivery fails, retries are scheduled automatically
What Counts as Success
Any HTTP 2xx response (200–299) is treated as a successful delivery.
What Triggers Retries
Retries are scheduled for:
- Timeouts — No response within 10 seconds
- Connection errors — DNS failure, connection refused, network reset
- Server errors — HTTP 5xx responses
- Rate limiting — HTTP 429 responses
What Does NOT Retry
These HTTP status codes are treated as terminal failures (no retry):
400, 401, 403, 404, 405, 406, 410, 411, 413, 414, 415, 422
Retry Schedule
| Attempt | Delay | Cumulative Wait |
|---|---|---|
| 1 | Immediate | — |
| 2 | 1 minute | 1 minute |
| 3 | 5 minutes | 6 minutes |
| 4 | 30 minutes | 36 minutes |
| 5 | 2 hours | ~2.5 hours |
| 6 | 12 hours | ~14.5 hours |
| 7 | 24 hours | ~38.5 hours |
After 7 attempts, the delivery is marked as failed_terminal.
Delivery Statuses
| Status | Meaning |
|---|---|
pending |
Queued for first delivery attempt |
processing |
Currently being sent |
delivered |
Successfully delivered (2xx response) |
retry_scheduled |
Failed, retry scheduled at a future time |
failed_terminal |
All retry attempts exhausted or non-retryable error |
skipped |
Endpoint was inactive, deleted, or had an environment mismatch |
Idempotency
The event ID (X-VerifyHuman-Event-Id) is stable across retries. Use it to deduplicate events on your end. The delivery ID (X-VerifyHuman-Delivery-Id) is unique per attempt, so you can distinguish retries.
Endpoint Health
Health States
| State | Condition | Dashboard Badge |
|---|---|---|
| Healthy | Active, no recent failures, at least one successful delivery | Green — Active |
| New | Active, no deliveries yet | Blue — New |
| Warning | Active, 2–4 consecutive failures | Yellow — Warning |
| Failing | Active, 5+ consecutive failures | Red — Failing |
| Auto-Disabled | Automatically disabled after 10 consecutive failures | Red — Auto-Disabled |
| Inactive | Manually disabled | Gray — Inactive |
Auto-Disable Behavior
After 10 consecutive delivery failures, an endpoint is automatically disabled to protect your server and avoid unnecessary retries. When this happens:
- The endpoint stops receiving new deliveries
- The disabled reason is recorded for audit
- You can re-enable the endpoint from the dashboard after resolving the issue
Re-Enabling an Endpoint
- Go to the endpoint detail page
- Click Enable
- The failure counter resets to zero
- New events will begin delivering again
Replay and Redelivery
Replay an Event
From the Event Detail page, click Replay Event to resend an event to all eligible active endpoints (or a specific endpoint). Replay:
- Creates new delivery records (preserving full audit history)
- Uses the original event payload and event ID
- Only delivers to endpoints that subscribe to the event type
- Respects the current signing secret (not the one from the original delivery)
Redeliver a Failed Delivery
From the Delivery Detail page, click Redeliver to create a new delivery attempt for the same event to the same endpoint. This is useful for recovering from transient failures after fixing your endpoint.
Secret Rotation
You can rotate your signing secret at any time from the endpoint detail page:
- Click Rotate next to the signing secret
- Copy the new secret — it is shown only once
- Update your server to accept the new secret
- All future deliveries will use the new secret
Note: Rotate secrets immediately if you suspect a leak. All deliveries (including retries of older events) are signed with the current secret at the time of sending, so after rotation all subsequent delivery attempts will use the new secret.
Managing Endpoints via API
All webhook operations are available through the REST API at /api/webhooks/.
| Operation | Method | Path |
|---|---|---|
| List endpoints | GET |
/api/webhooks/endpoints |
| Create endpoint | POST |
/api/webhooks/endpoints |
| Get endpoint | GET |
/api/webhooks/endpoints/{id} |
| Update endpoint | PUT |
/api/webhooks/endpoints/{id} |
| Delete endpoint | DELETE |
/api/webhooks/endpoints/{id} |
| Enable endpoint | POST |
/api/webhooks/endpoints/{id}/enable |
| Disable endpoint | POST |
/api/webhooks/endpoints/{id}/disable |
| Rotate secret | POST |
/api/webhooks/endpoints/{id}/rotate-secret |
| Send test event | POST |
/api/webhooks/endpoints/{id}/test |
| List deliveries | GET |
/api/webhooks/deliveries |
| Get delivery | GET |
/api/webhooks/deliveries/{id} |
| Redeliver | POST |
/api/webhooks/deliveries/{id}/redeliver |
| List events | GET |
/api/webhooks/events |
| Get event | GET |
/api/webhooks/events/{id} |
| Replay event | POST |
/api/webhooks/events/{id}/replay |
| List event types | GET |
/api/webhooks/event-types |
All API endpoints require authentication and webhooks.read or webhooks.manage permissions.
Security
- HTTPS only — Endpoint URLs must use HTTPS
- SSRF protection — Private IPs, localhost, and internal domains are blocked at registration and re-validated at delivery time
- HMAC-SHA256 signing — Every delivery is signed with your endpoint's unique secret
- Timestamp validation — Include timestamp checking in your verification logic to reject replay attacks
- Credential-free URLs — URLs containing usernames or passwords are rejected
- Audit trail — All webhook operations (create, delete, enable, disable, rotate, replay) are logged to the audit system
Troubleshooting
My endpoint is not receiving events
- Verify the endpoint is Active (not disabled or deleted)
- Check the environment matches (live endpoint for live events, test for test)
- Confirm the endpoint subscribes to the relevant event type
- Send a test event from the dashboard to verify connectivity
- Check Delivery Logs for error details
My signature verification is failing
- Ensure you are using the raw request body (not parsed/re-serialized JSON)
- Verify the signed payload format:
{timestamp}.{raw_body} - Confirm you are comparing against the full
sha256=...prefix - After secret rotation, update your server before the next delivery
My endpoint was auto-disabled
- Check the endpoint detail page for the disabled reason
- Review recent delivery logs for the error pattern
- Fix the underlying issue on your server
- Click Enable to re-activate the endpoint
Deliveries are stuck in "processing"
- Stale processing locks are automatically recovered after 5 minutes
- If a delivery stays in "processing" longer, it will be reset and retried