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.
Email delivery is being switched on workspace by workspace. While it isn't enabled for yours, 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.
How it works
- Connect your provider. An email connection (
emc_…) holds the credentials of your provider account, the default sender and what test renders do. - 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 with the render's data. - 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 inemail, 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)
- 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, 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
Create a connection in the dashboard under Email → Add connection, or through the API with a key that has the email:write scope:
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:
{
"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. 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. |
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 |
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 |
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 |
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
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).
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
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 thebrevoprovider. - 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.
hostis 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:SendRawEmailand, 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.Sendapplication permission.tenant_idis the directory (tenant) ID or one of your domains,client_idthe application (client) ID, andsenderthe 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. |
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
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.
{
"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
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
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:
"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
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:
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": "<p>Dear {{ customer.name }},</p><p>please find invoice {{ number }} attached.</p>",
"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 |
subject | yes | Required, unless a Brevo template 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 |
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 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 to a workspace member |
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 <address>. 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,ccandbccwins. - One email takes at most 50 recipients,
to,ccandbcctogether.
A loop can build the list from your data; the empty entry after the last comma is ignored:
{% 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
Rule fields render with the 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) |
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 |
files and download_links take precedence over data keys with the same names; use data.files to reach such a key.
<p>Dear {{ customer.name }},</p>
<p>Please find invoice {{ number }} attached.</p>
{{ download_links }}
<p>Kind regards<br>Example GmbH</p>htmlescapes values like an HTML template. Mark HTML you trust withsafe. The other fields aren't escaped.subject,from_nameandreply_tobecome 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: truein the render request applies to the rules too.- After rendering,
htmlcan be up to 512 KiB,textup to 256 KiB, andto,ccandbccup to 16 KiB each. A larger field fails the email withemail_too_large. - A template error fails the email with
email_template_error; itserrorgives thefield,lineandcolumn.
Conditions
condition is an expression in the same context, written without {{ }}:
send_invoice and customer.emailWhen 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 | 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_formatshas filtered them. - Brevo doesn't accept WebP attachments, so
attach_or_linklinks 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
htmlortextuses{{ download_links }}: a list of links named by file name inhtml, and onefilename: URLline per file intext. 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.
Brevo templates
With a Brevo connection, a rule can send one of your Brevo account's templates instead of its own body:
"provider_template": { "id": 12, "params": "{\"number\": {{ number | tojson }}, \"name\": {{ customer.name | tojson }}}" }idis the template's ID in Brevo, a whole number from 1.paramsis 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 withemail_template_error.htmlandtextcan be empty.subjectis 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
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 <address>, and the three lists together hold at most 50.
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 <erika.muster@customer.example>"], "cc": [] }
]
}
}'false is always accepted. A list fails:
- with
400 validation_errorwhen 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/<i>/… - with
402 plan_feature_unavailablewhen the plan doesn't include email delivery - with
422 email_needs_hosted_filefor 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.
Live and test renders
| Render | Emails |
|---|---|
| Live render | Go to the rendered recipients and count toward the daily 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
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.
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 datasetdataset_id(ds_…), else from the template's default dataset, else{} - work for switched-off rules, and take unsaved fields in
ruleinstead of the saved ones
The preview's email_preview:
"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": "<p>Dear Erika Muster,</p><p>please find invoice R-2026-0042 attached.</p>",
"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
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:
"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:
{
"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 of an email |
GET /v1/email-sends/stats | Today's recipients against the daily cap, and the last 30 days by status |
POST /v1/email-sends/{id}/resend | Send an email again |
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
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
unknownafter 10 minutes.
Resend
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,unknownandsentemails can be sent again. The answer is201with a new email that has the same content, recipients and files,resend_ofset 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
unknownemail, search your provider's logs for itsX-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:
{ "resent": 12, "skipped": [{ "send_id": "ems_01J9ZM1X3F7R8K2C4V6B8N0P2T", "code": "email_file_expired" }] }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 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:
{
"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
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:
{
"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
| 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 |
| 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 |
| 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
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,ccandbcc, 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:
{
"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).
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. Adelivery.emaillist fails with422 email_needs_hosted_file; without a list, the render carries the warningemail_skipped_zero_retentionwhen the template has enabled rules. In a zero-retention workspace, rule tests fail with422 email_needs_hosted_filetoo. - Renders with
hosted: falsekeep their hosted copy only until their uploads and emails are done, so their emails can't link files:attach_or_linkattaches or fails, andlinkfails the email withemail_needs_hosted_file. While one of their emails ispending,sending,retrying,failedorunknown, the copy stays, so the email can still be resent. See Your own 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 with409 email_file_expired.
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 |
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.
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. |
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 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 for every other code.
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). 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 adelivery.emaillist fails with402. - Renders submitted before the downgrade still send their emails.
- The credentials of every connection are deleted 30 days after the downgrade, and
credentials_delete_atshows 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.configuredisfalseand the connection isfailingwithemail_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
- Renders for
delivery, the render object and zero-retention delivery - Webhooks to receive the
email.*events and verify their signatures - Template language for the syntax of rule fields and conditions
- Authentication for test keys, scopes and template allowlists
- Batches for one email per item
- Your own storage for
hosted: falseand storage destinations - Plans and limits for what each plan includes
- Errors for every other error code
- API reference for the full schemas
Your own storage
Upload rendered files to Amazon S3, S3-compatible storage, Azure Blob Storage, Google Cloud Storage or SFTP, with destination settings, credentials, connection tests, path templates, hosted copies, zero retention, retries, limits and errors.
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.