Agent Mail by Ackuna

Every page here is also markdown — add .md to the URL.

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:

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.