Long-poll a temporary inbox for mail
Returns the messages that arrived after after, optionally waiting up
to wait seconds for something new.
Each message is the SAME object the inbound webhook POSTs
(schema_version: 1, documented at
https://developers.sendops.dev/api-reference/inbound-webhook) with
inbox_id added — so an agent written against one reads the other
without a second parser.
YOUR CLIENT'S REQUEST DEADLINE MUST EXCEED wait. A 20-second
poll issued on a client with a 10-second timeout hangs up before the
server answers, every time, and looks exactly like an inbox that never
receives anything. Set the client timeout to wait plus a few seconds.
There is nothing the server can do about this from its side.
Every poll costs one request against your per-key rate limit,
counted on entry and not refunded for the time spent waiting. A
20-second loop is 3 requests a minute, which is nothing for a live key.
A sk_test_ key's budget can be as low as 1 a minute, which makes a
tight loop unusable from a test credential — use a live key, or poll
less often.
The cursor. cursor in the response is the number of messages you
have now seen; send it as after next time. It is an integer rather
than an opaque token on purpose: if you lose it, send 0 and you get
the whole inbox back rather than an error.
Presigned URLs expire with the inbox. raw_url,
message.attachments[].url, message.text_body_url and
message.html_body_url are signed for whatever is left of the inbox's
life and are minted fresh on every response. Fetch what you need before
expires_at; afterwards the objects are deleted, not merely
unreachable.
Bodies may be out of line. A body over 256 KB is stored as an
object and text_body / html_body is empty with text_body_url /
html_body_url set instead. Handle both.
The sender check is address-only. A restricted inbox compares the
From: header address and the SES envelope sender against
allowed_senders; either matching passes. It is NOT an authentication
check — SPF and DKIM can be forged by anyone controlling the headers.
verdicts.spf, verdicts.dkim and verdicts.dmarc are in every
payload precisely so you can judge that yourself. Treat the address as
a filter and the verdicts as the evidence.
Statuses. 410 when the inbox has expired — you have had
expires_at in every response for this id, so this is the expected end
of a poll loop rather than an error to investigate. 404 when the
inbox was deleted, belongs to somebody else, or never existed: three
facts deliberately not distinguished.
PII. This route returns whole messages in the clear: the sender's address, every recipient, the subject, the body text and HTML, and links to every attachment. Nothing is masked and no further scope unmasks anything.
Path parameters
id string<uuid> required The inbox's UUID, exactly as returned by POST /v1/inboxes. A value that is not a UUID is a 404 rather than a 422 — it names nothing, and saying "that is not a valid UUID" would confirm the format of ids that do exist.
Query parameters
after integer<int64> optional The cursor from your previous page. 0, or omitted, returns everything the inbox holds. Negative is 422.
wait integer optional Seconds to hold the connection open waiting for a new message, 0 to 25. 0 (the default) answers immediately with whatever is there. Out of range is 422 rather than clamped — a wait silently shortened is indistinguishable from mail that never arrived. The hold also stops at expires_at, whichever comes first.
Responses
Errors follow the RFC 7807 problem format — see the error reference.
messages array<object> required What arrived after your cursor, oldest first. EMPTY IS NOT AN ERROR — it is the ordinary answer to a poll that timed out.
Each entry in messages:
schema_version integer required 1. The same number the webhook carries, deliberately: a change that would break either bumps both.
message_id string required The SES message id.
inbox_id string<uuid> required The temporary inbox this was delivered to. Not present in the webhook payload.
domain string required The temporary-inbox host the mail was addressed at.
recipients array<string> required The envelope recipients at domain that resolved to this inbox.
received_at string<date-time> optional verdicts object required SES's scan and authentication findings. The empty string IS A REAL VALUE and not a missing one: a replayed message carries no verdicts at all, because SES's results live only in the original notification and are gone by replay time.
Fields of verdicts:
spam string enum required One of: ``, pass, fail, gray, processing_failed, disabled
virus string enum required One of: ``, pass, fail, gray, processing_failed, disabled
spf string enum required One of: ``, pass, fail, gray, processing_failed, disabled
dkim string enum required One of: ``, pass, fail, gray, processing_failed, disabled
dmarc string enum required One of: ``, pass, fail, gray, processing_failed, disabled
raw_url string<uri> optional Presigned GET for the whole original message, valid until the inbox expires and re-minted on every response.
message object required The parsed message. Identical in shape to the webhook payload's message object.
Fields of message:
from object required name is attacker-controlled free text and must never be used to decide anything. address is the addr-spec.
Fields of from:
name string optional address string required to array<object> optional Each entry in to:
name string optional address string required cc array<object> optional Each entry in cc:
name string optional address string required reply_to array<object> optional Each entry in reply_to:
name string optional address string required subject string required date string optional RFC 3339 in the message's own offset. Empty when the Date header was absent or unparsable; the raw header is still in headers.
message_id string optional Angle brackets removed.
in_reply_to string optional reply_to_message_ids array<string> optional In-Reply-To then References, in order, de-duplicated — everything this message claims to be a reply to.
is_auto_reply boolean optional A HINT read off Auto-Submitted, Precedence and the X-Auto* headers. Not a verdict, and forgeable.
text_body string optional The first text/plain leaf, decoded to UTF-8. EMPTY when the body was too large to inline — text_body_url is then set instead. Handle both.
html_body string optional As text_body, for the first text/html leaf.
text_body_url string<uri> optional Presigned GET for a text body over 256 KB, valid until the inbox expires. Set only when text_body is empty for that reason.
html_body_url string<uri> optional attachments array<object> optional EVERY LEAF IS ACCOUNTED FOR EXACTLY ONCE — a part is the text body, the HTML body, or an entry here. An empty manifest means the message really had nothing but its bodies.
Each entry in attachments:
index integer required The part's stable index in the walk, and the name it is stored under.
filename string optional content_type string required size integer<int64> required content_id string optional Content-ID with angle brackets removed, so an HTML body's cid: reference matches it directly.
inline boolean optional The sender meant it displayed in the body rather than listed — Content-Disposition: inline, or any part carrying a Content-ID.
url string<uri> optional Presigned GET for the part's bytes, valid until the inbox expires and re-minted on every response. Fetch before expires_at; afterwards the object is deleted, not merely unreachable.
headers object optional Top-level headers, keys lower-cased, first value only, RFC 2047 decoded.
verdicts object required SES's scan and authentication findings. The empty string IS A REAL VALUE and not a missing one: a replayed message carries no verdicts at all, because SES's results live only in the original notification and are gone by replay time.
Fields of verdicts:
spam string enum required One of: ``, pass, fail, gray, processing_failed, disabled
virus string enum required One of: ``, pass, fail, gray, processing_failed, disabled
spf string enum required One of: ``, pass, fail, gray, processing_failed, disabled
dkim string enum required One of: ``, pass, fail, gray, processing_failed, disabled
dmarc string enum required One of: ``, pass, fail, gray, processing_failed, disabled
defects array<string> optional Malformations that were tolerated. Empty means it parsed cleanly; a non-empty list with a body still present means the payload is usable but incomplete.
cursor integer<int64> required Send as after next time. It is the count of messages you have seen, so losing it costs you a duplicate page rather than an error.
expires_at string<date-time> required Repeated on every page so a polling client knows when to stop.
code is
gone.
This is the expected end of a poll loop, not an error to investigate:
expires_at has been in every response for this id since it was
minted. Mint another inbox to receive more mail.
A DELETED inbox answers 404 instead — deletion is the owner asking us
to forget it, so we do not then confirm it existed.
application/problem+json validation_failed Problem. When the refusal is about a
reference — a segment key, template slug, topic or attribute name the
organization does not have — it additionally carries validation_code,
field, line/column, missing, candidates and next_step, so a
client can correct the call without a second round of guessing. See
ValidationProblem.
application/problem+json code is internal_error. The
request_id field can be quoted to SendOps support to investigate.
application/problem+json