Skip to content

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:

StatusMeaning
approvedVerification passed all checks.
declinedVerification failed due to fraud, blocklist, or unresolvable issues.
resubmission_requestedMedia quality or technical problems — user can re-upload.
abandonedNo submission within 7 days of starting.
expiredNo media uploaded within 7 days of creation.
reviewFlagged 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

json
{
  "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"
}
FieldTypeDescription
verification_idstringUUID of the verification session.
statusstringTerminal status that triggered the webhook.
external_idstring|nullThe external identifier you provided when creating the verification.
external_metadataobject|nullThe metadata object you attached at creation. Returned as-is.
reasonstring|nullReason code when status is declined or resubmission_requested. null for other statuses.
timestampstringISO 8601 timestamp of the decision.

Declined / Resubmission Payloads

When the status is declined or resubmission_requested, the reason field contains the primary reason code:

json
{
  "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:

json
{
  "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"
  }
}
FieldTypeDescription
duplicate_detectedbooleantrue when a duplicate match is found. Absent or false otherwise.
duplicate_of.verification_idstringUUID of the earlier verification that matched.
duplicate_of.external_idstring|nullExternal 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:

HeaderDescriptionExample
Content-TypeAlways application/json.application/json
X-Auth-ClientYour 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-SignatureHMAC-SHA256 hex digest of the request payload. Use this to verify authenticity.a1b2c3d4e5f6...
X-TimestampUnix 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

  1. Extract the X-Timestamp and X-HMAC-Signature headers from the incoming request.
  2. Read the raw request body (do not parse and re-serialize it).
  3. Construct the signature payload by joining the timestamp and the raw body with a period:
    signaturePayload = "{timestamp}.{rawBody}"
  4. Compute HMAC-SHA256 of the signature payload using your secret key.
  5. Compare the computed hex digest with the X-HMAC-Signature header 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) < 300

Reject webhooks with timestamps outside this window.


Signature Verification Examples

Node.js

javascript
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

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", 200

PHP

php
<?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.

AttemptDelay after previous attemptTotal elapsed
1 (initial)0 min
2 (1st retry)5 minutes5 min
3 (2nd retry)15 minutes20 min
4 (3rd retry)60 minutes80 min

After 4 total attempts (1 initial + 3 retries), the webhook is marked as failed.

Retryable Status Codes

HTTP StatusRetried?Reason
2xxNoSuccess — delivery complete.
408YesRequest timeout — your server was too slow.
429YesRate limited — too many requests on your side.
5xxYesServer error — temporary failure.
Other 4xxNoClient 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 200

Handle Duplicates (Idempotency)

Due to retries and network issues, your endpoint may receive the same webhook more than once. Make your handler idempotent:

  • Use verification_id as 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_id to 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 200 within 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_detected field is checked and multi-account fraud is flagged.
  • [ ] Webhook failures are monitored (e.g., alerting on repeated 4xx/5xx from 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

  1. Install ngrok from ngrok.com.
  2. Start your local server (e.g., on port 3000).
  3. Run ngrok:
    bash
    ngrok http 3000
  4. Copy the HTTPS forwarding URL (e.g., https://a1b2c3d4.ngrok-free.app).
  5. Set your webhook URL in the ProofAge dashboard to:
    https://a1b2c3d4.ngrok-free.app/webhooks/proofage
  6. 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.

ProofAge Developer Documentation