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. Your code can also subscribe more URLs to the decisions through the API; see Webhook subscriptions. Every payload has an event field. Read it first: A payload without event is a status.updated: a retry of a delivery created before the field existed is sent as it was first built.

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

  • event is status.updated in each of these examples. The data.updated payload is under Data corrections.
  • 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.

Data corrections

When an administrator or a support specialist corrects a document field in the console, or with the MCP tool correct-document-fields, ProofAge sends a webhook with event: "data.updated" to the workspace’s webhook URL, if it has one. Webhook subscriptions do not receive it. It has the same fields as a status webhook, plus changed_fields:
data.updated
  • status is the verification’s current status. A correction never changes it and runs no check again, so do not treat the webhook as a decision.
  • document holds the values as they are now, corrected ones included. changed_fields lists the names of what this correction changed, and no values: keys of document.fields, or type, issuing_country and issuing_subdivision. A corrected issuing_country clears the subdivision that was read with the old country, unless the subdivision was corrected too.
  • timestamp is when the correction was made.
  • The same corrected values are returned by GET /v1/verifications/{id}/document.
  • Corrections are allowed on approved, declined and review verifications. In a test workspace they are allowed when a document was photographed but never read, so you can try your handler before going live.
Dispatch on event first. A handler that ignores it sees something that looks like a repeat of the status it already has, which it must tolerate anyway; see Duplicates and order.

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 to the workspace’s webhook URL 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 not followed: a 3xx is your answer, so set the URL your handler actually serves. Every attempt, with your response code and the first 2 KB of your response 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.

The webhook URL

ProofAge sends webhooks only to public addresses. When you set the webhook URL:
  • It must be an https URL. An http URL saved before this rule keeps receiving webhooks.
  • Private, loopback, link-local and cloud-metadata addresses are refused, and so is a host name with such an address among its DNS records. So are local names: a name without a dot, or one ending in .localhost, .local, .internal or .home.arpa.
  • A URL with a username or password is refused.
  • Write an internationalised host name in punycode (xn--…); a Unicode host name is refused.
The address is checked again before each delivery. A delivery whose host now resolves to a private address is skipped and not retried; a host name that does not resolve is retried like a failed connection. To receive webhooks on your own machine, see Receiving webhooks locally. callback_url and the workspace’s redirect URL are opened by the person’s browser, not by ProofAge, so they only need to be http or https URLs.

Webhook subscriptions

Besides the workspace’s webhook URL, your code can subscribe more URLs to the decisions, and remove them again. This is the REST hook pattern the Zapier app uses: it subscribes when a Zap is turned on and deletes the subscription when it is turned off.
The answer is 201 with the subscription: id, url, statuses, include_document_data and created_at. GET /v1/webhook-subscriptions lists the workspace’s subscriptions, newest first, and DELETE /v1/webhook-subscriptions/{id} deletes one (204); deliveries already queued for it are then not sent.
  • In addition to the webhook URL. Each decision goes to the workspace’s webhook URL, if it has one, and to every subscription that asked for its status, as separate deliveries with their own delivery IDs.
  • Decisions only. A subscription receives status.updated webhooks, never data.updated. statuses picks which: approved, declined, resubmission_requested, review, abandoned, expired. Omit it, or send null, for all six.
  • No document data by default. Unless include_document_data is true, the body leaves out document, fingerprint_signals and manual_moderation.performed_by (who moderated). A delivery sent again from the console stays without them. With include_document_data: true, the body is the one the webhook URL gets.
  • Signed with the key that created it. Its deliveries are signed with the secret key that signed the POST, so the subscriber verifies them with the key it already holds. Once that key is deleted, they are signed with the workspace’s active key.
  • 410 Gone unsubscribes. A delivery answered with 410 deletes the subscription. Any other answer is handled as in Responding and retries.
  • Up to 50 per workspace. Past that, creating one answers 422 WEBHOOK_SUBSCRIPTION_LIMIT; delete one first.
  • The URL follows the same rules as the webhook URL, up to 2,048 characters.
A subscription’s body without document data:
approved (subscription)
Duplicate fields and manual_moderation (without performed_by) appear on it as they do on the webhook URL’s body.

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 data.updated is not a new decision: update the stored document fields for that verification and leave its status alone.
  • 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, and read event before status so a data.updated is not mistaken for a decision.
  • 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.