Skip to the documentation
API

Send an email

POST /emails: one message, now or later.

POSTapi.openemail.uk/emails

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.

FieldRequiredNotes
fromyesA bare address or Name <addr>. Must be one the key may send as.
toyesUp to 50 recipients across to, cc and bcc combined.
cc, bccnoBcc recipients are never named in the bytes anyone else receives.
subjectnoDefaults to empty.
html, textone ofBoth is fine. HTML is what recipients see.
templateone of{ id, version?, props?, slots? }. A stored body, by id or by slug. Refused alongside html, text or draftId. See Send with a template.
replyTonoA single address.
headersnoX-*, List-*, Reply-To, Precedence, Auto-Submitted, Importance, Priority, Feedback-Id.
attachmentsno{ filename, content, contentType } as base64, 5 MB in total, or { fileId } naming a file already in the workspace. 20 files.
attachmentDeliverynomime, link or auto. auto links files once they pass 2 MB on a domain with an active files domain. Defaults to the mailbox setting.
threadIdnoReply into an existing thread.
draftIdnoSend an existing draft.
scheduledAtnoISO instant or duration. See Scheduling.
cancellableForSecondsnoAn undo window of 0 to 900 seconds on an immediate send. Refused alongside scheduledAt, which stays cancellable until it sends. See Scheduling.
signaturenofalse 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.
tagsnoUp to 10 labels of your own. Echoed back, never interpreted.
trackingno{ 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.
translateno{ 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.

200 OK
{  "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
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" }  }'
200 OK
{  "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-face rules 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 on translate, 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, translate included; what the model produced is not. So retrying an unanswered send with the same Idempotency-Key replays 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 allows dir for exactly this reason, so the message on the wire carries the direction the preview showed.
CodeStatusWhen
`invalid_parameter`422translate.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`422The same failure caught a step later, by the service rather than the schema. A backstop, on translate.to.
`translation_too_long`422Over 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`409The 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`503The 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`422An 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.