Webhooks
ProofAge uses webhooks to notify your server when a verification reaches a terminal state. Instead of polling the API, configure a webhook URL in your dashboard and receive real-time HTTP POST callbacks with the verification result.
When Webhooks Fire
A decision webhook fires once per verification when it transitions to a terminal status:
| Status | Meaning |
|---|---|
approved | Verification passed all checks. |
declined | Verification failed due to fraud, blocklist, or unresolvable issues. |
resubmission_requested | Media quality or technical problems — user can re-upload. |
abandoned | No submission within 7 days of starting. |
expired | No media uploaded within 7 days of creation. |
review | Flagged for manual admin review. |
TIP
resubmission_requested is a terminal state for that particular submission attempt, but the verification session remains open for the user to re-upload media. A new webhook fires if the resubmission reaches a terminal state.
Payload Structure
Every webhook delivers a JSON payload via HTTP POST to your configured URL.
Standard Payload
{
"verification_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "approved",
"external_id": "user-123",
"external_metadata": {
"plan": "premium"
},
"reason": null,
"timestamp": "2026-03-19T12:05:00+00:00"
}| Field | Type | Description |
|---|---|---|
verification_id | string | UUID of the verification session. |
status | string | Terminal status that triggered the webhook. |
external_id | string|null | The external identifier you provided when creating the verification. |
external_metadata | object|null | The metadata object you attached at creation. Returned as-is. |
reason | string|null | Reason code when status is declined or resubmission_requested. null for other statuses. |
timestamp | string | ISO 8601 timestamp of the decision. |
Declined / Resubmission Payloads
When the status is declined or resubmission_requested, the reason field contains the primary reason code:
{
"verification_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "declined",
"external_id": "user-123",
"external_metadata": null,
"reason": "fraud_detected",
"timestamp": "2026-03-19T12:05:00+00:00"
}See the Error Handling guide for a full list of reason codes.
Duplicate Detection
When ProofAge detects that the same biometric identity appeared in a previous verification, the payload includes additional fields:
{
"verification_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "approved",
"external_id": "user-456",
"external_metadata": null,
"reason": null,
"timestamp": "2026-03-19T12:05:00+00:00",
"duplicate_detected": true,
"duplicate_of": {
"verification_id": "771a9200-c3df-4e88-b201-112233445566",
"external_id": "user-123"
}
}| Field | Type | Description |
|---|---|---|
duplicate_detected | boolean | true when a duplicate match is found. Absent or false otherwise. |
duplicate_of.verification_id | string | UUID of the earlier verification that matched. |
duplicate_of.external_id | string|null | External ID associated with the earlier verification. |
TIP
Duplicate detection fires regardless of the decision outcome. A verification can be approved and still flagged as a duplicate. Use duplicate_of.external_id to detect multi-account fraud in your system.
Outbound Headers
Every webhook request includes the following headers:
| Header | Description | Example |
|---|---|---|
Content-Type | Always application/json. | application/json |
X-Auth-Client | Your workspace's raw API key. Use this to identify which workspace the webhook belongs to if you share a single endpoint across workspaces. | pk_live_abc123... |
X-HMAC-Signature | HMAC-SHA256 hex digest of the request payload. Use this to verify authenticity. | a1b2c3d4e5f6... |
X-Timestamp | Unix timestamp (integer) of when the webhook was sent. Use this for replay protection. | 1742385900 |
Signature Verification
Every webhook is signed with your workspace's secret key so you can verify it was sent by ProofAge and not tampered with in transit.
Algorithm
- Extract the
X-TimestampandX-HMAC-Signatureheaders from the incoming request. - Read the raw request body (do not parse and re-serialize it).
- Construct the signature payload by joining the timestamp and the raw body with a period:
signaturePayload = "{timestamp}.{rawBody}" - Compute HMAC-SHA256 of the signature payload using your secret key.
- Compare the computed hex digest with the
X-HMAC-Signatureheader using a constant-time comparison.
WARNING
The raw body must be the canonical JSON as sent by ProofAge (encoded with JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE). Do not parse and re-serialize the body, as re-serialization may alter key order or escaping and break the signature.
Replay Protection
To prevent replay attacks, verify that the X-Timestamp is within an acceptable window (e.g., 5 minutes) of your server's current time:
abs(currentTime - receivedTimestamp) < 300Reject webhooks with timestamps outside this window.
Signature Verification Examples
Node.js
const crypto = require("crypto");
function verifyWebhookSignature(req, secretKey) {
const timestamp = req.headers["x-timestamp"];
const receivedSignature = req.headers["x-hmac-signature"];
const rawBody = req.body; // raw string, not parsed JSON
// Replay protection: reject if older than 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
return false;
}
const signaturePayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac("sha256", secretKey)
.update(signaturePayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
);
}
// Express example
const express = require("express");
const app = express();
app.post(
"/webhooks/proofage",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf-8");
const isValid = verifyWebhookSignature(
{
headers: req.headers,
body: rawBody,
},
process.env.PROOFAGE_SECRET_KEY
);
if (!isValid) {
return res.status(401).send("Invalid signature");
}
const payload = JSON.parse(rawBody);
// Process the webhook asynchronously
processWebhook(payload).catch(console.error);
// Respond immediately with 200
res.status(200).send("OK");
}
);Python
import hashlib
import hmac
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET_KEY = "your-secret-key"
def verify_signature(timestamp: str, raw_body: str, received_signature: str) -> bool:
now = int(time.time())
if abs(now - int(timestamp)) > 300:
return False
signature_payload = f"{timestamp}.{raw_body}"
expected = hmac.new(
SECRET_KEY.encode(),
signature_payload.encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, received_signature)
@app.route("/webhooks/proofage", methods=["POST"])
def handle_webhook():
timestamp = request.headers.get("X-Timestamp", "")
signature = request.headers.get("X-HMAC-Signature", "")
raw_body = request.get_data(as_text=True)
if not verify_signature(timestamp, raw_body, signature):
abort(401)
payload = request.get_json()
# Process asynchronously (e.g., enqueue a background job)
process_webhook(payload)
return "OK", 200PHP
<?php
function verifyWebhookSignature(
string $timestamp,
string $rawBody,
string $receivedSignature,
string $secretKey
): bool {
// Replay protection
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$signaturePayload = "{$timestamp}.{$rawBody}";
$expected = hash_hmac('sha256', $signaturePayload, $secretKey);
return hash_equals($expected, $receivedSignature);
}
// Usage in a raw PHP endpoint or framework controller
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_HMAC_SIGNATURE'] ?? '';
$rawBody = file_get_contents('php://input');
$secretKey = getenv('PROOFAGE_SECRET_KEY');
if (!verifyWebhookSignature($timestamp, $rawBody, $signature, $secretKey)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$payload = json_decode($rawBody, true);
// Process the webhook (e.g., dispatch a queued job)
processWebhook($payload);
http_response_code(200);
echo 'OK';Retry Policy
If your endpoint does not respond with a 2xx status code, ProofAge retries the webhook with exponential backoff.
| Attempt | Delay after previous attempt | Total elapsed |
|---|---|---|
| 1 (initial) | — | 0 min |
| 2 (1st retry) | 5 minutes | 5 min |
| 3 (2nd retry) | 15 minutes | 20 min |
| 4 (3rd retry) | 60 minutes | 80 min |
After 4 total attempts (1 initial + 3 retries), the webhook is marked as failed.
Retryable Status Codes
| HTTP Status | Retried? | Reason |
|---|---|---|
2xx | No | Success — delivery complete. |
408 | Yes | Request timeout — your server was too slow. |
429 | Yes | Rate limited — too many requests on your side. |
5xx | Yes | Server error — temporary failure. |
Other 4xx | No | Client error (e.g., 400, 404) — marked as completed, not retried. |
WARNING
Non-retryable 4xx errors (except 408 and 429) are treated as permanent failures. Ensure your webhook endpoint never returns 4xx codes for transient issues.
Best Practices
Respond Quickly
Return a 200 OK response as soon as you receive the webhook. Do not perform heavy processing before responding — this risks timeouts and unnecessary retries.
Receive webhook → validate signature → enqueue background job → return 200Handle Duplicates (Idempotency)
Due to retries and network issues, your endpoint may receive the same webhook more than once. Make your handler idempotent:
- Use
verification_idas a deduplication key. - Before processing, check whether you have already handled this verification decision.
- Store the result in a database with a unique constraint on
verification_idto prevent duplicate processing.
Verify Signatures in Production
Always verify the HMAC signature before acting on a webhook payload. Skipping verification in production exposes your system to spoofed webhook attacks.
Enforce Replay Protection
Check the X-Timestamp header and reject webhooks older than 5 minutes. This prevents attackers from replaying captured webhook requests.
Use HTTPS
Your webhook URL must use HTTPS. ProofAge does not deliver webhooks to plain HTTP endpoints.
Log Everything
Log incoming webhook payloads, signature validation results, and processing outcomes. This makes debugging delivery issues straightforward.
Handle All Statuses
Your webhook handler should account for every possible status, including statuses you may not currently act on (like abandoned or expired). At minimum, log unhandled statuses so you can detect new status types.
Production Readiness Checklist
Before going live, verify each item:
- [ ] Webhook URL is HTTPS.
- [ ] Signature verification is implemented and tested.
- [ ] Replay protection rejects timestamps older than 5 minutes.
- [ ] Handler responds with
200within 5 seconds. - [ ] Heavy processing is deferred to a background job / queue.
- [ ] Handler is idempotent — duplicate deliveries do not cause duplicate side effects.
- [ ] All terminal statuses are handled (
approved,declined,resubmission_requested,review,abandoned,expired). - [ ]
duplicate_detectedfield is checked and multi-account fraud is flagged. - [ ] Webhook failures are monitored (e.g., alerting on repeated
4xx/5xxfrom your endpoint). - [ ] Secret key is stored securely (environment variable, secret manager) — never hardcoded.
Local Development
During development, your webhook endpoint is likely running on localhost, which ProofAge cannot reach. Use a tunneling tool to expose your local server.
Using ngrok
- Install ngrok from ngrok.com.
- Start your local server (e.g., on port 3000).
- Run ngrok:bash
ngrok http 3000 - Copy the HTTPS forwarding URL (e.g.,
https://a1b2c3d4.ngrok-free.app). - Set your webhook URL in the ProofAge dashboard to:
https://a1b2c3d4.ngrok-free.app/webhooks/proofage - Submit a test-mode verification and watch the webhook arrive in your terminal.
TIP
Use ngrok http 3000 --inspect to enable the ngrok web inspector at http://localhost:4040, where you can replay failed webhooks and inspect request/response pairs.
Next Steps
- Endpoint Security — Unified guide covering HMAC signing, multi-key behavior, key rotation, and webhook verification.
- Error Handling — Understand error response formats and the full reason code catalog.
- Testing — Set up test-mode verifications and mock webhooks for local development.
- Verification Flow — Review the complete verification lifecycle.