# Errors

Every Agent Mail API failure — including framework-generated ones like a bad HTTP verb —
uses the same envelope:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "The to field must be a valid email address.",
    "fields": {"to": ["The to field must be a valid email address."]},
    "doc_url": "https://agents.ackuna.com/docs/errors.md#invalid_request"
  }
}
```

`fields` is present only for validation failures, keyed by field name with a list of
messages. `quota` is present on 429 `quota_exceeded` and names the quota that refused
the request (`outbound_daily`, `outbound_daily_shared_domain`,
`outbound_monthly`), matching the keys of `quotas` on `GET /v1/me`. `doc_url` is always
present and points at the markdown source of this page, so a failing agent fetches text
it can read rather than a rendered page — no human has to relay the explanation. The
fragment names the `### {code}` heading in that source; drop the `.md` for the same
anchor on the HTML page a person reads.

Below is the complete, frozen list of codes. If you see a `code` that is not here, that is
a bug: open an issue rather than trying to pattern-match the string.

### unauthenticated

**HTTP 401.** The API key is missing, malformed, revoked, or has passed its `expires_at`.
Check the `Authorization: Bearer ak_...` header is present and the key is still active.

### insufficient_scope

**HTTP 403.** The key authenticated, but does not carry the scope the endpoint requires.
`message` names the missing scope, e.g. `"This API key is missing the required scope:
messages:send."`. Issue a new key with that scope, or ask an operator to grant it.

### agent_mail_not_enabled

**HTTP 403.** The organization has not enabled Agent Mail (`agent_mail_access` is `none`).
This is checked before scopes, so such an organization learns this and nothing about which
scopes exist. Since 2026-09-11 every paid plan funds Agent Mail and grants access the moment
the subscription activates, so in practice this is reached only by an organization on the free
plan that has never switched it on. Such an organization enables Agent Mail from Workspace
settings; a new one registers at `/register?product=agent-mail` or over `POST /v1/signup`.
Until 2026-09 this code was named `beta_access_required`.

### organization_suspended

**HTTP 403.** An administrator suspended the organization's Agent Mail access. Every key
answers this until access is restored; inboxes keep receiving. Contact support.

### email_verification_required

**HTTP 403.** No owner of the organization has verified their email address yet, so outbound
sending is locked. Inboxes still receive, and keys, threads and webhooks work. A human owner
verifies through the link Ackuna mailed at registration; an organization created over
`POST /v1/signup` is verified by its sign-up code and never sees this.

### email_already_registered

**HTTP 409.** `POST /v1/signup` was called with an email that already belongs to an Ackuna
user. Sign in to the console with that address and issue an API key there; a sign-up is never
attached to an existing organization. The address is compared by mailbox: a `+tag` variant,
or a Gmail address with different dots, of an existing user's address is the same account.

### registration_disabled

**HTTP 503.** Self-serve sign-up is closed, so `POST /v1/signup` and `POST /v1/signup/verify`
create nothing. The operator controls it, and nothing in the request can change the answer.
Retry much later, or ask a person to create the account in the console.

### signup_pending

**HTTP 409.** `POST /v1/signup` was called for an address that already has a live code. The
earlier code stays valid, so use it with the `signup_id` you got the first time; a repeat
request never replaces it. `Retry-After` gives the seconds until that code expires, after
which you may ask again.

### signup_code_invalid

**HTTP 422.** The code sent to `POST /v1/signup/verify` does not match. `message` says how many
attempts remain before the sign-up expires.

### signup_expired

**HTTP 410.** The `signup_id` is unknown, already used, older than its `expires_at`, or has
run out of attempts. Call `POST /v1/signup` again for a fresh code.

### quota_exceeded

**HTTP 403 or 429.** A per-organization quota was reached.

- **403** — a structural cap (inboxes, webhook endpoints). Retrying will not help; the
  limit must be raised.
