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:

  1. 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).
  2. parses it: addresses, subject, text and HTML bodies (a text rendering when there is only HTML), headers, attachments, threading headers.
  3. stores it once. A redelivery with the same Message-ID to the same recipient is recognised and not stored twice.
  4. screens it, then publishes message.received, or message.held if 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.

FieldRules
nameUp to 32 lowercase letters, digits and inner -. Default inbox.
domainA 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_footerDefault true: mail sent from the inbox ends with "Written by an AI assistant." See Disclosure.
response · 201
{
  "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.

response · 201
{
  "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.

QueryMeaning
inboxOnly this inbox (ib_…)
threadOnly this thread
qSearch: a case-insensitive substring of the subject, the sender or the snippet. % and _ are literal.
limit1 to 100, default 20
beforeA 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=heldHeld mail instead; needs inbound:manage (403 held-needs-manage otherwise)
response · 200
{
  "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.

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

response · 202
{ "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.exe displays as exe.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 withHeld 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:

data
{
  "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).