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
eventisstatus.updatedin each of these examples. Thedata.updatedpayload is under Data corrections.reasonis a decision reason when the status isdeclinedorresubmission_requested, andnullotherwise, with one exception: when a facial age estimation can’t confirm 18+ and asks the person for an ID, the webhook isresubmission_requestedwithreason: null.GET /v1/verifications/{id}reports that verification asdocuments_required.- When duplicate detection found the same face behind another verification, the payload also has
duplicate_detected: true,duplicate_countandduplicate_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. documentis on every webhook, whatever the status. It is the same objectGET /v1/verifications/{id}/documentreturns, withoutmediaandmeta, read from the attempt that decided the verification:type,issuing_countryandfields.issuing_subdivisionis the state or province that issued the document, as a bare code besideissuing_country(for exampleFLwithUS), ornull; 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 (addressis the printed text as read, not parsed, and may contain line breaks); age verification workspaces receivefirst_name,last_name,date_of_birthanddocument_numberonly, and the other seven keys are absent.nationalityis published only when the document itself states it. The examples above show a KYC document ondeclined, an age workspace onapproved, and the empty object elsewhere.nullmeans 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.documentitself is nevernull. Dates areYYYY-MM-DD, countries are ISO 3166-1 alpha-2, andtypeandgenderare 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
typeandissuing_country, and every field isnull. - The webhook has no images. Fetch them with your key, as described in Reading results.
- An optional
fingerprint_signalsobject 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 toolcorrect-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
statusis the verification’s current status. A correction never changes it and runs no check again, so do not treat the webhook as a decision.documentholds the values as they are now, corrected ones included.changed_fieldslists the names of what this correction changed, and no values: keys ofdocument.fields, ortype,issuing_countryandissuing_subdivision. A correctedissuing_countryclears the subdivision that was read with the old country, unless the subdivision was corrected too.timestampis when the correction was made.- The same corrected values are returned by
GET /v1/verifications/{id}/document. - Corrections are allowed on
approved,declinedandreviewverifications. In a test workspace they are allowed when a document was photographed but never read, so you can try your handler before going live.
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 canPOST to your URL.
- Read the raw request body. Do not parse and re-serialise it: the signature covers the exact bytes.
- Reject the request if
X-Timestampis more than 300 seconds away from your clock. This stops an old, captured request from being replayed. - Compute the hex HMAC-SHA256 of
X-Timestamp + "." + raw bodywith the workspace’s active secret key. - Compare it with
X-HMAC-Signaturein constant time.
Responding and retries
Answer with any2xx 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
httpsURL. AnhttpURL 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,.internalor.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.
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.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.updatedwebhooks, neverdata.updated.statusespicks which:approved,declined,resubmission_requested,review,abandoned,expired. Omit it, or sendnull, for all six. - No document data by default. Unless
include_document_dataistrue, the body leaves outdocument,fingerprint_signalsandmanual_moderation.performed_by(who moderated). A delivery sent again from the console stays without them. Withinclude_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 Goneunsubscribes. A delivery answered with410deletes 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.
approved (subscription)
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.updatedis 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
approvedto a user who is already approved changes nothing. - Retries mean webhooks can arrive out of order. Compare
timestampwith the last one you applied for that verification, or fetch the current state withGET /v1/verifications/{id}before acting.
Receiving webhooks locally
Your development machine is not reachable from the internet, so ProofAge cannot deliver webhooks tolocalhost. 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 againstlocalhostwith 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
eventbeforestatusso adata.updatedis 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.