--- type: API title: Transactional email API description: Send one-off transactional mail (password resets, receipts, alerts) via POST /api/v1/transactional_emails. Domain-bound API keys, no unsubscribe headers, GDPR suppression honored. resource: https://euromailing.com/api/v1/transactional_emails tags: [transactional, email, api] timestamp: 2026-07-06T00:00:00Z --- # Transactional email API Send a single transactional message (password reset, receipt, alert) through Euromailing's delivery pipeline. Mail is DKIM-signed by the customer's verified domain and carries a proper Message-ID. Transactional mail deliberately differs from campaign mail: - **No unsubscribe headers** are added, and any `List-Unsubscribe*` headers supplied by the caller are stripped. - **Marketing unsubscribes are ignored** — a recipient who opted out of newsletters still receives their receipt. - **The GDPR suppression list IS honored** — erased addresses are refused with `recipient_suppressed`. ## Prerequisites (one-time, in the Euromailing dashboard) 1. Verify a sending domain (DKIM/SPF/DMARC DNS records) under *Sending domains*. 2. Create an API key under *Account → API keys* with the `transactional:send` scope, **bound to that sending domain**. A key not bound to a domain cannot send transactional mail. 3. The account needs an active subscription (or complimentary status); otherwise every send returns HTTP 402. **Partner mode.** A key with the `transactional:send_any_domain` scope may send from **any verified sending domain on the account** — no per-domain binding. This is for apps that send as their customers' domains; manage those domains via the [sending domains API](sending-domains.md). Error codes: `from_domain_not_on_account` (domain unknown on the account), `sending_domain_unverified` (known but not verified yet). ## Request ``` POST https://euromailing.com/api/v1/transactional_emails Authorization: Bearer eml_live_… Content-Type: application/json ``` | Field | Type | Required | Notes | |---------------|--------|----------|-------| | `from` | string | yes | `"Name "` or bare address. The domain must equal the key's bound sending domain — or, with a `transactional:send_any_domain` key, any verified domain on the account. | | `to` | string | yes | Exactly one recipient address. Loop for multiple recipients. | | `subject` | string | yes | | | `html_body` | string | one of these two | A plain-text alternative is auto-derived from HTML when `text_body` is absent. | | `text_body` | string | one of these two | Text-only mail is fully supported. | | `reply_to` | string | no | | | `headers` | object | no | Custom headers (e.g. `In-Reply-To` for threading). `From/To/Cc/Bcc/Subject`, `List-Unsubscribe*` and `X-EML-Meta-*` are silently stripped. A caller-supplied `Message-ID` is preserved. | | `attachments` | array | no | `[{ "filename": "invoice.pdf", "content_type": "application/pdf", "content": "", "content_id": "optional-cid" }]` — 10 MB total decoded size. Set `content_id` to make the attachment **inline (CID)**: reference it in `html_body` as `` and the message is assembled as multipart/related. | | `metadata` | object | no | Opaque key/values (max 2 KB JSON), echoed back verbatim in bounce/complaint webhooks — e.g. `{ "customer_id": 1, "user_id": 42 }` to tie a bounce to the exact record. Oversized → `metadata_too_large`. | ### Example ```bash curl -X POST https://euromailing.com/api/v1/transactional_emails \ -H "Authorization: Bearer $EUROMAILING_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "MyApp ", "to": "user@example.com", "subject": "Reset your password", "html_body": "

Reset

" }' ``` ### Inline (CID) image example ```json { "from": "MyApp ", "to": "user@example.com", "subject": "Welcome", "html_body": "

\"Logo\" Welcome!

