Docs · Sending
Sending email
One message with POST /v1/emails, up to 100 with POST /v1/emails/batch, and the message read back with GET /v1/emails/{id}. The shapes are Resend's, plus a few Owlpost fields, each marked below.
Send one email
POST/v1/emailsBuilt
Scope emails:send. A valid request is stored and answered at once with its id; delivery happens just after the response, so the caller never waits for the mail server.
| Field | Type | Rules |
|---|---|---|
from required | string | addr@domain or Name <addr@domain>. With a live key, on a verified domain or a subdomain of one. |
to required | string or string[] | At least one. 50 recipients at most across to, cc and bcc together. |
cc, bcc | string or string[] | Count towards the 50. |
reply_to | string or string[] | Where replies go. |
subject required | string | Not empty, at most 998 characters, no line breaks. |
html, text | string | At least one of the two, not blank. |
headers | object | Extra headers, name to value. See Headers. |
attachments | object[] | See Attachments. |
tags | {name, value}[] | See Tags. |
scheduled_at | string | RFC 3339, in the future, at most 30 days ahead. Natural language ("in 1 hour") is refused. |
stream Owlpost | string | transactional (default) or marketing. See Streams. |
topic Owlpost | string | Marketing only. 1 to 64 of a-z 0-9 : _ -, e.g. project:news. See Topics. |
template, variables | - | Refused with 422 until templates exist Planned. |
curl https://api.owlpost.to/v1/emails \
-H "Authorization: Bearer $OWLPOST_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4182-receipt" \
-d '{
"from": "Acme <receipts@example.com>",
"to": ["ada@example.org"],
"reply_to": "support@example.com",
"subject": "Your receipt for order 4182",
"html": "<p>Thanks for your order.</p>",
"tags": [{ "name": "category", "value": "receipt" }]
}'const res = await fetch('https://api.owlpost.to/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OWLPOST_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-4182-receipt',
},
body: JSON.stringify({
from: 'Acme <receipts@example.com>',
to: ['ada@example.org'],
reply_to: 'support@example.com',
subject: 'Your receipt for order 4182',
html: '<p>Thanks for your order.</p>',
tags: [{ name: 'category', value: 'receipt' }],
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.status} ${body.title}: ${body.detail}`);
console.log(body.id);import os
import requests
res = requests.post(
"https://api.owlpost.to/v1/emails",
headers={
"Authorization": f"Bearer {os.environ['OWLPOST_API_KEY']}",
"Idempotency-Key": "order-4182-receipt",
},
json={
"from": "Acme <receipts@example.com>",
"to": ["ada@example.org"],
"reply_to": "support@example.com",
"subject": "Your receipt for order 4182",
"html": "<p>Thanks for your order.</p>",
"tags": [{"name": "category", "value": "receipt"}],
},
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['status']} {body['title']}: {body['detail']}")
print(body["id"]){ "id": "em_01j9zq5x7k3m2n4p6r8s0t1v2w" }A request that breaks rules gets one 422 listing every broken field at once, so it can be fixed in one round trip:
{
"type": "https://owlpost.to/problems/validation-failed",
"title": "The request broke one or more rules",
"status": 422,
"detail": "to[1]: `bad@` has an invalid domain; subject: the subject is empty",
"instance": "01J9ZQ…",
"errors": [
{ "field": "to[1]", "message": "`bad@` has an invalid domain" },
{ "field": "subject", "message": "the subject is empty" }
]
}Other answers: 403 domain-not-verified when a live key names a sender on a domain it has not verified, 409 idempotency-conflict (below), 413 for a body over 8 MiB, 429 (rate limits).
Send a batch
POST/v1/emails/batchBuilt
Scope emails:send. The body is a JSON array of 1 to 100 messages, each shaped like a single send, plus an optional idempotency_key per message Owlpost. The body may be up to 32 MiB.
- All or nothing. Every message is checked before any is stored. One broken message refuses the whole batch with one
422, its fields prefixed with the message's index:[7].to,[2].subject. - No attachments in a batch: send a message with attachments on its own.
- Ids in request order:
{"data": [{"id": "em_…"}, …]}. - Rate limit: a batch of n messages costs n requests against the key, as n single sends would.
curl https://api.owlpost.to/v1/emails/batch \
-H "Authorization: Bearer $OWLPOST_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: digest-2026-10-03" \
-d '[
{ "from": "Acme <news@example.com>", "to": "ada@example.org",
"subject": "Your weekly digest", "text": "…", "idempotency_key": "digest-2026-10-03-ada" },
{ "from": "Acme <news@example.com>", "to": "grace@example.org",
"subject": "Your weekly digest", "text": "…", "idempotency_key": "digest-2026-10-03-grace" }
]'{ "data": [ { "id": "em_01j9…a" }, { "id": "em_01j9…b" } ] }Read a message back
GET/v1/emails/{id}Built
Scope emails:read. Another account's id answers 404, never 403, so ids cannot be probed.
{
"object": "email",
"id": "em_01j9zq5x7k3m2n4p6r8s0t1v2w",
"from": "Acme <receipts@example.com>",
"to": ["ada@example.org"],
"cc": [],
"bcc": [],
"reply_to": ["support@example.com"],
"subject": "Your receipt for order 4182",
"html": "<p>Thanks for your order.</p>",
"text": null,
"tags": [{ "name": "category", "value": "receipt" }],
"last_event": "delivered",
"stream": "transactional",
"topic": null,
"mode": "live",
"created_at": "2026-10-03T09:00:00Z",
"scheduled_at": null
}last_event is the latest thing that happened to the message:
| Value | Meaning |
|---|---|
queued | Stored, waiting for the sender (or waiting to retry) |
scheduled | Waiting for scheduled_at. The sender runs every minute, so it leaves within about a minute of that time. |
sent | Amazon SES accepted it (a test key marks it sent without sending) |
delivered, delivery_delayed | The recipient's server accepted it, or is taking its time |
bounced, soft_bounced | Permanent bounce (the address is suppressed), or a temporary one (it is not) |
complained | A recipient marked it as spam; the address is suppressed |
rejected | SES refused it |
failed | Owlpost gave up: the provider refused it, every recipient was suppressed, or ten attempts failed |
opened, clicked | Reported by SES when tracking applies |
Delivery is retried when the provider fails temporarily: after 1, 2, 4, 8 … minutes, at most 3 hours apart, ten attempts in all.
GET/v1/emailsPlanned
Listing sent mail with cursor pagination is not built: #12. Until then, keep the ids POST returns.
Idempotency keys
Retrying a send after a timeout should not send twice. Send an Idempotency-Key header (1 to 256 visible characters) and a retry gets the first answer back.
On a single send
- Same key, byte-identical body:
200with the first message's id. Nothing new is stored or sent. - Same key, different body:
409 idempotency-conflict. The comparison is a hash of the raw body, so re-serialising the same JSON with different key order or spacing counts as different. Retry with the exact bytes you sent. - Two concurrent requests with one key cannot both win: a unique index decides, and the loser answers as the winner did.
- Keys are per account and do not expire today.
On a batch
- The header covers the whole request. Same key and byte-identical body:
200with the same ids, in order. Same key, different body:409. Batch header keys live apart from single-send keys. - A message's
idempotency_keyOwlpost covers that message, so one message re-sent inside a new batch keeps its id and is not sent again. Two messages in one batch cannot share a key (422). Here the comparison is the parsed message, so formatting does not matter; a key already stored for a different message is409, naming its index. - Per-message keys share one namespace with single-send keys, but the two are compared differently (raw body for a single send, parsed message for a batch item). So reusing a single send's key as a batch message's key is refused with
409, even for the same message. Keep the two apart. - A batch that races a concurrent request using the same keys can get
409("This batch raced another request"). Retry the same request.
Attachments
| Field | Rules |
|---|---|
filename required | A plain file name: not empty, no /, \ or line breaks |
content | The file as a base64 string |
content_type | Optional; guessed from the extension otherwise (falls back to application/octet-stream) |
path | A URL to fetch the file from: refused with 422 for now Planned |
At most 50 attachments, and the whole message, attachments included after base64, at most 5 MiB. A larger message is refused at the door with a 422 on attachments. Attachments are not accepted in a batch.
"attachments": [
{ "filename": "invoice-4182.pdf", "content": "JVBERi0xLjcK…", "content_type": "application/pdf" }
]Tags
Up to 50 {"name", "value"} pairs per message, each 1 to 256 characters of letters, digits, _ and -. They are stored with the message and returned by GET /v1/emails/{id}.
Headers
Up to 50 custom headers. Names are printable ASCII without :; values may not contain line breaks. Owlpost builds these itself and refuses them (422): Bcc, Cc, Content-Transfer-Encoding, Content-Type, Date, DKIM-Signature, From, Message-ID, MIME-Version, Received, Reply-To, Return-Path, Sender, Subject, To. Use the fields instead.
Streams
Transactional and marketing mail travel on separate streams (separate SES configuration sets), so a campaign cannot hold up a password reset. A message with "stream": "marketing":
- goes to exactly one recipient in
to, with noccorbcc, because each recipient gets their own unsubscribe link. Send a list as a batch. - carries
List-UnsubscribeandList-Unsubscribe-Post: List-Unsubscribe=One-Click(RFC 8058). See Unsubscribes. - may name a
topic, so the recipient can leave that topic and keep the rest.
Test keys
A message sent with an op_test_… key never leaves Owlpost. It is validated, stored, checked against the suppression list and marked sent, so your code sees the same answers as in production. Two differences: any from domain is accepted, verified or not, and no webhook events are sent for test mail. Signed test events are Planned (#11). GET /v1/emails/{id} shows "mode": "test".