Gareth Wilson Gareth Wilson

Guide to Proof Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationWebhook API, /v2/webhooks
SubscriptionEvent list or transaction.* / notary.*
Signaturehex HMAC-SHA256
Signature headerx-notarize-signature
Signed contentThe request body
KeyThe creating API key, or a custom signing_key
OAuth-createdUnsigned unless signing_key is set
BodyJSON
Event routingBody field event
Timeout30 seconds; only 200 acknowledges
RetriesUp to 16 attempts across 48 hours
OrderingNot guaranteed; duplicates possible

Events and payloads

EventTriggered WhenCommon Use Cases
transaction.createdA transaction is createdTrack new transactions
transaction.completedA transaction reaches the completed stateUpdate order state
transaction.releasedDocuments are finalized and downloadable via the APIFetch signed documents
transaction.declinedA transaction is declinedNotify the requester
transaction.held_for_reviewA transaction is held for reviewFlag for manual follow-up
notary.createdA new notary is created in your organisationOnboarding 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. Like most Proof events, it identifies the transaction and leaves the details to the API.

{
  "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. Each subscription needs a unique URL.

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:

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.

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.

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

Make processing idempotent

Up to 16 attempts over 48 hours means the same event can arrive many times. The idempotency guide 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 verifies Proof's deliveries, answers within the timeout and keeps every transaction event available to replay.

Get started with Hookdeck for free and handle Proof webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.