", "attachments": [ { "filename": "logo.png", "content_type": "image/png", "content": "", "content_id": "logo" } ] } ``` ## Response Success is `202 Accepted`: ```json { "id": "<9f1c…@app.example.com>", "status": "queued", "from": "MyApp ", "to": "user@example.com" } ``` `id` is the message's **RFC Message-ID** — the same value in the mail's `Message-ID` header, so a recipient forwarding you a copy can be matched back to this send. Supply your own `Message-ID` via `headers` and that one is returned instead. **It is not the key for bounce correlation.** The `message_id` in bounce/complaint webhooks is the MTA's internal queue id, which does not exist yet at injection time and does not equal this value. To tie a bounce back to your own records, send `metadata` — it is echoed back verbatim in those webhooks: ```json { "from": "…", "to": "…", "subject": "…", "html_body": "…", "metadata": { "user_id": 42 } } ``` ## Knowing what happened afterwards Three webhook events cover the outcome of a send. Subscribe per endpoint under Account → Webhooks; you only receive the ones you list. | event | when | |---|---| | `email.delivered` | the receiving server accepted the message | | `email.bounced` | it was rejected — `type` is `hard` or `soft` | | `email.complained` | the recipient marked it as spam | **Subscribe to `email.delivered` if you send in batches.** Without it, the absence of a bounce means both "arrived" and "still retrying", and those need opposite responses — a soft bounce can retry for hours, so a quiet mailbox is not evidence the mailing landed. There is one of these per successful message, so it is materially higher volume than the failure events; that is why it is opt-in. Every one of the three carries: - `sender` — the full envelope From, and `sending_domain` for just the domain. Worth storing: we retain nothing about a transactional message after it is sent, so if you later need to answer "which address did this go out as", this field is the only place it exists. - `reason` — **the mail server's own words**, in full and never truncated. This is the field worth surfacing somewhere a person will see it. It says things like `certificate is only valid for DnsName("spamfilter22.delta.nl")` or `MX didn't resolve to any hosts`, which is usually the whole diagnosis. It can run to thousands of characters when the server enumerates every candidate host, so store it in a text column rather than a bounded one. - `metadata` — your own opaque values, echoed verbatim. ## Errors All errors use the envelope `{ "error": { "code": "...", "message": "...", "details": {} } }`. | HTTP | `error.code` | Meaning / fix | |------|--------------|---------------| | 401 | `missing_credentials`, `invalid_credentials` | Send `Authorization: Bearer `. | | 402 | `payment_required` | Account has no active subscription — choose a plan in the dashboard. | | 402 | `email_limit_reached` | Monthly plan volume used up — upgrade the plan or wait for the next month. | | 403 | `insufficient_scope` | Key lacks `transactional:send`. | | 422 | `key_not_domain_bound` | Bind the API key to a sending domain in the dashboard. | | 422 | `sending_domain_unverified` | Verify the domain's DNS records first. | | 422 | `from_domain_mismatch` | The From address must be on the key's bound domain (`details.allowed_domain`). | | 422 | `invalid_recipient` | `to` must be a single valid email address. | | 422 | `recipient_suppressed` | Recipient requested GDPR erasure — do not retry. | | 422 | `subject_required`, `body_required`, `from_required` | Missing field. | | 422 | `invalid_attachment`, `attachments_too_large` | Base64 must be strict; 10 MB total limit. | | 429 | `rate_limited` | 60 requests/minute per key. Honor `Retry-After`; queue sends through a background job. | | 502 | `injection_failed` | The mail server refused the message — safe to retry with backoff. | | 504 | `injection_timeout` | The mail server never accepted a connection. Nothing was sent; retry freely. | | 504 | `injection_timeout_unresolved` | The mail server stopped answering *while the message was in flight*. It may have been accepted and only the answer lost — **retrying risks a duplicate**. See below. | ### When the outcome is unknown `injection_timeout_unresolved` is the one error here that has no safe automatic response. Retrying may send the mail twice; not retrying may send it never. Which is worse depends on the mail, and only you know that — an invitation twice is embarrassing, a password reset never is worse. If you set your own `Message-ID` header (recommended for exactly this reason) you can tell the two apart afterwards: the message either shows up in your delivery events or it does not. Retry on that evidence rather than blindly. ## Recommended integration patterns - **Rails**: use the `euromailing-rails` gem — existing ActionMailer mailers work unchanged. See [the Rails guide](../guides/rails-gem.md). - **Anything else**: one HTTPS POST per message, retry on 429/5xx with exponential backoff, never retry `recipient_suppressed` or `from_domain_mismatch`. - Send from a background job/queue so the per-key rate limit (60/min) degrades gracefully under burst. ### Pacing a few hundred messages The 60/min rate limit is the documented ceiling, but it is usually not the constraint you hit first. **Concurrency is.** A worker pool that spaces jobs 1.5s apart still fires them in clumps — ten workers waking together put five requests on the wire inside 100ms, which is a burst of five however gentle the average looks. So cap the number of requests **in flight**, not just the rate. Two or three concurrent is plenty for a few hundred messages and leaves room for everything else your account is doing. Worth knowing: every message you send generates traffic back the other way. KumoMTA posts a delivery event per recipient, and looks up your DKIM key, against the same API. A round of 400 messages is closer to 1000 requests once its own callbacks are counted. That is handled for you, but it is why a send round is heavier than the outbound count suggests, and why in-flight concurrency is the number to hold down.