# 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](https://www.standardwebhooks.com/) 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 [#create-an-endpoint]

Add endpoints in the dashboard, or through the API with a key that has the `webhooks:write` scope:

```bash
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-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](/docs/batches) 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](/docs/storage) failed for good                                     | Available |
| `email.sent`              | Your provider accepted an email of an [email rule](/docs/email-delivery), 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](/docs/renders#the-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 [#delivery-format]

A delivery is an HTTP POST with a JSON body:

```http
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 [#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 [#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 [#per-request-webhooks]

Instead of (or in addition to) a configured endpoint, a single render can carry its own callback:

```json
{
  "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 [#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 [#nodejs]

No dependencies: `node:crypto` is built in.

```js title="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:

```js title="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 [#python]

No dependencies: `hmac`, `hashlib` and `base64` are in the standard library.

```python title="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.

<CodeBlockTabs defaultValue="Flask">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Flask">
      Flask
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Django">
      Django
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Flask">
    ```python
    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
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Django">
    ```python
    import os

    from django.http import HttpResponse, HttpResponseBadRequest
    from django.views.decorators.csrf import csrf_exempt
    from django.views.decorators.http import require_POST

    from .verify_webhook import WebhookVerificationError, verify_webhook


    @csrf_exempt
    @require_POST
    def handle_webhook(request):
        try:
            event = verify_webhook(request.body, request.headers, os.environ["DYNAMIC_DOCUMENT_API_WEBHOOK_SECRET"])
        except WebhookVerificationError:
            return HttpResponseBadRequest("invalid webhook")

        # Queue the event and return quickly; do the work outside the request.
        process_event.delay(event)
        return HttpResponse(status=204)
    ```
  </CodeBlockTab>
</CodeBlockTabs>

### Other languages [#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](/docs/sdks-and-integrations).

## Checklist [#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.