- **429** — an outbound send cap: the **daily** cap (resets at the next UTC midnight), the
**monthly** cap (resets on the first of the next month, UTC), or the **shared-domain** cap
(100 sends a day per account, across all its workspaces, from inboxes on
`agentmail.ackuna.com` and `agents.ackuna.com` together, whether or not a workspace has
been reviewed; resets at the next UTC midnight). The response carries a `Retry-After` header counting down to the reset;
`error.quota` says which cap was hit. `outbound_daily_shared_domain` means the account has
used today's allowance on the shared domains; an inbox on a custom domain can still send.

### queue_full

**HTTP 429.** This workspace already has the maximum number of messages waiting to be
sent, so this one was not accepted. A workspace may have one hour of its sending waiting
(its hourly limit), and never fewer than 100. Unlike `quota_exceeded`, nothing has been used
up: the queue drains on its own, and the same request can be sent again. `Retry-After`
is a fixed hint of 60 seconds, not an estimate of how long the backlog takes to clear.
Sending more slowly is the right response.

### message_not_retryable

**HTTP 409.** `POST /v1/messages/{id}/retry` was refused for this message. The message
says which rule applies: only an outbound message whose `status` is `failed` can be
retried; a message whose provider outcome was uncertain cannot, because it may already
have been delivered; and a message whose inbox has been archived cannot. Read
`GET /v1/messages/{id}` for the current state. To send the content again anyway, send a
new message. Use a new `Idempotency-Key` for each retry decision: a request that repeats
an earlier key is answered with that request's stored response, this refusal or an earlier
`202`, without looking at the message again.

### content_rejected

**HTTP 409.** `POST /v1/messages/{id}/retry` was refused because Ackuna's pre-send content
check did not let this message go: a reviewer rejected it, its review expired, or it
repeated content that had been rejected before. The decision is final and the reason is
not detailed. If you believe it was a mistake, contact support with the message id.

### forbidden

**HTTP 403.** A refusal with no more specific code applies.

### address_taken

**HTTP 422.** `POST /v1/inboxes` asked for a local part that is already in use on the
target domain. Retry with a different `username` — this is not a validation problem, so it
is its own code rather than folding into `invalid_request`.

### address_reserved

**HTTP 409.** `DELETE /v1/inboxes/{id}?permanent=true` was asked to permanently delete an
archived inbox on the shared platform domain. Addresses on a shared domain stay reserved
for the workspace that had them, so they cannot be freed; restore the inbox instead, or
create inboxes on a custom domain you own, where permanent deletion is allowed.

### local_part_not_allowed

**HTTP 422.** `POST /v1/inboxes` asked for a `username` that is not available on the shared
platform domain. Mail from `agentmail.ackuna.com` goes out under Ackuna's own identity, so it
cannot carry a role address (such as `postmaster`, `support` or `billing`), a role word such
as `admin`, `security` or `no-reply` anywhere in the name (`security-team`), or a brand,
payment or sign-in term. The message does not say which rule matched. Choose another name, or
create the inbox on a [custom domain](domains) you own, where these names are not
restricted.

### display_name_not_allowed

**HTTP 422.** `PATCH /v1/inboxes/{id}` asked for a `display_name` that contains a brand,
payment or sign-in term, on a shared-domain inbox. The display name is the From name of the
inbox's mail, so `PayPal Security <orders@agentmail.ackuna.com>` would be the same impersonation
as a brand-named address. Role words are fine in a display name ("Support desk"). Choose
another name, or use an inbox on a [custom domain](domains) you own, where display names are
not restricted.

### domain_not_allowed

**HTTP 422.** `POST /v1/inboxes` named a shared domain that no longer accepts new inboxes.
Inboxes already on an older shared domain keep working, but new ones are created on the
current shared domain, which the message names. `agents.ackuna.com` is such an older domain:
its existing inboxes keep sending and receiving, but it can no longer be chosen for new
inboxes, which go on `agentmail.ackuna.com`. Omit `domain`, or pass the domain the
message names.

### domain_taken

**HTTP 422.** `POST /v1/domains` asked to register a hostname that is already registered
(by this organization or another). Retrying with the same hostname will not succeed.

### not_found

