Overview
Every email you send goes through one endpoint family under /v1/emails. This
guide covers the full surface: a single send, batches, broadcasts, attachments,
scheduling, idempotency, tagging, and how to look up what happened afterwards.
For the exhaustive field-by-field reference, see the Emails API reference.
All requests send your API key as Authorization: Bearer em_live_... and target
https://api.euromail.dev. The API also accepts the key in an X-EuroMail-Api-Key
header. When a request fails, see Errors & Rate Limits.
Prefer SMTP, or integrating WordPress or a legacy app that only speaks SMTP? See Send Email via SMTP. Same pipeline, no code.
Send a single email
curl -X POST https://api.euromail.dev/v1/emails \
-H "Authorization: Bearer em_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"from": "[email protected]",
"to": "[email protected]",
"subject": "Welcome aboard",
"html_body": "<h1>Welcome!</h1><p>Thanks for signing up.</p>",
"text_body": "Welcome! Thanks for signing up."
}'
A successful call returns 202 Accepted. The email is queued for delivery, not
yet delivered. The response wraps the identifiers you need to track it in data:
{
"data": {
"id": "a1b2c3d4-...",
"message_id": "<[email protected]>",
"status": "queued",
"to": "[email protected]",
"sandbox": false,
"scheduled_at": null,
"created_at": "2026-06-13T10:00:00Z"
}
}
With an array of to recipients, data is an array with one entry per recipient.
Keep the id. It is the handle for status look-ups,
webhooks, and cancellation.
Required fields
| Field | Notes |
|---|---|
from | Must be an address on a domain you have added to your account (otherwise 403). Until the domain is verified, you can only send to your own account email; see Sandbox mode. |
to | A single address or an array of addresses. |
subject | Required unless you send via a template that sets it. |
You must supply at least one of html_body, text_body, or template_alias.
Always include a text_body alongside html_body. A plain-text part improves
deliverability and serves clients that cannot render HTML.
Recipients, CC, BCC and reply-to
to accepts either a string or an array. cc and bcc are arrays. reply_to
sets the address replies go to when it differs from from:
{
"from": "[email protected]",
"to": ["[email protected]", "[email protected]"],
"cc": ["[email protected]"],
"bcc": ["[email protected]"],
"reply_to": "[email protected]",
"subject": "Your June invoice",
"html_body": "<p>Invoice attached.</p>"
}Attachments
Attachments are base64-encoded inline in the request. Each needs a filename,
the base64 content, and a content_type:
{
"from": "[email protected]",
"to": "[email protected]",
"subject": "Your invoice",
"html_body": "<p>Invoice attached.</p>",
"attachments": [
{
"filename": "invoice-2026-06.pdf",
"content": "JVBERi0xLjQKJ...base64...",
"content_type": "application/pdf"
}
]
}
Keep the total request under the 2 MB body limit. For large files, link to a download instead of attaching.
What is refused
EuroMail reads the file itself, not only its name, so renaming a file does not
get it through. A send with a refused attachment is rejected with 422 and the
error code ATTACHMENT_REFUSED. The message names the attachment and the
reason, and nothing is queued:
{
"error": {
"type": "attachment_refused",
"code": "ATTACHMENT_REFUSED",
"message": "Attachment 'invoice.pdf' refused: the file is a Windows executable, which is not accepted"
}
}
These are refused:
- Programs, installers and scripts (
.exe,.msi,.bat,.js,.ps1,.jar,.lnk,.htaand similar), and disk images (.iso,.img,.vhd). - HTML and SVG files (
.html,.htm,.svg), which can carry script. An SVG picture inside a.docx,.xlsxor.pptxis fine. - Office files with macros (
.docm,.xlsm,.pptm, and a.docxthat holds a macro project), and legacy.xlsworkbooks with VBA or Excel 4.0 macro sheets. - Archives EuroMail cannot open:
.rar,.7z,.cab,.gz,.tar. - Outlook
.msgfiles. Attach the message as.emlinstead. - File names with control or invisible characters, such as
report.pdffollowed by a zero-width space or a right-to-left override. - A file whose content does not match its name, such as a program named
.pdf.
A .zip is accepted, and EuroMail opens it and checks every file inside
against the same rules.
A zip is refused when it is damaged, uses a compression method EuroMail cannot
read, holds archives nested more than two levels deep, or expands to more than
50 MB or 1,000 files.
Password-protected archives are refused, because nobody can check what is in them. The error says so: "password-protected archives are refused because they cannot be checked". Send the files unprotected, or share them through a link.
An attached .eml is opened the same way: each attachment inside it, including
forwarded messages, is held to these rules.
Files are also scanned for viruses. An infected file is refused with the same
422 and the name of what was found.
If the attachment scanner cannot answer, the send is rejected with 503, a
Retry-After header and the error code ATTACHMENT_SCAN_UNAVAILABLE. Nothing
in the request is sent or queued, even when it has many recipients or emails,
so retry the whole request after the delay. Over SMTP the same case is a 451 reply;
a refused attachment is a 554.
Browser session requests
The API examples use an API key. If your browser integration instead uses an authenticated
session cookie, requests that change data must include
X-Requested-With: XMLHttpRequest. Methods other than GET, HEAD and OPTIONS need
this exact value; without it, the API returns 403 with error code CSRF_FAILED. Your
origin must also pass the configured credentialed CORS policy. API-key requests do not
need this header. Dashboard forms use their own CSRF token.
Idempotency
Pass an idempotency_key when retrying a send. Within the seven-day lookup window,
requests using the same account and key share the original persisted email. Concurrent
requests with that key wait for the first request's decision before reserving quota or
creating another email. Keys may contain at most 255 bytes.
A retry returns the original result:
{
"from": "[email protected]",
"to": "[email protected]",
"subject": "Order #4815 confirmed",
"html_body": "<p>Thanks for your order.</p>",
"idempotency_key": "order-4815-confirmation"
}
The key goes in the JSON body as
idempotency_key. AnIdempotency-KeyHTTP header is ignored: a retry that sets only the header creates a second email.
Use a key derived from your own domain object, such as an order or invoice ID, and reuse it only for retries of that send. The original persisted message remains authoritative; changing the payload does not replace it.
For an array of to recipients, each position gets a stable derived key. Retry with the
same key and the same recipient list in the same order. If the first request stopped
partway through the list, the retry reuses earlier messages and continues the remaining
positions. Repeated addresses in distinct positions remain distinct sends.
A queue error can occur after a keyed message has been saved. EuroMail keeps that row and its quota reservation so recovery can publish it again; retry with the same key to retrieve its original identity. A saved message has not necessarily reached the receiving server. Idempotency prevents duplicate API message creation, but cannot guarantee exactly-once SMTP delivery across every network failure.
Scheduling and cancelling
Set send_at to an RFC 3339 timestamp to schedule delivery for the future. The
response status is scheduled and scheduled_at is populated. See the
Scheduled Sending guide for time-zone handling.
{
"from": "[email protected]",
"to": "[email protected]",
"subject": "Your trial ends tomorrow",
"html_body": "<p>Renew to keep your data.</p>",
"send_at": "2026-06-20T09:00:00Z"
}
Cancel a scheduled email any time before it is sent:
curl -X POST https://api.euromail.dev/v1/emails/{id}/cancel \
-H "Authorization: Bearer em_live_your_key_here"Tags and metadata
tags are short labels for grouping and reporting. They power the per-tag
breakdown in Analytics. metadata is an arbitrary JSON
object echoed back on the email and on webhook events, useful for correlating an
email with your own records:
{
"from": "[email protected]",
"to": "[email protected]",
"subject": "Password reset",
"html_body": "<p>Reset link inside.</p>",
"tags": ["auth", "password-reset"],
"metadata": { "user_id": "u_8814", "ip": "203.0.113.7" }
}Open and click tracking
Tracking is controlled per send with the tracking flag (defaults to your
account setting). When enabled, EuroMail rewrites links and inserts an open
pixel, then reports opens and clicks via webhooks and analytics. After a send,
inspect the rewritten links:
curl https://api.euromail.dev/v1/emails/{id}/links \
-H "Authorization: Bearer em_live_your_key_here"
For one-to-one transactional mail where tracking is undesirable, set
"tracking": false.
Batch sends
/v1/emails/batch accepts an array of independent messages in one request. Each
is processed on its own; the response reports per-message success and the index
of any failures, so one bad recipient never fails the whole batch:
curl -X POST https://api.euromail.dev/v1/emails/batch \
-H "Authorization: Bearer em_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"emails": [
{ "from": "[email protected]", "to": "[email protected]", "subject": "Hi A", "html_body": "<p>A</p>" },
{ "from": "[email protected]", "to": "[email protected]", "subject": "Hi B", "html_body": "<p>B</p>" }
]
}'
The response includes an operation_id you can poll, the successful data
entries, and an errors array of { index, error } for any that were rejected.
Broadcasts and newsletters
To send one message to a whole contact list, with unsubscribe handling and suppression checks applied automatically. Use a broadcast rather than batching addresses yourself. See the Newsletters guide for the end-to-end flow.
Looking up a sent email
Fetch the current status and delivery details of any email by id:
curl https://api.euromail.dev/v1/emails/{id} \
-H "Authorization: Bearer em_live_your_key_here"
List recent emails, optionally filtered by status and paged:
curl "https://api.euromail.dev/v1/emails?status=sent&page=1&per_page=50" \
-H "Authorization: Bearer em_live_your_key_here"
An email has one of these statuses:
| Status | Meaning |
|---|---|
queued | Accepted and waiting for a delivery worker. |
scheduled | Waiting for its send_at time. |
processing | A worker is delivering it now. |
sent | The recipient's mail server accepted it. This is where a successful delivery ends today. |
delivered | Part of the API enum but not set by the current delivery path. Treat it like sent. |
bounced | The recipient address was refused, either during delivery or by a bounce that arrived later (a sent email can still become bounced). |
failed | Delivery failed and is retried while attempts remain, or you cancelled a scheduled send. error_message says which. |
rejected | EuroMail refused to deliver it, for example because the account was paused after the send was accepted. |
For how bounces and retries work, see the Bounce & Feedback Loops guide. Emails that exhaust retries land in the Dead Letter Queue.
Sandbox mode
Sandbox applies while the from domain is added but not yet verified (DKIM and
return-path). In sandbox you can send only to the email address of your account,
with no cc or bcc; any other recipient is refused with 422. Those sends are
real: they are delivered to your inbox, and the response carries
"sandbox": true (plus an X-EuroMail-Sandbox: true header). They skip the
send-rate caps and the new-account allowance, so you can test freely against
your own inbox. Verify the domain to send to anyone.
New-account limits (first 24 hours)
For its first 24 hours -- counted from account creation -- every account is
on probation: a send-rate cap (5 emails/minute, 25/hour, loosening over the
first week) and a recipient allowance of 20 tokens refilling at 2 per hour,
counted per deliverable recipient (to plus every cc and bcc). A settled,
non-zero invoice raises the allowance to 1,000; a trial does not count as a
payment. The allowance lifts entirely once the account is a day old; the
send-rate cap loosens to 15/minute and 75/hour and reaches the full limits
after the first week.
A refused send returns 429 with a retry-after header you can back off
on (SMTP sends get 452, a standard transient
that mailers retry). Sandbox mode sends never consume the
allowance. Migrating existing traffic? Create the account a day early and
the window never touches you -- see the migration guides.
Best practices
- Send a
text_bodywith everyhtml_body. It is the single cheapest deliverability win. - Use an
idempotency_keyon anything triggered by user actions so retries never double-send. - Tag by purpose (
auth,billing,marketing) to keep analytics legible. - Verify your domain and warm your volume before high-rate sending. See Domain Verification and IP Warm-up.
- Subscribe to webhooks instead of polling for status at scale.