Skip to main content
ProofAge tells your backend about a verification’s outcome by sending an HTTP POST to the workspace’s webhook URL, set in the console on the workspace’s page. The full request, field by field, is on Decision webhook.

When webhooks are sent

A webhook is sent each time a verification moves to one of these statuses: One verification can therefore send several webhooks, for example resubmission_requested and then approved. Handle every status, and ignore those you do not act on.

Payload

  • reason is a decision reason when the status is declined or resubmission_requested, and null otherwise, with one exception: when a facial age estimation can’t confirm 18+ and asks the person for an ID, the webhook is resubmission_requested with reason: null. GET /v1/verifications/{id} reports that verification as documents_required.
  • When duplicate detection found the same face behind another verification, the payload also has duplicate_detected: true, duplicate_count and duplicate_of (verification_id, external_id).
  • When someone on your team approved or declined the verification by hand in the console, it has manual_moderation: the action, the reason they gave, and who did it.
  • document is on every webhook, whatever the status. It is the same object GET /v1/verifications/{id}/document returns, without media and meta, read from the attempt that decided the verification: type, issuing_country and fields. issuing_subdivision is the state or province that issued the document, as a bare code beside issuing_country (for example FL with US), or null; today it is filled for US driving licences and ID cards, it is on every workspace, and it is kept after erasure. Identity (KYC) workspaces receive eleven fields (address is the printed text as read, not parsed, and may contain line breaks); age verification workspaces receive first_name, last_name, date_of_birth and document_number only, and the other seven keys are absent. nationality is published only when the document itself states it. The examples above show a KYC document on declined, an age workspace on approved, and the empty object elsewhere.
  • null means the field was not read, is not printed on that document, or no document was read at all: a facial age estimation that passed without an ID, a test workspace (nothing is analysed), or a wallet check. document itself is never null. Dates are YYYY-MM-DD, countries are ISO 3166-1 alpha-2, and type and gender are open enums; see Reading results.
  • A resend, or a manual retry from the console or the MCP server, carries the document as it is now; an automatic retry sends the body as it was first sent. After the person’s data is erased, a resend carries only type and issuing_country, and every field is null.
  • The webhook has no images. Fetch them with your key, as described in Reading results.
  • An optional fingerprint_signals object carries device and network signals. Treat it as informational; its fields may change.

Headers

Verifying the signature

Verify every webhook before trusting it: anyone can POST to your URL.
  1. Read the raw request body. Do not parse and re-serialise it: the signature covers the exact bytes.
  2. Reject the request if X-Timestamp is more than 300 seconds away from your clock. This stops an old, captured request from being replayed.
  3. Compute the hex HMAC-SHA256 of X-Timestamp + "." + raw body with the workspace’s active secret key.
  4. Compare it with X-HMAC-Signature in constant time.
Webhooks are signed with the active key only. When you rotate keys, deploy the new key to your webhook handler before you make it active. The server SDKs do all four steps in one call:
Without an SDK:

Responding and retries

Answer with any 2xx as soon as the signature checks out, and do the work in the background. Redirects are followed, and the final response is the one that counts. Every attempt, with your response code and body, is listed in the console under the workspace’s webhooks, where you can also send a delivery again. The MCP tools list-webhook-deliveries, retry-webhook-delivery and resend-webhook-delivery do the same.

Duplicates and order

  • A retried delivery keeps its X-ProofAge-Webhook-Delivery-Id. Store the IDs you have processed and skip one you have seen.
  • A delivery sent again by hand is a new delivery with a new ID, so also make the processing itself idempotent: applying approved to a user who is already approved changes nothing.
  • Retries mean webhooks can arrive out of order. Compare timestamp with the last one you applied for that verification, or fetch the current state with GET /v1/verifications/{id} before acting.

Receiving webhooks locally

Your development machine is not reachable from the internet, so ProofAge cannot deliver webhooks to localhost. Two ways to receive them while you build:
  • A tunnel. Expose your local handler with a tunnel such as ngrok or Cloudflare Tunnel and set the tunnel’s HTTPS URL as the test workspace’s webhook URL.
  • Replay. Leave the webhook URL pointing anywhere, then use the MCP tool get-webhook-delivery-request: it returns the exact request ProofAge sends, signed with the active secret key, ready to replay against localhost with curl. Send the body byte for byte; a re-serialised body fails the signature check.

Before going live

  • Verify the signature and the timestamp on every request, in production too.
  • Serve the webhook URL over HTTPS.
  • Handle all six statuses.
  • Log the delivery ID, the status and your response, so you can match them with the console’s delivery log.
The complete list is in the Go-live checklist.