> ## Documentation Index
> Fetch the complete documentation index at: https://docs.proofage.net/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive signed decision and data events, verify them, and handle retries and duplicates.

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](/api-reference/webhook-decision). Your code can also subscribe more URLs to the decisions through the API; see [Webhook subscriptions](#webhook-subscriptions).

Every payload has an `event` field. Read it first:

| `event` | Meaning |
| - | - |
| `status.updated` | The verification moved to `status`. This is every webhook described under [When webhooks are sent](#when-webhooks-are-sent). |
| `data.updated` | Someone on your team [corrected document fields](/console/verifications-and-review#correcting-document-fields) the reader got wrong. See [Data corrections](#data-corrections). |

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:

| Status | What to do |
| - | - |
| `approved` | Grant access. |
| `declined` | Refuse; `reason` says why. |
| `resubmission_requested` | Nothing yet: the person is asked to retake an image, or to add an ID after a facial age estimation, in the same verification. |
| `review` | Wait: a person decides in the console, and another webhook follows. Test workspaces send it on every submission. |
| `abandoned` | The person stopped; offer them a new verification if they come back. |
| `expired` | The link was never used; the same. |

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

<CodeGroup>
  ```json declined theme={null}
  {
    "verification_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "declined",
    "external_id": "user_12345",
    "external_metadata": { "plan": "premium" },
    "reason": "document.face.mismatch",
    "timestamp": "2026-09-29T12:05:00+00:00",
    "document": {
      "type": "passport",
      "issuing_country": "DE",
      "issuing_subdivision": null,
      "fields": {
        "first_name": "JANE",
        "middle_name": null,
        "last_name": "DOE",
        "date_of_birth": "1990-04-12",
        "gender": "F",
        "nationality": "DE",
        "place_of_birth": "BERLIN",
        "address": null,
        "document_number": "C01X00T47",
        "issue_date": "2020-04-14",
        "expiry_date": "2030-04-13"
      }
    }
  }
  ```

  ```json approved theme={null}
  {
    "verification_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "approved",
    "external_id": "user_12345",
    "external_metadata": { "plan": "premium" },
    "reason": null,
    "timestamp": "2026-09-29T12:05:00+00:00",
    "document": {
      "type": "id",
      "issuing_country": "FR",
      "issuing_subdivision": null,
      "fields": {
        "first_name": "JEAN",
        "last_name": "MARTIN",
        "date_of_birth": "1988-11-02",
        "document_number": "X4RTBPFW4"
      }
    },
    "duplicate_detected": true,
    "duplicate_count": 1,
    "duplicate_of": {
      "verification_id": "771a9200-c3df-4e88-b201-112233445566",
      "external_id": "user_67890"
    }
  }
  ```

  ```json approved (US driving licence) theme={null}
  {
    "verification_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "approved",
    "external_id": "user_12345",
    "external_metadata": { "plan": "premium" },
    "reason": null,
    "timestamp": "2026-10-02T12:05:00+00:00",
    "document": {
      "type": "driver_license",
      "issuing_country": "US",
      "issuing_subdivision": "CA",
      "fields": {
        "first_name": "JANE",
        "last_name": "DOE",
        "date_of_birth": "1990-04-12",
        "document_number": "D1234567"
      }
    }
  }
  ```

  ```json resubmission_requested (retake) theme={null}
  {
    "verification_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "resubmission_requested",
    "external_id": "user_12345",
    "external_metadata": { "plan": "premium" },
    "reason": "selfie.face.not_clear",
    "timestamp": "2026-09-29T12:03:00+00:00",
    "document": {
      "type": null,
      "issuing_country": null,
      "issuing_subdivision": null,
      "fields": {
        "first_name": null,
        "last_name": null,
        "date_of_birth": null,
        "document_number": null
      }
    }
  }
  ```

  ```json resubmission_requested (ID needed) theme={null}
  {
    "verification_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "resubmission_requested",
    "external_id": "user_12345",
    "external_metadata": { "plan": "premium" },
    "reason": null,
    "timestamp": "2026-09-29T12:03:00+00:00",
    "document": {
      "type": null,
      "issuing_country": null,
      "issuing_subdivision": null,
      "fields": {
        "first_name": null,
        "last_name": null,
        "date_of_birth": null,
        "document_number": null
      }
    }
  }
  ```

  ```json review theme={null}
  {
    "verification_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "review",
    "external_id": "user_12345",
    "external_metadata": { "plan": "premium" },
    "reason": null,
    "timestamp": "2026-09-29T12:04:00+00:00",
    "document": {
      "type": null,
      "issuing_country": null,
      "issuing_subdivision": null,
      "fields": {
        "first_name": null,
        "last_name": null,
        "date_of_birth": null,
        "document_number": null
      }
    }
  }
  ```

  ```json abandoned theme={null}
  {
    "verification_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "abandoned",
    "external_id": "user_12345",
    "external_metadata": { "plan": "premium" },
    "reason": null,
    "timestamp": "2026-10-06T12:03:00+00:00",
    "document": {
      "type": null,
      "issuing_country": null,
      "issuing_subdivision": null,
      "fields": {
        "first_name": null,
        "last_name": null,
        "date_of_birth": null,
        "document_number": null
      }
    }
  }
  ```
</CodeGroup>

* `event` is `status.updated` in each of these examples. The `data.updated` payload is under [Data corrections](#data-corrections).
* `reason` is a [decision reason](/core-technology/decision-reasons) when the status is `declined` or `resubmission_requested`, and `null` otherwise, with one exception: when a [facial age estimation](/core-technology/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](/core-technology/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](/integration/sandbox-testing) (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](/integration/retrieving-results#the-document).
* 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](/console/data-retention), 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](/integration/retrieving-results#the-images).
* 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](#webhook-subscriptions) do not receive it. It has the same fields as a status webhook, plus `changed_fields`:

```json data.updated theme={null}
{
  "verification_id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "data.updated",
  "status": "approved",
  "external_id": "user_12345",
  "external_metadata": { "plan": "premium" },
  "reason": null,
  "timestamp": "2026-10-08T09:30:00+00:00",
  "document": {
    "type": "id",
    "issuing_country": "FR",
    "issuing_subdivision": null,
    "fields": {
      "first_name": "JEAN",
      "last_name": "MARTIN",
      "date_of_birth": "1988-02-11",
      "document_number": "X4RTBPFW4"
    }
  },
  "changed_fields": ["date_of_birth"]
}
```

* `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](/integration/sandbox-testing) 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](#duplicates-and-order).

## Headers

| Header | Value |
| - | - |
| `X-HMAC-Signature` | Hex HMAC-SHA256 of `{X-Timestamp}.{raw body}`, keyed with the workspace's **active** secret key (for a [subscription](#webhook-subscriptions), the key that created it). |
| `X-Timestamp` | Unix time, in seconds, when the request was signed. |
| `X-Auth-Client` | The workspace's public key. |
| `X-ProofAge-Webhook-Delivery-Id` | The delivery's ID; the same on every automatic retry of that delivery. |
| `Content-Type`, `Accept` | `application/json` |

## 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](/console/workspaces-and-keys), deploy the new key to your webhook handler before you make it active.

The server SDKs do all four steps in one call:

<CodeGroup>
  ```js Node.js theme={null}
  // Next.js App Router, Hono, Cloudflare Workers or anything with a standard Request
  import { webhookHandler } from '@proofage/node';

  export const POST = webhookHandler(async (payload) => {
    // payload.event, payload.verification_id, payload.status, payload.reason, …
  });
  ```

  ```python Python theme={null}
  from proofage import WebhookVerificationError, verify_webhook

  def handle(raw_body: bytes, headers: dict):
      try:
          payload = verify_webhook(raw_body, headers)
      except WebhookVerificationError:
          return 401
      # payload.event, payload.verification_id, payload.status, payload.reason, …
      return 200
  ```

  ```php PHP theme={null}
  use ProofAge\Sdk\Webhooks\WebhookVerifier;
  use ProofAge\Sdk\Exceptions\WebhookVerificationException;

  $verifier = new WebhookVerifier(getenv('PROOFAGE_API_KEY'), getenv('PROOFAGE_SECRET_KEY'));

  try {
      $verifier->verifyHeaders(getallheaders(), file_get_contents('php://input'));
  } catch (WebhookVerificationException $e) {
      http_response_code($e->statusCode);
      exit;
  }
  ```

  ```php Laravel theme={null}
  // routes/api.php
  Route::post('/webhooks/proofage', [ProofAgeWebhookController::class, 'handle'])
      ->middleware('proofage.verify_webhook');
  ```
</CodeGroup>

Without an SDK:

<CodeGroup>
  ```js Node.js theme={null}
  import crypto from 'node:crypto';

  export function verifyWebhook(rawBody, headers, secretKey) {
    const timestamp = Number(headers['x-timestamp']);
    const received = String(headers['x-hmac-signature'] ?? '');
    if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
      return false;
    }
    const expected = crypto.createHmac('sha256', secretKey).update(`${timestamp}.${rawBody}`).digest('hex');
    // timingSafeEqual throws on different lengths, so compare lengths first.
    return received.length === expected.length
      && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  }

  // Express: keep the body raw on this route.
  app.post('/webhooks/proofage', express.raw({ type: 'application/json' }), (req, res) => {
    const rawBody = req.body.toString('utf8');
    if (!verifyWebhook(rawBody, req.headers, process.env.PROOFAGE_SECRET_KEY)) {
      return res.status(401).end();
    }
    queueWebhook(JSON.parse(rawBody)); // process in the background
    res.status(200).end();
  });
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time


  def verify_webhook(raw_body: bytes, headers: dict, secret_key: str) -> bool:
      try:
          timestamp = int(headers.get("X-Timestamp", ""))
      except ValueError:
          return False
      if abs(time.time() - timestamp) > 300:
          return False
      expected = hmac.new(secret_key.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, headers.get("X-HMAC-Signature", ""))
  ```

  ```php PHP theme={null}
  function verifyWebhook(string $rawBody, string $timestamp, string $signature, string $secretKey): bool
  {
      if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
          return false;
      }

      return hash_equals(hash_hmac('sha256', $timestamp . '.' . $rawBody, $secretKey), $signature);
  }

  $ok = verifyWebhook(
      file_get_contents('php://input'),
      $_SERVER['HTTP_X_TIMESTAMP'] ?? '',
      $_SERVER['HTTP_X_HMAC_SIGNATURE'] ?? '',
      getenv('PROOFAGE_SECRET_KEY'),
  );
  ```
</CodeGroup>

## Responding and retries

Answer with any `2xx` as soon as the signature checks out, and do the work in the background.

| Your response | What ProofAge does |
| - | - |
| `2xx` | Done. |
| `408`, `429`, any `5xx`, no answer within 30 seconds, or no connection | Retries the same delivery after 5, 15 and 60 minutes: four attempts in all. |
| A redirect (`3xx`), or any other `4xx` | Stops. The delivery is recorded as answered and not retried. |
| `410` from a [subscription](#webhook-subscriptions)'s URL | Stops, and deletes the subscription. |

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](#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](/integration/zapier) uses: it subscribes when a Zap is turned on and deletes the subscription when it is turned off.

```bash theme={null}
POST /v1/webhook-subscriptions
```

```json theme={null}
{
  "url": "https://hooks.example.com/proofage",
  "statuses": ["approved", "declined"],
  "include_document_data": false
}
```

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](#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](#the-webhook-url), up to 2,048 characters.

A subscription's body without document data:

```json approved (subscription) theme={null}
{
  "verification_id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "status.updated",
  "status": "approved",
  "external_id": "user_12345",
  "external_metadata": { "plan": "premium" },
  "reason": null,
  "timestamp": "2026-10-08T12:05:00+00:00"
}
```

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](/integration/go-live).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.