# Guide to Proof Webhooks: Features and Best Practices

Proof, formerly Notarize, runs online notarisation and identity verification: signers meet a commissioned notary over video, prove who they are, and sign documents that are legally notarised. Lenders, title agencies and other businesses embed it through Proof's Business API. A transaction moves through many stages (sent to the signer, meeting requested, notary assigned, completed, documents released) and Proof's Webhooks V2 report each of those changes, along with notary onboarding events.

This guide covers creating Proof webhook subscriptions, the transaction and notary events, which event really means "documents ready", how the signing key depends on how you authenticated, and Proof's retry behaviour.

## What are Proof webhooks?

Proof webhooks are subscriptions created through the Webhook API (`POST /v2/webhooks`). Each one has its own URL and a list of events, or a wildcard (`transaction.*`, `notary.*`). A subscription can also ask Proof to add custom headers, set a request ID header, or fetch an OAuth token and send it as `Authorization: Bearer`.

Proof sends a JSON `POST` per event: an `event` name and a `data` object containing the transaction or notary ID, `date_occurred` and fields specific to the event. Full details come from the Proof API.

Whether a delivery is signed depends on how the subscription was created. With an API key, Proof signs automatically using that key; with OAuth, it signs only if you set a `signing_key`. The signature is a hex HMAC-SHA256 of the body in `X-Notarize-Signature`.

## Proof webhook features

| Feature | Details |
| --- | --- |
| Configuration | Webhook API, `/v2/webhooks` |
| Subscription | Event list or `transaction.*` / `notary.*` |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-notarize-signature` |
| Signed content | The request body |
| Key | The creating API key, or a custom `signing_key` |
| OAuth-created | Unsigned unless `signing_key` is set |
| Body | JSON |
| Event routing | Body field `event` |
| Timeout | 30 seconds; only `200` acknowledges |
| Retries | Up to 16 attempts across 48 hours |
| Ordering | Not guaranteed; duplicates possible |

## Events and payloads

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `transaction.created` | A transaction is created | Track new transactions |
| `transaction.completed` | A transaction reaches the completed state | Update order state |
| `transaction.released` | Documents are finalized and downloadable via the API | Fetch signed documents |
| `transaction.declined` | A transaction is declined | Notify the requester |
| `transaction.held_for_review` | A transaction is held for review | Flag for manual follow-up |
| `notary.created` | A new notary is created in your organisation | Onboarding sync |

`transaction.released` is the reliable "documents are ready" signal. Documents may not be available
when `transaction.completed` fires, and in signer-paid workflows `released` waits for payment.

### Event Payload Structure

`data` carries IDs and `date_occurred` (ISO 8601). Some events add fields: `organization_name`, a `document` with `document_id` and `name`, `signers`, `meeting` details or risk signals, and `notary_id` for `notary.*` events. Most of Proof's examples are templates whose values describe the field (`"ID of notary"`), so check real deliveries for formats.

### All Documented Event Types

* Notary: `notary.compliant`, `notary.created`, `notary.needs_review`, `notary.non_compliant`, `notary.signer_ready`
* Transaction lifecycle: `transaction.created`, `transaction.updated`, `transaction.deleted`, `transaction.canceled`, `transaction.expired`, `transaction.recalled`, `transaction.declined`, `transaction.held_for_review`
* Routing: `transaction.sent_to_signer`, `transaction.sent_to_title_agency`, `transaction.sent_to_closing_ops`, `transaction.reviewed`, `transaction.underwriter.not_available`
* Documents: `transaction.document.upload`, `transaction.document.processed`
* Meetings: `transaction.meeting.requested`, `transaction.meeting.created`, `transaction.meeting.failed`, `transaction.meeting.video.processed`, `transaction.notary.assigned`
* Signers: `transaction.signer.kba_passed`, `transaction.signer.kba_failed`, `transaction.signer.high_risk_detected`, `transaction.signer.medium_risk_detected`
* Completion: `transaction.partially_completed`, `transaction.completed`, `transaction.completed_with_rejections`, `transaction.released`
* Imports: `transaction.import.processed`, `transaction.import.failed`
* Identity verification: `transaction.automated_identity_verification_by_idv_service_failed`, `transaction.automated_identity_verification_by_idv_service_passed`

The two identity-verification events are headed with the names above, but their example bodies show `transaction.transaction.automated_identity_verification_by_idv_service_*`, with the prefix doubled. Which form is sent isn't confirmed, so match on the suffix if you rely on them.

### Published payload example

A `transaction.declined` delivery, from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/proof/v2/transaction.declined.json). Like most Proof events, it identifies the transaction and leaves the details to the API.

```json
{
  "event": "transaction.declined",
  "data": {
    "transaction_id": "ot_XXXXXX",
    "date_occurred": "2025-08-12T17:56:35Z"
  }
}

```

## Setting up Proof webhooks

### Prerequisites

* A Proof account with Business API access and an API key (or an OAuth client)
* A public HTTPS endpoint, for example `https://example.com/webhooks/proof`

### Get Your Signing Secret

Paste the Proof API key that created the webhook, or its signing_key if you set one. Webhooks created with OAuth must set a signing_key.

* **Created with an API key** (`ApiKey` header): Proof signs every request with the API key that created the subscription, unless you pass a custom `signing_key`.
* **Created with OAuth**: requests aren't signed unless you include a `signing_key` when creating or updating the subscription. Once set, signing can only be removed by deleting and recreating the subscription.

Store the value as `WEBHOOK_SECRET`. A custom `signing_key` is the better choice even with an API key, since it lets you rotate the API key without breaking verification.

### Register Your Endpoint

