Docs · Inbound
Inbound and agent inboxes
Owlpost receives mail for addresses it hosts (agent inboxes) and for your verified domains, parses it to JSON, screens it, stores it, and tells you with a message.received webhook. Agents read it through the API and reply in the same thread.
How mail gets in
Inbound mail arrives through Amazon SES receiving. SES accepts the message, scans it for viruses and spam, stores the raw message and sends Owlpost a signed notification. Owlpost checks the signature, fetches the message, and for each recipient:
- finds the owner: an agent inbox with that address, or else an account that verified the recipient's domain. Mail for nobody is discarded and logged (SES has already accepted it, and a bounce now would go to a possibly forged sender).
- parses it: addresses, subject, text and HTML bodies (a text rendering when there is only HTML), headers, attachments, threading headers.
- stores it once. A redelivery with the same
Message-IDto the same recipient is recognised and not stored twice. - screens it, then publishes
message.received, ormessage.heldif screening held it.
A message may be up to 10 MiB. Mail can also arrive through Cloudflare Email Routing, which refuses unknown recipients and oversize mail at SMTP time, so the sender gets a bounce.
Your own domain. Mail to any address at a domain your account verified reaches your account too, once the domain's MX record points at Owlpost's receiving. During early access we set that up with you; it is not self-serve yet.
Agent inboxes
An inbox is an address Owlpost hosts, for one agent or one task. By default it lives on agents.owlpost.to.
POST/v1/inbound/inboxesBuilt
Scope inbound:manage. Every field is optional; an empty body works.
| Field | Rules |
|---|---|
name | Up to 32 lowercase letters, digits and inner -. Default inbox. |
domain | A domain your account verified. The address is then exactly name@domain (409 inbox-exists if taken). Without it the address is name-<6 random characters>@agents.owlpost.to. |
ai_footer | Default true: mail sent from the inbox ends with "Written by an AI assistant." See Disclosure. |
{
"object": "inbox",
"id": "ib_01j9…",
"address": "booking-x7k2qp@agents.owlpost.to",
"name": "booking",
"ai_footer": true,
"created_at": "2026-10-03T09:00:00Z"
}An account may hold 100 inboxes; the next answers 403 inbox-limit.
GET/v1/inbound/inboxesBuilt
Scope inbound:read. {"object": "list", "data": [inbox, …]}, oldest first.
DELETE/v1/inbound/inboxes/{id}Built
Scope inbound:manage. An agents.owlpost.to address stops receiving; an address on your own domain then reaches the account like any other address there. Messages already received stay until you delete them.
Routing rules
By default every received message goes to the account's webhook endpoints that subscribe to message.received. A routing rule sends matching mail to its own endpoint instead.
POST/v1/inbound/routesBuilt
Scope inbound:manage. Body: {"match": "…", "endpoint": "https://…"}. match is one of:
- an address,
support@example.com: that address only; *@example.com: every address at that domain;*: everything.
The most specific rule wins: an address beats *@domain, which beats *. One rule per match (409 route-exists: delete it first to change its endpoint). Mail no rule matches goes to the account's webhooks.
{
"object": "inbound_route",
"id": "rt_01j9…",
"match": "*@example.com",
"endpoint": "https://app.example.com/hooks/inbound",
"signing_secret": "whsec_…",
"created_at": "2026-10-03T09:00:00Z"
}Each rule has its own signing secret, shown only in this answer. Its deliveries are signed, retried and dead-lettered like any webhook; the envelope's subject is <account>/inbound/<rule id>. Dead letters of a rule's endpoint are not listed by GET /v1/webhooks/dead-letters yet. message.held always goes to the account's webhooks, not to a rule.
GET/v1/inbound/routesBuilt
Scope inbound:read. Rules, oldest first, without their secrets.
DELETE/v1/inbound/routes/{id}Built
Scope inbound:manage. Removes the rule and its endpoint.
Listing, search and reading
GET/v1/inbound/messagesBuilt
Scope inbound:read. Newest first. Held mail is left out unless asked for.
| Query | Meaning |
|---|---|
inbox | Only this inbox (ib_…) |
thread | Only this thread |
q | Search: a case-insensitive substring of the subject, the sender or the snippet. % and _ are literal. |
limit | 1 to 100, default 20 |
before | A received_at timestamp: only older messages. Pass the last item's received_at for the next page while has_more is true. The comparison is strict, so a message received in the same second as the last one on a page can be skipped. |
status=held | Held mail instead; needs inbound:manage (403 held-needs-manage otherwise) |
{
"object": "list",
"has_more": false,
"data": [{
"object": "inbound_email",
"id": "im_01j9…",
"inbox_id": "ib_01j9…",
"to": "booking-x7k2qp@agents.owlpost.to",
"from": "reservations@hotel.example",
"subject": "Your booking 88213",
"snippet": "Dear guest, your booking is confirmed. Your verification code is 482913…",
"message_id": "abc@hotel.example",
"in_reply_to": null,
"thread_id": "abc@hotel.example",
"code": "482913",
"status": "delivered",
"size": 18234,
"attachments": 1,
"received_at": "2026-10-03T09:00:00Z"
}]
}snippet is the first 200 characters of the text on one line. code is a one-time code when the message says it carries one ("your verification code is 482913"): 4 to 8 digits, or 6 to 8 capitals and digits, found next to a keyword such as verification code, passcode or OTP. status is delivered, held or released.
GET/v1/inbound/messages/{id}Built
Scope inbound:read. The summary above plus the parsed message: from as {name, address}, cc, reply_to, references, date, text, html, headers, and attachments as a list of {filename, content_type, size, content_id, url}. A held message reads as 404 to a key without inbound:manage.
GET/v1/inbound/messages/{id}/rawBuilt
Scope inbound:read. The original message as received, message/rfc822, as a download named im_….eml.
DELETE/v1/inbound/messages/{id}Built
Scope inbound:manage. Deletes the message, its raw copy and its attachments.
Threads
GET/v1/inbound/threads/{thread_id}Built
Scope inbound:read. A conversation in time order: mail received and mail sent from Owlpost, each with a direction of received or sent. A thread id looks like a Message-ID (it contains @), so URL-encode it. Received mail joins a thread through its References and In-Reply-To headers, including replies to mail you sent through Owlpost. Held messages are not listed.
{
"object": "thread",
"id": "abc@hotel.example",
"data": [
{ "direction": "received", "id": "im_01j9…", "from": "reservations@hotel.example",
"to": "booking-x7k2qp@agents.owlpost.to", "subject": "Your booking 88213",
"snippet": "Dear guest, …", "code": "482913", "at": "2026-10-03T09:00:00Z" },
{ "direction": "sent", "id": "em_01j9…", "from": "booking-x7k2qp@agents.owlpost.to",
"to": ["reservations@hotel.example"], "subject": "Re: Your booking 88213",
"status": "sent", "at": "2026-10-03T09:02:11Z" }
]
}Replies and new mail
POST/v1/inbound/messages/{id}/replyBuilt
Scope emails:send. Body: text and/or html, and reply_all (default false: only the sender, or its Reply-To; true also copies the other To and Cc addresses). The reply is sent from the address the message was received at, with the subject prefixed Re: once and In-Reply-To and References set, so the other side's mail client threads it. A held message cannot be replied to (404) until it is released.
{ "object": "email", "id": "em_01j9…", "thread_id": "abc@hotel.example", "status": "queued" }POST/v1/inbound/inboxes/{id}/sendBuilt
Scope emails:send. New mail from an inbox, starting a new thread. Body: to (string or list), cc, subject, text, html. Answers 202 as above, with the new thread_id.
Both go through the normal sending pipeline (suppression, delivery events, email.* webhooks) and its rules, with three limits of their own: no attachments, text plus HTML at most 512 KiB, and a request body of at most 64 KiB. A test key stores the message and never sends it.
Disclosure
Mail sent from an agent inbox says so: it carries Auto-Submitted: auto-generated (RFC 3834, which also keeps vacation responders from answering it) and X-Owlpost-Sender: ai-agent, and, unless the inbox was created with "ai_footer": false, a closing line, "Written by an AI assistant."
Loop limits
An agent that answers itself can flood a person in minutes. An inbox sends at most 60 messages an hour (429 inbox-rate-limited), and one thread gets at most 10 messages an hour from Owlpost (429 thread-rate-limited).
Attachments and their safety rules
Each attachment in a read or a webhook has a url: a signed link, valid for seven days, that needs no API key. Read the message again for a fresh link; an expired or tampered link answers 404 link-expired. Files are served as downloads with Content-Security-Policy: default-src 'none'; sandbox and X-Content-Type-Options: nosniff, so a file cannot run in a browser that opens the link.
Some attachments should never reach an agent. These hold the whole message for a person to review, on every receiving path, whatever the virus scan said:
- Files that run code: programs, installers, scripts, disk images, shortcuts, macro-enabled Office files, HTML and SVG (both run script in a browser), OneNote files.
- Disguises: a right-to-left override in the file name (so
fdp.exedisplays asexe.pdf), or executable bytes (MZ, ELF, Mach-O,#!) under any name. - Archives that cannot be checked: encrypted ZIP entries, RAR, 7-Zip, ACE, and ZIPs that carry any of the above.
- Hidden macros: a
.docx-style file with a VBA project inside, or an old binary Office file with one.
The hold reason names the file: attachment: invoice.zip (.rar archives cannot be checked).
Screening and held mail
Before an agent sees a message, Owlpost checks it. Any objection holds it, and every reason is recorded:
| Reason starts with | Held because |
|---|---|
virus: | SES's scan found malware, or could not say the message is clean |
spam: | SES marked it as spam |
attachment: | An attachment broke a rule above |
dmarc=fail, unauthenticated: | DMARC failed, or neither SPF nor DKIM passed, by the receiving server's verdict (only the topmost, trusted Authentication-Results counts; a sender can forge the ones below) |
lookalike: | The sender's domain imitates a well-known one (paypa1.com, rnicrosoft.com, booking.com.example.net, punycode) |
prompt-injection: | Text aimed at an AI reader: "ignore previous instructions", "you are now", role tokens, text hidden in the HTML |
A held message is stored with status: "held". It is left out of lists, threads and replies, reads as 404 to keys without inbound:manage, and fires message.held instead of message.received. That event carries the same fields with text, html, attachments and code set to null, plus held_reasons.
GET/v1/inbound/inboxes/{id}/heldBuilt
Scope inbound:manage. Up to 200 held messages of an inbox, newest first, each with held_reasons and auth ({authserv, spf, dkim, dmarc}). For mail held on a verified domain rather than an inbox, use GET /v1/inbound/messages?status=held.
POST/v1/inbound/messages/{id}/releaseBuilt
Scope inbound:manage. A person has looked and the message is fine: its status becomes released and message.received fires, routed as usual. 409 not-held for a message that is not held. To throw a held message away, DELETE it.
The message.received webhook
Delivered as described on Webhooks. data is the parsed message:
{
"object": "inbound_email",
"id": "im_01j9…",
"inbox_id": "ib_01j9…",
"to": "booking-x7k2qp@agents.owlpost.to",
"from": { "name": "Hotel Reservations", "address": "reservations@hotel.example" },
"cc": [],
"reply_to": [],
"subject": "Your booking 88213",
"text": "Dear guest, …",
"html": "<p>Dear guest, …</p>",
"truncated": false,
"message_id": "abc@hotel.example",
"in_reply_to": null,
"thread_id": "abc@hotel.example",
"code": "482913",
"attachments": [
{ "filename": "confirmation.pdf", "content_type": "application/pdf",
"size": 48211, "content_id": null, "url": "https://api.owlpost.to/v1/inbound/attachments/…" }
],
"received_at": "2026-10-03T09:00:00Z"
}A body over 256 KiB is left out of the event (null) and truncated is true: read the message by id for all of it. inbox_id is null for mail to a verified domain rather than an inbox.
Waiting for the next message without polling (long poll) is Planned (#3), and so is delivering a note into an inbox by API (#2).