Guides
Webhooks
Get notified when documents change state, with signed payloads, retries and a delivery history.
Webhooks tell your server when something happens — a document is viewed, signed, completed or declined, a task finishes — so you do not have to poll. Macaaw sends an HTTPS POST with a JSON array of events to a URL you choose, signed with a key only you and Macaaw know.
Create a subscription#
Add one in Account → Developer → Webhooks or with POST /webhook-subscriptions. A workspace can have 20 subscriptions; each is either live or sandbox.
curl https://macaaw.com/public/v1/webhook-subscriptions \
-H "Authorization: API-Key $MACAAW_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "CRM sync", "url": "https://example.com/hooks/macaaw", "triggers": ["document_state_changed", "recipient_completed"], "active": true}'{
"uuid": "1a468cc3-ad5f-4ff6-932a-880ac302c782",
"name": "CRM sync",
"url": "https://example.com/hooks/macaaw",
"triggers": [
"document_state_changed",
"recipient_completed"
],
"active": true,
"sandbox": false,
"deactivated_reason": null,
"date_created": "2026-10-30T12:12:09.946000Z",
"date_modified": "2026-10-30T12:12:09.946000Z",
"shared_key": "whsk_BDsE…"
}shared_key is shown once. Store it as a secret. If you lose it, regenerate it (POST /webhook-subscriptions/{uuid}/shared-key or Regenerate in the dashboard); the old key stops working immediately.The URL must use https:// and point to a public host. Private, loopback, link-local and cloud metadata addresses are refused, and redirects are not followed.
Triggers#
| Trigger | Sent when | data |
|---|---|---|
document_state_changed | A document changes status (sent, viewed, completed, declined, voided, expired…). | Document details |
recipient_completed | One recipient finishes signing. | Document details |
document_completed_pdf_ready | The signed PDF with its certificate is ready to download. | Document details |
document_updated | A document’s recipients, fields or details change. | Document details |
document_deleted | A document is deleted. | Document details |
document_creation_failed | A create call was refused because of the file (damaged, password-protected…). | { id: null, name, status: "document.error", error: { code } } |
task_completed | A task finished and its result can be downloaded. | Task |
task_failed | A task failed. | Task |
The request#
POST /hooks/macaaw?signature=5d0c…e91a HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Macaaw-Webhooks/1The body is always a JSON array: events for the same subscription in the same second may be delivered together. Each item has the trigger in event and the object in data — the same shape as GET /documents/{id}/details or GET /tasks/{id}.
[
{
"event": "document_state_changed",
"data": {
"id": "58b66391-30fa-4219-a9b3-4f7badfad583",
"name": "Service agreement",
"status": "document.completed",
"sandbox": false,
"date_created": "2026-10-30T11:35:00.000000Z",
"date_modified": "2026-10-30T12:02:41.000000Z",
"expiration_date": "2026-11-13T11:36:12.000000Z",
"recipients": [
{
"id": "07ae35e1-9734-4b4c-9b44-5a5cec0269aa",
"email": "client@example.com",
"first_name": "Ana",
"last_name": "Silva",
"role": "Client",
"signing_order": 1,
"has_completed": true,
"status": "completed",
"completed_at": "2026-10-30T12:02:40.000000Z"
}
],
"tags": [
"sales"
],
"metadata": {
"crm_deal_id": "4815"
},
"download_available": true
}
}
]Payloads never contain document contents or file names; the document name is the one you set.
Verify the signature#
The signature query parameter is the hex-encoded HMAC-SHA256 of the raw request body, keyed with the subscription's shared key. Compute it over the exact bytes you received — before parsing JSON — and compare in constant time. Reject the request if it does not match.
Node.js#
import http from 'node:http';
import crypto from 'node:crypto';
const SHARED_KEY = process.env.MACAAW_WEBHOOK_KEY;
/** True when `signature` is the hex HMAC-SHA256 of the raw body with your shared key. */
export function isValidSignature(rawBody, signature, sharedKey) {
if (typeof signature !== 'string' || !/^[0-9a-f]{64}$/i.test(signature)) return false;
const expected = crypto.createHmac('sha256', sharedKey).update(rawBody).digest();
return crypto.timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}
http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (req.method !== 'POST' || url.pathname !== '/hooks/macaaw') return res.writeHead(404).end();
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks); // verify the exact bytes, before any JSON parsing
if (!isValidSignature(raw, url.searchParams.get('signature'), SHARED_KEY)) return res.writeHead(401).end();
for (const { event, data } of JSON.parse(raw.toString('utf8'))) {
console.log(event, data.id, data.status);
}
res.writeHead(200).end();
});
}).listen(3000);import express from 'express';
// isValidSignature() as above
const app = express();
// express.raw keeps the body as a Buffer; express.json() would change the bytes.
app.post('/hooks/macaaw', express.raw({ type: 'application/json' }), (req, res) => {
if (!isValidSignature(req.body, req.query.signature, process.env.MACAAW_WEBHOOK_KEY)) return res.sendStatus(401);
const events = JSON.parse(req.body.toString('utf8'));
// … handle events, then answer quickly
res.sendStatus(200);
});Python#
import hashlib
import hmac
import json
import os
from flask import Flask, abort, request
SHARED_KEY = os.environ["MACAAW_WEBHOOK_KEY"]
app = Flask(__name__)
def is_valid_signature(raw_body: bytes, signature: str | None, shared_key: str) -> bool:
"""True when signature is the hex HMAC-SHA256 of the raw body with your shared key."""
if not signature:
return False
expected = hmac.new(shared_key.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature.lower())
@app.post("/hooks/macaaw")
def macaaw_webhook():
raw = request.get_data() # the exact bytes, before any JSON parsing
if not is_valid_signature(raw, request.args.get("signature"), SHARED_KEY):
abort(401)
for item in json.loads(raw):
print(item["event"], item["data"].get("id"), item["data"].get("status"))
return "", 200Responding, retries and failures#
- Answer with any status below
400within 20 seconds. Do slow work after you answer. - On a timeout, a network error or a status of 400 or more, Macaaw retries 3 more times: after 1 minute, 10 minutes and 1 hour. After that the delivery is marked failed.
- Answering
410 Goneswitches the subscription off. Turn it back on in the dashboard or withPATCH /webhook-subscriptions/{uuid}. - Deliveries can arrive more than once and out of order. Use the document's
statusanddate_modified, not the order of arrival.
Delivery history and resending#
The dashboard's Webhooks tab lists every delivery with its status, attempts, HTTP status and the start of your server's answer, and has Send test event and Resend buttons. Through the API: GET /webhook-events?subscription=&status=&page= and POST /webhook-events/{id}/resend.
A test event is a normal signed delivery whose body is [{ "event": "test", "data": { "subscription": { "uuid", "name" }, "sandbox", "date_created" } }].