DynamicDocumentAPI

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.

View as Markdown

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

  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 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)
  • 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:

ScopeAllows
email:readReading connections and rules, and rule previews
email:writeCreating, changing, testing and deleting connections and rules, and rule tests
renders:readReading emails, their delivery events and the statistics
render:writeResending 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"
}
FieldDescription
nameA label for the dashboard, up to 100 characters
providerbrevo, smtp, postmark, resend, ses (Amazon SES) or graph (Microsoft 365). It can't change after the connection is created.
configThe 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.
credentialsThe provider's secrets. Write-only: reads show {"configured": true}.
default_fromemail (required) and name: the sender of rules that don't set their own. email is one address, without a display name.
reply_toOptional default reply-to address
test_modeWhat test renders do: sandbox (Brevo only, and its default), redirect or skip (the default for every other provider). See Live and test renders.
test_recipientRequired with redirect: the verified email address of a member of your workspace

Reads also return:

FieldDescription
idThe connection ID (emc_…)
sendersThe verified senders and domains that the last test found, see Senders and verification
statusactive, or failing after the provider refused the credentials or our IP addresses
last_test_at, last_test_statusWhen the last test ran, and whether it succeeded or failed
last_errorWhy the connection is failing: code (email_provider_auth_failed, email_ip_not_authorized or email_credentials_removed), message and at
credentials_delete_atSet after a downgrade: when the credentials will be deleted
egress_ipsThe IP addresses our email traffic leaves from, see Authorise our IP addresses
eventsBrevo only: the delivery events webhook per region, registered or failed
EndpointDescription
GET /v1/email-connectionsList connections. The list also carries egress_ips.
POST /v1/email-connectionsCreate 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}/testTest 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

providerconfigcredentials
brevo—api_key
smtphost, port (587 or 2525, default 587), usernamepassword
postmarkmessage_stream (default outbound)server_token
resend—api_key
sesregion, such as eu-central-1, and optionally configuration_setaccess_key_id, secret_access_key
graphtenant_id, client_id, senderclient_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:

FieldDescription
attachment_budget_bytesBytes of files attached to one email. Files beyond it are linked or fail the email, depending on the rule's attachments.
max_per_secondEmails 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.
ProviderAttachment budget, defaultMaximum
Brevo10 MiB13 MiB
SMTP6 MiB25 MiB
Postmark6 MiB6 MiB
Resend10 MiB25 MiB
Amazon SES10 MiB25 MiB
Microsoft 3652 MiB2 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:

ProviderSteps
Brevocredentials (the API key works), senders (lists your verified senders and domains), sandbox_send (Brevo checks a message from the default sender without delivering it)
SMTPconnect, starttls, smtp_auth
Postmarkcredentials
Resendcredentials, which also lists your verified domains
Amazon SEScredentials, senders
Microsoft 365credentials (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:

Providersenders
BrevoActive senders, and authenticated or verified domains
ResendVerified domains. A key that can only send can't list them, and the test passes without senders.
Amazon SESVerified identities, both domains and addresses. The senders step fails when there are none.
SMTP, Postmark, Microsoft 365None: 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.

FieldTemplate languageDescription
namenoA label, up to 100 characters
connection_idnoThe connection that sends (emc_…)
enablednofalse switches the rule off. Default true.
positionno0 to 4; the other rules move. Defaults to the end.
from_emailnoThe sender: one address, without a display name. Empty uses the connection's default_from.
from_nameyesThe sender name. Empty uses the connection's.
reply_toyesThe reply-to address. Empty uses the connection's.
to, cc, bccyesThe recipients, see Recipients
subjectyesRequired, unless a Brevo template provides it
htmlyes, values escapedThe body. html or text is required.
textyesThe plain-text body. Empty derives it from html.
attachmentsnoattach_or_link (default), attach, link or none, see Attachments and download links
attach_formatsnoOnly files of these formats: pdf, png, jpeg, webp, html, xml, zip. Empty sends every file.
conditionexpressionThe 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.
EndpointDescription
GET /v1/templates/{template_id}/email-rulesList a template's rules in position order
POST /v1/templates/{template_id}/email-rulesCreate 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}/previewPreview the email; nothing is sent
POST /v1/email-rules/previewPreview a rule that isn't saved yet
POST /v1/email-rules/{id}/testSend 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, 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:

