DynamicDocumentAPI

Webhooks

Receive signed render events, manage endpoints and retries, and verify Standard Webhooks signatures in Node.js and Python.

View as Markdown

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.

FieldDescription
urlWhere deliveries are sent. HTTPS is required for live mode.
descriptionA label for the dashboard
eventsThe event types this endpoint receives
template_idsOptional: only send events for these templates
headersOptional: up to 10 custom headers sent with every delivery, for example a shared token
enabledSet to false to pause deliveries
EndpointDescription
GET /v1/webhook-endpointsList endpoints
POST /v1/webhook-endpointsCreate 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-secretGenerate a new secret; expire_previous_in keeps the old one valid for that many seconds
POST /v1/webhook-endpoints/{id}/testSend a sample event, for example {"event": "render.succeeded"}. Samples exist for render.succeeded, render.failed, render.expired and template.published.
GET /v1/webhook-deliveriesList 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}/replaySend 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

EventSent whenStatus
render.succeededA render finished and its files are availableAvailable
render.failedA render failed; data.object.error explains whyAvailable
render.expiredA render's files were deleted at the end of the retention periodAvailable
template.publishedA new template version was publishedAvailable
usage.threshold_reachedRender usage passed 50 %, 80 % or 100 % of the included renders, or the monthly top-up limit stops further top-upsAvailable
batch.progressA batch advanced by another 10 %, or paused because the workspace ran out of rendersAvailable
batch.completedA batch finishedAvailable
storage.upload_failedAn upload to one of your storage destinations failed for goodAvailable
email.sentYour provider accepted an email of an email rule, or Brevo's sandbox didAvailable
email.send_failedAn email failed, or its outcome is unknownAvailable
email.deliveredBrevo reported the email as delivered (Brevo connections only)Available
email.bouncedBrevo reported a bounce, a block or an invalid address (Brevo connections only)Available
email.complainedBrevo reported that a recipient marked the email as spam (Brevo connections only)Available
api_key.expiringAn API key is about to expirePlanned

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/…" }]
    }
  }
}
HeaderMeaning
webhook-idUnique event ID, the same value as id in the body. Use it to detect duplicates.
webhook-timestampWhen the event was signed, as Unix seconds
webhook-signatureOne or more signatures, separated by spaces, each in the form v1,<base64>
user-agentDynamic-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:

AttemptSent afterRoughly after the first attempt
1immediately0
25 seconds5 s
35 minutes5 min
430 minutes35 min
52 hours2 h 35 min
65 hours7 h 35 min
710 hours17 h 35 min
810 hours27 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:

  1. Read the raw request body as bytes. Parsing and re-serializing JSON changes the bytes and breaks the signature.
  2. Read the webhook-id, webhook-timestamp and webhook-signature headers.
  3. Reject timestamps more than five minutes before or after your clock, which stops replays of captured deliveries.
  4. Take the secret, drop the whsec_ prefix and base64-decode the rest: those bytes are the HMAC key.
  5. Build the signed content as {webhook-id}.{webhook-timestamp}.{raw body}.
  6. Compute base64(HMAC-SHA256(key, signed content)).
  7. Compare it against every v1,… entry in the webhook-signature header 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.

verify-webhook.mjs
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:

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

verify_webhook.py
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 "", 204

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

On this page