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.

FieldTypeRules
from requiredstringaddr@domain or Name <addr@domain>. With a live key, on a verified domain or a subdomain of one.
to requiredstring or string[]At least one. 50 recipients at most across to, cc and bcc together.
cc, bccstring or string[]Count towards the 50.
reply_tostring or string[]Where replies go.
subject requiredstringNot empty, at most 998 characters, no line breaks.
html, textstringAt least one of the two, not blank.
headersobjectExtra headers, name to value. See Headers.
attachmentsobject[]See Attachments.
tags{name, value}[]See Tags.
scheduled_atstringRFC 3339, in the future, at most 30 days ahead. Natural language ("in 1 hour") is refused.
stream Owlpoststringtransactional (default) or marketing. See Streams.
topic OwlpoststringMarketing 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
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" }]
  }'
Node.js
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);
Python
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"])
response · 200
{ "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:

response · 422 · application/problem+json
{
  "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
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" }
  ]'
response · 200
{ "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.

response · 200
{
  "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:

ValueMeaning
queuedStored, waiting for the sender (or waiting to retry)
scheduledWaiting for scheduled_at. The sender runs every minute, so it leaves within about a minute of that time.
sentAmazon SES accepted it (a test key marks it sent without sending)
delivered, delivery_delayedThe recipient's server accepted it, or is taking its time
bounced, soft_bouncedPermanent bounce (the address is suppressed), or a temporary one (it is not)
complainedA recipient marked it as spam; the address is suppressed
rejectedSES refused it
failedOwlpost gave up: the provider refused it, every recipient was suppressed, or ten attempts failed
opened, clickedReported 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: 200 with 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: 200 with the same ids, in order. Same key, different body: 409. Batch header keys live apart from single-send keys.
  • A message's idempotency_key Owlpost 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 is 409, 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

FieldRules
filename requiredA plain file name: not empty, no /, \ or line breaks
contentThe file as a base64 string
content_typeOptional; guessed from the extension otherwise (falls back to application/octet-stream)
pathA 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.

JSON
"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 no cc or bcc, because each recipient gets their own unsubscribe link. Send a list as a batch.
  • carries List-Unsubscribe and List-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".