{% for contact in customer.contacts %}{{ contact.email }}, {% endfor %}
ProblemThe email fails with
An entry isn't an addressemail_address_invalid; the message names the entry
to has no addressemail_recipient_missing
More than 50 recipientsemail_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:

VariableDescription
Data keys and dataThe render's data, as in the template
renderid, created_at, template_id and test, plus invoice and einvoice when the render computed them (see E-invoicing)
filesThe render's files, in output order: filename, format, bytes and pages (0 for images)
download_linksWhere 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>
  • 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

condition is an expression in the same context, written without {{ }}:

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.

attachmentsWhat the email carries
attach_or_linkThe 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.
attachEvery 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.
linkA download link for every file
noneNo 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.

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 }}}" }
  • 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

delivery.email on POST /v1/renders, the convenience endpoints and POST /v1/batches decides which rules a render uses:

delivery.emailRules
absent or nullEvery enabled rule of the template
falseNone
a list of 1 to 5 entriesOnly 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_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/<i>/…
  • 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.

Live and test renders

RenderEmails
Live renderGo 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 testOne email to the member address you name, with the subject prefixed [Test]
Preview, signed-link renderNone
test_modeWhat a test render's email doesStatusEvent
sandboxBrevo checks the message without delivering it. Brevo only, and its default.sandboxedemail.sent
redirectGoes only to test_recipient, without cc and bcc, with the subject prefixed [Test] sentemail.sent
skipNo 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 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:

"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
}
FieldDescription
sendWhether the rule would send
skip_reasoncondition_false when the condition is false
errorA template, recipient or attachment error that would fail the email
to, cc, bccThe recipients as parsed, each with email and name
recipient_errorsEvery recipient entry that isn't an address
html, textThe 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.
attachmentsThe files, each attached or linked
provider_templateThe 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_…):

StatusMeaning
pendingWaiting to be sent
sendingBeing handed to your provider
retryingAn attempt failed and another follows; error says why
sentYour provider accepted the email. This doesn't mean it has reached the inbox.
sandboxedBrevo's sandbox accepted the email; nothing was delivered
skippedNot sent: the condition was false (condition_false), or a test render's connection has test_mode: "skip" (test_mode)
failedNot sent; error says why
unknownYour provider may have accepted the email, for example because the connection broke after the request was sent. It is never retried automatically.
canceledStopped 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"
}
FieldDescription
idThe email's ID (ems_…)
render_id, batch_id, template_id, rule_id, connection_idWhere the email comes from
status, skip_reasonSee the table above
modelive; for test renders the connection's test_mode (sandbox, redirect or skip); redirect for a rule test
testtrue for the emails of test renders
sandboxtrue when the email went to Brevo's sandbox
toThe to addresses. cc and bcc appear only as counts, cc_count and bcc_count.
subjectThe rendered subject
attachmentsThe files: file_id, filename, bytes and mode (attached or linked)
provider, provider_message_idThe provider and its ID for the message. For SMTP it is the Message-ID we set; Microsoft 365 returns none.
attemptsAttempts so far
errorcode and message. Provider errors add provider_status and provider_code; template errors add field, line and column.
deliveryBrevo's delivery events per kind: delivered, deferred, bounced (soft and hard bounces), blocked, complained and invalid. Zeros for other providers.
resend_ofThe email this one repeats
created_at, sent_atWhen the email was created, and when the provider accepted it
EndpointDescription
GET /v1/email-sendsList 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}/eventsBrevo's delivery events of an email
GET /v1/email-sends/statsToday's recipients against the daily cap, and the last 30 days by status
POST /v1/email-sends/{id}/resendSend an email again
POST /v1/batches/{id}/resend-emailsResend 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.

