Long-poll a temporary inbox for mail

Search Documentation

Search across all developer documentation

inboxes

Long-poll a temporary inbox for mail

GET /v1/inboxes/{id}/messages
Auth required api.inboxes.view

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.

200 The messages after the cursor. An EMPTY list is the ordinary answer to a poll that timed out and is not an error — poll again with the returned cursor. application/json
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.

401 Missing, malformed, or unknown API key application/problem+json
403 Key lacks the required scope or plan limit violated application/problem+json
404 Resource not found application/problem+json
410 The inbox expired and its messages have been deleted. The 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
422 A query parameter, path value or body field failed validation. The body is a 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
429 Per-org rate limit exceeded application/problem+json
500 Unexpected server-side failure. The code is internal_error. The request_id field can be quoted to SendOps support to investigate. application/problem+json