**HTTP 404.** The identifier in the path (or referenced from the request body, such as
`reply_to_message_id`) does not exist, or belongs to a different organization. A
cross-organization identifier is always a 404, never a 403 — this API never confirms that
another organization's resource exists.

### method_not_allowed

**HTTP 405.** The path exists but does not support the HTTP verb used.

### invalid_request

**HTTP 422.** Request validation failed. `fields` names every invalid field. This code is
also used for a small number of business-rule rejections that are not quota- or
identifier-related — for example, redriving a webhook delivery that is not `dead`, or
deleting a receiving domain that still hosts inboxes. Read `message` for the specific
reason.

### inbox_paused

**HTTP 409.** `POST /v1/inboxes/{inbox}/messages` was called on an inbox whose `status` is
`paused`. Resume the inbox (`PATCH /v1/inboxes/{inbox}` with `{"status": "active"}`) before
sending.

### domain_not_ready

**HTTP 409.** Either `POST /v1/inboxes` named a receiving domain that is not yet `active`,
or a send was attempted from an inbox whose domain has no sending identity, or whose
sending domain is reputation-blocked. For a domain that is not yet `active`, poll
`GET /v1/domains/{domain}` until `status` is `active`. Polling does not help with a missing
sending identity or a reputation block: a block stays until an operator lifts it after
review, so contact support.

### idempotency_key_reuse

**HTTP 409.** The `Idempotency-Key` header was reused with a request body that does not
match the original request to the same method and path. Use a new key for a genuinely
different request.

### idempotency_key_in_progress

**HTTP 409.** The `Idempotency-Key` header matches a request that is still being
processed. Retry shortly, or wait for the original response.

### recipient_suppressed

**HTTP 422.** The recipient address is on the suppression list (from a prior bounce or
complaint) and cannot be sent to.

### recipient_not_allowed

**HTTP 422.** This workspace has not been reviewed yet. Until it is, it can reply to
anyone who has written to one of its inboxes and contact a small number of new
addresses a day (10 by default); today's allowance is used. Reply to inbound mail, or
ask support to review the workspace.

### recipient_undeliverable

**HTTP 422.** The recipient address cannot receive mail: it is not a valid email address
(such as `user@localhost`), its domain has no mail route, or (for a workspace not yet
reviewed) the mailbox does not exist, is disposable, or looks like random input. Nothing
was sent. Check the address with the sender.

### sending_paused

**HTTP 503.** Outbound Agent Mail is paused platform-wide by the operator, usually
during a deliverability incident. It is also returned for an inbox on a shared Ackuna domain
while that domain's Amazon SES account is still in the sandbox. Receiving, inboxes, keys and webhooks keep working.
Retry later; no `Retry-After` is given because the pause has no fixed length.

### rate_limited

**HTTP 429.** Too many requests. Three limits can answer it: the IP address's request
limit, the API key's request limit, and, on `POST /v1/inboxes/{inbox}/messages` and
`POST /v1/messages/{message}/retry`, the workspace's hourly send limit (for a reviewed workspace
the plan's figure: 13 send requests an hour on Free, 250 on Builder, 1,250 on Scale and
6,250 on Startup; 100 an hour for a workspace not yet reviewed; counted across every key
and shared with the console). The response carries `Retry-After`, the seconds
until the limit that was hit resets, and, where available, `X-RateLimit-Limit`,
`X-RateLimit-Remaining` and `X-RateLimit-Reset` headers. Unlike `quota_exceeded`, it has no
`error.quota`.

### send_failed

Formerly **HTTP 502**, when the mail provider rejected or errored on a send.

No longer returned by `POST /inboxes/{inbox}/messages`. That endpoint accepts the message
and answers `202`; a provider refusal now surfaces as `status: "failed"` with
`error_message`, and as an `inbox.message.failed` webhook. The code stays documented
because existing integrations may still handle it.

### internal_error

**HTTP 500.** An unhandled failure on the server. The failure is reported internally.
Retry after a delay; if it persists, contact Ackuna with the request's approximate time.