OutcomeStatusNext
The provider accepted the emailsent, or sandboxed in Brevo's sandboxemail.sent
The provider couldn't be reached, refused our IP address or applied its rate limitretryingAnother attempt
The provider refused the message or the credentials, or the email can't be builtfailedemail.send_failed
The provider may have accepted it, for example because the connection broke after the request was sentunknownemail.send_failed; no automatic retry
AttemptAfter the previous attempt
21 minute
35 minutes
415 minutes
51 hour
64 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

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:

{ "resent": 12, "skipped": [{ "send_id": "ems_01J9ZM1X3F7R8K2C4V6B8N0P2T", "code": "email_file_expired" }] }

Events and webhooks

EventSent when
email.sentYour provider accepted an email (sent), or Brevo's sandbox did (sandboxed)
email.send_failedAn email ended failed or unknown, including emails that failed as soon as they were created
email.deliveredBrevo reported the email delivered to a recipient
email.bouncedBrevo reported a soft or hard bounce, a block or an invalid address
email.complainedBrevo 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
}
eventWebhook event
deliveredemail.delivered
deferrednone
soft_bounce, hard_bounce, blocked, invalidemail.bounced
spamemail.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

LimitValue
Connections per workspace10
Rules per template, and so per render5
Recipients per email50, to, cc and bcc together, without duplicates
Recipients per dayThe daily recipient cap
Rule sources16 KiB per field; html 256 KiB; Brevo template params 64 KiB
Rendered fieldssubject, 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 emailThe connection's attachment budget, see Providers
Sending ratemax_per_second per connection: 1 to 100, default 10
Attempts per email6, within 24 hours
Download linksValid until the file's retention ends, at most 7 days
Email history30 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:

PlanRecipients per day
Free, Starternone: email delivery isn't included
Growth15,000
Pro50,000
Scale200,000
Enterprise1,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:

{
  "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. 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.
  • 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

Request errors:

StatusCodeWhen
400validation_errorA 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.
402plan_feature_unavailableThe 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
403feature_not_enabledEmail 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.
409email_connection_in_useThe connection you deleted is used by rules; rules lists them with id, name, template_id and template_name
409email_not_resendableThe email can't be resent, see Resend
409email_file_expiredA file of the email you resent is no longer hosted
422email_template_errorA rule field doesn't compile; errors[] gives path, line and column
422email_needs_hosted_fileA delivery.email list on a zero-retention render, or a rule test in a zero-retention workspace

Render warnings:

CodeMeaning
email_skipped_planThe template has enabled email rules, but the plan doesn't include email delivery. No email was sent.
email_skipped_zero_retentionThe 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:

CodeStatusMeaning
email_template_errorfailedA rule field failed to render; field, line and column say where
email_too_largefailedA rendered field is over its limit
email_recipient_missingfailedto rendered no address
email_address_invalidfailedA recipient, the sender or the reply-to address isn't a valid address
email_too_many_recipientsfailedMore than 50 recipients
email_attachment_too_largefailedWith attach, the files don't fit the attachment budget
email_attachment_type_not_allowedfailedWith attach, the provider refuses a file type
email_needs_hosted_filefailedA file can't be linked or attached because no hosted copy exists
email_provider_rejectedfailedThe provider refused the message, for example its sender, a recipient or the content
email_provider_auth_failedfailedThe provider refused the credentials. Update them and test the connection.
email_provider_unavailableretrying, then failedThe provider couldn't be reached
email_ip_not_authorizedretrying, then failedThe provider refused our IP address. Authorise it.
email_rate_limitedretrying, then failedThe provider's rate limit or quota
email_outcome_unknownunknownThe provider may have accepted the email; check its logs before you resend
email_daily_cap_reachedfailedThe daily recipient cap is reached; resend after 00:00 UTC
email_disabledfailedSending email is switched off for your workspace. Contact support.
email_credentials_removedfailedThe connection's credentials were deleted after a downgrade. Enter them again.
email_send_expiredfailedThe email couldn't be attempted within 24 hours
internal_errorfailedThe 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 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.

On this page