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_ | 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_, 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 (
ApiKeyheader): Proof signs every request with the API key that created the subscription, unless you pass a customsigning_key. - Created with OAuth: requests aren't signed unless you include a
signing_keywhen 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_keyon 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_occurredwith 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.releasedonly.
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.