# 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`.

```json
{
  "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](#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](inboxes#the-inbox-object). 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](webhooks)). Identical to the full shape except `text_body`, `html_body` and
`headers` are replaced by a single `body_preview`:

```json
{
  "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](#download-an-attachment) 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_...
```

```json
{
  "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_body` and `html_body` cannot both be absent/`null` — send
  requires at least one.
- **A recipient, one way or another.** `to` is required unless `reply_to_message_id` is
  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 expose
  `reply_to`, so check the response's `to` field 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 —
  422 `invalid_request`, not 404. An id that does not exist at all, or belongs to a
  different organization entirely, is 404 `not_found` instead.

| 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](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](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](domains), 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.
