← Back to docs

Sending Emails

The complete guide to sending transactional email with EuroMail. Single sends, batches, attachments, scheduling, idempotency, and tracking.

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

FieldNotes
fromMust 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.
toA single address or an array of addresses.
subjectRequired 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, .hta and similar), and disk images (.iso, .img, .vhd).
  • HTML and SVG files (.html, .htm, .svg), which can carry script. An SVG picture inside a .docx, .xlsx or .pptx is fine.
  • Office files with macros (.docm, .xlsm, .pptm, and a .docx that holds a macro project), and legacy .xls workbooks with VBA or Excel 4.0 macro sheets.
  • Archives EuroMail cannot open: .rar, .7z, .cab, .gz, .tar.
  • Outlook .msg files. Attach the message as .eml instead.
  • File names with control or invisible characters, such as report.pdf followed 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. An Idempotency-Key HTTP 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:

StatusMeaning
queuedAccepted and waiting for a delivery worker.
scheduledWaiting for its send_at time.
processingA worker is delivering it now.
sentThe recipient's mail server accepted it. This is where a successful delivery ends today.
deliveredPart of the API enum but not set by the current delivery path. Treat it like sent.
bouncedThe recipient address was refused, either during delivery or by a bounce that arrived later (a sent email can still become bounced).
failedDelivery failed and is retried while attempts remain, or you cancelled a scheduled send. error_message says which.
rejectedEuroMail 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_body with every html_body. It is the single cheapest deliverability win.
  • Use an idempotency_key on 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.

Send your first email in about 90 seconds

The free tier includes 3,000 emails a month. Email storage and AI processing in Finland, with a DPA on every plan.

Create free account See live delivery data