Messages
A message is one inbound or outbound mail item inside a thread. Messages come in two shapes: full (a single-message fetch, with complete bodies and headers) and summary (list endpoints and webhook payloads, with a body preview instead of the full text). Both are described below.
The message object (full)
Returned by GET /v1/messages/{message} and by POST /v1/inboxes/{inbox}/messages.
{
"id": "msg_01k2n9v8qf3m7d5b0y6xz4rt5g",
"object": "message",
"inbox_id": "inb_01k2n9v8qf3m7d5b0y6xz4rt1c",
"thread_id": "thr_01k2n9v8qf3m7d5b0y6xz4rt7i",
"direction": "inbound",
"status": "received",
"held": false,
"error_message": null,
"from": {"email": "customer@example.com", "name": "A Customer"},
"to": [{"email": "orders-bot@agentmail.ackuna.com", "name": null}],
"cc": [],
"subject": "Question about my order",
"text_body": "Hi, my order hasn't arrived yet...",
"html_body": "<p>Hi, my order hasn't arrived yet...</p>",
"headers": {
"message_id": "<def456@example.com>",
"in_reply_to": null,
"references": []
},
"attachments": [
{"id": "att_01k2n9v8qf3m7d5b0y6xz4rt8j", "object": "attachment", "filename": "receipt.pdf", "mime_type": "application/pdf", "size_bytes": 48213}
],
"spam_verdict": "PASS",
"virus_verdict": "PASS",
"authentication": {"spf": "PASS", "dkim": "PASS", "dmarc": "PASS", "spam": "PASS", "state": "passed"},
"provider_message_id": "0100019203abcdef-...",
"created_at": "2026-08-19T10:00:00.000Z"
}
direction is inbound or outbound; status is received (inbound), queued,
sending, in_review or sent (outbound), or failed (either direction).
from/to/cc are {"email", "name"} objects (name may be null). provider_message_id
is the mail provider's own id for the send or receipt — the handle to use if you ever need
to correlate a message with a delivery or bounce event reported elsewhere. An outbound
message has none until its status is sent. error_message says why an outbound
message failed (see Delivery); it is null otherwise, and always null on
inbound mail.
held is true for inbound mail that arrived while its inbox was paused: it is stored and
readable, but its inbox.message.received webhook has not fired yet. Resuming the inbox
releases it (the webhook fires once and held becomes false); see
Inboxes. It is false for every other message. List and fetch
endpoints return held mail too; an agent that polls rather than using webhooks and wants
a pause to stop it should skip messages with held: true. Held mail follows the plan's
retention windows: a message held past the body window is released without its body and
attachments, and one held past the record window is deleted without being released.
authentication carries the SPF, DKIM, DMARC and spam verdicts SES reported on receipt
(uppercase, or null when SES sent none) and a state: failed when DMARC or spam is
FAIL, or when neither SPF nor DKIM passes and at least one of them is FAIL; passed when
DMARC passes (or SPF and DKIM both do); otherwise unknown. Outbound messages are always
unknown. A failed message is stored and delivered like any other, but it is never threaded
by sender and subject alone, so mail forging a known correspondent's From address lands in a
thread of its own. The same holds for an unknown message with an SPF or DKIM FAIL. Check state before replying: a reply goes to the
message's Reply-To or From, which a forger chooses.
thread_id and from are both always present as keys. Either can be null for an
inbound message whose status is failed: that is mail that could not be parsed, so no
thread was ever resolved and no sender could be confirmed. An outbound message always has
a thread and a sender, including when it is failed: it keeps both, and error_message
says why it failed.
The message object (summary)
Returned by list endpoints (GET /v1/threads/{thread}/messages) and inside every
inbox.message.received, inbox.message.sent and inbox.message.failed webhook payload
(see Webhooks). Identical to the full shape except text_body, html_body and
headers are replaced by a single body_preview:
{
"id": "msg_01k2n9v8qf3m7d5b0y6xz4rt5g",
"object": "message",
"inbox_id": "inb_01k2n9v8qf3m7d5b0y6xz4rt1c",
"thread_id": "thr_01k2n9v8qf3m7d5b0y6xz4rt7i",
"direction": "inbound",
"status": "received",
"held": false,
"error_message": null,
"from": {"email": "customer@example.com", "name": "A Customer"},
"to": [{"email": "orders-bot@agentmail.ackuna.com", "name": null}],
"cc": [],
"subject": "Question about my order",
"body_preview": "Hi, my order hasn't arrived yet...",
"attachments": [
{"id": "att_01k2n9v8qf3m7d5b0y6xz4rt8j", "object": "attachment", "filename": "receipt.pdf", "mime_type": "application/pdf", "size_bytes": 48213}
],
"spam_verdict": "PASS",
"virus_verdict": "PASS",
"authentication": {"spf": "PASS", "dkim": "PASS", "dmarc": "PASS", "spam": "PASS", "state": "passed"},
"provider_message_id": null,
"created_at": "2026-08-19T10:00:00.000Z"
}
body_preview is the first 500 characters of the text body, falling back to a plain-text
rendering of the HTML body when there is no text part. attachments is present in the
summary too — enough to know mail arrived with a PDF without a second fetch — but the
bytes themselves still need downloading separately.
The same nullability applies here as on the full form, and only to inbound mail: for an
inbox.message.failed webhook whose message.direction is inbound, the carried
message.thread_id and message.from are null, since parsing failed before either could
be determined. An outbound inbox.message.failed, and every inbox.message.sent, carries
both.
List messages in a thread
GET /v1/threads/thr_01k2n9v8qf3m7d5b0y6xz4rt7i/messages?limit=25
Authorization: Bearer ak_...
{
"data": [ /* message objects, summary form, oldest first */ ],
"next_cursor": null,
"has_more": false
}
Requires scope messages:read. 404 not_found if the thread is unknown or belongs to
another organization.
Fetch a message
GET /v1/messages/msg_01k2n9v8qf3m7d5b0y6xz4rt5g
Authorization: Bearer ak_...
Requires scope messages:read. Returns the full-form message object, or 404 not_found.
Send a message
POST /v1/inboxes/inb_01k2n9v8qf3m7d5b0y6xz4rt1c/messages
Authorization: Bearer ak_...
Content-Type: application/json
Idempotency-Key: reply-to-msg-01k2n9-1
{
"reply_to_message_id": "msg_01k2n9v8qf3m7d5b0y6xz4rt5g",
"text_body": "Thanks for reaching out — a human will follow up shortly."
}
Returns 202 with the new message, full form, in status: "queued". Requires scope
messages:send. Every field is individually optional, but three cross-field rules are
enforced before the message is accepted,
each failing with 422 invalid_request when violated:
- At least one body.
text_bodyandhtml_bodycannot both be absent/null— send requires at least one. - A recipient, one way or another.
tois required unlessreply_to_message_idis present, in which case it defaults to the original message's Reply-To address when that message carried one, otherwise its sender address. The message resource does not exposereply_to, so check the response'stofield to see which address was actually used. - Reply targets must be answerable.
reply_to_message_id, if present, must name an inbound message in this same inbox. A message that exists but is outbound, or that belongs to a different inbox (even within the same organization), fails this rule — 422invalid_request, not 404. An id that does not exist at all, or belongs to a different organization entirely, is 404not_foundinstead.
| Field | Notes |
|---|---|
reply_to_message_id |
A msg_... id for an inbound message in this inbox. When present, the reply is always sent to that message's Reply-To address if it had one, otherwise its sender address — any to you send is ignored; subject defaults from the original subject only when you omit subject. The response's to field shows which address was actually used. |
to |
Recipient email address, up to 255 characters. Required if reply_to_message_id is absent; ignored if it is present. |
subject |
Defaults to Re: <original subject> when replying. |
text_body |
Plain-text body. See "at least one body" above. |
html_body |
HTML body. See "at least one body" above. |
Failure modes specific to this endpoint, all answered before the message is accepted: 403
email_verification_required if no owner of the workspace has verified their email; 409
inbox_paused if the inbox is paused; 409 domain_not_ready if the inbox's domain has no
sending identity, or its sending domain is reputation-blocked; 422 recipient_suppressed if
the recipient is on the suppression list; 422 recipient_not_allowed if a workspace not yet
reviewed has used today's allowance of new recipients; 422 recipient_undeliverable if the
recipient address cannot receive mail; 429 queue_full if too many messages are already
waiting to be sent; 429 quota_exceeded (with Retry-After) if the daily outbound cap, the
shared-domain daily cap or the monthly outbound cap is reached (error.quota says which);
429 rate_limited over the workspace's hourly send limit; 503 sending_paused while
outbound sending is paused platform-wide. A refusal by the mail provider is not one of these: the message has already been accepted,
and the refusal arrives later, as status: "failed" (see Delivery, below).
Delivery
The 202 means the message is accepted, not delivered. Read the outcome from
GET /v1/messages/{message}, or subscribe to the two webhooks below and skip the polling.
An outbound message is in one of five states:
status |
Meaning |
|---|---|
queued |
Accepted, and waiting for a worker. It stays queued while sending is paused (by Ackuna, or by the mail provider) and while a delivery attempt that the provider could not take yet waits to be tried again. |
sending |
A worker has taken it and is handing it to the mail provider. Normally seconds. When the provider's answer was uncertain (a timeout, for instance) it can stay sending for up to about nine minutes, while Ackuna waits to learn whether the provider took it. |
in_review |
Held by Ackuna's pre-send content check until a person reviews it. Nothing was sent and no webhook fires for the hold. A release sends it as usual; otherwise it ends failed, and that refusal cannot be retried (content_rejected). A review that is not made within 7 days fails the message. |
sent |
The mail provider accepted it. provider_message_id is set from here on. |
failed |
It will not be delivered. error_message says why. |
provider_message_id is null until sent. That id is what an incoming reply's
In-Reply-To is matched against to file the reply in the message's thread, so replies
are threaded by it only once the message is sent.
error_message is a sentence written to be read or passed on, for example that the provider
refused the message, that the recipient is on the suppression list, that the inbox was
paused or archived before the message left, or that it could not be delivered within 24
hours of being accepted. It is also set on a queued message whose last attempt was
deferred, and it clears once the message is sent.
Two webhooks report the outcome: inbox.message.sent when the provider accepts the message,
and inbox.message.failed, with message.direction outbound, when it will not be
delivered. See Webhooks.
If the provider's answer stays uncertain, the message ends in failed without a retry, and
its error_message says the provider may already have delivered it. It cannot be retried,
because a retry could send it twice.
Retry. POST /v1/messages/{message}/retry queues a failed outbound message again. It
returns 202 with the message, full form, in status: "queued", and requires scope
messages:send. A retry is a new send: it uses a daily slot (and a shared-domain slot for a
message from the shared domain), it counts toward the workspace's hourly send limit, and the
checks a new send goes through can refuse it, with the same codes: 403
email_verification_required, 409 inbox_paused, 409 domain_not_ready, 422
recipient_not_allowed, recipient_suppressed or recipient_undeliverable, 429
queue_full, quota_exceeded or rate_limited, and 503 sending_paused. It answers 409
message_not_retryable when the message is not an outbound message in failed, when the
provider may already have delivered it, or when its inbox has been archived.
Download an attachment
GET /v1/messages/msg_01k2n9v8qf3m7d5b0y6xz4rt5g/attachments/att_01k2n9v8qf3m7d5b0y6xz4rt8j
Authorization: Bearer ak_...
Requires scope messages:read. Responds with 302 to a five-minute presigned download
URL on the storage backend (the common case), or streams the bytes directly with 200
when the configured disk cannot sign a temporary URL. 404 not_found if the message or
attachment is unknown, or belongs to another organization.
Either way the bytes arrive as application/octet-stream with an attachment disposition,
whatever type the sender declared; that declared type is the mime_type field on the message.
Next: Webhooks to get pushed new mail instead of polling
GET /v1/threads/{thread}/messages.
Limits for new workspaces
Until a person at Ackuna has reviewed a workspace, it is untrusted: it may send up to
20 messages a day, may reply to anyone who has written to one of its inboxes, and may
contact at most 10 addresses a day that never wrote in. A message to an 11th new address
answers 422 recipient_not_allowed. Every recipient is also checked for a working mail
domain (and, for untrusted workspaces, an existing, non-disposable mailbox) before
anything is sent; a failure answers 422 recipient_undeliverable. During a platform
incident all outbound may be paused, which answers 503 sending_paused. Receiving is
never limited by any of this.
Limits on the shared domain
Inboxes on the shared platform domain (agentmail.ackuna.com) send under Ackuna's own name,
and so do inboxes created earlier on the previous shared domain (agents.ackuna.com), which
keep sending and receiving as before. Whether or not a workspace has been reviewed, an
account may send at most 100 messages a day (UTC) from the shared domains, both together,
across all its workspaces. An inbox's address never
changes, so inboxes created on the shared domain keep this limit for good. For volume,
create new inboxes on a custom domain, up to your plan's limits. Over the limit,
the send answers 429 quota_exceeded with error.quota = outbound_daily_shared_domain
and a Retry-After counting down to the next UTC midnight.