# Dynamic Document API documentation # Authentication and test mode (/docs/authentication) > Authenticate with API keys, choose scopes, use test mode, roll and revoke keys, and find the API host for your data. Every API request is authenticated with an API key that belongs to one workspace. Keys have a mode (test or live), a set of scopes and optional restrictions. Create and manage them under **API keys** in the dashboard at [https://app.dynamicdocumentapi.com](https://app.dynamicdocumentapi.com). ## Send your API key [#send-your-api-key] Pass the key as a bearer token in the `Authorization` header: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/account \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" ``` Tools that can only set a custom header can send the key in `X-API-Key` instead: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/account \ -H "X-API-Key: $DYNAMIC_DOCUMENT_API_KEY" ``` A request without credentials fails with `401 authentication_required`, and an unknown or malformed key fails with `401 invalid_api_key`. The [Zapier](https://dynamicdocumentapi.com/docs/zapier) and [Make](https://dynamicdocumentapi.com/docs/make) apps and the [MCP server](https://dynamicdocumentapi.com/docs/mcp) use OAuth access tokens instead of keys: you connect them with your dashboard login and choose the workspace on the consent screen. ## Key format [#key-format] Keys have this structure: ```text dda_live_<24-character key ID>_<40-character secret> dda_test_<24-character key ID>_<40-character secret> ``` The mode is part of the key, so you can tell test and live keys apart at a glance. Only a hash of the secret is stored. The full key is displayed once, when you create it; afterwards the dashboard shows only the prefix and the last four characters. If you lose a key, roll it or create a new one. Creating a key requires you to confirm your identity again. ## Keys, scopes and restrictions [#keys-scopes-and-restrictions] A workspace can have any number of keys, for example one per service or environment. Owners and Admins manage all keys in a workspace, and Developers manage their own keys. Each key has: * a **name**, so you can tell what it's used for * a **mode**: `test` or `live` * **scopes** that limit what it can do * an optional **template allowlist** * an optional **IP allowlist** * an optional **expiry date** ### Scopes [#scopes] Give each key only the scopes it needs. A request outside the key's scopes fails with `403 insufficient_scope`. | Scope | Allows | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `render:write` | Creating renders with `POST /v1/renders` and the `/v1/pdf/…` and `/v1/images/…` convenience endpoints, as well as [batches](https://dynamicdocumentapi.com/docs/batches), [PDF tools](https://dynamicdocumentapi.com/docs/pdf-tools), e-invoice XML and uploads | | `renders:read` | Listing and retrieving renders, batches and email sends, with their files and logs | | `renders:delete` | Purging render files with `DELETE /v1/renders/{id}/files` | | `templates:read` | Listing templates and reading their schemas, versions and sources | | `templates:write` | Creating templates, replacing drafts, publishing, rolling back and deleting templates through the API | | `webhooks:read`, `webhooks:write` | Reading webhook endpoints and deliveries; managing endpoints and replaying deliveries | | `storage:read`, `storage:write` | Reading [storage destinations](https://dynamicdocumentapi.com/docs/storage); creating, changing, testing and deleting them | | `signed_links:read`, `signed_links:write` | Reading [signed links](https://dynamicdocumentapi.com/docs/signed-links); creating, changing, signing and deleting them | | `email:read`, `email:write` | Reading the connections and rules of [email delivery](https://dynamicdocumentapi.com/docs/email-delivery); creating, changing, testing and deleting them | | `account:read` | Reading the account, plan, limits and usage (`GET /v1/account`, `GET /v1/usage`) | For webhooks, storage, signed links and email, the `write` scope includes reading: a key with only `storage:write` can also list and retrieve storage destinations. ### Template allowlist [#template-allowlist] Restrict a key to specific templates, for example a key used by a no-code tool that should only generate one document type. Rendering any other template fails with `403 template_not_allowed_for_key`. ### IP allowlist [#ip-allowlist] Restrict a key to one or more IPv4 or IPv6 ranges in CIDR notation, such as `203.0.113.0/24`. Requests from other addresses fail with `403 ip_not_allowed`. To restrict every key of the workspace, set the **API IP allowlist** under **Settings** → **Security** in the dashboard. It works the same way, and a key with its own allowlist has to pass both. ### Expiry [#expiry] Keys can expire on a date you choose. You receive an email before a key expires, and requests with an expired key fail with `401 api_key_expired`. Seven days before, an [`api_key.expiring`](https://dynamicdocumentapi.com/docs/webhooks#event-types) webhook event is sent as well. ## Test mode [#test-mode] Test keys let you build and run automated tests against the real API without using any of your renders: | | Test key | Live key | | ------------------ | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | Prefix | `dda_test_` | `dda_live_` | | Billed renders | Free; not counted in usage | Billed per the [render table](https://dynamicdocumentapi.com/docs/plans-and-limits#what-counts-as-a-render) | | Output | Watermarked "TEST" (e-invoice XML: a [TEST note](https://dynamicdocumentapi.com/docs/e-invoicing#test-mode)) | Clean | | File storage | Deleted after 24 hours | Kept for your workspace's retention period | | Webhooks | Delivered and signed | Delivered and signed | | Rate limit | Test renders per minute by plan: 10 (Free), 60 (Starter, Growth), 120 (Pro), 300 (Scale) | Your plan's [rate limits](https://dynamicdocumentapi.com/docs/plans-and-limits#limits-by-plan) | | Template drafts | `"version": "draft"` allowed | Only if your workspace settings allow it | | Email verification | Not required | Required (`403 email_not_verified`) | Renders made with a test key are always test renders and have `"test": true`. Sending `"test": true` with a live key fails with `403 test_key_required`; use a test key instead. Use test keys in development, CI and staging. Test renders go through the same pipeline as live renders, so layouts, page counts, errors and webhooks match live behaviour apart from the watermark. ## Roll and revoke keys [#roll-and-revoke-keys] Rotate keys regularly and whenever someone who had access leaves. * **Roll** a key to create a new secret with the same name, mode, scopes and restrictions. Choose a grace period of 0, 1, 24 or 72 hours during which the old secret keeps working, deploy the new key, and let the old one lapse. * **Revoke** a key to disable it immediately. Requests with a revoked key fail with `401 api_key_revoked`. The dashboard shows when each key was last used, the last IP address and request counts for the past 24 hours and 30 days. Keys that haven't been used for 90 days are flagged so you can remove them. > **If a key leaks** > > Roll it with a grace period of 0 hours, or revoke it, then deploy the replacement. Review recent renders in the dashboard for requests you don't recognise. ## Never use secret keys in browsers [#never-use-secret-keys-in-browsers] CORS is not enabled for secret API keys, so browsers can't call the API directly, and a key embedded in a web page, mobile app or public repository can be copied by anyone who sees it. Call the API from your backend and pass the resulting file or signed URL to the client. To let a page or an email request an image or PDF without a key, use [signed links](https://dynamicdocumentapi.com/docs/signed-links): URLs signed on your server with a per-link secret. Publishable keys that can only create signed URLs for allowlisted templates and origins are planned. ## Data residency [#data-residency] Render payloads and generated files are processed and stored in the EU. The API answers on two hosts: | Host | Region | | --------------------------------------- | ------------ | | `https://api-eu.dynamicdocumentapi.com` | EU (default) | | `https://api.dynamicdocumentapi.com` | EU (alias) | Append `/v1` to the host to get the base URL, for example `https://api-eu.dynamicdocumentapi.com/v1`. ## Request IDs [#request-ids] Every response has an `X-Request-Id` header, and error responses repeat it in the `request_id` field. Log it with your own request data, and include it when you contact support at [support@dynamicdocumentapi.com](mailto:support@dynamicdocumentapi.com). You can also send your own `X-Request-Id` header to correlate API calls with your logs. # Batches (/docs/batches) > Render one template for many items with POST /v1/batches, from JSON and CSV items and the combined ZIP or merged PDF to progress, cancel and retry, webhooks, billing and limits. A batch renders one published template version for many items in a single request. Each item has its own data and becomes an ordinary render with its own files. The batch tracks the items together, reports progress and can package the results as one ZIP file or one merged PDF. Batches are included from the Starter plan. Batches are asynchronous: `POST /v1/batches` answers `202 Accepted` at once, and the items render in the background. Follow them with `GET /v1/batches/{id}` or the `batch.progress` and `batch.completed` [webhook events](#webhooks). ## When to use a batch [#when-to-use-a-batch] Use a batch when you render the same template for a list of records, such as certificates for every participant of a course, monthly statements or name badges: * One request covers up to your plan's [item limit](#limits) instead of one request per document. The items start in turn, as many at a time as your plan's bulk concurrency allows. * One batch object shows the progress of all items, and two webhook events replace one event per document. * The results can be packaged as one ZIP file or one merged PDF. * Failed items can be rendered again with one call. * The items can come from a CSV file. Send single renders with `POST /v1/renders` instead when you need the file in the response (sync mode, `binary` or `base64` delivery), when the documents use different templates, or for HTML, URL or Markdown input. A batch renders one stored template, and its items can't use `data_url`. In the dashboard, **Batches** → **New batch** walks through the same steps: pick a published template, upload a CSV file or paste JSON, map the columns, check a few sample renders and start the batch. ## Create a batch [#create-a-batch] ```bash curl https://api-eu.dynamicdocumentapi.com/v1/batches \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: certificates-course-2026-09" \ -H "Content-Type: application/json" \ -d '{ "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", "items": [ { "data": { "name": "Ann Example", "course": "Safety 101" }, "reference": "cert_001", "filename": "ann-example.pdf" }, { "data": { "name": "Bob Example", "course": "Safety 101" }, "reference": "cert_002" } ], "output": { "format": "pdf", "filename": "certificate-{{ data.name }}.pdf" }, "combine": { "zip": true, "zip_filename": "certificates.zip" }, "reference": "course_2026_09", "metadata": { "course": "safety-101" } }' ``` The API checks the whole batch before anything renders: the template, the options and every item. If something is invalid, no batch is created, and the error's `errors[]` lists each problem with a JSON pointer, such as `/items/1/data` or `/csv/row/5`. A valid batch is answered with `202 Accepted`, a `Location` header and the [batch object](#the-batch-object): ```http HTTP/1.1 202 Accepted Location: /v1/batches/bat_01J9ZN4T6V8X0Z2B4D6F8H0K2M Content-Type: application/json {"id": "bat_01J9ZN4T6V8X0Z2B4D6F8H0K2M", "object": "batch", "status": "queued", "total": 2, "succeeded": 0, "failed": 0, "pending": 2, "progress": 0.0} ``` The response body above is shortened. ### Request body [#request-body] | Field | Type | Default | Description | | --------------- | ----------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `template_id` | string | required | The template to render (`tpl_…`) | | `version` | string or integer | `"live"` | `"live"` for the published version, or a version number such as `7`. Drafts can't be rendered in a batch, not even with a test key. | | `items` | array | — | The items as JSON. See [items](#items). | | `csv_upload_id` | string | — | A CSV file uploaded with `POST /v1/uploads` (`upl_…`), instead of `items`. See [CSV input](#csv-input). | | `csv_mapping` | object | every column | Which CSV column fills which template variable | | `output` | object | the template's options | Format, file name and format options, as in a [render request](https://dynamicdocumentapi.com/docs/renders). Applies to every item. | | `delivery` | object | `url` delivery, or `none` without hosted copies | As in a render request, except `binary` and `base64`. Applies to every item. | | `combine` | object | none | A ZIP file and a merged PDF of the results. See [combined files](#combined-files). | | `webhook` | object | none | A webhook for this batch's events. See [webhooks](#webhooks). | | `reference` | string | none | Your identifier for the batch, up to 255 characters. Usable as a list filter. | | `metadata` | object | none | Key-value pairs for the batch. They are copied to every item's render. | | `engine` | string | template or workspace default | Engine channel for every item | | `test` | boolean | `false` | Marks a test batch. See [test batches](#test-batches). | Send exactly one of `items` and `csv_upload_id`. Fields of a render request that aren't listed here, such as `mode`, `data_url` or `priority`, are rejected with `400 validation_error`. The [API reference](https://dynamicdocumentapi.com/docs/api-reference) has the full schemas. ### Items [#items] Each entry in `items` is one document: | Field | Description | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data` | The item's data for the template, up to 1 MB. Keys become template variables, as in a render request. | | `reference` | Your identifier for the item, up to 255 characters. It becomes the `reference` of the item's render. | | `filename` | File name for this item, up to 255 characters. It replaces `output.filename`, and the file gets the extension of the output format. | | `metadata` | Key-value pairs added to the batch's `metadata` on this item's render. The item's value wins when a key is in both. Batch and item together allow 50 keys. | `output.filename` is evaluated for each item with that item's data, so `certificate-{{ data.name }}.pdf` gives every file its own name. ### CSV input [#csv-input] A batch can also read its items from a CSV file. Upload the file with `POST /v1/uploads` first: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/uploads \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -F "file=@participants.csv" ``` The response contains the upload's `id` (`upl_…`). Uploads can be up to 50 MB and are kept for 24 hours; the batch reads the file when you create it. Then pass the ID as `csv_upload_id`: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/batches \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", "csv_upload_id": "upl_01J9ZM1X3F7R8K2C4V6B8N0P2Q", "csv_mapping": { "name": "Full Name", "course": "Course" }, "combine": { "merged_pdf": true } }' ``` With this file, the first item gets the data `{"name": "Ann Example", "course": "Safety 101"}`, the reference `cert_001` and the file name `ann-example.pdf`: ```csv title="participants.csv" Full Name,Course,_reference,_filename Ann Example,Safety 101,cert_001,ann-example Bob Example,Safety 101,cert_002,bob-example ``` * The file is UTF-8 (a byte order mark is ignored) and comma-separated as in RFC 4180, with the column names in the first row. Give it a `.csv` name when you upload it. * `csv_mapping` maps template variables to column names, and only the mapped columns are used. A dotted variable such as `customer.name` becomes a nested object. * Without `csv_mapping`, every column is used under its own name, and a dotted column name such as `customer.name` becomes a nested object. * Values are always strings, and an empty cell is `""`. For numbers, booleans, lists (such as an e-invoice's `lines`) or per-item `metadata`, send JSON `items`. * The optional columns `_reference` and `_filename` set each item's `reference` and `filename`, up to 255 characters. They aren't part of the data. * Rows whose cells are all empty are skipped. Every other row needs as many fields as the header. * Rows are numbered as in a spreadsheet: the header is row 1 and the first item is row 2. Errors point at rows as `/csv/row/` and at the mapping as `/csv_mapping/`. ### Options for all items [#options-for-all-items] `output` and `delivery` work as in a [render request](https://dynamicdocumentapi.com/docs/renders): the template's stored options apply, and what you send overrides them. Some rules are specific to batches: * **Async only.** `delivery.type` is `url` or `none`. Without it, the items get `url` when they keep a hosted copy and `none` when they don't, see [Hosted copy](https://dynamicdocumentapi.com/docs/storage#hosted-copy). `binary` and `base64` need a sync render and are rejected. * **Data schema.** If the template enforces a JSON Schema for its data, every item is checked when you create the batch. One mismatch rejects the whole batch with `422 data_schema_mismatch`, with pointers such as `/items/3/data/name` or `/csv/row/5/data/name`. * **E-invoices.** A literal `output.einvoice.invoice` applies to every item. `invoice_path`, and a template's default e-invoice, are resolved in each item's own data. See [E-invoicing](https://dynamicdocumentapi.com/docs/e-invoicing). * **Storage.** Each item's files are uploaded to the destinations in `delivery.storage`, or to your workspace's default destinations, like a single render's. In path templates, `render.reference` is the item's reference. See [Storage](https://dynamicdocumentapi.com/docs/storage). * **Emails.** Email rules apply to every item. See [emails](#emails). ## Combined files [#combined-files] Set `combine` to package the results once every item has finished: | Field | Default | Description | | -------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `zip` | `false` | One ZIP file with the file of every succeeded item | | `merged_pdf` | `false` | One PDF with the pages of every succeeded item. Needs `output.format: "pdf"`. | | `zip_filename` | the batch ID | File name of the ZIP, up to 200 characters. The merged PDF gets the same name with `.pdf`. Without it, the files are named after the batch, such as `bat_….zip`. | * The combined files are built when every item has a final status and at least one item succeeded. They use the hosted files of the succeeded items, in item order; failed and canceled items are left out. * ZIP entries are named after the items' files. A file without a name of its own is named after its render ID and stored as `-rnd_….`. Repeated names get ` (2)`, ` (3)` and so on before the extension, ignoring case. * The merged PDF has one bookmark per item, titled with the item's file name. It counts against your plan's pages per PDF: a batch with more items than that limit can't request a merged PDF, and a merged PDF that comes out longer fails. * The files appear in the batch's `files` with a signed `url` that lasts `delivery.expires_in` seconds. Every `GET /v1/batches/{id}` returns fresh URLs. The files are kept for the same retention period as the items. * The combined files hold every succeeded item or none. If a combined file can't be built, the batch ends as `failed`, and its `error` says why. The item files stay available on their renders as long as their retention lasts. * An item's files can be gone by the time the last item finishes: their retention ended while the batch waited for renders or ran for a long time (test batches keep files for a day), or `DELETE /v1/renders/{id}/files` purged them. Then neither the ZIP nor the merged PDF is built, and `error` is `{"code": "item_files_expired", "message": "…", "items": […]}`, where `items` lists the indexes of those items (at most 100). * A canceled batch isn't combined. * `combine` needs items that keep their hosted copy. It is rejected with `400 validation_error` at `/combine` for [zero-retention](#zero-retention) batches (code `zero_retention`) and for batches whose items keep no hosted copy (code `not_hosted`): `delivery.hosted: false`, or no `hosted` while every storage destination of the batch has `keep_hosted_copy: false`, see [Hosted copy](https://dynamicdocumentapi.com/docs/storage#hosted-copy). To combine with such destinations, send `delivery.hosted: true`. In the dashboard, tick **Keep hosted copies** in the **Options** step of **New batch**; until you do, the ZIP file and the merged PDF can't be selected. ## Track a batch [#track-a-batch] `GET /v1/batches/{id}` returns the batch object with its counters, its progress and, once they are built, signed URLs for the ZIP file and the merged PDF. [Webhooks](#webhooks) tell you when a batch has finished, without polling. ### The batch object [#the-batch-object] ```json { "id": "bat_01J9ZN4T6V8X0Z2B4D6F8H0K2M", "object": "batch", "status": "completed", "test": false, "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", "template_version": 7, "total": 1200, "succeeded": 1198, "failed": 2, "canceled": 0, "pending": 0, "progress": 1.0, "paused": null, "error": null, "files": [ { "kind": "zip", "file_id": "file_01J9ZP0A2C4E6G8J0K2N4Q6S8V", "filename": "certificates.zip", "content_type": "application/zip", "bytes": 41873920, "url": "https://dda-eu-outputs.nbg1.your-objectstorage.com/…", "url_expires_at": "2026-09-17T16:21:08Z", "expires_at": "2026-10-17T15:21:08Z" } ], "combine": { "zip": true, "merged_pdf": false, "zip_filename": "certificates.zip" }, "emails": { "sent": 0, "failed": 0, "unknown": 0, "pending": 0, "skipped": 0, "canceled": 0 }, "reference": "course_2026_09", "metadata": { "course": "safety-101" }, "created_at": "2026-09-17T15:06:33Z", "completed_at": "2026-09-17T15:21:08Z" } ``` | Field | Description | | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Batch ID (`bat_…`) | | `object` | Always `batch` | | `status` | See [status values](#status-values) | | `test` | `true` for test batches | | `template_id`, `template_version` | The template and the version number every item renders | | `total` | Number of items | | `succeeded`, `failed`, `canceled` | Items that ended with that status | | `pending` | Items without a final status yet, started or not | | `progress` | Finished items divided by `total`, from `0` to `1` | | `paused` | `null`, or the reason and start time while a live batch waits for renders. See [paused batches](#paused-batches). | | `error` | `null`, unless the batch `failed` because a requested combined file couldn't be built: then that file's error (the ZIP's first), with `code` and `message`, and `items` for `item_files_expired`. See [combined files](#combined-files). | | `files` | The ZIP file and the merged PDF, once built | | `combine` | The `combine` options of the request | | `emails` | The items' emails by status. See [emails](#emails). | | `reference`, `metadata` | Values from your request | | `created_at`, `completed_at` | When the batch was created, and when it reached a final status | Each entry in `files` has: | Field | Description | | ----------------------------------- | ---------------------------------------------------------------------------------------- | | `kind` | `zip` or `merged_pdf` | | `file_id` | File ID (`file_…`) | | `filename`, `content_type`, `bytes` | File name, media type and size in bytes | | `pages` | Number of pages, for the merged PDF | | `url`, `url_expires_at` | Signed download URL and when it stops working. Omitted when no hosted copy is available. | | `expires_at` | When the file will be deleted, or `null` if it's kept | ### Status values [#status-values] | Status | Meaning | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `queued` | Accepted; no item has started yet | | `processing` | Items are rendering, or the combined files are being built | | `completed` | Every item has a final status, at least one succeeded, and every requested combined file was built. Items that didn't succeed are counted in `failed` and `canceled`. | | `failed` | No item succeeded, or a requested combined file couldn't be built; `error` then says why | | `canceled` | Canceled with `POST /v1/batches/{id}/cancel` | ### Paused batches [#paused-batches] If your workspace runs out of renders while a live batch is running, the batch pauses instead of failing: * Items that haven't started wait. Items that are already rendering finish normally. * The batch stays `processing`, `paused` shows `{"reason": "render_limit_reached", "since": "…"}`, and one `batch.progress` event is sent. * About once a minute the batch checks again. It resumes on its own once renders are available, for example after you raise the monthly top-up limit or change your plan, or when the next billing cycle starts. * Canceling works while a batch is paused. A live batch created while no renders are left at all fails at once with `402 render_limit_reached`. See [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits) for top-ups and the monthly top-up limit. ### Items [#items-1] `GET /v1/batches/{id}/items` lists the items in their original order, with cursor pagination. Filter by `status`, for example to see what `retry-failed` would render again: ```bash curl -G https://api-eu.dynamicdocumentapi.com/v1/batches/bat_01J9ZN4T6V8X0Z2B4D6F8H0K2M/items \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ --data-urlencode "status=failed" ``` ```json { "object": "list", "data": [ { "index": 17, "status": "failed", "render_id": "rnd_01J9ZN5B7D9F1H3K5M7P9R1T3V", "reference": "cert_018", "filename": null, "error": { "code": "template_runtime_error", "message": "'dict object' has no attribute 'course'" } } ], "has_more": false, "next_cursor": null } ``` | Field | Description | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `index` | The item's position, counting from 0: its place in `items`, or among the CSV's data rows | | `status` | See the table below | | `render_id` | The item's render (`rnd_…`) once it has started, otherwise `null` | | `reference`, `filename` | The item's values | | `error` | Why the item failed: `code` and `message`, plus `line`, `column` and `excerpt` for template errors. While a retried item waits for its new render, `previous_render_id` names the failed one. | | Item status | Meaning | | ----------- | ------------------------------------------ | | `pending` | Not started yet | | `queued` | Started: its render is queued or rendering | | `succeeded` | Its render succeeded | | `failed` | Its render failed | | `canceled` | Canceled before it finished | ### Renders of a batch [#renders-of-a-batch] Every item is an ordinary render with `source: "batch"`, `mode: "async"` and the batch's ID in `batch_id`. Its `reference` is the item's, not the batch's, and its `metadata` combines the batch's and the item's. Get an item's files with `GET /v1/renders/{id}`, using the `render_id` from the items list, or list every render of the batch with `GET /v1/renders?batch_id=bat_…`. That list also contains the renders that built the ZIP file and the merged PDF (`input_type: "pdf_tool"`) and the earlier attempts of retried items; add `input_type=template` to leave out the combined files. See [Renders](https://dynamicdocumentapi.com/docs/renders) for the render object and the other list filters. ## Cancel a batch [#cancel-a-batch] `POST /v1/batches/{id}/cancel` stops a batch that hasn't finished: ```bash curl -X POST https://api-eu.dynamicdocumentapi.com/v1/batches/bat_01J9ZN4T6V8X0Z2B4D6F8H0K2M/cancel \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" ``` * Items that haven't started are canceled at once and never billed. * Items that have started are canceled when a renderer picks them up. An item that is already rendering may still finish; it then counts as succeeded or failed, and is billed if it succeeds. * The batch becomes `canceled` immediately, `batch.completed` is sent, and no combined files are built. The response is the batch object. Canceling a batch that has already finished fails with `409 conflict`, and the problem details include its `batch_status`. ## Retry failed items [#retry-failed-items] `POST /v1/batches/{id}/retry-failed` renders the failed items of a finished batch again: ```bash curl -X POST https://api-eu.dynamicdocumentapi.com/v1/batches/bat_01J9ZN4T6V8X0Z2B4D6F8H0K2M/retry-failed \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" ``` * Each failed item goes back to `pending` and renders as a new render, with the template version, data and options the batch was created with. Retrying helps when items failed for a temporary reason. To fix a template or data error, create a new batch with the failed items. * The batch returns to `processing`, `completed_at` is cleared, `error` goes back to `null` and `files` is emptied. The ZIP file and the merged PDF are built again, and `batch.completed` is sent again, when the batch finishes. * Retrying works for `completed`, `failed` and `canceled` batches with at least one failed item. Canceled items stay canceled. The response is the batch object with `202 Accepted`. A batch that is still running or has no failed items answers `409 conflict`. A [zero-retention](#zero-retention) batch answers `409 zero_retention`, because its items no longer have their data. A batch with `combine` whose succeeded items' files are already gone answers `409 item_files_expired`, with their indexes in `items`, before anything is rendered again: the new combined files couldn't hold those items. Submit the failed items in a new batch instead. ## Endpoints [#endpoints] | Endpoint | Scope | Description | | ------------------------------------- | -------------- | -------------------------------------------------------------------------- | | `POST /v1/batches` | `render:write` | Create a batch | | `GET /v1/batches` | `renders:read` | List batches, newest first. Filters: `status`, `template_id`, `reference`. | | `GET /v1/batches/{id}` | `renders:read` | Retrieve a batch, with fresh signed URLs for its combined files | | `GET /v1/batches/{id}/items` | `renders:read` | List the items in order. Filter: `status`. | | `POST /v1/batches/{id}/cancel` | `render:write` | Cancel a batch that hasn't finished | | `POST /v1/batches/{id}/retry-failed` | `render:write` | Render the failed items again | | `POST /v1/batches/{id}/resend-emails` | `render:write` | Resend the items' failed emails. See [emails](#emails). | Both lists use cursor pagination like `GET /v1/renders`: `limit` (up to 100, 50 by default) and `cursor`. Test keys only see test batches. ## Webhooks [#webhooks] A batch sends two events instead of one event per item: | Event | Sent when | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `batch.progress` | Another 10 % of the items has finished (at 10 %, 20 % and so on up to 90 %, each at most once), or a live batch [paused](#paused-batches) because the render limit was reached | | `batch.completed` | The batch reached a final status: `completed`, `failed` or `canceled` | The item that finishes a batch sends no progress event: the batch builds its combined files, if any, and then sends `batch.completed`. `data.object` is the full [batch object](#the-batch-object), including signed URLs for the combined files unless your workspace has file URLs in webhooks turned off. There are two ways to receive the events: * **Webhook endpoints** subscribed to `batch.progress` or `batch.completed`. An endpoint with a `template_ids` filter receives a batch's events only when the filter lists the batch's template. * **A webhook for one batch**, set in the request: ```json { "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", "items": [{ "data": { "name": "Ann Example", "course": "Safety 101" } }], "webhook": { "url": "https://example.com/webhooks/batches", "events": ["batch.completed"] } } ``` | Field | Description | | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `url` | Where the events go. HTTPS is required, except for test batches. Private and local addresses are rejected with `422 url_not_allowed`. | | `events` | `batch.progress`, `batch.completed` or both (the default) | Like per-request render webhooks, these deliveries are signed with the secret of your workspace's default webhook endpoint, so the workspace needs one. Without it, the batch is rejected with `400 validation_error` at `/webhook`. A delivery looks like this (the object is shortened): ```json { "id": "evt_01J9ZP2C4E6G8J0K2M4P6R8T0V", "type": "batch.completed", "created_at": "2026-09-17T15:21:08Z", "workspace_id": "ws_01J9ZK0P2R4T6V8X0Z2B4D6F8H", "region": "eu", "data": { "object": { "id": "bat_01J9ZN4T6V8X0Z2B4D6F8H0K2M", "object": "batch", "status": "completed", "total": 1200, "succeeded": 1198, "failed": 2, "files": [{ "kind": "zip", "filename": "certificates.zip", "url": "https://dda-eu-outputs.nbg1.your-objectstorage.com/…" }] } } } ``` Batch events are signed, retried and logged like every other event; see [Webhooks](https://dynamicdocumentapi.com/docs/webhooks) to verify them. Test batches send them too, with `"test": true` on the batch. The endpoint test, `POST /v1/webhook-endpoints/{id}/test`, only sends render and template events, so run a small test batch to try batch events. > **No render events for batch items** > > Item renders don't send `render.succeeded`, `render.failed` or `render.expired`, not even to endpoints subscribed to them: the batch events cover them. Read the outcome of each item from `GET /v1/batches/{id}/items`. Email events of items are sent as usual. ## Emails [#emails] With [email delivery](https://dynamicdocumentapi.com/docs/email-delivery), on Growth and higher plans, every item sends the template's email rules like a single render does. `delivery.email` works as in a render request: leave it out for the template's enabled rules, set `false` for none, or list the rules to use. The rules are read once, when the batch is created. The batch object's `emails` counts the newest email of each item and rule by status. To send the failed ones again: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/batches/bat_01J9ZN4T6V8X0Z2B4D6F8H0K2M/resend-emails \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"statuses": ["failed"]}' ``` ```json { "resent": 12, "skipped": [{ "send_id": "ems_01J9ZM1X3F7R8K2C4V6B8N0P2T", "code": "email_file_expired" }] } ``` `statuses` defaults to `["failed"]`. Add `"unknown"` to resend emails whose outcome is unknown as well, but check your provider's logs first: the provider may already have delivered them. `skipped` lists up to 100 emails that couldn't be resent, with the reason. ## Zero retention [#zero-retention] A batch can use [zero-retention delivery](https://dynamicdocumentapi.com/docs/renders) when the files go to your own [storage](https://dynamicdocumentapi.com/docs/storage). Set `delivery.retention` to `"none"` (or turn zero retention on in your workspace settings), and send the files to a storage destination, in `delivery.storage` or through your workspace's default destinations: ```json { "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", "items": [{ "data": { "employee": { "name": "Alex Example" }, "salary": 5400 } }], "delivery": { "retention": "none", "storage": [{ "destination_id": "dst_01J9ZK7R9T1V3X5Z7B9D1F3H5K" }] } } ``` In a zero-retention batch: * each item's files go to your storage only, so there are no hosted copies and no download URLs * `delivery.type` is `none`, the only type such a batch can use, so you can leave it out * `combine` is rejected, because there are no hosted files to combine * an item's data is deleted as soon as the item finishes, so `retry-failed` answers `409 zero_retention`; submit the failed items in a new batch * email rules don't apply ## Test batches [#test-batches] A batch created with a test key is a test batch, with `"test": true`. Sending `"test": true` with a live key fails with `403 test_key_required`. The items of a test batch are test renders: free and watermarked "TEST". A test batch counts as one render towards your plan's test renders per minute, whatever its size, and sends webhook events like a live batch. ## Billing [#billing] Every item is a render and is billed like one, following the [render table](https://dynamicdocumentapi.com/docs/plans-and-limits): a PDF of up to 50 pages is 1 render, each further 50 pages add 1, and an image is 1 render. Each item's render shows its cost in `billed_renders`. | Part of a batch | Billed renders | | ---------------------------------------- | ------------------------------------------------- | | Item that succeeds | The same as a single render of that output | | Item that fails or is canceled | 0 | | ZIP file | 0 (the renders inside are already billed) | | Merged PDF | 1 | | Retried item | Billed like any item when its new render succeeds | | Test batch, including its combined files | 0 | For example, a batch of 1,000 two-page invoices with a ZIP file and a merged PDF costs 1,001 renders when every item succeeds. Items are billed as they finish. A live batch that runs out of renders [pauses](#paused-batches) until renders are available again. ## Limits [#limits] Batches are available from the Starter plan. On Free, `POST /v1/batches` answers `402 plan_feature_unavailable`. | Limit | Free | Starter | Growth | Pro | Scale | Enterprise | | ---------------- | ---- | ------- | ------ | ------ | ------ | ---------- | | Items per batch | — | 500 | 2,000 | 10,000 | 50,000 | custom | | Bulk concurrency | — | 2 | 10 | 20 | 50 | custom | **Bulk concurrency** is the number of batch items of your workspace that render at the same time, across all its batches. Running batches take turns, oldest first, and a new item starts when one finishes. Your current value is `limits.bulk_concurrency` in `GET /v1/account`. A batch with more items than your plan allows is rejected with `400 validation_error` (`too_many_items`); split it into several batches. | Limit on every plan | Value | | ---------------------------------- | ---------------------------------------------------------------- | | Request body of `POST /v1/batches` | 25 MB | | Data per item | 1 MB | | CSV upload | 50 MB, kept for 24 hours | | `metadata` | 50 keys for batch and item together, values up to 500 characters | | `reference` and item `filename` | 255 characters | | `combine.zip_filename` | 200 characters | | `csv_mapping` | 500 entries | Each item also renders within your plan's limits for a single render, such as the async maximum duration and the pages per PDF. See [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits). ## Idempotency [#idempotency] `POST /v1/batches` accepts an `Idempotency-Key` header, as `POST /v1/renders` does. Reuse the key when you retry after a network error or a timeout: * The same key with the same body within 24 hours returns the original `202` response, and no second batch is created. * While the first request is still being processed, a retry fails with `409 idempotency_in_progress`. * The same key with a different body fails with `422 idempotency_key_reused`. The cancel, retry-failed and resend-emails endpoints accept the header too. ## Errors [#errors] | Status | Code | When | | ------ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `validation_error` | The request or an item is invalid. `errors[]` points at each problem, such as `/items/3/metadata`, `/csv/row/5` or `/combine/merged_pdf`. `/combine` with the code `zero_retention` or `not_hosted` means the items keep no hosted files to combine. | | `402` | `plan_feature_unavailable` | Your plan doesn't include batches, or a feature the batch uses, such as e-invoices below Growth | | `402` | `render_limit_reached` | A live batch was created while your workspace had no renders left | | `403` | `test_key_required` | `"test": true` with a live key | | `404` | `template_not_found`, `version_not_found` | The template or the version doesn't exist, or the template has no published version | | `404` | `upload_not_found` | The CSV upload doesn't exist or has expired | | `404` | `not_found` | The batch doesn't exist. Test keys only see test batches. | | `409` | `conflict` | Cancel on a finished batch, or retry-failed on a running batch or one without failed items | | `409` | `zero_retention` | Retry-failed on a zero-retention batch | | `409` | `item_files_expired` | Retry-failed on a batch with `combine` whose succeeded items' files are gone; `items` lists their indexes | | `410` | `template_deleted` | The template was deleted | | `413` | `payload_too_large` | The request body is over 25 MB, or an item's data is over 1 MB | | `422` | `data_schema_mismatch` | An item's data doesn't match the template's JSON Schema | | `422` | `url_not_allowed` | The batch's `webhook.url` isn't allowed | See [Errors](https://dynamicdocumentapi.com/docs/errors) for the problem details format and every other code. ## Related pages [#related-pages] * [Renders](https://dynamicdocumentapi.com/docs/renders) for the render request, delivery options and the render object * [Webhooks](https://dynamicdocumentapi.com/docs/webhooks) for endpoints, signatures and retries * [Storage](https://dynamicdocumentapi.com/docs/storage) for uploading files to your own buckets * [Email delivery](https://dynamicdocumentapi.com/docs/email-delivery) for email rules and sends * [E-invoicing](https://dynamicdocumentapi.com/docs/e-invoicing) for e-invoices in batches * [PDF tools](https://dynamicdocumentapi.com/docs/pdf-tools) for merging, splitting and converting existing PDFs * [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits) for the render table and every limit * [Errors](https://dynamicdocumentapi.com/docs/errors) for every error code # Canvas editor (/docs/canvas-editor) > Design image templates in the dashboard, with artboard sizes, text that fits its box, images, QR codes, barcodes, charts, auto layout and constraints. Covers dynamic properties, sample data, previews, publishing, keyboard shortcuts and limits. The canvas editor is where you design image templates: social cards and Open Graph images, email headers, display ads, certificates, badges and labels. You place elements on an artboard and mark the properties that change from render to render as dynamic. Then you render the template through the API with your data. Canvas templates render to PNG (the default), JPEG, WebP or PDF. The editor's instant preview and our renderer draw the design with the same scene renderer, so what you see in the editor is what the API returns. PDF templates written in HTML and CSS use the other editor, see [Template language](https://dynamicdocumentapi.com/docs/template-language). ## Create an image template [#create-an-image-template] * In the [dashboard](https://app.dynamicdocumentapi.com), choose **New template** → **Blank image** for an empty 1200 × 630 canvas. Pick another size under **Artboard**. * Or filter the gallery by **Format** → **Image** and choose **Use this template** on one of the image templates: Open Graph images, email headers, display ads, testimonials, sale banners and event cards. In the dashboard, image templates have the type **Image** (`image_canvas` in the API). The editor needs a desktop-width window; on smaller screens the dashboard shows a notice instead. ## The editor at a glance [#the-editor-at-a-glance] | Area | What's there | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Top bar | **Preview**, **Publish**, **API** (⌘J: ready-made requests for this template) and **More actions**: Duplicate template, Export bundle, Revert draft to live version, Keyboard shortcuts, Open documentation, Download PNG, Copy JSON example | | Toolbar | The tools (Select, Hand, Text, Rectangle, Ellipse, Line, Image, Frame), Undo and Redo, grouping, auto layout, alignment and distribution, order, snapping, the sample-data switch, the server preview, Export and zoom | | Left panel | **Layers**, the element tree: drag to reorder or to move into a frame, rename, search, hide and lock. **Elements**: everything you can add | | Canvas | The artboard. The bottom bar shows its size, the selection, scene problems and the zoom, and switches **Rulers**, **Pixel grid** and **Pixel preview** (Off, 1×, 2×) | | Right panel | **Design**: the properties of the selection, or of the artboard when nothing is selected. **Data**: dynamic properties and sample data. **Preview**: instant and server previews | The panels can be resized, and the editor remembers their widths. ## Artboard and output [#artboard-and-output] With nothing selected, the **Design** tab shows the artboard and the output settings. * **Size.** Pick a preset or enter a custom size, up to 8000 × 8000 px. The presets are: | Group | Presets | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Web | Open Graph 1200 × 630, Email header 600 × 200, Full HD 1920 × 1080 | | Social | X/Twitter 1600 × 900, Instagram square 1080 × 1080, Instagram portrait 1080 × 1350, Story 1080 × 1920, LinkedIn 1200 × 627, Facebook 1200 × 630, Pinterest 1000 × 1500, YouTube 1280 × 720 | | Ads | Leaderboard 728 × 90, Medium rectangle 300 × 250, Wide skyscraper 160 × 600 | | Print | Certificate 1754 × 1240, A4 portrait 794 × 1123, Name badge 600 × 900, Shipping label 384 × 576 | * **Background.** None (transparent), a solid colour, a linear or radial gradient, or an image. * **Default format.** PNG, JPEG, WebP or PDF. Renders use it unless the request names another format; see [Render with your data](#render-with-your-data). * **Scale.** The output is the artboard size times the scale. The API accepts 1 to 3; 2 gives sharp images on high-density screens. * **Quality.** For JPEG and WebP. Without a value, renders use 85 for JPEG and 80 for WebP. * **Transparent background.** For PNG and WebP. The file then has no artboard background at all; the instant preview doesn't show this, the server preview does. * **File name.** The name of the rendered file. A PDF from a canvas template is one page at the artboard size, converted at 96 px per inch. It is billed like any PDF render, and it can't be PDF/UA. See [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits#what-counts-as-a-render). ## Elements [#elements] Add elements from the **Elements** tab: a click places one in the middle of the view, inside the selected frame, or drag it onto the canvas. The tools in the toolbar draw text, rectangles, ellipses, lines, images and frames directly. | Group | Elements | | ---------- | ---------------------------------------------------------------------------------------------------- | | Text | Heading, Subheading, Body text | | Shapes | Rectangle, Rounded rectangle, Ellipse, Triangle, Polygon, Star, Line, Arrow | | Images | Upload, workspace assets, stock photos or an image URL, see [Images](#images) | | Components | QR code, Barcode, Rating, Progress bar, Progress ring, Bar chart, Line chart, Pie chart, Donut chart | | Layout | Frame, Auto-layout stack | | Icons | A search over the Lucide icon set; the icon is stored in the template | Every element has a position, size and rotation, can be flipped and skewed, and has opacity, a blend mode, up to 8 drop or inner shadows and a layer blur. Shapes and frames take a fill (solid, linear or radial gradient, or an image) and a stroke with dashes, line ends, corner joins and a position inside, centred on or outside the edge. Rectangles and frames can round each corner separately. | Element | What you can set | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Text | Font, weight, italic, size, line height, letter spacing, colour, underline and strikethrough, horizontal and vertical alignment, case, direction (auto, left to right, right to left), a background box per text or per line, highlight and outline. Double-click the text to format part of it. Named styles keep formatting consistent | | QR code | Content up to 2,953 characters, error correction L, M, Q or H, module and finder styles, colours or a gradient, the quiet zone, and a logo in the middle. A logo needs error correction Q or H, or the code may not scan | | Barcode | Code 128, GS1-128, EAN-8, EAN-13, UPC-A, UPC-E, ITF-14, Code 39, Code 93, Codabar, Data Matrix, PDF417 and Aztec, with human-readable text in OCR-A or OCR-B | | Chart | Bar, horizontal bar, line, area, pie, donut, radar, scatter and gauge charts with labels and series, a title, palette, legend, values, axes, stacking and smoothing. **Advanced ECharts option** merges your own JSON into the chart's options | | Rating | 1 to 20 stars, hearts, circles, squares or a custom shape, in whole or half steps, or exact | | Progress | A bar or a ring, with a label as a percentage, the value or a fraction | | Line | Start and end caps: arrow, triangle, circle, square, bar or diamond | | Polygon and star | 3 to 100 sides or points, with rounded corners | | Ellipse | Arcs and rings | | Icon | Inline SVG or a URL, recoloured, fitted with contain, cover or stretch | ### Text that fits its box [#text-that-fits-its-box] A text box can grow with its text (**Auto width**, **Auto height**) or keep a **Fixed size**. For text that comes from your data, two settings keep the design intact: * **Auto-fit** sets a minimum and maximum font size. Each render uses the largest size between them at which the text fits the box. * **Max lines** and **Overflow** cut text that is still too long, with an ellipsis if you like. Text that is cut off, or that overflows its box, is reported with the warning `text_overflow`. ### Frames, auto layout and constraints [#frames-auto-layout-and-constraints] A **frame** groups elements in a container with its own fill, stroke and corner radius, and can clip what sticks out (**Clip content**). With **auto layout**, a frame arranges its children in a row or a column, with a gap, padding, alignment and distribution. Each child is **Fixed**, **Hug contents** or **Fill container**. A badge whose background grows with its text is a frame with auto layout around a text element. **Constraints** decide how an element moves or resizes when the element it's attached to changes size: per axis left or top, centre, right or bottom, stretch, or scale. An element can be attached to its parent or, with **Anchor**, to another element, for example a price tag that stays to the right of a headline of any length. Constraints don't apply to children that auto layout arranges. ## Images [#images] The image picker has these tabs: * **Upload image**: PNG, JPEG, WebP, GIF or SVG up to 10 MB, kept in the workspace's assets * **Workspace assets**: images uploaded before * **Stock photos**: a search over Unsplash and Pexels, where your dashboard offers it * **Image URL**: any public image address You can also drop an image file on the canvas or paste an image URL. Images from other websites reach the editor through our [image proxy](https://dynamicdocumentapi.com/bot/), up to 20 MB each. | Setting | Options | | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Fit | Cover (the default), Contain, Stretch, Original size, Smart crop, Focal point (click the spot to keep in view), and zoom | | Filters | Presets (Vintage, Warm, Cool, Mono, Fade, Dramatic, Noir) and sliders for brightness, contrast, saturation, hue, greyscale, sepia, invert, blur, pixelate, noise and duotone | | Mask | Circle, ellipse, rounded rectangle, triangle, diamond, pentagon, hexagon, star, heart, arch, blob, or your own SVG path | | Other | Corner radius, stroke, background colour and alt text | ## Fonts [#fonts] The font picker offers the built-in fonts, Inter and Noto Sans, and a search over about 150 Google Fonts families. The dashboard serves those fonts itself, so editing a template never contacts Google. If a font can't be loaded, the editor and the renderer use Noto Sans instead and report the warning `font_fallback_used`. ## Dynamic properties [#dynamic-properties] Canvas templates have no placeholders in the text. Instead, you make individual properties **dynamic**: select an element, open the **Data** tab and choose **Make … dynamic** for each property that your data should set. A headline's text, a photo's address, a QR code's content or a badge's visibility are typical examples. Each dynamic property has an **API key**, by default `element.property`, such as `headline.text` or `hero.src`. The element's name is part of the key, so renaming an element changes its default keys. Give a property its own **API key** if integrations already use it. Keys are letters, digits, `-` and `_`, in up to four dotted parts. | Element | Properties you can make dynamic | | ---------------------------------------- | ----------------------------------------------------------------------------------- | | Every element | `visible`, `opacity`, `x`, `y`, `width`, `height`, `rotation` | | Text | `text`, `color`, `fontSize`, `fontFamily`, `fontWeight`, `align`, `backgroundColor` | | Rectangle, ellipse, polygon, star, frame | `fill`, `strokeColor`, `strokeWidth`; rectangles and frames also `cornerRadius` | | Line | `strokeColor`, `strokeWidth` | | Image | `src`, `fit`, `focalX`, `focalY`, `alt` | | Icon | `svg`, `src`, `color` | | QR code | `content`, `color`, `backgroundColor`, `logo` | | Barcode | `content`, `color`, `backgroundColor`, `showText` | | Rating | `value`, `count`, `color`, `emptyColor` | | Progress | `value`, `max`, `color`, `trackColor` | | Chart | `data`, `labels`, `values` (the first series), `series`, `title`, `colors` | For each dynamic property you can set a **Default**, a **Field label** and **Description** (shown in Zapier and Make), **Required**, a **Max length** with **Truncate longer values**, a **Minimum** and **Maximum**, and **Allowed values**. There are no conditions or loops in a canvas template. To show or hide an element per render, make its `visible` property dynamic. Lists of values go to charts. **The API accepts** decides what renders may change: **Dynamic properties only** (the default), or **Any property of a named element** for designs that were built before the properties were made dynamic. Dynamic properties take effect when you publish. `GET /v1/templates/{id}/schema` lists the `dynamic_properties` of the live version. ### Sample data [#sample-data] The **Data** tab also holds the template's sample data. **Generate from dynamic properties** fills it with every key and its default; edit the values to see the design with realistic data. The database button in the toolbar, **Show the sample data on the canvas**, puts the sample data on the canvas; switch it off again to edit. **Copy JSON example** copies a `data` object with every key, and **Copy Zapier/Make field list** the input fields for those tools. ## Render with your data [#render-with-your-data] Send the dynamic properties in `data`, with `POST /v1/images/from-template` for an image: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/images/from-template \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", "data": { "headline.text": "Spring sale: 30 % off everything", "hero.src": "https://example.com/spring.jpg", "qr.content": "https://example.com/sale", "badge.visible": false }, "format": "png" }' ``` * **Data shapes.** Keys can be flat, as above, or nested per element: `"headline": { "text": "…" }`. A `null` value counts as not sent, so the property keeps its default. * **Format.** `POST /v1/images/from-template` renders PNG when the request names no `format`, whatever the template's default format is. `POST /v1/renders` without `output.format` uses the template's default. For a PDF, call `POST /v1/pdf/from-template`. * **Values are converted where that is unambiguous.** Booleans accept `true`, `1`, `yes` and `on` (and their opposites), numbers can be strings, colours can be hex, `rgb()` or CSS names, and lists can be JSON arrays or comma-separated text. Image addresses must be `https://`, `http://` or `data:` URLs. * **Problems with single values don't fail the render.** An unknown key, a key that isn't dynamic, or a value that can't be used is reported as the warning `data_coerced`, and the property keeps its designed value. A missing required property, or `data` that isn't an object, fails with `data_invalid`. To reject any mismatch before rendering, turn on **Validate data against the schema** in the template's settings; such requests then fail with `422 data_schema_mismatch`. * **Test keys** render with a `TEST` watermark and cost nothing. Renders use the template's live version. [Signed links](https://dynamicdocumentapi.com/docs/signed-links) render a canvas template from a URL without an API key, for images in web pages and emails. More on image output in [Image options](https://dynamicdocumentapi.com/docs/image-options#image-templates). ## Preview, save and publish [#preview-save-and-publish] The editor saves your draft 1.5 seconds after each change. If someone else saved the template in the meantime, it asks whether to keep your version or take theirs. The **Preview** tab has two previews: * **Instant preview**: drawn in the browser with the active sample data, updated as you edit, at 0.5×, 1× or 2×, with the warnings the render would report. * **Server render** (⌘↵): a render by our servers with the sample data. It's free and carries a `PREVIEW` watermark. **Compare side by side** shows both, with the share of pixels that differ. The toolbar's **Export** downloads PNG, JPEG or WebP straight from the browser, without a watermark. **Download PDF** uses the server preview, watermark included. **Publish** turns the draft into a new, immutable version that renders use from then on. A design with problems, which the bottom bar lists, can't be published. Under **Versions** you can make an older version live again or copy it into the draft; **Revert draft to live version** discards the draft's changes. ## Warnings [#warnings] The inspector shows **Rendering warnings** for the selected element, and the **Layers** panel marks elements that have any. The same warnings come back in a render's `warnings`: | Code | Meaning | | --------------------- | -------------------------------------------------------- | | `text_overflow` | Text was cut off, shortened or overflows its box | | `font_fallback_used` | A font couldn't be loaded, so Noto Sans was used | | `data_coerced` | A data value was converted, shortened or ignored | | `asset_failed` | An image or other file couldn't be loaded and is missing | | `feature_unavailable` | A setting can't take effect and was ignored | See [Errors and warnings](https://dynamicdocumentapi.com/docs/errors) for every code. ## Keyboard shortcuts [#keyboard-shortcuts] **Keyboard shortcuts** in **More actions** lists them in the editor. On Windows and Linux, use Ctrl for ⌘ and Alt for ⌥. | Keys | Action | | ---------------------- | ------------------------------------------------------------------- | | V, H, T, R, O, L, I, F | Select, Hand, Text, Rectangle, Ellipse, Line, Image and Frame tools | | ⌘Z, ⌘⇧Z | Undo, redo | | ⌘G, ⌘⇧G | Group, ungroup | | ⌘⌥G | Put the selection in a frame | | ⇧A | Put the selection in an auto-layout frame | | ⌘D, ⌥ + drag | Duplicate | | Arrow keys | Move by 1 px, with ⇧ by 10 px; with nothing selected, move the view | | ⌘] and ⌘\[ | Bring forward, send backward | | ⌘⇧] and ⌘⇧\[ | Bring to front, send to back | | Space + drag | Move the view | | ⌘0, ⌘1, ⌘2 | Zoom to 100 %, to fit, to the selection | | ⌘+ and ⌘− | Zoom in, zoom out | | ⌘C, ⌘X, ⌘V | Copy, cut, paste, also between templates | | ⌘A | Select all | | Enter, Esc | Enter a group or edit the text; deselect or leave the group | | Tab, ⇧Tab | Select the next or previous layer | | ⌘S, ⌘↵, ⌘J | Save now, server preview, API panel | With the mouse: ⌘-click selects inside groups, ⇧ while dragging keeps to one axis, ⇧ while rotating snaps to 15°, and ⌥ while resizing scales from the centre. ## Limits [#limits] | Limit | Value | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Artboard | 1 to 8000 px per side | | Scale | 1 to 3 | | Output size | PNG and PDF: up to 16,384 px per side and 268 million pixels, else `scene_invalid`. JPEG and WebP: up to 16,384 px per side (WebP 16,383) and 100 million pixels, else `output_too_large` | | Elements | 2,000 per template, nested up to 32 levels | | Template content | 5 MB | | Sample data | 1 MB per dataset | | Image uploads | 10 MB | | Image URLs | 8,192 characters; `data:` URLs up to 10 MB | | Undo | 200 steps | An 8000 px artboard at scale 3 is 24,000 px wide and too large; lower the scale or the artboard. ## Related pages [#related-pages] * [Image options](https://dynamicdocumentapi.com/docs/image-options) for formats, sizes and image renders in general * [Renders](https://dynamicdocumentapi.com/docs/renders) for the render object, versions and delivery * [Signed links](https://dynamicdocumentapi.com/docs/signed-links) to serve canvas images from a URL * [Errors and warnings](https://dynamicdocumentapi.com/docs/errors) for `scene_invalid`, `data_invalid` and the warnings # E-invoicing (/docs/e-invoicing) > Factur-X and ZUGFeRD PDFs with embedded EN 16931 XML, XRechnung export in CII or UBL, the invoice model, computed totals and validation reports. Dynamic Document API turns one invoice model into e-invoices that pass the official validators. Two outputs are available: * **Hybrid PDFs** (Factur-X, ZUGFeRD): your usual invoice PDF, converted to PDF/A-3b, with the invoice XML embedded. * **Standalone XML** (XRechnung, EN 16931): the XML on its own, in CII or UBL syntax. Every file is validated before it is delivered, and the validation report comes back with the render. | Output | Request | Format | | ------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------- | | Factur-X / ZUGFeRD PDF | `output.einvoice` on any PDF render | PDF/A-3b with embedded UN/CEFACT CII XML (Factur-X 1.09, identical to ZUGFeRD 2.x) | | XRechnung PDF | `output.einvoice` with the profile `XRECHNUNG` | PDF/A-3b with an embedded XRechnung 3.0 CII file, `xrechnung.xml` | | XRechnung or EN 16931 XML | `POST /v1/einvoices` | CII (UN/CEFACT D16B) or UBL 2.1 | The XML follows the European semantic model EN 16931-1. It is checked with the KoSIT validator, which applies the XRechnung, EN 16931 and Factur-X rules. PDFs are checked with veraPDF. Neither validator is modified. E-invoicing is included in the Growth, Pro, Scale and Enterprise plans. On Free and Starter, requests with `output.einvoice` and calls to `POST /v1/einvoices` fail with `402 plan_feature_unavailable`, and a template's [default e-invoice](#make-a-template-an-e-invoice-by-default) is skipped with a warning. See [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits). > **What we guarantee** > > Dynamic Document API guarantees technical format conformance, validated against the official schemas and schematron rules. You remain responsible for the content of your invoices, their tax correctness and their transmission, for example through a certified platform (PDP/PA) in France or the e-invoicing portals of German public authorities. ## Profiles [#profiles] A profile decides which information the XML carries and which rules apply. Pick the one your recipient asks for. `EN16931` is the right default for B2B invoices in the EU, and `XRECHNUNG` is the standard for German public-sector buyers. | Profile | Guideline ID (BT-24) | Invoice lines | Embedded file | `AFRelationship` | Syntaxes | | ----------- | ----------------------------------------------------------------------- | --------------- | --------------- | ---------------- | -------- | | `MINIMUM` | `urn:factur-x.eu:1p0:minimum` | not transmitted | `factur-x.xml` | `Data` | CII | | `BASIC_WL` | `urn:factur-x.eu:1p0:basicwl` | not transmitted | `factur-x.xml` | `Data` | CII | | `BASIC` | `urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic` | basic subset | `factur-x.xml` | `Alternative` | CII | | `EN16931` | `urn:cen.eu:en16931:2017` | complete | `factur-x.xml` | `Alternative` | CII, UBL | | `EXTENDED` | `urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended` | complete | `factur-x.xml` | `Alternative` | CII | | `XRECHNUNG` | `urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0` | complete | `xrechnung.xml` | `Alternative` | CII, UBL | * `MINIMUM` and `BASIC_WL` carry header data and totals only. They don't satisfy EN 16931, so German B2B rules don't accept them as e-invoices. Use them only where a booking aid is enough. * The PDF's XMP metadata names the profile in `fx:ConformanceLevel`, as the Factur-X specification requires (for example `EN 16931` or `BASIC WL`). * UBL is available for standalone XML only. Hybrid PDFs always embed CII. ## Create a Factur-X or ZUGFeRD PDF [#create-a-factur-x-or-zugferd-pdf] Add `output.einvoice` to any PDF render: template, HTML, URL or Markdown. The page renders as usual. Then the engine: 1. generates the XML from the invoice model and validates it 2. converts the PDF to PDF/A-3b and embeds the XML as an attachment 3. writes the Factur-X metadata and validates the finished PDF with veraPDF The invoice model can sit in your template data. `invoice_path` points at it, so the template and the XML use the same numbers: cURL: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/renders \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: invoice-R-2026-0042" \ -H "Content-Type: application/json" \ -d '{ "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, "data": { "invoice": { "number": "R-2026-0042", "issue_date": "2026-09-23", "due_date": "2026-10-23", "currency": "EUR", "buyer_reference": "PO-4711", "delivery": { "date": "2026-09-22" }, "seller": { "name": "Example GmbH", "vat_id": "DE123456789", "electronic_address": { "id": "invoices@example.com", "scheme": "EM" }, "address": { "line1": "Beispielstraße 1", "city": "Berlin", "postcode": "10115", "country": "DE" } }, "buyer": { "name": "Customer AG", "vat_id": "DE987654321", "electronic_address": { "id": "ap@customer.example", "scheme": "EM" }, "address": { "line1": "Marktplatz 5", "city": "München", "postcode": "80331", "country": "DE" } }, "payment": { "means_code": "58", "credit_transfers": [{ "account_id": "DE02120300000000202051" }] }, "lines": [ { "quantity": "10", "unit": "HUR", "price": { "net": "100.00" }, "tax": { "category": "S", "rate": "19" }, "item": { "name": "Consulting" } } ] } }, "output": { "format": "pdf", "filename": "invoice-{{ data.invoice.number }}.pdf", "einvoice": { "profile": "EN16931", "invoice_path": "invoice" } } }' ``` Python: ```python import os import requests invoice = { "number": "R-2026-0042", "issue_date": "2026-09-23", "due_date": "2026-10-23", "currency": "EUR", "buyer_reference": "PO-4711", "delivery": {"date": "2026-09-22"}, "seller": { "name": "Example GmbH", "vat_id": "DE123456789", "electronic_address": {"id": "invoices@example.com", "scheme": "EM"}, "address": {"line1": "Beispielstraße 1", "city": "Berlin", "postcode": "10115", "country": "DE"}, }, "buyer": { "name": "Customer AG", "vat_id": "DE987654321", "electronic_address": {"id": "ap@customer.example", "scheme": "EM"}, "address": {"line1": "Marktplatz 5", "city": "München", "postcode": "80331", "country": "DE"}, }, "payment": {"means_code": "58", "credit_transfers": [{"account_id": "DE02120300000000202051"}]}, "lines": [ { "quantity": "10", "unit": "HUR", "price": {"net": "100.00"}, "tax": {"category": "S", "rate": "19"}, "item": {"name": "Consulting"}, } ], } response = requests.post( "https://api-eu.dynamicdocumentapi.com/v1/renders", headers={ "Authorization": f"Bearer {os.environ['DYNAMIC_DOCUMENT_API_KEY']}", "Idempotency-Key": f"invoice-{invoice['number']}", }, json={ "input": {"type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C"}, "data": {"invoice": invoice}, "output": { "format": "pdf", "filename": "invoice-{{ data.invoice.number }}.pdf", "einvoice": {"profile": "EN16931", "invoice_path": "invoice"}, }, }, timeout=130, ) render = response.json() print(render["status"], render.get("result")) ``` The [E-Rechnung template](https://dynamicdocumentapi.com/templates/invoices/invoice-e-rechnung) in the gallery is built for this. It reads the invoice model from `invoice`, prints the computed amounts and works with every profile. | Field | Description | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `output.einvoice.profile` | Required. One of the [profiles](#profiles) | | `output.einvoice.invoice` | The [invoice model](#the-invoice-model) itself | | `output.einvoice.invoice_path` | A dotted path into `data` that holds the invoice model, such as `"invoice"` or `"order.invoice"`. Send exactly one of `invoice` and `invoice_path`. | | `output.einvoice.syntax` | Omit it, or send `"cii"`. Hybrid PDFs always embed CII. | Rules checked before anything renders. Each is a `400 validation_error` with a JSON Pointer to the field: * `output.format` must be `pdf`. An e-invoice PDF is always PDF/A-3b, so `pdf.pdfa` must be omitted or `"3b"`. * `pdf.protect` can't be combined with `output.einvoice`, because PDF/A forbids encryption. * `invoice_path` must resolve to an object in `data`. It can't be combined with `data_url`, because the data isn't available when the request is checked; send `invoice` instead. (A template's [default e-invoice](#make-a-template-an-e-invoice-by-default) works with `data_url`.) * The invoice is validated against the model's JSON Schema, for example `/output/einvoice/invoice/lines/0/tax/category`. * Editor previews reject `output.einvoice`. Test renders accept it; see [test mode](#test-mode). In [batches](https://dynamicdocumentapi.com/docs/batches), a literal `invoice` applies to every item. `invoice_path` is resolved in each item's own data, so every row can carry its own invoice. ### Print the computed amounts [#print-the-computed-amounts] When a render embeds an e-invoice, or its template has a [default e-invoice](#make-a-template-an-e-invoice-by-default), the engine computes the invoice before the template runs and passes the results to the template as `render.invoice`. Print these values instead of calculating totals in the template, and the visible invoice always matches the XML to the cent: ```jinja {% set e = render.invoice %} {% for line in invoice.lines %} {% endfor %}
{{ line.item.name }} {{ line.quantity }} {{ e.lines[loop.index0].net_amount | format_currency(e.currency, "de-DE") }}
{% for vat in e.vat_breakdown %}

VAT {{ vat.rate }} % on {{ vat.basis | format_currency(e.currency, "de-DE") }}: {{ vat.tax | format_currency(e.currency, "de-DE") }}

{% endfor %}

Total due: {{ e.totals.due_payable | format_currency(e.currency, "de-DE") }}

``` | Field | Description | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `number`, `currency` | From the invoice | | `lines[].id`, `lines[].net_amount` | Line ID and line net amount (BT-131), in the order of `invoice.lines` | | `vat_breakdown[]` | One entry per VAT category and rate: `category`, `rate`, `basis`, `tax`, `exemption_reason`, `exemption_reason_code` | | `totals` | `line_total`, `allowance_total`, `charge_total`, `tax_basis_total`, `tax_total`, `grand_total`, `prepaid`, `rounding`, `due_payable` | Amounts are strings with two decimals, such as `"1190.00"`, which `format_currency` accepts. `render.invoice` is there in every render that embeds an e-invoice (`output.einvoice`, or the template's [default](#make-a-template-an-e-invoice-by-default)), and in every render of a template with a default e-invoice that doesn't embed it: the opt-out below, plans without e-invoicing, image output, editor and dashboard previews. The computation is free, so the template needs no fallback and no arithmetic of its own. `render.einvoice` holds the same values plus `profile`, but only when the XML is embedded. Use it to tell the two apart, for example for a note on the page: ```jinja {% if render.einvoice %}

This PDF carries an embedded {{ render.einvoice.profile }} e-invoice.

{% endif %} ``` An invoice that can't be computed fails an e-invoice render with `einvoice_invalid` before the template runs. Where nothing is embedded, it never fails the render: `render.invoice` is left out and the render carries the warning `invoice_totals_unavailable`, whose message names the first problem. ## Make a template an e-invoice by default [#make-a-template-an-e-invoice-by-default] A PDF template (code or Markdown) can store the e-invoice in its default options. Every render of it is then a Factur-X / ZUGFeRD PDF, and callers only send the data: ```json { "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, "data": { "invoice": { "number": "R-2026-0042", "issue_date": "2026-09-23", "currency": "EUR", "…": "…" } } } ``` Turn it on in the dashboard under the template's **Options** → **E-invoice (ZUGFeRD / Factur-X)**: pick the profile (`EN16931` by default) and the data path (`invoice` by default). Through the API, it is `einvoice` in the template's `options`, next to `output_format` and `pdf`: ```json "options": { "output_format": "pdf", "pdf": { "paper": { "size": "A4" } }, "einvoice": { "profile": "EN16931", "invoice_path": "invoice" } } ``` A template default holds a profile and a data path, never an invoice. It is checked when the template is saved: `profile` must be one of the [profiles](#profiles), `invoice_path` a dotted data path, `output_format` must be `pdf`, and `pdf.protect` isn't allowed with it. A default `pdf.pdfa` of `"2b"` is raised to `"3b"` at render time. Canvas templates can't store an e-invoice; send `output.einvoice` with the request instead. Every invoice template in the [gallery](https://dynamicdocumentapi.com/templates) ships with `{"profile": "EN16931", "invoice_path": "invoice"}`, and "Use this template" keeps it. What a request sends decides: | Request | Result | | -------------------------------- | ------------------------------------------------------------------------------------ | | No `output.einvoice` | The template's default applies, with the same checks as `output.einvoice` | | `output.einvoice` with an object | Replaces the default completely, for example another profile or path | | `"output": { "einvoice": null }` | Turns the default off: a plain PDF (`"einvoice": null` in the convenience endpoints) | The same applies to batches (`POST /v1/batches`): the default's `invoice_path` is resolved in each item's data, an item without an invoice object there rejects the batch with `400 validation_error` at `/items//data/` (`/csv/row//data/` for CSV rows, whose text cells can't hold the invoice's `lines`, so send JSON `items`), and `"output": {"einvoice": null}` turns the default off for every item. In the dashboard's batch wizard, that is the **Embed the e-invoice** checkbox on the review step. Other differences from a request-level `output.einvoice`: * **Missing invoice.** If the path doesn't lead to an object in the request's `data`, the request fails with `400 validation_error` at `/data`, and the message names the opt-out. Model errors point into `data`, for example `/data/invoice/seller/name`. * **`data_url` works.** The data isn't available when the request is checked, so the renderer resolves the path. If it finds no invoice there, the render fails with `einvoice_invalid`. * **Plans without e-invoicing.** On Free and Starter the default is skipped: the render is a normal PDF and carries the warning `einvoice_skipped_plan`. A request that sends `output.einvoice` itself still fails with `402 plan_feature_unavailable`. * **Other formats.** The default only applies to PDF output: rendering such a template as PNG, JPG or WebP gives a plain image without an e-invoice. * **Previews.** Editor previews and the publish test render ignore the default and stay plain PDFs. In all of these cases without an embedded e-invoice, the template still gets the [computed amounts](#print-the-computed-amounts) as `render.invoice`, at no cost. ## Export XRechnung or EN 16931 XML [#export-xrechnung-or-en-16931-xml] `POST /v1/einvoices` returns the XML on its own, without a PDF. It creates a regular render with one `application/xml` file, and accepts the usual `mode`, `delivery`, `webhook`, `reference`, `metadata` and `test` fields and the `Idempotency-Key` header: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/einvoices \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: xrechnung-XR-2026-0007" \ -H "Content-Type: application/json" \ -d @xrechnung-request.json \ -o XR-2026-0007.xml ``` `xrechnung-request.json` holds the profile, the syntax and the invoice, for example the one in [The invoice model](#the-invoice-model) (shortened here): ```json { "profile": "XRECHNUNG", "syntax": "ubl", "filename": "XR-2026-0007.xml", "invoice": { "number": "XR-2026-0007", "issue_date": "2026-09-23", "currency": "EUR", "seller": {}, "buyer": {}, "lines": [] }, "delivery": { "type": "binary" } } ``` | Field | Description | | ---------- | ------------------------------------------------------------------------------ | | `profile` | Required. One of the [profiles](#profiles) | | `syntax` | `cii` (default) or `ubl`. UBL is available for `EN16931` and `XRECHNUNG` only. | | `invoice` | Required. The [invoice model](#the-invoice-model) | | `filename` | Defaults to the invoice number, cleaned up for use as a file name, plus `.xml` | With `delivery.type: "binary"` the response body is the XML file itself. Otherwise you get the render object: its `input_type` is `einvoice`, its `output_format` is `xml`, and it carries the validation report in `result`. In UBL, credit notes (type code `381`) use the `CreditNote` root element and everything else uses `Invoice`. ## The invoice model [#the-invoice-model] The invoice model is JSON for the EN 16931 semantic model, with snake\_case names. Every field corresponds to one business term (BT) or business group (BG) of the standard, and the [API reference](https://dynamicdocumentapi.com/docs/api-reference) lists all of them. This complete example is a valid XRechnung for a German federal authority: ```json { "number": "XR-2026-0007", "issue_date": "2026-09-23", "due_date": "2026-10-23", "currency": "EUR", "buyer_reference": "04011000-12345-34", "payment_terms": "Zahlbar innerhalb von 30 Tagen ohne Abzug.", "invoice_period": { "start": "2026-09-01", "end": "2026-09-30" }, "seller": { "name": "Größenwahn GmbH", "vat_id": "DE123456789", "tax_number": "30/123/45678", "legal_registration": { "id": "HRB 12345" }, "electronic_address": { "id": "rechnung@groessenwahn.example", "scheme": "EM" }, "address": { "line1": "Beispielstraße 1", "city": "Berlin", "postcode": "10115", "country": "DE" }, "contact": { "name": "Erika Muster", "phone": "+49 30 1234567", "email": "erika.muster@groessenwahn.example" } }, "buyer": { "name": "Bundesamt für Beispiele", "electronic_address": { "id": "04011000-12345-34", "scheme": "0204" }, "address": { "line1": "Amtsweg 2", "city": "Bonn", "postcode": "53113", "country": "DE" } }, "payment": { "means_code": "58", "remittance_information": "XR-2026-0007", "credit_transfers": [{ "account_id": "DE02120300000000202051", "account_name": "Größenwahn GmbH" }] }, "lines": [ { "quantity": "8", "unit": "HUR", "price": { "net": "95.00" }, "tax": { "category": "S", "rate": "19" }, "item": { "name": "Softwarewartung", "description": "Wartung Fachverfahren, September 2026" } }, { "quantity": "1", "unit": "C62", "price": { "net": "250.00" }, "tax": { "category": "S", "rate": "19" }, "item": { "name": "Lizenz Modul Statistik" }, "allowances": [{ "amount": "25.00", "reason": "Rabatt" }] } ] } ``` ### Conventions [#conventions] | Topic | Convention | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Amounts | Decimal strings such as `"95.00"`, with at most two decimals. JSON numbers are accepted, but strings avoid floating-point surprises. | | Quantities and prices | Up to six decimals, for example `"0.125"` | | Dates | `YYYY-MM-DD` | | Codes | Currencies in ISO 4217 (`EUR`), countries in ISO 3166-1 alpha-2 (`DE`), units from UN/ECE Recommendation 20 (`C62` one, `H87` piece, `HUR` hour, `DAY` day, `KGM` kilogram). `unit` defaults to `C62`. | | Electronic addresses | `id` plus an EAS `scheme`, such as `EM` for an e-mail address, `0204` for a German Leitweg-ID or `9930` for a German VAT ID | | Document type | `type_code` (BT-3) defaults to `380`, a commercial invoice. Others include `381` credit note, `384` corrected invoice, `389` self-billed invoice, `326` partial invoice and `386` prepayment invoice. | ### Required fields [#required-fields] Every invoice needs `number`, `issue_date`, `currency`, `seller` (with `name` and `address`), `buyer` (with `name` and `address`) and at least one line. Each line needs `quantity`, `price.net`, `tax.category` and `item.name`, and a `tax.rate` unless its category is `O`. `MINIMUM` and `BASIC_WL` don't transmit lines, but they still need them to compute the totals. The profiles add their own rules. For `XRECHNUNG`: * `buyer_reference` (BT-10), the Leitweg-ID for German public buyers * `seller.contact` with `name`, `phone` and `email` * `electronic_address` for both the seller and the buyer * `payment` with a payment means code, for example `58` for a SEPA credit transfer, and the account `process_id` (BT-23) defaults to `urn:fdc:peppol.eu:2017:poacc:billing:01:1.0` for `XRECHNUNG`. ### VAT categories [#vat-categories] | Category | Meaning | Rate | Also required | | -------- | ---------------------- | ---------------------- | ------------------------------------------------------------------ | | `S` | Standard rate | the rate, such as `19` | — | | `Z` | Zero rated goods | `0` | — | | `E` | Exempt from VAT | `0` | `vat_exemptions.E` | | `AE` | Reverse charge | `0` | `vat_exemptions.AE`, seller and buyer VAT IDs | | `K` | Intra-community supply | `0` | `vat_exemptions.K`, seller and buyer VAT IDs, delivery information | | `G` | Export outside the EU | `0` | `vat_exemptions.G` | | `O` | Not subject to VAT | none | `vat_exemptions.O` | | `L` | Canary Islands IGIC | the IGIC rate | — | | `M` | Ceuta and Melilla IPSI | the IPSI rate | — | `vat_exemptions` gives the exemption reason (BT-120) and code (BT-121) for each category. This is a reverse-charge supply to a customer in another EU country: ```json { "vat_exemptions": { "AE": { "reason": "Reverse charge", "reason_code": "vatex-eu-ae" } }, "charges": [{ "amount": "15.00", "tax_category": "AE", "tax_rate": "0", "reason": "Versand", "reason_code": "FC" }], "lines": [ { "quantity": "12", "unit": "H87", "price": { "net": "49.50" }, "tax": { "category": "AE", "rate": "0" }, "item": { "name": "Druckkopf X2" } } ] } ``` Document-level `allowances` and `charges` need their own `tax_category` and `tax_rate`, because they change the VAT basis of that category. ### Credit notes [#credit-notes] Set `type_code` to `"381"` and reference the original invoice in `preceding_invoices`. Quantities and prices stay positive, because the type code is what makes the document a credit: ```json { "number": "GS-2026-0003", "type_code": "381", "issue_date": "2026-09-23", "preceding_invoices": [{ "number": "R-2026-0042", "issue_date": "2026-09-23" }] } ``` ### Computed fields [#computed-fields] Don't send line totals, the VAT breakdown or document totals. The engine computes them from the lines, allowances and charges, using the EN 16931 rules: | Value | Business terms | Rule | | ------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------- | | Line net amount | BT-131 | quantity × net price ÷ base quantity + line charges − line allowances | | VAT per category and rate | BT-116, BT-117 | basis = the lines, allowances and charges of that category and rate; tax = basis × rate ÷ 100, rounded to two decimals | | Sum of line net amounts | BT-106 | sum of the line net amounts | | Total without VAT | BT-109 | lines − document allowances + document charges | | Total VAT | BT-110 | sum of the VAT amounts per category | | Total with VAT | BT-112 | total without VAT + total VAT | | Amount due | BT-115 | total with VAT − `prepaid_amount` + `rounding_amount` | For the example above: 8 × 95.00 = 760.00 and 250.00 − 25.00 = 225.00, so the lines total 985.00. VAT at 19 % on 985.00 is 187.15, and 1,172.15 is due. A line may carry its own `net_amount`. It isn't needed, but when present the engine checks it against the computed value and rejects the invoice if they differ. ## Validation report [#validation-report] PDF/A and e-invoice renders carry a conformance report in `result.conformance`. Succeeded renders have one, and so do renders that failed validation: ```json { "result": { "conformance": { "pdfa": { "level": "3b", "status": "passed", "validator": "veraPDF 1.30.2", "profile": "PDF/A-3B validation profile", "errors": [], "warnings": [] }, "einvoice": { "profile": "XRECHNUNG", "syntax": "cii", "status": "passed", "validator": "KoSIT Validator 1.6.3", "scenario": "EN16931 XRechnung (CII)", "errors": [], "warnings": [] } } } } ``` A render that failed because the invoice lacks a field the profile requires carries the same object with `status: "failed"`, and its findings point at the invoice: ```json { "rule": "BR-DE-2", "message": "The seller contact (BG-6) is required for XRECHNUNG.", "location": "invoice.seller.contact", "count": 1 } ``` | Field | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `status` | `passed`, `failed` or `unavailable`. `unavailable` means the validator couldn't be reached in time and the file was delivered unvalidated. | | `level` | PDF/A level that was checked, `2b` or `3b` | | `profile`, `syntax` | The e-invoice profile and syntax that were generated | | `validator`, `scenario` | The validator, its version and the rule set it applied | | `errors`, `warnings` | Findings, each with `rule` (such as `BR-DE-15` or a PDF/A clause like `6.2.4.3-2`), `message`, `location` and `count` | Each list holds at most 100 findings. Findings with the same rule and location are merged, and `count` says how often they occurred. For problems found in the invoice model, `location` is a JSON path such as `invoice.seller.contact.email`. For problems in the generated file, it is the validator's XPath or veraPDF's object path. The dashboard shows the same report on the render's page. ## Errors and warnings [#errors-and-warnings] A failed e-invoice render isn't billed. Synchronous requests answer `422` with the error, and the report is in the render's `result`: | Code | When | Fix | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `einvoice_invalid` | The invoice breaks a rule of the profile before any XML is written, for example a missing BT the profile requires, a VAT category without a rate or a `net_amount` that doesn't add up | `result.conformance.einvoice.errors` lists every problem with a JSON path into the invoice | | `einvoice_validation_failed` | The validator rejected the generated XML | Read the rules in `result.conformance.einvoice.errors`; they are usually about content, such as a missing Leitweg-ID or an invalid code | | `pdfa_conversion_failed` | The PDF couldn't be made PDF/A conformant, or veraPDF found it non-compliant | See [PDF/A](https://dynamicdocumentapi.com/docs/pdf-options#pdfa); fonts that aren't embedded are the usual cause | Warnings don't fail the render: | Code | Meaning | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `einvoice_validation_warning` | The validator accepted the XML with warnings; the message names the rules | | `einvoice_validation_unavailable` | The e-invoice validator didn't answer in time, so the XML is unvalidated | | `pdfa_validation_unavailable` | veraPDF didn't answer in time, so the PDF/A file is unvalidated | | `einvoice_skipped_plan` | The template embeds an e-invoice [by default](#make-a-template-an-e-invoice-by-default), but the plan doesn't include e-invoicing, so the PDF has none | | `invoice_totals_unavailable` | The render embeds no e-invoice and its invoice couldn't be computed, so `render.invoice` is missing; the message names the first problem, such as a missing `invoice` in `data` | See [Errors](https://dynamicdocumentapi.com/docs/errors) for every other code. ## Test mode [#test-mode] Test renders are free and validated like live renders, so you can build and check your integration with a test key. Test PDFs carry the TEST watermark. It is drawn as vector outlines and stamped before the PDF/A conversion, so a test PDF is conformant too and the report describes the file you receive. An XML file can't carry a watermark, so test XML carries a note instead. This applies to `POST /v1/einvoices` and to the XML embedded in a test PDF, the part that systems receiving the invoice read. The note is the first invoice note (BT-22): ```text TESTRECHNUNG – keine gültige Rechnung. TEST INVOICE – not a valid invoice (created with a test API key). ``` * Every profile gets the note except `MINIMUM`, which has no field for it. A `MINIMUM` XML isn't an e-invoice on its own. * `EXTENDED` also sets `TestIndicator` to `true`, the test flag that only its schema defines. * Apart from the marker, the XML is the one a live key produces, and the validation report is the same. Use a live key for invoices you send. ## Billed renders [#billed-renders] | Operation | Billed renders | | ------------------------------------ | -------------------------------------------- | | E-invoice layer on a PDF render | included (+0), only the PDF itself is billed | | XML export with `POST /v1/einvoices` | 1 | | Test renders and failed renders | 0 | A two-page Factur-X invoice is 1 render. E-invoicing needs the Growth plan or higher. See [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits#what-counts-as-a-render). ## Related pages [#related-pages] * [PDF options](https://dynamicdocumentapi.com/docs/pdf-options#pdfa) for PDF/A without an e-invoice, and for converting existing PDFs * [Renders](https://dynamicdocumentapi.com/docs/renders#the-render-object) for the render object and its `result` * [Template language](https://dynamicdocumentapi.com/docs/template-language) for `format_currency` and the other filters # Email delivery (/docs/email-delivery) > Email rendered files through your own Brevo, SMTP, Postmark, Resend, Amazon SES or Microsoft 365 account, with connections, sender checks, email rules, test renders, send statuses, events, limits, errors and billing. Email delivery sends a render's files to the people named in its data. You connect your own email provider account, add email rules to a template, and every succeeded render of that template sends one email per rule. The mail goes out through your account and from your domain; nothing is sent from ours. Emails aren't billed: your provider charges for them, and the render is billed as usual. Email delivery is included in the Growth, Pro, Scale and Enterprise plans. On Free and Starter, a template's rules don't send, and creating or changing connections and rules fails with `402 plan_feature_unavailable`. See [Plans and billing](#plans-and-billing). If email delivery isn't enabled for your workspace, the dashboard doesn't show it, and creating, changing or testing connections and creating or changing rules fail with `403 feature_not_enabled`; reading, deleting and switching rules off still work. To have it enabled, write to [support@dynamicdocumentapi.com](mailto:support@dynamicdocumentapi.com). ## How it works [#how-it-works] 1. **Connect your provider.** An email connection (`emc_…`) holds the credentials of your provider account, the default sender and what test renders do. 2. **Add email rules to a template.** An email rule (`emr_…`) says which connection sends, from which sender, to whom, with which subject, body and files, and under which condition. Recipients, subject and body use the [template language](https://dynamicdocumentapi.com/docs/template-language) with the render's data. 3. **Render the template.** When the render succeeds, each enabled rule becomes one email send (`ems_…`) that goes out through the rule's connection. The render lists its emails in `email`, and webhooks report how each one ended. Rules apply to renders of the template from the API, including the convenience endpoints and batches, from the dashboard and from integrations. No email is sent for: * previews, including the editor's * signed-link renders, because anyone who has the URL could otherwise choose the recipients * renders without a template: HTML, URL and Markdown input, PDF tools and `POST /v1/einvoices` * zero-retention renders, which keep no file to send (see [Zero retention and hosted files](#zero-retention-and-hosted-files)) * renders that fail or are canceled A rule that can't be rendered fails only its own email. The render, its files and its other emails are unaffected. In the [dashboard](https://app.dynamicdocumentapi.com), connections are under **Email**, and a template's rules on its **Email** tab. API keys need these scopes: | Scope | Allows | | -------------- | ------------------------------------------------------------------------------ | | `email:read` | Reading connections and rules, and rule previews | | `email:write` | Creating, changing, testing and deleting connections and rules, and rule tests | | `renders:read` | Reading emails, their delivery events and the statistics | | `render:write` | Resending emails | Rendering a template that has rules needs no extra scope. ## Connect your provider [#connect-your-provider] Create a connection in the dashboard under **Email** → **Add connection**, or through the API with a key that has the `email:write` scope: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/email-connections \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Brevo", "provider": "brevo", "credentials": { "api_key": "xkeysib-…" }, "default_from": { "email": "billing@example.com", "name": "Example Billing" }, "reply_to": "accounts@example.com", "test_mode": "sandbox" }' ``` The response is the connection, without the credentials: ```json { "id": "emc_01J9ZK3M7Q8V5W2X4Y6Z8A0B1F", "object": "email_connection", "name": "Brevo", "provider": "brevo", "config": { "attachment_budget_bytes": 10485760, "max_per_second": 10 }, "credentials": { "configured": true }, "default_from": { "email": "billing@example.com", "name": "Example Billing" }, "reply_to": "accounts@example.com", "test_mode": "sandbox", "test_recipient": null, "senders": [], "status": "active", "last_test_at": null, "last_test_status": null, "last_error": null, "credentials_delete_at": null, "egress_ips": ["203.0.113.10"], "events": {}, "created_at": "2026-10-01T09:10:00Z", "updated_at": "2026-10-01T09:10:00Z" } ``` | Field | Description | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | A label for the dashboard, up to 100 characters | | `provider` | `brevo`, `smtp`, `postmark`, `resend`, `ses` (Amazon SES) or `graph` (Microsoft 365). It can't change after the connection is created. | | `config` | The provider's settings, see [Providers](#providers). `attachment_budget_bytes` and `max_per_second` apply to every provider; reads always show them, filled in with the provider's defaults. | | `credentials` | The provider's secrets. Write-only: reads show `{"configured": true}`. | | `default_from` | `email` (required) and `name`: the sender of rules that don't set their own. `email` is one address, without a display name. | | `reply_to` | Optional default reply-to address | | `test_mode` | What test renders do: `sandbox` (Brevo only, and its default), `redirect` or `skip` (the default for every other provider). See [Live and test renders](#live-and-test-renders). | | `test_recipient` | Required with `redirect`: the verified email address of a member of your workspace | Reads also return: | Field | Description | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | The connection ID (`emc_…`) | | `senders` | The verified senders and domains that the last test found, see [Senders and verification](#senders-and-verification) | | `status` | `active`, or `failing` after the provider refused the credentials or our IP addresses | | `last_test_at`, `last_test_status` | When the last test ran, and whether it `succeeded` or `failed` | | `last_error` | Why the connection is failing: `code` (`email_provider_auth_failed`, `email_ip_not_authorized` or `email_credentials_removed`), `message` and `at` | | `credentials_delete_at` | Set after a downgrade: when the credentials will be deleted | | `egress_ips` | The IP addresses our email traffic leaves from, see [Authorise our IP addresses](#authorise-our-ip-addresses) | | `events` | Brevo only: the delivery events webhook per region, `registered` or `failed` | | Endpoint | Description | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `GET /v1/email-connections` | List connections. The list also carries `egress_ips`. | | `POST /v1/email-connections` | Create a connection | | `GET /v1/email-connections/{id}` | Retrieve a connection | | `PATCH /v1/email-connections/{id}` | Change a connection. A body without `credentials` keeps the stored ones. | | `DELETE /v1/email-connections/{id}` | Delete a connection. While rules use it, the answer is `409 email_connection_in_use`, with the rules in `rules`. | | `POST /v1/email-connections/{id}/test` | [Test the connection](#test-a-connection) | A workspace can have up to 10 connections. Deleting a connection cancels its emails that haven't been sent yet; the history of sent emails stays. ### How credentials are stored [#how-credentials-are-stored] Credentials are encrypted before they are stored, and the API never returns them. They are decrypted only to call your provider for you. To replace them, send new `credentials` with `PATCH`. Deleting a connection deletes its credentials, and when your plan stops including email delivery they are deleted after 30 days (see [Plans and billing](#plans-and-billing)). > **Use a separate Brevo key** > > A Brevo API key gives access to your whole Brevo account. Create a key only for this connection, so you can revoke it without affecting anything else. ### Providers [#providers] | `provider` | `config` | `credentials` | | ---------- | -------------------------------------------------------------------- | ------------------------------------ | | `brevo` | — | `api_key` | | `smtp` | `host`, `port` (587 or 2525, default 587), `username` | `password` | | `postmark` | `message_stream` (default `outbound`) | `server_token` | | `resend` | — | `api_key` | | `ses` | `region`, such as `eu-central-1`, and optionally `configuration_set` | `access_key_id`, `secret_access_key` | | `graph` | `tenant_id`, `client_id`, `sender` | `client_secret` | * **Brevo**: an API key (`xkeysib-…`) from **SMTP & API** → **API keys**. Brevo's SMTP keys (`xsmtpsib-…`) aren't accepted, and an SMTP connection to Brevo's relay is refused: use the `brevo` provider. * **SMTP**: any server that offers STARTTLS and authentication on port 587 or 2525. TLS 1.2 or later is required, and a server that offers authentication without STARTTLS is refused, so your password never travels unencrypted. `host` is the server's public host name, without a scheme or port. * **Postmark**: the server's API token, and the message stream to send through. * **Resend**: an API key with sending access. Every attempt of an email carries the same idempotency key, so an interrupted call is retried safely instead of ending as `unknown`. * **Amazon SES**: the access key of an IAM user that may call `ses:SendRawEmail` and, for the connection test, read the account and its identities. Temporary credentials aren't supported. * **Microsoft 365**: register an app in Microsoft Entra ID with the `Mail.Send` application permission. `tenant_id` is the directory (tenant) ID or one of your domains, `client_id` the application (client) ID, and `sender` the address of the mailbox that sends. Emails are saved in that mailbox's Sent Items. Every provider also takes these `config` fields: | Field | Description | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `attachment_budget_bytes` | Bytes of files attached to one email. Files beyond it are linked or fail the email, depending on the rule's [attachments](#attachments-and-download-links). | | `max_per_second` | Emails per second through this connection, 1 to 100 (default 10). Further emails wait their turn, so a large batch stays within your provider's rate limits. | | Provider | Attachment budget, default | Maximum | | ------------- | -------------------------- | ------- | | Brevo | 10 MiB | 13 MiB | | SMTP | 6 MiB | 25 MiB | | Postmark | 6 MiB | 6 MiB | | Resend | 10 MiB | 25 MiB | | Amazon SES | 10 MiB | 25 MiB | | Microsoft 365 | 2 MiB | 2 MiB | The maximums leave room for encoding and the body within each provider's message size limit. ### Test a connection [#test-a-connection] ```bash curl https://api-eu.dynamicdocumentapi.com/v1/email-connections/emc_01J9ZK3M7Q8V5W2X4Y6Z8A0B1F/test \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"send_to": "alex@example.com"}' ``` The test checks the saved settings one step at a time, through the same IP addresses as your emails: | Provider | Steps | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Brevo | `credentials` (the API key works), `senders` (lists your verified senders and domains), `sandbox_send` (Brevo checks a message from the default sender without delivering it) | | SMTP | `connect`, `starttls`, `smtp_auth` | | Postmark | `credentials` | | Resend | `credentials`, which also lists your verified domains | | Amazon SES | `credentials`, `senders` | | Microsoft 365 | `credentials` (Microsoft Entra ID issues a token) | With `send_to`, a final `send` step sends a real test email to that address, which must be the verified email address of a workspace member. In the dashboard, **Send a test email to me** uses your own address. ```json { "object": "email_connection_test", "ok": true, "steps": [ { "name": "credentials", "ok": true, "detail": "Brevo accepted the API key (account: Example GmbH)." }, { "name": "senders", "ok": true, "detail": "Verified: 1 sender and 1 domain." }, { "name": "sandbox_send", "ok": true, "detail": "Brevo accepted a sandboxed email from billing@example.com; nothing was delivered." }, { "name": "send", "ok": true, "detail": "The test email was accepted (message id <…@smtp-relay.mailin.fr>)." } ], "senders": [ { "email": "billing@example.com", "name": "Example Billing", "kind": "sender", "verified": true }, { "email": "example.com", "name": null, "kind": "domain", "verified": true } ], "connection": { "id": "emc_01J9ZK3M7Q8V5W2X4Y6Z8A0B1F", "object": "email_connection", "status": "active", "last_test_status": "succeeded" } } ``` The `connection` above is shortened; the response carries the whole connection. A failed step answers `200` with `ok: false`. The test stops at that step, and its `detail` says what went wrong. Every test updates `last_test_at` and `last_test_status`, and `senders` when the provider listed them. A passing test sets `status` to `active`. A failed `credentials` or `smtp_auth` step sets it to `failing`, with `last_error`. Emails update the status too. When they fail because the provider refuses the credentials or our IP addresses, the connection turns `failing` within a few minutes, and the workspace's owners and admins get an email, at most once a day per connection. The next accepted email or passing test sets the connection back to `active`. ### Authorise our IP addresses [#authorise-our-ip-addresses] Your emails leave from a fixed set of IP addresses, listed in `egress_ips` and on the dashboard's **Email** page. If your provider only accepts calls from known IP addresses, authorise them there. Brevo blocks unknown addresses once its IP security has learned your usual ones: add ours under **Security** → **Authorized IPs**. Changes to the addresses are announced 30 days ahead. An email that the provider refuses because of our IP address is retried (`email_ip_not_authorized`), so it still goes out if you authorise the addresses while its retries last. ## Senders and verification [#senders-and-verification] Each email is sent from the rule's `from_email`, or else from the connection's `default_from`. The sender name and reply-to address come from the rule when they render to something, and from the connection otherwise. Your provider decides which senders it accepts, so send from an address or domain that you verified there. The connection test lists what your provider has verified, and stores it on the connection as `senders`: | Provider | `senders` | | ----------------------------- | ------------------------------------------------------------------------------------------------ | | Brevo | Active senders, and authenticated or verified domains | | Resend | Verified domains. A key that can only send can't list them, and the test passes without senders. | | Amazon SES | Verified identities, both domains and addresses. The `senders` step fails when there are none. | | SMTP, Postmark, Microsoft 365 | None: these providers don't list senders to us | Each entry has `email` (an address, or the domain for `kind: "domain"`), `name`, `kind` (`sender` or `domain`) and `verified`. A rule whose `from_email` matches no entry, neither the address itself nor its domain, is saved with a warning: ```json "warnings": [ { "code": "email_sender_unverified", "message": "invoices@example.com is not among the verified senders of Brevo; the provider may refuse it.", "path": "/from_email" } ] ``` The check runs only once a test has listed senders, and it never blocks anything: your provider decides when the email goes out, and a sender it refuses fails the email with `email_provider_rejected`. After you verify a new sender at your provider, test the connection again to refresh `senders`. ## Email rules [#email-rules] A template can have up to five rules. Every enabled rule sends its own email, in `position` order, for example the invoice to the customer and a copy to accounting. Add rules on the template's **Email** tab, or with a key that has the `email:write` scope: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/templates/tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C/email-rules \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Invoice to customer", "connection_id": "emc_01J9ZK3M7Q8V5W2X4Y6Z8A0B1F", "from_email": "billing@example.com", "from_name": "Example Billing", "to": "{{ customer.name }} <{{ customer.email }}>", "cc": "{{ customer.accounting_email }}", "subject": "Invoice {{ number }}", "html": "

Dear {{ customer.name }},

please find invoice {{ number }} attached.

", "attachments": "attach_or_link", "attach_formats": ["pdf", "xml"], "condition": "send_invoice" }' ``` The response is the rule with its ID (`emr_…`), its fields as saved and `warnings`. | Field | Template language | Description | | ------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `name` | no | A label, up to 100 characters | | `connection_id` | no | The connection that sends (`emc_…`) | | `enabled` | no | `false` switches the rule off. Default `true`. | | `position` | no | 0 to 4; the other rules move. Defaults to the end. | | `from_email` | no | The sender: one address, without a display name. Empty uses the connection's `default_from`. | | `from_name` | yes | The sender name. Empty uses the connection's. | | `reply_to` | yes | The reply-to address. Empty uses the connection's. | | `to`, `cc`, `bcc` | yes | The recipients, see [Recipients](#recipients) | | `subject` | yes | Required, unless a [Brevo template](#brevo-templates) provides it | | `html` | yes, values escaped | The body. `html` or `text` is required. | | `text` | yes | The plain-text body. Empty derives it from `html`. | | `attachments` | no | `attach_or_link` (default), `attach`, `link` or `none`, see [Attachments and download links](#attachments-and-download-links) | | `attach_formats` | no | Only files of these formats: `pdf`, `png`, `jpeg`, `webp`, `html`, `xml`, `zip`. Empty sends every file. | | `condition` | expression | The rule sends only when the expression is true. Empty always sends. | | `provider_template` | — | Brevo only: send one of your Brevo templates instead of `html` and `text` | Saving compiles every field. A field that doesn't compile fails with `422 email_template_error`, and each entry of `errors[]` gives the field as `path` (such as `/subject`) with `line` and `column`. Each field's source can be up to 16 KiB, and `html` up to 256 KiB. Rules are live settings, not part of template versions: * A change applies to renders submitted after you save, without publishing. A render keeps the rules as they were when it was submitted. * Switching a rule off or deleting it cancels its emails that haven't been sent yet. * Duplicating a template copies its rules switched off, so the copy doesn't send before someone has checked them. * Transferring a template to another workspace deletes its rules, because connections belong to the workspace. | Endpoint | Description | | ---------------------------------------------- | --------------------------------------------------------------------- | | `GET /v1/templates/{template_id}/email-rules` | List a template's rules in `position` order | | `POST /v1/templates/{template_id}/email-rules` | Create a rule | | `GET /v1/email-rules/{id}` | Retrieve a rule | | `PATCH /v1/email-rules/{id}` | Change a rule. `{"enabled": false}` switches it off. | | `DELETE /v1/email-rules/{id}` | Delete a rule | | `POST /v1/email-rules/{id}/preview` | [Preview](#preview-and-test-a-rule) the email; nothing is sent | | `POST /v1/email-rules/preview` | Preview a rule that isn't saved yet | | `POST /v1/email-rules/{id}/test` | Send one [test email](#preview-and-test-a-rule) to a workspace member | ### Recipients [#recipients] `to`, `cc` and `bcc` render to a list of addresses: * Separate the entries with `,`, `;` or line breaks. * Each entry is an address or `Name
`. Quote a name that may contain a comma: `"{{ customer.name }}" <{{ customer.email }}>`. * Empty entries are ignored, and an address that appears more than once is kept once: the first occurrence across `to`, `cc` and `bcc` wins. * One email takes at most 50 recipients, `to`, `cc` and `bcc` together. A loop can build the list from your data; the empty entry after the last comma is ignored: ```jinja {% for contact in customer.contacts %}{{ contact.email }}, {% endfor %} ``` | Problem | The email fails with | | ------------------------- | ---------------------------------------------------- | | An entry isn't an address | `email_address_invalid`; the message names the entry | | `to` has no address | `email_recipient_missing` | | More than 50 recipients | `email_too_many_recipients` | The rendered `reply_to` and the sender are checked the same way. ### Subject and body [#subject-and-body] Rule fields render with the [template language](https://dynamicdocumentapi.com/docs/template-language), in the context of the document they send: | Variable | Description | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Data keys and `data` | The render's data, as in the template | | `render` | `id`, `created_at`, `template_id` and `test`, plus `invoice` and `einvoice` when the render computed them (see [E-invoicing](https://dynamicdocumentapi.com/docs/e-invoicing)) | | `files` | The render's files, in output order: `filename`, `format`, `bytes` and `pages` (0 for images) | | `download_links` | Where the links to linked files go, see [Attachments and download links](#attachments-and-download-links) | `files` and `download_links` take precedence over data keys with the same names; use `data.files` to reach such a key. ```jinja

Dear {{ customer.name }},

Please find invoice {{ number }} attached.

{{ download_links }}

Kind regards
Example GmbH

``` * `html` escapes values like an HTML template. Mark HTML you trust with `safe`. The other fields aren't escaped. * `subject`, `from_name` and `reply_to` become one line: line breaks and control characters turn into spaces, and they are cut to 255 characters. Data can't add email headers. * `strict_undefined: true` in the render request applies to the rules too. * After rendering, `html` can be up to 512 KiB, `text` up to 256 KiB, and `to`, `cc` and `bcc` up to 16 KiB each. A larger field fails the email with `email_too_large`. * A template error fails the email with `email_template_error`; its `error` gives the `field`, `line` and `column`. ### Conditions [#conditions] `condition` is an expression in the same context, written without `{{ }}`: ```jinja send_invoice and customer.email ``` When it is false, the email is recorded as `skipped` with `skip_reason: "condition_false"`, and no event is sent. An empty condition always sends. ### Attachments and download links [#attachments-and-download-links] | `attachments` | What the email carries | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `attach_or_link` | The default. Each file is attached while the attached files fit the connection's attachment budget and the provider accepts the file type; any other file is linked. | | `attach` | Every file is attached. A file over the budget fails the email with `email_attachment_too_large`, a type the provider refuses with `email_attachment_type_not_allowed`. | | `link` | A download link for every file | | `none` | No files | * Files are taken in the render's output order, after `attach_formats` has filtered them. * Brevo doesn't accept WebP attachments, so `attach_or_link` links WebP files. * A link is a signed URL that downloads the file. It is valid until the file's retention ends, and at most 7 days after the email was created. * The links appear where `html` or `text` uses `{{ download_links }}`: a list of links named by file name in `html`, and one `filename: URL` line per file in `text`. Without `{{ download_links }}`, the list is added at the end of the body. When nothing is linked, the placeholder is removed. Each email lists its files in `attachments`, with `mode` set to `attached` or `linked`. Attaching and linking need the render's hosted file, see [Zero retention and hosted files](#zero-retention-and-hosted-files). ### Brevo templates [#brevo-templates] With a Brevo connection, a rule can send one of your Brevo account's templates instead of its own body: ```json "provider_template": { "id": 12, "params": "{\"number\": {{ number | tojson }}, \"name\": {{ customer.name | tojson }}}" } ``` * `id` is the template's ID in Brevo, a whole number from 1. * `params` is template source that must render to a JSON object, up to 64 KiB of source. Brevo receives it as the template's params. Anything else fails the email with `email_template_error`. * `html` and `text` can be empty. `subject` is optional; an empty one uses the Brevo template's subject. * Attachments work as usual. Other providers can't send provider templates. ## Choose rules per render [#choose-rules-per-render] `delivery.email` on `POST /v1/renders`, the convenience endpoints and `POST /v1/batches` decides which rules a render uses: | `delivery.email` | Rules | | ------------------------ | ----------------------------------------------------------------------------------------------- | | absent or `null` | Every enabled rule of the template | | `false` | None | | a list of 1 to 5 entries | Only these rules. Each entry names an enabled rule of the render's template in `rule_id`, once. | An entry can replace the rule's recipients with literal addresses: `to` (1 to 50 entries), `cc` and `bcc` (`[]` for none). Each entry is one address or `Name
`, and the three lists together hold at most 50. ```bash curl https://api-eu.dynamicdocumentapi.com/v1/renders \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: invoice-R-2026-0042" \ -H "Content-Type: application/json" \ -d '{ "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, "data": { "number": "R-2026-0042", "send_invoice": true, "customer": { "name": "Erika Muster", "email": "erika.muster@customer.example" } }, "mode": "async", "delivery": { "email": [ { "rule_id": "emr_01J9ZK3M7Q8V5W2X4Y6Z8A0B1E", "to": ["Erika Muster "], "cc": [] } ] } }' ``` `false` is always accepted. A list fails: * with `400 validation_error` when the render has no template, or for an unknown rule, another template's rule, a switched-off rule, a rule listed twice, an invalid address or more than 50 recipients; `errors[]` points to `/delivery/email//…` * with `402 plan_feature_unavailable` when the plan doesn't include email delivery * with `422 email_needs_hosted_file` for a zero-retention render A batch resolves its rules once, when it is submitted, and every item sends its own emails. The Batch object counts them in `emails`: `sent`, `failed`, `unknown`, `pending`, `skipped` and `canceled`. See [Batches](https://dynamicdocumentapi.com/docs/batches). ## Live and test renders [#live-and-test-renders] | Render | Emails | | --------------------------- | -------------------------------------------------------------------------------------- | | Live render | Go to the rendered recipients and count toward the [daily cap](#daily-recipient-cap) | | Test render (test key) | Follow the connection's `test_mode`. The addresses in the data never receive anything. | | Rule test | One email to the member address you name, with the subject prefixed `[Test] ` | | Preview, signed-link render | None | | `test_mode` | What a test render's email does | Status | Event | | ----------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------ | | `sandbox` | Brevo checks the message without delivering it. Brevo only, and its default. | `sandboxed` | `email.sent` | | `redirect` | Goes only to `test_recipient`, without `cc` and `bcc`, with the subject prefixed `[Test] ` | `sent` | `email.sent` | | `skip` | No provider call. The email is recorded with what would have been sent. The default for every provider except Brevo. | `skipped`, with `skip_reason: "test_mode"` | none | In `sandbox` and `redirect` mode, the rendered recipients are still checked, so a test render fails the same way as a live render with the same data. Emails of test renders never count toward the daily cap, and test renders are free. ### Preview and test a rule [#preview-and-test-a-rule] `POST /v1/email-rules/{id}/preview` renders a watermarked preview of the template with the rule, and returns the render object with `email_preview`: the rendered fields, the recipients as parsed and the files as they would be attached or linked. Nothing is sent and nothing is billed. For a rule that isn't saved yet, use `POST /v1/email-rules/preview` with the `template_id` and the rule's fields in `rule`, including `connection_id`. `POST /v1/email-rules/{id}/test` creates a free, watermarked test render whose only email is the rule's, sent to `to` with the subject prefixed `[Test] `. `to` must be the verified email address of an active member of your workspace. ```bash curl https://api-eu.dynamicdocumentapi.com/v1/email-rules/emr_01J9ZK3M7Q8V5W2X4Y6Z8A0B1E/test \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "alex@example.com", "data": { "number": "R-2026-0042", "send_invoice": true, "customer": { "name": "Erika Muster", "email": "erika.muster@customer.example" } } }' ``` The answer is `202` with the test render. Once it has finished, its `email` shows the test email. Both endpoints: * render the template's live version, or its draft when nothing is published * take the data from `data`, else from the dataset `dataset_id` (`ds_…`), else from the template's default dataset, else `{}` * work for switched-off rules, and take unsaved fields in `rule` instead of the saved ones The preview's `email_preview`: ```json "email_preview": { "send": true, "skip_reason": null, "error": null, "from_email": "billing@example.com", "from_name": "Example Billing", "reply_to": "accounts@example.com", "to": [{ "email": "erika.muster@customer.example", "name": "Erika Muster" }], "cc": [{ "email": "ap@customer.example", "name": null }], "bcc": [], "recipient_errors": [], "subject": "Invoice R-2026-0042", "html": "

Dear Erika Muster,

please find invoice R-2026-0042 attached.

", "text": "", "attachments": [ { "file_id": "file_01J9ZM4T6W8Y0A2C4E6G8J0K2M", "filename": "invoice-R-2026-0042.pdf", "bytes": 48213, "mode": "attached" } ], "provider_template": null } ``` | Field | Description | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `send` | Whether the rule would send | | `skip_reason` | `condition_false` when the condition is false | | `error` | A template, recipient or attachment error that would fail the email | | `to`, `cc`, `bcc` | The recipients as parsed, each with `email` and `name` | | `recipient_errors` | Every recipient entry that isn't an address | | `html`, `text` | The rendered body. `html` and `text` still hold the placeholder of `{{ download_links }}`, which the links replace when the email is sent. An empty `text` is derived from `html` then. | | `attachments` | The files, each `attached` or `linked` | | `provider_template` | The Brevo template and its rendered `params` | When the preview doesn't finish in time, the answer is `202` with `email_preview: null`. ## Email sends [#email-sends] Every email of a render is an email send (`ems_…`): | Status | Meaning | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `pending` | Waiting to be sent | | `sending` | Being handed to your provider | | `retrying` | An attempt failed and another follows; `error` says why | | `sent` | Your provider accepted the email. This doesn't mean it has reached the inbox. | | `sandboxed` | Brevo's sandbox accepted the email; nothing was delivered | | `skipped` | Not sent: the condition was false (`condition_false`), or a test render's connection has `test_mode: "skip"` (`test_mode`) | | `failed` | Not sent; `error` says why | | `unknown` | Your provider may have accepted the email, for example because the connection broke after the request was sent. It is never retried automatically. | | `canceled` | Stopped before sending: the rule was switched off or deleted, the connection was deleted, the batch was canceled or the workspace was suspended | The render object lists its emails in `email`, in the order they were created: ```json "email": [ { "id": "ems_01J9ZM1X3F7R8K2C4V6B8N0P2T", "rule_id": "emr_01J9ZK3M7Q8V5W2X4Y6Z8A0B1E", "status": "sent", "to": ["erika.muster@customer.example"], "provider_message_id": "<202610011015.12345678901@smtp-relay.mailin.fr>", "error": null, "sent_at": "2026-10-01T10:15:02Z" } ] ``` The emails are created when the finished render is processed. A sync response therefore lists none yet, and the `render.succeeded` event shows them as they were created: `pending`, or `skipped` or `failed` straight away. `GET /v1/renders/{id}` shows their current status. `GET /v1/email-sends/{id}` returns the whole Email Send: ```json { "id": "ems_01J9ZM1X3F7R8K2C4V6B8N0P2T", "object": "email_send", "render_id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q", "batch_id": null, "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", "rule_id": "emr_01J9ZK3M7Q8V5W2X4Y6Z8A0B1E", "connection_id": "emc_01J9ZK3M7Q8V5W2X4Y6Z8A0B1F", "status": "sent", "skip_reason": null, "mode": "live", "test": false, "sandbox": false, "to": ["erika.muster@customer.example"], "cc_count": 1, "bcc_count": 0, "subject": "Invoice R-2026-0042", "attachments": [ { "file_id": "file_01J9ZM4T6W8Y0A2C4E6G8J0K2M", "filename": "invoice-R-2026-0042.pdf", "bytes": 48213, "mode": "attached" } ], "provider": "brevo", "provider_message_id": "<202610011015.12345678901@smtp-relay.mailin.fr>", "attempts": 1, "error": null, "delivery": { "delivered": 2, "deferred": 0, "bounced": 0, "blocked": 0, "complained": 0, "invalid": 0 }, "resend_of": null, "created_at": "2026-10-01T10:15:01Z", "sent_at": "2026-10-01T10:15:02Z" } ``` | Field | Description | | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | The email's ID (`ems_…`) | | `render_id`, `batch_id`, `template_id`, `rule_id`, `connection_id` | Where the email comes from | | `status`, `skip_reason` | See the table above | | `mode` | `live`; for test renders the connection's `test_mode` (`sandbox`, `redirect` or `skip`); `redirect` for a rule test | | `test` | `true` for the emails of test renders | | `sandbox` | `true` when the email went to Brevo's sandbox | | `to` | The `to` addresses. `cc` and `bcc` appear only as counts, `cc_count` and `bcc_count`. | | `subject` | The rendered subject | | `attachments` | The files: `file_id`, `filename`, `bytes` and `mode` (`attached` or `linked`) | | `provider`, `provider_message_id` | The provider and its ID for the message. For SMTP it is the `Message-ID` we set; Microsoft 365 returns none. | | `attempts` | Attempts so far | | `error` | `code` and `message`. Provider errors add `provider_status` and `provider_code`; template errors add `field`, `line` and `column`. | | `delivery` | Brevo's delivery events per kind: `delivered`, `deferred`, `bounced` (soft and hard bounces), `blocked`, `complained` and `invalid`. Zeros for other providers. | | `resend_of` | The email this one repeats | | `created_at`, `sent_at` | When the email was created, and when the provider accepted it | | Endpoint | Description | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /v1/email-sends` | List emails, newest first. Filter by `render_id`, `batch_id`, `template_id`, `rule_id`, `connection_id`, `status` and `created_after`; page with `limit` (1 to 100, default 50) and `cursor`. | | `GET /v1/email-sends/{id}` | Retrieve an email | | `GET /v1/email-sends/{id}/events` | Brevo's [delivery events](#brevo-delivery-events) of an email | | `GET /v1/email-sends/stats` | Today's recipients against the [daily cap](#daily-recipient-cap), and the last 30 days by status | | `POST /v1/email-sends/{id}/resend` | [Send an email again](#resend) | | `POST /v1/batches/{id}/resend-emails` | Resend a batch's failed emails | Emails and their delivery events are kept for 30 days. Test keys see only the emails of test renders. If request logging is off in your workspace settings, an email's body is deleted as soon as the email is final; its addresses and subject stay. Every message carries the header `X-Dda-Send-Id` with the email's ID, so you can find it in your provider's logs. ### Retries and failures [#retries-and-failures] Each attempt calls your provider once, and an email is never sent twice by our retries: a call that may have reached the provider ends as `unknown` instead of being repeated. | Outcome | Status | Next | | ------------------------------------------------------------------------------------------------------ | ----------------------------------------- | --------------------------------------- | | The provider accepted the email | `sent`, or `sandboxed` in Brevo's sandbox | `email.sent` | | The provider couldn't be reached, refused our IP address or applied its rate limit | `retrying` | Another attempt | | The provider refused the message or the credentials, or the email can't be built | `failed` | `email.send_failed` | | The provider may have accepted it, for example because the connection broke after the request was sent | `unknown` | `email.send_failed`; no automatic retry | | Attempt | After the previous attempt | | ------- | -------------------------- | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 15 minutes | | 5 | 1 hour | | 6 | 4 hours | * A rate limit waits until the provider's limit resets, and the wait doesn't count as an attempt. * After the sixth attempt, or when the next one would come later than 24 hours after the email was created, the email fails with the last error. * An email that couldn't be attempted within 24 hours fails with `email_send_expired`. * If our service stops while it hands an email to the provider, the email becomes `unknown` after 10 minutes. ### Resend [#resend] ```bash curl -X POST https://api-eu.dynamicdocumentapi.com/v1/email-sends/ems_01J9ZM1X3F7R8K2C4V6B8N0P2T/resend \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: resend-ems_01J9ZM1X3F7R8K2C4V6B8N0P2T" ``` * `failed`, `unknown` and `sent` emails can be sent again. The answer is `201` with a new email that has the same content, recipients and files, `resend_of` set to the original and its own events. * A resend counts toward today's daily cap. It renders nothing, so no render is billed. * `409 email_not_resendable`: the email has another status; or it failed before it reached your provider, because of its template, recipients or files, which a copy would repeat (fix the rule or the data and render again); or its body wasn't kept because request logging is off. * `409 email_file_expired`: a file the email attaches or links is no longer hosted. * Before you resend an `unknown` email, search your provider's logs for its `X-Dda-Send-Id`, so the recipients don't get it twice. For a batch, `POST /v1/batches/{id}/resend-emails` with `{"statuses": ["failed"]}` (the default) or `{"statuses": ["failed", "unknown"]}` resends the newest email of each item and rule whose status is listed. It answers with the number resent and the emails it skipped, at most 100 of them: ```json { "resent": 12, "skipped": [{ "send_id": "ems_01J9ZM1X3F7R8K2C4V6B8N0P2T", "code": "email_file_expired" }] } ``` ## Events and webhooks [#events-and-webhooks] | Event | Sent when | | ------------------- | ----------------------------------------------------------------------------------------------- | | `email.sent` | Your provider accepted an email (`sent`), or Brevo's sandbox did (`sandboxed`) | | `email.send_failed` | An email ended `failed` or `unknown`, including emails that failed as soon as they were created | | `email.delivered` | Brevo reported the email delivered to a recipient | | `email.bounced` | Brevo reported a soft or hard bounce, a block or an invalid address | | `email.complained` | Brevo reported that a recipient marked the email as spam | Subscribe a [webhook endpoint](https://dynamicdocumentapi.com/docs/webhooks) to these types. The endpoint's template filter applies, and the items of a batch send these events too. `skipped` and `canceled` emails send no event. `data.object` is the Email Send: ```json { "id": "evt_01J9ZM4T6W8Y0A2C4E6G8J0K2M", "type": "email.send_failed", "created_at": "2026-10-01T10:15:02Z", "workspace_id": "ws_01J9ZK0P2R4T6V8X0Z2B4D6F8H", "region": "eu", "data": { "object": { "id": "ems_01J9ZM1X3F7R8K2C4V6B8N0P2T", "object": "email_send", "render_id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q", "rule_id": "emr_01J9ZK3M7Q8V5W2X4Y6Z8A0B1E", "status": "failed", "error": { "code": "email_daily_cap_reached", "message": "The workspace's daily limit of 15,000 email recipients is reached; it resets at 00:00 UTC." } } } } ``` The Email Send above is shortened. `email.delivered`, `email.bounced` and `email.complained` also carry `data.event` with `event`, `recipient`, `reason` and `occurred_at`. ### Brevo delivery events [#brevo-delivery-events] Only Brevo reports what happens after it accepts an email. When you create a Brevo connection or change its key, a transactional webhook is added to your Brevo account for this purpose. The connection's `events` shows whether it is `registered` or `failed`; a failed registration doesn't affect sending and is retried daily. Mail that you send through the same Brevo account from elsewhere is ignored and not stored. `GET /v1/email-sends/{id}/events` lists an email's events, oldest first: ```json { "object": "list", "data": [ { "event": "delivered", "recipient": "erika.muster@customer.example", "reason": "", "occurred_at": "2026-10-01T10:15:04Z" } ], "has_more": false, "next_cursor": null } ``` | `event` | Webhook event | | -------------------------------------------------- | ------------------ | | `delivered` | `email.delivered` | | `deferred` | none | | `soft_bounce`, `hard_bounce`, `blocked`, `invalid` | `email.bounced` | | `spam` | `email.complained` | `reason` is Brevo's reason for bounces and blocks, and may be empty. The email's `delivery` counts the events. The other providers don't report delivery events, so their emails end at `sent`. ## Limits [#limits] | Limit | Value | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | Connections per workspace | 10 | | Rules per template, and so per render | 5 | | Recipients per email | 50, `to`, `cc` and `bcc` together, without duplicates | | Recipients per day | The [daily recipient cap](#daily-recipient-cap) | | Rule sources | 16 KiB per field; `html` 256 KiB; Brevo template `params` 64 KiB | | Rendered fields | `subject`, `from_name` and `reply_to`: one line of up to 255 characters; `html` 512 KiB; `text` 256 KiB; `to`, `cc` and `bcc` 16 KiB each | | Files attached per email | The connection's attachment budget, see [Providers](#providers) | | Sending rate | `max_per_second` per connection: 1 to 100, default 10 | | Attempts per email | 6, within 24 hours | | Download links | Valid until the file's retention ends, at most 7 days | | Email history | 30 days | ### Daily recipient cap [#daily-recipient-cap] A workspace can email a limited number of recipients per day. The cap equals the number of renders your plan includes per month: | Plan | Recipients per day | | ------------- | ----------------------------------- | | Free, Starter | none: email delivery isn't included | | Growth | 15,000 | | Pro | 50,000 | | Scale | 200,000 | | Enterprise | 1,000,000 | * The cap counts the recipients of live emails: `to`, `cc` and `bcc`, after duplicates are removed. * An email is counted once, at its first attempt. A resend is a new email and counts again. * Emails of test renders and rule tests, and skipped emails, don't count. * The count runs per UTC day and region, and resets at 00:00 UTC. * An email that would take the count over the cap fails with `email_daily_cap_reached`, without contacting your provider. Resend it after the reset. * Owners and admins get an email when the cap is reached, at most once a day. * A render keeps the cap it was submitted with, unless the current cap is higher. `GET /v1/email-sends/stats` shows today's count, and the dashboard's **Email** page shows it too: ```json { "object": "email_stats", "today": { "recipients": 1204, "limit": 15000, "resets_at": "2026-10-02T00:00:00Z" }, "last_30_days": { "sent": 18423, "sandboxed": 41, "skipped": 305, "failed": 12, "unknown": 1, "canceled": 0 } } ``` > **Why there is a cap** > > Anyone who can render a template with email rules can choose its recipients through the data. The cap limits how many addresses a leaked API key could reach. Keep keys secret, and restrict them to the templates they need (see [Authentication](https://dynamicdocumentapi.com/docs/authentication)). ## Zero retention and hosted files [#zero-retention-and-hosted-files] Attaching and linking read the render's hosted file: * **Zero-retention renders** (`delivery.retention: "none"`, or the workspace default) keep no file, so they never send email. A `delivery.email` list fails with `422 email_needs_hosted_file`; without a list, the render carries the warning `email_skipped_zero_retention` when the template has enabled rules. In a zero-retention workspace, rule tests fail with `422 email_needs_hosted_file` too. * **Renders with `hosted: false`** keep their hosted copy only until their uploads and emails are done, so their emails can't link files: `attach_or_link` attaches or fails, and `link` fails the email with `email_needs_hosted_file`. While one of their emails is `pending`, `sending`, `retrying`, `failed` or `unknown`, the copy stays, so the email can still be resent. See [Your own storage](https://dynamicdocumentapi.com/docs/storage). * **Expired files** can't be attached or linked. An attempt after the file's retention ended fails with `email_needs_hosted_file`, and a resend with `409 email_file_expired`. ## Errors and warnings [#errors-and-warnings] Request errors: | Status | Code | When | | ------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | `validation_error` | A connection or rule field is invalid, the workspace already has 10 connections or the template 5 rules, a `delivery.email` list is invalid, or a test address isn't the verified address of a workspace member. `errors[]` points to the field. | | `402` | `plan_feature_unavailable` | The plan doesn't include email delivery: creating, changing or testing connections, creating or changing rules other than switching them off, previews, rule tests, resends and `delivery.email` lists | | `403` | `feature_not_enabled` | Email delivery isn't enabled for your workspace yet: creating, changing or testing connections, and creating or changing rules other than switching them off. This check comes before the plan's `402`. | | `409` | `email_connection_in_use` | The connection you deleted is used by rules; `rules` lists them with `id`, `name`, `template_id` and `template_name` | | `409` | `email_not_resendable` | The email can't be resent, see [Resend](#resend) | | `409` | `email_file_expired` | A file of the email you resent is no longer hosted | | `422` | `email_template_error` | A rule field doesn't compile; `errors[]` gives `path`, `line` and `column` | | `422` | `email_needs_hosted_file` | A `delivery.email` list on a zero-retention render, or a rule test in a zero-retention workspace | Render warnings: | Code | Meaning | | ------------------------------ | ---------------------------------------------------------------------------------------------------------- | | `email_skipped_plan` | The template has enabled email rules, but the plan doesn't include email delivery. No email was sent. | | `email_skipped_zero_retention` | The template has enabled email rules, but zero-retention renders keep no file to email. No email was sent. | Saving a rule can return the warning `email_sender_unverified`, see [Senders and verification](#senders-and-verification). An email's `error.code`: | Code | Status | Meaning | | ----------------------------------- | ------------------------- | ------------------------------------------------------------------------------------ | | `email_template_error` | `failed` | A rule field failed to render; `field`, `line` and `column` say where | | `email_too_large` | `failed` | A rendered field is over its limit | | `email_recipient_missing` | `failed` | `to` rendered no address | | `email_address_invalid` | `failed` | A recipient, the sender or the reply-to address isn't a valid address | | `email_too_many_recipients` | `failed` | More than 50 recipients | | `email_attachment_too_large` | `failed` | With `attach`, the files don't fit the attachment budget | | `email_attachment_type_not_allowed` | `failed` | With `attach`, the provider refuses a file type | | `email_needs_hosted_file` | `failed` | A file can't be linked or attached because no hosted copy exists | | `email_provider_rejected` | `failed` | The provider refused the message, for example its sender, a recipient or the content | | `email_provider_auth_failed` | `failed` | The provider refused the credentials. Update them and test the connection. | | `email_provider_unavailable` | `retrying`, then `failed` | The provider couldn't be reached | | `email_ip_not_authorized` | `retrying`, then `failed` | The provider refused our IP address. [Authorise it](#authorise-our-ip-addresses). | | `email_rate_limited` | `retrying`, then `failed` | The provider's rate limit or quota | | `email_outcome_unknown` | `unknown` | The provider may have accepted the email; check its logs before you resend | | `email_daily_cap_reached` | `failed` | The [daily recipient cap](#daily-recipient-cap) is reached; resend after 00:00 UTC | | `email_disabled` | `failed` | Sending email is switched off for your workspace. Contact support. | | `email_credentials_removed` | `failed` | The connection's credentials were deleted after a downgrade. Enter them again. | | `email_send_expired` | `failed` | The email couldn't be attempted within 24 hours | | `internal_error` | `failed` | The email couldn't be prepared on our side | See [Errors](https://dynamicdocumentapi.com/docs/errors) for every other code. ## Plans and billing [#plans-and-billing] Email delivery is included in the Growth, Pro, Scale and Enterprise plans. Emails aren't billed: there is no charge per email or per recipient, and your provider bills you for the emails under your own plan with them. The render is billed as usual, whether it sends emails or not (see [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits)). Test renders, previews and rule tests are free, and a resend uses no render. On Free and Starter, and after a downgrade to them: * Connections and rules are kept, read-only. Reading them, deleting them and switching rules off keep working; everything else fails with `402 plan_feature_unavailable`. * New renders skip the template's rules with the warning `email_skipped_plan`, and a `delivery.email` list fails with `402`. * Renders submitted before the downgrade still send their emails. * The credentials of every connection are deleted 30 days after the downgrade, and `credentials_delete_at` shows the date. Owners and admins get an email at the downgrade and 7 days before the deletion. Moving back to Growth or higher before that date keeps them. * After the deletion, `credentials.configured` is `false` and the connection is `failing` with `email_credentials_removed`. Its emails fail with the same code until you enter the credentials again. Before you change plans, the dashboard lists the email rules that would stop sending. ## Related pages [#related-pages] * [Renders](https://dynamicdocumentapi.com/docs/renders) for `delivery`, the render object and zero-retention delivery * [Webhooks](https://dynamicdocumentapi.com/docs/webhooks) to receive the `email.*` events and verify their signatures * [Template language](https://dynamicdocumentapi.com/docs/template-language) for the syntax of rule fields and conditions * [Authentication](https://dynamicdocumentapi.com/docs/authentication) for test keys, scopes and template allowlists * [Batches](https://dynamicdocumentapi.com/docs/batches) for one email per item * [Your own storage](https://dynamicdocumentapi.com/docs/storage) for `hosted: false` and storage destinations * [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits) for what each plan includes * [Errors](https://dynamicdocumentapi.com/docs/errors) for every other error code * [API reference](https://dynamicdocumentapi.com/docs/api-reference) for the full schemas # Errors (/docs/errors) > The problem details format, which failures are worth retrying, and every error code grouped by HTTP status with a suggested fix. Errors are returned as problem details (RFC 9457) with the content type `application/problem+json`. Every error carries a stable machine-readable `code`; branch on that, never on the human-readable `title` or `detail`. ```json { "type": "https://docs.dynamicdocumentapi.com/errors/template_runtime_error", "title": "Template runtime error", "status": 422, "code": "template_runtime_error", "detail": "'dict object' has no attribute 'total' (line 12, column 7)", "errors": [ { "path": "/data/items/0/qty", "code": "type_error", "message": "expected number" } ], "render_id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q", "request_id": "req_01J9ZM4T6W8Y0A2C4E6G8J0K2M", "doc_url": "https://docs.dynamicdocumentapi.com/errors/template_runtime_error" } ``` | Field | Description | | ------------ | ----------------------------------------------------------------------------------------------------------- | | `type` | URI identifying the error type, such as `https://docs.dynamicdocumentapi.com/errors/template_runtime_error` | | `title` | Short human-readable summary | | `status` | The HTTP status code, repeated in the body | | `code` | Stable identifier to branch on. Every code is listed below. | | `detail` | What went wrong in this particular case, including template line and column where applicable | | `errors` | For validation failures: one entry per problem, with a JSON Pointer `path`, a `code` and a `message` | | `render_id` | Present when a render was created before the failure | | `request_id` | The request's ID, also in the `X-Request-Id` header. Include it in support requests. | | `doc_url` | Link to the documentation for this code | Failed renders carry the same information. A sync render that fails returns `422` with the render error, and an async render ends with `status: "failed"` and an `error` object holding `code`, `message` and, for template problems, `line`, `column` and `excerpt`. ## Handling errors [#handling-errors] Retry these: * `429` after the number of seconds in `Retry-After` * `500` and `503`, with exponential backoff; `503` also sends `Retry-After` * `409 idempotency_in_progress`, after a short pause, with the same idempotency key Don't retry the others without changing the request: `400`, `401`, `402`, `403`, `404`, `405`, `410`, `413`, `415`, `422` and `423` describe something that will fail the same way again. Always send an `Idempotency-Key` header so that a retry can never produce a second document, and log `request_id` next to your own identifiers. TypeScript: ```ts const MAX_ATTEMPTS = 5; async function createRender(body: unknown, idempotencyKey: string) { for (let attempt = 1; ; attempt++) { const response = await fetch("https://api-eu.dynamicdocumentapi.com/v1/renders", { method: "POST", headers: { Authorization: `Bearer ${process.env.DYNAMIC_DOCUMENT_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify(body), }); if (response.ok) return response.json(); const problem = await response.json().catch(() => ({})); const retryable = response.status === 429 || response.status >= 500 || problem.code === "idempotency_in_progress"; if (!retryable || attempt === MAX_ATTEMPTS) { throw new Error(`${problem.code ?? response.status}: ${problem.detail ?? ""} (request ${problem.request_id ?? "?"})`); } const retryAfter = Number(response.headers.get("Retry-After")); const delaySeconds = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, delaySeconds * 1000)); } } ``` Python: ```python import os import time import requests MAX_ATTEMPTS = 5 def create_render(body: dict, idempotency_key: str) -> dict: for attempt in range(1, MAX_ATTEMPTS + 1): response = requests.post( "https://api-eu.dynamicdocumentapi.com/v1/renders", headers={ "Authorization": f"Bearer {os.environ['DYNAMIC_DOCUMENT_API_KEY']}", "Idempotency-Key": idempotency_key, }, json=body, timeout=130, ) if response.ok: return response.json() try: problem = response.json() except ValueError: problem = {} retryable = ( response.status_code == 429 or response.status_code >= 500 or problem.get("code") == "idempotency_in_progress" ) if not retryable or attempt == MAX_ATTEMPTS: raise RuntimeError( f"{problem.get('code', response.status_code)}: {problem.get('detail', '')} " f"(request {problem.get('request_id')})" ) retry_after = response.headers.get("Retry-After", "") time.sleep(int(retry_after) if retry_after.isdecimal() else 2**attempt) raise RuntimeError("unreachable") ``` ## 400 Bad Request [#400-bad-request] | Code | Meaning | Fix | | ---------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | **`invalid_json`** | The request body isn't valid JSON | Check quoting and encoding. In shells, watch for unescaped quotes inside `-d '…'`. | | **`validation_error`** | The JSON is valid but a field is missing, has the wrong type or is out of range | Read `errors[]`: each entry points at the offending field with a JSON Pointer | ## 401 Unauthorized [#401-unauthorized] | Code | Meaning | Fix | | ----------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------- | | **`authentication_required`** | No API key was sent | Send `Authorization: Bearer ` or the `X-API-Key` header | | **`invalid_api_key`** | The key is malformed or unknown | Check for truncation, stray whitespace or a key from another workspace | | **`api_key_expired`** | The key passed its expiry date | Create a new key or extend the expiry | | **`api_key_revoked`** | The key was revoked, or a rolled key's grace period ended | Deploy the current key | ## 402 Payment Required [#402-payment-required] | Code | Meaning | Fix | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **`render_limit_reached`** | The Free plan's included renders are used up, or a paid workspace ran out of renders and no auto top-up can be added (monthly top-up limit reached, auto top-ups off, or a top-up payment outstanding) | Free: upgrade, buy a render pack, or wait for the next cycle. Paid: raise the monthly top-up limit or settle the open payment. Test keys keep working. See [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits). | | **`plan_feature_unavailable`** | The request uses a feature that isn't part of your plan, such as PDF/UA, PDF/A or e-invoicing below Growth | Remove the option or upgrade | | **`free_workspace_limit`** | You already own two workspaces on the Free plan, the most one person can have. Free workspaces you deleted in the last 30 days still count, and so does taking over a free workspace from someone else | Upgrade one of your workspaces to a paid plan, then create the new one | ## 403 Forbidden [#403-forbidden] | Code | Meaning | Fix | | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`insufficient_scope`** | The key lacks a scope for this operation | Use a key with the right [scope](https://dynamicdocumentapi.com/docs/authentication#scopes), for example `render:write` | | **`ip_not_allowed`** | The request came from an address outside the key's IP allowlist | Add the address range, or call from an allowed network | | **`test_key_required`** | The request needs a test key, for example rendering a template draft or sending `test: true` with a live key | Use a test key, or render a published version | | **`workspace_suspended`** | The workspace is suspended | Check billing and email, or contact [support@dynamicdocumentapi.com](mailto:support@dynamicdocumentapi.com) | | **`workspace_deletion_scheduled`** | The workspace is scheduled for deletion, so its API keys are refused | The owner can cancel the deletion in the dashboard until it completes | | **`template_not_allowed_for_key`** | The key's template allowlist doesn't include this template | Add the template to the allowlist or use another key | | **`email_not_verified`** | Live rendering requires a verified email address | Verify the address; test keys work meanwhile | | **`region_not_enabled`** | Your workspace isn't enabled for the region of this API host, so renders, previews, PDF tools and e-invoices are refused here | Call the API host of your workspace's region, see [Data residency](https://dynamicdocumentapi.com/docs/authentication#data-residency), or contact [support@dynamicdocumentapi.com](mailto:support@dynamicdocumentapi.com) | | **`feature_not_enabled`** | [Email delivery](https://dynamicdocumentapi.com/docs/email-delivery) isn't enabled for your workspace yet, so its connections and rules can't be created, changed or tested | Write to [support@dynamicdocumentapi.com](mailto:support@dynamicdocumentapi.com) to have it enabled; reading, deleting and switching rules off work meanwhile | | **`invalid_signature`** | A [signed link](https://dynamicdocumentapi.com/docs/signed-links) URL has no `sig`, or it doesn't match the URL | Sign the exact path and query with the link secret; any change to a parameter, the name or `exp` needs a new signature | | **`referrer_not_allowed`** | The page embedding a signed link isn't in the link's `allowed_referrers` | Add the site to `allowed_referrers`, or remove the list | | **`forbidden`** | The credential can't be used for this endpoint, for example an API key on an endpoint that only the dashboard calls | Check the path against the [API reference](https://dynamicdocumentapi.com/docs/api-reference), which lists the endpoints for API keys | ## 404 Not Found [#404-not-found] | Code | Meaning | Fix | | ------------------------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------- | | **`not_found`** | The resource doesn't exist, or belongs to another workspace | Check the ID and the key's workspace | | **`template_not_found`** | No template with this ID | Check the `template_id`; `GET /v1/templates` lists them | | **`render_not_found`** | No render with this ID | Check the ID and the key's workspace; test keys only see test renders | | **`version_not_found`** | The template has no such version | List versions with `GET /v1/templates/{id}/versions` | | **`upload_not_found`** | The upload doesn't exist or has expired | Uploads expire 24 hours after creation; upload the file again | ## 405 Method Not Allowed [#405-method-not-allowed] | Code | Meaning | Fix | | ------------------------------------ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | **`method_not_allowed`** | The endpoint doesn't support this HTTP method | Check the method against the [API reference](https://dynamicdocumentapi.com/docs/api-reference); the `Allow` response header lists the methods the endpoint accepts | ## 408 Request Timeout [#408-request-timeout] | Code | Meaning | Fix | | -------------------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | **`render_timeout`** | A sync render reached the plan's sync timeout and was canceled because `timeout_behavior` is `cancel` | Use `mode: "async"`, simplify the document, or upgrade for a longer timeout | ## 409 Conflict [#409-conflict] | Code | Meaning | Fix | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | **`idempotency_in_progress`** | An earlier request with the same idempotency key is still running | Wait briefly and retry with the same key | | **`draft_revision_conflict`** | The template draft changed since you read it | Fetch the current draft and apply your change again | | **`conflict`** | The request doesn't fit the resource's current state, for example canceling a render that already finished, retrying a batch that is still running, or replaying a delivery to a deleted endpoint | Read the `detail`, which says what conflicts | | **`zero_retention`** | `retry-failed` on a zero-retention batch, which keeps no item data | Submit the failed items in a new batch | | **`item_files_expired`** | `retry-failed` on a batch with `combine` whose succeeded items' files are gone, so new combined files couldn't hold them. `items` lists those items' indexes. | Submit the failed items in a new batch. See [combined files](https://dynamicdocumentapi.com/docs/batches#combined-files). | | **`email_connection_in_use`** | Email rules still use the connection you're deleting | Change or delete those rules first | | **`email_file_expired`** | A file of the email send is no longer hosted, so the send can't be repeated | Render the document again | | **`email_not_resendable`** | The send can't be repeated: its content wasn't kept, or it failed before sending | Fix the rule or the data and render again | ## 410 Gone [#410-gone] | Code | Meaning | Fix | | ---------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------- | | **`template_deleted`** | The template was deleted | Restore it from the trash in the dashboard, or use another template | | **`file_expired`** | The file was deleted at the end of its retention period or purged | Render again, or raise the retention period | | **`link_expired`** | A [signed link](https://dynamicdocumentapi.com/docs/signed-links) or the URL's `exp` has expired | Sign a new URL, or extend the link's `expires_at` | ## 413 Payload Too Large [#413-payload-too-large] | Code | Meaning | Fix | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | | **`payload_too_large`** | The request body is larger than your plan allows | Trim the payload, use `data_url` for large data sets, or upgrade | | **`output_too_large`** | The generated file exceeds the limit for the delivery type (20 MB for `binary`, 10 MB for `base64`) or the maximum output size | Use `url` delivery, compress images, or split the document | ## 415 Unsupported Media Type [#415-unsupported-media-type] | Code | Meaning | Fix | | ---------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------- | | **`unsupported_media_type`** | The `Content-Type` isn't supported by this endpoint | Send `application/json`, or `multipart/form-data` when uploading files | ## 422 Unprocessable Entity [#422-unprocessable-entity] Most render failures land here. When a render was created before it failed, the body includes `render_id`. | Code | Meaning | Fix | | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | **`template_syntax_error`** | The template can't be parsed | Fix the syntax at the reported line and column; the editor flags these live | | **`template_runtime_error`** | The template failed while rendering, for example an unknown filter, an unsupported operation, or an undefined value in strict mode | Check the line, column and excerpt against your data | | **`template_fuel_exhausted`** | The template exceeded its [execution budget](https://dynamicdocumentapi.com/docs/template-language#sandbox-and-limits) | Simplify nested loops, precompute values in your application, or upgrade | | **`data_schema_mismatch`** | The `data` doesn't match the template's JSON Schema | Read `errors[]` for the failing paths, or relax the schema | | **`idempotency_key_reused`** | The same idempotency key was used with a different body | Use a new key for a different request | | **`url_not_allowed`** | The target URL is blocked: a private or internal address, an unsupported scheme or a non-standard port | Use a publicly reachable HTTP or HTTPS URL on a standard port | | **`navigation_failed`** | The page couldn't be loaded | Check that the URL is reachable from the public internet and that any credentials in `input.http` are correct | | **`wait_timeout`** | The page never reached the wait condition within `wait.timeout_ms` | Check that the selector appears or the ready flag is set, raise the timeout, or use another wait strategy | | **`asset_fetch_failed`** | A required remote file couldn't be fetched, such as the source URL of a PDF tool | Check that the host is reachable and fast enough, or upload the file instead | | **`data_url_fetch_failed`** | The `data_url` couldn't be fetched | Check that the URL is public, returns JSON and responds quickly | | **`page_limit_exceeded`** | The PDF has more pages than your plan allows | Check for unexpectedly long data, split the document, or upgrade. Nothing is billed. | | **`attachment_failed`** | A file in `pdf.attachments` couldn't be loaded, for example because its URL fails or it is too large; the message names it | Check the attachment's source and size. See [Attachments](https://dynamicdocumentapi.com/docs/pdf-options#attachments). | | **`invalid_pdf_source`** | A PDF passed to a PDF tool isn't a valid PDF | Check the source file | | **`pdf_password_required`** | The source PDF is encrypted | Supply the password, or unlock the file first | | **`scene_invalid`** | A canvas template's design can't be rendered, for example because the output would be larger than 16,384 pixels on a side | Read the message; for size problems, lower the artboard size or `scale` | | **`data_invalid`** | A canvas template's `data` isn't an object, or a required dynamic property is missing | Send an object with every required property | | **`email_template_error`** | A field of an email rule, such as its subject or recipients, can't be parsed | Fix the field at the reported line and column. See [Email delivery](https://dynamicdocumentapi.com/docs/email-delivery). | | **`email_needs_hosted_file`** | Email rules were requested for a zero-retention render, which keeps no file to send | Leave out `delivery.email`, or render without zero retention | | **`render_canceled`** | The render was canceled before it finished, for example with `POST /v1/renders/{id}/cancel`. A request that repeats its idempotency key gets the same answer. | Create the render again with a new idempotency key | | **`page_crashed`** | The browser crashed while rendering the page, also on a second attempt | Render again. Very large images or long-running scripts can cause it; if it keeps happening, send us the `render_id`. | | **`storage_error`** | The finished file couldn't be stored or delivered on our side | Render again. If it persists, send us the `render_id` and `request_id`. | | **`render_failed`** | The render failed without a more specific code | Send us the `render_id` and `request_id` | | **`pdfua_validation_failed`** | The PDF doesn't pass veraPDF's PDF/UA-1 checks, often because of a missing title, language, `alt` text or table headers | Read the failed rules in `result.conformance.pdfua`. See [PDF/UA](https://dynamicdocumentapi.com/docs/pdf-options#pdfua). | | **`pdfa_conversion_failed`** | The PDF couldn't be made PDF/A conformant, for example because a font isn't embedded or the source is encrypted, or veraPDF found it non-compliant | Read the message, which names missing fonts, and the failed rules in `result.conformance.pdfa`. See [PDF/A](https://dynamicdocumentapi.com/docs/pdf-options#pdfa). | | **`einvoice_invalid`** | The invoice model breaks a rule of the e-invoice profile before any XML is written, such as a missing field the profile requires, a VAT category without a rate or a `net_amount` that doesn't add up | `result.conformance.einvoice.errors` lists every problem with a JSON path into the invoice. See [E-invoicing](https://dynamicdocumentapi.com/docs/e-invoicing#errors-and-warnings). | | **`einvoice_validation_failed`** | The e-invoice validator rejected the generated XML | Read the rules in `result.conformance.einvoice.errors`; they usually concern content such as a missing Leitweg-ID or an invalid code | `invalid_pdf_source` and `pdf_password_required` come from the [PDF tools](https://dynamicdocumentapi.com/docs/pdf-tools), and `scene_invalid` and `data_invalid` from canvas templates. The last four codes come from PDF/UA, PDF/A and e-invoice output: such renders fail without being billed and carry the validation report in `result`. ## 423 Locked [#423-locked] | Code | Meaning | Fix | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | **`template_locked`** | The template is locked, so through the API its draft can't be replaced (`PUT /v1/templates/{id}/draft`) and it can't be deleted (`DELETE /v1/templates/{id}`) | Ask an Owner or Admin to unlock it in the dashboard | ## 429 Too Many Requests [#429-too-many-requests] | Code | Meaning | Fix | | ------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | **`rate_limited`** | Too many requests per second for your plan | Wait for `Retry-After`, smooth out bursts, or upgrade. See [rate limits](https://dynamicdocumentapi.com/docs/renders#rate-limits). | | **`concurrency_limited`** | Too many synchronous renders are running at once | Wait for `Retry-After`, use `mode: "async"`, or lower your parallelism | | **`quota_exceeded`** | A [signed link](https://dynamicdocumentapi.com/docs/signed-links) has used its `quota_total` renders | Raise the link's quota; cached URLs keep being served | ## 500 and 503 [#500-and-503] | Status | Code | Meaning | Fix | | ------ | --------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | 500 | **`internal_error`** | Something failed on our side | Retry with the same idempotency key. If it persists, send us the `request_id`. | | 503 | **`region_degraded`** | The region is running with reduced capacity | Retry after `Retry-After`; see [https://status.dynamicdocumentapi.com](https://status.dynamicdocumentapi.com) | | 503 | **`maintenance`** | Planned maintenance is in progress | Retry after `Retry-After`; see [https://status.dynamicdocumentapi.com](https://status.dynamicdocumentapi.com) | A render that the platform loses fails with the code `render_lost`: its job expired in the queue, or it stopped without a result well past its time limit. Nothing is billed; render it again. ## Render warnings [#render-warnings] Warnings don't fail a render. They appear in the render object's `warnings` array and in webhook payloads, and they are worth logging: they usually explain why output doesn't look the way you expect. | Code | Meaning | What to check | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `slow_asset` | A remote asset took a long time to load | Host large images closer to the renderer, or embed them; slow assets also slow down renders | | `asset_failed` | An asset couldn't be loaded and is missing from the output | Check the URL and that the host is publicly reachable | | `font_fallback_used` | A canvas template uses a font that isn't available, so Noto Sans was used instead | Pick one of the bundled fonts, or add the font file to the template. HTML renders don't report fallback fonts; see [common problems](https://dynamicdocumentapi.com/docs/pagination#common-problems-and-fixes). | | `header_footer_overlap` | A header or footer is taller than its page margin, so it may overlap the body | Raise `margin.top` or `margin.bottom`, or lower the `height`. See [height and margins](https://dynamicdocumentapi.com/docs/headers-and-footers#height-and-margins). | | `locale_unsupported` | The engine doesn't support the `locale` you set | Use another BCP 47 tag, such as `de-DE` | | `feature_unavailable` | Something in the request or the template can't take effect and was ignored, such as a `@page :first` rule meant to hide the header on the first page | Read the message, which names what was ignored | | `data_coerced` | A canvas template couldn't use a data value as given, such as an unknown key or a value that had to be shortened; an invalid value keeps the designed one | Check the keys and values against the template's dynamic properties | | `text_overflow` | Text in a canvas template was cut off or overflows its box | Shorten the text or make the box larger | | `storage_upload_failed` | An upload to one of your [storage destinations](https://dynamicdocumentapi.com/docs/storage) failed for good. The message says whether a copy remains: the hosted file, a copy kept for a day with `hosted: false`, or none under zero retention. | Check the destination's credentials and permissions | | `einvoice_validation_warning` | The e-invoice validator accepted the XML but reported warnings; the message names the rules | Read `result.conformance.einvoice.warnings` and fix the content if your recipient requires it | | `einvoice_validation_unavailable` | The e-invoice validator didn't answer in time, so the XML was delivered unvalidated | Validate the file yourself or render it again later | | `pdfa_validation_unavailable` | veraPDF didn't answer in time, so the PDF/A file was delivered unvalidated | Render it again later if you need the report | | `pdfua_validation_unavailable` | veraPDF didn't answer in time, so the PDF/UA file was delivered unvalidated | Render it again later if you need the report | | `accessibility_validation_unavailable` | A `tagged` PDF without `pdfua` comes without its PDF/UA report, because veraPDF didn't answer in time | Render it again later if you need the report | | `einvoice_skipped_plan` | The template embeds a ZUGFeRD e-invoice by default, but the plan doesn't include e-invoicing, so the PDF has none | Upgrade to Growth or higher, or send `"output": {"einvoice": null}` to render plain PDFs without the warning | | `storage_skipped_plan` | The workspace has a default storage destination, but the plan doesn't include storage destinations, so the files stayed hosted; under zero retention nothing was uploaded | Upgrade to Starter or higher, or set `is_default` to `false` on the destination | | `email_skipped_plan` | The template has email rules, but they need the Growth plan or higher, so no email was sent | Upgrade, or switch the rules off | | `email_skipped_zero_retention` | Zero-retention renders keep no file to email, so the template's email rules were skipped | Render without zero retention when the document should be emailed | | `invoice_totals_unavailable` | The template has a default e-invoice that this render doesn't embed, and the invoice at its data path couldn't be computed, so `render.invoice` is missing | Send the invoice at the template's data path (`invoice` by default); the message names the first problem | Email sends report their own error codes in the email send object; see [Email delivery](https://dynamicdocumentapi.com/docs/email-delivery). # Headers and footers (/docs/headers-and-footers) > Add running headers and footers with page numbers, dates, logos and data, in simple text mode or with your own HTML. Headers and footers repeat on every page of a PDF. They are configured in `output.pdf.header` and `output.pdf.footer` (or stored as defaults on the template), and they come in two flavours: a simple three-slot text mode, and full HTML. | Option | Type | Default | Description | | ------------------------- | ------- | -------- | ------------------------------------------------ | | `enabled` | boolean | `false` | Draw this header or footer | | `html` | string | none | HTML mode: the markup to render on every page | | `left`, `center`, `right` | string | none | Simple mode: text for the three slots | | `font_size` | string | `"9px"` | Base font size for the header or footer document | | `height` | string | `"15mm"` | Height reserved on every page | ## Simple mode [#simple-mode] Give one or more of `left`, `center` and `right` a line of text. Tokens are replaced per page, and your data is available through the template language: ```json { "output": { "format": "pdf", "pdf": { "margin": { "top": "20mm", "right": "16mm", "bottom": "18mm", "left": "16mm" }, "footer": { "enabled": true, "left": "{{ company.name }} · Invoice {{ invoice.number }}", "right": "Page {{page}} of {{pages}}", "font_size": "8pt", "height": "12mm" } } } } ``` | Token | Replaced with | | ----------- | ---------------------------------- | | `{{page}}` | The current page number | | `{{pages}}` | The total number of pages | | `{{date}}` | The date the document was rendered | | `{{title}}` | The document title | The `{{date}}` token uses the renderer's default date format. When the format matters, pass the date in your data and format it yourself, for example `{{ invoice.issue_date | format_date("long", "en-GB") }}`. ## HTML mode [#html-mode] Set `html` for full control. The markup is a small standalone document that is drawn on every page; spans with particular class names are filled in by the renderer: ```json { "header": { "enabled": true, "html": "
{{ company.name }}Page of
", "height": "16mm" } } ``` The same markup, formatted for reading: ```html
{{ company.name }} Page of
``` | Class | Filled with | | ---------------------------- | -------------------------------------------------------- | | `page` (alias `pageNumber`) | The current page number | | `pages` (alias `totalPages`) | The total number of pages | | `date` | The render date | | `title` | The document title | | `url` | The document URL, which is most useful for `url` renders | Put the class on an empty element: any content inside it is replaced. ## How headers and footers are rendered [#how-headers-and-footers-are-rendered] The header and footer are rendered in an isolated document, separate from your page. That has a few consequences worth knowing: * **Your document's CSS does not apply.** Style the header with inline styles. * **Network resources cannot be loaded.** Images referenced with `` and fonts declared with `@font-face` are downloaded and inlined as data URIs for you, up to 2 MB each. You can also inline an image yourself with [`image_data_uri()`](https://dynamicdocumentapi.com/docs/template-language#image_data_uri). * **The base font size is small.** It comes from `font_size` (`9px` by default), so set it, or set sizes on the elements themselves. * **Name a concrete font, not only a generic family.** In the header and footer document a bare `font-family: sans-serif` resolves to a serif face. Give a family the engine has, with fallbacks, for example `font-family: "Inter", "Liberation Sans", Arial, sans-serif`. * **The area spans the full page width**, including the page margins. Add horizontal padding that matches your page margins so the header lines up with the body text. * **Background colours print as specified**, without extra CSS on your side. * **The template language runs** with the same data as the document, and output is autoescaped. ## Height and margins [#height-and-margins] A header is drawn inside the top margin and a footer inside the bottom margin, so the margin has to be large enough to hold it. * If you don't set `margin.top` or `margin.bottom`, the margin follows the `height` of the header or footer. * If you do set them, keep the margin at least as large as `height`, plus a few millimetres of breathing room. A margin smaller than the height produces a `header_footer_overlap` warning on the render, and body content can end up underneath the header. For the example above, a `height` of `16mm` pairs well with a `margin.top` of `22mm`. ## Template defaults [#template-defaults] Templates store their own header and footer in the editor's **Header & footer** tab, so most requests don't need to send anything. Options you send with a render request override the template's defaults, which is useful for a "COPY" or "DRAFT" banner in one specific call. ## Examples [#examples] A footer with page numbers and a company line: ```json { "footer": { "enabled": true, "html": "
Example GmbH · VAT DE123456789Page of
", "height": "14mm" } } ``` A letterhead header with a logo, using a data URI so the image is always available: ```html
{{ document.type }} {{ document.number }} · {{ document.date | format_date("medium", "en-GB") }}
``` A centred confidentiality note in simple mode: ```json { "header": { "enabled": true, "center": "Confidential — prepared for {{ customer.name }}", "font_size": "8pt", "height": "12mm" } } ``` ## Planned [#planned] * Different headers and footers on the first page, and separate odd and even page variants * A visual header and footer builder in the editor * Running section titles, which repeat the current section's heading in the header, as part of the [pagination toolkit](https://dynamicdocumentapi.com/docs/pagination#pagination-toolkit) # Image and screenshot options (/docs/image-options) > Render PNG, JPEG and WebP images from templates, HTML or URLs, with viewport, scale, cropping, transparency and quality settings. Image renders use the same [render request](https://dynamicdocumentapi.com/docs/renders) as PDFs, with `output.format` set to an image format and settings in `output.image`. The input can be a stored template, HTML you send with the request, or a URL to screenshot. ```json { "input": { "type": "html", "html": "
Hello {{ name }}
" }, "data": { "name": "World" }, "output": { "format": "png", "filename": "card.png", "image": { "viewport": { "width": 1200, "height": 630 }, "scale": 2 } } } ``` ## Formats [#formats] | Format | Status | Notes | | ------ | --------- | --------------------------------------------------------------------------------------------------------- | | `png` | Available | Lossless, supports transparency, the default for the image endpoints | | `jpeg` | Available | Smaller files for photographic content; `quality` and `progressive` apply | | `webp` | Available | Lossy with `quality`, or lossless with `lossless`; supports transparency. At most 16,383 pixels per side. | ## Options [#options] | Option | Type | Default | Description | | ----------------------------------- | ------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `format_options.quality` | number | `85` (JPEG), `80` (WebP) | Quality from 1 to 100 for JPEG and lossy WebP | | `format_options.progressive` | boolean | `false` | Encode JPEG progressively | | `format_options.lossless` | boolean | `false` | Lossless WebP; `quality` doesn't apply | | `transparent` | boolean | `false` | Keep transparency instead of a white background (PNG and WebP) | | `scale` | number | `1` | Device scale factor from 1 to 3. `2` doubles the pixel dimensions, like a high-resolution screen. | | `width`, `height` | number | none | Resize the finished image in pixels. With only one of them set, the aspect ratio is kept. | | `fit` | string | `"contain"` | How the image fits into `width` × `height`: `contain` scales it to fit, `cover` scales it to fill and crops the overflow, `fill` stretches it | | `viewport.width`, `viewport.height` | number | `1280` × `720` | Browser viewport in CSS pixels, from 16 to 8192 each, for HTML and URL input | | `full_page` | boolean | `false` | Capture the whole scrollable page instead of just the viewport | | `clip_selector` | string | none | Capture only the element matching this CSS selector | | `omit_background` | boolean | `false` | Leave out the page's default white background, so transparent areas stay transparent | | `wait.until` | string | `"load"` | Wait strategy: `load`, `networkidle`, `selector`, `ready_flag` or `delay`, as in [PDF options](https://dynamicdocumentapi.com/docs/pdf-options#waiting-and-scripting) | | `wait.selector` | string | none | CSS selector to wait for, with `until: "selector"` | | `wait.delay_ms` | number | `0` | Extra delay in milliseconds, up to 10000 | | `wait.timeout_ms` | number | `30000` | Maximum time for each wait step | The output size is the viewport (or the clipped element, or the full page) multiplied by `scale`, unless you resize it with `width` and `height`. ## Screenshot HTML [#screenshot-html] `POST /v1/images/from-html` takes the markup, optional `head`, data and image options at the top level. This example renders a social preview image at 1200 × 630 CSS pixels and a scale factor of 2, so the PNG is 2400 × 1260 pixels: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/images/from-html \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "html": "

{{ title }}

{{ author }}

", "head": "", "data": { "title": "How we generate documents", "author": "Alex Example" }, "image": { "viewport": { "width": 1200, "height": 630 }, "scale": 2 }, "filename": "og-image.png", "delivery": { "type": "binary" } }' \ -o og-image.png ``` The image endpoints produce PNG by default. For JPEG or WebP, add `"format": "jpeg"` or `"format": "webp"` to the body. ## Screenshot a URL [#screenshot-a-url] ```bash curl https://api-eu.dynamicdocumentapi.com/v1/renders \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": { "type": "url", "url": "https://example.com/pricing" }, "output": { "format": "jpeg", "filename": "pricing.jpg", "image": { "viewport": { "width": 1440, "height": 900 }, "full_page": true, "format_options": { "quality": 80 }, "wait": { "until": "networkidle", "timeout_ms": 20000 } } } }' ``` The same rules as for URL input to PDF apply: the page must be publicly reachable, and you can pass headers, cookies, basic authentication and a user agent in `input.http`. See [input types](https://dynamicdocumentapi.com/docs/renders#input-types). ## Crop, resize and transparency [#crop-resize-and-transparency] Capture one element instead of the whole viewport: ```json { "image": { "clip_selector": "#invoice-summary", "scale": 2 } } ``` Produce a transparent PNG or WebP by leaving out the page background. Make sure your CSS doesn't paint a background on `html` or `body`: ```json { "image": { "clip_selector": ".badge", "omit_background": true, "scale": 3 } } ``` Resize the finished image, for example to fit a fixed slot in an email: ```json { "image": { "width": 600, "height": 315, "fit": "cover" } } ``` ## Image templates [#image-templates] You can store an image design as a template today: create a code template, size the outer element (for example 1080 × 1080 pixels), and render it with `output.format: "png"`. Set the viewport to the same size, or use `clip_selector` to capture exactly the element you designed. Data binding, filters and the whole [template language](https://dynamicdocumentapi.com/docs/template-language) work as they do for PDFs, including QR codes and barcodes. ```json { "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, "data": { "title": "Summer sale", "discount": 0.25 }, "output": { "format": "png", "image": { "viewport": { "width": 1080, "height": 1080 }, "scale": 2 } } } ``` You can also design images in the dashboard's [canvas editor](https://dynamicdocumentapi.com/docs/canvas-editor): choose **New template** → **Blank image**, or start from an image template in the gallery. Frames can use auto layout, and images can be cropped with smart crop or around a focal point. Make the properties you want to change per render dynamic, such as `title.text`, `hero.src` or `badge.visible`; a key defaults to `element.property`. Renders set them through `data`, flat as below or nested per element (`{"title": {"text": "Summer Sale"}}`), with `POST /v1/renders` or, as here, `POST /v1/images/from-template`: ```json { "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", "data": { "title.text": "Summer Sale", "hero.src": "https://example.com/beach.jpg", "badge.visible": false }, "format": "webp" } ``` `GET /v1/templates/{id}/schema` lists a canvas template's `dynamic_properties`. Canvas templates render to PNG (the default), JPEG, WebP or PDF; `POST /v1/images/from-template` renders PNG unless the request names a `format`, while `POST /v1/renders` falls back to the template's default format. Size variants, several sizes from one design, are planned. To let a web page or email request an image by URL without an API key, use [signed links](https://dynamicdocumentapi.com/docs/signed-links), served from `https://img.dynamicdocumentapi.com`. ## Billed renders [#billed-renders] An image render is 1 render, whatever its size. Test renders and failed renders are free. See [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits#what-counts-as-a-render). # Overview (/docs) > How the API turns templates, HTML, URLs and Markdown into PDFs and images, with the core concepts and conventions to know first. Dynamic Document API is a REST API for generating PDFs and images. You send JSON data together with a stored template, raw HTML, a web page URL or Markdown, and the API returns the finished file, either directly in the response or through a signed webhook when the render is complete. Typical documents are invoices, quotes, receipts, statements, certificates, tickets, shipping labels and reports. You can also convert existing web pages to PDF or take screenshots of them. ## How it works [#how-it-works] 1. **Build a template.** Write HTML and CSS with Jinja placeholders in the dashboard code editor at [https://app.dynamicdocumentapi.com](https://app.dynamicdocumentapi.com), or start from a template in the [gallery](https://dynamicdocumentapi.com/templates). 2. **Publish a version.** Publishing validates the template and creates an immutable version. 3. **Create a render.** Call `POST /v1/renders` with the template ID and your data. 4. **Receive the file.** Get a signed download URL, the raw file or base64 content. Long renders can run asynchronously and notify your server with a webhook. ```bash curl https://api-eu.dynamicdocumentapi.com/v1/renders \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, "data": { "number": "2026-0042", "customer": { "name": "Example GmbH" } }, "output": { "format": "pdf" } }' ``` ## Core concepts [#core-concepts] ### Templates and versions [#templates-and-versions] A template stores a document's HTML, the contents of `` (usually CSS), header and footer, sample data and default output options. Templates use a Jinja-compatible [template language](https://dynamicdocumentapi.com/docs/template-language) with extra filters for currencies, dates, QR codes and barcodes. Every template has one editable draft and a series of immutable published versions (`1`, `2`, `3`, …). Publishing checks the syntax, validates the sample data and runs a test render before it moves the `live` pointer to the new version. Renders use the `live` version unless you pin a version number, and a rollback moves the pointer back to an earlier version. Test keys can also render the draft. Each template is pinned to an engine channel such as `2026.4`, which fixes the Chromium version and the bundled fonts. The same template version, data and engine channel produce visually identical output. PDF templates are HTML, CSS and Jinja, edited visually or as code, or Markdown; image templates are designed in the [canvas editor](https://dynamicdocumentapi.com/docs/canvas-editor). ### Renders [#renders] A render is one generation job. `POST /v1/renders` accepts four input types (`template`, `html`, `url` and `markdown`) and produces PDF, PNG, JPEG, WebP or HTML output. PDFs can be accessible as [PDF/UA](https://dynamicdocumentapi.com/docs/pdf-options#pdfua), archived as [PDF/A](https://dynamicdocumentapi.com/docs/pdf-options#pdfa) or carry an embedded [e-invoice](https://dynamicdocumentapi.com/docs/e-invoicing) (Factur-X, ZUGFeRD, XRechnung). [Batches](https://dynamicdocumentapi.com/docs/batches) render many documents in one request, and [PDF tools](https://dynamicdocumentapi.com/docs/pdf-tools) merge, split, protect and convert existing PDFs. Each render has an ID (`rnd_…`), a status and a list of output files. Renders are synchronous by default: the response contains the result. With `mode: "async"` the API responds immediately and you poll the render or wait for a webhook. See [Renders](https://dynamicdocumentapi.com/docs/renders). ### Usage [#usage] Usage is measured in renders. A PDF of up to 50 pages is 1 render, with 1 more for each additional 50 pages, and an image is 1 render. Test renders and failed renders are free. Plans include a monthly number of renders, and paid plans top up automatically with 1,000 renders when they run low, up to a monthly top-up limit that you control. See [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits). ### Test and live keys [#test-and-live-keys] A workspace can have several API keys, each in test or live mode. Test keys (`dda_test_…`) render for free with a "TEST" watermark, keep files for 24 hours and still deliver webhooks. Live keys (`dda_live_…`) produce clean output and count as billed renders. See [Authentication and test mode](https://dynamicdocumentapi.com/docs/authentication). ### Data residency [#data-residency] Render payloads and generated files are processed and stored in the EU (`https://api-eu.dynamicdocumentapi.com`). ### Files and retention [#files-and-retention] Generated files are private. Download URLs are signed and expire after one hour by default, and files are deleted when your workspace's retention period ends. For sensitive documents, zero-retention delivery returns the file in the response without keeping a copy. ## API conventions [#api-conventions] | Topic | Convention | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Base URL | `https://api-eu.dynamicdocumentapi.com/v1` (`https://api.dynamicdocumentapi.com/v1` is an alias) | | Authentication | `Authorization: Bearer dda_live_…` | | Request bodies | JSON with `Content-Type: application/json` | | Errors | `application/problem+json` (RFC 9457) with a stable `code` field | | IDs | Prefixed opaque strings such as `rnd_…`, `tpl_…` and `file_…` | | Timestamps | RFC 3339 in UTC, for example `2026-09-17T15:06:33Z` | | Booleans and enums | JSON booleans and lowercase strings | | Lengths | CSS length strings with a unit, such as `"20mm"` or `"1in"` | | Pagination | Cursor-based, with `limit` and `cursor` query parameters | | Idempotency | `Idempotency-Key` header on POST requests; responses are replayed for 24 hours | | Request IDs | `X-Request-Id` response header, also included in error bodies | | Versioning | The major version is part of the path. Additive changes don't change it, and deprecations are announced at least 12 months ahead with `Deprecation` and `Sunset` headers | ## Next steps [#next-steps] - [Quickstart](https://dynamicdocumentapi.com/docs/quickstart): Create a test key and render your first PDF in a few minutes. - [Authentication and test mode](https://dynamicdocumentapi.com/docs/authentication): API keys, scopes, test mode, key rotation and API hosts. - [Renders](https://dynamicdocumentapi.com/docs/renders): The render request, sync and async modes, delivery and idempotency. - [Template language](https://dynamicdocumentapi.com/docs/template-language): Jinja syntax plus filters for money, dates, QR codes and barcodes. - [Canvas editor](https://dynamicdocumentapi.com/docs/canvas-editor): Design image templates in the dashboard and choose what each render changes. - [PDF options](https://dynamicdocumentapi.com/docs/pdf-options): Paper sizes, margins, headers, metadata, PDF/UA, PDF/A, attachments, protection and wait strategies. - [E-invoicing](https://dynamicdocumentapi.com/docs/e-invoicing): Factur-X, ZUGFeRD and XRechnung from one invoice model, with validation reports. - [Batches](https://dynamicdocumentapi.com/docs/batches): Many documents from one template in a single request, as files, a ZIP or a merged PDF. - [Your own storage](https://dynamicdocumentapi.com/docs/storage): Deliver rendered files to your own S3, Azure, Google Cloud or SFTP storage, with path templates and upload results. - [PDF tools](https://dynamicdocumentapi.com/docs/pdf-tools): Merge, split, rotate, protect, unlock and read or set the metadata of existing PDFs. - [Email delivery](https://dynamicdocumentapi.com/docs/email-delivery): Send rendered documents through your own email provider when a render succeeds. - [Webhooks](https://dynamicdocumentapi.com/docs/webhooks): Signed event delivery, retries, replays and signature verification. - [Errors](https://dynamicdocumentapi.com/docs/errors): Problem details and every error code with suggested fixes. - [API reference](https://dynamicdocumentapi.com/docs/api-reference): Every endpoint, parameter and response schema. # Make (/docs/make) > Create PDFs, images and e-invoices from your templates in Make scenarios, use the PDF tools, and start scenarios when renders finish, with the Dynamic Document API app for Make. The Dynamic Document API app for [Make](https://www.make.com) fills your templates with data from any earlier module and hands the finished file to the next one: attach the invoice to an email, upload the certificate to a drive, post the social image to a channel. Instant triggers start a scenario when a render succeeds or fails, a batch completes or a template gets a new version. The app is in Make's review and not yet listed in Make's app directory. Until it is, write to [support@dynamicdocumentapi.com](mailto:support@dynamicdocumentapi.com) for an invite link, which adds the app to your Make organization. ## Connect your account [#connect-your-account] When you add a Dynamic Document API module to a scenario, Make asks you to create a connection. You sign in with your Dynamic Document API account ([free plan, no card needed](https://app.dynamicdocumentapi.com/signup)) and the consent screen shows: * **The workspace** the connection may use. Make shows its name with the connection, so you can connect several workspaces and pick one per module. * **The permissions:** read templates, create renders, read and delete renders and their files, read the account's plan and usage, manage the webhook endpoints of its instant triggers, and read signed links and create signed URLs. The connection can't change templates, API keys, members or settings. The connection lasts until you remove it under **Settings** → **Integrations** in the dashboard. Make renews its access in the background; if the connection isn't used for 90 days, reauthorize it in Make. ## Triggers [#triggers] | Trigger | Starts the scenario when | | ----------------------------- | ------------------------------------------------------------------------------------------- | | **Watch renders** | A render succeeded or failed, with its files or its error | | **Watch completed batches** | A [batch](https://dynamicdocumentapi.com/docs/batches) finished, with its counts and the ZIP or merged PDF if it made one | | **Watch published templates** | A template got a new live version, published or rolled back | All three are instant: Dynamic Document API sends the event to Make as a [webhook](https://dynamicdocumentapi.com/docs/webhooks) the moment it happens (see [webhook endpoints](#webhook-endpoints) below). The trigger then loads the render, batch or template again through your connection, so the scenario gets current data and working file links. When you add a trigger, Make asks you to create a webhook, with these options: * **Events** (renders only): render succeeded, render failed, or both. * **Templates:** only events of these templates. Leave it empty for all. With a template selected, renders without a template (HTML, URLs, PDF tools) don't trigger. * **Test renders** (renders only): include them, skip them or only use them. Skip them once the scenario sends real documents. * **Renders made in Make** (renders only): skipped by default, so a scenario that renders in its own modules can't start itself again and again. Include them only with a template filter that leaves out the templates the scenario renders. Batches don't record where they came from, so **Watch completed batches** has no such option: a scenario that starts with it shouldn't create a batch of a template its trigger listens to. ## Modules [#modules] | Group | Modules | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Documents and images | **Create a PDF from a template**, **Create an image from a template**, **Create a PDF from HTML**, **Create a PDF from a URL**, **Create a PDF from Markdown**, **Create an image from HTML**, **Create a screenshot of a URL**, **Create an e-invoice** ([e-invoicing](https://dynamicdocumentapi.com/docs/e-invoicing)) | | PDF tools | **Get PDF metadata**, **Merge PDFs**, **Split a PDF**, **Rotate PDF pages**, **Protect a PDF**, **Unlock a PDF**, **Update PDF metadata**, **Convert a PDF to PDF/A**, **Upload a file** ([PDF tools](https://dynamicdocumentapi.com/docs/pdf-tools)) | | Renders | **Search renders**, **Get a render**, **Wait for a render**, **Download a render file**, **Delete render files** | | Batches | **List batch items**, **Get a batch**, **Create a batch** ([batches](https://dynamicdocumentapi.com/docs/batches)) | | Templates and signed links | **Search templates**, **Get a template**, **Create a signed URL** ([signed links](https://dynamicdocumentapi.com/docs/signed-links)) | | Account | **Get account details**, **Get usage** | | Other | **Make an API call**: any request to the [API](https://dynamicdocumentapi.com/docs/api-reference) with your connection; enter the path after `https://api-eu.dynamicdocumentapi.com`, for example `/v1/templates` | **Search renders** filters by status, template, source, reference, test mode and creation time, newest first. The searches and lists return up to the **Limit** you set and page through the API's results for you. Renders from Make show the source **Make** in the dashboard's render list and usage breakdown. ## Fill a template [#fill-a-template] The template list shows published templates only, so publish a template in the dashboard before you use it in a scenario. **Template data** then offers two ways to send its data: * **Map each field** lists the template's data fields from its schema, with the labels and descriptions set in the template editor. Objects become nested groups, and line items such as invoice lines become arrays: map an array from an earlier module, for example an iterator's or aggregator's output, or add the rows by hand. Fields inside an optional object aren't required in Make; the template's schema still checks them when you fill that object. * **JSON** sends the whole `data` object, for example from **JSON** › **Create JSON** or mapped from an earlier module. **Create a PDF from a template** can also embed an e-invoice. Keep **Use the template's setting**, choose **Embed an e-invoice** with a profile and the invoice from the data or as JSON, or choose **Off** for a plain PDF. ## Files and waiting [#files-and-waiting] With **Download file** on (the default), a render module outputs the file as **Data** and **File name**, which the Email, Google Drive, Dropbox and other modules take as an attachment or upload. A render with several files, such as the parts of a split, outputs the first; **Files** lists every file with a signed **URL** that stays valid for 24 hours. The API answers a render within 25 seconds. A render that takes longer keeps running, and the module asks for it again every three seconds until it is done or **Max wait** (default 120 seconds, at most 240) has passed. Then it outputs the render with the status `processing` and no file yet: add **Wait for a render** later in the scenario, or start a second scenario with **Watch renders**, filtered to the template, with **Renders made in Make** included. A failed render stops the module with the API's error message, the fields it names and the render ID, so Make's error handlers (**Ignore**, **Resume**, **Break** and so on) apply. Invalid data and unknown renders or templates are data errors; an expired or revoked connection asks you to reauthorize it. The **PDF tools** take each input PDF as a **File from an earlier module** (uploaded for 24 hours in the same step), a public **URL**, an earlier **Render**, or an **Upload ID** from **Upload a file**. **Merge PDFs** takes a list of URLs, render IDs and upload IDs: upload files from earlier modules with **Upload a file** first. Make's plan limits the size of the files a scenario can handle, so large PDFs may need a Make plan with a higher file size limit. ## Test renders [#test-renders] Each render module has a **Test render** option under the advanced settings, off by default. While you build a scenario, turn it on: [test renders](https://dynamicdocumentapi.com/docs/authentication#test-mode) carry a TEST watermark and aren't billed. Turn it off for the final documents, which count like API renders. ## Webhook endpoints [#webhook-endpoints] Each webhook of an instant trigger adds one webhook endpoint to the workspace, named after its trigger, such as "Make: Watch renders". You see them under **Webhooks** in the dashboard, marked **Managed by Make**. You can't edit or delete them there: * Deleting the webhook in Make (**Webhooks** in Make's left menu) removes its endpoint. A scenario that is turned off keeps its webhook, and Make queues the events until the scenario runs again. * Disconnecting Make under **Settings** → **Integrations** removes the endpoints once no Make connection to the workspace is left. * If Make answers a delivery with `410 Gone`, Dynamic Document API removes the endpoint. Managed endpoints never become the workspace's default endpoint, and they count towards the limit of 20 webhook endpoints per workspace. ## Plans [#plans] Every plan, Free included, can render PDFs and images, use the PDF tools and run the triggers. Some modules need a plan with the feature: | Module | Plan | | -------------------------------------------------------------------------------------------------- | ----------------- | | **Create a signed URL** | Starter or higher | | **Create a batch** | Starter or higher | | **Create an e-invoice**, e-invoices embedded by **Create a PDF from a template**, PDF/A and PDF/UA | Growth or higher | The usual [limits](https://dynamicdocumentapi.com/docs/plans-and-limits) apply, such as the rate limit, pages per PDF and batch items. A workspace with an **API IP allowlist** (**Settings** → **Security**) refuses Make's requests, because they come from Make's servers. # MCP server (/docs/mcp) > Connect Claude, ChatGPT, Cursor, VS Code and other AI agents to your templates over the Model Context Protocol, from sign-in and tools to test mode and limits. The MCP server lets AI agents use Dynamic Document API as a tool. An agent can list your templates, read the data a template expects, and render PDFs, images and e-invoices, then hand you the download links. It speaks the [Model Context Protocol](https://modelcontextprotocol.io), so it works with any client that supports remote MCP servers. ``` https://mcp.dynamicdocumentapi.com/mcp ``` You sign in with your Dynamic Document API account the first time a client connects, and choose the workspace it may use. There is nothing to install. ## Connect a client [#connect-a-client] | Client | How | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | Claude (claude.ai and Claude Desktop) | Add a custom connector with the URL above | | ChatGPT | Add a connector with the URL above, where your plan offers custom connectors | | Claude Code | `claude mcp add --transport http dynamic-document-api https://mcp.dynamicdocumentapi.com/mcp` | | Cursor | In `.cursor/mcp.json`: `{"mcpServers": {"dynamic-document-api": {"url": "https://mcp.dynamicdocumentapi.com/mcp"}}}` | | VS Code | In `.vscode/mcp.json`: `{"servers": {"dynamic-document-api": {"type": "http", "url": "https://mcp.dynamicdocumentapi.com/mcp"}}}` | The client opens the Dynamic Document API sign-in in your browser. After you sign in, the consent screen shows: * **The permissions:** list templates and read their schemas (`templates:read`), create renders (`render:write`) and read renders and their files (`renders:read`). Nothing else: the server can't change templates, keys, webhooks or settings. * **The workspace** the client may use, and its region. You need a role that may manage integrations in it. * **"Unverified app"** and the address you return to. AI clients register themselves when they first connect, so the name they show is their own claim. Continue only if the address belongs to the client you're connecting, such as `claude.ai` for Claude or `http://localhost` for a client on your computer. The connection lasts until you remove it under **Settings** → **Integrations** in the dashboard. The client renews its access in the background; if you haven't used it for 90 days, it asks you to sign in again. ### With an API key [#with-an-api-key] Clients that let you set request headers can send an API key instead of signing in: ``` Authorization: Bearer dda_test_… ``` With a key, the key decides whether renders are test renders. A test key (`dda_test_…`) makes every render a test render. A live key (`dda_live_…`) makes every render final and billed, because the API accepts [test renders](https://dynamicdocumentapi.com/docs/authentication#test-mode) only from test keys. Start with a test key and switch to a live key when you want the final documents. Prefer signing in where the client supports it: a key in a client's configuration file is easy to leak. ## Tools [#tools] | Tool | What it does | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `list_templates` | Lists the workspace's published templates, filtered by type or name | | `get_template_schema` | Returns the data a template expects: the JSON Schema, a list of fields, an example payload and the template's default e-invoice | | `render_pdf_from_template` | Renders a PDF from a template and JSON data, optionally as a hybrid [e-invoice](https://dynamicdocumentapi.com/docs/e-invoicing) | | `render_image_from_template` | Renders a PNG, JPEG or WebP from an image template | | `render_pdf_from_html` | Renders a PDF from HTML and CSS | | `render_pdf_from_markdown` | Renders a PDF from Markdown with a built-in theme | | `create_einvoice` | Creates a validated e-invoice XML: XRechnung, EN 16931, Factur-X and ZUGFeRD profiles | | `get_render` | Returns a render's status and fresh download links | | `merge_pdfs` | Merges 2 to 200 PDFs into one, see [PDF tools](https://dynamicdocumentapi.com/docs/pdf-tools#merge) | Each tool result has a short text the agent can quote and the full result as structured data. Download links are signed and expire; `get_render` returns new ones. The server also offers each template as a resource (`template://tpl_…`, its schema and an example payload), each render as `render://rnd_…`, and two prompts that clients show as starting points: **create\_invoice\_pdf** and **make\_social\_image**. ## Test mode first [#test-mode-first] After you sign in, renders from the MCP server are [test renders](https://dynamicdocumentapi.com/docs/authentication#test-mode) unless the agent passes `test: false`: they carry a TEST watermark and aren't billed. The result tells the agent how to get the final document, so ask for it when you're happy with the test version. Every final render counts like an API render. A connection [with an API key](#with-an-api-key) has no such switch: a test key always makes test renders and a live key always final ones. The server tells the agent which of the two it is. ## Limits [#limits] * **60 tool calls a minute** per connection. Your plan's API rate limits apply as well. * **25 seconds per render call.** A render that takes longer keeps running and the tool returns its id with the status `queued` or `processing`; the agent collects the files with `get_render`. * **IP allowlists:** requests reach the API from Dynamic Document API's own servers, so a workspace with an API IP allowlist (**Settings** → **Security**) refuses them. * Renders show the source `mcp` in the dashboard's render list and usage breakdown. ## Local server [#local-server] The server also runs on your computer, with an API key instead of a sign-in. It's published as a Docker image, [`franko727/mcp` on Docker Hub](https://hub.docker.com/r/franko727/mcp), for Intel and Apple silicon. You need Docker installed; the client starts the container itself. 1. Create an API key under **API keys** in the dashboard at [https://app.dynamicdocumentapi.com](https://app.dynamicdocumentapi.com). Start with a test key (`dda_test_…`). 2. Add the server to your client. Claude Desktop (`claude_desktop_config.json`), Cursor (`.cursor/mcp.json`) and most other clients: ```json { "mcpServers": { "dynamic-document-api": { "command": "docker", "args": ["run", "-i", "--rm", "-e", "DYNAMIC_DOCUMENT_API_KEY", "franko727/mcp"], "env": { "DYNAMIC_DOCUMENT_API_KEY": "dda_test_…" } } } } ``` Claude Code: ```bash claude mcp add dynamic-document-api --env DYNAMIC_DOCUMENT_API_KEY=dda_test_… -- docker run -i --rm -e DYNAMIC_DOCUMENT_API_KEY franko727/mcp ``` The local server has the same tools, resources and prompts. As [with an API key](#with-an-api-key) above, the key decides whether renders are test renders. Its requests reach the API from your computer, so a workspace's IP allowlist applies to your address. Starting it with `npx`, without Docker, is coming soon. # n8n (/docs/n8n) > Render PDFs, images and e-invoices from your templates in n8n workflows, use the PDF tools, and start workflows when renders finish, with the Dynamic Document API community nodes. The Dynamic Document API nodes for [n8n](https://n8n.io) fill your templates with data from any earlier node and pass the finished file on as binary data: attach the invoice to an email, upload the certificate to a drive, post the image to a chat. The trigger starts a workflow when a render succeeds or fails, a batch completes, a template gets a new version, or another workspace event happens. The package is [`@dynamic-document-api/n8n-nodes-dynamic-document-api`](https://www.npmjs.com/package/@dynamic-document-api/n8n-nodes-dynamic-document-api) on npm, published with provenance from its [public repository](https://github.com/DynamicDocumentApi/n8n-nodes-dynamic-document-api). It contains two nodes: * **Dynamic Document API**: renders documents and images, runs the PDF tools, creates e-invoices, batches and signed URLs, and reads renders, templates and usage. It also works as a tool for n8n's **AI Agent**. * **Dynamic Document API Trigger**: starts a workflow on [webhook events](https://dynamicdocumentapi.com/docs/webhooks#event-types). ## Install [#install] In a self-hosted n8n, an owner or admin installs it under **Settings** → **Community Nodes** → **Install** with the package name: ``` @dynamic-document-api/n8n-nodes-dynamic-document-api ``` The nodes are waiting for n8n's verification. Until they are verified, n8n Cloud can't install them; every self-hosted n8n can. ## Credentials [#credentials] Create an API key under **API keys** in the dashboard at [https://app.dynamicdocumentapi.com](https://app.dynamicdocumentapi.com) ([free plan, no card needed](https://app.dynamicdocumentapi.com/signup)). In n8n, add a **Dynamic Document API** credential with the key and your workspace's region. The key decides whether renders are [test renders](https://dynamicdocumentapi.com/docs/authentication#test-mode): a test key (`dda_test_…`) makes free renders with a TEST watermark, which is the right choice while you build a workflow. A live key (`dda_live_…`) makes final renders that count against your plan. The key's [scopes](https://dynamicdocumentapi.com/docs/authentication#scopes) decide which operations work. The **Render only** preset covers rendering, the PDF tools, e-invoices, batches and reading renders and templates. Add `signed_links:write` for signed URLs, `account:read` for the account and usage, `renders:delete` for deleting files, and `webhooks:write` for the trigger, or use a key with full access. ## Operations [#operations] | Resource | Operations | | -------------- | -------------------------------------------------------------------------------------------------- | | Document (PDF) | Create from template, HTML, URL or Markdown | | Image | Create from template, HTML or URL (a screenshot of the page) | | PDF Tool | [Merge, split, rotate, protect, unlock, get or update metadata, convert to PDF/A](https://dynamicdocumentapi.com/docs/pdf-tools) | | E-Invoice | Create an XRechnung, EN 16931 or Factur-X / ZUGFeRD XML ([e-invoicing](https://dynamicdocumentapi.com/docs/e-invoicing)) | | Batch | Create, get, get items ([batches](https://dynamicdocumentapi.com/docs/batches)) | | Render | Get, get many, wait, delete files | | Template | Get, get many, get schema | | Signed Link | Sign a URL that renders an image or PDF when opened ([signed links](https://dynamicdocumentapi.com/docs/signed-links)) | | Account | Get the plan and renders left, get usage | Renders from n8n show the source **n8n** in the dashboard's render list and usage breakdown. ## Fill a template [#fill-a-template] Choose the template from the list (published templates) or by ID. **Data Entry** then offers two ways to send its data: * **Map Each Field** lists the template's data fields from its schema. Nested fields show as `Customer › Name`; arrays such as invoice lines take JSON, for example an expression with the previous node's items. * **Using JSON** sends the whole data object, for example `{{ $json }}` of the previous node. **Document → Create From Template** can also embed an e-invoice: keep the template's own setting, embed one with a profile and the invoice from the data or as JSON, or turn it off for a plain PDF. ## Files and waiting [#files-and-waiting] With **Download File** on (the default), the file arrives as binary data in `data`, and further files, such as the parts of a split, in `data_2`, `data_3` and so on. The JSON output has a signed **file\_url** that stays valid for 24 hours. Turn off **Simplify** for the full render object. The API answers a render step within 25 seconds. A render that takes longer keeps running, and the node asks for it again every two seconds until it is done or **Max Wait** (default 120 seconds) has passed; then the item carries the status `processing` and a hint. **Render → Wait** picks it up later, and so does the trigger. A failed render fails the item with the API's error, including the render ID; with **On Error** set to continue, the workflow goes on with the error in the item. A retried node doesn't render twice: each item sends an idempotency key made from the execution, the node and the item. **PDF tools** take each input PDF from a binary field of an earlier node (uploaded for 24 hours), a public URL, or an earlier render. ## Trigger [#trigger] Activating a workflow with the trigger creates a webhook endpoint in your workspace, listed under **Webhooks** in the dashboard as "n8n: *workflow name*". Deactivating the workflow deletes it, and changing the events or templates replaces it. Every delivery's signature is checked against the endpoint's secret. * **Your n8n must be reachable over https** on port 443 (or 80, 8080, 8443): Dynamic Document API doesn't deliver webhooks to other ports or to private addresses. Set n8n's `WEBHOOK_URL` to that public address. * **Listen for test event** asks Dynamic Document API to send a sample of the first selected event right away, so you can map its fields without rendering. * **Templates** limits render and batch events to these templates. Renders without a template (HTML, URL, PDF tools) then don't match. * **Renders made in n8n** are skipped by default, so a workflow that renders in its own steps can't start itself again and again. Include them only with a template filter that leaves out the templates the workflow renders. * **Test renders** can be included, skipped, or be the only ones. * **Fetch Fresh Render** (on by default) loads the render again, so the event carries working file links. Each active workflow with the trigger uses one of the workspace's 20 webhook endpoints. ## Plans and limits [#plans-and-limits] Every plan, Free included, can render PDFs and images, use the PDF tools and run the trigger. Signed URLs and batches need the Starter plan or higher; e-invoices, PDF/A and PDF/UA need Growth or higher. Your plan's [rate limits](https://dynamicdocumentapi.com/docs/plans-and-limits) apply to the requests from n8n. The requests come from your n8n server, so an [IP allowlist](https://dynamicdocumentapi.com/docs/authentication#ip-allowlist) on the key or the workspace must include its address. # Pagination basics (/docs/pagination) > Control page breaks, keep blocks together, repeat table headers and fix the usual pagination problems in HTML to PDF output. A PDF is your HTML poured into fixed-size pages. Chromium decides where the content breaks, and CSS fragmentation properties let you steer those decisions. This page collects the rules that matter for documents, and the fixes for the problems that come up most often. The page box comes from the [PDF options](https://dynamicdocumentapi.com/docs/pdf-options#page-setup): `paper`, `orientation` and `margin`, or from your CSS `@page` rule when `prefer_css_page_size` is on. ## Force a page break [#force-a-page-break] Break before or after an element with CSS: ```css .chapter { break-before: page; } .cover { break-after: page; } ``` Or insert a break from the template, which is easier inside loops: ```jinja {% for certificate in certificates %}
…
{% if not loop.last %}{{ page_break() }}{% endif %} {% endfor %} ``` Note the `loop.last` check: a break after the final element produces an empty last page. ## Keep content together [#keep-content-together] `break-inside: avoid` moves an element to the next page instead of splitting it: ```css .totals, .signature-block, figure, tr { break-inside: avoid; } ``` An element taller than one page will still be split: there is nowhere else to put it. ## Keep a heading with its content [#keep-a-heading-with-its-content] `break-after: avoid` asks for no break directly after an element: ```css h2, h3 { break-after: avoid; } ``` The most reliable approach is to wrap the heading and the first block that must stay with it: ```html

Payment details

Please transfer the total to the account below within 30 days.

``` ## Tables [#tables] Long tables are the main source of pagination problems. Three rules cover most of it: ```css thead { display: table-header-group; } tfoot { display: table-footer-group; } tr { break-inside: avoid; } ``` * `thead` repeats the header row at the top of every page the table spans. Put the header cells in a real `` element, not in the first `` row. * A `tfoot` group repeats at the bottom of every page, so keep grand totals in a block after the table if they should appear only once. * `tr { break-inside: avoid; }` keeps a row's cells on one page. Rows with a lot of text can still break if a single row is taller than the page. ## Orphans and widows [#orphans-and-widows] Stop single lines being left behind at the bottom or top of a page: ```css p { orphans: 3; widows: 3; } ``` ## Page size in CSS [#page-size-in-css] By default the `paper`, `orientation` and `margin` options define the page. With `prefer_css_page_size: true`, the size from your CSS wins, which also allows different sizes within one document using named pages: ```css @page { size: A4; } @page wide { size: A4 landscape; } .appendix-table { page: wide; } ``` Keep setting margins through the `margin` option, so that headers and footers and your margins stay in sync. ## Single-page output [#single-page-output] `single_page: true` produces one page as tall as the content, with the paper width you set. It is the usual choice for receipts on narrow paper and for continuous documents that will be read on screen: ```json { "paper": { "width": "80mm", "height": "200mm" }, "single_page": true } ``` ## Common problems and fixes [#common-problems-and-fixes] | Symptom | Likely cause | Fix | | -------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Table rows split across pages | Rows may break by default | `tr { break-inside: avoid; }` | | Header row only on the first page | Header cells are in `tbody`, or `display` was overridden | Use `` with `display: table-header-group` | | Empty page at the end | A break after the last element, or a full-height element | Skip the break for the last item, avoid `height: 100vh` and fixed full-page heights | | Heading alone at the bottom of a page | No keep-with-next rule | `break-after: avoid`, or wrap heading and first paragraph in a `break-inside: avoid` container | | Body text hidden behind the header or footer | Margin smaller than the header or footer `height` | Increase `margin.top` or `margin.bottom`; check the render's warnings | | Background colours or images missing | Backgrounds aren't printed | Set `print_background: true` and `print-color-adjust: exact` on the element | | The PDF looks different from the browser | Print styles are applied | Check your `@media print` rules, or render with `emulate_media: "screen"` | | Wrong or fallback fonts | The font isn't bundled with the engine, or its file couldn't be loaded | Use the engine's bundled fonts, or declare `@font-face` in the head with a reachable source. The browser falls back without a warning, but a font file that couldn't be loaded shows up as an `asset_failed` warning | | Content cut off on the right | Fixed widths wider than the printable area | Printable width is the paper width minus both margins, for example 180 mm on A4 with 15 mm margins. Use percentages and `box-sizing: border-box`. | | Flex or grid sections break oddly | Fragmentation of flex and grid containers is limited | Use block layout or tables for long, paginated content | | Unexpectedly many pages | Long data, oversized images or forced breaks | Check the render's `pages` count; a PDF over your plan's page limit fails with `page_limit_exceeded` | ## Page numbers [#page-numbers] Page numbers belong in the header or footer, where the renderer fills in the current and total page numbers. See [Headers and footers](https://dynamicdocumentapi.com/docs/headers-and-footers). ## Pagination toolkit [#pagination-toolkit] Renders of code and Markdown templates, and of HTML or Markdown you send, load a small pagination stylesheet that covers the same ground with less CSS. Pages rendered from a URL don't get it. **Print defaults.** Table header and footer groups repeat on every page. Table rows, images, SVGs and figures aren't split. Paragraphs, list items and quotes keep at least three lines at the bottom and top of a page, and headings stay with what follows them. The defaults sit in a CSS cascade layer, so any rule of your own overrides them. **Utility classes.** Add them to elements in your template: | Class | Effect | | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `.dda-keep-together` | Don't split the element across pages | | `.dda-keep-with-next` | No page break directly after the element | | `.dda-page-break-before`, `.dda-page-break-after` | Start a new page before or after the element | | `.dda-avoid-row-split`, `.dda-allow-row-split` | On a table or a row: keep rows on one page, or let them break | | `.dda-repeat-header`, `.dda-no-repeat-header` | On a table: repeat the header row on every page, or print it once | | `.dda-orphans-widows` | Keep at least three lines at the bottom and top of a page | | `.dda-print-only`, `.dda-screen-only` | Show the element only in print, as in PDFs, or only on screen | | `.dda-page`, `.dda-fill-page` | A column at least one page tall, and inside it a block that takes the height the rest of the content leaves. See below | **Push a block to the bottom of the page.** CSS has no "rest of this page" height, so `.dda-fill-page` only works inside a `.dda-page` column, placed directly in `body`: ```html

Invoice

…
``` * On a one-page document the signature block sits at the bottom of the page. * When the content runs over a page, the column grows with it and the block follows the content on the last page. * Keep the top and bottom margin and padding of `body` at 0. The stylesheet removes the browser's default margin, but a margin or padding of your own pushes the end of the column onto a second page. Padding on the column itself is fine. * The column is a flex container, so the vertical margins of its children don't collapse. **Pagination check.** The editor's PDF preview flags table rows and images split across pages, tables that continue without their header, table footers such as totals that print on every page, headings at the bottom of a page, elements wider than the printable area, unbreakable blocks taller than a page, and blank or nearly blank last pages. Most findings come with a one-click fix that adds CSS to the template's head. Still planned: * `.dda-running-title`, which repeats the current section's title in the header * an optional paged-media mode for features Chromium lacks, such as running elements, tables of contents with page references, and footnotes # PDF options (/docs/pdf-options) > Paper sizes, margins, scaling, print behaviour, metadata, accessibility and PDF/UA, PDF/A archiving, attachments, protection and wait strategies for PDF output. PDF options control how a page is printed: its size, margins, what is waited for and what metadata the file carries. Send them as `output.pdf` in a [render request](https://dynamicdocumentapi.com/docs/renders), or as `pdf` in the convenience endpoints. ```json { "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, "data": { "number": "2026-0042" }, "output": { "format": "pdf", "pdf": { "paper": { "size": "A4" }, "margin": { "top": "20mm", "right": "15mm", "bottom": "20mm", "left": "15mm" }, "print_background": true } } } ``` Templates store their own defaults, set in the editor's Options tab. Options you send with a request override the template's defaults. A PDF template can also store a default e-invoice, `einvoice: {"profile": "EN16931", "invoice_path": "invoice"}` next to `pdf` in its options, set in the Options tab under **E-invoice (ZUGFeRD / Factur-X)**. Every render of it is then a Factur-X / ZUGFeRD PDF/A-3b unless the request sends its own `output.einvoice`, or `"einvoice": null` to render a plain PDF. On plans without e-invoicing it is skipped with the warning `einvoice_skipped_plan`. See [Make a template an e-invoice by default](https://dynamicdocumentapi.com/docs/e-invoicing#make-a-template-an-e-invoice-by-default). ## Page setup [#page-setup] | Option | Type | Default | Description | | ------------------------------------------------------------ | ------- | ------------ | ------------------------------------------------------------------------------------------------------------------------- | | `paper.size` | string | `"A4"` | A named size: `A0` to `A6`, `B4`, `B5`, `Letter`, `Legal`, `Tabloid` or `Ledger` | | `paper.width`, `paper.height` | string | — | A custom size instead of `size`, as CSS lengths, for example `"80mm"` and `"200mm"` | | `orientation` | string | `"portrait"` | `portrait` or `landscape`. Landscape swaps width and height. | | `margin.top`, `margin.right`, `margin.bottom`, `margin.left` | string | not set | Page margins as CSS lengths. When a header or footer is enabled and margins are not set, the margin follows its `height`. | | `scale` | number | `1.0` | Scales the rendered content between `0.1` and `2.0` | | `prefer_css_page_size` | boolean | `false` | Take the page size from the CSS `@page` rule instead of `paper`, which also allows different sizes per page | | `single_page` | boolean | `false` | Produce a single continuous page as tall as the content | | `page_ranges` | string | all pages | Keep only these pages, for example `"1-3,5"` | ### Paper sizes [#paper-sizes] | Size | Millimetres | Inches | | ------- | ------------- | ------------- | | A0 | 841 × 1189 | 33.11 × 46.81 | | A1 | 594 × 841 | 23.39 × 33.11 | | A2 | 420 × 594 | 16.54 × 23.39 | | A3 | 297 × 420 | 11.69 × 16.54 | | A4 | 210 × 297 | 8.27 × 11.69 | | A5 | 148 × 210 | 5.83 × 8.27 | | A6 | 105 × 148 | 4.13 × 5.83 | | B4 | 250 × 353 | 9.84 × 13.90 | | B5 | 176 × 250 | 6.93 × 9.84 | | Letter | 215.9 × 279.4 | 8.5 × 11 | | Legal | 215.9 × 355.6 | 8.5 × 14 | | Tabloid | 279.4 × 431.8 | 11 × 17 | | Ledger | 431.8 × 279.4 | 17 × 11 | Custom sizes are useful for labels and receipts: ```json { "paper": { "width": "4in", "height": "6in" } } ``` ### CSS length units [#css-length-units] Sizes and margins are CSS length strings with an explicit unit: | Unit | Meaning | | ---------- | ------------------------ | | `mm`, `cm` | Millimetres, centimetres | | `in` | Inches | | `pt` | Points, 1/72 inch | | `pc` | Picas, 12 points | | `px` | CSS pixels, 1/96 inch | For example `"20mm"`, `"0.75in"` and `"48px"` are valid; a bare number is not. ## Content and print behaviour [#content-and-print-behaviour] | Option | Type | Default | Description | | ------------------ | ------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `print_background` | boolean | `true` | Print background colours and images | | `emulate_media` | string | `"print"` | `print` applies your `@media print` rules; `screen` renders the page as a browser screen would, which is often what you want for `url` input | | `javascript` | boolean | `true` | Run scripts in the page. Turn it off for untrusted HTML or to speed up renders. | | `timezone` | string | `"UTC"` | IANA time zone used by scripts in the page | | `locale` | string | `"en-US"` | Locale used by scripts in the page, for example for `Intl` formatting | Backgrounds only print when the element also allows it in CSS. Add `print-color-adjust: exact` (with the `-webkit-` prefixed property for older engines) to elements with coloured backgrounds. ## Headers and footers [#headers-and-footers] `header` and `footer` take the same fields. [Headers and footers](https://dynamicdocumentapi.com/docs/headers-and-footers) explains both modes in detail. | Option | Type | Default | Description | | ---------------------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------- | | `header.enabled` | boolean | `false` | Draw the header on every page | | `header.html` | string | none | HTML for the header, with template expressions and the `page`, `pages`, `date`, `title` and `url` classes | | `header.left`, `header.center`, `header.right` | string | none | Simple text mode, with the `{{page}}`, `{{pages}}`, `{{date}}` and `{{title}}` tokens | | `header.font_size` | string | `"9px"` | Base font size of the header document | | `header.height` | string | `"15mm"` | Height reserved for the header | ## Accessibility and archiving [#accessibility-and-archiving] | Option | Type | Default | Description | | ------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tagged` | boolean | `false` | Produce a tagged PDF with a structure tree, so assistive technology can read the document. Use semantic HTML (headings, lists, table headers, `alt` text) and set `metadata.lang`. The render's `result.conformance.pdfua` holds a PDF/UA-1 report for information. | | `pdfua` | string | `null` | `"1"`: deliver a PDF/UA-1 document, validated with veraPDF. The render fails if the check fails. Implies `tagged`. See [PDF/UA](#pdfua). | | `outline` | boolean | `false` | Add PDF bookmarks generated from the `h1` to `h6` headings | | `pdfa` | string | `null` | `"2b"` or `"3b"`: convert the PDF to PDF/A and validate it with veraPDF. See [PDF/A](#pdfa). | | `attachments` | array | `[]` | Up to 20 files embedded in the PDF. See [Attachments](#attachments). | > **Compliance features** > > PDF/UA and PDF/A output and e-invoices ([Factur-X, ZUGFeRD and XRechnung](https://dynamicdocumentapi.com/docs/e-invoicing)) are available, each with a validation report. ## PDF/A [#pdfa] PDF/A (ISO 19005) is the standard for PDFs that must stay readable for decades, such as invoices, contracts and statements. Set `pdfa` and the engine converts the printed PDF, then validates it with veraPDF: ```json { "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, "data": { "number": "2026-0042" }, "output": { "format": "pdf", "pdf": { "pdfa": "2b", "metadata": { "title": "Invoice 2026-0042", "lang": "de-DE" } } } } ``` | Level | Standard | Use it for | | ----- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `2b` | PDF/A-2b (ISO 19005-2), conformance level B: reliable visual appearance | Archiving business documents | | `3b` | PDF/A-3b (ISO 19005-3) | The same as `2b`, plus embedded files of any type. [E-invoices](https://dynamicdocumentapi.com/docs/e-invoicing) use it to carry their XML. | The conversion adds an sRGB output intent and writes the PDF/A identification and the document metadata into XMP. It doesn't render the page again, so text, images and layout stay as printed. PDF/A output costs no additional renders. It is included in the Growth plan and higher; on Free and Starter, `pdfa` fails with `402 plan_feature_unavailable`. What to know: * **Fonts must be embedded.** If a font can't be embedded, the render fails with `422 pdfa_conversion_failed` and the message names the font. * **No encryption.** PDF/A forbids it, so `protect` together with `pdfa` fails with `400 validation_error`. * **Report.** `result.conformance.pdfa` on the render holds the level, the status (`passed`, `failed` or `unavailable`) and any findings. If veraPDF finds the file non-compliant, the render fails with `pdfa_conversion_failed`, costs nothing, and the failed rules are listed in the report. See [validation report](https://dynamicdocumentapi.com/docs/e-invoicing#validation-report). * **Unavailable validator.** If veraPDF doesn't answer in time, the file is delivered unvalidated, with the warning `pdfa_validation_unavailable` and `status: "unavailable"`. * **Test renders.** The TEST watermark is drawn as vector outlines and stamped before the conversion, so a test PDF is conformant too and the report describes the delivered file. ### Convert an existing PDF [#convert-an-existing-pdf] `POST /v1/pdf-tools/pdfa` converts a PDF that wasn't rendered by Dynamic Document API and validates the result: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/pdf-tools/pdfa \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "source": { "url": "https://example.com/contract.pdf" }, "level": "2b", "filename": "contract-pdfa.pdf" }' ``` | Field | Description | | ---------- | -------------------------------------------------------------------------------------------------- | | `source` | The PDF. Exactly one of `url`, `render_id` (with an optional `file_id`), `upload_id` or `data_uri` | | `level` | `2b` or `3b` | | `filename` | Name of the converted file | The response is a render with the converted file and the report in `result.conformance.pdfa`. It accepts the same `mode`, `delivery`, `webhook`, `reference`, `metadata` and `test` fields as the other PDF tools. Conversion is best effort: it can't embed fonts the source doesn't contain, and it can't process encrypted PDFs. Either fails with `pdfa_conversion_failed`. A conversion is billed as 1 render, and failed ones cost nothing. Like `pdfa`, this endpoint needs the Growth plan or higher. ## PDF/UA [#pdfua] PDF/UA (ISO 14289-1) is the standard for accessible PDFs: screen readers and other assistive technology can read them in the right order, with headings, lists, tables, link texts and image descriptions. Set `pdfua` to `"1"` and the render either delivers a PDF that passes veraPDF's PDF/UA-1 checks or fails: ```json { "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, "data": { "number": "2026-0042" }, "output": { "format": "pdf", "pdf": { "pdfua": "1", "metadata": { "title": "Invoice 2026-0042", "lang": "en-GB" } } } } ``` After rendering, Dynamic Document API fixes what the browser's tagging leaves open: link descriptions, list structure, headers and footers marked as artifacts that screen readers skip, and the PDF/UA identification. What only your template can provide: * **A title and a language.** Set `metadata.title` or a `` element, and `metadata.lang` or `<html lang>`. With `pdfua` there is no default language: a document without one fails the check. * **Text alternatives.** `alt` text on meaningful images, and `alt=""` on decorative ones. * **Structure.** Real tables with `<th scope>`, headings in order, and lists as `<ul>` or `<ol>`. Content that is hidden from the accessibility tree (`aria-hidden`, `alt=""`, CSS `content`, borders) becomes a decoration that screen readers skip, so meaningful text belongs in the markup. What to know: * **Report.** `result.conformance.pdfua` holds the standard, the status (`passed`, `failed` or `unavailable`) and the rules that failed. A render that fails the check fails with `422 pdfua_validation_failed` and the report, and costs nothing. * **Unavailable validator.** If veraPDF doesn't answer in time, the file is delivered unvalidated, with the warning `pdfua_validation_unavailable` and `status: "unavailable"`. * **Limits of the check.** veraPDF checks what a machine can check. Whether alt text is meaningful and the reading order is right is yours to review. * **Combinations.** `pdfua` works with `pdfa`, [e-invoices](https://dynamicdocumentapi.com/docs/e-invoicing), attachments and `protect`, which keeps access for assistive technology. PDFs from canvas templates can't be PDF/UA (`400 validation_error`). * **Previews and publishing.** Editor previews show the report but never fail. Publishing warns when the template's test render fails the check. * **Plan.** PDF/UA is included in the Growth plan and higher; on Free and Starter, `pdfua` fails with `402 plan_feature_unavailable`. ## Attachments [#attachments] `attachments` embeds files in the PDF, for example the supporting documents of an invoice. Each entry has: | Field | Description | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `url`, `data_uri` or `upload_id` | Exactly one source. A `url` is fetched like an asset. A `data_uri` may be at most 5 MB, and all of them together at most 8 MB of base64; use an upload for more. | | `name` | File name in the PDF: 1 to 255 characters, without `/`, `\` or control characters, and unique in the document, ignoring case | | `mime_type` | Optional. Derived from the name's extension, else `application/octet-stream` | | `description` | Optional description shown by PDF readers | | `relationship` | Optional: `Source`, `Data`, `Alternative`, `Supplement` or `Unspecified` (the default) | ```json { "output": { "format": "pdf", "pdf": { "attachments": [ { "url": "https://example.com/timesheet.csv", "name": "timesheet.csv", "relationship": "Supplement" } ] } } } ``` Up to 20 files, each at most 10 MiB and 25 MiB together. A file that can't be fetched fails the render with `422 attachment_failed`, which names it. Attachments work with plain PDFs and with `pdfa: "3b"`, where they sit next to an e-invoice's XML. `pdfa: "2b"` can't carry them (`400 validation_error`), and an attachment can't take the name of the embedded e-invoice (`factur-x.xml`, `xrechnung.xml`). With `protect`, the files are embedded before encryption. Editor previews and test renders leave attachments out. Uploads expire after 24 hours, so a template's default attachments use `url` or `data_uri`. ## Metadata [#metadata] | Option | Type | Default | Description | | ------------------- | ---------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `metadata.title` | string | none | Document title stored in the PDF | | `metadata.author` | string | none | Author | | `metadata.subject` | string | none | Subject | | `metadata.keywords` | array of strings | `[]` | Keywords | | `metadata.creator` | string | none | The application that created the document | | `metadata.lang` | string | `"en"` | Document language as a BCP 47 tag, such as `"de-DE"`. It sets the `lang` attribute of the rendered HTML and the document language in the PDF. With `pdfua` there is no default: set it here or in `<html lang>`. | ## Security [#security] | Option | Type | Default | Description | | ------------------------------------ | ------- | ------- | -------------------------------------------- | | `protect.user_password` | string | none | Password needed to open the document | | `protect.owner_password` | string | none | Password needed to change permissions | | `protect.permissions.print` | boolean | `true` | Allow printing | | `protect.permissions.print_high_res` | boolean | `true` | Allow printing at full quality | | `protect.permissions.copy` | boolean | `true` | Allow copying text and images | | `protect.permissions.modify` | boolean | `false` | Allow changing the content | | `protect.permissions.annotate` | boolean | `false` | Allow adding comments and annotations | | `protect.permissions.fill_forms` | boolean | `true` | Allow filling in form fields | | `protect.permissions.assemble` | boolean | `false` | Allow inserting, rotating and deleting pages | Protecting a document restricts editing, not reading: a permission you leave out keeps its default. To protect an existing PDF, use the [protect tool](https://dynamicdocumentapi.com/docs/pdf-tools). Documents are encrypted with AES-256. Passwords are part of the request body, so keep them out of logs on your side and use request-log redaction rules if you enable request logging. Protection can't be combined with `pdfa` or an e-invoice, because PDF/A forbids encryption. With `pdfua`, protection keeps the permission that lets assistive technology read the text. ## Waiting and scripting [#waiting-and-scripting] Rendering starts once the page is ready. Choose a strategy that matches how your page loads: | `wait.until` | Ready when | | ------------- | ------------------------------------------------------------------------------------------------------------ | | `load` | The page's `load` event has fired | | `networkidle` | No request has been in flight for 500 ms. The default, and a good fit for pages with remote images or fonts. | | `selector` | An element matching `wait.selector` exists | | `ready_flag` | The page has set `window.__DYNAMIC_DOCUMENT_API_READY__ = true` | | `delay` | `wait.delay_ms` milliseconds have passed after load | | Option | Type | Default | Description | | ----------------- | ------ | --------------- | -------------------------------------------------- | | `wait.until` | string | `"networkidle"` | Wait strategy, see above | | `wait.selector` | string | none | CSS selector to wait for, with `until: "selector"` | | `wait.delay_ms` | number | `0` | Extra delay in milliseconds, up to 10000 | | `wait.timeout_ms` | number | `30000` | Maximum time for each wait step | After the wait condition, the renderer always waits for web fonts to load and images to decode before printing, so a fixed `delay` is rarely necessary. When a wait step times out, the render continues with the page as it is and records a warning, so check the render's `warnings` if output looks incomplete. A render that cannot continue fails with `422 wait_timeout`. For pages that build content with JavaScript, such as charts, set the ready flag when you are done: ```html <script> renderChart(data).then(() => { window.__DYNAMIC_DOCUMENT_API_READY__ = true; }); </script> ``` ```json { "wait": { "until": "ready_flag", "timeout_ms": 20000 } } ``` ## Examples [#examples] An A4 invoice with a footer and metadata: ```json { "paper": { "size": "A4" }, "margin": { "top": "18mm", "right": "16mm", "bottom": "22mm", "left": "16mm" }, "print_background": true, "footer": { "enabled": true, "html": "<div style=\"width:100%;padding:0 16mm;display:flex;justify-content:space-between;font-size:8pt;color:#555\"><span>Example GmbH</span><span>Page <span class=\"page\"></span> of <span class=\"pages\"></span></span></div>", "height": "14mm" }, "metadata": { "title": "Invoice 2026-0042", "author": "Example GmbH", "lang": "de-DE" } } ``` An 80 mm receipt on one continuous page, where the declared height is replaced by the content height: ```json { "paper": { "width": "80mm", "height": "200mm" }, "margin": { "top": "4mm", "right": "4mm", "bottom": "4mm", "left": "4mm" }, "single_page": true } ``` A landscape report with bookmarks and a tagged structure: ```json { "paper": { "size": "A3" }, "orientation": "landscape", "scale": 0.9, "outline": true, "tagged": true, "metadata": { "title": "Quarterly report", "lang": "en-GB" } } ``` A shipping label at 4 × 6 inches, printed edge to edge: ```json { "paper": { "width": "4in", "height": "6in" }, "margin": { "top": "0mm", "right": "0mm", "bottom": "0mm", "left": "0mm" }, "print_background": true } ``` A protected document with printing allowed but copying and editing blocked: ```json { "paper": { "size": "A4" }, "protect": { "user_password": "…", "owner_password": "…", "permissions": { "print": true, "copy": false, "modify": false } } } ``` ## Related pages [#related-pages] * [Headers and footers](https://dynamicdocumentapi.com/docs/headers-and-footers) for running headers, page numbers and logos * [Pagination basics](https://dynamicdocumentapi.com/docs/pagination) for page breaks, repeating table headers and layout problems * [Image and screenshot options](https://dynamicdocumentapi.com/docs/image-options) for PNG and JPEG output * [E-invoicing](https://dynamicdocumentapi.com/docs/e-invoicing) for Factur-X, ZUGFeRD and XRechnung * [PDF tools](https://dynamicdocumentapi.com/docs/pdf-tools) for merging, splitting, protecting and converting existing PDFs # PDF tools (/docs/pdf-tools) > Merge, split, rotate, protect and unlock existing PDFs and read or change their metadata, from sources and page lists to delivery, encrypted files, errors and billing. The PDF tools work on PDFs you already have: files from earlier renders, uploads, URLs or inline data. Each call creates a render with `input_type: "pdf_tool"`, so it behaves like [`POST /v1/renders`](https://dynamicdocumentapi.com/docs/renders): sync or async, the same delivery types, webhooks and idempotency keys, and an entry in your render log. Each successful call is 1 billed render. ## Tools [#tools] | Tool | Endpoint | What it does | | ------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------- | | [Merge](#merge) | `POST /v1/pdf-tools/merge` | Joins 2 to 200 PDFs into one, with a bookmark per source by default | | [Split](#split) | `POST /v1/pdf-tools/split` | Writes page ranges, or runs of a fixed number of pages, as separate PDFs or as one ZIP file | | [Rotate](#rotate) | `POST /v1/pdf-tools/rotate` | Turns pages by 90, 180 or 270 degrees | | [Protect](#protect) | `POST /v1/pdf-tools/protect` | Encrypts a PDF with AES-256, with passwords and permissions | | [Unlock](#unlock) | `POST /v1/pdf-tools/unlock` | Removes the encryption, given the user or the owner password | | [Metadata](#metadata) | `POST /v1/pdf-tools/metadata` | Reads the page count, page sizes and document information, or changes the information | | [PDF/A](#convert-to-pdfa) | `POST /v1/pdf-tools/pdfa` | Converts a PDF to PDF/A-2b or PDF/A-3b and validates it | The tools are available on every plan; the PDF/A conversion needs the Growth plan or higher. Tool calls and uploads need an API key with the `render:write` scope. ## Sources [#sources] Each tool takes its PDF as a source object: `source`, or `sources` for a merge. A source has exactly one of these fields: | Field | Example | What it is | | ----------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `url` | `{"url": "https://example.com/files/contract.pdf"}` | A public `https` URL. The file is downloaded when the call runs: at most 100 MB, within 30 seconds. | | `render_id` | `{"render_id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q"}` | The PDF of an earlier render in your workspace, at most 100 MB. Without `file_id` it is the render's first PDF file; add `file_id` (`file_…`) to pick another, such as one part of a split. | | `upload_id` | `{"upload_id": "upl_01J9ZQ4V6X8Z0B2D4F6H8K0M2P"}` | A PDF uploaded with `POST /v1/uploads`: at most 50 MB, kept for 24 hours | | `data_uri` | `{"data_uri": "data:application/pdf;base64,JVBERi0xLjcK…"}` | The file itself, base64-encoded, at most 5 MB. The prefix `data:application/octet-stream;base64,` works too. | `render_id` chains tools: pass the render ID of one call to the next, for example to rotate the pages of a file you have just unlocked. The API checks sources before anything runs: * A source without exactly one of these fields, a `url` that isn't a public `https` address, and an upload or data URI that isn't a PDF fail with `400 validation_error`. * A render or upload that isn't in your workspace, or an expired upload, answers `404 render_not_found` or `404 upload_not_found`. * A render without a PDF file to read answers `410 file_expired`: it hasn't succeeded, its files were deleted, or it used zero-retention delivery. Data URIs count towards your plan's request body limit, which is 2 MB on Free, so upload larger files first. `POST /v1/uploads` takes one file in the multipart field `file` and recognises PDFs by their content: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/uploads \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -F "file=@contract.pdf" ``` ```json { "id": "upl_01J9ZQ4V6X8Z0B2D4F6H8K0M2P", "object": "upload", "filename": "contract.pdf", "bytes": 482113, "mime": "application/pdf", "sha256": "5e8f…", "created_at": "2026-10-03T09:12:44Z", "expires_at": "2026-10-04T09:12:44Z" } ``` ## Page lists [#page-lists] Split and rotate select pages with page lists. Pages are numbered from 1: | Page list | Pages | | --------- | ----------------------- | | `"7"` | Page 7 | | `"1,3-5"` | Pages 1, 3, 4 and 5 | | `"4-"` | Page 4 to the last page | | `"all"` | Every page | Spaces around numbers, dashes and commas are ignored, and every page counts once, in document order: `"5,1-2"` selects pages 1, 2 and 5. A list in any other form is rejected with `400 validation_error`. Page 0, a range that runs backwards such as `"5-3"`, and a page after the last one fail the call with `validation_error`, and the message names the page, for example `page 9 is outside the document, which has 5 pages`. ## Common fields [#common-fields] Besides its own fields, every tool accepts these, with the same meaning as for [renders](https://dynamicdocumentapi.com/docs/renders): | Field | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `filename` | Name of the output file, which always ends in `.pdf`. Without it, the file is named after the render ID. A split uses it as the stem of the parts' names. | | `mode` | `sync` (default) or `async`. See [Sync, async and webhooks](#sync-async-and-webhooks). | | `timeout_behavior` | `cancel` cancels a sync call that reaches your plan's sync timeout | | `delivery` | How you receive the files. See [Delivery](#delivery). | | `webhook` | A per-request webhook with `url` and `events` | | `reference`, `metadata` | Your own identifier and key-value pairs for the render, as for renders. This `metadata` isn't the PDF's document information, which the [metadata tool](#metadata) reads and changes. | | `test` | `true` makes a test call, which costs nothing and returns PDFs with the "TEST" watermark. See [Test mode](#test-mode). Test keys always make test calls; `true` with a live key fails with `403 test_key_required`. | Send an `Idempotency-Key` header with every call, as for renders. ## Merge [#merge] `POST /v1/pdf-tools/merge` joins the pages of 2 to 200 PDFs into one document, in the order of `sources`: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/pdf-tools/merge \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: contract-4711-merge" \ -H "Content-Type: application/json" \ -d '{ "sources": [ { "url": "https://example.com/files/cover.pdf" }, { "render_id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q" }, { "upload_id": "upl_01J9ZQ4V6X8Z0B2D4F6H8K0M2P" } ], "bookmarks": "filenames", "filename": "contract-4711.pdf", "reference": "contract-4711" }' ``` | Field | Description | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sources` | Required. 2 to 200 [sources](#sources), merged in this order | | `bookmarks` | `filenames` (default): one top-level bookmark per source, pointing at its first page, and the PDF opens with the bookmarks shown. `none`: no bookmarks. | Bookmarks are named after the files, without `.pdf`: the end of a `url` path (`Quarterly%20Report.pdf` becomes `Quarterly Report`), the name of an uploaded file, or the name of a render's file. Sources without a name, such as data URIs, become `Document 1`, `Document 2` and so on, by their position. The response is a [render object](https://dynamicdocumentapi.com/docs/renders#the-render-object) with the merged file. Its `input_type` is `pdf_tool`, and `kind` names the tool: `pdf_merge`, `pdf_split`, `pdf_rotate`, `pdf_protect`, `pdf_unlock`, `pdf_metadata` or `pdf_pdfa`. Every tool answers this way. ```json { "id": "rnd_01JA2B3C4D5E6F7G8H9J0K1M2N", "object": "render", "status": "succeeded", "mode": "sync", "test": false, "input_type": "pdf_tool", "kind": "pdf_merge", "output_format": "pdf", "files": [ { "id": "file_01JA2B3C4D5E6F7G8H9J0K1M2P", "format": "pdf", "filename": "contract-4711.pdf", "bytes": 912384, "pages": 14, "content_type": "application/pdf", "url": "https://dda-eu-outputs.nbg1.your-objectstorage.com/…", "url_expires_at": "2026-10-03T10:12:45Z", "sha256": "3a7b…" } ], "pages": 14, "billed_renders": 1, "warnings": [], "error": null, "result": null, "reference": "contract-4711", "created_at": "2026-10-03T09:12:44Z", "completed_at": "2026-10-03T09:12:45Z" } ``` The response body above is shortened. The merged PDF keeps the sources' pages with their content and annotations, and links between pages keep working. What belongs to a source document as a whole is not carried over: its bookmarks, form fields (their appearance stays on the page), tags, page labels, embedded files, JavaScript and document information. The result is untagged and has no title; set one with the [metadata tool](#metadata). It may have at most your plan's pages per PDF, or the call fails with `page_limit_exceeded`. ## Split [#split] `POST /v1/pdf-tools/split` writes parts of a PDF as separate files: one per [page list](#page-lists) in `ranges`, or one per run of `every` pages. ```bash curl https://api-eu.dynamicdocumentapi.com/v1/pdf-tools/split \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": { "upload_id": "upl_01J9ZQ4V6X8Z0B2D4F6H8K0M2P" }, "ranges": ["1-2", "3-"], "filename": "statement.pdf" }' ``` | Field | Description | | -------- | --------------------------------------------------------------------------------------------------------------- | | `source` | Required. The PDF to split | | `ranges` | Up to 200 page lists, one output file each, such as `["1-2", "3-"]` | | `every` | Instead of `ranges`: one file per run of this many pages, from 1 to 10,000. The last file may hold fewer pages. | | `zip` | `true` delivers one ZIP file that holds the parts, instead of one file per part. Default `false`. | Send exactly one of `ranges` and `every`. Ranges may overlap, so a page can appear in several files. `every: 10` splits a 25-page document into pages 1–10, 11–20 and 21–25, and `every: 1` makes one file per page. A split makes at most 200 files; asking for more fails with `validation_error`. ### Names and files [#names-and-files] The parts are named `<stem>-1.pdf`, `<stem>-2.pdf` and so on, in the order of `ranges` or of the runs. `<stem>` is `filename` without its extension, or `split` without a `filename`. With `zip: true`, the only output is `<stem>.zip`, which holds the parts under these names. The render's `files` lists the parts in that order, each with its own `id`, `pages` and signed `url`, and `pages` on the render counts the pages of all parts. `GET /v1/renders/{id}/files/{file_id}` redirects to a single part. For the request above and a 6-page statement: ```json { "id": "rnd_01JA2B3C4D5E6F7G8H9J0K1M2N", "object": "render", "status": "succeeded", "input_type": "pdf_tool", "kind": "pdf_split", "output_format": "pdf", "files": [ { "id": "file_01JA2B3C4D5E6F7G8H9J0K1M2P", "format": "pdf", "filename": "statement-1.pdf", "pages": 2, "url": "https://dda-eu-outputs.nbg1.your-objectstorage.com/…" }, { "id": "file_01JA2B3C4D5E6F7G8H9J0K1M2Q", "format": "pdf", "filename": "statement-2.pdf", "pages": 4, "url": "https://dda-eu-outputs.nbg1.your-objectstorage.com/…" } ], "pages": 6, "billed_renders": 1 } ``` With `zip: true`, `output_format` is `zip` and `files` holds one `statement.zip` (`application/zip`). Either way, a split is 1 billed render, however many files it makes. ### Delivering several files [#delivering-several-files] | `delivery.type` | Split result | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `url` | A signed `url` for every part | | `binary` | Only for a single output: `zip: true`, or exactly one range. Otherwise the request fails with `400 validation_error` at `/delivery/type`. | | `base64` | Every part, each with its own `content_base64`, at most 10 MB together | [Storage destinations](https://dynamicdocumentapi.com/docs/storage) receive every file, and a path template can tell the parts apart with `file.index`. ### What the parts keep [#what-the-parts-keep] Each part keeps the source's document information and language. Links to pages in the same part keep working; links to other pages are removed. Like a merge, a part doesn't carry the source's bookmarks, form fields, tags, page labels, embedded files or XMP metadata. > **PDF/A files and e-invoices** > > Merged and split files are plain PDFs, even when a source is a PDF/A file or a Factur-X or ZUGFeRD invoice: the PDF/A identification and the embedded invoice XML belong to the document as a whole and don't carry over. Convert the result with the [PDF/A tool](#convert-to-pdfa) if you need PDF/A again. ## Rotate [#rotate] `POST /v1/pdf-tools/rotate` turns pages by a multiple of 90 degrees: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/pdf-tools/rotate \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": { "upload_id": "upl_01J9ZQ4V6X8Z0B2D4F6H8K0M2P" }, "pages": "2,4-", "degrees": 90 }' ``` | Field | Description | | --------- | ----------------------------------------------------------------------------------------------------- | | `source` | Required. The PDF | | `degrees` | Required. `90`, `180` or `270` turn pages clockwise; `-90`, `-180` or `-270` turn them anticlockwise. | | `pages` | The pages to turn, as a [page list](#page-lists). Default `"all"`. | The angle is added to each selected page's current rotation: a page that is already turned by 90 degrees ends up at 180 with `"degrees": 90`. The other pages keep theirs. Only the rotation changes, not the page content, and the rest of the document stays as it is. ## Protect [#protect] `POST /v1/pdf-tools/protect` encrypts a PDF with AES-256, like the [`protect` option](https://dynamicdocumentapi.com/docs/pdf-options#security) of a render: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/pdf-tools/protect \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": { "render_id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q" }, "user_password": "…", "owner_password": "…", "permissions": { "copy": false }, "filename": "statement-protected.pdf" }' ``` | Field | Description | | ---------------- | --------------------------------------------------------------------------------------------------------------- | | `source` | Required. An unencrypted PDF | | `user_password` | Password needed to open the document, up to 127 characters | | `owner_password` | Password that grants full access, up to 127 characters | | `permissions` | What someone who opens the document with the user password may do. Flags you leave out take the defaults below. | Send at least one password; passwords can't contain NUL characters. Without `user_password`, anyone can open the document, and the permissions apply. Without `owner_password`, a random owner password is set that nobody knows. | Permission | Default | Allows | | ---------------- | ------- | ---------------------------------------------------------------------- | | `print` | `true` | Printing, in low resolution unless `print_high_res` is allowed too | | `print_high_res` | `true` | Printing in full quality, together with `print` | | `copy` | `true` | Copying text and images | | `modify` | `false` | Changing the document in other ways | | `annotate` | `false` | Adding and changing annotations such as comments, and filling in forms | | `fill_forms` | `true` | Filling in existing form fields | | `assemble` | `false` | Inserting, rotating and deleting pages, and changing bookmarks | These are the defaults of the `protect` render option too. Assistive technology can always extract the text. Other permission names fail with `400 validation_error`. A source that is already encrypted fails with `pdf_password_required`: [unlock](#unlock) it first to change its passwords or permissions. PDF/A forbids encryption, so a protected PDF/A file is no longer PDF/A. > **Passwords in requests** > > Passwords are part of the request body for protect and unlock. Keep them out of your own logs, and use request-log redaction rules if request logging is enabled for your workspace. ## Unlock [#unlock] `POST /v1/pdf-tools/unlock` removes a PDF's encryption and returns the same document without passwords or restrictions: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/pdf-tools/unlock \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": { "upload_id": "upl_01J9ZQ4V6X8Z0B2D4F6H8K0M2P" }, "password": "…", "filename": "statement.pdf" }' ``` | Field | Description | | ---------- | ----------------------------------------------------------------------- | | `source` | Required. The encrypted PDF | | `password` | Required. The user password or the owner password, up to 127 characters | * A password that opens the PDF neither as user nor as owner fails with `422 pdf_password_required` and the message `The password doesn't open this PDF.` A failed call costs nothing. * A PDF that opens without a password but restricts printing, copying or editing is encrypted too. Unlock it with `"password": ""` or with its owner password. * An unencrypted source comes back unchanged, and the call is 1 billed render like any other. A [test call](#test-mode) adds the "TEST" watermark. To change an encrypted PDF, unlock it, pass the unlocked render's ID as `render_id` to the next tool, and [protect](#protect) the result again if needed. ## Metadata [#metadata] `POST /v1/pdf-tools/metadata` reads a PDF's pages, page sizes and document information. With `set`, it changes the document information and returns the edited PDF. ### Read the metadata [#read-the-metadata] ```bash curl https://api-eu.dynamicdocumentapi.com/v1/pdf-tools/metadata \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": { "render_id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q" } }' ``` A read returns no file. The information is in the render's `result`: ```json { "id": "rnd_01JA2B3C4D5E6F7G8H9J0K1M2N", "object": "render", "status": "succeeded", "input_type": "pdf_tool", "kind": "pdf_metadata", "files": [], "billed_renders": 1, "result": { "pages": 12, "page_sizes": [ { "width": "210mm", "height": "297mm", "pages": "1-11" }, { "width": "297mm", "height": "210mm", "pages": "12" } ], "encrypted": false, "pdf_version": "1.7", "metadata": { "title": "Quarterly report", "author": "Example GmbH", "subject": null, "keywords": ["report", "2026"], "creator": null, "producer": "Dynamic Document API (engine 2026.4)", "created": "2026-09-17T15:06:34Z", "modified": "2026-09-17T15:06:34Z", "lang": "en-GB" } } } ``` | Field | Description | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pages` | Number of pages | | `page_sizes` | Runs of consecutive pages shown at the same size, in millimetres with at most one decimal. The size is the page's crop box, else its media box, with the page's rotation applied. | | `encrypted` | `true` for any encryption, also for restrictions without a password | | `pdf_version` | The PDF version the file declares, such as `1.7` | | `metadata` | `title`, `author`, `subject`, `keywords` (a list, split at commas and semicolons), `creator`, `producer`, `created` and `modified` (UTC timestamps) and `lang`. Values the file doesn't have are `null`. | A read works on encrypted PDFs. One that needs a password answers `encrypted: true` and `metadata: null`, with `pages` and `page_sizes` if they can be read without the password (otherwise `null` and `[]`). One that only restricts what you may do is read completely. Because a read has no file, `binary` delivery and storage destinations fail with `400 validation_error`, and your workspace's default storage destinations are skipped. A read is 1 billed render. ### Change the metadata [#change-the-metadata] ```bash curl https://api-eu.dynamicdocumentapi.com/v1/pdf-tools/metadata \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": { "upload_id": "upl_01J9ZQ4V6X8Z0B2D4F6H8K0M2P" }, "set": { "title": "Invoice 2026-0042", "author": "Example GmbH", "keywords": ["invoice", "2026"], "subject": "" }, "filename": "invoice-2026-0042.pdf" }' ``` | `set` field | Description | | ----------- | ------------------------------------------------------------------------ | | `title` | Document title, up to 500 characters | | `author` | Author, up to 500 characters | | `subject` | Subject, up to 500 characters | | `keywords` | Up to 50 keywords of up to 100 characters each | | `creator` | The application that created the original document, up to 500 characters | | `producer` | The application that produced the PDF, up to 500 characters | | `lang` | Document language, such as `de-DE`, up to 35 characters | Fields you leave out stay as they are. `""` or `null` removes a field, and `[]` or `null` removes the keywords; the example above removes `subject`. An empty `set` or an unknown field fails with `400 validation_error`. Don't confuse `set` with the top-level `metadata` field, which holds your own key-value pairs for the render. The values go into the document information and the XMP metadata alike. The modification date becomes the current time, and the creation date stays. In the XMP metadata, only the properties of the changed fields are replaced. Everything else stays, including the PDF/A identification and extension schemas such as Factur-X, so a PDF/A file or an e-invoice keeps its identification, and embedded files such as the invoice XML stay in place. A PDF without XMP metadata doesn't get any. The response holds the edited PDF and, in `result`, the information of the edited file, as a read returns it. Changing the metadata needs an unencrypted source. ## Convert to PDF/A [#convert-to-pdfa] `POST /v1/pdf-tools/pdfa` converts an existing PDF to PDF/A-2b or PDF/A-3b and validates the result with veraPDF. Send a `source` and a `level` (`2b` or `3b`); the report comes back in `result.conformance.pdfa`. A source that can't be made conformant, such as an encrypted one or one whose fonts aren't embedded, fails with `pdfa_conversion_failed`. The conversion needs the Growth plan or higher. [Convert an existing PDF](https://dynamicdocumentapi.com/docs/pdf-options#convert-an-existing-pdf) describes it in full. ## Delivery [#delivery] Tool calls deliver their files like renders: | `delivery.type` | Response | Size limit | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | | `url` | The render object. Each file has a signed `url` and `url_expires_at`. | none | | `binary` | The file as the response body, with `Content-Type`, `Content-Disposition`, `X-Render-Id`, `X-Pages` and `X-Billed-Renders` headers. Only for calls with exactly one output file. | 20 MB | | `base64` | The render object, with each file's content in `content_base64` | 10 MB for all files together | | `none` | The render object without file URLs, for files that go to your storage destinations | none | * Without `delivery.type`, a call gets `url` when it keeps a hosted copy and `none` when it doesn't, as for [renders](https://dynamicdocumentapi.com/docs/renders#delivery-types). * `binary` and `base64` need `mode: "sync"`. * A split into several files and a metadata read can't use `binary`: the request fails with `400 validation_error` at `/delivery/type`. * Files over the size limit fail with `413 output_too_large`; use `url` delivery for them. * `expires_in`, `retention_days`, [zero-retention delivery](https://dynamicdocumentapi.com/docs/renders#zero-retention-delivery) and [storage destinations](https://dynamicdocumentapi.com/docs/storage) work as for renders. Your workspace's default storage destinations receive tool outputs too, except metadata reads. * With zero retention, read metadata with `mode: "sync"` and `base64` delivery. The information comes back in the response, and the stored render keeps no `metadata` in its `result`. ## Sync, async and webhooks [#sync-async-and-webhooks] Calls run like renders: * **Sync** (the default): the API waits for the result up to your plan's sync timeout. If the call is still running then, the API answers `202 Accepted` and the call continues. With `timeout_behavior: "cancel"`, the call is canceled instead and the answer is `408 render_timeout`. * **Async**: with `mode: "async"`, the API answers `202 Accepted` at once, with the render object and a `Location: /v1/renders/{id}` header. Wait for a webhook or poll `GET /v1/renders/{id}`. * **Webhooks**: tool calls send `render.succeeded` and `render.failed` to a per-request `webhook` and to your [webhook endpoints](https://dynamicdocumentapi.com/docs/webhooks) that subscribe to these events, except endpoints limited to certain templates with `template_ids`. `data.object` is the render object. * **Idempotency**: a retry with the same `Idempotency-Key` and body within 24 hours returns the first response and creates no second render. See [Idempotency](https://dynamicdocumentapi.com/docs/renders#idempotency). * **Limits**: calls count towards your plan's [rate limits](https://dynamicdocumentapi.com/docs/renders#rate-limits) and concurrent sync renders. Test calls also count towards your plan's test renders per minute; see [Test mode](#test-mode). * **Render log**: `GET /v1/renders?input_type=pdf_tool` lists tool calls, together with the renders that build a batch's ZIP file and merged PDF. The [other render endpoints](https://dynamicdocumentapi.com/docs/renders#other-render-endpoints) work for tool calls too. An async split into one file per page, packed as a ZIP file, with a per-request webhook: ```bash curl -i https://api-eu.dynamicdocumentapi.com/v1/pdf-tools/split \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "source": { "url": "https://example.com/files/scans.pdf" }, "every": 1, "zip": true, "mode": "async", "webhook": { "url": "https://example.com/webhooks/documents", "events": ["render.succeeded", "render.failed"] } }' ``` ```http HTTP/1.1 202 Accepted Location: /v1/renders/rnd_01JA2B3C4D5E6F7G8H9J0K1M2N Content-Type: application/json {"id": "rnd_01JA2B3C4D5E6F7G8H9J0K1M2N", "object": "render", "status": "queued", "mode": "async", "input_type": "pdf_tool", "kind": "pdf_split"} ``` The response body above is shortened. ## Encrypted sources [#encrypted-sources] What happens with an encrypted source depends on the tool: | Tool | Encrypted source | | -------------------------------------------------- | ------------------------------------------------------------------------------------------- | | Merge, split, rotate, protect, metadata with `set` | Fails with `pdf_password_required` and the message `The PDF is encrypted; unlock it first.` | | Unlock | Decrypted with the `password` you send | | Metadata read | Read, with `encrypted: true` | | PDF/A conversion | Fails with `pdfa_conversion_failed` | Encrypted means any encryption. A PDF that opens without a password but restricts printing, copying or editing counts too, because rewriting it would drop the restrictions. Unlock such a file first, with its owner password or `"password": ""`, then pass the unlocked render to the next tool. ## Errors [#errors] The API checks the request before anything runs: | Status | Code | When | | ------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `validation_error` | A field is missing or invalid, for example a source without exactly one field, a `url` that isn't a public `https` address, a malformed page list, both `ranges` and `every`, or `binary` delivery for several files. `errors[]` points at the field. | | 402 | `plan_feature_unavailable` | The PDF/A conversion, or storage destinations, aren't part of your plan | | 402 | `render_limit_reached` | Your workspace has no renders left | | 404 | `render_not_found`, `upload_not_found` | The render or upload isn't in your workspace, or the upload has expired | | 410 | `file_expired` | The render has no PDF file to read | | 413 | `payload_too_large` | The request body is larger than your plan allows, or a data URI holds more than 5 MB | | 429 | `rate_limited` | Too many requests, or more test calls per minute than your plan allows. Wait for the `Retry-After` header. | Problems found while the tool runs fail the call. A sync call answers `422` with the code, the message and `render_id`; an async call ends with `status: "failed"` and the code in `error`. Failed calls cost nothing. | Code | When | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | | `invalid_pdf_source` | A source isn't a readable PDF, or has no pages. In a merge, the message names the source, such as `source 2`. | | `pdf_password_required` | A source is encrypted, or the unlock password is wrong. See [Encrypted sources](#encrypted-sources). | | `validation_error` | A page list doesn't fit the document, or `every` would make more than 200 files. The message says which page or how many files. | | `page_limit_exceeded` | A source, or the merged PDF, has more pages than your plan allows per PDF. A metadata read has no page limit. | | `url_not_allowed` | A source `url` leads to a blocked address | | `asset_fetch_failed` | A source `url` couldn't be downloaded: an error status, a download that takes longer than 30 seconds, or a file over 100 MB | | `payload_too_large` | A render's file used as a source is larger than 100 MB | | `pdfa_conversion_failed` | The PDF/A conversion failed. See [Convert an existing PDF](https://dynamicdocumentapi.com/docs/pdf-options#convert-an-existing-pdf). | | `render_timeout` | The call ran longer than your plan allows | See [Errors](https://dynamicdocumentapi.com/docs/errors) for every other code. ## Test mode [#test-mode] Calls with a test key, or with `"test": true`, are test calls, as in [test mode](https://dynamicdocumentapi.com/docs/authentication#test-mode) for renders. They cost nothing, and every PDF they return carries the "TEST" watermark. Use a live key for files you keep or send. * The watermark is on the merged PDF, on each part of a split and each PDF in its ZIP file, and on the rotated, protected, unlocked, edited or converted PDF. * Protect adds the watermark before it encrypts the PDF. * The PDF/A conversion adds it before it converts. It is drawn as vector outlines, so a test PDF/A file is conformant too, and the report in `result.conformance.pdfa` describes the file you receive. * Unlocking an unencrypted PDF returns it with the watermark, not unchanged. * A metadata read returns no file, so there is nothing to mark. Its `result` describes your source. * A source that carries the watermark already, such as a test render, gets it a second time, so the mark looks a little darker. Test calls count towards your plan's test renders per minute (see [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits#limits-by-plan)), together with test renders. A call beyond the limit fails with `429 rate_limited` and a `Retry-After` header. Requests the API rejects before the call runs, such as a `400 validation_error`, don't count. ## Billing [#billing] Each successful call is 1 billed render, whatever the number of pages, sources or output files. Merging 200 PDFs, splitting a document into 50 files, reading metadata and unlocking a PDF that wasn't encrypted are 1 render each. Failed calls, test calls and calls canceled before they started cost nothing. The render's `billed_renders` field and the `X-Billed-Renders` header of binary responses show the number. See [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits) for the render table. ## Related pages [#related-pages] * [Renders](https://dynamicdocumentapi.com/docs/renders) for the render object, sync and async, delivery and idempotency * [PDF options](https://dynamicdocumentapi.com/docs/pdf-options) for protection, metadata and PDF/A when you render a new PDF * [Webhooks](https://dynamicdocumentapi.com/docs/webhooks) for `render.succeeded` and `render.failed` events * [Storage](https://dynamicdocumentapi.com/docs/storage) for delivering files to your own bucket * [Batches](https://dynamicdocumentapi.com/docs/batches) for rendering many documents at once, combined into one ZIP file or one merged PDF * [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits) for pages per PDF and the render table * [Errors](https://dynamicdocumentapi.com/docs/errors) for every error code * [API reference](https://dynamicdocumentapi.com/docs/api-reference) for the full request and response schemas # Plans and limits (/docs/plans-and-limits) > Plan prices and included renders, the render table, auto top-ups, the monthly top-up limit and render packs, payments, and every rate, size and timeout limit per plan. Usage is measured in renders: one render is one generated document or image of up to 50 pages. Each plan includes a monthly number of renders and a set of technical limits. The [pricing page](https://dynamicdocumentapi.com/pricing/) shows what each plan includes and what one render costs on each. ## Plans [#plans] Prices are in euros (€) and exclude VAT, which Stripe adds at checkout based on your location. Annual billing costs 20 % less and is shown here as the effective monthly price. | Plan | Monthly | Annual (per month) | Renders per month | Auto top-up (1,000 renders) | Templates | Members | File retention (maximum) | | ---------- | ----------- | ------------------ | ----------------- | --------------------------- | --------- | --------- | ------------------------ | | Free | €0 | €0 | 50 | not available | 2 | 1 | 7 days | | Starter | €19 | €15 | 3,000 | €6.00 | unlimited | 3 | 90 days | | Growth | €59 | €47 | 15,000 | €4.00 | unlimited | 10 | 1 year | | Pro | €149 | €119 | 50,000 | €3.00 | unlimited | 25 | forever | | Scale | €399 | €319 | 200,000 | €2.00 | unlimited | unlimited | forever | | Enterprise | from €1,500 | custom | 1,000,000 and up | from €1.20 | unlimited | unlimited | forever | PDFs and images are available on every plan. Every paid plan includes the whole generation feature set: templates and versioning, webhooks, test mode, zero-retention delivery, [signed links](https://dynamicdocumentapi.com/docs/signed-links), batches and uploads to [your own storage](https://dynamicdocumentapi.com/docs/storage). From Growth, plans also include compliance output: [PDF/A](https://dynamicdocumentapi.com/docs/pdf-options#pdfa), [PDF/UA](https://dynamicdocumentapi.com/docs/pdf-options#pdfua) and [e-invoices](https://dynamicdocumentapi.com/docs/e-invoicing), and [email delivery](https://dynamicdocumentapi.com/docs/email-delivery) through your own email provider. Higher plans add organisational capacity: more members, longer log retention and higher technical limits. The Free plan stops when its 50 renders are used; it has no auto top-ups. ## What counts as a render [#what-counts-as-a-render] | Operation | Billed renders | | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- | | PDF render (template, HTML, URL, Markdown or canvas), up to 50 pages | 1 | | Each additional 50 pages of the same PDF | +1 | | Image render (PNG, JPEG or WebP) | 1 | | [PDF tool](https://dynamicdocumentapi.com/docs/pdf-tools) call (`POST /v1/pdf-tools/…`): merge, split, rotate, protect, unlock, metadata or PDF/A conversion | 1 | | [E-invoice](https://dynamicdocumentapi.com/docs/e-invoicing) layer on top of a PDF render (Factur-X, ZUGFeRD, XRechnung) | included (+0) | | E-invoice XML export (`POST /v1/einvoices`) | 1 | | Batch ZIP download | 0 (the renders inside are already billed) | | Batch merged PDF ([batches](https://dynamicdocumentapi.com/docs/batches)) | 1 | | [Signed-link](https://dynamicdocumentapi.com/docs/signed-links) render: cache miss / cache hit | 1 / 0 | | Test renders, editor previews, failed and canceled renders | 0 | Examples: a 12-page invoice is 1 render, and so is the same invoice as a Factur-X e-invoice; a 120-page report is 3 renders (50 + 50 + 20 pages); a social image in two sizes is 2 renders. PDF/A output on a render costs nothing extra. Nothing is billed for renders that fail, renders canceled before rendering started, or renders rejected with `page_limit_exceeded`. The number billed is recorded per render in the render object's `billed_renders` field and in the `X-Billed-Renders` header of binary responses. Included renders reset at the start of each billing cycle and don't roll over. Rollover of unused renders on annual plans is planned. ## Auto top-up and monthly top-up limit [#auto-top-up-and-monthly-top-up-limit] Paid plans are never billed for overage. Instead they use **auto top-up**: when fewer than 200 renders (the default; high-volume workspaces can ask support for a higher number) are left in your workspace (included renders plus any prepaid renders), we add 1,000 renders for your plan's top-up price excl. VAT so your renders keep working: €6 on Starter, €4 on Growth, €3 on Pro and €2 on Scale. Stripe (via Link) charges your card within about 24 hours. Unused renders roll over and expire after 12 months. The renders are available at once, and you get an email for every top-up. The API calls auto top-ups `render_blocks`. * Renders are drawn from the included allowance first, then from prepaid renders with the earliest expiry. * A new top-up is only added after the previous one has been paid. If a payment fails, no further top-ups are added until it succeeds. * Top-ups already bought keep their renders and price when you change plans. The **monthly top-up limit** is the most your workspace can spend per cycle on the plan plus auto top-ups. It defaults to twice your plan's monthly price and you can change it; `0` turns auto top-ups off. On Growth billed monthly, for example, the default limit of €118 leaves €59 for top-ups: it allows up to 14 top-ups, or 14,000 extra renders, per cycle. Billed annually, the limit is €94, which leaves €47, or 11 top-ups. The API calls it the spend cap (`spend_cap_cents`). When no top-up can be added (the monthly top-up limit is reached, auto top-ups are off, or a top-up payment is outstanding) and the renders run out, live renders fail with `402 render_limit_reached`; test keys continue to work. You are notified by email, and by the `usage.threshold_reached` webhook event, at 50 %, 80 % and 100 % of the included renders and when the monthly top-up limit stops further top-ups. ### Render packs (Free plan) [#render-packs-free-plan] Render packs are prepaid renders for the Free plan: 10,000 renders for €50, 50,000 for €200 and 250,000 for €750 (excluding VAT), each valid for 12 months. Packs bought on Free stay usable after you upgrade. Paid plans top up automatically instead and cannot buy packs. ## Payments, receipts and tax [#payments-receipts-and-tax] Payments are processed by Stripe. Link (by Stripe) is the seller of record: your receipts and invoices come from Link, and card statements show `LINK.COM*`. All prices exclude VAT; Stripe calculates the applicable VAT or sales tax from your location, adds it at checkout and collects it. You are charged in euros. Enter your VAT or other tax ID at Stripe Checkout; it then appears on your invoices. ## Check your usage [#check-your-usage] `GET /v1/account` returns the plan, the current cycle, renders and the limits that apply to your workspace: ```json { "workspace": { "id": "ws_01J9ZK0P2R4T6V8X0Z2B4D6F8H", "name": "Example GmbH" }, "plan": { "code": "growth", "interval": "month" }, "cycle": { "start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z" }, "renders": { "included": 15000, "used": 4211, "remaining_included": 10789, "prepaid_remaining": 0, "spend_cap_cents": 11800 }, "limits": { "rate_limit_rps": 20, "sync_concurrency": 20, "sync_timeout_s": 60, "max_payload_mb": 10, "max_pages": 500, "bulk_concurrency": 10 }, "regions": ["eu"] } ``` `prepaid_remaining` counts the renders left in render packs and auto top-ups. `spend_cap_cents` is the monthly top-up limit in euro cents, excluding VAT; `0` means auto top-ups are off. `GET /v1/usage?from=2026-09-01&to=2026-09-30&granularity=day&group_by=template_id` returns a time series of billed renders and render counts, which is handy for internal cost dashboards. Both endpoints need the `account:read` scope. ## Limits by plan [#limits-by-plan] | Limit | Free | Starter | Growth | Pro | Scale | Enterprise | | ----------------------------------------- | ------ | ------- | ------- | -------- | --------- | ---------- | | Rate limit (sustained / burst per second) | 2 / 5 | 10 / 20 | 20 / 40 | 50 / 100 | 150 / 300 | custom | | Concurrent sync renders | 2 | 10 | 20 | 50 | 150 | custom | | Sync timeout | 30 s | 60 s | 60 s | 90 s | 120 s | 120 s | | Async maximum duration | 60 s | 5 min | 10 min | 15 min | 30 min | custom | | Request body | 2 MB | 10 MB | 10 MB | 20 MB | 50 MB | custom | | Pages per PDF | 20 | 200 | 500 | 1,000 | 2,000 | custom | | Remote assets per render | 50 | 200 | 200 | 500 | 1,000 | custom | | Bulk concurrency | — | 2 | 10 | 20 | 50 | custom | | Batch items | — | 500 | 2,000 | 10,000 | 50,000 | custom | | File retention (maximum) | 7 days | 90 days | 1 year | forever | forever | forever | | Request log retention | — | 7 days | 7 days | 30 days | 90 days | custom | | Test renders per minute | 10 | 60 | 60 | 120 | 300 | custom | | Team members | 1 | 3 | 10 | 25 | unlimited | unlimited | | PDF/A, PDF/UA and e-invoicing | — | — | ✓ | ✓ | ✓ | ✓ | The template engine's execution budget also scales with the plan, from 50,000 units on Free to 20 million on Scale. See [sandbox and limits](https://dynamicdocumentapi.com/docs/template-language#sandbox-and-limits). One person can own up to two workspaces on the Free plan. Paid workspaces don't count, but a free workspace you deleted in the last 30 days still does, and so does one you take over from someone else. To add another, upgrade one of your workspaces to a paid plan. ## Limits on every plan [#limits-on-every-plan] | Limit | Value | | ----------------------------------- | --------------------------------------------------- | | `metadata` | 50 keys per render, values up to 500 characters | | `Idempotency-Key` | 255 characters; responses are replayed for 24 hours | | `binary` delivery | 20 MB | | `base64` delivery | 10 MB | | Signed URL lifetime | 60 seconds to 7 days, 1 hour by default | | `data_url` document | 50 MB | | Rendered HTML per template | 50 MB | | Template recursion depth | 50 | | Fixed wait delay (`wait.delay_ms`) | 10 seconds | | Custom headers per webhook endpoint | 10 | | Webhook response time | 10 seconds | | Webhook delivery log | 30 days | | Uploaded files | 50 MB each, kept for 24 hours | ## What happens when you hit a limit [#what-happens-when-you-hit-a-limit] | Limit | Response | What to do | | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | Free renders used up, or no auto top-up can be added (monthly top-up limit reached, auto top-ups off, top-up payment outstanding) | `402 render_limit_reached` | Free: upgrade, buy a render pack or wait for the next cycle. Paid: raise the monthly top-up limit or settle the open payment | | Requests per second | `429 rate_limited` with `Retry-After` | Back off and spread requests out | | Concurrent sync renders | `429 concurrency_limited` with `Retry-After` | Lower parallelism or switch to `mode: "async"` | | Sync timeout | `202` with the render still running, or `408 render_timeout` with `timeout_behavior: "cancel"` | Poll the render or wait for the webhook | | Async maximum duration | The render ends as `failed` with `render_timeout` | Simplify the document or split it | | Request body | `413 payload_too_large` | Send less data, or use `data_url` | | Output size for the delivery type | `413 output_too_large` | Use `url` delivery | | Pages per PDF | `422 page_limit_exceeded`, nothing billed | Check the data volume, split the document, or upgrade | | Remote assets per render | Further asset requests are blocked and reported as warnings | Reduce the number of remote assets or inline them | | Template execution budget | `422 template_fuel_exhausted` | Simplify loops or precompute values | | Test renders per minute | `429 rate_limited` | Slow down test loops in CI | See [Errors](https://dynamicdocumentapi.com/docs/errors) for the full list of codes and fixes. # Quickstart (/docs/quickstart) > Create a test API key, render your first PDF from HTML, get a signed download URL and render a stored template by ID. This guide takes about five minutes. You will create a test key, render a PDF from HTML, get a signed download link, and then render a template stored in your workspace. The examples use cURL, TypeScript with the built-in `fetch` of Node.js 18 or later, and Python with the `requests` package. ## 1. Create an account and a test key [#1-create-an-account-and-a-test-key] 1. Sign up at [https://app.dynamicdocumentapi.com](https://app.dynamicdocumentapi.com). 2. Open **API keys** and create a key in **test** mode. 3. Copy the key. It is shown only once. Test keys work before you verify your email address. Test renders are free, carry a "TEST" watermark and are deleted after 24 hours, so you can experiment without using any of your renders. ## 2. Export the key [#2-export-the-key] Keep the key out of your source code. The examples read it from an environment variable: ```bash export DYNAMIC_DOCUMENT_API_KEY="dda_test_…" ``` ## 3. Render your first PDF [#3-render-your-first-pdf] This request renders an HTML snippet with one variable and returns the PDF itself as the response body (`delivery.type: "binary"`): cURL: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/renders \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "input": { "type": "html", "html": "<h1>Hello {{ name }}</h1>" }, "data": { "name": "World" }, "output": { "format": "pdf", "pdf": { "paper": { "size": "A4" } } }, "delivery": { "type": "binary" } }' \ -o hello.pdf ``` TypeScript: ```ts import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; const response = await fetch("https://api-eu.dynamicdocumentapi.com/v1/renders", { method: "POST", headers: { Authorization: `Bearer ${process.env.DYNAMIC_DOCUMENT_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": randomUUID(), }, body: JSON.stringify({ input: { type: "html", html: "<h1>Hello {{ name }}</h1>" }, data: { name: "World" }, output: { format: "pdf", pdf: { paper: { size: "A4" } } }, delivery: { type: "binary" }, }), }); if (!response.ok) throw new Error(`Render failed (${response.status}): ${await response.text()}`); await writeFile("hello.pdf", Buffer.from(await response.arrayBuffer())); console.log(`Saved hello.pdf: ${response.headers.get("X-Pages")} page(s), ${response.headers.get("X-Billed-Renders")} billed render(s)`); ``` Python: ```python import os import uuid import requests response = requests.post( "https://api-eu.dynamicdocumentapi.com/v1/renders", headers={ "Authorization": f"Bearer {os.environ['DYNAMIC_DOCUMENT_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "input": {"type": "html", "html": "<h1>Hello {{ name }}</h1>"}, "data": {"name": "World"}, "output": {"format": "pdf", "pdf": {"paper": {"size": "A4"}}}, "delivery": {"type": "binary"}, }, timeout=90, ) response.raise_for_status() with open("hello.pdf", "wb") as f: f.write(response.content) print(f"Saved hello.pdf: {response.headers['X-Pages']} page(s)") ``` Save the TypeScript example as `hello.mjs` and run it with `node hello.mjs`. Open `hello.pdf`: it says "Hello World" and carries the test watermark. What happened: * `input` describes what to render. The template engine replaced `{{ name }}` with the value from `data`. * `output` selects the format and [PDF options](https://dynamicdocumentapi.com/docs/pdf-options) such as the paper size. * `delivery.type: "binary"` returns the file as the response body. The `X-Render-Id`, `X-Pages` and `X-Billed-Renders` response headers describe the render. * The `Idempotency-Key` header makes retries safe: repeating the request with the same key returns the original result instead of rendering again. > **If the request fails** > > Errors are returned as JSON problem details with a `code` such as `invalid_api_key` or `validation_error`. With cURL, the error body ends up in `hello.pdf`, so open it in a text editor. See [Errors](https://dynamicdocumentapi.com/docs/errors) for every code. ## 4. Get a signed download URL [#4-get-a-signed-download-url] For most applications it's more convenient to store the file and hand out a link. With `delivery.type: "url"` (the default whenever we keep a [hosted copy](https://dynamicdocumentapi.com/docs/storage#hosted-copy)), the response is a render object whose files have a signed, expiring URL: cURL: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/renders \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "input": { "type": "html", "html": "<h1>Hello {{ name }}</h1>" }, "data": { "name": "World" }, "output": { "format": "pdf", "filename": "hello.pdf" }, "delivery": { "type": "url", "expires_in": 3600 } }' ``` TypeScript: ```ts import { randomUUID } from "node:crypto"; const response = await fetch("https://api-eu.dynamicdocumentapi.com/v1/renders", { method: "POST", headers: { Authorization: `Bearer ${process.env.DYNAMIC_DOCUMENT_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": randomUUID(), }, body: JSON.stringify({ input: { type: "html", html: "<h1>Hello {{ name }}</h1>" }, data: { name: "World" }, output: { format: "pdf", filename: "hello.pdf" }, delivery: { type: "url", expires_in: 3600 }, }), }); if (!response.ok) throw new Error(`Render failed (${response.status}): ${await response.text()}`); const render = await response.json(); console.log(render.status, render.files[0].url); ``` Python: ```python import os import uuid import requests response = requests.post( "https://api-eu.dynamicdocumentapi.com/v1/renders", headers={ "Authorization": f"Bearer {os.environ['DYNAMIC_DOCUMENT_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "input": {"type": "html", "html": "<h1>Hello {{ name }}</h1>"}, "data": {"name": "World"}, "output": {"format": "pdf", "filename": "hello.pdf"}, "delivery": {"type": "url", "expires_in": 3600}, }, timeout=90, ) response.raise_for_status() render = response.json() print(render["status"], render["files"][0]["url"]) ``` The response looks like this (shortened): ```json { "id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q", "object": "render", "status": "succeeded", "mode": "sync", "test": true, "region": "eu", "input_type": "html", "output_format": "pdf", "files": [ { "id": "file_01J9ZM4T6W8Y0A2C4E6G8J0K2M", "format": "pdf", "filename": "hello.pdf", "bytes": 18342, "pages": 1, "url": "https://dda-eu-outputs.nbg1.your-objectstorage.com/…", "url_expires_at": "2026-09-17T16:06:34Z" } ], "billed_renders": 0, "created_at": "2026-09-17T15:06:33Z", "completed_at": "2026-09-17T15:06:34Z" } ``` Treat the URL as opaque and don't store it: it stops working at `url_expires_at`. Call `GET /v1/renders/{id}` to get a fresh URL for as long as the file is retained. The [render object](https://dynamicdocumentapi.com/docs/renders#the-render-object) reference describes every field. ## 5. Render a stored template [#5-render-a-stored-template] Inline HTML is useful for experiments. In production you usually keep the design in a template, so that designers can change it without a deployment and every change is versioned. 1. In the dashboard, create a template or start from one in the [gallery](https://dynamicdocumentapi.com/templates). 2. Publish it. Publishing creates version `1` and makes it the `live` version. 3. Copy the template ID (`tpl_…`) from the editor. Then render it with your data: cURL: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/renders \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: invoice-2026-0042" \ -H "Content-Type: application/json" \ -d '{ "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, "data": { "number": "2026-0042", "customer": { "name": "Example GmbH" }, "items": [ { "description": "Consulting", "quantity": 2, "unit_price": 450 } ] }, "output": { "format": "pdf", "filename": "invoice-{{ data.number }}.pdf" } }' ``` TypeScript: ```ts const response = await fetch("https://api-eu.dynamicdocumentapi.com/v1/renders", { method: "POST", headers: { Authorization: `Bearer ${process.env.DYNAMIC_DOCUMENT_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "invoice-2026-0042", }, body: JSON.stringify({ input: { type: "template", template_id: "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, data: { number: "2026-0042", customer: { name: "Example GmbH" }, items: [{ description: "Consulting", quantity: 2, unit_price: 450 }], }, output: { format: "pdf", filename: "invoice-{{ data.number }}.pdf" }, }), }); if (!response.ok) throw new Error(`Render failed (${response.status}): ${await response.text()}`); const render = await response.json(); console.log(render.files[0].url); ``` Python: ```python import os import requests response = requests.post( "https://api-eu.dynamicdocumentapi.com/v1/renders", headers={ "Authorization": f"Bearer {os.environ['DYNAMIC_DOCUMENT_API_KEY']}", "Idempotency-Key": "invoice-2026-0042", }, json={ "input": {"type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C"}, "data": { "number": "2026-0042", "customer": {"name": "Example GmbH"}, "items": [{"description": "Consulting", "quantity": 2, "unit_price": 450}], }, "output": {"format": "pdf", "filename": "invoice-{{ data.number }}.pdf"}, }, timeout=90, ) response.raise_for_status() print(response.json()["files"][0]["url"]) ``` The keys in `data` must match the variables your template uses. `GET /v1/templates/{id}/schema` returns the JSON Schema of the data a template expects. A few variations: * Pin a version with `"version": 3` in `input`. Without it, the `live` version is used, so publishing a new version changes future renders. * Render the unpublished draft with `"version": "draft"` and a test key. * Use the shorter convenience endpoint `POST /v1/pdf/from-template`, which takes `template_id`, `data` and PDF options at the top level. See [convenience endpoints](https://dynamicdocumentapi.com/docs/renders#convenience-endpoints). * Here the idempotency key is derived from the invoice number, so a retried request can never produce a second invoice PDF. ## 6. Go live [#6-go-live] Before you switch to a live key, work through this list: 1. **Verify your email address.** Live keys can't render until it is verified. 2. **Create a live key with only the scopes you need.** A backend that renders and downloads documents needs `render:write` and `renders:read`. Restrict the key to your servers' IP ranges where possible. 3. **Store the key as a secret.** Use your platform's secret manager or environment variables, and never send the key to browsers or mobile apps. 4. **Choose a plan and review your monthly top-up limit.** See [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits). 5. **Send an `Idempotency-Key` with every POST request**, and reuse the same key when you retry. 6. **Handle errors deliberately.** Retry `429` and `5xx` responses after `Retry-After`, fix `4xx` requests instead of retrying them, and log the `request_id`. See [Errors](https://dynamicdocumentapi.com/docs/errors). 7. **Use async mode and webhooks for long renders.** Anything that may exceed your plan's sync timeout should use `mode: "async"`. [Verify webhook signatures](https://dynamicdocumentapi.com/docs/webhooks#verify-signatures). 8. **Decide how long files live.** Set a workspace retention period, use `delivery.expires_in` for link lifetimes, or use zero-retention delivery for sensitive data. 9. **Subscribe to status updates** at [https://status.dynamicdocumentapi.com](https://status.dynamicdocumentapi.com). # Renders (/docs/renders) > Create PDFs and images with POST /v1/renders, from the request body and sync or async modes to delivery, idempotency, listing and rate limits. A render is one document generation job. Every PDF, image and HTML output is created through the same resource, `POST /v1/renders`, whatever the input: a stored template, raw HTML, a web page URL or Markdown. ## Create a render [#create-a-render] cURL: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/renders \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: invoice-2026-0042" \ -H "Content-Type: application/json" \ -d '{ "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", "version": "live" }, "data": { "number": "2026-0042", "customer_id": "c_981", "items": [ { "sku": "A1", "qty": 2 } ] }, "output": { "format": "pdf", "filename": "invoice-{{ data.number }}.pdf" }, "delivery": { "type": "url", "expires_in": 3600 }, "reference": "inv_2026_0042", "metadata": { "customer_id": "c_981" } }' ``` TypeScript: ```ts const response = await fetch("https://api-eu.dynamicdocumentapi.com/v1/renders", { method: "POST", headers: { Authorization: `Bearer ${process.env.DYNAMIC_DOCUMENT_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "invoice-2026-0042", }, body: JSON.stringify({ input: { type: "template", template_id: "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", version: "live" }, data: { number: "2026-0042", customer_id: "c_981", items: [{ sku: "A1", qty: 2 }] }, output: { format: "pdf", filename: "invoice-{{ data.number }}.pdf" }, delivery: { type: "url", expires_in: 3600 }, reference: "inv_2026_0042", metadata: { customer_id: "c_981" }, }), }); const render = await response.json(); ``` Python: ```python import os import requests response = requests.post( "https://api-eu.dynamicdocumentapi.com/v1/renders", headers={ "Authorization": f"Bearer {os.environ['DYNAMIC_DOCUMENT_API_KEY']}", "Idempotency-Key": "invoice-2026-0042", }, json={ "input": {"type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", "version": "live"}, "data": {"number": "2026-0042", "customer_id": "c_981", "items": [{"sku": "A1", "qty": 2}]}, "output": {"format": "pdf", "filename": "invoice-{{ data.number }}.pdf"}, "delivery": {"type": "url", "expires_in": 3600}, "reference": "inv_2026_0042", "metadata": {"customer_id": "c_981"}, }, timeout=130, ) render = response.json() ``` Possible responses: | Status | When | Body | | -------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `200 OK` | A sync render succeeded | The [render object](#the-render-object), or the file itself with `delivery.type: "binary"` | | `202 Accepted` | An async render was accepted, or a sync render was still running when the sync timeout was reached | The render object with status `queued` or `processing`, and a `Location: /v1/renders/{id}` header | | `408 Request Timeout` | A sync render reached the sync timeout and `timeout_behavior` is `cancel` | Problem details with code `render_timeout` | | `422 Unprocessable Entity` | A sync render failed, for example because of a template error or a page that couldn't be loaded | Problem details with `render_id` and the render error | | Other `4xx` and `5xx` | Invalid requests, authentication, limits, outages | Problem details; see [Errors](https://dynamicdocumentapi.com/docs/errors) | ## Request body [#request-body] | Field | Type | Default | Description | | ------------------ | ------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `input` | object | required | What to render. See [input types](#input-types). | | `data` | object | none | JSON data for the template engine | | `data_url` | string | none | URL to fetch the JSON data from instead of `data`. See [data](#data). | | `output` | object | PDF | Output format, filename and format options. See [output](#output). | | `delivery` | object | `url` delivery, or `none` without a hosted copy | How you receive the files. See [delivery](#delivery). | | `mode` | string | `sync` | `sync` waits for the result; `async` returns immediately. See [sync and async](#sync-and-async). | | `timeout_behavior` | string | continue | Set to `cancel` to cancel a sync render that reaches the sync timeout instead of letting it finish in the background | | `webhook` | object | none | Per-request webhook: `url` and `events`, for example `["render.succeeded", "render.failed"]`. The workspace needs a webhook endpoint, whose secret signs the delivery. See [Webhooks](https://dynamicdocumentapi.com/docs/webhooks#per-request-webhooks). | | `reference` | string | none | Your own identifier, such as an invoice number. Returned on the render and usable as a list filter. | | `metadata` | object | none | Up to 50 key-value pairs, values up to 500 characters. Returned on the render and usable as list filters. | | `engine` | string | template or workspace default | Engine channel to render with, for example `"2026.4"`. See [engine channels](#engine-channels). | | `priority` | string | `normal` | `normal` or `low`. `low` puts an async render in the lower-priority queue that batch items use; sync renders ignore it. A cheaper price for `low` is planned. | | `test` | boolean | `false` | Marks a test render. Test keys always create test renders, and `true` with a live key fails with `test_key_required`. | ### Input types [#input-types] Set `input.type` and the fields for that type. #### `template` [#template] Renders a template stored in your workspace. | Field | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `template_id` | Template ID (`tpl_…`), shown in the editor and returned by `GET /v1/templates` | | `version` | `"live"` (default) for the currently published version, a version number such as `7` to pin one, or `"draft"` for the unpublished draft (test keys; live keys only if your workspace settings allow it) | The template's stored output options apply, and anything you send in `output` overrides them. #### `html` [#html] Renders an HTML document you send in the request. | Field | Description | | ------------ | ---------------------------------------------------------------------------------------------------------------------- | | `html` | Body markup. The template language is available, for example `<h1>Hello {{ name }}</h1>`. | | `head` | Optional contents of `<head>`: `<style>`, `<link>`, `<script>` and `<meta>` elements | | `templating` | Whether to run the template engine on `html` and `head`. Defaults to `true` when `data` is present, otherwise `false`. | #### `url` [#url] Loads a web page and renders it. | Field | Description | | ----------------- | ------------------------------------------------------------------ | | `url` | The page to render | | `http.headers` | Extra request headers, for example `{"Authorization": "Bearer …"}` | | `http.cookies` | Cookies to set, each with `name`, `value` and `domain` | | `http.basic_auth` | `username` and `password` for HTTP basic authentication | | `http.user_agent` | A custom `User-Agent` string | Headers, cookies and basic authentication are only sent to the same registrable domain as `url`, not to third-party assets on the page. Pages and assets must be publicly reachable over the standard ports (80, 443, 8080 and 8443): private and internal addresses are blocked with `url_not_allowed`. Blocking ads and trackers and hiding elements such as cookie banners are planned. #### `markdown` [#markdown] Converts GitHub Flavored Markdown (tables, task lists, footnotes, strikethrough and autolinks) to a styled document. | Field | Description | | ---------------- | -------------------------------------------------------------------------------------------------------- | | `markdown` | Markdown source. The template language runs first, so expressions can produce Markdown. | | `theme` | `default`, `github`, `academic`, `minimal` or `none` | | `css` | Additional CSS applied after the theme | | `templating` | Run the template language on the Markdown first. On by default when the request has `data` or `data_url` | | `allow_raw_html` | `true` keeps HTML written inside the Markdown exactly as it is, without sanitizing. Default `false` | By default the converted HTML is sanitized: harmless tags such as `<br>` or `<span>` stay, while scripts, event handlers and other active content are removed. With `allow_raw_html: true` nothing is sanitized and scripts in the Markdown run, so use it only for Markdown you wrote yourself, never for text from users or a language model. Markdown templates stored in your workspace are always sanitized. ### Data [#data] Keys in `data` become top-level variables in the template: with `"data": {"customer": {"name": "Example GmbH"}}`, the template uses `{{ customer.name }}`. In `output.filename`, the data is available under `data`, as in `invoice-{{ data.number }}.pdf`. For template renders you can send `data_url` instead. The API fetches the JSON document from that URL (up to 50 MB) through the same network policy as page assets. The URL must be on the template's list of allowed data sources, and a failed fetch fails the render with `data_url_fetch_failed`. The size of the request body is limited by plan, from 2 MB on Free to 50 MB on Scale. Templates can enforce a JSON Schema for their data; a mismatch fails with `422 data_schema_mismatch`, and `errors[]` lists each problem with a JSON Pointer path. ### Output [#output] | Field | Description | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `format` | `pdf`, `png`, `jpeg`, `webp` or `html` | | `filename` | File name, with template expressions allowed, for example `invoice-{{ data.number }}.pdf` | | `pdf` | [PDF options](https://dynamicdocumentapi.com/docs/pdf-options) such as paper size, margins, headers and footers, PDF/UA, PDF/A and attachments | | `image` | [Image options](https://dynamicdocumentapi.com/docs/image-options) such as viewport, scale and quality | | `einvoice` | Turns the PDF into an e-invoice: Factur-X, ZUGFeRD or XRechnung, with the invoice XML embedded. Replaces a template's [default e-invoice](https://dynamicdocumentapi.com/docs/e-invoicing#make-a-template-an-e-invoice-by-default); `null` turns that default off. See [E-invoicing](https://dynamicdocumentapi.com/docs/e-invoicing). | The `html` format returns the rendered HTML document. For standalone e-invoice XML, use `POST /v1/einvoices`, described in [E-invoicing](https://dynamicdocumentapi.com/docs/e-invoicing#export-xrechnung-or-en-16931-xml). ### Delivery [#delivery] | Field | Default | Description | | ---------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | `url`, or `none` without a hosted copy | `url`, `binary`, `base64` or `none`. Without a hosted copy, `url` fails with `400 validation_error`. See [receiving files](#receiving-files). | | `expires_in` | `3600` | Lifetime of signed URLs in seconds, from 60 seconds to 7 days | | `retention_days` | workspace setting | How long the hosted file is kept, up to your plan's maximum | | `retention` | none | `"none"` for [zero-retention delivery](#zero-retention-delivery) | | `hosted` | `true`, unless your destinations keep no copy | `false` skips the hosted copy and only uploads to your [storage destinations](https://dynamicdocumentapi.com/docs/storage). When you leave it out, it is `false` if every destination of the render (those in `storage`, else your default destination) has `keep_hosted_copy: false`. Zero-retention renders never keep one. See [Hosted copy](https://dynamicdocumentapi.com/docs/storage#hosted-copy). | | `storage` | your default destination | Uploads to your own storage buckets: up to 10 entries, each with a `destination_id` and an optional `path`. `[]` uploads nowhere. See [Your own storage](https://dynamicdocumentapi.com/docs/storage). | | `email` | the template's rules | Email rules to run: `false` for none, or a list such as `[{"rule_id": "emr_…"}]` for only those. See [Email delivery](https://dynamicdocumentapi.com/docs/email-delivery). | ## Sync and async [#sync-and-async] ### Sync mode [#sync-mode] `mode: "sync"` is the default. The API waits for the render and responds with the result, up to your plan's sync timeout: | Plan | Sync timeout | Async maximum duration | | ---------- | ------------ | ---------------------- | | Free | 30 s | 60 s | | Starter | 60 s | 5 min | | Growth | 60 s | 10 min | | Pro | 90 s | 15 min | | Scale | 120 s | 30 min | | Enterprise | 120 s | custom | If a sync render is still running when the timeout is reached, the API responds with `202 Accepted` and the render continues in the background, exactly like an async render. Set `timeout_behavior: "cancel"` if you'd rather have the render canceled; the response is then `408` with the code `render_timeout`. Set your HTTP client's timeout a little above your plan's sync timeout so that you receive the `202` response instead of closing the connection. ### Async mode [#async-mode] With `mode: "async"`, the API responds immediately with `202 Accepted`, the render object and a `Location` header. Wait for a [webhook](https://dynamicdocumentapi.com/docs/webhooks) or poll the render. A render that runs longer than your plan's async maximum duration fails with the code `render_timeout`. ```bash curl -i https://api-eu.dynamicdocumentapi.com/v1/renders \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "input": { "type": "url", "url": "https://example.com/reports/42" }, "output": { "format": "pdf" }, "mode": "async", "webhook": { "url": "https://example.com/webhooks/documents", "events": ["render.succeeded", "render.failed"] }, "reference": "report-42" }' ``` ```http HTTP/1.1 202 Accepted Location: /v1/renders/rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q Content-Type: application/json {"id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q", "object": "render", "status": "queued", "mode": "async"} ``` The response body above is shortened. ### Poll for the result [#poll-for-the-result] Webhooks are the most efficient way to learn that a render finished. If you poll, back off between requests: TypeScript: ```ts async function waitForRender(id: string, timeoutMs = 10 * 60_000) { const deadline = Date.now() + timeoutMs; let delayMs = 1000; while (Date.now() < deadline) { const response = await fetch(`https://api-eu.dynamicdocumentapi.com/v1/renders/${id}`, { headers: { Authorization: `Bearer ${process.env.DYNAMIC_DOCUMENT_API_KEY}` }, }); if (!response.ok) throw new Error(`Retrieve failed (${response.status}): ${await response.text()}`); const render = await response.json(); if (render.status !== "queued" && render.status !== "processing") return render; await new Promise((resolve) => setTimeout(resolve, delayMs)); delayMs = Math.min(delayMs * 2, 5000); } throw new Error(`Render ${id} did not finish in time`); } ``` Python: ```python import os import time import requests def wait_for_render(render_id: str, timeout: float = 600) -> dict: deadline = time.monotonic() + timeout delay = 1.0 while time.monotonic() < deadline: response = requests.get( f"https://api-eu.dynamicdocumentapi.com/v1/renders/{render_id}", headers={"Authorization": f"Bearer {os.environ['DYNAMIC_DOCUMENT_API_KEY']}"}, timeout=30, ) response.raise_for_status() render = response.json() if render["status"] not in ("queued", "processing"): return render time.sleep(delay) delay = min(delay * 2, 5.0) raise TimeoutError(f"Render {render_id} did not finish in time") ``` ## Receiving files [#receiving-files] ### Delivery types [#delivery-types] | `delivery.type` | Response | Size limit | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `url` | The render object. Each file has a signed `url` and `url_expires_at`. | none | | `binary` | The file as the response body, with `Content-Type`, `Content-Disposition`, `X-Render-Id`, `X-Pages` and `X-Billed-Renders` headers | 20 MB | | `base64` | The render object in JSON, with the file content base64-encoded | 10 MB | | `none` | The render object without file URLs, for example when the files go to your [storage destinations](https://dynamicdocumentapi.com/docs/storage) | none | Without `delivery.type`, a render that keeps a hosted copy gets `url`, and one that keeps none gets `none`: with `hosted: false`, under [zero retention](#zero-retention-delivery), or when every storage destination of the render has `keep_hosted_copy: false`. Such a render answers without file URLs, and its files go to your storage destinations and email rules only. An explicit `url` without a hosted copy fails with `400 validation_error`; see [Hosted copy](https://dynamicdocumentapi.com/docs/storage#hosted-copy). `binary` and `base64` are the simplest choice when your code stores the file itself. Files larger than the limit fail with `413 output_too_large`; use `url` delivery for them. If a sync render with `binary` or `base64` delivery returns `202` because it reached the sync timeout, retrieve the render once it has succeeded. ### Signed URLs [#signed-urls] Generated files are private. File URLs are signed and expire after `delivery.expires_in` seconds (one hour by default, at most seven days). They point at our file storage in Germany: today under `https://dda-eu-outputs.nbg1.your-objectstorage.com`, or under `https://dda-eu-outputs-fsn1.fsn1.your-objectstorage.com` for files written while the first location was refusing writes. Neither host is part of the API and both can change, so use each URL as you receive it. If your network restricts outbound connections, allow both hosts. * `GET /v1/renders/{id}` returns fresh signed URLs for as long as the files are retained, so store the render ID rather than the URL. * `GET /v1/renders/{id}/files/{file_id}` redirects (`302`) to a fresh signed URL. Add `?download=true` to get an attachment `Content-Disposition`, or `?inline=true` to stream the file through the API. ### File retention [#file-retention] Hosted files are kept for your workspace's default retention period: 1, 7, 30, 90 or 365 days, or forever on paid plans, up to your plan's maximum. Override it per request with `delivery.retention_days`. When the period ends, the files are deleted, the render's status changes to `expired` and a `render.expired` webhook event is sent. Downloading an expired file fails with `410 file_expired`. To delete files before then, call `DELETE /v1/renders/{id}/files`. The hosted copy and any cached copies are purged immediately and the render becomes `expired`. Copies in your [storage destinations](https://dynamicdocumentapi.com/docs/storage) stay. ### Zero-retention delivery [#zero-retention-delivery] For sensitive documents, set `delivery.retention` to `"none"`, or make it the default in your workspace settings: ```json { "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, "data": { "employee": { "name": "Alex Example" }, "salary": 5400 }, "output": { "format": "pdf" }, "delivery": { "type": "binary", "retention": "none" } } ``` With zero retention: * no hosted copy is kept: the file is returned in the response with sync `binary` or `base64` delivery, or uploaded to your [storage destinations](https://dynamicdocumentapi.com/docs/storage). Uploading is the default without `delivery.type`, and it also works for async renders and batches. For the upload, each file is kept as a transient copy until it has reached every destination, at most about a day, and the API never serves it. * `url` delivery, [email rules](https://dynamicdocumentapi.com/docs/email-delivery) and the ZIP and merged PDF of a batch aren't available * request and response payloads are not logged; only metadata is recorded * your data is processed in memory and is not written to disk * the render record keeps only non-personal metadata such as the ID, status, timings and billed renders ## The render object [#the-render-object] ```json { "id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q", "object": "render", "status": "succeeded", "mode": "sync", "test": false, "region": "eu", "source": "api", "input_type": "template", "template": { "id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", "version": 7 }, "engine": "2026.4", "output_format": "pdf", "files": [ { "id": "file_01J9ZM4T6W8Y0A2C4E6G8J0K2M", "format": "pdf", "variant": null, "filename": "invoice-2026-0042.pdf", "bytes": 48213, "pages": 2, "width": null, "height": null, "url": "https://dda-eu-outputs.nbg1.your-objectstorage.com/…", "url_expires_at": "2026-09-17T16:06:34Z", "sha256": "9f2c…" } ], "storage": [], "email": [], "billed_renders": 1, "timings": { "queued_ms": 3, "render_ms": 612, "postprocess_ms": 21, "upload_ms": 38, "total_ms": 681 }, "warnings": [ { "code": "slow_asset", "message": "https://cdn.example.com/logo.png took 2.4s" } ], "error": null, "result": null, "reference": "inv_2026_0042", "metadata": { "customer_id": "c_981" }, "batch_id": null, "created_at": "2026-09-17T15:06:33Z", "completed_at": "2026-09-17T15:06:34Z", "expires_at": "2026-10-17T15:06:34Z" } ``` | Field | Description | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `id` | Render ID (`rnd_…`) | | `object` | Always `render` | | `status` | See [status values](#status-values) | | `mode` | `sync` or `async` | | `test` | `true` for test renders | | `region` | Region that processed the render, such as `eu` | | `source` | Where the request came from, such as `api` or `dashboard` | | `input_type` | `template`, `html`, `url`, `markdown`, `pdf_tool` for the [PDF tools](https://dynamicdocumentapi.com/docs/pdf-tools), or `einvoice` for XML exported with `POST /v1/einvoices` | | `template` | Template ID and the version number that was rendered (template renders only) | | `engine` | Engine channel used | | `output_format` | The requested output format | | `files` | Output files; see the table below | | `storage` | Upload result per [storage destination](https://dynamicdocumentapi.com/docs/storage): `destination_id`, `status` (`pending`, `succeeded`, `failed` or `skipped`), `location` and `error` | | `email` | The render's email sends, each with `id`, `rule_id`, `status`, `to`, `provider_message_id`, `error` and `sent_at`. See [Email delivery](https://dynamicdocumentapi.com/docs/email-delivery). | | `billed_renders` | Renders billed for this request, a whole number (`0` for test, failed and canceled renders) | | `timings` | Milliseconds spent queued, rendering, post-processing, uploading and in total | | `warnings` | Non-fatal problems such as slow or missing assets. See [render warnings](https://dynamicdocumentapi.com/docs/errors#render-warnings). | | `error` | For failed renders: `code` and `message`, plus `line`, `column` and `excerpt` for template errors | | `result` | Machine-readable results, otherwise `null`. PDF/UA, PDF/A and e-invoice renders carry their [validation report](https://dynamicdocumentapi.com/docs/e-invoicing#validation-report) in `result.conformance`, both when they succeed and when they fail. | | `reference`, `metadata` | Values from your request | | `batch_id` | The [batch](https://dynamicdocumentapi.com/docs/batches) the render belongs to, otherwise `null` | | `created_at`, `completed_at` | When the render was created and finished | | `expires_at` | When the files will be deleted | Each entry in `files` has: | Field | Description | | ----------------- | --------------------------------------------------------------------- | | `id` | File ID (`file_…`) | | `format` | File format, such as `pdf`, `png` or `xml` | | `variant` | Size variant of a multi-size image render (planned); otherwise `null` | | `filename` | File name | | `bytes` | File size in bytes | | `pages` | Number of pages (PDF) | | `width`, `height` | Dimensions in pixels (images) | | `url` | Signed download URL. Omitted when no hosted copy exists. | | `url_expires_at` | When `url` stops working | | `sha256` | SHA-256 checksum of the file | ### Status values [#status-values] | Status | Meaning | | ------------ | ------------------------------------------------------------------- | | `queued` | Accepted and waiting for a renderer | | `processing` | Rendering | | `succeeded` | Finished; files are available | | `failed` | Failed; `error` explains why. Nothing is billed. | | `canceled` | Canceled with `POST /v1/renders/{id}/cancel` | | `expired` | The files were deleted at the end of the retention period or purged | ## Convenience endpoints [#convenience-endpoints] These endpoints are thin wrappers around `POST /v1/renders` with a flatter body, which is handy in no-code tools and quick scripts. They return the same responses. | Endpoint | Body | Equivalent | | ------------------------------- | ----------------------------------------- | ------------------------------------------------ | | `POST /v1/pdf/from-template` | `template_id`, `version`, `data`, `pdf` | `input.type: "template"`, `output.format: "pdf"` | | `POST /v1/pdf/from-html` | `html`, `head`, `data`, `pdf` | `input.type: "html"`, `output.format: "pdf"` | | `POST /v1/pdf/from-url` | `url`, `http`, `pdf` | `input.type: "url"`, `output.format: "pdf"` | | `POST /v1/pdf/from-markdown` | `markdown`, `theme`, `css`, `data`, `pdf` | `input.type: "markdown"`, `output.format: "pdf"` | | `POST /v1/images/from-template` | `template_id`, `data`, `image`, `format` | `input.type: "template"`, PNG by default | | `POST /v1/images/from-html` | `html`, `head`, `data`, `image`, `format` | HTML screenshot | | `POST /v1/images/from-url` | `url`, `image`, `format` | URL screenshot | The image endpoints produce PNG unless `format` is `jpeg` or `webp`. `delivery`, `mode`, `webhook`, `reference`, `metadata`, `test` and `filename` are accepted at the top level of these bodies: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/pdf/from-html \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"html":"<h1>Hello {{ name }}</h1>","data":{"name":"World"},"pdf":{"paper":{"size":"A4"}},"filename":"hello.pdf"}' ``` ## Idempotency [#idempotency] Network errors and timeouts leave you unsure whether a request was processed. Send an `Idempotency-Key` header (up to 255 characters) with every POST request and reuse it when you retry: * The same key with the same body within 24 hours returns the original response. No second render is created and nothing is billed again. * If the original request is still being processed, the retry fails with `409 idempotency_in_progress`. Wait briefly and retry with the same key. * The same key with a different body fails with `422 idempotency_key_reused`. Keys are scoped to your workspace. Derive them from the business event when you can, such as `invoice-2026-0042`, or generate a UUID per logical operation and store it with your retry state. ## List renders [#list-renders] `GET /v1/renders` returns renders newest first, with cursor pagination: ```bash curl -G https://api-eu.dynamicdocumentapi.com/v1/renders \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ --data-urlencode "status=failed" \ --data-urlencode "created[gte]=2026-09-01T00:00:00Z" \ --data-urlencode "metadata[customer_id]=c_981" \ --data-urlencode "limit=50" ``` ```json { "object": "list", "data": [ { "id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q", "object": "render", "status": "failed" } ], "has_more": true, "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOS0xN1QxNTowNjozM1oifQ" } ``` To fetch the next page, repeat the request with the same filters and `cursor` set to `next_cursor`. Stop when `has_more` is `false`. | Parameter | Description | | ------------------------------ | ---------------------------------------------------------------------- | | `status` | `queued`, `processing`, `succeeded`, `failed`, `canceled` or `expired` | | `template_id` | Renders of one template | | `input_type` | `template`, `html`, `url`, `markdown`, `pdf_tool` or `einvoice` | | `output_format` | `pdf`, `png`, `jpeg`, `webp`, `html`, `xml` or `zip` | | `source` | Origin of the render, such as `api` or `dashboard` | | `reference` | Your `reference` value | | `batch_id` | Renders of one [batch](https://dynamicdocumentapi.com/docs/batches) | | `test` | `true` or `false` | | `created[gte]`, `created[lte]` | Creation time range, as RFC 3339 timestamps | | `metadata[key]` | A metadata value, for example `metadata[customer_id]=c_981` | | `limit` | Page size (default 50) | | `cursor` | The `next_cursor` value from the previous page | ## Other render endpoints [#other-render-endpoints] | Endpoint | Description | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `GET /v1/renders/{id}` | Retrieve a render, with fresh signed file URLs | | `GET /v1/renders/{id}/files/{file_id}` | Redirect to a file's signed URL. `?download=true` sets an attachment disposition, `?inline=true` streams the file through the API. | | `POST /v1/renders/{id}/cancel` | Cancel a `queued` or `processing` render. Canceling is best effort, and nothing is billed if rendering hadn't started. | | `DELETE /v1/renders/{id}/files` | Delete the hosted files now; the render becomes `expired` | | `GET /v1/renders/{id}/logs` | The redacted request and response log, if request logging is enabled for your workspace | Request logs are kept for 7 days on Starter and Growth, 30 days on Pro and 90 days on Scale, with redaction rules you configure in the dashboard. ## Rate limits [#rate-limits] Limits depend on your plan: | Plan | Sustained (requests/s) | Burst (requests/s) | Concurrent sync renders | | ---------- | ---------------------- | ------------------ | ----------------------- | | Free | 2 | 5 | 2 | | Starter | 10 | 20 | 10 | | Growth | 20 | 40 | 20 | | Pro | 50 | 100 | 50 | | Scale | 150 | 300 | 150 | | Enterprise | custom | custom | custom | Responses include the current policy and your remaining quota: ```http RateLimit-Policy: "burst";q=20;w=1, "sustained";q=600;w=60 RateLimit: "burst";r=17;t=1 ``` `q` is the quota for a window of `w` seconds, `r` is the number of requests remaining and `t` is the number of seconds until the window resets. When you exceed a limit, the API responds with `429` and a `Retry-After` header. The code is `rate_limited` when you send requests too fast and `concurrency_limited` when too many sync renders are running at once. Wait for `Retry-After` seconds before retrying. For large volumes, use async mode and spread requests over time. See [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits#limits-by-plan) for all limits. ## Billed renders [#billed-renders] Every successful live render is billed as one or more renders, reported in the render's `billed_renders` field and, for binary delivery, in the `X-Billed-Renders` header. | Render | Billed renders | | ------------------------------------------------------------- | -------------- | | PDF (template, HTML, URL or Markdown), up to 50 pages | 1 | | Each additional 50 pages | +1 | | [E-invoice](https://dynamicdocumentapi.com/docs/e-invoicing) layer on a PDF | included (+0) | | E-invoice XML (`POST /v1/einvoices`) | 1 | | Image (PNG, JPEG or WebP) | 1 | | Test render, failed render, render canceled before it started | 0 | A PDF over your plan's page limit fails with `422 page_limit_exceeded` and isn't billed. See [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits#what-counts-as-a-render) for the full render table. ## Engine channels [#engine-channels] The renderer is released in engine channels named `YYYY.N`, such as `2026.4`. A channel fixes the Chromium version, the bundled fonts and the image renderer, so output stays identical as long as the template version, the data and the channel don't change. * Templates use the channel they were created or last upgraded with; the editor shows a visual comparison before you upgrade. * New templates use the current default channel. * Set `engine` on a request to render with a different channel, for example to test an upgrade. * At most three channels are available at a time. A channel is deprecated with at least 12 months' notice. # SDKs and integrations (/docs/sdks-and-integrations) > The TypeScript and Python SDKs, what is planned for other languages, the CLI and no-code platforms, the MCP server, and how to use the REST API directly. The REST API is the product: everything the dashboard does, you can do over HTTP. Client libraries and platform integrations are being built on top of it, and this page is an honest status list so you can plan around it. ## Status [#status] | Tool | Status | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | REST API at `https://api-eu.dynamicdocumentapi.com/v1` | Available | | OpenAPI 3.1 description at `https://api-eu.dynamicdocumentapi.com/v1/openapi.json` and the [API reference](https://dynamicdocumentapi.com/docs/api-reference) | Available | | [TypeScript and JavaScript SDK](#typescript-and-javascript) ([`@dynamic-document-api/sdk`](https://www.npmjs.com/package/@dynamic-document-api/sdk)) | Available | | [Python SDK](#python) ([`dynamic-document-api`](https://pypi.org/project/dynamic-document-api/)) | Available | | PHP SDK (`dynamic-document-api/dynamic-document-api-php`) | Planned | | Ruby, Go, Java and C# SDKs | Planned, after PHP | | Command-line interface | Planned | | [n8n](https://dynamicdocumentapi.com/docs/n8n) community nodes ([`@dynamic-document-api/n8n-nodes-dynamic-document-api`](https://www.npmjs.com/package/@dynamic-document-api/n8n-nodes-dynamic-document-api)) | Available for self-hosted n8n; n8n Cloud after n8n's verification | | [Zapier](https://dynamicdocumentapi.com/docs/zapier) app, in the [Zapier app directory](https://zapier.com/apps/dynamic-document-api/integrations) | Available | | [Make](https://dynamicdocumentapi.com/docs/make) app | In Make's review; available by invite link until then | | Bubble | Planned | | [MCP server](https://dynamicdocumentapi.com/docs/mcp) at `https://mcp.dynamicdocumentapi.com/mcp` | Available | | Power Automate, Pipedream, Activepieces, Airtable, Google Sheets, WordPress and WooCommerce, Shopify | Planned | Planned means designed and on the roadmap, not available yet. Until an SDK ships for your language, call the API directly: it is a small surface, and the [quickstart](https://dynamicdocumentapi.com/docs/quickstart) has working examples in cURL, TypeScript and Python. ## TypeScript and JavaScript [#typescript-and-javascript] ```bash npm install @dynamic-document-api/sdk ``` The SDK runs on Node.js 18 and later, Deno, Bun and edge runtimes, and has no dependencies. It covers the whole public API: renders, batches, PDF tools, e-invoices, templates, email delivery, storage destinations, webhook endpoints and signed links. ```ts import { DynamicDocumentApi } from "@dynamic-document-api/sdk"; const dda = new DynamicDocumentApi(); // reads DYNAMIC_DOCUMENT_API_KEY const render = await dda.pdf.fromTemplate({ templateId: "tpl_…", data: { number: "2026-0042", customer: { name: "Ada Lovelace" } }, }); console.log(render.files?.[0]?.url); ``` The [package page on npm](https://www.npmjs.com/package/@dynamic-document-api/sdk) documents every resource, the configuration and the error classes. Keep your API key on the server; don't use the SDK in browser code. ## Python [#python] ```bash pip install dynamic-document-api ``` The SDK runs on Python 3.9 and later and depends only on `httpx`. `DynamicDocumentApi` is the synchronous client, and `AsyncDynamicDocumentApi` has the same methods for `asyncio`. Keyword arguments carry the API's own names, and responses are the API's JSON as dicts with type hints. ```python from dynamic_document_api import DynamicDocumentApi dda = DynamicDocumentApi() # reads DYNAMIC_DOCUMENT_API_KEY render = dda.pdf.from_template( template_id="tpl_…", data={"number": "2026-0042", "customer": {"name": "Ada Lovelace"}}, ) print(render["files"][0]["url"]) ``` The [package page on PyPI](https://pypi.org/project/dynamic-document-api/) documents every resource, the configuration and the error classes. ## What the SDKs provide [#what-the-sdks-provide] The SDKs' types are generated from the same OpenAPI description as the [API reference](https://dynamicdocumentapi.com/docs/api-reference), so they stay in step with the API. The TypeScript and Python SDKs offer all of the following, and the other languages will too: * **A configurable base URL** (the EU host by default), with the API key read from the `DYNAMIC_DOCUMENT_API_KEY` environment variable * **Automatic retries** for `429`, `5xx` and network errors, with exponential backoff and jitter, honouring `Retry-After` * **Automatically generated idempotency keys** for POST requests, which you can override * **A timeout above the longest synchronous wait** (130 seconds), so a slow render answers `202` instead of being cut off * **Typed errors** per error `code`, carrying the `request_id` * **Pagination iterators** for list endpoints * **Helpers**: wait for a render to finish, download a file as bytes or a stream, verify webhook signatures, and sign signed-link URLs locally * **A `User-Agent` with the SDK name and version**, which you can turn off The TypeScript SDK runs on Node.js 18 and later, Deno, Bun and edge runtimes, and the Python SDK on Python 3.9 and later. Planned runtimes for the others are PHP 8.1 and later, Ruby 3.1 and later, Go 1.22 and later, Java 11 and later, and .NET 8. ## Using the REST API today [#using-the-rest-api-today] Any HTTP client works. A few practical notes: * Set your client's timeout above your plan's [sync timeout](https://dynamicdocumentapi.com/docs/renders#sync-and-async) so you receive the `202` response instead of dropping the connection. * Send an `Idempotency-Key` header on every POST request and reuse it on retries. * Implement the small retry policy described in [Errors](https://dynamicdocumentapi.com/docs/errors#handling-errors). * For long renders, use `mode: "async"` plus [webhooks](https://dynamicdocumentapi.com/docs/webhooks), which includes copy-paste verification code for Node.js and Python. * Postman, Bruno and Insomnia can import the OpenAPI description directly. ## No-code platforms [#no-code-platforms] The [n8n nodes](https://dynamicdocumentapi.com/docs/n8n), the [Zapier app](https://dynamicdocumentapi.com/docs/zapier) and the [Make app](https://dynamicdocumentapi.com/docs/make) render from your templates with fields built from each template's schema, run the PDF tools, and start workflows from webhook events (`render.succeeded`, `render.failed`, `batch.completed`, `template.published`). The Make app is in Make's review; until Make lists it, write to [support@dynamicdocumentapi.com](mailto:support@dynamicdocumentapi.com) for an invite link. A Bubble plugin is planned. Other platforms can call the API with their generic HTTP modules: * Authenticate with the `X-API-Key` header, which is easier than a bearer token in some builders. * Use the flat [convenience endpoints](https://dynamicdocumentapi.com/docs/renders#convenience-endpoints), for example `POST /v1/pdf/from-template` with `template_id`, `data` and `filename`. * Most platforms abort a step after 25 to 40 seconds. Keep requests inside that budget, or submit with `mode: "async"` and continue from a webhook. * `GET /v1/templates` and `GET /v1/templates/{id}/schema` give you template lists and field definitions for dropdowns and mappings. ## MCP server [#mcp-server] The remote MCP server at `https://mcp.dynamicdocumentapi.com/mcp` lets AI agents and assistants list templates, read their schemas and generate PDFs, images and e-invoices. Clients sign in with OAuth 2.1, you choose the workspace at consent, and renders stay in test mode until the agent asks for the final document. Setup for Claude, ChatGPT, Cursor and VS Code is in [MCP server](https://dynamicdocumentapi.com/docs/mcp). ## Building your own integration [#building-your-own-integration] If you distribute an integration to other workspaces: * Send the `X-Dynamic-Document-Api-Source` header with a short, stable identifier for your platform, and put your integration's name and version in the `User-Agent`. Renders are then attributable to your integration in the dashboard's render list and usage breakdown. * Use `GET /v1/account` as a connection test: it returns the workspace name and plan, which makes a good connection label. * Build input fields from `GET /v1/templates/{id}/schema` rather than asking users for raw JSON, and offer a raw JSON mode as a fallback. * Send an `Idempotency-Key` for every render so that a retried automation step doesn't produce duplicate documents. * Let users set the API host, rather than hard-coding one. * Ask users for a key with the narrowest scopes your integration needs, and support test keys. The Zapier and Make apps and the MCP server sign users in with OAuth, so nobody pastes an API key. OAuth clients are registered by us: if you build an integration for other workspaces and want users to connect the same way, write to [support@dynamicdocumentapi.com](mailto:support@dynamicdocumentapi.com). # Signed links (/docs/signed-links) > Image and PDF URLs that render a template on request, without an API key. URL format, HMAC signatures, open links, checks and errors, caching, quotas and billing. A signed link turns a published template into a URL. Put the URL in an `<img>` tag, an email, an Open Graph tag or a no-code tool, and every request renders the template with the parameters in the URL. There is no API key in the URL: the link's secret signs the URL instead, so nobody can change the parameters without invalidating it. ``` https://img.dynamicdocumentapi.com/l/lnk_01J9ZM1X3F7R8K2C4V6B8N0P2Q/banner.png?title.text=Summer%20Sale&exp=1789661194&sig=BsM0_V6iRJeeRIjIhywJ3zFHrW6j0plh… ``` The first request renders the template's live version. After that the result comes from the cache until the template is published again, and a cache hit costs no render. Signed links are included from the Starter plan. ## Create a link [#create-a-link] Create links in the dashboard under **Signed links**, or with a key that has the `signed_links:write` scope: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/signed-links \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C", "name": "Summer campaign banners", "formats": ["png", "webp"], "allowed_params": ["title.text", "hero.src"], "defaults": {"title.text": "Summer Sale"}, "param_limits": {"title.text": {"max_length": 80}, "hero.src": {"max_length": 300}}, "quota_total": 10000, "cache_ttl_seconds": 86400 }' ``` The response contains `base_url` and the link's `secret`. The secret is shown **once**: store it next to your API keys. Reads never return it again. | Field | Description | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `template_id` | The template to render. It must have a published version; the link always renders the live one. Can't be changed later. | | `formats` | Extensions the URL may use: `png`, `jpeg` (also as `.jpg`), `webp`, `pdf` | | `allowed_params` | The parameters a URL may set. Anything else is rejected. | | `defaults` | Values used when the URL leaves a parameter out | | `param_limits` | Per parameter, `{"max_length": n}`. Required for every allowed parameter of an open link. | | `access` | `signed` (default) or `open`, see [Open links](#open-links) | | `expires_at` | After this time every URL of the link answers `410 link_expired` | | `quota_total` | Maximum number of billed renders (cache misses) for the link | | `rate_limit_per_ip_per_hour` | Requests per client IP address and hour. Unlimited by default for signed links, 120 for open links. | | `allowed_referrers` | Sites that may embed the link, as exact hosts (`example.com`) or `*.example.com` for subdomains | | `cache_ttl_seconds` | How long a result stays cached. By default until the template is published again, at most 30 days. | | `fallback_image_asset_id` | An image asset served instead of an error when your workspace has no renders left | | `enabled` | Set to `false` to stop serving without deleting the link | `GET`, `PATCH` and `DELETE /v1/signed-links/{id}` read, change and delete a link. The link object also reports `quota_used`, the number of billed renders so far. ## The URL [#the-url] ``` https://img.dynamicdocumentapi.com/l/{link_id}/{name}.{ext}?{param}={value}&exp={unix}&sig={signature} ``` * **`name`**: any name of 1 to 80 characters from `A–Z`, `a–z`, `0–9`, `-` and `_`. It becomes the file name of the download (`Content-Disposition: inline; filename="{name}.{ext}"`), and it's part of the signature. * **`ext`**: one of the link's formats. * **Parameters**: the values for `allowed_params`, for example `title.text=Summer%20Sale`. Parameters left out take the link's defaults. * **`exp`** (optional): Unix time in seconds after which this URL answers `410 link_expired`. * **`sig`**: the signature. Open links don't need one. The query string may be at most 8 KB, and each parameter may appear only once. ### How parameters reach the template [#how-parameters-reach-the-template] Parameters are turned into data the same way as `overrides` in a render request: * **[Canvas templates](https://dynamicdocumentapi.com/docs/image-options#image-templates)** use `element.property` names, such as `title.text` or `hero.src`, exactly as they appear in the template's dynamic properties. * **Code and Markdown templates** receive dotted names as nested data: `customer.name=Ada` becomes `{"customer": {"name": "Ada"}}`, available as `{{ customer.name }}`. Values from the URL are always strings. Use `defaults` for values of other types. ## Sign a URL [#sign-a-url] The signature is an HMAC-SHA256 of the request line, keyed with the link secret: ``` sig = base64url( HMAC_SHA256( secret, "GET\n/l/{link_id}/{name}.{ext}\n" + canonical_query ) ) ``` * `secret` is the base64url-decoded link secret. * `canonical_query` contains every parameter except `sig`, including `exp`, sorted by name, each name and value percent-encoded per RFC 3986 (unreserved characters `A–Z a–z 0–9 - . _ ~` stay as they are, a space becomes `%20`), joined as `name=value` with `&`. * `base64url` is the URL-safe alphabet without `=` padding. Sign on your server, so the secret never reaches a browser: ```ts import { createHmac } from "node:crypto"; const enc = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); export function signedUrl(linkId: string, secret: string, name: string, ext: string, params: Record<string, string>) { const query = Object.keys(params) .sort() .map((key) => `${enc(key)}=${enc(params[key])}`) .join("&"); const key = Buffer.from(secret, "base64url"); const sig = createHmac("sha256", key).update(`GET\n/l/${linkId}/${name}.${ext}\n${query}`).digest("base64url"); return `https://img.dynamicdocumentapi.com/l/${linkId}/${name}.${ext}?${query}${query ? "&" : ""}sig=${sig}`; } signedUrl("lnk_01J9ZM1X3F7R8K2C4V6B8N0P2Q", process.env.LINK_SECRET!, "banner", "png", { "title.text": "Summer Sale", exp: String(Math.floor(Date.now() / 1000) + 7 * 86400), }); ``` ```python import base64, hashlib, hmac, time from urllib.parse import quote def signed_url(link_id: str, secret: str, name: str, ext: str, params: dict[str, str]) -> str: query = "&".join(f"{quote(k, safe='-._~')}={quote(v, safe='-._~')}" for k, v in sorted(params.items())) key = base64.urlsafe_b64decode(secret + "=" * (-len(secret) % 4)) message = f"GET\n/l/{link_id}/{name}.{ext}\n{query}".encode() sig = base64.urlsafe_b64encode(hmac.new(key, message, hashlib.sha256).digest()).rstrip(b"=").decode() return f"https://img.dynamicdocumentapi.com/l/{link_id}/{name}.{ext}?{query}{'&' if query else ''}sig={sig}" signed_url("lnk_01J9ZM1X3F7R8K2C4V6B8N0P2Q", LINK_SECRET, "banner", "png", {"title.text": "Summer Sale", "exp": str(int(time.time()) + 7 * 86400)}) ``` Without code, `POST /v1/signed-links/{id}/sign` with `{"params": {...}, "format": "png", "name": "banner", "expires_at": 1789661194}` returns a signed `url`. The dashboard's **Build a URL** action does the same. ## Open links [#open-links] Some tools can't compute an HMAC, for example email builders that only insert merge fields into a URL. For them, create a link with `"access": "open"`: it accepts unsigned URLs, and ignores `sig` if one is present. An open link needs a `param_limits` entry for every allowed parameter, a `quota_total` and a `rate_limit_per_ip_per_hour`, because anyone who sees the URL can change its parameters within those limits. Keep the parameters to what the design can safely show, set tight `max_length` values, and prefer signed links wherever you can sign. ## Checks and errors [#checks-and-errors] Every request runs these checks in this order. The first one that fails decides the answer: | # | Check | Answer | | - | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | | 1 | The link exists, is enabled and not deleted, the workspace is active, and the extension is one of the link's formats | `404 not_found` | | 2 | The link's `expires_at` and the URL's `exp` are in the future | `410 link_expired` | | 3 | Signed links: `sig` matches. Open links: every parameter is allowed and has a limit | `403 invalid_signature` / `400 validation_error` | | 4 | Every parameter is in `allowed_params` and within its `max_length` | `400 validation_error` | | 5 | If `allowed_referrers` is set, a `Referer` header names one of those sites. Requests without a `Referer`, such as images in email clients, pass. | `403 referrer_not_allowed` | | 6 | The client IP address is within the hourly rate limit | `429 rate_limited` with `Retry-After` | | 7 | Your plan includes signed links | `402 plan_feature_unavailable` | When rendering, the link's quota and your workspace's renders apply: `429 quota_exceeded` once the link has used `quota_total` renders, and `402 render_limit_reached` when the workspace has no renders left. With a `fallback_image_asset_id`, the link returns that image (`200`, `Cache-Control: public, max-age=60`, header `X-Fallback-Reason: render_limit_reached`) instead of the `402`, so emails and pages keep showing something. A template error answers `422` like a render request. Errors are [problem details](https://dynamicdocumentapi.com/docs/errors) in JSON (`application/problem+json`) and are never cached. ## Caching [#caching] A result is cached per link, live template version, format and parameter values (after defaults are applied). The name in the URL, `exp` and `sig` are not part of the cache key. | Situation | What happens | Billed | | ---------------------------------- | ---------------------------------------------------------------- | -------- | | First request for these parameters | The template's live version is rendered and the result is cached | 1 render | | The same parameters again | The cached file is returned; no render is created | 0 | | You publish a new version | The cache key changes, the next request renders the new version | 1 render | | You change the link's `defaults` | The effective parameters change, so the next request renders | 1 render | Responses carry: | Header | Value | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `Content-Type` | `image/png`, `image/jpeg`, `image/webp` or `application/pdf` | | `Cache-Control` | `public, max-age=…`: `cache_ttl_seconds` (1 hour by default), at most one day, and never beyond the URL's `exp` or the link's `expires_at` | | `ETag` | Identifies the cached result; send it back as `If-None-Match` to get `304 Not Modified` | | `X-Billed-Renders` | `1` when this request rendered, `0` for cache hits, `304`s and errors | | `X-Render-Id` | The render a cache miss created. It appears in your render log with source `signed_link` and reference `signed:{link_id}`. | | `Access-Control-Allow-Origin` | `*`, so pages on any site can load the file with `fetch` | `HEAD` requests run the same checks and answer with the same headers, but never render or bill. Browsers and our edge network cache files as `Cache-Control` allows. Disabling or deleting a link stops new requests at once; copies already cached in browsers expire on their own. ## Quotas and billing [#quotas-and-billing] Each cache miss is one billed render, whatever the format or number of pages; cache hits are free. They count towards your plan's renders like any other render and appear in usage with the source `signed_link`. `quota_total` caps the billed renders of one link. It counts cache misses only, so a popular image keeps being served from the cache after the quota is reached, while URLs with new parameters answer `429 quota_exceeded`. `quota_used` on the link shows the count. The template's default [e-invoice](https://dynamicdocumentapi.com/docs/e-invoicing#make-a-template-an-e-invoice-by-default) applies to `.pdf` links as it does to render requests (the invoice data comes from the link's `defaults`); images are never e-invoices. # Your own storage (/docs/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. Storage destinations upload rendered files to storage you own: an Amazon S3 bucket, an S3-compatible service, Azure Blob Storage, Google Cloud Storage or an SFTP server. Uploads run in the background once a render has succeeded, they are retried when they fail, and the render object reports where each file went. You decide whether we keep a hosted copy as well. Storage destinations are included from the Starter plan, and uploads don't cost renders. ## Why use your own storage [#why-use-your-own-storage] * **Keep documents in your systems.** Every file lands in your bucket or on your server, under a path you choose, for your archive or the systems that process it. * **Apply your own retention and access rules.** With `keep_hosted_copy: false`, we delete our copy once the upload has succeeded, so your storage holds the only copy. * **Combine it with zero retention.** We keep the file only until it has reached your storage, and never longer than about a day. * **Feed systems that watch a folder.** On an SFTP server, a file appears only once it is complete. ## Destinations [#destinations] A destination is one bucket, container or server. Its settings go in `config` and its secrets in `credentials`. | `provider` | Storage | Credentials | | --------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | `s3` | Amazon S3 | Access keys | | `s3_compatible` | Services with an S3 API, such as Cloudflare R2, Backblaze B2, Wasabi, Scaleway, OVHcloud, Hetzner Object Storage or MinIO | Access keys | | `azure_blob` | Azure Blob Storage | SAS URL or connection string | | `gcs` | Google Cloud Storage | Service account key | | `sftp` | Your own server over SFTP | Password or private key, plus the server's host key | Uploads leave from our EU region and only go to public addresses: object storage over HTTPS, SFTP servers over SSH. Every destination can have a `prefix`, a folder that all its files go under, such as `invoices/`. ### Amazon S3 [#amazon-s3] | `config` field | Description | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bucket` | Required. The bucket name, 3 to 63 characters | | `region` | Required. The bucket's region, such as `eu-central-1` | | `prefix` | Optional folder for every file, up to 500 characters | | `storage_class` | Optional. `STANDARD`, `STANDARD_IA`, `ONEZONE_IA`, `INTELLIGENT_TIERING` or `GLACIER_IR`. Every uploaded object gets this class, and so does the connection test's probe. Leave it out to use the bucket's default. | | `sse` | Optional. `AES256` or `aws:kms`. With `aws:kms` and a `kms_key_id`, uploads are encrypted with that KMS key; otherwise the bucket's default encryption applies. | | `kms_key_id` | The KMS key for `aws:kms` | | `force_path_style` | Optional, default `false`: requests name the bucket in the host name, `<bucket>.s3.<region>.amazonaws.com`. `true` puts it in the path, `s3.<region>.amazonaws.com/<bucket>`. See [Bucket in the host name or in the path](#bucket-in-the-host-name-or-in-the-path). | | `credentials` field | Description | | ------------------- | -------------------------------------------------------------------------------------------- | | `access_key_id` | Required | | `secret_access_key` | Required | | `session_token` | Optional: the session token of temporary credentials. Uploads stop working when they expire. | Only instant-access storage classes are allowed, because the connection test reads its probe back. Archive classes such as `GLACIER` and `DEEP_ARCHIVE` are refused with `400 validation_error` at `/config/storage_class`. ### S3-compatible storage [#s3-compatible-storage] `s3_compatible` takes the same fields and credentials as Amazon S3, except `storage_class`, which only Amazon S3 supports. These fields differ: | `config` field | Description | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `endpoint` | Required. The service's URL, with `https://` and a public host name or address. Any port works. | | `region` | Optional, default `auto`. The region name your service expects. | | `force_path_style` | Optional, default `true`: the bucket goes in the path, `<endpoint>/<bucket>/<key>`, which most services accept and self-hosted ones such as MinIO usually require. `false` puts it in the host name, `<bucket>.<endpoint host>/<key>`, for services that expect that. | ```json { "name": "R2 EU", "provider": "s3_compatible", "config": { "endpoint": "https://<account-id>.eu.r2.cloudflarestorage.com", "region": "auto", "bucket": "example-documents", "force_path_style": true }, "credentials": { "access_key_id": "…", "secret_access_key": "…" } } ``` #### Bucket in the host name or in the path [#bucket-in-the-host-name-or-in-the-path] `force_path_style` decides where requests name the bucket. The destination shows the value that applies, also when you left it out. | `force_path_style` | Amazon S3 | S3-compatible | | ------------------ | --------------------------------------------------------------- | ---------------------------------------- | | `false` | `https://<bucket>.s3.<region>.amazonaws.com/<key>`, the default | `https://<bucket>.<endpoint host>/<key>` | | `true` | `https://s3.<region>.amazonaws.com/<bucket>/<key>` | `<endpoint>/<bucket>/<key>`, the default | A host name can't carry every bucket name, so these always use the path: bucket names with dots, underscores or capital letters, which a provider's certificate doesn't cover, and endpoints that are IP addresses. ### Azure Blob Storage [#azure-blob-storage] | `config` field | Description | | -------------- | ------------------------------------------------------------------------------------- | | `account` | Required. The storage account name, not its URL: 3 to 24 lowercase letters and digits | | `container` | Required. 3 to 63 lowercase letters, digits and `-`, starting with a letter or digit | | `prefix` | Optional folder for every file | Send one of these credentials: | `credentials` field | Description | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sas_url` | A shared access signature URL, such as `https://exampledocs.blob.core.windows.net/invoices?sv=…&sig=…`. We use its token with `account` and `container`. | | `connection_string` | A connection string that contains the account key (`AccountKey=…`) | ### Google Cloud Storage [#google-cloud-storage] | Field | Description | | ---------------------------------- | ---------------------------------------------------------------------------------------------- | | `config.bucket` | Required. The bucket name, 3 to 222 characters | | `config.prefix` | Optional folder for every file | | `credentials.service_account_json` | Required. The JSON key file of a service account, as Google Cloud created it, sent as a string | ### SFTP [#sftp] | `config` field | Description | | -------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `host` | Required. The server's public host name or IP address, without `sftp://`, a user or a port | | `port` | Optional, default `22` | | `username` | Required | | `host_key` | Required. The SHA-256 fingerprint of the server's host key, as `ssh-keygen -lf` prints it: `SHA256:` followed by 43 characters | | `prefix` | Optional folder. A prefix that starts with `/` is an absolute path; otherwise it is inside the login directory. | As credentials, send `password`, or `private_key` (the whole key file, in OpenSSH or PEM format) with a `passphrase` if the key is encrypted. ```json { "name": "Invoices server", "provider": "sftp", "config": { "host": "sftp.example.com", "port": 22, "username": "renders", "prefix": "/incoming/invoices", "host_key": "SHA256:e2uuyLCsFPfkWbJjOEXNLQO/3MSsf1kJQ/7eLsf3BEM" }, "credentials": { "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----\n", "passphrase": "…" } } ``` The host key pins the server: if it shows another key, the connection is refused before any credentials are sent. We use the server's Ed25519 key if it has one, then ECDSA, then RSA, so pin the first of those your server offers: ```bash ssh-keyscan -t ed25519 sftp.example.com | ssh-keygen -lf - ``` In the dashboard, the connection test shows the key the server presents and offers to trust it. Compare it with your server's fingerprint before you do. If the server's key changes later, uploads fail until you check the new key and save it as `config.host_key`. Each file is written under a hidden temporary name in its target folder and renamed once it is complete, so a process that watches the folder never reads half a file. An upload that fails or runs out of time removes its temporary file. Missing folders are created, and a file with the same name is replaced. ## Credentials [#credentials] Credentials are write-only. Send them in `credentials` when you create a destination; responses only say whether they are set: ```json "credentials": { "configured": true } ``` * They are encrypted with AES-256-GCM before they are stored, and decrypted only to upload a file or to run a connection test. * To rotate them, send the complete new `credentials` object with `PATCH /v1/storage-destinations/{id}`. Requests without `credentials` keep the stored ones. * In the dashboard, saving credentials asks you to confirm your identity again. The credentials need permission to write files under the prefix. The connection test also reads its probe back and deletes it, so allow reading and deleting as well. For Amazon S3, that is `s3:PutObject`, `s3:GetObject` and `s3:DeleteObject`, plus `s3:AbortMultipartUpload` so a failed upload of a large file can be cleaned up, see [Upload a render](#upload-a-render). On an SFTP server, the user must be able to create folders and to write, rename, read and delete files. ## Create and manage destinations [#create-and-manage-destinations] In the dashboard, open **Storage** and choose **Add destination**. The form can test your settings before you save them. Owners, Admins and Developers can manage destinations. Through the API, use a key with the `storage:write` scope: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/storage-destinations \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Invoice archive", "provider": "s3", "config": { "bucket": "example-documents", "region": "eu-central-1", "prefix": "invoices/", "storage_class": "STANDARD_IA" }, "credentials": { "access_key_id": "…", "secret_access_key": "…" }, "path_template": "{{ data.customer_id }}/{{ render.id }}.{{ file.ext }}", "is_default": true }' ``` The response is the destination, with `201 Created`: ```json { "id": "dst_01J9ZN2B4D6F8H0K2M4P6R8T0V", "object": "storage_destination", "name": "Invoice archive", "provider": "s3", "config": { "region": "eu-central-1", "bucket": "example-documents", "prefix": "invoices/", "force_path_style": false, "storage_class": "STANDARD_IA" }, "credentials": { "configured": true }, "path_template": "{{ data.customer_id }}/{{ render.id }}.{{ file.ext }}", "is_default": true, "keep_hosted_copy": true, "last_test": { "at": null, "status": null, "error": null }, "created_at": "2026-09-17T15:00:12Z", "updated_at": "2026-09-17T15:00:12Z" } ``` | Field | Description | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | Required. Up to 80 characters, shown in the dashboard and in `storage.upload_failed` events | | `provider` | Required. `s3`, `s3_compatible`, `azure_blob`, `gcs` or `sftp`. It can't be changed later; create a new destination instead. | | `config` | Required. The provider's settings, see [Destinations](#destinations) | | `credentials` | Required when you create the destination. Write-only. | | `path_template` | Where files go below the prefix, see [Path templates](#path-templates). Up to 500 characters. | | `is_default` | `true` makes this the [default destination](#the-default-destination). Default `false`. | | `keep_hosted_copy` | Default `true`. With `false`, a render whose destinations all say so keeps no hosted copy and, without `delivery.type`, answers without file URLs, see [Hosted copy](#hosted-copy). | The response adds the `id` (`dst_…`), `last_test` (see [Test the connection](#test-the-connection)), `created_at` and `updated_at`. | Endpoint | Description | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /v1/storage-destinations` | List all destinations, sorted by name, in one page | | `POST /v1/storage-destinations` | Create a destination | | `GET /v1/storage-destinations/{id}` | Retrieve a destination | | `PATCH /v1/storage-destinations/{id}` | Change a destination. `config` and `credentials` are each replaced as a whole, so send every field of the one you change. | | `DELETE /v1/storage-destinations/{id}` | Delete a destination. Files already uploaded stay in your storage; uploads that haven't succeeded yet stop, see [Retries and failures](#retries-and-failures). | | `POST /v1/storage-destinations/{id}/test` | Run the connection test | Listing and retrieving destinations needs the `storage:read` or the `storage:write` scope; the other endpoints need `storage:write`. A workspace can have up to 20 destinations. ### The default destination [#the-default-destination] One destination per workspace can be the default. Renders that don't send `delivery.storage` are uploaded to it. Marking a destination as the default, with `is_default: true` or **Make default** in the dashboard, removes the flag from the previous one. ## Test the connection [#test-the-connection] `POST /v1/storage-destinations/{id}/test` writes a small probe file, `dynamic-document-api-connection-test-<id>.txt`, below the prefix, reads it back and deletes it. It runs from our EU region like the uploads, and the probe gets the destination's storage class. ```bash curl -X POST https://api-eu.dynamicdocumentapi.com/v1/storage-destinations/dst_01J9ZN2B4D6F8H0K2M4P6R8T0V/test \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" ``` ```json { "object": "storage_test", "ok": false, "steps": [ { "name": "credentials", "ok": true, "detail": "configuration accepted" }, { "name": "write", "ok": false, "detail": "… 403 Forbidden … AccessDenied …" } ], "destination": { "id": "dst_01J9ZN2B4D6F8H0K2M4P6R8T0V", "object": "storage_destination", "last_test": { "at": "2026-09-17T15:01:05Z", "status": "failed", "error": "… 403 Forbidden … AccessDenied …" } } } ``` The `destination` above is shortened. The response is `200` whenever the test ran: `ok: false` means one of the checks failed, not the request. Each step has `name`, `ok` and a `detail`: | Step | What it checks | | ------------------------- | --------------------------------------------------------------------------------- | | `connect` | SFTP: the server can be reached | | `host_key` | SFTP: the server shows the pinned host key | | `credentials` | The settings and credentials can be used. On SFTP, the server accepted the login. | | `sftp` | SFTP: the SFTP service started after the login (only listed when it didn't) | | `write`, `read`, `delete` | The probe was written, read back and deleted | | `deliverer` | Only listed when our region didn't answer within 20 seconds | The result is saved on the destination as `last_test`: `at`, `status` (`succeeded` or `failed`) and `error`, the detail of the first failed step. Changing a destination doesn't reset `last_test`, so test again after you change its settings. When an SFTP server could be reached, the response also contains `host_key`, the fingerprint of the key it showed. If that isn't `config.host_key`, the `host_key` step fails. Check the key, then save it as `config.host_key`. ## Upload a render [#upload-a-render] A render is uploaded to the destinations in `delivery.storage` when the request sends it, and to the default destination otherwise: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/renders \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, "data": { "number": "2026-0042", "customer_id": "c_981" }, "output": { "format": "pdf", "filename": "invoice-{{ data.number }}.pdf" }, "reference": "inv_2026_0042", "delivery": { "storage": [ { "destination_id": "dst_01J9ZN2B4D6F8H0K2M4P6R8T0V", "path": "{{ data.customer_id }}/{{ file.filename }}" } ] } }' ``` | `delivery` field | Description | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `storage` | Up to 10 destinations, each `{"destination_id": "dst_…", "path": "…"}`. `path` is optional and replaces the destination's `path_template` for this render. Without `storage`, the default destination applies; `[]` uploads nowhere. | | `hosted` | Whether we keep a hosted copy too, see [Hosted copy](#hosted-copy) | | `type` | `url` needs a hosted copy, `none` returns the render object without file URLs, and `binary` and `base64` return the file in the sync response as well. Without `type`, a render gets `url` when it keeps a hosted copy and `none` when it doesn't. | | `retention` | `"none"` for [zero retention](#zero-retention-with-your-own-storage) | Uploads start once the render has succeeded. A sync response arrives before they finish, so its upload results are still `pending`; see [Upload results](#upload-results). A render that fails uploads nothing. Every object gets the file's content type, such as `application/pdf` or `image/png`, so a file served straight from your bucket opens in the browser instead of downloading. Files larger than 8 MiB are uploaded in parts. If such an upload fails or runs out of time, we abort it, so no parts stay in your bucket. On Amazon S3 that needs `s3:AbortMultipartUpload`; without it, the parts stay until a lifecycle rule removes incomplete multipart uploads. Every request with `delivery` can upload files: `POST /v1/renders`, the convenience endpoints such as `POST /v1/pdf/from-html` (with `delivery` at the top level), [batches](https://dynamicdocumentapi.com/docs/batches) for every item, [PDF tools](https://dynamicdocumentapi.com/docs/pdf-tools) and `POST /v1/einvoices`. Naming destinations needs no scope beyond `render:write`. Signed links and editor previews are never uploaded. Test renders are uploaded like live ones, watermark included. Use `render.test` in a path template to keep them apart. ## Hosted copy [#hosted-copy] Uploading doesn't replace our hosted copy unless you ask for that. Whether a render keeps one is decided by: 1. `delivery.hosted`, when the request sends it 2. otherwise the render's destinations, those in `delivery.storage` or else the default destination: `false` when every one of them has `keep_hosted_copy: false`, `true` in all other cases, also when the render has no destination Zero-retention renders never keep one, whatever `delivery.hosted` says. Hosted copies live in our file storage in Germany, and their signed URLs point at one of two hosts, see [Signed URLs](https://dynamicdocumentapi.com/docs/renders#signed-urls). | Render | Our copy | File URLs | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | Hosted | Kept for the render's retention period, like any render | Yes | | `hosted: false` | Deleted once every upload of the file has succeeded and every email of the render has been sent. While an upload or an email is still retrying, or has failed, the copy is kept, for at most a day. | No | | Zero retention | A transient copy, see [Zero retention with your own storage](#zero-retention-with-your-own-storage) | No | The hosted copy also sets the default `delivery.type`: `url` when the render keeps one, `none` when it doesn't. Without a hosted copy, a render that sends no `type` answers without file URLs, and its files go to your destinations and email rules only. So with a default destination that has `keep_hosted_copy: false`, requests without `delivery.type` and `delivery.hosted` get no file URLs. Send `binary` or `base64` in sync mode to get the file in the response as well. > **No hosted copy, no URL delivery** > > An explicit `"type": "url"` needs a hosted copy. When the render has none because its destinations have `keep_hosted_copy: false` or the request sent `delivery.hosted: false`, the request fails with `400 validation_error` at `/delivery/hosted`, and the message names that cause. Send `delivery.hosted: true` to keep the copy, or leave out `delivery.type` for a response without file URLs. Without hosted copies, a [batch](https://dynamicdocumentapi.com/docs/batches#combined-files) can't `combine` either; send `delivery.hosted: true` to keep them. ## Path templates [#path-templates] The path decides where a file goes below the destination's `prefix`: the object key in a bucket, or the file's path on an SFTP server. It comes from the first of: 1. the render's `delivery.storage[].path` 2. the destination's `path_template` 3. `<render_id>/<filename>`, such as `rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q/invoice-2026-0042.pdf` Path templates use the [template language](https://dynamicdocumentapi.com/docs/template-language) with these variables: | Variable | Value | | ---------------------- | ------------------------------------------------------------------------------------------------------------ | | `render.id` | The render ID, `rnd_…` | | `render.created_at` | When the render was queued, in UTC, such as `2026-09-17T15:06:33Z` | | `render.template_id` | The template ID, `tpl_…`; empty for other inputs | | `render.reference` | Your `reference`; empty without one | | `render.test` | `true` for test renders | | `render.kind` | What was rendered, such as `pdf_template`, `image_url` or `pdf_merge` | | `render.output_format` | The output format, such as `pdf` or `png` | | `file.filename` | The file name, such as `invoice-2026-0042.pdf` | | `file.name` | The file name without its extension: `invoice-2026-0042` | | `file.ext` | The extension, without the dot: `pdf` | | `file.format` | The file format, such as `pdf` or `jpeg` | | `file.index` | The file's position among the render's files, starting at `0`. Splitting a PDF produces several. | | `file.sha256` | The file's SHA-256 checksum | | `file.bytes` | The file size in bytes | | `workspace_id` | Your workspace ID, `ws_…` | | `data` | The render's data, such as `data.customer_id`. Unlike in documents, data keys aren't variables of their own. | `now()` returns the time the render was queued, so a path stays the same between retries. For the render above, `rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q`, queued at `2026-09-17T15:06:33Z`: ```jinja {{ data.customer_id }}/{{ file.filename }} → c_981/invoice-2026-0042.pdf {{ render.created_at | format_date("%Y/%m") }}/{{ render.reference }}.{{ file.ext }} → 2026/09/inv_2026_0042.pdf {% if render.test %}test/{% endif %}{{ render.template_id }}/{{ render.id }}.{{ file.ext }} → tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C/rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q.pdf ``` * Missing values and `null` print as empty text, and empty folders are dropped: `invoices/{{ data.missing }}/a.pdf` becomes `invoices/a.pdf`. * The path is cleaned so it can't leave the prefix: `.` and `..` segments, leading slashes and control characters are removed, and spaces around each folder name are trimmed. * Paths are stored as they are, in UTF-8 and without percent-encoding: `Rechnungen/Müller & Söhne/Rechnung für Müller.pdf` arrives under that name. * A file that already exists at the path is replaced. Include `render.id` or another unique value if every file must be kept. * The template is fixed when the render is submitted: a new `path_template` applies to later renders only. * PDF tool results have no data, so `data` is empty in their paths. A template that doesn't parse is refused with `422 template_syntax_error`, at `/path_template` on the destination or at `/delivery/storage/<i>/path` on a render. A template that fails while rendering, renders an empty path or renders a path longer than 1,024 bytes fails that upload at once, without retries, with the `storage_upload_failed` warning and the `storage.upload_failed` event. The render itself still succeeds. ## Upload results [#upload-results] The render object's `storage` array reports the uploads. Retrieve the render with `GET /v1/renders/{id}`, or open it in the dashboard, where the same results appear under **Storage uploads**. ```json "storage": [ { "destination_id": "dst_01J9ZN2B4D6F8H0K2M4P6R8T0V", "status": "succeeded", "location": "s3://example-documents/invoices/c_981/invoice-2026-0042.pdf", "error": null } ] ``` | Field | Description | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `destination_id` | The destination | | `status` | `pending`: not uploaded yet, or waiting for a retry. `succeeded`: uploaded. `failed`: every attempt failed, or the destination was deleted first. `skipped`: the render didn't succeed, so there was nothing to upload. | | `location` | Where the file was written, once the upload has succeeded | | `error` | The last error. A `pending` entry with an `error` is waiting for its next attempt. | Until a render's uploads have been created, `storage` has one entry per destination, all `pending`. That is what the sync response and the `render.succeeded` webhook event show. After that, it has one entry per destination and file. There is no event for a successful upload: retrieve the render to confirm, and subscribe to `storage.upload_failed` for failures. | Provider | `location` | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `s3` | `s3://<bucket>/<prefix>/<path>` | | `s3_compatible` | `<endpoint>/<bucket>/<prefix>/<path>` | | `azure_blob` | `https://<account>.blob.core.windows.net/<container>/<prefix>/<path>` | | `gcs` | `gs://<bucket>/<prefix>/<path>` | | `sftp` | `sftp://<user>@<host>/<prefix>/<path>`, with `:<port>` after the host when it isn't 22, and `/~` before folders inside the login directory | ## Retries and failures [#retries-and-failures] The first attempt starts as soon as the render has succeeded. A failed attempt is retried up to five times: | Attempt | Delay before it | Roughly after the first attempt | | ------- | --------------- | ------------------------------- | | 1 | none | 0 | | 2 | 1 minute | 1 min | | 3 | 5 minutes | 6 min | | 4 | 15 minutes | 21 min | | 5 | 1 hour | 1 h 21 min | | 6 | 4 hours | 5 h 21 min | > **Fix the destination, not the render** > > Each attempt uses the destination's current settings and credentials. If uploads fail because of a wrong key or bucket policy, correct the destination and run the connection test: the remaining retries use the new settings. The path doesn't change. After the sixth failed attempt, the upload is `failed`. The render then gets the warning `storage_upload_failed`, once, however many of its uploads fail. Its message says whether a copy remains: | Render | Message | | --------------- | ----------------------------------------------------------------- | | Hosted | A storage upload failed; the file is still hosted. | | `hosted: false` | A storage upload failed; the hosted copy is kept for a day. | | Zero retention | A storage upload failed; zero retention kept no copy of the file. | Webhook endpoints subscribed to `storage.upload_failed` also receive one event per failed upload. An endpoint's template filter applies, and per-request webhooks don't receive the event. ```json { "id": "evt_01J9ZQ8D2F4H6K8M0P2R4T6V8X", "type": "storage.upload_failed", "created_at": "2026-09-17T20:27:41Z", "workspace_id": "ws_01J9ZK0P2R4T6V8X0Z2B4D6F8H", "region": "eu", "data": { "object": { "object": "storage_upload", "render_id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q", "destination_id": "dst_01J9ZN2B4D6F8H0K2M4P6R8T0V", "destination_name": "Invoice archive", "status": "failed", "attempts": 6, "error": "upload failed: … AccessDenied …", "location": null } } } ``` Deleting a destination stops its uploads. An upload that hasn't succeeded yet, because it waits for a retry or its render finishes after the delete, fails at its next attempt without connecting: it becomes `failed` with the error `the storage destination was deleted; nothing was uploaded`, and the render gets the warning and the event above, as for any failed upload. To recover the file after a failed upload: with a hosted copy, download it as usual. With `hosted: false`, `GET /v1/renders/{id}/files/{file_id}` still downloads it for up to a day after the render. Under zero retention nothing is kept, so render it again. ## Zero retention with your own storage [#zero-retention-with-your-own-storage] Zero retention (`delivery.retention: "none"`, or the workspace setting) keeps no hosted copy and logs no payloads. Combined with storage destinations, it delivers files to your storage without leaving them with us: ```bash curl https://api-eu.dynamicdocumentapi.com/v1/renders \ -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": { "type": "template", "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C" }, "data": { "employee": { "id": "e_4711", "name": "Alex Example" }, "salary": 5400 }, "mode": "async", "delivery": { "retention": "none", "storage": [ { "destination_id": "dst_01J9ZN2B4D6F8H0K2M4P6R8T0V", "path": "payslips/{{ data.employee.id }}/{{ render.id }}.pdf" } ] } }' ``` * Each file is kept as a transient copy in our EU object storage only until every upload of it has succeeded or finally failed: at most about a day while uploads are retried. Then it is deleted, whatever the outcome. * Nobody can read the copy through the API. The render shows `files: []`, `GET /v1/renders/{id}/files/{file_id}` answers `404`, and replaying a `binary` or `base64` request with its idempotency key answers `410 file_expired`. * The render keeps only non-personal metadata and its upload results, `location` included, so you can see where each file went. A zero-retention render's `reference` and `metadata` aren't stored, so `render.reference` is empty in its paths (batch items keep their own); use a value from `data` instead. * Without `delivery.type`, zero-retention renders are delivered as `none`, which needs a destination, in `delivery.storage` or as the default. Sync ones can use `binary` or `base64` instead; async ones always need a destination. `url` delivery isn't available. * In [batches](https://dynamicdocumentapi.com/docs/batches), every item is delivered the same way. Combined files aren't available, and `retry-failed` answers `409 zero_retention`. ## Plans [#plans] Storage destinations are included in every paid plan, from Starter. Uploads don't cost renders: only the render itself is billed. On the Free plan, creating a destination answers `402 plan_feature_unavailable`. When a workspace moves to the Free plan, its destinations are kept but stop receiving files until it upgrades again: * Renders skip the default destination and carry the warning `storage_skipped_plan`. Their files stay hosted, even with `hosted: false`. * Naming a destination in `delivery.storage` answers `402 plan_feature_unavailable`. * Zero-retention renders keep no copy, so nothing is uploaded. Async ones, and sync ones without `delivery.type` or with `"none"`, answer `402 plan_feature_unavailable` instead; sync `binary` and `base64` still return the file. ## Limits [#limits] | Limit | Value | | -------------------------------------------- | ------------------------------------------------ | | Destinations per workspace | 20 | | Default destinations per workspace | 1 | | Destinations per render (`delivery.storage`) | 10 | | Destination `name` | 80 characters | | `prefix` | 500 characters | | `path_template` | 500 characters | | `delivery.storage[].path` | 1,024 characters | | Rendered path below the prefix | 1,024 bytes | | Upload attempts | 6, over about 5 hours 20 minutes | | Transient copy under zero retention | Until every upload is final, at most about a day | ## Errors and warnings [#errors-and-warnings] | Status | Code | When | | ------ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `validation_error` | Invalid `config` or `credentials`; `errors[]` points at the field, such as `/config/endpoint` (code `url_not_allowed`) for an endpoint that isn't public or doesn't use `https`, `/config/storage_class` or `/config/host_key`. On a render: an unknown destination at `/delivery/storage/<i>/destination_id`, more than 10 destinations, an explicit `"type": "url"` without a hosted copy (`/delivery/hosted`), `hosted: false` with nowhere to deliver the files (`/delivery`), or a zero-retention render with no way to deliver its files (`/delivery/type`, `/mode`) | | 402 | `plan_feature_unavailable` | The plan doesn't include storage destinations: creating one, naming one in `delivery.storage`, or a zero-retention render that needs the skipped default destination, see [Plans](#plans) | | 403 | `insufficient_scope` | The key has neither `storage:read` nor `storage:write` (reads), or lacks `storage:write` (everything else) | | 404 | `not_found` | No destination with this ID in your workspace | | 409 | `conflict` | The workspace already has 20 destinations | | 422 | `template_syntax_error` | A path template doesn't parse: at `/path_template`, or at `/delivery/storage/<i>/path` on a render | Render warnings don't fail the render: | Code | Meaning | What to do | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | `storage_upload_failed` | An upload failed after every attempt. The message says whether a copy remains. | Read `storage[].error`, fix the destination and run the connection test | | `storage_skipped_plan` | The plan doesn't include storage destinations, so the default destination was skipped. The files stayed hosted; under zero retention, nothing was uploaded and no copy was kept. | Upgrade to Starter or higher, or remove the default destination | See [Errors](https://dynamicdocumentapi.com/docs/errors) for every other code. ## Related pages [#related-pages] * [Renders](https://dynamicdocumentapi.com/docs/renders) for delivery types, retention and the render object * [Webhooks](https://dynamicdocumentapi.com/docs/webhooks) to receive `storage.upload_failed` events * [Batches](https://dynamicdocumentapi.com/docs/batches) to render and upload many documents in one request * [PDF tools](https://dynamicdocumentapi.com/docs/pdf-tools), whose results can be uploaded too * [Template language](https://dynamicdocumentapi.com/docs/template-language) for filters such as `format_date` * [Authentication](https://dynamicdocumentapi.com/docs/authentication) for API key scopes * [Plans and limits](https://dynamicdocumentapi.com/docs/plans-and-limits) * [Errors](https://dynamicdocumentapi.com/docs/errors) * [API reference](https://dynamicdocumentapi.com/docs/api-reference) for the full request and response schemas # Template language (/docs/template-language) > Jinja syntax, autoescaping, built-in filters and every custom filter for money, dates, QR codes, barcodes, charts and layout, with sandbox limits. Templates are written in Jinja syntax: HTML and CSS with expressions such as `{{ customer.name }}` and blocks such as `{% for item in items %}`. The engine is a Rust implementation built on MiniJinja, extended with filters for documents. The dashboard preview and the production renderer use the same engine, so what you see in the editor is what the API produces. ## Where the template language runs [#where-the-template-language-runs] * The **body** and **head** of a template, and `input.html` and `input.head` for HTML renders (when `templating` is on, which is the default as soon as you send `data`) * **Header and footer** HTML and simple-mode text * **Markdown** input, before the Markdown is converted to HTML * **Output filenames**, where your data is available under `data`, as in `invoice-{{ data.number }}.pdf` ## Syntax [#syntax] ### Variables [#variables] Keys of the `data` object are available as top-level variables. Use dots or brackets for nested values: ```jinja <h1>Invoice {{ invoice.number }}</h1> <p>{{ customer["name"] }} · {{ items[0].description }}</p> ``` The whole object is also available as `data`, and `render` describes the render itself: | Variable | Description | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `render.id`, `render.created_at`, `render.template_id`, `render.test` | The render ID, its time (RFC 3339), the template ID (empty for HTML, URL and Markdown renders) and whether it is a test render | | `render.invoice` | The invoice amounts computed by the platform (line net amounts, VAT breakdown, totals) for templates with invoice data; see [Print the computed amounts](https://dynamicdocumentapi.com/docs/e-invoicing#print-the-computed-amounts) | | `render.einvoice` | The same amounts plus the `profile`, only when the PDF embeds an e-invoice | ### Filters [#filters] Filters transform a value. They take arguments and can be chained: ```jinja {{ customer.name | upper }} {{ note | default("No notes") | truncate(80) }} {{ items | map(attribute="quantity") | sum }} ``` ### Tests [#tests] Tests ask a question about a value with `is`: ```jinja {% if discount is defined and discount > 0 %}…{% endif %} {% if item.note is none %}…{% endif %} {% if loop.index is even %}…{% endif %} ``` Available tests include `defined`, `undefined`, `none`, `boolean`, `true`, `false`, `number`, `integer`, `float`, `string`, `sequence`, `mapping`, `iterable`, `odd`, `even`, `divisibleby(n)`, `lower`, `upper`, `sameas`, `in`, and the comparison tests `eq`, `ne`, `lt`, `le`, `gt` and `ge`. ### Conditions [#conditions] ```jinja {% if invoice.status == "paid" %} <span class="badge badge-paid">Paid</span> {% elif invoice.status == "overdue" %} <span class="badge badge-overdue">Overdue</span> {% else %} <span class="badge">Open</span> {% endif %} ``` Expressions support `==`, `!=`, `<`, `<=`, `>`, `>=`, `and`, `or`, `not`, `in`, arithmetic (`+`, `-`, `*`, `/`, `//`, `%`, `**`), string concatenation with `~`, and an inline conditional: ```jinja {{ "Paid" if invoice.status == "paid" else "Due" }} ``` ### Loops [#loops] ```jinja <tbody> {% for item in items %} <tr class="{{ loop.cycle('odd', 'even') }}"> <td>{{ loop.index }}</td> <td>{{ item.description }}</td> <td>{{ item.amount | format_currency("EUR", "de-DE") }}</td> </tr> {% else %} <tr><td colspan="3">No items</td></tr> {% endfor %} </tbody> ``` Inside a loop, `loop` describes the iteration: | Variable | Value | | --------------------------------- | --------------------------------------------------------- | | `loop.index`, `loop.index0` | Position, counting from 1 or from 0 | | `loop.revindex`, `loop.revindex0` | Position counted from the end | | `loop.first`, `loop.last` | Whether this is the first or last iteration | | `loop.length` | Number of items | | `loop.cycle(a, b, …)` | Cycles through the given values | | `loop.previtem`, `loop.nextitem` | The neighbouring items | | `loop.changed(value)` | `true` when the value differs from the previous iteration | | `loop.depth`, `loop.depth0` | Nesting level in a `recursive` loop | You can filter and unpack while looping: ```jinja {% for item in items if item.quantity > 0 %}…{% endfor %} {% for key, value in totals.items() %}…{% endfor %} ``` The `else` block runs when the sequence is empty, which is a tidy way to handle empty tables. ### Assignments [#assignments] ```jinja {% set subtotal = items | sum_by("amount") %} {% set tax = subtotal * invoice.tax_rate %} ``` Variables set inside a loop don't survive the iteration. Use a namespace to accumulate across iterations: ```jinja {% set totals = namespace(weight=0) %} {% for parcel in parcels %} {% set totals.weight = totals.weight + parcel.weight %} {% endfor %} <p>Total weight: {{ totals.weight | format_number("en-GB", 2) }} kg</p> ``` Block assignments capture markup: ```jinja {% set address %} {{ customer.street }}<br> {{ customer.postcode }} {{ customer.city }} {% endset %} ``` ### with [#with] `with` scopes one or more variables to a block: ```jinja {% with total = items | sum_by("amount") %} <p>Total: {{ total | format_currency("EUR", "en-IE") }}</p> {% endwith %} ``` ### Macros and call [#macros-and-call] Macros are reusable snippets: ```jinja {% macro money(amount, currency, locale) -%} <span class="amount">{{ amount | format_currency(currency, locale) }}</span> {%- endmacro %} <td>{{ money(item.amount, invoice.currency, invoice.locale) }}</td> ``` `call` passes a block of markup to a macro, which renders it with `caller()`: ```jinja {% macro panel(title) %} <section class="panel"><h2>{{ title }}</h2>{{ caller() }}</section> {% endmacro %} {% call panel("Payment details") %} <p>IBAN: {{ payment.iban }}</p> {% endcall %} ``` ### Raw blocks [#raw-blocks] Everything inside `raw` is copied verbatim, which is useful when the document itself talks about template syntax: ```jinja {% raw %}Write {{ customer.name }} to insert the customer name.{% endraw %} ``` ### Whitespace control [#whitespace-control] A minus sign next to a block delimiter strips the whitespace on that side. This matters in HTML where stray spaces can affect inline layout: ```jinja {%- for tag in tags -%} {{ tag }}{% if not loop.last %}, {% endif %} {%- endfor -%} ``` ### Comments [#comments] ```jinja {# Totals come from the billing system and are not recalculated here #} ``` Comments never appear in the output. ### Includes and imports [#includes-and-imports] Shared partials at workspace level, such as a legal footer or an address block, are planned. They will be versioned like templates and available through `include` and `import`. Templates cannot read files from disk or fetch other templates. ## Autoescaping [#autoescaping] HTML sources are autoescaped: characters such as `<`, `>` and `&` in your data become HTML entities, so data can never inject markup by accident. The head, the header and the footer are escaped too. Markdown templates are escaped the same way, and a value inside a code span or a fenced code block prints exactly as your data has it: `{{ release.install_command }}` in a code block shows `npm install x && y migrate`, not `&&`. To output HTML that you trust, mark it safe: ```jinja {{ product.description_html | safe }} ``` Only use `safe` for content you control. For text written by users, use the `markdown` filter instead, which sanitizes its output. Filters that build markup — `qrcode`, `barcode`, `render_chart`, `table`, `markdown`, `nl2br`, `page_break()` and the `render_*` aliases — return safe HTML already, so you never need `safe` with them. `tojson` produces JSON that is safe to embed in HTML. ## Undefined values [#undefined-values] Missing variables render as an empty string, and so does attribute access on a missing value: `{{ customer.address.city }}` is empty when `address` isn't there. This keeps a missing field from breaking a whole document. Two ways to handle gaps deliberately: ```jinja {{ customer.vat_id | default("—") }} {% if customer.vat_id is defined %}VAT {{ customer.vat_id }}{% endif %} ``` A JSON `null` is a defined value, so `default` doesn't replace it unless you also pass `true`: ```jinja {{ customer.vat_id | default("—", true) }} ``` To catch typos and missing data during development, set `strict_undefined: true` in the render request. Referencing an undefined variable then fails with `template_runtime_error` and the path that was missing, instead of rendering an empty space. ## Python method compatibility [#python-method-compatibility] Templates written for Python Jinja2 often call Python methods on strings and dictionaries. These work here too: **Strings:** `capitalize`, `count`, `endswith`, `find`, `format`, `isalnum`, `isalpha`, `isascii`, `islower`, `isnumeric`, `isspace`, `isupper`, `join`, `lower`, `lstrip`, `replace`, `rfind`, `rstrip`, `split`, `splitlines`, `startswith`, `strip`, `title`, `upper` **Maps:** `get`, `items`, `keys`, `values` ```jinja {{ customer.email.strip().lower() }} {{ "{} of {}".format(loop.index, items | length) }} {{ settings.get("currency", "EUR") }} {% if order.reference.startswith("TEST-") %}…{% endif %} {% for key, value in totals.items() %}…{% endfor %} ``` Values are immutable, so list methods that modify in place, such as `append`, are not available. Build lists with filters, or accumulate with `namespace`. ## Built-in filters [#built-in-filters] The Jinja2 built-ins are available. The most useful ones for documents: | Filter | Description | Example | | ---------------------------- | ------------------------------------------------------------ | --------------------------------------------------- | | `abs` | Absolute value | `{{ -3 \| abs }}` | | `attr(name)` | Look up an attribute by name | `{{ item \| attr("sku") }}` | | `batch(n)` | Split a sequence into chunks of `n` | `{% for row in items \| batch(3) %}` | | `capitalize` | First letter upper case, rest lower case | `{{ "hello world" \| capitalize }}` | | `count`, `length` | Number of items or characters | `{{ items \| length }}` | | `default(value)`, `d(value)` | Fallback for undefined values | `{{ note \| default("—") }}` | | `dictsort` | Sort a mapping by key | `{% for k, v in totals \| dictsort %}` | | `escape`, `e` | Escape HTML | `{{ raw_text \| escape }}` | | `first`, `last` | First or last item | `{{ items \| first }}` | | `float`, `int` | Convert to a number | `{{ "12.50" \| float }}` | | `format(…)` | Printf-style formatting | `{{ "%s of %s" \| format(3, 10) }}` | | `groupby(attribute)` | Group a list by an attribute | `{% for g in items \| groupby("category") %}` | | `indent(n)` | Indent lines | `{{ text \| indent(4) }}` | | `items` | Key-value pairs of a mapping | `{% for k, v in totals \| items %}` | | `join(separator)` | Join a sequence into a string | `{{ tags \| join(", ") }}` | | `list` | Convert to a list | `{{ "abc" \| list }}` | | `lower`, `upper`, `title` | Change case | `{{ name \| upper }}` | | `map(attribute=…)` | Pick an attribute from every item | `{{ items \| map(attribute="sku") \| join(", ") }}` | | `max`, `min` | Largest or smallest value | `{{ prices \| max }}` | | `random` | A random item (avoid in documents that must be reproducible) | `{{ quotes \| random }}` | | `reject`, `select` | Keep items that fail or pass a test | `{{ numbers \| select("odd") \| list }}` | | `rejectattr`, `selectattr` | The same, by attribute | `{{ items \| selectattr("taxable") \| list }}` | | `replace(old, new)` | Replace text | `{{ phone \| replace(" ", "") }}` | | `reverse` | Reverse a sequence | `{{ items \| reverse \| list }}` | | `round(precision)` | Round a number | `{{ 3.14159 \| round(2) }}` | | `safe` | Mark a string as HTML | `{{ snippet \| safe }}` | | `slice(n)` | Split into `n` columns | `{% for column in items \| slice(2) %}` | | `sort(attribute=…)` | Sort a sequence | `{{ items \| sort(attribute="position") }}` | | `string` | Convert to a string | `{{ 42 \| string }}` | | `sum(attribute=…)` | Sum numbers | `{{ items \| sum(attribute="amount") }}` | | `tojson(indent=…)` | Serialize as JSON | `{{ chart \| tojson }}` | | `trim` | Strip surrounding whitespace | `{{ note \| trim }}` | | `truncate(n)` | Shorten text | `{{ description \| truncate(60) }}` | | `unique` | Remove duplicates | `{{ tags \| unique \| list }}` | | `urlencode` | Percent-encode for URLs | `{{ query \| urlencode }}` | | `wordcount` | Count words | `{{ text \| wordcount }}` | | `wordwrap(width)` | Wrap long text | `{{ text \| wordwrap(60) }}` | Global functions include `range(start, stop, step)`, `dict(…)`, `namespace(…)`, `cycler(…)` and `joiner(separator)`. ## Formatting filters [#formatting-filters] These use CLDR locale data, so number, currency and date formats match local conventions. ### format\_currency [#format_currency] ```jinja {{ amount | format_currency(currency, locale, decimals) }} ``` Formats a number as an amount of money. `currency` is an ISO 4217 code such as `"EUR"`. `locale` is a BCP 47 tag and defaults to `"en"`. `decimals` is optional and defaults to the currency's usual number of decimal places. Values are rounded half up. ```jinja {{ 1234.5 | format_currency("EUR", "de-DE") }} {# 1.234,50 € #} {{ 1234.5 | format_currency("USD", "en-US") }} {# $1,234.50 #} {{ 1234.5 | format_currency("USD", "en-US", 0) }} {# $1,235 #} ``` ### format\_number [#format_number] ```jinja {{ value | format_number(locale, decimals) }} ``` Formats a number with the locale's grouping and decimal separators. Without `decimals`, the locale's standard format is used. ```jinja {{ 1234567.891 | format_number("en-US", 2) }} {# 1,234,567.89 #} {{ 1234567.891 | format_number("de-DE", 2) }} {# 1.234.567,89 #} {{ 12.5 | format_number("en-GB", 1) }} {# 12.5 #} ``` ### format\_percent [#format_percent] ```jinja {{ ratio | format_percent(locale, decimals) }} ``` Formats a ratio as a percentage: `0.19` becomes 19 %. Pass `decimals` for fractional percentages. ```jinja {{ 0.19 | format_percent("de-DE") }} {# 19 % #} {{ 0.19 | format_percent("en-US") }} {# 19% #} {{ 0.0725 | format_percent("en-US", 2) }} {# 7.25% #} ``` ### number\_to\_words [#number_to_words] ```jinja {{ number | number_to_words(lang) }} ``` Writes a whole number in words, for cheques and contracts. `lang` is `en`, `de`, `fr`, `es` or `nl`. Pass an integer; for amounts with cents, convert the whole part and add the cents separately. ```jinja {{ 1250 | number_to_words("en") }} {# one thousand two hundred and fifty #} ``` ## Date and time filters [#date-and-time-filters] Dates can be ISO 8601 strings such as `"2026-09-14"` or `"2026-09-14T09:30:00Z"`, or values produced by `strptime`, `epoch_to_datetime`, `date_add` and `now`. ### format\_date [#format_date] ```jinja {{ value | format_date(style_or_pattern, locale, tz) }} ``` Formats a date or date-time. The first argument is either a locale-aware style — `short`, `medium`, `long` or `full` — or a strftime pattern such as `"%d.%m.%Y"`. `locale` defaults to `"en"`, and `tz` is an IANA time zone that defaults to `"UTC"`. ```jinja {{ "2026-09-14" | format_date("short", "en-US") }} {# 9/14/26 #} {{ "2026-09-14" | format_date("medium", "en-US") }} {# Sep 14, 2026 #} {{ "2026-09-14" | format_date("long", "en-GB") }} {# 14 September 2026 #} {{ "2026-09-14" | format_date("full", "de-DE") }} {# Montag, 14. September 2026 #} {{ "2026-09-14" | format_date("%d.%m.%Y") }} {# 14.09.2026 #} {{ "2026-09-14T09:30:00Z" | format_date("%d %b %Y, %H:%M", "en-GB", "Europe/Berlin") }} {# 14 Sep 2026, 11:30 #} ``` Common pattern directives: | Directive | Meaning | Example | | ---------------- | ---------------------------- | ------------------ | | `%Y`, `%y` | Year, four or two digits | `2026`, `26` | | `%m`, `%d` | Month and day, zero-padded | `09`, `14` | | `%B`, `%b` | Month name, full or short | `September`, `Sep` | | `%A`, `%a` | Weekday name, full or short | `Monday`, `Mon` | | `%H`, `%M`, `%S` | Hours (24), minutes, seconds | `09`, `30`, `00` | | `%I`, `%p` | Hours (12) and AM/PM | `09`, `AM` | | `%j` | Day of the year | `257` | | `%z`, `%Z` | UTC offset, time zone name | `+0200`, `CEST` | ### date\_add [#date_add] ```jinja {{ value | date_add(days=…, months=…) }} ``` Shifts a date by days, months or both. Use it for due dates and validity periods. ```jinja {{ invoice.issue_date | date_add(days=30) | format_date("medium", "en-US") }} {{ "2026-09-14" | date_add(months=3) | format_date("%Y-%m-%d") }} {# 2026-12-14 #} ``` ### now [#now] ```jinja {{ now(tz) }} ``` The current time, in UTC unless you pass an IANA time zone. Format it with `format_date` or `strftime`. ```jinja Generated {{ now("Europe/Berlin") | format_date("%d.%m.%Y %H:%M", "de-DE", "Europe/Berlin") }} ``` Templates that use `now()` produce different output every time they run. When a document must be reproducible, pass the date in your data instead. ### strptime [#strptime] ```jinja {{ text | strptime(format) }} ``` Parses a date-time from text that isn't ISO 8601. ```jinja {{ "14/09/2026" | strptime("%d/%m/%Y") | format_date("long", "en-GB") }} {# 14 September 2026 #} ``` ### strftime [#strftime] ```jinja {{ datetime | strftime(format) }} ``` Formats a date-time with a strftime pattern. Month and weekday names are English; use `format_date` when you need them localized. ```jinja {{ strptime("2026-09-14 09:30", "%Y-%m-%d %H:%M") | strftime("%H:%M on %d %b") }} {# 09:30 on 14 Sep #} ``` ### epoch\_to\_datetime [#epoch_to_datetime] ```jinja {{ epoch_to_datetime(seconds, tz_hours=0) }} ``` Converts a Unix timestamp in seconds to a date-time, optionally at a fixed UTC offset. ```jinja {{ epoch_to_datetime(1789657594) | strftime("%Y-%m-%d %H:%M") }} {# 2026-09-17 15:06 #} {{ epoch_to_datetime(1789657594, tz_hours=2) | strftime("%Y-%m-%d %H:%M") }} {# 2026-09-17 17:06 #} ``` ## Codes [#codes] ### qrcode [#qrcode] ```jinja {{ value | qrcode(size=…, margin=…, ecc=…, color=…, background=…, style=…, class=…, alt=…, inline=…) }} ``` Renders a QR code as an image element containing an SVG, so it stays sharp at any print resolution. It can also be called as a function: `{{ qrcode(value, size=160) }}`. | Argument | Description | | ------------ | ---------------------------------------------------------------------------------------------------------------------------- | | `size` | Width and height in pixels, or a CSS length such as `"30mm"`. Default `200` | | `margin` | Quiet zone around the code, in modules. Default `4`; `0` removes it | | `ecc` | Error correction level: `L`, `M` (default), `Q` or `H` | | `color` | Colour of the modules: hex (`#1b2a4a`), `rgb(…)`, `rgba(…)` or a colour name. Default black | | `background` | Background colour; `transparent` leaves it out. Default white | | `style` | Inline CSS applied to the generated image element | | `class` | CSS class of the image element | | `alt` | Alternative text of the image. Default `"QR code"` | | `inline` | `true` outputs the `<svg>` element itself instead of an image (`style`, `class` and `alt` then don't apply). Default `false` | ```jinja {{ ticket.url | qrcode(size=160, margin=0, ecc="Q") }} {{ invoice.payment_link | qrcode(size=120, color="#1b2a4a", style="float:right") }} ``` Use a higher error correction level (`Q` or `H`) for codes that will be printed on labels or may be partly covered. ### barcode [#barcode] ```jinja {{ value | barcode(type, module_width=…, module_height=…, quiet_zone=…, font_size=…, text_distance=…, write_text=…, foreground=…, background=…, font_family=…, style=…, class=…, alt=…, inline=…) }} ``` Renders a 1D barcode as SVG, with the human-readable text below it. The function form is `{{ barcode(value, "code128") }}`. | Argument | Description | | --------------- | ---------------------------------------------------------------------------------------- | | `type` | The symbology, from the table below. Default `code128` | | `module_width` | Width of the narrowest bar in millimetres (0.05 to 5). Default `0.2` | | `module_height` | Height of the bars in millimetres (1 to 500). Default `15` | | `quiet_zone` | Empty space left and right of the bars in millimetres. Default `6.5` | | `font_size` | Size of the text in points (1 to 72). Default `10` | | `text_distance` | Distance from the bars to the text baseline in millimetres. Default `5` | | `write_text` | `false` leaves out the human-readable text. Default `true` | | `foreground` | Colour of the bars and the text, as for `qrcode`; `color` is accepted too. Default black | | `background` | Background colour; `transparent` leaves it out. Default white | | `font_family` | CSS font family of the text. Default `monospace` | | `style` | Inline CSS applied to the generated image element | | `class` | CSS class of the image element | | `alt` | Alternative text of the image. Default: the encoded text | | `inline` | `true` outputs the `<svg>` element itself instead of an image. Default `false` | The symbologies: | Type | Use | | ----------------------------- | ------------------------------------------- | | `code128`, `code39`, `code93` | General purpose, alphanumeric | | `ean8`, `ean13`, `jan` | Retail articles | | `upca`, `upce` | Retail articles in North America | | `itf`, `gtin14` | Cartons and logistics units | | `gs1_128` | Logistics data with application identifiers | | `codabar` | Libraries, blood banks, logistics | | `isbn10`, `isbn13`, `issn` | Books and periodicals | | `pzn` | Pharmaceutical products | Check digits are calculated for `isbn10`, `isbn13`, `issn`, `gtin14`, `jan`, `pzn` and `gs1_128`. Sizes are given in millimetres (`module_width`, `module_height`, `quiet_zone`, `text_distance`) and the text size in points (`font_size`); CSS lengths such as `"0.3mm"` or `"12pt"` work too. ```jinja {{ parcel.tracking_number | barcode("code128", module_height=14) }} {{ product.gtin | barcode("ean13") }} ``` ## Data helpers [#data-helpers] ### sum\_by [#sum_by] ```jinja {{ list | sum_by(attribute) }} ``` Adds up one attribute across a list of objects. With `items` set to `[{"amount": 120.0}, {"amount": 80.5}]`: ```jinja {{ items | sum_by("amount") }} {# 200.5 #} {{ items | sum_by("amount") | format_currency("EUR", "en-IE") }} {# €200.50 #} ``` ### group\_by [#group_by] ```jinja {{ list | group_by(attribute) }} ``` Groups a list of objects by an attribute. Each group has `grouper` (the shared value) and `list` (the items), the same shape as the built-in `groupby` filter. ```jinja {% for group in items | group_by("category") %} <h3>{{ group.grouper }}</h3> <ul> {% for item in group.list %}<li>{{ item.name }}</li>{% endfor %} </ul> <p>Subtotal: {{ group.list | sum_by("amount") | format_currency("EUR", "en-IE") }}</p> {% endfor %} ``` ### json and tojson [#json-and-tojson] ```jinja {{ value | tojson(indent=…) }} ``` Serializes a value as JSON, escaped so that it is safe inside HTML. `json` is an alias of `tojson`. This is the safe way to hand data to a script in the page: ```jinja <script type="application/json" id="chart-data">{{ chart | tojson }}</script> {{ {"sku": "A1", "qty": 2} | tojson }} {# {"sku":"A1","qty":2} #} ``` ### gt, gte, lt, lte [#gt-gte-lt-lte] ```jinja {{ gt(a, b) }} ``` Comparison helpers that return `true` or `false`, equivalent to `a > b`, `a >= b`, `a < b` and `a <= b`. They also work as filters, where the piped value is the first argument. They exist mainly for templates migrated from other systems; in new templates, the operators read better. ```jinja {% if gt(invoice.total, 1000) %}Approval required{% endif %} {{ stock.quantity | lte(stock.reorder_level) }} {# true #} ``` ## Content filters [#content-filters] ### markdown [#markdown] ```jinja {{ text | markdown }} ``` Converts GitHub Flavored Markdown to sanitized HTML. Scripts, event handlers and unsafe URLs are removed, which makes it the right filter for text your users write. ```jinja {{ invoice.notes | markdown }} ``` With `notes` set to `"**Payment terms:** 30 days.\n\nThank you for your business."`, the output is: ```html <p><strong>Payment terms:</strong> 30 days.</p> <p>Thank you for your business.</p> ``` ### nl2br [#nl2br] ```jinja {{ text | nl2br }} ``` Escapes the text and turns line breaks into `<br>` elements. Useful for addresses and free-text fields. ```jinja {{ customer.address | nl2br }} {# Example Ltd<br>1 Sample Street<br>Dublin #} ``` ### slugify [#slugify] ```jinja {{ text | slugify }} ``` Turns text into a lowercase, hyphenated identifier, handy in filenames and anchors. ```jinja {{ "Quarterly Report: Q3 2026" | slugify }} {# quarterly-report-q3-2026 #} ``` ### table [#table] ```jinja {{ list | table(class=…, columns=…, headers=…) }} ``` Renders a list of objects as an HTML table. Without `columns`, the columns are the union of the objects' keys in the order they first appear, and the keys are used as headers. `columns` selects and orders columns, `headers` gives them labels in the same order, and `class` sets the table's CSS class. ```jinja {{ items | table(class="items", columns=["sku", "description", "qty"], headers=["SKU", "Description", "Qty"]) }} ``` ```html <table class="items"> <thead><tr><th>SKU</th><th>Description</th><th>Qty</th></tr></thead> <tbody> <tr><td>A1</td><td>Copy paper, A4</td><td>2</td></tr> </tbody> </table> ``` Write the table markup yourself when you need per-column styling, totals or repeating headers; `table` is for quick, uniform data. ### image\_data\_uri [#image_data_uri] ```jinja {{ image_data_uri(url, max_bytes=…) }} ``` Downloads an image and returns it as a `data:` URI, so it is embedded in the document instead of linked. The download goes through the same policy-checked fetcher as page assets — public HTTP and HTTPS only — and is cached. `max_bytes` caps the size and defaults to 5 MB. It also works as a filter. ```jinja <img src="{{ image_data_uri(company.logo_url) }}" alt="{{ company.name }}" style="height:12mm"> ``` This is the reliable way to show a remote image in a header or footer, which renders in an isolated context. ## Layout [#layout] ### page\_break [#page_break] ```jinja {{ page_break() }} ``` Inserts an element that forces a page break, which is easier to place inside loops than a CSS rule. ```jinja {% for certificate in certificates %} <section class="certificate">…</section> {% if not loop.last %}{{ page_break() }}{% endif %} {% endfor %} ``` See [Pagination basics](https://dynamicdocumentapi.com/docs/pagination) for the CSS approach and for keeping blocks together. ## Charts [#charts] ### render\_chart [#render_chart] ```jinja {{ rows | render_chart(type, label=…, value=…, …) }} {{ render_chart(type, rows, label=…, value=…, …) }} ``` Draws a chart from your data: `bar`, `horizontal-bar`, `line`, `area`, `pie`, `donut`, `radar`, `scatter` or `gauge`. The renderer draws it as SVG with the same chart engine as image templates, so a chart looks the same in both editors and stays sharp at any zoom. Your document needs no script for it: you don't have to turn on `javascript` or wait for a ready flag, and charts work in PDF/A files and e-invoices. ```jinja {{ monthly_revenue | render_chart("bar", label="month", value="revenue") }} ``` #### Data [#data] A chart takes its data from a list of rows, or from lists that you give directly. | Argument | Description | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `rows` | A list of objects, one per category: the second argument of the function, or the value the filter is applied to | | `label` | The path of each row's category label, such as `"month"`. It is written as text; a missing value is an empty label | | `value` | The path of each row's value, such as `"revenue"`, or a list of paths for one series each: `["revenue", "costs"]` | | `names` | The series names, one per `value` path. Default: the path's last part with `_` as a space and a capital first letter, so `"amount.net_total"` is named "Net total" | | `labels` | A list of category labels. With `rows`, it replaces the row labels, for example with formatted months | | `values` | A list of numbers for one series, used with `labels` instead of `rows` | | `series` | A list of series, each `{"name": …, "values": […], "color": …}`, used with `labels` instead of `rows` | Paths reach into nested objects and lists: `"amount.net"`, `"totals.0.value"`. Values are numbers or numeric strings such as `"1234.5"`; anything else, including `null` and `""`, leaves a gap. A chart takes at most 10,000 labels, 50 series and 10,000 values per series. When `rows` is missing or `null` in the data, the chart is drawn empty instead of failing, like `table`. A scatter chart plots points: x comes from `label` (or `labels`), which must then be numbers, and y from `value`. Points with a missing x or y are left out. A `series` entry can also list its points directly: `"points": [{"x": 1, "y": 2}, …]`. #### Size [#size] `width` and `height` are CSS lengths in `px`, `mm`, `cm`, `in` or `pt`, or numbers of pixels. The default is `width="160mm"` and `height="70mm"`, and each must be between 16 and 4,000 pixels (96 pixels per inch). In a narrower container, the chart scales down and keeps its proportions. #### Look [#look] | Argument | Description | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `title` | A title above the chart | | `colors` | The series colours, as a list of hex colours: `#rgb`, `#rrggbb` or `#rrggbbaa` | | `highlight_last` | `true` draws the last bar or point of a single-series `bar`, `horizontal-bar`, `line` or `area` chart in the second colour and the others in the first, to set the current period apart from the previous ones | | `legend` | `true`, `false` or a position: `"top"`, `"bottom"`, `"left"` or `"right"`. Default: at the bottom for more than one series and for `pie` and `donut`, otherwise hidden | | `show_values` | `true` writes the value next to each bar, point or slice | | `stacked` | `true` stacks the series of a bar, line or area chart | | `smooth` | `true` draws smooth curves in line and area charts | | `font_family` | The font of all text. Default `"Inter"` | | `font_size` | The text size in pixels. Default `12` | | `text_color` | The text colour, as a hex colour | | `background` | A background colour, as a hex colour. Without it, the chart is transparent | #### Axes [#axes] | Argument | Description | | -------------------- | ----------------------------------------------------------------------------- | | `x_title`, `y_title` | Axis titles | | `min`, `max` | The range of the value axis, and the scale of a `gauge` (0 to 100 by default) | | `grid` | `false` hides the grid lines. Default `true` | | `axis_labels` | `false` hides the axis labels. Default `true` | #### Numbers [#numbers] Numbers on the axis, in value labels and in pie and donut labels are plain numbers unless you choose a format: | Argument | Description | | ---------- | -------------------------------------------------------------------------------------------------------------- | | `format` | `"number"` (default), `"money"` or `"percent"`. Percent values are in percent units: `7.2` is written as 7.2 % | | `currency` | An ISO 4217 code such as `"EUR"`; required with `"money"` | | `locale` | A BCP 47 tag such as `"de-DE"` for separators and symbols. Default `"en"` | | `compact` | `true` writes short numbers: 138,400 becomes 138K | | `decimals` | The number of decimal places, from 0 to 6 | #### Reference lines [#reference-lines] `reference` draws a line at a value across a `bar`, `line` or `area` chart, such as a monthly target: horizontal, or vertical on a `horizontal-bar` chart. Pass a list for several lines, at most 10. `reference_label` labels the line (a list labels several lines in order), and `reference_color` sets its colour. #### Output [#output] `render_chart` returns a `<dda-chart>` placeholder element that describes the chart. The renderer replaces it with an `<svg>` element before the document is printed, in the body, the header and the footer, and in `format: "html"` output. If a chart can't be drawn, it leaves an empty box of the chart's size and an `asset_failed` warning. | Argument | Description | | -------- | ------------------------------------------------------------------------------------------ | | `alt` | The text for screen readers and PDF accessibility. Default: the `title`, otherwise "Chart" | | `class` | A CSS class for the chart | | `style` | Inline CSS added to the chart's own | HTML previews return the placeholder as it is, and the dashboard draws it. An unknown chart type or argument, or a value out of range, fails with `template_runtime_error` and a message that names the argument. #### Example [#example] With monthly revenue in the data: ```json { "currency": "EUR", "target": 180000, "monthly_revenue": [ { "month": "2026-06-01", "revenue": 163400, "costs": 121000 }, { "month": "2026-07-01", "revenue": 171900, "costs": 118500 }, { "month": "2026-08-01", "revenue": 184250, "costs": 126300 } ] } ``` this bar chart shows the revenue per month in euros against the target, with the current month highlighted and the months written as "Jun 2026": ```jinja {{ render_chart("bar", monthly_revenue, label="month", value="revenue", labels=monthly_revenue | map(attribute="month") | map("format_date", "%b %Y") | list, title="Revenue", format="money", currency=currency, compact=true, highlight_last=true, colors=["#c7d2fe", "#4338ca"], reference=target, reference_label="Monthly target", width="170mm", height="70mm", alt="Revenue per month against the monthly target") }} ``` Two series as lines, and a donut from values written in the template: ```jinja {{ monthly_revenue | render_chart("line", label="month", value=["revenue", "costs"], legend="top") }} {{ render_chart("donut", labels=["Online", "Retail", "Partners"], values=[52, 31, 17], format="percent") }} ``` ## Compatibility aliases [#compatibility-aliases] Templates migrated from other systems often use these names. They behave like the filters above: | Alias | Same as | Notes | | ------------------------------------------------------------------------------------------------ | --------- | -------------------------------- | | `render_qrcode(style=…)` | `qrcode` | Accepts the same options | | `render_barcode(type, style, quiet_zone, font_size, text_distance, module_width, module_height)` | `barcode` | Same type names and option names | | `render_table(table_class=…)` | `table` | `table_class` sets the CSS class | | `json` | `tojson` | Same output | ## Sandbox and limits [#sandbox-and-limits] Templates run in a sandbox with no access to the filesystem, the network or the host: the only exception is `image_data_uri`, which goes through the policy-checked asset fetcher. | Limit | Value | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Execution budget | Every operation consumes fuel, from 50,000 units on Free to 20 million on Scale. Exceeding it fails with `422 template_fuel_exhausted`. | | Recursion depth | 50, for nested macros, recursive loops and includes | | Output size | 50 MB of rendered HTML | | Time | Bounded by the render's sync timeout or async maximum duration | Most templates stay far below the execution budget. Deeply nested loops over thousands of rows are the usual cause of `template_fuel_exhausted`: precompute totals in your application, or split the document into several renders. ## Errors [#errors] Template problems are reported with the line, column and an excerpt of the source, both in the error response and in the render's `error` field: ```json { "type": "https://docs.dynamicdocumentapi.com/errors/template_runtime_error", "title": "Template runtime error", "status": 422, "code": "template_runtime_error", "detail": "'dict object' has no attribute 'total' (line 12, column 7)", "render_id": "rnd_01J9ZM1X3F7R8K2C4V6B8N0P2Q", "request_id": "req_01J9ZM4T6W8Y0A2C4E6G8J0K2M", "doc_url": "https://docs.dynamicdocumentapi.com/errors/template_runtime_error" } ``` * `template_syntax_error`: the template can't be parsed, for example because of an unclosed block. Publishing a template validates the syntax, so these rarely reach production. * `template_runtime_error`: something went wrong while rendering, such as an unknown filter, an unsupported operation between types, or an undefined value in strict mode. * `template_fuel_exhausted`: the template exceeded its execution budget. See [Errors](https://dynamicdocumentapi.com/docs/errors) for the full list. ## Differences from Python Jinja2 [#differences-from-python-jinja2] The engine implements Jinja2 syntax and semantics and is checked against Python Jinja2 with a differential test suite, but it doesn't run Python. Keep these differences in mind when you port templates: * Python methods are limited to the [compatibility list](#python-method-compatibility). A datetime's `.strftime()` or a list's `.append()` are not available; use the `strftime` filter and `namespace` instead. * Values are immutable, so nothing can be modified in place. * Booleans render as `true` and `false`. * Templates are loaded from the version bundle only. There is no filesystem loader, and workspace partials are planned. * The `%` string-formatting operator isn't supported. Use the `format` filter or `.format()`. * Jinja2 extensions such as `{% trans %}` and custom Python filters aren't available. The [custom filters](#formatting-filters) cover the common document cases. # Webhooks (/docs/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"}`. Every event type has a sample in the real event's shape, with made-up IDs and no file URLs. | | `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](https://dynamicdocumentapi.com/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](https://dynamicdocumentapi.com/docs/storage) failed for good | Available | | `email.sent` | Your provider accepted an email of an [email rule](https://dynamicdocumentapi.com/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 expires in 7 days; sent once, together with the email | Available | For `api_key.expiring`, `data.object` is the key without its secret: `id`, `name`, `mode`, `prefix`, `last4`, `scopes`, `expires_at`, `last_used_at` and `created_at`. For render events, `data.object` is the full [render object](https://dynamicdocumentapi.com/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://dda-eu-outputs.nbg1.your-objectstorage.com/…" }] } } } ``` | 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] In addition to the endpoints you configure, 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, so the workspace needs one: without it, the request is rejected with `400 validation_error` at `/webhook`. 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. 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 ``` 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) ``` ### 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. The [TypeScript](https://dynamicdocumentapi.com/docs/sdks-and-integrations#typescript-and-javascript) and [Python](https://dynamicdocumentapi.com/docs/sdks-and-integrations#python) SDKs have a built-in `webhooks.verify()` helper; SDKs for other languages are [planned](https://dynamicdocumentapi.com/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. # Zapier (/docs/zapier) > Create PDFs, images and e-invoices from your templates in Zapier, start Zaps when renders finish, and attach the files to emails, drives and chats. The Dynamic Document API app for Zapier fills your templates with data from any of Zapier's apps and hands the finished file to the next step: attach the invoice to an email, upload the certificate to a drive, post the social image to a channel. Triggers start a Zap when a render succeeds or fails, a batch completes or a template gets a new version. The app is listed in the [Zapier app directory](https://zapier.com/apps/dynamic-document-api/integrations): search for Dynamic Document API when you add a step to a Zap. ## Connect your account [#connect-your-account] When you add a Dynamic Document API step to a Zap, Zapier asks you to connect an account. You sign in with your Dynamic Document API account ([free plan, no card needed](https://app.dynamicdocumentapi.com/signup)) and the consent screen shows: * **The workspace** the connection may use. Zapier names the connection after it, so you can connect several workspaces and pick one per step. * **The permissions:** read templates, create renders, read and delete renders and their files, read the account's plan and usage, manage the webhook endpoints of its instant triggers, and create signed links. The connection can't change templates, API keys, members or settings. The connection lasts until you remove it under **Settings** → **Integrations** in the dashboard. Zapier renews its access in the background; if the connection isn't used for 90 days, Zapier asks you to reconnect it. ## Triggers [#triggers] | Trigger | Starts the Zap when | | ---------------------------------- | ------------------------------------------------------------------------------------------- | | **New Render Succeeded** | A render finished, with its file ready for the next step | | **New Render Failed** | A render failed, with the error code and message | | **New Batch Completed** | A [batch](https://dynamicdocumentapi.com/docs/batches) finished, with its counts and the ZIP or merged PDF if it made one | | **New Template Version Published** | A template got a new live version in the dashboard, published or rolled back | The first three are instant: Dynamic Document API sends the event to Zapier as a [webhook](https://dynamicdocumentapi.com/docs/webhooks) the moment it happens (see [webhook endpoints](#webhook-endpoints) below). **New Template Version Published** is checked on Zapier's polling schedule. The render and batch triggers take these options: * **Templates:** only renders or batches of these templates. Leave it empty for every one, including renders from HTML, URLs and PDF tools. * **Test renders** (render triggers only): include them, skip them or only use them. Skip them once the Zap sends real documents. * **Renders made in Zapier** (render triggers only): skipped by default, so a Zap that renders in its own steps can't start itself again and again. Include them to [continue a slow render](#files-and-links) in a second Zap, and filter that Zap by template so it doesn't react to its own renders. Batches don't record where they came from, so **New Batch Completed** has no such option: a Zap that starts with it shouldn't create a batch of a template its trigger listens to. ## Actions [#actions] | Action | What it does | | ------------------------------ | -------------------------------------------------------------------------------------------------------------- | | **Create PDF From Template** | Fills a PDF template with data from earlier steps, optionally as a hybrid [e-invoice](https://dynamicdocumentapi.com/docs/e-invoicing) | | **Create Image From Template** | Renders a PNG, JPEG or WebP from an image template | | **Create PDF From HTML** | Renders HTML and CSS, for example a page an earlier step built | | **Create PDF From URL** | Prints a public web page as a browser would | | **Create PDF From Markdown** | Turns Markdown into a PDF, for example text from an AI step | | **Merge PDFs** | Merges files, URLs and earlier renders into one PDF, see [PDF tools](https://dynamicdocumentapi.com/docs/pdf-tools#merge) | | **Protect PDF** | Encrypts a PDF with AES-256: a password to open it and limits on printing, copying and editing | | **Create E-Invoice** | Creates a validated e-invoice XML (XRechnung, EN 16931, Factur-X and ZUGFeRD profiles) | | **Create Signed Image URL** | Creates a [signed URL](https://dynamicdocumentapi.com/docs/signed-links) that renders a personalised image when opened, for emails and posts | | **Create Batch** | Renders one document per item from a template, optionally as a ZIP or one merged PDF | | **Wait for Render** | Waits up to 25 seconds for a render that is still running and returns its file | | **Delete Render Files** | Deletes a render's stored files right away, for example after an earlier step uploaded them elsewhere | ## Searches [#searches] | Search | Finds | | ---------------------- | ---------------------------------------------------------------------------------------- | | **Find Render** | A render by its ID or by your reference, with fresh links to its files | | **Find Template** | A template by its name or part of it | | **Find Account Usage** | The workspace's plan, the renders left this billing period and the usage in a date range | Zapier needs an action after a search: a Zap can't end with one. ## Fill a template [#fill-a-template] After you pick a template, the step lists the template's data fields from its schema, with the labels and descriptions set in the template editor. Line items such as invoice lines become a line-item group: map a list from an earlier step, or enter the rows by hand. Fields inside an optional object aren't required in Zapier; the template's schema still checks them when you fill that object. Set **Data entry** to **Raw JSON** for structures the fields can't express, and paste or map the whole `data` object. The template list shows published templates only, so publish a template in the dashboard before you use it in a Zap. **Create PDF From Template** can embed an e-invoice. Leave **Embed e-invoice** empty to keep the template's own setting, choose a profile and map the invoice JSON or give the path to the invoice in the data, or choose **Off** for a plain PDF. ## Files and links [#files-and-links] Every render action returns the render with its first file: * **File:** the file itself. Map it into an attachment or upload field, such as Gmail's attachments or Google Drive's file field. * **File URL (signed, expires):** a download link that stays valid for 24 hours. * **Render ID**, **Status**, **Pages**, **Billed renders**, **Test render** and **Warnings**. Zapier stops a step after 30 seconds, so a render step waits up to 25. A render that takes longer keeps running and the step returns it with the status `processing` and no file yet. Add a **Wait for Render** step, or start the next steps from a second Zap with **New Render Succeeded**, filtered to the template, with **Renders made in Zapier** set to include them. Renders from Zapier show the source **Zapier** in the dashboard's render list and usage breakdown. ## Test mode [#test-mode] Each render action has a **Test mode** option, set to **No** by default. While you build a Zap, set it to **Yes**: [test renders](https://dynamicdocumentapi.com/docs/authentication#test-mode) carry a TEST watermark and aren't billed. Switch it back to **No** for the final documents, which count like API renders. ## Webhook endpoints [#webhook-endpoints] Each Zap with an instant trigger adds one webhook endpoint to the workspace while the Zap is on. You see them under **Webhooks** in the dashboard, marked **Managed by Zapier**. You can't edit or delete them there: * Turning the Zap off or deleting it removes its endpoint. * Disconnecting Zapier under **Settings** → **Integrations** removes the endpoints once no Zapier connection to the workspace is left. * If Zapier answers a delivery with `410 Gone`, Dynamic Document API removes the endpoint. Managed endpoints never become the workspace's default endpoint, and they count towards the limit of 20 webhook endpoints per workspace. ## Plans [#plans] Every plan, Free included, can render PDFs and images, use the PDF tools and run the triggers. Some actions need a plan with the feature: | Action | Plan | | ----------------------------------------------------------------------------- | ----------------- | | **Create Signed Image URL** | Starter or higher | | **Create Batch** | Starter or higher | | **Create E-Invoice**, and e-invoices embedded by **Create PDF From Template** | Growth or higher | The usual [limits](https://dynamicdocumentapi.com/docs/plans-and-limits) apply, such as the rate limit, pages per PDF and batch items. A workspace with an **API IP allowlist** (**Settings** → **Security**) refuses Zapier's requests, because they come from Zapier's servers. --- # Dynamic Document API Version 1.0.0. 84 endpoints in 11 groups, each group with its parameters, request bodies and responses on one Markdown page. Machine-readable OpenAPI document: https://dynamicdocumentapi.com/openapi/openapi-public.json The Dynamic Document API turns templates and JSON into PDFs and images. * Authenticate with `Authorization: Bearer dda_live_…` (test keys: `dda_test_…`). * Errors are RFC 9457 problem details (`application/problem+json`). * All POST endpoints accept an `Idempotency-Key` header. * Rate-limit headers: `RateLimit-Policy`, `RateLimit`, and `Retry-After` on 429. ## Servers - https://api-eu.dynamicdocumentapi.com — EU (Germany) ## Endpoint groups ### Renders Create and manage renders (PDFs, images, HTML). [Renders: 14 endpoints as Markdown](https://dynamicdocumentapi.com/docs/api-reference/renders/index.md) - `POST /v1/images/from-html` — Screenshot of HTML - `POST /v1/images/from-template` — Image from a template - `POST /v1/images/from-url` — Screenshot of a URL - `POST /v1/pdf/from-html` — PDF from HTML - `POST /v1/pdf/from-markdown` — PDF from Markdown - `POST /v1/pdf/from-template` — PDF from a template - `POST /v1/pdf/from-url` — PDF from a URL - `GET /v1/renders` — List renders - `POST /v1/renders` — Create a render - `GET /v1/renders/{render_id}` — Retrieve a render - `POST /v1/renders/{render_id}/cancel` — Cancel a render - `DELETE /v1/renders/{render_id}/files` — Purge stored files - `GET /v1/renders/{render_id}/files/{file_id}` — Download a render file - `GET /v1/renders/{render_id}/logs` — Request/response log ### Batches Render one template for many items (JSON or CSV), plus an optional ZIP / merged PDF. [Batches: 7 endpoints as Markdown](https://dynamicdocumentapi.com/docs/api-reference/batches/index.md) - `GET /v1/batches` — List batches - `POST /v1/batches` — Create a batch - `GET /v1/batches/{batch_id}` — Retrieve a batch - `POST /v1/batches/{batch_id}/cancel` — Cancel a batch - `GET /v1/batches/{batch_id}/items` — List batch items - `POST /v1/batches/{batch_id}/resend-emails` — Resend a batch's failed emails - `POST /v1/batches/{batch_id}/retry-failed` — Retry the failed items ### Templates Read published templates, versions and data schemas; create, publish and roll back. [Templates: 10 endpoints as Markdown](https://dynamicdocumentapi.com/docs/api-reference/templates/index.md) - `GET /v1/templates` — List templates - `POST /v1/templates` — Create a template - `GET /v1/templates/{template_id}` — Retrieve a template - `DELETE /v1/templates/{template_id}` — Delete a template - `PUT /v1/templates/{template_id}/draft` — Replace the draft sources - `POST /v1/templates/{template_id}/publish` — Publish the draft - `POST /v1/templates/{template_id}/rollback` — Roll back to a version - `GET /v1/templates/{template_id}/schema` — Data schema of a template - `GET /v1/templates/{template_id}/versions` — List template versions - `GET /v1/templates/{template_id}/versions/{number}` — Retrieve a template version ### PDF tools Merge, split, rotate, protect, unlock, inspect PDFs and convert them to PDF/A. [PDF tools: 7 endpoints as Markdown](https://dynamicdocumentapi.com/docs/api-reference/pdf-tools/index.md) - `POST /v1/pdf-tools/merge` — Merge PDFs - `POST /v1/pdf-tools/metadata` — Read or set PDF metadata - `POST /v1/pdf-tools/pdfa` — Convert a PDF to PDF/A - `POST /v1/pdf-tools/protect` — Password-protect a PDF - `POST /v1/pdf-tools/rotate` — Rotate PDF pages - `POST /v1/pdf-tools/split` — Split a PDF - `POST /v1/pdf-tools/unlock` — Remove a PDF password ### E-invoices XRechnung, Factur-X / ZUGFeRD and EN 16931 invoices: standalone CII or UBL XML here; hybrid PDF/A-3b invoices through `output.einvoice` on any PDF render. Validated with KoSIT. [E-invoices: 1 endpoints as Markdown](https://dynamicdocumentapi.com/docs/api-reference/e-invoices/index.md) - `POST /v1/einvoices` — Create an e-invoice XML ### Uploads Temporary files for PDF tools and batches. [Uploads: 1 endpoints as Markdown](https://dynamicdocumentapi.com/docs/api-reference/uploads/index.md) - `POST /v1/uploads` — Upload a file (≤ 50 MB, kept 24 h) ### Webhooks Webhook endpoints, the delivery log and replays. [Webhooks: 10 endpoints as Markdown](https://dynamicdocumentapi.com/docs/api-reference/webhooks/index.md) - `GET /v1/webhook-deliveries` — List webhook deliveries - `GET /v1/webhook-deliveries/{delivery_id}` — Retrieve a webhook delivery - `POST /v1/webhook-deliveries/{delivery_id}/replay` — Replay a webhook delivery - `GET /v1/webhook-endpoints` — List webhook endpoints - `POST /v1/webhook-endpoints` — Create a webhook endpoint - `GET /v1/webhook-endpoints/{endpoint_id}` — Retrieve a webhook endpoint - `PATCH /v1/webhook-endpoints/{endpoint_id}` — Update a webhook endpoint - `DELETE /v1/webhook-endpoints/{endpoint_id}` — Delete a webhook endpoint - `POST /v1/webhook-endpoints/{endpoint_id}/roll-secret` — Roll the signing secret - `POST /v1/webhook-endpoints/{endpoint_id}/test` — Send a test event ### Storage Your own S3/Azure/GCS buckets for render output (BYOS). [Storage: 6 endpoints as Markdown](https://dynamicdocumentapi.com/docs/api-reference/storage/index.md) - `GET /v1/storage-destinations` — List storage destinations - `POST /v1/storage-destinations` — Create a storage destination - `GET /v1/storage-destinations/{destination_id}` — Retrieve a storage destination - `PATCH /v1/storage-destinations/{destination_id}` — Update a storage destination - `DELETE /v1/storage-destinations/{destination_id}` — Delete a storage destination - `POST /v1/storage-destinations/{destination_id}/test` — Test a storage destination ### Signed links Image URLs rendered straight from a template. [Signed links: 7 endpoints as Markdown](https://dynamicdocumentapi.com/docs/api-reference/signed-links/index.md) - `GET /l/{link_id}/{filename}` — Render a signed link - `GET /v1/signed-links` — List signed links - `POST /v1/signed-links` — Create a signed link - `GET /v1/signed-links/{link_id}` — Retrieve a signed link - `PATCH /v1/signed-links/{link_id}` — Update a signed link - `DELETE /v1/signed-links/{link_id}` — Delete a signed link - `POST /v1/signed-links/{link_id}/sign` — Sign a link URL ### Email Email renders through your own provider account (Brevo, SMTP, Postmark, Resend, Amazon SES or Microsoft 365): connections, rules per template, previews, tests, sends, delivery events and resends. Growth plan and up. While email delivery isn't enabled for a workspace yet, writes to its connections and rules answer `403 feature_not_enabled`. [Email: 19 endpoints as Markdown](https://dynamicdocumentapi.com/docs/api-reference/email/index.md) - `GET /v1/email-connections` — List email connections - `POST /v1/email-connections` — Create an email connection - `GET /v1/email-connections/{connection_id}` — Retrieve an email connection - `PATCH /v1/email-connections/{connection_id}` — Update an email connection - `DELETE /v1/email-connections/{connection_id}` — Delete an email connection - `POST /v1/email-connections/{connection_id}/test` — Test an email connection - `POST /v1/email-rules/preview` — Preview a rule that is not saved yet - `GET /v1/email-rules/{rule_id}` — Retrieve an email rule - `PATCH /v1/email-rules/{rule_id}` — Update an email rule - `DELETE /v1/email-rules/{rule_id}` — Delete an email rule - `POST /v1/email-rules/{rule_id}/preview` — Preview an email rule - `POST /v1/email-rules/{rule_id}/test` — Send a test email - `GET /v1/email-sends` — List email sends - `GET /v1/email-sends/stats` — Email statistics - `GET /v1/email-sends/{send_id}` — Retrieve an email send - `GET /v1/email-sends/{send_id}/events` — List a send's delivery events - `POST /v1/email-sends/{send_id}/resend` — Resend an email - `GET /v1/templates/{template_id}/email-rules` — List a template's email rules - `POST /v1/templates/{template_id}/email-rules` — Create an email rule ### Account Plan, renders, limits and usage. [Account: 2 endpoints as Markdown](https://dynamicdocumentapi.com/docs/api-reference/account/index.md) - `GET /v1/account` — Plan, renders and limits - `GET /v1/usage` — Usage time series --- # Dynamic Document API: Renders > Create and manage renders (PDFs, images, HTML). 14 endpoints of the [API reference](https://dynamicdocumentapi.com/docs/api-reference/index.md), version 1.0.0. Interactive reference: https://dynamicdocumentapi.com/docs/api-reference#tag/renders. Machine-readable OpenAPI document: https://dynamicdocumentapi.com/openapi/openapi-public.json ## Servers - https://api-eu.dynamicdocumentapi.com — EU (Germany) ## Endpoints - `POST /v1/images/from-html` — Screenshot of HTML - `POST /v1/images/from-template` — Image from a template - `POST /v1/images/from-url` — Screenshot of a URL - `POST /v1/pdf/from-html` — PDF from HTML - `POST /v1/pdf/from-markdown` — PDF from Markdown - `POST /v1/pdf/from-template` — PDF from a template - `POST /v1/pdf/from-url` — PDF from a URL - `GET /v1/renders` — List renders - `POST /v1/renders` — Create a render - `GET /v1/renders/{render_id}` — Retrieve a render - `POST /v1/renders/{render_id}/cancel` — Cancel a render - `DELETE /v1/renders/{render_id}/files` — Purge stored files - `GET /v1/renders/{render_id}/files/{file_id}` — Download a render file - `GET /v1/renders/{render_id}/logs` — Request/response log ## POST /v1/images/from-html **Screenshot of HTML** Convenience wrapper over `POST /v1/renders` with flat, no-code friendly fields. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): object | Field | Type | Description | |---|---|---| | `data` | object or null | | | `data_url` | string or null | | | `delivery` | DeliverySpec or "url" \| "binary" \| "base64" \| "none" | | | `engine` | string or null | | | `expires_in` | integer | | | `filename` | string | Jinja-enabled filename. | | `format` | "png" \| "jpeg" \| "webp" | | | `head` | string or null | | | `hosted` | boolean or null | Keep the hosted copy; `false` deletes it once the storage uploads and email sends have succeeded. Absent or `null`: `false` when every storage destination of the render has `keep_hosted_copy: false`, else `true`. Zero-retention renders never keep one. | | `html` (required) | string | | | `image` | ImageOptions or null | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `retention_days` | integer or null | | | `templating` | boolean or null | | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `webhook` | WebhookSpec or null | | | `webhook_url` | string | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## POST /v1/images/from-template **Image from a template** Convenience wrapper over `POST /v1/renders` with flat, no-code friendly fields. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): object | Field | Type | Description | |---|---|---| | `data` | object or null | | | `data_url` | string or null | | | `delivery` | DeliverySpec or "url" \| "binary" \| "base64" \| "none" | | | `engine` | string or null | | | `expires_in` | integer | | | `filename` | string | Jinja-enabled filename. | | `format` | "png" \| "jpeg" \| "webp" | | | `hosted` | boolean or null | Keep the hosted copy; `false` deletes it once the storage uploads and email sends have succeeded. Absent or `null`: `false` when every storage destination of the render has `keep_hosted_copy: false`, else `true`. Zero-retention renders never keep one. | | `image` | ImageOptions or null | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `overrides` | array of object or null | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `retention_days` | integer or null | | | `template_id` (required) | string | | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `version` | "live" \| "draft" or integer | | | `webhook` | WebhookSpec or null | | | `webhook_url` | string | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## POST /v1/images/from-url **Screenshot of a URL** Convenience wrapper over `POST /v1/renders` with flat, no-code friendly fields. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): object | Field | Type | Description | |---|---|---| | `data` | object or null | | | `data_url` | string or null | | | `delivery` | DeliverySpec or "url" \| "binary" \| "base64" \| "none" | | | `engine` | string or null | | | `expires_in` | integer | | | `filename` | string | Jinja-enabled filename. | | `format` | "png" \| "jpeg" \| "webp" | | | `hosted` | boolean or null | Keep the hosted copy; `false` deletes it once the storage uploads and email sends have succeeded. Absent or `null`: `false` when every storage destination of the render has `keep_hosted_copy: false`, else `true`. Zero-retention renders never keep one. | | `http` | HttpOptions or null | | | `image` | ImageOptions or null | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `retention_days` | integer or null | | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `url` (required) | string | | | `webhook` | WebhookSpec or null | | | `webhook_url` | string | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## POST /v1/pdf/from-html **PDF from HTML** Convenience wrapper over `POST /v1/renders` with flat, no-code friendly fields. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): object | Field | Type | Description | |---|---|---| | `data` | object or null | | | `data_url` | string or null | | | `delivery` | DeliverySpec or "url" \| "binary" \| "base64" \| "none" | | | `einvoice` | EInvoiceSpec or null | | | `engine` | string or null | | | `expires_in` | integer | | | `filename` | string | Jinja-enabled filename. | | `head` | string or null | | | `hosted` | boolean or null | Keep the hosted copy; `false` deletes it once the storage uploads and email sends have succeeded. Absent or `null`: `false` when every storage destination of the render has `keep_hosted_copy: false`, else `true`. Zero-retention renders never keep one. | | `html` (required) | string | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `pdf` | PdfOptions or null | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `retention_days` | integer or null | | | `templating` | boolean or null | | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `webhook` | WebhookSpec or null | | | `webhook_url` | string | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## POST /v1/pdf/from-markdown **PDF from Markdown** Convenience wrapper over `POST /v1/renders` with flat, no-code friendly fields. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): object | Field | Type | Description | |---|---|---| | `allow_raw_html` | boolean or null | | | `css` | string or null | | | `data` | object or null | | | `data_url` | string or null | | | `delivery` | DeliverySpec or "url" \| "binary" \| "base64" \| "none" | | | `einvoice` | EInvoiceSpec or null | | | `engine` | string or null | | | `expires_in` | integer | | | `filename` | string | Jinja-enabled filename. | | `hosted` | boolean or null | Keep the hosted copy; `false` deletes it once the storage uploads and email sends have succeeded. Absent or `null`: `false` when every storage destination of the render has `keep_hosted_copy: false`, else `true`. Zero-retention renders never keep one. | | `markdown` (required) | string | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `pdf` | PdfOptions or null | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `retention_days` | integer or null | | | `templating` | boolean or null | | | `test` | boolean or null | | | `theme` | "default" \| "github" \| "academic" \| "minimal" \| "none" | | | `timeout_behavior` | "continue" \| "cancel" | | | `webhook` | WebhookSpec or null | | | `webhook_url` | string | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## POST /v1/pdf/from-template **PDF from a template** Convenience wrapper over `POST /v1/renders` with flat, no-code friendly fields. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): object | Field | Type | Description | |---|---|---| | `data` | object or null | | | `data_url` | string or null | | | `delivery` | DeliverySpec or "url" \| "binary" \| "base64" \| "none" | | | `einvoice` | EInvoiceSpec or null | | | `engine` | string or null | | | `expires_in` | integer | | | `filename` | string | Jinja-enabled filename. | | `hosted` | boolean or null | Keep the hosted copy; `false` deletes it once the storage uploads and email sends have succeeded. Absent or `null`: `false` when every storage destination of the render has `keep_hosted_copy: false`, else `true`. Zero-retention renders never keep one. | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `overrides` | array of object or null | | | `pdf` | PdfOptions or null | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `retention_days` | integer or null | | | `template_id` (required) | string | | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `version` | "live" \| "draft" or integer | | | `webhook` | WebhookSpec or null | | | `webhook_url` | string | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## POST /v1/pdf/from-url **PDF from a URL** Convenience wrapper over `POST /v1/renders` with flat, no-code friendly fields. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): object | Field | Type | Description | |---|---|---| | `data` | object or null | | | `data_url` | string or null | | | `delivery` | DeliverySpec or "url" \| "binary" \| "base64" \| "none" | | | `einvoice` | EInvoiceSpec or null | | | `engine` | string or null | | | `expires_in` | integer | | | `filename` | string | Jinja-enabled filename. | | `hosted` | boolean or null | Keep the hosted copy; `false` deletes it once the storage uploads and email sends have succeeded. Absent or `null`: `false` when every storage destination of the render has `keep_hosted_copy: false`, else `true`. Zero-retention renders never keep one. | | `http` | HttpOptions or null | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `pdf` | PdfOptions or null | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `retention_days` | integer or null | | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `url` (required) | string | | | `webhook` | WebhookSpec or null | | | `webhook_url` | string | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## GET /v1/renders **List renders** Parameters: | Name | In | Type | Description | |---|---|---|---| | `batch_id` | query | string | | | `created[gte]` | query | string | | | `created[lte]` | query | string | | | `cursor` | query | string | | | `input_type` | query | "einvoice" \| "html" \| "markdown" \| "pdf_tool" \| "template" \| "url" | | | `limit` | query | integer | 1 to 100 (default 50). | | `metadata[key]` | query | string | Filter by a metadata key/value. | | `output_format` | query | "html" \| "jpeg" \| "pdf" \| "png" \| "webp" \| "xml" \| "zip" | | | `reference` | query | string | | | `source` | query | string | | | `status` | query | "queued" \| "processing" \| "succeeded" \| "failed" \| "canceled" \| "expired" or array of "queued" \| "processing" \| "succeeded" \| "failed" \| "canceled" \| "expired" | Repeat it (or comma-separate the values) to match any of several statuses. | | `template_id` | query | string | | | `test` | query | boolean | | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). ## POST /v1/renders **Create a render** Unified endpoint for template, HTML, URL and Markdown inputs → PDF, images or HTML. `output.pdf.pdfa` makes the PDF PDF/A-2b or -3b; `output.pdf.pdfua` makes it PDF/UA-1 (accessible, Growth plan and up); `output.einvoice` embeds a Factur-X / ZUGFeRD / XRechnung invoice (a hybrid PDF/A-3b, no extra billed render). All are validated (veraPDF, KoSIT); the report is the render's `result`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): RenderRequest | Field | Type | Description | |---|---|---| | `data` | object or null | | | `data_url` | string or null | | | `delivery` | DeliverySpec or null | | | `engine` | string or null | | | `input` (required) | TemplateInputSpec or HtmlInputSpec or UrlInputSpec or MarkdownInputSpec | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `output` | OutputSpec or null | | | `overrides` | array of object or null | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `webhook` | WebhookSpec or null | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## GET /v1/renders/{render_id} **Retrieve a render** Fresh signed file URLs are issued on every retrieve while files are hosted. Parameters: | Name | In | Type | Description | |---|---|---|---| | `render_id` (required) | path | string | | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Render not found. - `429` Rate limited (`Retry-After`). ## POST /v1/renders/{render_id}/cancel **Cancel a render** Best effort: queued renders are canceled immediately; processing renders finish. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `render_id` (required) | path | string | | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Render not found. - `409` `conflict`: the render already finished. - `429` Rate limited (`Retry-After`). ## DELETE /v1/renders/{render_id}/files **Purge stored files** Deletes hosted copies now; the render becomes `expired`. BYOS copies are untouched. For a zero-retention render it deletes the transient copies its storage uploads read (uploads still pending then fail), and the status stays. Parameters: | Name | In | Type | Description | |---|---|---|---| | `render_id` (required) | path | string | | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Render not found. - `409` `conflict`: the render is still running; cancel it first. - `429` Rate limited (`Retry-After`). ## GET /v1/renders/{render_id}/files/{file_id} **Download a render file** Parameters: | Name | In | Type | Description | |---|---|---|---| | `download` | query | boolean | Attachment disposition. | | `file_id` (required) | path | string | | | `inline` | query | boolean | Stream through the API instead of 302. | | `render_id` (required) | path | string | | Responses: - `200` File (`?inline=true`). - `302` Redirect to a signed URL. - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Render or file not found. Every file of a zero-retention render answers 404: its copies for storage destinations can't be opened. - `410` File expired or deleted. - `429` Rate limited (`Retry-After`). ## GET /v1/renders/{render_id}/logs **Request/response log** Parameters: | Name | In | Type | Description | |---|---|---|---| | `render_id` (required) | path | string | | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Logging disabled or log expired. - `429` Rate limited (`Retry-After`). --- # Dynamic Document API: Batches > Render one template for many items (JSON or CSV), plus an optional ZIP / merged PDF. 7 endpoints of the [API reference](https://dynamicdocumentapi.com/docs/api-reference/index.md), version 1.0.0. Interactive reference: https://dynamicdocumentapi.com/docs/api-reference#tag/batches. Machine-readable OpenAPI document: https://dynamicdocumentapi.com/openapi/openapi-public.json ## Servers - https://api-eu.dynamicdocumentapi.com — EU (Germany) ## Endpoints - `GET /v1/batches` — List batches - `POST /v1/batches` — Create a batch - `GET /v1/batches/{batch_id}` — Retrieve a batch - `POST /v1/batches/{batch_id}/cancel` — Cancel a batch - `GET /v1/batches/{batch_id}/items` — List batch items - `POST /v1/batches/{batch_id}/resend-emails` — Resend a batch's failed emails - `POST /v1/batches/{batch_id}/retry-failed` — Retry the failed items ## GET /v1/batches **List batches** Newest first. Test keys see test batches only. Parameters: | Name | In | Type | Description | |---|---|---|---| | `cursor` | query | string | | | `limit` | query | integer | 1 to 100 (default 50). | | `reference` | query | string | | | `status` | query | "canceled" \| "completed" \| "failed" \| "processing" \| "queued" | | | `template_id` | query | string | | Responses: - `200` - `400` Unknown `status` or malformed `template_id`. - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). ## POST /v1/batches **Create a batch** Renders one template version for many items — a JSON `items` array (≤ 25 MB body) or a CSV from `POST /v1/uploads` with an optional `csv_mapping` — sharing `output`, `delivery` and `webhook`. Items become ordinary renders (`source: batch`), released under your plan's bulk concurrency; `combine` adds a ZIP of all outputs and/or one merged PDF. Answers `202` right away; follow progress with `GET /v1/batches/{id}` or the `batch.progress` / `batch.completed` webhooks. Live items are billed as they are released: if the render limit is reached mid-batch, the batch pauses (`paused`, one `batch.progress`) and resumes on its own once renders are available again. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): BatchRequest | Field | Type | Description | |---|---|---| | `combine` | CombineSpec or null | | | `csv_mapping` | object or null | | | `csv_upload_id` | string or null | | | `delivery` | DeliverySpec or null | | | `engine` | string or null | | | `items` | array of BatchItemSpec or null | | | `metadata` | object or null | | | `output` | OutputSpec or null | | | `reference` | string or null | | | `template_id` (required) | string | | | `test` | boolean or null | | | `version` | string or integer | | | `webhook` | BatchWebhookSpec or null | | Responses: - `202` Batch accepted (`Location: /v1/batches/{id}`). - `400` Invalid request, items or CSV (`errors[]` with `/items/<i>/…` or `/csv/row/<n>`). - `401` Missing, invalid, expired or revoked credentials. - `402` Batches not on your plan, or render limit reached (`render_limit_reached`). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template, version or upload not found. - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Body over 25 MB or an item's data over 1 MB. - `422` Item data does not match the template schema, or the webhook URL is not allowed. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## GET /v1/batches/{batch_id} **Retrieve a batch** Counters, progress and — once combined — the ZIP / merged PDF with signed URLs. Parameters: | Name | In | Type | Description | |---|---|---|---| | `batch_id` (required) | path | string | Batch id (`bat_…`). | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Batch not found. - `429` Rate limited (`Retry-After`). ## POST /v1/batches/{batch_id}/cancel **Cancel a batch** Items not released yet are canceled; released renders are canceled when a worker picks them up (items already rendering may still finish and are counted). The batch is `canceled` at once and `batch.completed` fires. Canceled items are not billed. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `batch_id` (required) | path | string | Batch id (`bat_…`). | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Batch not found. - `409` `conflict`: the batch already finished. - `429` Rate limited (`Retry-After`). ## GET /v1/batches/{batch_id}/items **List batch items** Items in `index` order; `?status=failed` lists what `retry-failed` renders again. Parameters: | Name | In | Type | Description | |---|---|---|---| | `batch_id` (required) | path | string | Batch id (`bat_…`). | | `cursor` | query | string | | | `limit` | query | integer | 1 to 100 (default 50). | | `status` | query | "canceled" \| "failed" \| "pending" \| "queued" \| "succeeded" | | Responses: - `200` - `400` Unknown `status`. - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Batch not found. - `429` Rate limited (`Retry-After`). ## POST /v1/batches/{batch_id}/resend-emails **Resend a batch's failed emails** For each item and rule, the newest email is resent when its status is listed (`failed` by default; `unknown` only when listed). Emails that can't be resent are listed in `skipped` with the reason (at most 100). Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `batch_id` (required) | path | string | Batch id (`bat_…`). | Request body (`application/json`): BatchResendEmailsRequest | Field | Type | Description | |---|---|---| | `statuses` | array of "failed" \| "unknown" | Resend the newest email of each render and rule when its status is listed. | Responses: - `200` - `400` Unknown status. - `401` Missing, invalid, expired or revoked credentials. - `402` Email delivery needs the Growth plan or higher (`plan_feature_unavailable`). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Batch not found. - `429` Rate limited (`Retry-After`). ## POST /v1/batches/{batch_id}/retry-failed **Retry the failed items** For a finished batch with failed items: those items are rendered again as new renders (the old render id stays in `error.previous_render_id`), earlier combined files are dropped and the batch is `processing` until it finishes again. Zero-retention batches can't be retried: their items keep no data once they finish. Nor can a batch with `combine` whose succeeded items' files expired or were deleted: its new combined files couldn't hold those items. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `batch_id` (required) | path | string | Batch id (`bat_…`). | Responses: - `202` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Batch not found. - `409` `conflict`: the batch is still running or has no failed items. `zero_retention`: a zero-retention batch keeps no item data; submit the failed items in a new batch. `item_files_expired`: the batch has `combine` and the files of succeeded items expired or were deleted (`items` lists their indexes, at most 100); submit the failed items in a new batch. - `429` Rate limited (`Retry-After`). --- # Dynamic Document API: Templates > Read published templates, versions and data schemas; create, publish and roll back. 10 endpoints of the [API reference](https://dynamicdocumentapi.com/docs/api-reference/index.md), version 1.0.0. Interactive reference: https://dynamicdocumentapi.com/docs/api-reference#tag/templates. Machine-readable OpenAPI document: https://dynamicdocumentapi.com/openapi/openapi-public.json ## Servers - https://api-eu.dynamicdocumentapi.com — EU (Germany) ## Endpoints - `GET /v1/templates` — List templates - `POST /v1/templates` — Create a template - `GET /v1/templates/{template_id}` — Retrieve a template - `DELETE /v1/templates/{template_id}` — Delete a template - `PUT /v1/templates/{template_id}/draft` — Replace the draft sources - `POST /v1/templates/{template_id}/publish` — Publish the draft - `POST /v1/templates/{template_id}/rollback` — Roll back to a version - `GET /v1/templates/{template_id}/schema` — Data schema of a template - `GET /v1/templates/{template_id}/versions` — List template versions - `GET /v1/templates/{template_id}/versions/{number}` — Retrieve a template version ## GET /v1/templates **List templates** Parameters: | Name | In | Type | Description | |---|---|---|---| | `cursor` | query | string | | | `limit` | query | integer | | | `q` | query | string | | | `type` | query | string | | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). ## POST /v1/templates **Create a template** Creates a `pdf_code`, `pdf_markdown` or `image_canvas` template. The content fields depend on `type` (any type may send one `content` object instead); they land in the draft — publish it to get a live version. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): TemplateCreateRequest Responses: - `201` - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `template_not_allowed_for_key`: the key is restricted to specific templates. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## GET /v1/templates/{template_id} **Retrieve a template** Parameters: | Name | In | Type | Description | |---|---|---|---| | `template_id` (required) | path | string | | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Template not found. - `410` Template deleted. - `429` Rate limited (`Retry-After`). ## DELETE /v1/templates/{template_id} **Delete a template** Soft delete: the template moves to the trash and is purged after 30 days. Renders that reference it keep working until then; new renders answer `410 template_deleted`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `template_id` (required) | path | string | Template id (`tpl_…`). | Responses: - `200` - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Template not found. - `410` Template already deleted. - `423` The template is locked (`template_locked`); Owners and Admins unlock it in the dashboard. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## PUT /v1/templates/{template_id}/draft **Replace the draft sources** Replaces the draft with the content fields of the template's type (see `POST /v1/templates`) or one `content` object. Send `If-Match: <revision>` (the `revision` of the draft you edited) to detect concurrent edits; a stale revision answers `409 draft_revision_conflict`. The response carries the new revision in `ETag`. `image_canvas` drafts are compiled when published — and by `version: draft` renders, which answer `422` with JSON pointers under `/template/draft` when the draft does not compile. Parameters: | Name | In | Type | Description | |---|---|---|---| | `If-Match` | header | string | Draft revision you based the edit on. | | `template_id` (required) | path | string | Template id (`tpl_…`). | Request body (`application/json`): TemplateDraftWriteRequest Responses: - `200` Saved. `ETag` holds the new revision. - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Template not found. - `409` The draft changed since the revision in `If-Match` (`draft_revision_conflict`). - `410` Template deleted. - `423` The template is locked (`template_locked`); Owners and Admins unlock it in the dashboard. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/templates/{template_id}/publish **Publish the draft** Validates the draft (compile, options, syntax, datasets) and publishes a new live version. `201` when the run finished, `202` when validation is still running, `422` when a step failed — the problem details then carry the run in `publish_run`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `template_id` (required) | path | string | Template id (`tpl_…`). | Request body (`application/json`): TemplatePublish | Field | Type | Description | |---|---|---| | `note` | string | | Responses: - `201` Published. - `202` Validation is still running. - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Template not found. - `410` Template deleted. - `422` Validation failed; `publish_run` holds the failing steps. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/templates/{template_id}/rollback **Roll back to a version** Points `live_version` at an existing version. Nothing is re-rendered and no new version is created. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `template_id` (required) | path | string | Template id (`tpl_…`). | Request body (`application/json`, required): TemplateRollback | Field | Type | Description | |---|---|---| | `version` (required) | integer or string | Version number (`7`) or a `tv_…` id. | Responses: - `200` - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Template or version not found. - `410` Template deleted. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## GET /v1/templates/{template_id}/schema **Data schema of a template** Parameters: | Name | In | Type | Description | |---|---|---|---| | `template_id` (required) | path | string | | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Template not found. - `429` Rate limited (`Retry-After`). ## GET /v1/templates/{template_id}/versions **List template versions** Parameters: | Name | In | Type | Description | |---|---|---|---| | `template_id` (required) | path | string | | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). ## GET /v1/templates/{template_id}/versions/{number} **Retrieve a template version** Parameters: | Name | In | Type | Description | |---|---|---|---| | `number` (required) | path | string | | | `template_id` (required) | path | string | | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Version not found. - `429` Rate limited (`Retry-After`). --- # Dynamic Document API: PDF tools > Merge, split, rotate, protect, unlock, inspect PDFs and convert them to PDF/A. 7 endpoints of the [API reference](https://dynamicdocumentapi.com/docs/api-reference/index.md), version 1.0.0. Interactive reference: https://dynamicdocumentapi.com/docs/api-reference#tag/pdf-tools. Machine-readable OpenAPI document: https://dynamicdocumentapi.com/openapi/openapi-public.json ## Servers - https://api-eu.dynamicdocumentapi.com — EU (Germany) ## Endpoints - `POST /v1/pdf-tools/merge` — Merge PDFs - `POST /v1/pdf-tools/metadata` — Read or set PDF metadata - `POST /v1/pdf-tools/pdfa` — Convert a PDF to PDF/A - `POST /v1/pdf-tools/protect` — Password-protect a PDF - `POST /v1/pdf-tools/rotate` — Rotate PDF pages - `POST /v1/pdf-tools/split` — Split a PDF - `POST /v1/pdf-tools/unlock` — Remove a PDF password ## POST /v1/pdf-tools/merge **Merge PDFs** Merge 2-200 PDFs into one document. Answers a Render object with `input_type: "pdf_tool"`; `delivery`, `mode`, `webhook`, `reference`, `metadata` and `test` behave as on `POST /v1/renders`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): MergeRequest | Field | Type | Description | |---|---|---| | `bookmarks` | "filenames" \| "none" | | | `delivery` | DeliverySpec or null | | | `filename` | string or null | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `sources` (required) | array of PdfSourceSpec | | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `webhook` | WebhookSpec or null | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## POST /v1/pdf-tools/metadata **Read or set PDF metadata** Without `set`: no file, and the render's `result` is `{pages, page_sizes, encrypted, pdf_version, metadata}`. `page_sizes` lists runs of pages shown at one size (`{"width": "210mm", "height": "297mm", "pages": "1-10"}`); `metadata` holds `title`, `author`, `subject`, `keywords` (a list), `creator`, `producer`, `created`, `modified` and `lang`, null when absent. A PDF that needs a password still answers `pages` and `encrypted: true`, with `metadata` null. With `set` (`title`, `author`, `subject`, `keywords`, `creator`, `producer`, `lang`): the PDF with only those fields changed (`""`, `null` or `[]` clears one) and the same `result` for it; an encrypted source fails with `pdf_password_required` (422). A read cannot use `delivery.type: "binary"`. 1 billed render. Answers a Render object with `input_type: "pdf_tool"`; `delivery`, `mode`, `webhook`, `reference`, `metadata` and `test` behave as on `POST /v1/renders`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): PdfMetadataRequest | Field | Type | Description | |---|---|---| | `delivery` | DeliverySpec or null | | | `filename` | string or null | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `set` | object or null | | | `source` (required) | PdfSourceSpec | Exactly one of `url`, `render_id` (+ optional `file_id`), `upload_id` or `data_uri`. | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `webhook` | WebhookSpec or null | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## POST /v1/pdf-tools/pdfa **Convert a PDF to PDF/A** Converts any PDF to PDF/A-2b or PDF/A-3b (best effort: fonts must be embedded and the source must not be encrypted) and validates the result with veraPDF; the report is `result.conformance.pdfa`. A PDF that cannot be made conformant fails with `pdfa_conversion_failed`. 1 billed render. Answers a Render object with `input_type: "pdf_tool"`; `delivery`, `mode`, `webhook`, `reference`, `metadata` and `test` behave as on `POST /v1/renders`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): PdfaRequest | Field | Type | Description | |---|---|---| | `delivery` | DeliverySpec or null | | | `filename` | string or null | | | `level` (required) | "2b" \| "3b" | PDF/A conformance level: `2b` (ISO 19005-2) or `3b` (ISO 19005-3, allows attachments). | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `source` (required) | PdfSourceSpec | Exactly one of `url`, `render_id` (+ optional `file_id`), `upload_id` or `data_uri`. | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `webhook` | WebhookSpec or null | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## POST /v1/pdf-tools/protect **Password-protect a PDF** AES-256 encryption with a user and/or owner password, as the `pdf.protect` render option does. Unset `permissions` flags take the same defaults: `print`, `print_high_res`, `copy` and `fill_forms` allowed, `modify`, `annotate` and `assemble` denied. An encrypted source fails with `pdf_password_required` (422). 1 billed render. Answers a Render object with `input_type: "pdf_tool"`; `delivery`, `mode`, `webhook`, `reference`, `metadata` and `test` behave as on `POST /v1/renders`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): ProtectRequest | Field | Type | Description | |---|---|---| | `delivery` | DeliverySpec or null | | | `filename` | string or null | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `owner_password` | string or null | | | `permissions` | object or null | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `source` (required) | PdfSourceSpec | Exactly one of `url`, `render_id` (+ optional `file_id`), `upload_id` or `data_uri`. | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `user_password` | string or null | | | `webhook` | WebhookSpec or null | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## POST /v1/pdf-tools/rotate **Rotate PDF pages** Adds `degrees` (90, 180 or 270; negative turns anticlockwise) to the rotation of the pages in `pages` (`"all"`, `"1,3-5"`, `"4-"`); other pages keep theirs. A page outside the document fails with `validation_error` (422), naming it; an encrypted source with `pdf_password_required` (unlock it first). 1 billed render. Answers a Render object with `input_type: "pdf_tool"`; `delivery`, `mode`, `webhook`, `reference`, `metadata` and `test` behave as on `POST /v1/renders`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): RotateRequest | Field | Type | Description | |---|---|---| | `degrees` (required) | 90 \| 180 \| 270 \| -90 \| -180 \| -270 | | | `delivery` | DeliverySpec or null | | | `filename` | string or null | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `pages` | string | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `source` (required) | PdfSourceSpec | Exactly one of `url`, `render_id` (+ optional `file_id`), `upload_id` or `data_uri`. | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `webhook` | WebhookSpec or null | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## POST /v1/pdf-tools/split **Split a PDF** One file per entry of `ranges` (`"1-3"`, `"4-"` to the last page, `"1,3-5"`) or per run of `every` pages, at most 200, named `<stem>-<n>.pdf` (n from 1; `<stem>` is `filename` without `.pdf`, else `split`) and listed in that order in `files`. With `zip: true` a single `<stem>.zip` holds them instead. `delivery.type: "binary"` needs a single output (`zip: true` or exactly one range); `base64` carries every file, and storage destinations get every file (`file.index` in path templates). A page outside the document fails with `validation_error` (422), naming it; an encrypted source with `pdf_password_required` (unlock it first). 1 billed render. Answers a Render object with `input_type: "pdf_tool"`; `delivery`, `mode`, `webhook`, `reference`, `metadata` and `test` behave as on `POST /v1/renders`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): SplitRequest | Field | Type | Description | |---|---|---| | `delivery` | DeliverySpec or null | | | `every` | integer or null | One output file per run of this many pages instead. | | `filename` | string or null | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `priority` | "normal" \| "low" | | | `ranges` | array of string or null | Page lists, one output file each: `"1-3"`, `"4-"` (to the last page), `"7"`, `"1,3-5"`. Pages are numbered from 1. | | `reference` | string or null | | | `source` (required) | PdfSourceSpec | Exactly one of `url`, `render_id` (+ optional `file_id`), `upload_id` or `data_uri`. | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `webhook` | WebhookSpec or null | | | `zip` | boolean | Deliver one ZIP (`<stem>.zip`) holding the parts instead of one file per part. | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## POST /v1/pdf-tools/unlock **Remove a PDF password** Decrypts a PDF with its user or owner password and removes its restrictions. A wrong password fails with `pdf_password_required` (422); an unencrypted PDF comes back unchanged. 1 billed render. Answers a Render object with `input_type: "pdf_tool"`; `delivery`, `mode`, `webhook`, `reference`, `metadata` and `test` behave as on `POST /v1/renders`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): UnlockRequest | Field | Type | Description | |---|---|---| | `delivery` | DeliverySpec or null | | | `filename` | string or null | | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `password` (required) | string | | | `priority` | "normal" \| "low" | | | `reference` | string or null | | | `source` (required) | PdfSourceSpec | Exactly one of `url`, `render_id` (+ optional `file_id`), `upload_id` or `data_uri`. | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `webhook` | WebhookSpec or null | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). --- # Dynamic Document API: E-invoices > XRechnung, Factur-X / ZUGFeRD and EN 16931 invoices: standalone CII or UBL XML here; hybrid PDF/A-3b invoices through `output.einvoice` on any PDF render. Validated with KoSIT. 1 endpoints of the [API reference](https://dynamicdocumentapi.com/docs/api-reference/index.md), version 1.0.0. Interactive reference: https://dynamicdocumentapi.com/docs/api-reference#tag/e-invoices. Machine-readable OpenAPI document: https://dynamicdocumentapi.com/openapi/openapi-public.json ## Servers - https://api-eu.dynamicdocumentapi.com — EU (Germany) ## Endpoints - `POST /v1/einvoices` — Create an e-invoice XML ## POST /v1/einvoices **Create an e-invoice XML** Generates a standalone e-invoice from the invoice model: UN/CEFACT CII (every profile) or UBL 2.1 (`EN16931`, `XRECHNUNG`), e.g. an XRechnung for German public buyers. The XML is validated with the KoSIT validator (report in `result.conformance.einvoice`) and delivered as one `application/xml` file; a model that breaks a profile rule fails with `einvoice_invalid` and the list of issues. Answers a Render object with `input_type: "einvoice"` and `kind: "einvoice_xml"`; `delivery`, `mode`, `webhook`, `reference`, `metadata` and `test` behave as on `POST /v1/renders`. 1 billed render. Test renders are free, and their XML has a TEST note as its first invoice note (BT-22; every profile but `MINIMUM`, which has no notes; `EXTENDED` also sets `TestIndicator`). To embed the invoice in a PDF instead, use `output.einvoice` on a render. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`): EInvoiceRequest | Field | Type | Description | |---|---|---| | `delivery` | DeliverySpec or null | | | `filename` | string or null | Output filename (`.xml` is enforced). Default: the invoice number, sanitised, + `.xml`. | | `invoice` (required) | object | The invoice model (EN 16931 business terms). | | `metadata` | object or null | | | `mode` | "sync" \| "async" | | | `priority` | "normal" \| "low" | | | `profile` (required) | "MINIMUM" \| "BASIC_WL" \| "BASIC" \| "EN16931" \| "EXTENDED" \| "XRECHNUNG" | EN 16931 profile of the XML: `XRECHNUNG` (German public sector), `EN16931`, or a Factur-X / ZUGFeRD profile (`MINIMUM`, `BASIC_WL`, `BASIC`, `EXTENDED`; CII only). | | `reference` | string or null | | | `syntax` | "cii" \| "ubl" | `cii` (UN/CEFACT Cross Industry Invoice D16B) or `ubl` (UBL 2.1 Invoice / CreditNote; `EN16931` and `XRECHNUNG` only). | | `test` | boolean or null | | | `timeout_behavior` | "continue" \| "cancel" | | | `webhook` | WebhookSpec or null | | Responses: - `200` Sync render finished (`status=succeeded`). With `delivery.type=binary` the body is the file. - `202` Async render accepted, or the sync timeout was exceeded (`Location: /v1/renders/{id}`). - `400` Malformed or invalid request (`errors[]` with JSON pointers), e.g. an unknown, switched-off or another template's rule at `/delivery/email/<i>/rule_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Render limit reached (`render_limit_reached`) or feature not on plan (`plan_feature_unavailable`, e.g. a `delivery.email` list below the Growth plan). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `region_not_enabled`: the workspace's enabled regions leave out this region. - `404` Template or version not found. - `408` Sync timeout exceeded and the render was canceled (`timeout_behavior=cancel`). - `409` A request with this Idempotency-Key is still in progress. - `410` Template deleted. - `413` Payload or output too large. - `422` Render failed (`render_id`, `render_error`; `render_failed` when the worker reports no more specific code), schema mismatch, URL not allowed or idempotency key reused. `einvoice_invalid`, `einvoice_validation_failed`, `pdfa_conversion_failed` and `pdfua_validation_failed` also carry the conformance report as `result`. `email_needs_hosted_file`: `delivery.email` lists rules, but a zero-retention render keeps no file to email. `render_canceled`: the sync render was canceled, or its job expired, before it finished. `scene_invalid`, `data_invalid`: a canvas template's scene can't be rendered (e.g. an output over 16,384 px per side), or its `data` isn't an object or lacks a required dynamic property. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). --- # Dynamic Document API: Uploads > Temporary files for PDF tools and batches. 1 endpoints of the [API reference](https://dynamicdocumentapi.com/docs/api-reference/index.md), version 1.0.0. Interactive reference: https://dynamicdocumentapi.com/docs/api-reference#tag/uploads. Machine-readable OpenAPI document: https://dynamicdocumentapi.com/openapi/openapi-public.json ## Servers - https://api-eu.dynamicdocumentapi.com — EU (Germany) ## Endpoints - `POST /v1/uploads` — Upload a file (≤ 50 MB, kept 24 h) ## POST /v1/uploads **Upload a file (≤ 50 MB, kept 24 h)** Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`multipart/form-data`): object | Field | Type | Description | |---|---|---| | `file` (required) | string | | Responses: - `201` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `413` File too large. - `415` Unsupported file type. - `429` Rate limited (`Retry-After`). --- # Dynamic Document API: Webhooks > Webhook endpoints, the delivery log and replays. 10 endpoints of the [API reference](https://dynamicdocumentapi.com/docs/api-reference/index.md), version 1.0.0. Interactive reference: https://dynamicdocumentapi.com/docs/api-reference#tag/webhooks. Machine-readable OpenAPI document: https://dynamicdocumentapi.com/openapi/openapi-public.json ## Servers - https://api-eu.dynamicdocumentapi.com — EU (Germany) ## Endpoints - `GET /v1/webhook-deliveries` — List webhook deliveries - `GET /v1/webhook-deliveries/{delivery_id}` — Retrieve a webhook delivery - `POST /v1/webhook-deliveries/{delivery_id}/replay` — Replay a webhook delivery - `GET /v1/webhook-endpoints` — List webhook endpoints - `POST /v1/webhook-endpoints` — Create a webhook endpoint - `GET /v1/webhook-endpoints/{endpoint_id}` — Retrieve a webhook endpoint - `PATCH /v1/webhook-endpoints/{endpoint_id}` — Update a webhook endpoint - `DELETE /v1/webhook-endpoints/{endpoint_id}` — Delete a webhook endpoint - `POST /v1/webhook-endpoints/{endpoint_id}/roll-secret` — Roll the signing secret - `POST /v1/webhook-endpoints/{endpoint_id}/test` — Send a test event ## GET /v1/webhook-deliveries **List webhook deliveries** Parameters: | Name | In | Type | Description | |---|---|---|---| | `cursor` | query | string | | | `endpoint_id` | query | string | | | `limit` | query | integer | | | `render_id` | query | string | | | `status` | query | "exhausted" \| "failed" \| "pending" \| "succeeded" | | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). ## GET /v1/webhook-deliveries/{delivery_id} **Retrieve a webhook delivery** Parameters: | Name | In | Type | Description | |---|---|---|---| | `delivery_id` (required) | path | string | | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Not found. - `429` Rate limited (`Retry-After`). ## POST /v1/webhook-deliveries/{delivery_id}/replay **Replay a webhook delivery** Sends the stored event again as a new delivery (`replay_of` set, same event id). An endpoint's delivery goes to the endpoint's current URL. A delivery to a render's or batch's own `webhook.url` goes to that URL again, signed with the secret of the endpoint that signed the original (the workspace's default endpoint). Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `delivery_id` (required) | path | string | | Responses: - `202` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Not found. - `409` `conflict`: the endpoint is deleted, or disabled; for a delivery to a per-request `webhook.url`, the endpoint whose secret signs it. Test events can still be replayed to a disabled endpoint. - `429` Rate limited (`Retry-After`). ## GET /v1/webhook-endpoints **List webhook endpoints** Endpoints registered for this workspace. Secrets are never returned by a read. Responses: - `200` Every object in the workspace (a single page today). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/webhook-endpoints **Create a webhook endpoint** The response carries the signing `secret` **once** — store it now. Deliveries are signed per [Standard Webhooks](https://www.standardwebhooks.com/); see [webhooks](https://dynamicdocumentapi.com/docs/webhooks). Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`, required): WebhookEndpointWrite | Field | Type | Description | |---|---|---| | `description` | string | | | `enabled` | boolean | | | `events` (required) | array of "render.succeeded" \| "render.failed" \| "render.expired" \| "batch.progress" \| "batch.completed" \| "storage.upload_failed" \| "email.sent" \| "email.send_failed" \| "email.delivered" \| "email.bounced" \| "email.complained" \| "usage.threshold_reached" \| "template.published" \| "api_key.expiring" | | | `headers` | object | Extra request headers. | | `is_default` | boolean | Receive events from renders without an inline `webhook`. | | `template_ids` | array of string | Only deliver events for these templates. | | `url` (required) | string | Absolute https URL with a public host and no credentials, on port 443, 80, 8080 or 8443. | Responses: - `201` Created; `secret` is shown once. - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## GET /v1/webhook-endpoints/{endpoint_id} **Retrieve a webhook endpoint** Common base: API key or OAuth only, never a dashboard region token. Parameters: | Name | In | Type | Description | |---|---|---|---| | `endpoint_id` (required) | path | string | Webhook endpoint id (`whe_…`). | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Webhook endpoint not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## PATCH /v1/webhook-endpoints/{endpoint_id} **Update a webhook endpoint** Partial update; omitted fields keep their value. The secret is not changed — use `POST /v1/webhook-endpoints/{id}/roll-secret`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `endpoint_id` (required) | path | string | Webhook endpoint id (`whe_…`). | Request body (`application/json`): PatchedWebhookEndpointWrite | Field | Type | Description | |---|---|---| | `description` | string | | | `enabled` | boolean | | | `events` | array of "render.succeeded" \| "render.failed" \| "render.expired" \| "batch.progress" \| "batch.completed" \| "storage.upload_failed" \| "email.sent" \| "email.send_failed" \| "email.delivered" \| "email.bounced" \| "email.complained" \| "usage.threshold_reached" \| "template.published" \| "api_key.expiring" | | | `headers` | object | Extra request headers. | | `is_default` | boolean | Receive events from renders without an inline `webhook`. | | `template_ids` | array of string | Only deliver events for these templates. | | `url` | string | Absolute https URL with a public host and no credentials, on port 443, 80, 8080 or 8443. | Responses: - `200` - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Webhook endpoint not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## DELETE /v1/webhook-endpoints/{endpoint_id} **Delete a webhook endpoint** Pending deliveries for this endpoint stop; the delivery log is kept. Parameters: | Name | In | Type | Description | |---|---|---|---| | `endpoint_id` (required) | path | string | Webhook endpoint id (`whe_…`). | Responses: - `204` Deleted. - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Webhook endpoint not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/webhook-endpoints/{endpoint_id}/roll-secret **Roll the signing secret** Returns a new `secret` once. Deliveries carry both signatures until the previous secret expires, so receivers can be updated without downtime. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `endpoint_id` (required) | path | string | Webhook endpoint id (`whe_…`). | Request body (`application/json`): RollSecret | Field | Type | Description | |---|---|---| | `expire_previous_in` | integer | Seconds the previous secret keeps signing deliveries (0 = immediately). | Responses: - `200` The new secret, shown once. - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Webhook endpoint not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/webhook-endpoints/{endpoint_id}/test **Send a test event** Queues a signed sample event (`test: true`) to the endpoint and returns the delivery id; follow it with `GET /v1/webhook-deliveries/{id}`. Every event type can be tested; the sample `data` has the real event's shape, including `data.event` for `email.delivered`, `email.bounced` and `email.complained`. Ids are made up and files have no URL. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `endpoint_id` (required) | path | string | Webhook endpoint id (`whe_…`). | Request body (`application/json`): TestEvent | Field | Type | Description | |---|---|---| | `event` | "render.succeeded" \| "render.failed" \| "render.expired" \| "batch.progress" \| "batch.completed" \| "storage.upload_failed" \| "email.sent" \| "email.send_failed" \| "email.delivered" \| "email.bounced" \| "email.complained" \| "usage.threshold_reached" \| "template.published" \| "api_key.expiring" | * `render.succeeded` - render.succeeded * `render.failed` - render.failed * `render.expired` - render.expired * `batch.progress` - batch.progress * `batch.completed` - batch.completed * `storage.upload_failed` - storage.upload_failed * `email.sent` - email.sent * `email.send_failed` - email.send_failed * `email.delivered` - email.delivered * `email.bounced` - email.bounced * `email.complained` - email.complained * `usage.threshold_reached` - usage.threshold_reached * `template.published` - template.published * `api_key.expiring` - api_key.expiring | Responses: - `202` Queued. - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Webhook endpoint not found, or not replicated to this region yet. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). --- # Dynamic Document API: Storage > Your own S3/Azure/GCS buckets for render output (BYOS). 6 endpoints of the [API reference](https://dynamicdocumentapi.com/docs/api-reference/index.md), version 1.0.0. Interactive reference: https://dynamicdocumentapi.com/docs/api-reference#tag/storage. Machine-readable OpenAPI document: https://dynamicdocumentapi.com/openapi/openapi-public.json ## Servers - https://api-eu.dynamicdocumentapi.com — EU (Germany) ## Endpoints - `GET /v1/storage-destinations` — List storage destinations - `POST /v1/storage-destinations` — Create a storage destination - `GET /v1/storage-destinations/{destination_id}` — Retrieve a storage destination - `PATCH /v1/storage-destinations/{destination_id}` — Update a storage destination - `DELETE /v1/storage-destinations/{destination_id}` — Delete a storage destination - `POST /v1/storage-destinations/{destination_id}/test` — Test a storage destination ## GET /v1/storage-destinations **List storage destinations** Your own buckets that renders can be uploaded to. Credentials are never returned. Responses: - `200` Every object in the workspace (a single page today). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/storage-destinations **Create a storage destination** `credentials` are encrypted at rest and never returned; reads answer `{"configured": true}`. Reference the destination from `delivery.storage[]` on a render. On Amazon S3, `config.storage_class` sets the class of every uploaded object and of the connection test's probe: `STANDARD`, `STANDARD_IA`, `ONEZONE_IA`, `INTELLIGENT_TIERING` or `GLACIER_IR`; leave it out for the bucket's default. Archive classes (`GLACIER`, `DEEP_ARCHIVE`) and classes on other providers answer `400`. `config.force_path_style` (S3 and S3-compatible) puts the bucket in the path (`true`, `<endpoint>/<bucket>/<key>`) or in the host name (`false`, `<bucket>.<endpoint host>`); left out, it is `false` for Amazon S3 and `true` for S3-compatible services. Bucket names with dots, underscores or capitals and IP-address endpoints always use the path. Uploaded objects get the file's content type. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`, required): StorageDestinationWrite | Field | Type | Description | |---|---|---| | `config` (required) | object | | | `credentials` | object | Never returned again; send it only when it changes. | | `is_default` | boolean | | | `keep_hosted_copy` | boolean | | | `name` (required) | string | | | `path_template` | string | | | `provider` (required) | "s3" \| "s3_compatible" \| "azure_blob" \| "gcs" \| "sftp" | * `s3` - Amazon S3 * `s3_compatible` - S3-compatible * `azure_blob` - Azure Blob Storage * `gcs` - Google Cloud Storage * `sftp` - SFTP | Responses: - `201` - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## GET /v1/storage-destinations/{destination_id} **Retrieve a storage destination** Common base: API key or OAuth only, never a dashboard region token. Parameters: | Name | In | Type | Description | |---|---|---|---| | `destination_id` (required) | path | string | Storage destination id (`dst_…`). | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Storage destination not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## PATCH /v1/storage-destinations/{destination_id} **Update a storage destination** Partial update. `provider` is immutable — create a new destination to change it. Parameters: | Name | In | Type | Description | |---|---|---|---| | `destination_id` (required) | path | string | Storage destination id (`dst_…`). | Request body (`application/json`): PatchedStorageDestinationWrite | Field | Type | Description | |---|---|---| | `config` | object | | | `credentials` | object | Never returned again; send it only when it changes. | | `is_default` | boolean | | | `keep_hosted_copy` | boolean | | | `name` | string | | | `path_template` | string | | | `provider` | "s3" \| "s3_compatible" \| "azure_blob" \| "gcs" \| "sftp" | * `s3` - Amazon S3 * `s3_compatible` - S3-compatible * `azure_blob` - Azure Blob Storage * `gcs` - Google Cloud Storage * `sftp` - SFTP | Responses: - `200` - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Storage destination not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## DELETE /v1/storage-destinations/{destination_id} **Delete a storage destination** Files already uploaded to your bucket are untouched. Uploads that haven't succeeded yet stop: each fails at its next attempt, without connecting, with `storage.upload_failed`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `destination_id` (required) | path | string | Storage destination id (`dst_…`). | Responses: - `204` Deleted. - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Storage destination not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/storage-destinations/{destination_id}/test **Test a storage destination** Writes, reads back and deletes a small probe object from the region that owns the workspace. `200` with `ok: false` means the credentials or the bucket policy are wrong — `steps[]` says which check failed. The result is also stored as `last_test`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `destination_id` (required) | path | string | Storage destination id (`dst_…`). | Responses: - `200` The probe ran; check `ok`. - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Storage destination not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). --- # Dynamic Document API: Signed links > Image URLs rendered straight from a template. 7 endpoints of the [API reference](https://dynamicdocumentapi.com/docs/api-reference/index.md), version 1.0.0. Interactive reference: https://dynamicdocumentapi.com/docs/api-reference#tag/signed-links. Machine-readable OpenAPI document: https://dynamicdocumentapi.com/openapi/openapi-public.json ## Servers - https://api-eu.dynamicdocumentapi.com — EU (Germany) ## Endpoints - `GET /l/{link_id}/{filename}` — Render a signed link - `GET /v1/signed-links` — List signed links - `POST /v1/signed-links` — Create a signed link - `GET /v1/signed-links/{link_id}` — Retrieve a signed link - `PATCH /v1/signed-links/{link_id}` — Update a signed link - `DELETE /v1/signed-links/{link_id}` — Delete a signed link - `POST /v1/signed-links/{link_id}/sign` — Sign a link URL ## GET /l/{link_id}/{filename} **Render a signed link** The public image URL of a signed link, served from `https://img.dynamicdocumentapi.com` without an API key. Checks, in order: unknown, disabled or deleted link, or a format the link does not enable → 404 `not_found`; expired link or a signed `exp` in the past → 410 `link_expired`; `access: signed` needs `sig` = base64url(HMAC-SHA256(secret, "GET\n/l/{link_id}/{name}.{ext}\n" + canonical query without `sig`)) → 403 `invalid_signature` (open links accept only allowed parameters that have `param_limits`); unknown parameters or a value over `max_length` → 400 `validation_error`; a `Referer` whose host is not in `allowed_referrers` → 403 `referrer_not_allowed`; the per-IP hourly limit → 429 `rate_limited`; the plan feature → 402 `plan_feature_unavailable`; the link quota → 429 `quota_exceeded`. A cached result is returned with `X-Billed-Renders: 0` and creates no render; a cache miss renders the template's live version (`X-Billed-Renders: 1`, `X-Render-Id`). Responses carry `ETag` (304 on `If-None-Match`), `Cache-Control: public, max-age=…` (`private` for a link with `allowed_referrers` and in zero-retention workspaces: no shared cache keeps the file) and `Access-Control-Allow-Origin: *`. `HEAD` runs the same checks and never renders. When the render limit is reached, a link with a fallback image returns it instead of 402 `render_limit_reached`. Cloudflare's cache in front of the URL keeps `public` responses until their `max-age`, so a repeated URL can come from it (`CF-Cache-Status: HIT`, `Age`) with the headers of the response that filled it: `X-Billed-Renders` and `X-Render-Id` then describe that first response, and the hit is never billed. Changing what a link serves or deleting it, a change to its template's live version or settings, and suspending or deleting the workspace or turning on zero retention remove the copies from Cloudflare within seconds of the change reaching the region. Browsers keep theirs until `max-age`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `If-None-Match` | header | string | A previous `ETag`. | | `exp` | query | integer | Expiry (Unix seconds), signed. | | `filename` (required) | path | string | `{name}.{ext}`: any name of 1 to 80 characters `[A-Za-z0-9_-]` and a format enabled for the link (`png`, `jpeg` or `jpg`, `webp`, `pdf`). The name only sets the download filename; both are signed. | | `link_id` (required) | path | string | Signed link id (`lnk_…`). | | `params` | query | object | The template parameters (`allowed_params`), e.g. `title.text=Summer%20Sale`. | | `sig` | query | string | base64url HMAC-SHA256 signature (`access: signed`). | Responses: - `200` The rendered file (PNG, JPEG, WebP or PDF per the extension). - `304` Not modified (`If-None-Match`). - `400` Parameter not allowed, over `max_length`, malformed or over 8 KB of query. - `402` `plan_feature_unavailable` or `render_limit_reached` (no fallback image). - `403` `invalid_signature` or `referrer_not_allowed`. - `404` Unknown, disabled or deleted link, or a format the link does not enable. - `410` `link_expired` (or the template was deleted). - `422` The render failed (template error). - `429` `rate_limited` (per IP and hour, `Retry-After`) or `quota_exceeded`. ## GET /v1/signed-links **List signed links** Image links that render a template straight from a URL. Secrets are never returned by a read. Responses: - `200` Every object in the workspace (a single page today). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/signed-links **Create a signed link** Returns `base_url` and the HMAC `secret` **once**; SDKs sign URLs locally with it. Use `access: "open"` for tools that cannot compute an HMAC. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`, required): SignedLinkWrite | Field | Type | Description | |---|---|---| | `access` | "signed" \| "open" | `open` accepts unsigned requests within `allowed_params` and `param_limits`. * `signed` - signed * `open` - open | | `allowed_params` | array of string | Dynamic properties callers may set, e.g. `title.text`. | | `allowed_referrers` | array of string | | | `cache_ttl_seconds` | integer \| null | | | `defaults` | object | | | `enabled` | boolean | | | `expires_at` | string \| null | | | `fallback_image_asset_id` | string \| null | Ready image asset (`ast_…`) returned instead of 402 `render_limit_reached`; null clears. | | `formats` (required) | array of "png" \| "jpeg" \| "webp" \| "pdf" | | | `name` (required) | string | | | `param_limits` | object | | | `quota_total` | integer \| null | | | `rate_limit_per_ip_per_hour` | integer \| null | | | `template_id` (required) | string | | Responses: - `201` Created; `secret` is shown once. - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Template not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## GET /v1/signed-links/{link_id} **Retrieve a signed link** Common base: API key or OAuth only, never a dashboard region token. Parameters: | Name | In | Type | Description | |---|---|---|---| | `link_id` (required) | path | string | Signed link id (`lnk_…`). | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Signed link not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## PATCH /v1/signed-links/{link_id} **Update a signed link** Partial update. `template_id` is immutable and the secret is unchanged. Parameters: | Name | In | Type | Description | |---|---|---|---| | `link_id` (required) | path | string | Signed link id (`lnk_…`). | Request body (`application/json`): PatchedSignedLinkWrite | Field | Type | Description | |---|---|---| | `access` | "signed" \| "open" | `open` accepts unsigned requests within `allowed_params` and `param_limits`. * `signed` - signed * `open` - open | | `allowed_params` | array of string | Dynamic properties callers may set, e.g. `title.text`. | | `allowed_referrers` | array of string | | | `cache_ttl_seconds` | integer \| null | | | `defaults` | object | | | `enabled` | boolean | | | `expires_at` | string \| null | | | `fallback_image_asset_id` | string \| null | Ready image asset (`ast_…`) returned instead of 402 `render_limit_reached`; null clears. | | `formats` | array of "png" \| "jpeg" \| "webp" \| "pdf" | | | `name` | string | | | `param_limits` | object | | | `quota_total` | integer \| null | | | `rate_limit_per_ip_per_hour` | integer \| null | | | `template_id` | string | | Responses: - `200` - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Signed link not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## DELETE /v1/signed-links/{link_id} **Delete a signed link** Existing URLs stop working immediately (cached CDN copies expire on their own). Parameters: | Name | In | Type | Description | |---|---|---|---| | `link_id` (required) | path | string | Signed link id (`lnk_…`). | Responses: - `204` Deleted. - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Signed link not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/signed-links/{link_id}/sign **Sign a link URL** Convenience for languages without an SDK: signs `params` with the link secret. Signing locally avoids the round trip. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `link_id` (required) | path | string | Signed link id (`lnk_…`). | Request body (`application/json`, required): SignUrl | Field | Type | Description | |---|---|---| | `expires_at` | integer | Unix seconds; omit for a link that does not expire. | | `format` (required) | "png" \| "jpeg" \| "webp" \| "pdf" | * `png` - png * `jpeg` - jpeg * `webp` - webp * `pdf` - pdf | | `name` | string | File name in the URL (cosmetic, but signed). | | `params` | object | | Responses: - `200` - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Signed link not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). --- # Dynamic Document API: Email > Email renders through your own provider account (Brevo, SMTP, Postmark, Resend, Amazon SES or Microsoft 365): connections, rules per template, previews, tests, sends, delivery events and resends. Growth plan and up. While email delivery isn't enabled for a workspace yet, writes to its connections and rules answer `403 feature_not_enabled`. 19 endpoints of the [API reference](https://dynamicdocumentapi.com/docs/api-reference/index.md), version 1.0.0. Interactive reference: https://dynamicdocumentapi.com/docs/api-reference#tag/email. Machine-readable OpenAPI document: https://dynamicdocumentapi.com/openapi/openapi-public.json ## Servers - https://api-eu.dynamicdocumentapi.com — EU (Germany) ## Endpoints - `GET /v1/email-connections` — List email connections - `POST /v1/email-connections` — Create an email connection - `GET /v1/email-connections/{connection_id}` — Retrieve an email connection - `PATCH /v1/email-connections/{connection_id}` — Update an email connection - `DELETE /v1/email-connections/{connection_id}` — Delete an email connection - `POST /v1/email-connections/{connection_id}/test` — Test an email connection - `POST /v1/email-rules/preview` — Preview a rule that is not saved yet - `GET /v1/email-rules/{rule_id}` — Retrieve an email rule - `PATCH /v1/email-rules/{rule_id}` — Update an email rule - `DELETE /v1/email-rules/{rule_id}` — Delete an email rule - `POST /v1/email-rules/{rule_id}/preview` — Preview an email rule - `POST /v1/email-rules/{rule_id}/test` — Send a test email - `GET /v1/email-sends` — List email sends - `GET /v1/email-sends/stats` — Email statistics - `GET /v1/email-sends/{send_id}` — Retrieve an email send - `GET /v1/email-sends/{send_id}/events` — List a send's delivery events - `POST /v1/email-sends/{send_id}/resend` — Resend an email - `GET /v1/templates/{template_id}/email-rules` — List a template's email rules - `POST /v1/templates/{template_id}/email-rules` — Create an email rule ## GET /v1/email-connections **List email connections** The workspace's email provider accounts. Credentials are never returned; `egress_ips` are the addresses to authorize at the provider. Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/email-connections **Create an email connection** Connects your own provider account. `provider` and what it takes: * `brevo`: `credentials.api_key`, an API key (Brevo's SMTP key is not accepted). * `smtp`: `config` `host`, `port` (587 or 2525, with STARTTLS) and `username`; `credentials.password`. * `postmark`: `config.message_stream` (default `outbound`); `credentials.server_token`. * `resend`: `credentials.api_key`. * `ses` (Amazon SES): `config.region` and optionally `config.configuration_set`; `credentials` `access_key_id` and `secret_access_key`. * `graph` (Microsoft 365): `config` `tenant_id`, `client_id` and `sender`, the mailbox that sends; `credentials.client_secret` of an app with the `Mail.Send` application permission. Every provider takes `config.attachment_budget_bytes` and `config.max_per_second`; reads show them with the provider's defaults filled in. At most 10 connections per workspace; `provider` can't change later. `credentials` are encrypted and never returned. Authorize `egress_ips` at providers that restrict calling IPs (Brevo: Security → Authorized IPs). Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | Request body (`application/json`, required): EmailConnectionWrite | Field | Type | Description | |---|---|---| | `config` | object | Per provider: SMTP `{host, port, username}` (port 587 or 2525); Postmark `{message_stream}` (default `outbound`, also when empty or null); Amazon SES `{region, configuration_set?}`; Microsoft 365 `{tenant_id, client_id, sender}` (`sender`: the mailbox that sends); Brevo and Resend none. Every provider: `attachment_budget_bytes`, the bytes of files attached per email (default / maximum: Brevo 10 / 13 MiB, SMTP 6 / 25 MiB, Postmark 6 / 6 MiB, Resend 10 / 25 MiB, Amazon SES 10 / 25 MiB, Microsoft 365 2 / 2 MiB), and `max_per_second` (1 to 100, default 10). | | `credentials` | object | Write-only: Brevo `{api_key}` (an API key, not its SMTP key), SMTP `{password}`, Postmark `{server_token}`, Resend `{api_key}`, Amazon SES `{access_key_id, secret_access_key}`, Microsoft 365 `{client_secret}`. A PATCH without them keeps them. | | `default_from` (required) | EmailDefaultFromWrite | | | `name` (required) | string | | | `provider` (required) | "brevo" \| "smtp" \| "postmark" \| "resend" \| "ses" \| "graph" | `brevo`, `smtp`, `postmark`, `resend`, `ses` (Amazon SES) or `graph` (Microsoft 365); it can't change after creation. * `brevo` - Brevo * `smtp` - SMTP * `postmark` - Postmark * `resend` - Resend * `ses` - Amazon SES * `graph` - Microsoft 365 (Graph) | | `reply_to` | string \| null | | | `test_mode` | "sandbox" \| "redirect" \| "skip" | Test renders: `sandbox` (Brevo only, and its default: Brevo checks the email without delivering it), `redirect` (to `test_recipient`, subject prefixed `[Test] `), `skip` (no provider call; the default for every other provider). * `sandbox` - Sandbox * `redirect` - Redirect * `skip` - Skip | | `test_recipient` | string \| null | The verified address of a workspace member; required with `test_mode: redirect`. | Responses: - `201` - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `402` Email delivery needs the Growth plan or higher (`plan_feature_unavailable`). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `feature_not_enabled`: email delivery isn't enabled for this workspace yet. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## GET /v1/email-connections/{connection_id} **Retrieve an email connection** Common base: API key or OAuth only, never a dashboard region token. Parameters: | Name | In | Type | Description | |---|---|---|---| | `connection_id` (required) | path | string | Email connection id (`emc_…`). | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Email connection not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## PATCH /v1/email-connections/{connection_id} **Update an email connection** Partial update; a body without `credentials` keeps them. A `config` replaces the stored one as a whole. `provider` is immutable. Parameters: | Name | In | Type | Description | |---|---|---|---| | `connection_id` (required) | path | string | Email connection id (`emc_…`). | Request body (`application/json`): PatchedEmailConnectionWrite | Field | Type | Description | |---|---|---| | `config` | object | Per provider: SMTP `{host, port, username}` (port 587 or 2525); Postmark `{message_stream}` (default `outbound`, also when empty or null); Amazon SES `{region, configuration_set?}`; Microsoft 365 `{tenant_id, client_id, sender}` (`sender`: the mailbox that sends); Brevo and Resend none. Every provider: `attachment_budget_bytes`, the bytes of files attached per email (default / maximum: Brevo 10 / 13 MiB, SMTP 6 / 25 MiB, Postmark 6 / 6 MiB, Resend 10 / 25 MiB, Amazon SES 10 / 25 MiB, Microsoft 365 2 / 2 MiB), and `max_per_second` (1 to 100, default 10). | | `credentials` | object | Write-only: Brevo `{api_key}` (an API key, not its SMTP key), SMTP `{password}`, Postmark `{server_token}`, Resend `{api_key}`, Amazon SES `{access_key_id, secret_access_key}`, Microsoft 365 `{client_secret}`. A PATCH without them keeps them. | | `default_from` | EmailDefaultFromWrite | | | `name` | string | | | `provider` | "brevo" \| "smtp" \| "postmark" \| "resend" \| "ses" \| "graph" | `brevo`, `smtp`, `postmark`, `resend`, `ses` (Amazon SES) or `graph` (Microsoft 365); it can't change after creation. * `brevo` - Brevo * `smtp` - SMTP * `postmark` - Postmark * `resend` - Resend * `ses` - Amazon SES * `graph` - Microsoft 365 (Graph) | | `reply_to` | string \| null | | | `test_mode` | "sandbox" \| "redirect" \| "skip" | Test renders: `sandbox` (Brevo only, and its default: Brevo checks the email without delivering it), `redirect` (to `test_recipient`, subject prefixed `[Test] `), `skip` (no provider call; the default for every other provider). * `sandbox` - Sandbox * `redirect` - Redirect * `skip` - Skip | | `test_recipient` | string \| null | The verified address of a workspace member; required with `test_mode: redirect`. | Responses: - `200` - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `402` Email delivery needs the Growth plan or higher (`plan_feature_unavailable`). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `feature_not_enabled`: email delivery isn't enabled for this workspace yet. - `404` Email connection not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## DELETE /v1/email-connections/{connection_id} **Delete an email connection** A connection that rules use answers `409 email_connection_in_use`: delete or move those rules first. Works on every plan. Parameters: | Name | In | Type | Description | |---|---|---|---| | `connection_id` (required) | path | string | Email connection id (`emc_…`). | Responses: - `204` Deleted. - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Email connection not found. - `409` Rules use the connection (`email_connection_in_use`). - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/email-connections/{connection_id}/test **Test an email connection** Checks the saved settings from the region that sends, step by step: * `brevo`: `credentials`, `senders` (the verified senders and domains) and `sandbox_send` (Brevo checks an email from the default sender without delivering it). * `smtp`: `connect`, `starttls`, `smtp_auth`. * `postmark`: `credentials`. * `resend`: `credentials`, which also lists the verified domains. * `ses`: `credentials`, `senders` (the verified identities; it fails when there are none). * `graph`: `credentials` (Microsoft Entra ID issues a token). The senders found are stored as the connection's `senders`. `send_to` adds a real test email (`send`) to a verified member address. `200` with `ok: false` means a step failed; the test stops there. A failed `credentials` or `smtp_auth` step marks the connection `failing`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `connection_id` (required) | path | string | Email connection id (`emc_…`). | Request body (`application/json`): EmailConnectionTestRequest | Field | Type | Description | |---|---|---| | `send_to` | string \| null | Also send a real test email to this address: the verified address of a workspace member. | Responses: - `200` The test ran; check `ok`. - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `402` Email delivery needs the Growth plan or higher (`plan_feature_unavailable`). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `feature_not_enabled`: email delivery isn't enabled for this workspace yet. - `404` Email connection not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/email-rules/preview **Preview a rule that is not saved yet** A watermarked preview render of the template (its live version, else its draft) with the rule's email rendered in the document's context: the fields, the recipients as parsed and the attachments as they would be planned. Nothing is sent and nothing is billed. The data is `data`, else the dataset `dataset_id`, else the template's default dataset, else `{}`. `rule.connection_id` names the connection. Request body (`application/json`): EmailRuleDraftPreviewRequest | Field | Type | Description | |---|---|---| | `data` | object or null | | | `dataset_id` | string or null | | | `rule` (required) | EmailRuleSpec | The editable fields of an email rule, as the rule editor holds them (unsaved state). Used instead of the saved rule; fields the editor does not know (`name`, `enabled`, …) are ignored. `null` counts as empty. Syntax errors come back as the preview's `email_preview.error`. | | `template_id` (required) | string | | Responses: - `200` - `202` The preview did not finish within the sync wait (`email_preview: null`). - `400` Invalid request (`errors[]`), e.g. an unknown `rule.connection_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Email delivery needs the Growth plan or higher (`plan_feature_unavailable`). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Rule, template, connection or dataset not found. - `410` Template deleted. - `422` The document failed to render (`render_error`). - `429` Rate limited (`Retry-After`). - `503` Region degraded or the email checks are unavailable (`Retry-After`). ## GET /v1/email-rules/{rule_id} **Retrieve an email rule** Common base: API key or OAuth only, never a dashboard region token. Parameters: | Name | In | Type | Description | |---|---|---|---| | `rule_id` (required) | path | string | Email rule id (`emr_…`). | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Email rule not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## PATCH /v1/email-rules/{rule_id} **Update an email rule** Partial update; changing `position` moves the others. Switching a rule off (`enabled: false`) also cancels its emails that were not sent yet. Below the Growth plan, or while email delivery isn't enabled for the workspace, an update may only switch the rule off. Parameters: | Name | In | Type | Description | |---|---|---|---| | `rule_id` (required) | path | string | Email rule id (`emr_…`). | Request body (`application/json`): PatchedEmailRuleWrite | Field | Type | Description | |---|---|---| | `attach_formats` | array of "pdf" \| "png" \| "jpeg" \| "webp" \| "html" \| "xml" \| "zip" | Only files of these formats; empty = every file. | | `attachments` | "attach_or_link" \| "attach" \| "link" \| "none" | * `attach_or_link` - Attach Or Link * `attach` - Attach * `link` - Link * `none` - None | | `bcc` | string \| null | | | `cc` | string \| null | | | `condition` | string \| null | An expression; the rule sends when it is true. Empty = always. | | `connection_id` | string | `emc_…` of this workspace. | | `enabled` | boolean | | | `from_email` | string \| null | Literal; empty = the connection's default sender. One the provider hasn't verified is saved with the warning `email_sender_unverified`. | | `from_name` | string \| null | Template source; one line after rendering. | | `html` | string \| null | Template source, values HTML-escaped (≤ 256 KiB). | | `name` | string | | | `position` | integer | 0 to 4; the others move. Default: the end. | | `provider_template` | EmailProviderTemplate or null | | | `reply_to` | string \| null | Template source; one line after rendering. | | `subject` | string \| null | Template source; required unless a provider template gives it. | | `text` | string \| null | Template source; empty = derived from `html`. | | `to` | string \| null | Template source rendering to addresses separated by `,` `;` or line breaks. | Responses: - `200` - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `402` Email delivery needs the Growth plan or higher (`plan_feature_unavailable`). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `feature_not_enabled`: email delivery isn't enabled for this workspace yet. - `404` Email rule not found. - `422` A field does not compile (`email_template_error`). - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## DELETE /v1/email-rules/{rule_id} **Delete an email rule** Its emails that were not sent yet are canceled. Works on every plan. Parameters: | Name | In | Type | Description | |---|---|---|---| | `rule_id` (required) | path | string | Email rule id (`emr_…`). | Responses: - `204` Deleted. - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Email rule not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/email-rules/{rule_id}/preview **Preview an email rule** A watermarked preview render of the template (its live version, else its draft) with the rule's email rendered in the document's context: the fields, the recipients as parsed and the attachments as they would be planned. Nothing is sent and nothing is billed. The data is `data`, else the dataset `dataset_id`, else the template's default dataset, else `{}`. Works for switched-off rules; `rule` previews unsaved fields instead of the saved ones. Parameters: | Name | In | Type | Description | |---|---|---|---| | `rule_id` (required) | path | string | Email rule id (`emr_…`). | Request body (`application/json`): EmailRulePreviewRequest | Field | Type | Description | |---|---|---| | `data` | object or null | | | `dataset_id` | string or null | A dataset (`ds_…`) of the template. | | `rule` | EmailRuleSpec or null | Unsaved fields instead of the saved rule. | Responses: - `200` - `202` The preview did not finish within the sync wait (`email_preview: null`). - `400` Invalid request (`errors[]`), e.g. an unknown `rule.connection_id`. - `401` Missing, invalid, expired or revoked credentials. - `402` Email delivery needs the Growth plan or higher (`plan_feature_unavailable`). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Rule, template, connection or dataset not found. - `410` Template deleted. - `422` The document failed to render (`render_error`). - `429` Rate limited (`Retry-After`). - `503` Region degraded or the email checks are unavailable (`Retry-After`). ## POST /v1/email-rules/{rule_id}/test **Send a test email** Renders the template (its live version, else its draft) as a test render (free, watermarked) whose one email is this rule's, redirected to `to` with the subject prefixed `[Test] `. `to` must be the verified address of an active member of the workspace. Works for switched-off rules; `rule` tests unsaved fields. Follow the render: its `email[]` shows the send. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `rule_id` (required) | path | string | Email rule id (`emr_…`). | Request body (`application/json`): EmailRuleTestRequest | Field | Type | Description | |---|---|---| | `data` | object or null | | | `dataset_id` | string or null | | | `rule` | EmailRuleSpec or null | Unsaved fields instead of the saved rule. | | `to` (required) | string | The verified email address of an active member of the workspace. | Responses: - `202` The test render was queued (`Location: /v1/renders/{id}`). - `400` Invalid request, or `to` is not a verified member address (`/to`). - `401` Missing, invalid, expired or revoked credentials. - `402` Email delivery needs the Growth plan or higher (`plan_feature_unavailable`). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Rule, template, connection or dataset not found. - `410` Template deleted. - `422` Zero-retention workspaces keep no file to email (`email_needs_hosted_file`). - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## GET /v1/email-sends **List email sends** Newest first; sends are kept for 30 days. Test keys see test sends only. Parameters: | Name | In | Type | Description | |---|---|---|---| | `batch_id` | query | string | | | `connection_id` | query | string | | | `created_after` | query | string | | | `cursor` | query | string | | | `limit` | query | integer | 1 to 100 (default 50). | | `render_id` | query | string | | | `rule_id` | query | string | | | `status` | query | "canceled" \| "failed" \| "pending" \| "retrying" \| "sandboxed" \| "sending" \| "sent" \| "skipped" \| "unknown" | | | `template_id` | query | string | | Responses: - `200` - `400` Malformed id, unknown `status` or invalid `created_after`. - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). ## GET /v1/email-sends/stats **Email statistics** Today's recipients of live emails in this region against the daily cap (it resets at 00:00 UTC), and the sends of the last 30 days by status. Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## GET /v1/email-sends/{send_id} **Retrieve an email send** Parameters: | Name | In | Type | Description | |---|---|---|---| | `send_id` (required) | path | string | Email send id (`ems_…`). | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Email send not found. - `429` Rate limited (`Retry-After`). ## GET /v1/email-sends/{send_id}/events **List a send's delivery events** What Brevo reported after it accepted the email (delivered, deferred, bounces, blocks, spam complaints, invalid addresses), oldest first. Only Brevo connections have delivery events: other providers' sends list none. Also sent as the webhooks `email.delivered`, `email.bounced` and `email.complained`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `send_id` (required) | path | string | Email send id (`ems_…`). | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Email send not found. - `429` Rate limited (`Retry-After`). ## POST /v1/email-sends/{send_id}/resend **Resend an email** Sends a `failed`, `unknown` or `sent` email again as a new send (`resend_of` set) with the same content and files; it counts toward today's cap. An email that failed before it was sent (template, recipients or attachments) or whose content was not kept (request logging off) answers `409 email_not_resendable`; a file that is no longer hosted `409 email_file_expired`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `send_id` (required) | path | string | Email send id (`ems_…`). | Responses: - `201` - `401` Missing, invalid, expired or revoked credentials. - `402` Email delivery needs the Growth plan or higher (`plan_feature_unavailable`). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Email send not found. - `409` `email_not_resendable` or `email_file_expired`. - `429` Rate limited (`Retry-After`). - `503` Region degraded (`Retry-After`). ## GET /v1/templates/{template_id}/email-rules **List a template's email rules** 0 to 5 rules in `position` order. Rules are live configuration, not versioned with the template: an edit applies to renders submitted afterwards. Parameters: | Name | In | Type | Description | |---|---|---|---| | `template_id` (required) | path | string | Template id (`tpl_…`). | Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `404` Template not found. - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). ## POST /v1/templates/{template_id}/email-rules **Create an email rule** `from_name`, `reply_to`, `to`, `cc`, `bcc`, `subject`, `html` and `text` use the template language with the document's context (data keys, `data`, `render` including `render.invoice`) plus `files` and `download_links`; `condition` is an expression. Every field must compile, else `422 email_template_error` with `errors[].path`, line and column. A `from_email` the provider hasn't verified is saved with the warning `email_sender_unverified`. Parameters: | Name | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Replays the stored response for 24 h; a different body with the same key → 422. | | `template_id` (required) | path | string | Template id (`tpl_…`). | Request body (`application/json`, required): EmailRuleWrite | Field | Type | Description | |---|---|---| | `attach_formats` | array of "pdf" \| "png" \| "jpeg" \| "webp" \| "html" \| "xml" \| "zip" | Only files of these formats; empty = every file. | | `attachments` | "attach_or_link" \| "attach" \| "link" \| "none" | * `attach_or_link` - Attach Or Link * `attach` - Attach * `link` - Link * `none` - None | | `bcc` | string \| null | | | `cc` | string \| null | | | `condition` | string \| null | An expression; the rule sends when it is true. Empty = always. | | `connection_id` (required) | string | `emc_…` of this workspace. | | `enabled` | boolean | | | `from_email` | string \| null | Literal; empty = the connection's default sender. One the provider hasn't verified is saved with the warning `email_sender_unverified`. | | `from_name` | string \| null | Template source; one line after rendering. | | `html` | string \| null | Template source, values HTML-escaped (≤ 256 KiB). | | `name` (required) | string | | | `position` | integer | 0 to 4; the others move. Default: the end. | | `provider_template` | EmailProviderTemplate or null | | | `reply_to` | string \| null | Template source; one line after rendering. | | `subject` | string \| null | Template source; required unless a provider template gives it. | | `text` | string \| null | Template source; empty = derived from `html`. | | `to` | string \| null | Template source rendering to addresses separated by `,` `;` or line breaks. | Responses: - `201` - `400` Invalid request body (`errors[]` with JSON pointers). - `401` Missing, invalid, expired or revoked credentials. - `402` Email delivery needs the Growth plan or higher (`plan_feature_unavailable`). - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified; or `feature_not_enabled`: email delivery isn't enabled for this workspace yet. - `404` Template or connection not found. - `422` A field does not compile (`email_template_error`). - `429` Rate limited (`Retry-After`). - `503` The control plane is unreachable (`Retry-After`). --- # Dynamic Document API: Account > Plan, renders, limits and usage. 2 endpoints of the [API reference](https://dynamicdocumentapi.com/docs/api-reference/index.md), version 1.0.0. Interactive reference: https://dynamicdocumentapi.com/docs/api-reference#tag/account. Machine-readable OpenAPI document: https://dynamicdocumentapi.com/openapi/openapi-public.json ## Servers - https://api-eu.dynamicdocumentapi.com — EU (Germany) ## Endpoints - `GET /v1/account` — Plan, renders and limits - `GET /v1/usage` — Usage time series ## GET /v1/account **Plan, renders and limits** Responses: - `200` - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). - `503` Control plane unavailable. ## GET /v1/usage **Usage time series** Parameters: | Name | In | Type | Description | |---|---|---|---| | `from` | query | string | `YYYY-MM-DD` or ISO-8601; default: cycle start. | | `granularity` | query | "day" \| "hour" | | | `group_by` | query | "key_id" \| "kind" \| "region" \| "source" \| "template_id" | | | `to` | query | string | `YYYY-MM-DD` (inclusive) or ISO-8601 (exclusive). | Responses: - `200` - `400` Invalid `from`/`to`, `granularity` or `group_by`. - `401` Missing, invalid, expired or revoked credentials. - `403` Insufficient scope, IP not allowed, workspace suspended or scheduled for deletion, or email not verified. - `429` Rate limited (`Retry-After`). - `503` Control plane unavailable.