Create a subscription with [Create Webhook V2](https://dev.proof.com/reference/createwebhookv2). Each subscription needs a unique URL.

```bash
curl -X POST https://api.proof.com/v2/webhooks \
  -H "ApiKey: $PROOF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/proof",
    "subscriptions": ["transaction.created", "transaction.completed", "transaction.released", "notary.created"],
    "signing_key": "choose-a-long-random-string"
  }'

```

Other optional fields include `headers` (extra headers Proof sends with each request), `request_id_header_name` and `oauth_config`. List, update and delete subscriptions through the same `/v2/webhooks` API.

### Test a delivery

Start a local receiver and forward requests with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 proof --path /webhooks/proof

```

Create a subscription for `transaction.*` with the CLI's URL, then create a test transaction in a Proof test environment and send it to a signer.

## Securing Proof webhooks

Compute HMAC-SHA256 of the raw body with the signing key's UTF-8 bytes, hex-encode it, and compare it with `X-Notarize-Signature` in constant time. Proof describes the scheme in prose only, with no code sample or test vector. See the [Webhooks V2 documentation](https://dev.proof.com/docs/webhooks-v2).

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-notarize-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler deduplicates on the event, the resource and `date_occurred`, ignores updates older than the state it already has, and fetches documents only on `transaction.released`.

```javascript
const express = require('express');

function createProofApp({ claim, transactions, fetchDocuments }) {
  const app = express();
  app.post('/webhooks/proof', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
    let parsed;
    try {
      parsed = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
    const { event, data } = parsed;
    if (typeof event !== 'string' || !data || typeof data !== 'object') return res.sendStatus(400);
    const resourceId = data.transaction_id || data.notary_id;
    try {
      if (!(await claim(`${event}:${resourceId}:${data.date_occurred}`))) return res.sendStatus(200);
      if (event.startsWith('transaction.')) {
        const applied = await transactions.applyIfNewer(resourceId, event, data.date_occurred);
        if (applied && event === 'transaction.released') await fetchDocuments(resourceId);
      } else {
        console.log('Proof notary event', event, resourceId);
      }
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Downloading documents can take a while; within Proof's 30-second limit, queue it rather than awaiting it in production.

Only the body is signed, so a replay verifies. The dedup key and the `date_occurred` comparison make both retries and replays harmless.

Hookdeck's Proof source type verifies `X-Notarize-Signature` against the request body using the key you paste. It sends `200` back to Proof whether or not the check passes, records a failure as failed verification, and holds that request back from your destination. There's no timestamp window. If an OAuth subscription deliberately has no `signing_key`, leave verification off on the source.

> **Make Proof webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) answers Proof well inside 30 seconds, verifies every notarisation event, and keeps a replayable history for each transaction.

## Proof webhook limitations and pain points

### Unsigned OAuth subscriptions

**The Problem:** Webhooks arrive with no signature, and anyone who learns the URL could send fake transaction events.

**Why It Happens:** Subscriptions created with OAuth aren't signed unless a `signing_key` is set.

**Workarounds:**

* Always set a `signing_key` on OAuth-created subscriptions.
* Alternatively, have Proof send a custom shared-secret header, and check it.

**How Hookdeck Can Help:** With verification on, unsigned requests are logged as failed and not forwarded, which makes a missing key obvious.

### Duplicates and out-of-order events

**The Problem:** A transaction appears to go backwards, or downstream actions run twice.

**Why It Happens:** Proof says webhooks can be sent more than once and may arrive out of order.

**Workarounds:**

* Compare `date_occurred` with the latest you've stored before applying a change.
* Fetch the transaction from the API before taking irreversible actions.

**How Hookdeck Can Help:** Hookdeck deduplication can drop identical deliveries, and its event log shows the arrival order for each transaction.

### "Completed" doesn't mean documents are ready

**The Problem:** Document downloads fail right after `transaction.completed`.

**Why It Happens:** Documents may not be available when `transaction.completed` fires, and in signer-paid workflows `transaction.released` waits for payment.

**Workarounds:**

* Download documents on `transaction.released` only.

**How Hookdeck Can Help:** A Hookdeck connection filtered on `event` can send only `transaction.released` to your document-fetching service.

### Template examples and the IDV naming conflict

**The Problem:** Parsers built from the docs don't match real deliveries.

**Why It Happens:** Most examples are templates with descriptive placeholder values, and the IDV events show two different names.

**Workarounds:**

* Capture real deliveries in a Proof test environment and use them as fixtures.
* Route IDV events on their suffix.

**How Hookdeck Can Help:** Hookdeck stores every delivery's real body, so you can see the exact `event` strings your account receives.

## Best practices

### Prefer a custom `signing_key`

Decoupling the signing key from the API key means rotating credentials doesn't silently break webhook verification.

### Reply with 200 exactly

Only `200` acknowledges. Other `2xx` codes, such as `202` or `204`, are treated as failures and retried.

### Keep handlers fast

Store the event and return `200` well within 30 seconds, then do API calls and downloads in the background, as shown in our [asynchronous processing guide](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make processing idempotent

Up to 16 attempts over 48 hours means the same event can arrive many times. The [idempotency guide](/webhooks/guides/implement-webhook-idempotency) shows how to record a key alongside each change.

## Conclusion

Proof's Webhooks V2 track every stage of a notarisation, with an HMAC signature tied to the key that created the subscription. The details that matter are setting a `signing_key` for OAuth subscriptions, waiting for `transaction.released` before downloading, replying with `200` specifically, and handling duplicates and out-of-order delivery.

[Hookdeck Event Gateway](https://hookdeck.com) verifies Proof's deliveries, answers within the timeout and keeps every transaction event available to replay.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Proof webhooks reliably in minutes.