Send an email
POST /emails: one message, now or later.
Runs the real call against your workspace, with your own key.
The request
from is required. Unlike the composer there is no fallback sender, because that fallback is postmaster@ whichever domain was added first and it changes invisibly as domains come and go.
| Field | Required | Notes |
|---|---|---|
| from | yes | A bare address or Name <addr>. Must be one the key may send as. |
| to | yes | Up to 50 recipients across to, cc and bcc combined. |
| cc, bcc | no | Bcc recipients are never named in the bytes anyone else receives. |
| subject | no | Defaults to empty. |
| html, text | one of | Both is fine. HTML is what recipients see. |
| template | one of | { id, version?, props?, slots? }. A stored body, by id or by slug. Refused alongside html, text or draftId. See Send with a template. |
| replyTo | no | A single address. |
| headers | no | X-*, List-*, Reply-To, Precedence, Auto-Submitted, Importance, Priority, Feedback-Id. |
| attachments | no | { filename, content, contentType } as base64, 5 MB in total, or { fileId } naming a file already in the workspace. 20 files. |
| attachmentDelivery | no | mime, link or auto. auto links files once they pass 2 MB on a domain with an active files domain. Defaults to the mailbox setting. |
| threadId | no | Reply into an existing thread. |
| draftId | no | Send an existing draft. |
| scheduledAt | no | ISO instant or duration. See Scheduling. |
| cancellableForSeconds | no | An undo window of 0 to 900 seconds on an immediate send. Refused alongside scheduledAt, which stays cancellable until it sends. See Scheduling. |
| signature | no | false leaves the signature off this message. Otherwise it carries the signature of the address it is sent from, which is that address’s own or else the one set for All addresses. |
| tags | no | Up to 10 labels of your own. Echoed back, never interpreted. |
| tracking | no | { opens?, clicks? }. Either one overrides the setting for this message; omit a field and that half falls back to the setting of the address it is sent from, or else to All addresses, and it is on unless one of those turned it off. |
| translate | no | { to, from?, subject?, includeOriginal? }. Sends it in the recipient’s language. Resolved when the request is accepted, refused alongside draftId. |
Unknown fields are rejected rather than ignored, so a misspelled name is a 422 now instead of a surprise later. Headers that would defeat sender authorisation (From, Sender, Bcc, Message-ID, Return-Path and others) are refused with reserved_header.
The response
200 when the message has already gone, 202 when something still has to happen to it. A caller branching on the status code is right about both.
{ "object": "email", "id": "msg_c5f21cc6bfec4e848caf905b", "status": "sent", "mode": "live", "from": "[email protected]", "subject": "Your September invoice", "messageId": "<2598…@acme.com>", "transport": "ses", "sentAt": "2026-08-29T08:19:08.000Z", "source": "api", "replayed": false}id is the durable handle you keep, and the one a delivery event comes back on, since a bounce webhook names it as emailId. messageId is the RFC 5322 Message-ID and is null until the MIME exists. Do not correlate on it: the sending service rewrites that header on the way out, so the value here appears in no bounce or delivery report and a match on it never fires.
In the recipient’s language
translate writes the message in somebody else’s language before it goes. The body, and the subject unless you turn that off, is translated at the moment the request is ACCEPTED, which is the same rule template follows and is load-bearing for the same reasons: a scheduled message carries the words that were approved rather than whatever a model produces on Tuesday, and a translation that could not be produced refuses the send before a row exists. Nothing is delivered in a language its sender did not choose.
translate
tostringrequired- The language to write in: a BCP-47 code (`de`), an English name ("German") or the language’s own name ("Deutsch"), 2 to 60 characters. All three are normalised to the table code before anything else happens, so they are one request, which matters because the Idempotency-Key fingerprint is taken over the parsed request. Aliases resolve as well: `zh-TW` becomes `zh-Hant`. One that resolves to nothing is a 422 on `translate.to`.
fromstring- What you wrote it in, in any of the same three forms. Purely an optimisation. Left out, the body is read and the language worked out, which costs one short model call. Worth stating on a high-volume path, and worth stating when the body is mostly names, numbers and links: detection abstains rather than guess, and an undetermined source costs you nothing but the language named in the caption over your original. Not the top-level `from`, which is an address.
subjectboolean- Translate the subject line too. Defaults to true; false sends the subject exactly as you wrote it.
includeOriginalboolean- Put what you actually wrote below the translation, behind a separator and captioned in the recipient’s language. Defaults to true, and worth leaving on. It is the only thing that lets the person reading check a sentence that lands strangely rather than being asked to trust a model neither of you can see the output of.
curl -X POST "$OE/emails" -H "$AUTH" -H "Content-Type: application/json" \ -d '{ "from": "[email protected]", "to": ["[email protected]"], "subject": "Your September invoice", "html": "<p>Invoice attached. Payment is due on the 14th.</p>", "translate": { "to": "de" } }'{ "object": "email", "id": "msg_c5f21cc6bfec4e848caf905b", "status": "sent", "from": "[email protected]", "subject": "Ihre Rechnung für September", "translation": { "language": "de", "languageName": "German", "detectedSourceLanguage": "en", "subject": true, "includeOriginal": true }}translation is additive and appears only on a message that was translated: on this response and on GET /emails/{id}, never on a list row, because a list does not fetch the stored request and its silence there says nothing either way. It carries codes rather than whole language rows: it is a record of what was done, and GET /languages is where the endonym lives. The subject on the response is the translated one, so a console never lists a message under a string the recipient never saw.
- Works with
template, and that is the useful case: the RENDERED output is what gets translated, so one stored body serves every language your customers read in. A template that renders a whole document is taken apart first: only what is inside<body>reaches the model, and the doctype, the<style>blocks and the@font-facerules are put back around the answer. It is also why the 30,000-character limit measures the prose and not the document: a two-line message wrapped in a branded stylesheet is a two-line message. - The one part of a template left untranslated is its
<title>, which no mail client displays. A react-email<Preview>renders into the body and is translated with the rest. - Refused with
draftId: a 422 ontranslate, reading "A draft is sent as it was written; translate a body or send a draft, not both". A draft was written by a person and is sent as they left it. - Deliberately not part of the idempotency fingerprint. What is hashed is the request you sent,
translateincluded; what the model produced is not. So retrying an unanswered send with the sameIdempotency-Keyreplays the original. The message that already exists comes back, with no second send and no second translation. Hashing the wording instead would make an honest retry fingerprint differently every time, which is how the same message goes out twice. - A translated message that is queued or scheduled is frozen against wording changes. Move it or cancel it; altering what it says means cancelling and sending again, in front of somebody who can read the new words.
- A right-to-left target is produced right to left: the translation wrapped in
dir="rtl", your original underneath oriented on its own. The attribute survives the outgoing sanitiser, which allowsdirfor exactly this reason, so the message on the wire carries the direction the preview showed.
| Code | Status | When |
|---|---|---|
| `invalid_parameter` | 422 | translate.to or translate.from names no language we can place. The message says which three forms are accepted and points at GET /languages. |
| `unknown_language` | 422 | The same failure caught a step later, by the service rather than the schema. A backstop, on translate.to. |
| `translation_too_long` | 422 | Over 30,000 characters at either end of the model call. A refusal rather than a truncation: half a translated message has no seam to show where it stopped, and the person reading acts on the half they were given. |
| `translation_not_configured` | 409 | The workspace has no AI key and platform AI is off. A 409 rather than a 503 because the retry fails identically. Nothing was sent. Send without translate if you meant to send it as written. |
| `translation_failed` | 503 | The provider did not answer, or answered with nothing usable. Nothing was sent; the message is never posted untranslated as a fallback. This one is ours and is worth retrying. |
| `unknown_parameter` | 422 | An unrecognised key inside translate, which is a strict object like the rest of the request. |
A send from code has nobody reading the translation first. POST /emails/translate is the same round trip stopped one step early, for showing a person what they are about to send. Then send what they approved as an ordinary html/subject with no translate on the request at all.