Webhooks
Receive signed render events, manage endpoints and retries, and verify Standard Webhooks signatures in Node.js and Python.
Webhooks tell your server when something happened, without polling. They are the natural companion to mode: "async": you submit a render, the API answers immediately, and your endpoint receives a signed event when the files are ready.
Deliveries follow the Standard Webhooks specification, so the signature scheme is the same one many other APIs use, and open-source verification libraries exist for most languages.
Create an endpoint
Add endpoints in the dashboard, or through the API with a key that has the webhooks:write scope:
curl https://api-eu.dynamicdocumentapi.com/v1/webhook-endpoints \
-H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/documents",
"description": "Invoice renders",
"events": ["render.succeeded", "render.failed"],
"enabled": true
}'The response contains the endpoint and its signing secret (a string starting with whsec_). The secret is shown once: store it as DYNAMIC_DOCUMENT_API_WEBHOOK_SECRET in your secret manager.
| Field | Description |
|---|---|
url | Where deliveries are sent. HTTPS is required for live mode. |
description | A label for the dashboard |
events | The event types this endpoint receives |
template_ids | Optional: only send events for these templates |
headers | Optional: up to 10 custom headers sent with every delivery, for example a shared token |
enabled | Set to false to pause deliveries |
| Endpoint | Description |
|---|---|
GET /v1/webhook-endpoints | List endpoints |
POST /v1/webhook-endpoints | Create an endpoint; the response includes the secret |
GET /v1/webhook-endpoints/{id} | Retrieve an endpoint |
PATCH /v1/webhook-endpoints/{id} | Update URL, events, headers or the enabled flag |
DELETE /v1/webhook-endpoints/{id} | Delete an endpoint |
POST /v1/webhook-endpoints/{id}/roll-secret | Generate a new secret; expire_previous_in keeps the old one valid for that many seconds |
POST /v1/webhook-endpoints/{id}/test | Send a sample event, for example {"event": "render.succeeded"}. Samples exist for render.succeeded, render.failed, render.expired and template.published. |
GET /v1/webhook-deliveries | List deliveries; filter by endpoint_id and status |
GET /v1/webhook-deliveries/{id} | Retrieve one delivery with its request and response |
POST /v1/webhook-deliveries/{id}/replay | Send a delivery again |
Local development
Deliveries can't reach private or local addresses, so point the endpoint at a public tunnel while developing. A CLI command that forwards live events to a local port is planned.
Event types
| Event | Sent when | Status |
|---|---|---|
render.succeeded | A render finished and its files are available | Available |
render.failed | A render failed; data.object.error explains why | Available |
render.expired | A render's files were deleted at the end of the retention period | Available |
template.published | A new template version was published | Available |
usage.threshold_reached | Render usage passed 50 %, 80 % or 100 % of the included renders, or the monthly top-up limit stops further top-ups | Available |
batch.progress | A batch advanced by another 10 %, or paused because the workspace ran out of renders | Available |
batch.completed | A batch finished | Available |
storage.upload_failed | An upload to one of your storage destinations failed for good | Available |
email.sent | Your provider accepted an email of an email rule, or Brevo's sandbox did | Available |
email.send_failed | An email failed, or its outcome is unknown | Available |
email.delivered | Brevo reported the email as delivered (Brevo connections only) | Available |
email.bounced | Brevo reported a bounce, a block or an invalid address (Brevo connections only) | Available |
email.complained | Brevo reported that a recipient marked the email as spam (Brevo connections only) | Available |
api_key.expiring | An API key is about to expire | Planned |
For render events, data.object is the full render object. Test renders deliver events too, with "test": true on the render. Renders that belong to a batch send no render.* events; the batch events report on them instead. Batch events carry the batch, email events the email send, and storage.upload_failed the failed upload.
Delivery format
A delivery is an HTTP POST with a JSON body:
POST /webhooks/documents HTTP/1.1
content-type: application/json
webhook-id: evt_01J9ZM4T6W8Y0A2C4E6G8J0K2M
webhook-timestamp: 1789657594
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
user-agent: Dynamic-Document-Api-Webhooks/1.0
{
"id": "evt_01J9ZM4T6W8Y0A2C4E6G8J0K2M",
"type": "render.succeeded",
"created_at": "2026-09-17T15:06:34Z",
"workspace_id": "ws_01J9ZK0P2R4T6V8X0Z2B4D6F8H",
"region": "eu",
"data": {
"object": {
"id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q",
"object": "render",
"status": "succeeded",
"reference": "inv_2026_0042",
"files": [{ "id": "file_01J9ZM4T6W8Y0A2C4E6G8J0K2M", "format": "pdf", "pages": 2, "url": "https://files-eu.dynamicdocumentapi.com/f/…" }]
}
}
}| Header | Meaning |
|---|---|
webhook-id | Unique event ID, the same value as id in the body. Use it to detect duplicates. |
webhook-timestamp | When the event was signed, as Unix seconds |
webhook-signature | One or more signatures, separated by spaces, each in the form v1,<base64> |
user-agent | Dynamic-Document-Api-Webhooks/1.0 |
If your workspace has file URLs in webhooks turned off, the files entries arrive without url; retrieve the render through the API to get a signed URL.
Responding, retries and failures
Return any 2xx status within 10 seconds. Do the minimum in the request handler — verify the signature, enqueue the event, respond — and process afterwards.
Anything else counts as a failure and is retried:
| Attempt | Sent after | Roughly after the first attempt |
|---|---|---|
| 1 | immediately | 0 |
| 2 | 5 seconds | 5 s |
| 3 | 5 minutes | 5 min |
| 4 | 30 minutes | 35 min |
| 5 | 2 hours | 2 h 35 min |
| 6 | 5 hours | 7 h 35 min |
| 7 | 10 hours | 17 h 35 min |
| 8 | 10 hours | 27 h 35 min |
Each delay varies by up to 10 % so that many endpoints don't retry in lockstep. Redirects are not followed, so register the final URL. An endpoint that fails continuously for 5 days is disabled automatically and you receive an email.
Because of retries, the same event can arrive more than once, and events can arrive out of order. Store the webhook-id values you have processed and ignore repeats, and treat the render object in the payload as the current state rather than assuming an order.
Delivery log and replay
Every attempt is logged for 30 days with the payload, the response status, an excerpt of the response body, the latency and the attempt number. Inspect deliveries in the dashboard or with GET /v1/webhook-deliveries, and resend one with POST /v1/webhook-deliveries/{id}/replay after you fix a bug on your side.
Per-request webhooks
Instead of (or in addition to) a configured endpoint, a single render can carry its own callback:
{
"input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" },
"data": { "number": "2026-0042" },
"mode": "async",
"webhook": {
"url": "https://example.com/webhooks/documents",
"events": ["render.succeeded", "render.failed"]
}
}These deliveries are signed with the signing secret of your workspace's default webhook endpoint, and you verify them exactly like any other delivery.
Verify signatures
Anyone can POST to your endpoint, so verify every delivery before you trust it.
The scheme is straightforward:
- Read the raw request body as bytes. Parsing and re-serializing JSON changes the bytes and breaks the signature.
- Read the
webhook-id,webhook-timestampandwebhook-signatureheaders. - Reject timestamps more than five minutes before or after your clock, which stops replays of captured deliveries.
- Take the secret, drop the
whsec_prefix and base64-decode the rest: those bytes are the HMAC key. - Build the signed content as
{webhook-id}.{webhook-timestamp}.{raw body}. - Compute
base64(HMAC-SHA256(key, signed content)). - Compare it against every
v1,…entry in thewebhook-signatureheader using a constant-time comparison, and accept the delivery if one matches.
The header can contain several signatures. While a rolled secret is still valid, deliveries carry one signature per active secret, so accepting any match lets you rotate secrets without downtime.
Node.js
No dependencies: node:crypto is built in.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
/**
* Verifies a webhook delivery (Standard Webhooks) and returns the parsed event.
* Throws if a header is missing, the timestamp is outside the tolerance or no signature matches.
*
* @param {Buffer | Uint8Array | string} rawBody The request body exactly as received.
* @param {Record<string, string | string[] | undefined>} headers Request headers with lower-case names.
* @param {string} secret The endpoint's signing secret ("whsec_…").
*/
export function verifyWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (typeof id !== "string" || typeof timestamp !== "string" || typeof signatures !== "string") {
throw new Error("Missing webhook headers");
}
const now = Math.floor(Date.now() / 1000);
if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) {
throw new Error("Webhook timestamp is invalid or outside the tolerance");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const body = Buffer.from(rawBody);
const expected = Buffer.from(
createHmac("sha256", key).update(`${id}.${timestamp}.`).update(body).digest("base64"),
);
for (const entry of signatures.split(" ")) {
const comma = entry.indexOf(",");
if (comma === -1 || entry.slice(0, comma) !== "v1") continue;
const received = Buffer.from(entry.slice(comma + 1));
if (received.length === expected.length && timingSafeEqual(received, expected)) {
return JSON.parse(body.toString("utf8"));
}
}
throw new Error("No matching webhook signature");
}In Express, mount the route with express.raw so that req.body is a Buffer. If your app uses express.json() globally, register this route before it, or exclude this path:
import express from "express";
import { verifyWebhook } from "./verify-webhook.mjs";
const app = express();
app.post("/webhooks/documents", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers, process.env.DYNAMIC_DOCUMENT_API_WEBHOOK_SECRET);
} catch {
return res.status(400).send("invalid webhook");
}
if (event.type === "render.succeeded") {
const render = event.data.object;
console.log(`Render ${render.id} finished with ${render.files.length} file(s)`);
}
res.sendStatus(204);
});
app.listen(3000);In frameworks built on the Fetch API, pass Buffer.from(await request.arrayBuffer()) as the body and Object.fromEntries(request.headers) as the headers.
Python
No dependencies: hmac, hashlib and base64 are in the standard library.
import base64
import hashlib
import hmac
import json
import time
TOLERANCE_SECONDS = 5 * 60
class WebhookVerificationError(Exception):
"""Raised when a webhook delivery cannot be verified."""
def verify_webhook(raw_body: bytes, headers, secret: str) -> dict:
"""Verify a webhook delivery (Standard Webhooks) and return the parsed event.
raw_body -- the request body exactly as received (bytes)
headers -- request headers; Flask and Django header objects work as-is,
a plain dict needs lower-case names
secret -- the endpoint's signing secret ("whsec_...")
"""
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures:
raise WebhookVerificationError("missing webhook headers")
if not timestamp.isdecimal() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
raise WebhookVerificationError("webhook timestamp is invalid or outside the tolerance")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature.encode(), expected):
return json.loads(raw_body)
raise WebhookVerificationError("no matching webhook signature")Use the raw body in your framework: request.get_data() in Flask, request.body in Django.
import os
from flask import Flask, abort, request
from verify_webhook import WebhookVerificationError, verify_webhook
app = Flask(__name__)
@app.post("/webhooks/documents")
def handle_webhook():
try:
event = verify_webhook(request.get_data(), request.headers, os.environ["DYNAMIC_DOCUMENT_API_WEBHOOK_SECRET"])
except WebhookVerificationError:
abort(400)
if event["type"] == "render.succeeded":
render = event["data"]["object"]
print(f"Render {render['id']} finished with {len(render['files'])} file(s)")
return "", 204Other languages
The Standard Webhooks project publishes verification libraries for many languages, and any HMAC-SHA256 implementation can follow the seven steps above. Official SDKs with a built-in webhooks.verify helper are planned.
Checklist
- Verify every delivery and answer unverified requests with
400. - Serve the endpoint over HTTPS and keep the secret in a secret manager.
- Roll the secret with an overlap if it might have leaked, then remove the old one.
- Deduplicate on
webhook-id. - Respond quickly and process asynchronously; deliveries time out after 10 seconds.
- Watch the delivery log after deploys, and replay anything your service missed.
Pagination basics
Control page breaks, keep blocks together, repeat table headers and fix the usual pagination problems in HTML to PDF output.
Your own storage
Upload rendered files to Amazon S3, S3-compatible storage, Azure Blob Storage, Google Cloud Storage or SFTP, with destination settings, credentials, connection tests, path templates, hosted copies, zero retention, retries, limits and errors.