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

# Webhooks

> Receive signed decision 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).

## 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>

* `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.

## Headers

| Header | Value |
| - | - |
| `X-HMAC-Signature` | Hex HMAC-SHA256 of `{X-Timestamp}.{raw body}`, keyed with the workspace's **active** secret key. |
| `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 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.verification_id, payload.status, payload.reason, …
  });
  ```

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

  def handle(raw_body: bytes, headers: dict):
      try:
          event = verify_webhook(raw_body, headers)
      except WebhookVerificationError:
          return 401
      # event.verification_id, event.status, event.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. |
| Any other `4xx` | Stops. The delivery is recorded as answered and not retried. |

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


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