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}'
201 Created
{
  "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…"
}
The 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#

TriggerSent whendata
document_state_changedA document changes status (sent, viewed, completed, declined, voided, expired…).Document details
recipient_completedOne recipient finishes signing.Document details
document_completed_pdf_readyThe signed PDF with its certificate is ready to download.Document details
document_updatedA document’s recipients, fields or details change.Document details
document_deletedA document is deleted.Document details
document_creation_failedA create call was refused because of the file (damaged, password-protected…).{ id: null, name, status: "document.error", error: { code } }
task_completedA task finished and its result can be downloaded.Task
task_failedA task failed.Task

The request#

HTTP
POST /hooks/macaaw?signature=5d0c…e91a HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Macaaw-Webhooks/1

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

Body
[
  {
    "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#

Node.js (no dependencies)
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);
Node.js with Express
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#

Python (Flask)
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 "", 200

Responding, retries and failures#

  • Answer with any status below 400 within 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 Gone switches the subscription off. Turn it back on in the dashboard or with PATCH /webhook-subscriptions/{uuid}.
  • Deliveries can arrive more than once and out of order. Use the document's status and date_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" } }].

Webhooks · Macaaw API