# Ackuna Agent Mail API

Agent Mail is a mail API built for agents: create an inbox, read and reply to what lands
in it, and get pushed a webhook the moment new mail arrives — no IMAP/SMTP session to
manage.

Every request is made against the base URL **`https://agents.ackuna.com/api/v1`** — every
path shown on this site is relative to that, so "`GET /inboxes`" on any page here means
`GET https://agents.ackuna.com/api/v1/inboxes` on the wire. Every request authenticates with
a bearer API key and speaks one JSON envelope in both directions: `{"data": ...}` on
success, `{"error": {...}}` on failure. Anyone can start: an agent over `POST /v1/signup`, a
person at the console. Free covers 3 inboxes and 100 messages a day.

## Start here

1. [Sign up](signup) — an agent creates its organization and first key; humans can skip to 2.
2. [Quickstart](quickstart) — create a key, an inbox, and reply to mail, in five requests.
3. [Authentication](authentication) — bearer keys, scopes, access states, and rate limits.
4. [Inboxes](inboxes) — the mailbox resource: create, pause, resume.
5. [Threads](threads) — read-only conversation grouping over an inbox's messages.
6. [Messages](messages) — read mail, send replies, and download attachments.
7. [Webhooks](webhooks) — get pushed new mail instead of polling for it.
8. [Receiving domains](domains) — host inboxes on a domain you control.
9. [Errors](errors) — the complete, frozen error-code reference.
10. [Versioning](versioning) — how `/v1` changes over time, and how it does not.

## Machine-readable description

The full surface is also described as OpenAPI 3.1 at
[`/docs/openapi.yaml`](openapi.yaml), kept honest against the live router by an automated
test — if a route exists, it is in that document, and vice versa.

Two more entry points exist for agents that would rather read everything than click:
`/llms.txt` is an index of every page as markdown, and `/llms-full.txt` returns every page
plus the OpenAPI document in a single response. Both live at the application root, not
under `/docs`.

## Conventions across every endpoint

- **Identifiers** are opaque strings of the form `<prefix>_<26-character ULID>` —
  `inb_01k2n9v8qf3m7d5b0y6xz4rt1c` is an inbox, `thr_...` a thread, `msg_...` a message,
  `att_...` an attachment, `whe_...` a webhook endpoint, `evt_...` a webhook delivery,
  `dom_...` a receiving domain. Never construct or parse one; treat it as a string.
- **Collections** are cursor-paginated: `{"data": [...], "next_cursor": "...", "has_more":
  true}`. Pass `?cursor=` from `next_cursor` to fetch the next page; `?limit=` controls
  page size (default 25, max 100).
- **Timestamps** are ISO-8601 UTC with milliseconds, e.g. `2026-08-20T14:03:11.482Z`.
- **Mutating requests** (`POST`, `PATCH`, `DELETE`) accept an optional `Idempotency-Key`
  header — see [Authentication](authentication).


---

# Sign up

An agent can create its own Ackuna organization and API key. Two requests, one emailed
code. No key is needed for either request.

API sign-up can be closed by the operator, independently of the console's registration form.
While it is closed, both requests answer **503** `registration_disabled` and nothing is created
or mailed; a code issued earlier cannot be redeemed until it reopens.

## 1. Ask for a code

```
POST /v1/signup
Content-Type: application/json

{"email": "ops@yourcompany.com", "organization_name": "Robot Co"}
```

```json
{"data": {"object": "signup", "signup_id": "sgn_01k4c2v8qf3m7d5b0y6xz4rt1c", "email": "ops@yourcompany.com", "expires_at": "2026-09-04T12:15:00.000Z"}}
```

The status is **202**: nothing has been created yet, a code has only been mailed to that
address. It is valid for 15 minutes and 5 attempts. The address is normalized before use —
lower-cased, a `+tag` in the local part is dropped, and for Gmail (`gmail.com`,
`googlemail.com`) dots are dropped and the domain becomes `gmail.com` — so
`ops+robots@yourcompany.com` and `ops@yourcompany.com` are the same account, and so are
`jane.doe@gmail.com` and `janedoe@googlemail.com`. Disposable mail providers are refused with
422 `invalid_request`. If the address already belongs to an Ackuna user you get
409 `email_already_registered`: sign in to the console with it and issue a key there instead.

One code per address at a time. While a code is live, asking again answers
409 `signup_pending` and mails nothing; the first code keeps working, and `Retry-After` says
when it expires. Keep the `signup_id` from the first response.

Rate limits on this path are deliberately tight: 5 requests per hour per IP, 3 per hour per
address, and 5 completed sign-ups per IP per day shared with the console's registration form;
the verify step allows 30 per hour per IP on top of the 5 attempts per code. Read the code
from the mailbox — do not loop on this request.

## 2. Prove the mailbox

```
POST /v1/signup/verify
Content-Type: application/json

{"signup_id": "sgn_01k4c2v8qf3m7d5b0y6xz4rt1c", "code": "K7MP2XQ9"}
```

```json
{"data": {
  "object": "signup_completed",
  "organization": {"id": "org_01k4c2v8qf3m7d5b0y6xz4rt2d", "name": "Robot Co"},
  "api_key": {"key": "ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4", "prefix": "ak_9f2c1e7b4", "scopes": ["inboxes:read", "inboxes:write", "messages:read", "messages:send", "webhooks:read", "webhooks:write", "domains:read", "domains:write"]},
  "console_url": "https://agents.ackuna.com/login?product=agent-mail",
  "docs_url": "https://agents.ackuna.com/docs/quickstart",
  "console_claim": "Use \"Forgot password\" on the console login page; the owner account has no password yet."
}}
```

The status is **201**: the organization, its owner account and that key now exist. The key is
shown once — store it now. `console_claim` is the sentence to hand a human who wants the
console: the owner account was created without a password, so it is claimed through "Forgot
password" rather than a normal sign-in. A wrong code answers 422 `signup_code_invalid` with
the attempts remaining; an expired, used or exhausted sign-up answers 410 `signup_expired` —
ask for a new code; and if a person registered that address through the web form while the
code was in flight, verification answers 409 `email_already_registered` — sign in to the
console and issue a key there instead.

## What you have now

- An organization on the **Free** plan: 3 inboxes, 100 outbound messages a day, 3,000 a
  month, 1 webhook endpoint. `GET /v1/me` shows the exact numbers.
- An owner account for that organization, already verified — the code was the proof — so
  sending is unlocked immediately. See [Errors](errors#email_verification_required) for what
  organizations registered through the web form see before their owner clicks the link.
- The console at `console_url`, where a person can watch what the agent is doing once they
  have claimed the account as described above.

Continue with the [Quickstart](quickstart) from step 2.


---

# Quickstart

This walks a coding agent through creating an inbox, registering a webhook so new mail is
pushed to it, and replying to a message — the three actions almost every integration
needs.

## 1. Get an API key

Two ways, same result — a key of the form `ak_` + 40 characters, shown once:

- **Agent, no human:** [Sign up](signup) over the API — request a code, read it from the
  mailbox, verify, and the response carries your first key.
- **Human:** register at `https://agents.ackuna.com/register?product=agent-mail`, then Agent
  Mail → Developers → API keys.

An Ackuna operator can also issue a key for an organization that already exists, with
`php artisan api-key:issue {organization} {name}`. Every request below sends the key as a
bearer token.

## 2. Confirm what the key can do

Every request in this guide (and every path shown anywhere in these docs) is relative to
the base URL `https://agents.ackuna.com/api/v1`:

```
curl https://agents.ackuna.com/api/v1/me \
  -H "Authorization: Bearer ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4"
```

Shown from here on as a bare path against that same base, for brevity:

```
GET /v1/me
Authorization: Bearer ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4
```

```json
{
  "data": {
    "object": "account",
    "organization": {"id": "org_01k2n9v8qf3m7d5b0y6xz4rt1c", "name": "Acme Robotics"},
    "api_key": {
      "id": "key_01k2n9v8qf3m7d5b0y6xz4rt2d",
      "name": "prod agent",
      "scopes": ["inboxes:read", "inboxes:write", "messages:read", "messages:send", "webhooks:read", "webhooks:write"]
    },
    "access": "active",
    "quotas": {
      "inboxes": {"limit": 3, "used": 0},
      "outbound_daily": {"limit": 100, "used": 0, "resets_at": "2026-08-21T23:59:59.999999Z"},
      "outbound_daily_shared_domain": {"limit": 100, "used": 0, "workspace_used": 0, "resets_at": "2026-08-21T23:59:59.999999Z"},
      "outbound_monthly": {"limit": 3000, "used": 0, "scope": "service", "service_used": 0, "resets_at": "2026-08-31T23:59:59.999999Z"},
      "webhook_endpoints": {"limit": 1, "used": 0},
      "custom_domains": {"limit": 0, "used": 0}
    }
  }
}
```

If `access` were `"none"` here, every other call in this guide would fail with 403 `agent_mail_not_enabled`; `"suspended"` fails with `organization_suspended` — see [Errors](errors#agent_mail_not_enabled).

`outbound_daily`, `outbound_daily_shared_domain` and `outbound_monthly` each carry a
`resets_at`; the structural caps (`inboxes`, `webhook_endpoints`, `custom_domains`) do not clear with
time — only a larger grant helps.

## 3. Create an inbox

```
POST /v1/inboxes
Authorization: Bearer ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4
Content-Type: application/json
Idempotency-Key: create-support-inbox-1

{"username": "orders-bot"}
```

```json
{
  "data": {
    "id": "inb_01k2n9v8qf3m7d5b0y6xz4rt3e",
    "object": "inbox",
    "address": "orders-bot@agentmail.ackuna.com",
    "local_part": "orders-bot",
    "domain": "agentmail.ackuna.com",
    "display_name": null,
    "status": "active",
    "thread_count": 0,
    "created_at": "2026-08-20T14:00:00.000Z"
  }
}
```

Omitting `domain` hosts the inbox on the shared platform domain, as above. Pass a `domain`
(a `dom_...` id from [Receiving domains](domains)) to host it on a domain you control
instead.

## 4. Register a webhook so new mail is pushed to you

```
POST /v1/webhook-endpoints
Authorization: Bearer ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4
Content-Type: application/json
Idempotency-Key: register-agent-webhook-1

{"url": "https://agent.example.com/webhooks/ackuna", "event_types": ["inbox.message.received"]}
```

```json
{
  "data": {
    "id": "whe_01k2n9v8qf3m7d5b0y6xz4rt4f",
    "object": "webhook_endpoint",
    "url": "https://agent.example.com/webhooks/ackuna",
    "event_types": ["inbox.message.received"],
    "status": "active",
    "created_at": "2026-08-20T14:01:00.000Z",
    "secret": "whsec_k3n9v8qf3m7d5b0y6xz4rt1c9a2b7e4d"
  }
}
```

Save `secret` now — it is shown exactly this once. See [Webhooks](webhooks) for the
delivery payload shape and how to verify `X-Ackuna-Signature`.

## 5. Reply to a message that arrived

When mail lands in `orders-bot@agentmail.ackuna.com`, the webhook above delivers an
`inbox.message.received` event carrying the message's `id`. Reply to it:

```
POST /v1/inboxes/inb_01k2n9v8qf3m7d5b0y6xz4rt3e/messages
Authorization: Bearer ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4
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."}
```

```json
{
  "data": {
    "id": "msg_01k2n9v8qf3m7d5b0y6xz4rt6h",
    "object": "message",
    "inbox_id": "inb_01k2n9v8qf3m7d5b0y6xz4rt3e",
    "thread_id": "thr_01k2n9v8qf3m7d5b0y6xz4rt7i",
    "direction": "outbound",
    "status": "queued",
    "error_message": null,
    "from": {"email": "orders-bot@agentmail.ackuna.com", "name": null},
    "to": [{"email": "customer@example.com", "name": "A Customer"}],
    "cc": [],
    "subject": "Re: Question about my order",
    "text_body": "Thanks for reaching out — a human will follow up shortly.",
    "html_body": null,
    "headers": {"message_id": null, "in_reply_to": "<def456@example.com>", "references": ["<def456@example.com>"]},
    "attachments": [],
    "spam_verdict": null,
    "virus_verdict": null,
    "authentication": {"spf": null, "dkim": null, "dmarc": null, "spam": null, "state": "unknown"},
    "provider_message_id": null,
    "created_at": "2026-08-20T14:05:00.000Z"
  }
}
```

The status is **202**: the message is accepted into the send queue, not yet delivered, so
`status` is `queued` and `provider_message_id` is `null`. The outcome arrives as an
`inbox.message.sent` or `inbox.message.failed` webhook (add both to `event_types` in step 4
to receive them), or read it from `GET /v1/messages/{id}`; see
[Messages](messages#delivery). A message held by Ackuna's pre-send content check waits in
`in_review` until a person reviews it.

`reply_to_message_id` is enough — `to` is inferred from the message being replied to. See
[Messages](messages) for sending without a reply target, and for how attachments and
threads work.

From here: [Inboxes](inboxes), [Threads](threads), [Messages](messages),
[Webhooks](webhooks), [Receiving domains](domains), [Errors](errors).


---

# Authentication

Every `/v1` request authenticates with a bearer API key: `Authorization: Bearer ak_...`.
There is no session, cookie, or OAuth flow.

```
GET /v1/me
Authorization: Bearer ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4
```

```json
{
  "data": {
    "object": "account",
    "organization": {"id": "org_01k2n9v8qf3m7d5b0y6xz4rt1c", "name": "Acme Robotics"},
    "api_key": {"id": "key_01k2n9v8qf3m7d5b0y6xz4rt2d", "name": "prod agent", "scopes": ["inboxes:read", "messages:read", "messages:send"]},
    "access": "active",
    "quotas": {
      "inboxes": {"limit": 3, "used": 0},
      "outbound_daily": {"limit": 100, "used": 0, "resets_at": "2026-08-21T23:59:59.999999Z"},
      "outbound_daily_shared_domain": {"limit": 100, "used": 0, "workspace_used": 0, "resets_at": "2026-08-21T23:59:59.999999Z"},
      "outbound_monthly": {"limit": 3000, "used": 0, "scope": "service", "service_used": 0, "resets_at": "2026-08-31T23:59:59.999999Z"},
      "webhook_endpoints": {"limit": 1, "used": 0},
      "custom_domains": {"limit": 0, "used": 0}
    }
  }
}
```

`GET /v1/me` requires no scope — a key must always be able to discover what it can do, even
one with no scopes at all. Call it first in any new integration.

## Keys

A key is issued once — by [sign-up](signup), in the console under Developers → API keys, or
by an operator — and shown in plaintext exactly that one time: `ak_` followed by 40 random
characters. It is stored server-side only as a hash — there is no way to retrieve the
plaintext again; issue a new key if it is lost. A key can carry an `expires_at`; once past,
every request with it fails with 401 `unauthenticated`, the same as a revoked or malformed
key.

## Access states

Every `/v1` request is checked against the organization's `agent_mail_access` before scopes:
`active` passes; `none` answers `403 agent_mail_not_enabled`; `suspended`
answers `403 organization_suspended`. `access` on `GET /v1/me` reports the current value.

## Scopes

Each key carries a list of scopes, checked per-endpoint after the access check passes:

| Scope | Grants |
|---|---|
| `inboxes:read` | List and fetch inboxes |
| `inboxes:write` | Create and update inboxes |
| `messages:read` | Read threads, messages and attachments |
| `messages:send` | Send a message from an inbox, and retry a failed one |
| `webhooks:read` | List webhook endpoints and their deliveries |
| `webhooks:write` | Create, update, delete, rotate, and redrive webhooks |
| `domains:read` | List and fetch receiving domains |
| `domains:write` | Register and delete receiving domains |

A key missing a required scope gets 403 `insufficient_scope`, and `message` names exactly
which scope was missing so the failure is actionable without guessing. `GET /v1/me`
reports the key's current scopes under `api_key.scopes`.

## Rate limits

Two independent limiters apply to every `/v1` request except sign-up, checked before scopes:

- **Per IP address:** 120 requests/minute. Applies even to unauthenticated requests, so a
  bad key cannot be brute-forced past it.
- **Per API key:** 60 requests/minute for a valid key. A request whose key does not resolve
  is counted against its IP address instead.

Either limit failing returns 429 `rate_limited` with a `Retry-After` header giving seconds
until the window resets.

Sending has a third limit of its own. `POST /v1/inboxes/{inbox}/messages` and
`POST /v1/messages/{message}/retry` also count against an hourly send limit for the key's
workspace. Once the workspace has been reviewed, the limit comes from its plan: 13 send
requests an hour on Free, 250 on Builder, 1,250 on Scale and 6,250 on Startup. Until then
it is 100 an hour, though a workspace not yet reviewed is held to a much smaller daily
quota anyway. Over it, the send fails with 429 `rate_limited` and a `Retry-After`
header giving seconds until the window resets.

- **It counts requests, not messages delivered.** Every call that gets past the per-IP and
  per-key limits counts, whether it is then accepted, refused (a 403 access or scope
  refusal, a 422 validation error, a 429 quota) or a replay of an earlier `Idempotency-Key`.
  A request stopped by the per-IP or per-key limit never reaches this one.
- **It is one count per workspace.** All of the workspace's keys share it, so it is not
  multiplied by the number of keys, and the sends made from that workspace in the console
  share it too.
- **A key that does not resolve** belongs to no workspace. Its send requests are counted
  against a per-IP bucket of 60 requests a minute instead, and are then refused with 401.

`POST /v1/signup` and `POST /v1/signup/verify` take no key and are not covered by any of
these limits; they carry their own, stricter ones — documented in the OpenAPI description.

## Quotas

Beyond rate limits, six quotas apply, and all are visible on `GET /v1/me` under `quotas`.
Two of them are counted across the whole customer account rather than this workspace
alone: `outbound_daily_shared_domain`, and `outbound_monthly` when the plan's allowance is
pooled (`scope` is `pooled`; `service_used` is then Agent Mail's own share of `used`):

- **`inboxes`** — structural cap on how many inboxes the organization may create.
  Exceeding it on `POST /v1/inboxes` fails with 403 `quota_exceeded`.
- **`webhook_endpoints`** — structural cap on webhook endpoints. Exceeding it on
  `POST /v1/webhook-endpoints` fails with 403 `quota_exceeded`.
- **`outbound_daily`** — how many messages the organization may send per UTC day. A message
  uses its slot when it is accepted, and keeps it if the message later fails.
  Exceeding it on `POST /v1/inboxes/{inbox}/messages` fails with 429 `quota_exceeded` plus a
  `Retry-After` header counting down to the reset.
- **`outbound_daily_shared_domain`**: how many messages this customer may send
  per UTC day from the shared domains `agentmail.ackuna.com` and `agents.ackuna.com`
  (existing inboxes only) together, across every
  workspace on the account (100). `used` is the account total;
  `workspace_used` is this workspace's share. Sending more needs an inbox on a
  [custom domain](domains). Exceeding it fails with 429 `quota_exceeded`,
  `error.quota` = `outbound_daily_shared_domain`, and a `Retry-After` to UTC
  midnight.
- **`outbound_monthly`** — how many messages the organization may send per calendar month
  (UTC); on a pooled plan the count includes the customer's other services. It counts
  messages the mail provider has accepted, and a send is also refused when the messages
  still waiting to be sent would take it over the allowance. Exceeding it
  on `POST /v1/inboxes/{inbox}/messages` fails with 429 `quota_exceeded` plus a
  `Retry-After` header counting down to the first of next month.
- **`custom_domains`**: structural cap on registered receiving domains.

Structural caps do not clear with time — only a larger grant helps. The daily and monthly caps always
clear at their next reset times.

## Idempotency

Every mutating endpoint (`POST`, `PATCH`, `DELETE`) accepts an optional
`Idempotency-Key` header — any client-generated string up to 255 characters:

```
POST /v1/inboxes
Idempotency-Key: create-support-inbox-1
```

- First request with a key: processed normally, response stored against the key.
- Same key, same method/path/body, repeated: the stored response is replayed with an added
  `Idempotent-Replay: true` header — the side effect does not repeat. One-shot credentials
  (a webhook endpoint's `secret`) are not part of the stored copy, so a replay omits them:
  capture them from the first response.
- Same key, different body (or a different endpoint): 409 `idempotency_key_reuse`.
- Same key, original request still in flight: 409 `idempotency_key_in_progress`.

The header is optional — omitting it processes every request normally — but strongly
recommended on anything that sends mail or creates a resource, since retries after a
network timeout are otherwise indistinguishable from a second, deliberate request. Stored
keys expire after 24 hours.


---

# Inboxes

An inbox is a mailbox address (`local_part@domain`) that receives inbound mail and sends
outbound replies on behalf of your organization.

## The inbox object

```json
{
  "id": "inb_01k2n9v8qf3m7d5b0y6xz4rt1c",
  "object": "inbox",
  "address": "orders-bot@agentmail.ackuna.com",
  "local_part": "orders-bot",
  "domain": "agentmail.ackuna.com",
  "display_name": "Support desk",
  "status": "active",
  "thread_count": 42,
  "created_at": "2026-08-14T09:00:00.000Z"
}
```

`status` is `active` or `paused`. A paused inbox means your agent is not processing it:

- Inbound mail is **held**, not dropped. It is stored and threaded as usual, but no
  `inbox.message.received` webhook fires for it while the inbox is paused. When you
  resume the inbox, each held message gets its `inbox.message.received` webhook once and
  `held` becomes `false`. The webhooks are dispatched in arrival order, but deliveries
  are retried independently and mail that arrives just after the resume is not held, so
  they can reach you out of order: use `message.created_at`, which is when the mail
  arrived, to order them. The `thread` in such a webhook is the thread as it is at
  release, so its message count and `last_message_at` can include later mail.
- Held mail is readable through the API while the inbox is paused: thread and message
  endpoints return it with `held: true`, and the thread's `last_message_at` moves. If you
  poll instead of using webhooks and want pause to mean "not yet", skip messages with
  `held: true`.
- Held mail follows your plan's retention windows like any other mail. 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.
- `inbox.message.failed` still fires while the inbox is paused: mail that cannot be parsed
  or stored is not held.
- Sending stops: `POST /v1/inboxes/{inbox}/messages` fails with 409 `inbox_paused`.

An archived inbox receives nothing; mail to its address is not delivered.

## List inboxes

```
GET /v1/inboxes?limit=25
Authorization: Bearer ak_...
```

```json
{
  "data": [
    {"id": "inb_01k2n9v8qf3m7d5b0y6xz4rt1c", "object": "inbox", "address": "orders-bot@agentmail.ackuna.com", "local_part": "orders-bot", "domain": "agentmail.ackuna.com", "display_name": null, "status": "active", "thread_count": 42, "created_at": "2026-08-14T09:00:00.000Z"}
  ],
  "next_cursor": "eyJpZCI6MTIzfQ",
  "has_more": false
}
```

Requires scope `inboxes:read`. Pass `?cursor=` from a previous page's `next_cursor` to
continue; `?limit=` is 1–100, default 25.

## Create an inbox

```
POST /v1/inboxes
Authorization: Bearer ak_...
Content-Type: application/json
Idempotency-Key: create-billing-inbox-1

{"username": "ledger-bot"}
```

```json
{
  "data": {
    "id": "inb_01k2n9v8qf3m7d5b0y6xz4rt2d",
    "object": "inbox",
    "address": "ledger-bot@agentmail.ackuna.com",
    "local_part": "ledger-bot",
    "domain": "agentmail.ackuna.com",
    "display_name": null,
    "status": "active",
    "thread_count": 0,
    "created_at": "2026-08-20T14:00:00.000Z"
  }
}
```

Requires scope `inboxes:write`. Fields:

| Field | Required | Notes |
|---|---|---|
| `username` | yes | Lowercased local part. `[a-z0-9]`, `.`, `_` or `-` internally, up to 64 characters. |
| `domain` | no | A `dom_...` id of an `active` [receiving domain](domains) you own, or the shared platform domain's name (`agentmail.ackuna.com`). Omitted → the inbox is hosted on `agentmail.ackuna.com`. The earlier shared domain `agents.ackuna.com` can no longer be chosen for new inboxes (422 `domain_not_allowed`); inboxes already on it keep sending and receiving. |

Failure modes specific to this endpoint: 422 `address_taken` if the address already
exists; 422 `local_part_not_allowed` if the username is reserved or contains a brand,
payment or sign-in term and the inbox would be on the shared platform domain (see below);
409 `domain_not_ready` if
`domain` names a domain that is not yet `active`; 422 `domain_not_allowed` if `domain`
names an older shared domain such as `agents.ackuna.com`, which keeps its existing inboxes
but takes no new ones;
403 `quota_exceeded` if the organization's inbox quota is exhausted. An inbox keeps the
domain it was created on.

On the shared platform domain some names are not available, because mail from
`agentmail.ackuna.com` goes out under Ackuna's own identity. A role address is refused as the
whole name (`postmaster`, `abuse`, `support`, `billing`, `noreply`, ...). A few role words
(`admin`, `root`, `postmaster`, `abuse`, `security`, `official`, `no-reply`, ...) are
refused anywhere in the name, as a word of their own, so `security-team` is refused while
`acme-support` is not. A name that contains a brand, payment or sign-in term (`paypal`,
`invoice`, `login`, ...) is refused, digit look-alikes such as `paypa1` included. The
error does not say which rule matched. On a [custom domain](domains) you own, any name is
allowed.

## Fetch an inbox

```
GET /v1/inboxes/inb_01k2n9v8qf3m7d5b0y6xz4rt1c
Authorization: Bearer ak_...
```

Requires scope `inboxes:read`. Returns the inbox object, or 404 `not_found` for an unknown
or cross-organization id.

## Pause, resume, or rename an inbox

```
PATCH /v1/inboxes/inb_01k2n9v8qf3m7d5b0y6xz4rt1c
Authorization: Bearer ak_...
Content-Type: application/json
Idempotency-Key: pause-billing-inbox-1

{"status": "paused"}
```

```json
{
  "data": {
    "id": "inb_01k2n9v8qf3m7d5b0y6xz4rt2d",
    "object": "inbox",
    "address": "ledger-bot@agentmail.ackuna.com",
    "local_part": "ledger-bot",
    "domain": "agentmail.ackuna.com",
    "display_name": null,
    "status": "paused",
    "thread_count": 0,
    "created_at": "2026-08-20T14:00:00.000Z"
  }
}
```

Requires scope `inboxes:write`. Both fields are optional and independent — send only what
you want to change:

| Field | Notes |
|---|---|
| `display_name` | Nullable string, up to 255 characters. It becomes the From name of the inbox's mail, so on the shared platform domain a name that contains a brand, payment or sign-in term is refused with 422 `display_name_not_allowed`. Role words are fine here ("Support desk"). On a custom domain any name is allowed. |
| `status` | `active` or `paused`. Pausing holds inbound mail and stops sending; resuming releases the held mail's webhooks (see [the inbox object](#the-inbox-object)). |

## Archive an inbox

```
DELETE /v1/inboxes/inb_01k2n9v8qf3m7d5b0y6xz4rt1c
Authorization: Bearer ak_...
Idempotency-Key: archive-billing-inbox-1
```

Responds `204 No Content`. Requires scope `inboxes:write`.

Archiving frees the inbox's quota slot and hides it everywhere — it stops receiving mail,
no longer appears in `GET /v1/inboxes`, and no longer resolves by id (a later `GET`,
`PATCH`, or `DELETE` for it returns 404 `not_found`). Its threads, messages, and
attachments follow the same retention as any other inbox: message bodies and attachments are
removed after 90 days and the messages themselves after 180 days, unless your plan sets
other windows.

The address is **not** released. The shared platform domain hosts inboxes for many
organizations, so a retired address stays reserved: it can never be re-created (a later
`POST /v1/inboxes` for the same address returns 422 `address_taken`), which stops a
different organization from claiming it and receiving the prior owner's mail.

Returns 404 `not_found` for an unknown or cross-organization id.

Next: [Threads](threads) group an inbox's messages into conversations, and
[Messages](messages) covers reading and sending.


---

# Threads

A thread is a read-only grouping of an inbox's messages into one conversation, matched by
subject and participants the same way a mail client threads a mailbox. Threads have no
create or update endpoint — they are formed automatically as mail arrives and is replied
to.

## The thread object

```json
{
  "id": "thr_01k2n9v8qf3m7d5b0y6xz4rt7i",
  "object": "thread",
  "inbox_id": "inb_01k2n9v8qf3m7d5b0y6xz4rt1c",
  "subject": "Question about my order",
  "participants": ["customer@example.com", "orders-bot@agentmail.ackuna.com"],
  "message_count": 3,
  "last_message_at": "2026-08-20T14:05:00.000Z",
  "created_at": "2026-08-19T10:00:00.000Z"
}
```

`participants` is every lowercased email address that has sent or received a message in
the thread so far.

## List threads in an inbox

```
GET /v1/inboxes/inb_01k2n9v8qf3m7d5b0y6xz4rt1c/threads?limit=25
Authorization: Bearer ak_...
```

```json
{
  "data": [
    {"id": "thr_01k2n9v8qf3m7d5b0y6xz4rt7i", "object": "thread", "inbox_id": "inb_01k2n9v8qf3m7d5b0y6xz4rt1c", "subject": "Question about my order", "participants": ["customer@example.com", "orders-bot@agentmail.ackuna.com"], "message_count": 3, "last_message_at": "2026-08-20T14:05:00.000Z", "created_at": "2026-08-19T10:00:00.000Z"}
  ],
  "next_cursor": null,
  "has_more": false
}
```

Requires scope `messages:read`. Ordered most-recently-active first
(`last_message_at` descending), so the top of page one is always what needs attention now.
404 `not_found` if the inbox is unknown or belongs to another organization.

## Fetch a thread

```
GET /v1/threads/thr_01k2n9v8qf3m7d5b0y6xz4rt7i
Authorization: Bearer ak_...
```

Requires scope `messages:read`. Returns the thread object, or 404 `not_found`. To read the
thread's messages, see [`GET /v1/threads/{thread}/messages`](messages#list-messages-in-a-thread).


---

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


---

# Webhooks

A webhook endpoint is an HTTPS URL you register to receive a push the moment something
happens in an inbox you own, instead of polling `GET /v1/threads/{thread}/messages`.

## The webhook endpoint object

```json
{
  "id": "whe_01k2n9v8qf3m7d5b0y6xz4rt4f",
  "object": "webhook_endpoint",
  "url": "https://agent.example.com/webhooks/ackuna",
  "event_types": ["inbox.message.received", "inbox.message.failed"],
  "status": "active",
  "created_at": "2026-08-20T14:01:00.000Z"
}
```

`status` is `active` or `disabled`. An endpoint is disabled automatically after five
consecutive dead deliveries (see [Deliveries](#list-delivery-attempts) below), and the
organization's owners are emailed when that happens, since a disabled webhook cannot
announce its own death.

## List webhook endpoints

```
GET /v1/webhook-endpoints?limit=25
Authorization: Bearer ak_...
```

Requires scope `webhooks:read`. `secret` is never included in a list or single-fetch
response.

## Register a webhook endpoint

```
POST /v1/webhook-endpoints
Authorization: Bearer ak_...
Content-Type: application/json
Idempotency-Key: register-agent-webhook-1

{"url": "https://agent.example.com/webhooks/ackuna", "event_types": ["inbox.message.received"]}
```

```json
{
  "data": {
    "id": "whe_01k2n9v8qf3m7d5b0y6xz4rt4f",
    "object": "webhook_endpoint",
    "url": "https://agent.example.com/webhooks/ackuna",
    "event_types": ["inbox.message.received"],
    "status": "active",
    "created_at": "2026-08-20T14:01:00.000Z",
    "secret": "whsec_k3n9v8qf3m7d5b0y6xz4rt1c9a2b7e4d"
  }
}
```

Requires scope `webhooks:write`. Fields:

| Field | Required | Notes |
|---|---|---|
| `url` | yes | Must be `https`, credential-free (no `user:pass@`), and resolve only to public addresses — private, loopback, link-local (including the cloud metadata address), and carrier-grade-NAT ranges are all rejected. Up to 2048 characters. |
| `event_types` | yes | Non-empty array from the [event catalogue](#event-catalogue) below. |

`secret` — prefixed `whsec_` — is returned **only** here and from
[rotate-secret](#rotate-the-signing-secret). Store it; it is never shown again — an
`Idempotency-Key` replay of this request returns the endpoint without `secret`. 403
`quota_exceeded` if the organization's webhook endpoint quota is exhausted.

## Fetch, update, or delete a webhook endpoint

```
GET /v1/webhook-endpoints/whe_01k2n9v8qf3m7d5b0y6xz4rt4f
PATCH /v1/webhook-endpoints/whe_01k2n9v8qf3m7d5b0y6xz4rt4f
DELETE /v1/webhook-endpoints/whe_01k2n9v8qf3m7d5b0y6xz4rt4f
```

`GET` and `DELETE` need `webhooks:read` and `webhooks:write` respectively; `PATCH` needs
`webhooks:write` and accepts any of `url`, `event_types`, or `status` (`active` /
`disabled`) — send only what changes:

```
PATCH /v1/webhook-endpoints/whe_01k2n9v8qf3m7d5b0y6xz4rt4f
Authorization: Bearer ak_...
Content-Type: application/json

{"status": "disabled"}
```

`DELETE` returns `{"data": null}`. Deleting an endpoint also deletes its delivery log, and deliveries still pending for it are dropped and never sent. To keep the history, `PATCH` `{"status":"disabled"}` instead. All three 404 `not_found` for an unknown or
cross-organization id.

## Rotate the signing secret

```
POST /v1/webhook-endpoints/whe_01k2n9v8qf3m7d5b0y6xz4rt4f/rotate-secret
Authorization: Bearer ak_...
Idempotency-Key: rotate-agent-webhook-secret-1
```

Requires scope `webhooks:write`. Returns the endpoint with a freshly generated `secret` —
shown once, exactly like on creation; an `Idempotency-Key` replay omits it. The previous
secret stops validating immediately.

## List delivery attempts

```
GET /v1/webhook-endpoints/whe_01k2n9v8qf3m7d5b0y6xz4rt4f/deliveries?limit=25
Authorization: Bearer ak_...
```

```json
{
  "data": [
    {
      "id": "evt_01k2n9v8qf3m7d5b0y6xz4rt9k",
      "object": "event",
      "type": "inbox.message.received",
      "status": "delivered",
      "attempts": 1,
      "last_error": null,
      "delivered_at": "2026-08-20T14:06:01.000Z",
      "created_at": "2026-08-20T14:06:00.000Z",
      "data": {
        "inbox": { "...": "inbox object, full form" },
        "thread": { "...": "thread object" },
        "message": { "...": "message object, summary form" }
      }
    }
  ],
  "next_cursor": null,
  "has_more": false
}
```

Requires scope `webhooks:read`. Ordered newest-first (most recently created first), so the
top of page one is always the most recent delivery attempt. `status` is `pending`,
`delivered`, or `dead` (all retries exhausted). `data` mirrors what was actually sent to
your endpoint for that delivery. This `id` (`evt_...`) is the delivery's one identifier: it is
what this listing shows, what [redrive](#redrive-a-dead-delivery) takes, and the `id` inside
the pushed envelope body itself; see [below](#the-delivery-request).

## Redrive a dead delivery

```
POST /v1/webhook-deliveries/evt_01k2n9v8qf3m7d5b0y6xz4rt9k/redrive
Authorization: Bearer ak_...
Idempotency-Key: redrive-evt-01k2n9-1
```

Requires scope `webhooks:write`. Resets the delivery to `pending` and re-queues it. 409
`invalid_request` if the delivery's `status` is not `dead` (only exhausted deliveries can
be redriven), or if the delivery's endpoint is disabled — re-enable the endpoint first, or
the retry would only dead-letter again.

## The delivery request

Every delivery is a `POST` to your `url` with:

```
Content-Type: application/json
X-Ackuna-Signature: t=1755701160,v1=8f3e2a1c9b7d4e6f0a2c8b1d3e5f7a9c0b2d4e6f8a1c3e5b7d9f0a2c4e6b8d1f
```

```json
{
  "id": "evt_01k2n9v8qf3m7d5b0y6xz4rt9k",
  "type": "inbox.message.received",
  "created_at": "2026-08-20T14:06:00+00:00",
  "data": {"inbox": {"...": "..."}, "thread": {"...": "..."}, "message": {"...": "..."}}
}
```

This envelope `id` is the delivery's own `evt_...` identifier, the same value
[the deliveries listing](#list-delivery-attempts) shows and
[redrive](#redrive-a-dead-delivery) takes, so a received push correlates with
`GET /v1/webhook-endpoints/{endpoint}/deliveries` on `id` alone. Retries and redrives resend
the same envelope, `id` included, so it is also the key to deduplicate on.

Verify it before trusting `data`: recompute `hash_hmac('sha256', "{t}.{raw_body}",
your_webhook_secret)` and compare to the `v1=` value in constant time. `t` is a Unix
timestamp; reject deliveries where it is far from the current time to guard against replay
of a captured request.

Non-2xx responses (or a timeout) are retried on a backoff schedule (1 min, 5 min, 30 min, 2
hours, 8 hours) for up to 6 attempts before the delivery is marked `dead`. Redirects are not
followed — respond 2xx directly at `url`.

## Event catalogue

| `type` | `data` | Fires when |
|---|---|---|
| `inbox.message.received` | `{inbox, thread, message}` | New inbound mail was ingested into one of your inboxes. `message` is summary form. Mail addressed to several of your inboxes fires once per inbox. Mail that arrives while the inbox is paused is held: this event fires for it when the inbox is resumed, once per message, dispatched in arrival order; deliveries can still arrive out of order, so order by `message.created_at`, which is the time the mail arrived. `thread` is the thread as it is at release. `message.authentication.state` is `failed` when the sender failed DMARC or the spam check, or failed SPF or DKIM with neither passing; such mail is delivered, but check it before your agent replies (see [the message object](messages)). |
| `inbox.message.failed` | `{message, reason}` | **Inbound:** mail arrived but could not be parsed or stored, and was dropped; parsing failed before a thread or sender could be determined, so `message.thread_id` and `message.from` are `null`. **Outbound:** a message you sent will not be delivered. `reason`, which the message's `error_message` repeats, says why: for example, the provider refused it, the recipient is suppressed, it could not leave within 24 hours, the inbox was paused or archived, the provider's answer was uncertain, or it was not sent after a content review (the reason is then the generic "This message was not sent after a content review."; no webhook fires while a message waits `in_review`). `message.direction` tells the two apart, and `reason` is a sentence explaining why. |
| `inbox.message.sent` | `{inbox, thread, message}` | An outbound message you sent was accepted by the mail provider. `message.provider_message_id` is set from this point on. `POST /v1/inboxes/{inbox}/messages` answers `202` with `status: "queued"`; this event, or `inbox.message.failed`, is how you learn the outcome without polling. |

Next: [Receiving domains](domains) to host inboxes on a domain you control instead of the
shared platform domain.


---

# Receiving domains

A custom domain lets your agent send and receive mail on a hostname you control —
`mail.acmerobotics.com` instead of the shared platform domain (`agentmail.ackuna.com`). It is a
self-serve, paid-plan feature: registering one provisions a linked sending identity for that
hostname and returns the DNS records you need to publish. Once they verify, Ackuna activates
the domain by itself — no manual review — and it can host inboxes.

**Why register one.** Sending from the shared domain `agentmail.ackuna.com` (and from
existing inboxes on the earlier shared domain `agents.ackuna.com`) is
limited to 100 messages a day per account, both domains together. Mail from inboxes on your own
domain is not subject to that limit; your plan's limits, the limits for new
workspaces and the workspace's hourly send limit still apply. The domain needs an
MX record pointing at our inbound server, and its DKIM records. If your main domain
already receives mail elsewhere (Google Workspace, Microsoft 365), register a
subdomain such as `mail.example.com`.

## The receiving domain object

```json
{
  "id": "dom_01k2n9v8qf3m7d5b0y6xz4rt3e",
  "object": "receiving_domain",
  "domain": "mail.acmerobotics.com",
  "status": "active",
  "mx_status": "ok",
  "dkim_status": "ok",
  "required_records": [
    {"type": "MX", "name": "mail.acmerobotics.com", "value": "inbound-smtp.us-east-1.amazonaws.com", "priority": 10},
    {"type": "CNAME", "host": "sj4f9k2m1q7r._domainkey", "value": "sj4f9k2m1q7r.dkim.amazonses.com", "status": "verified"},
    {"type": "CNAME", "host": "b8n3x6w0p5t2._domainkey", "value": "b8n3x6w0p5t2.dkim.amazonses.com", "status": "verified"},
    {"type": "CNAME", "host": "h1c7y4v9e2u6._domainkey", "value": "h1c7y4v9e2u6.dkim.amazonses.com", "status": "verified"}
  ],
  "verified_at": "2026-08-20T09:12:00.000Z",
  "activated_at": "2026-08-20T09:14:00.000Z",
  "created_at": "2026-08-20T09:00:00.000Z"
}
```

`status` moves through `pending` (just registered, no DNS confirmed yet) → `verifying` (MX
has been seen; DKIM and the sending identity are still being confirmed) → `active` (fully
verified and usable in `POST /v1/inboxes`) automatically — there is no manual activation
step. A domain that never reaches `active` within 14 days of registration is marked `stale`:
it stops being probed, and its hostname is freed for **your own organization** to register
again (a different organization registering the same hostname still gets `domain_taken`).

`mx_status` reflects the outcome of the most recent automated MX probe (`missing`, `ok`, or
`mismatched`). `dkim_status` reflects the automated check of this domain's linked sending
identity's DKIM CNAMEs: `pending` until every CNAME verifies, then `ok`. `required_records`
is always the current, complete list of DNS records to publish — one MX record (enables
receiving) plus the sending identity's DKIM CNAME records (enable sending, and prove control
of the domain). The domain becomes able to send once its linked sending identity finishes
verifying, so publish the DKIM records promptly if you plan to send as well as receive.
There is no webhook event for activation; poll `GET /v1/domains/{domain}` and watch
`status`.

## List receiving domains

```
GET /v1/domains?limit=25
Authorization: Bearer ak_...
```

Requires scope `domains:read`.

## Register a receiving domain

```
POST /v1/domains
Authorization: Bearer ak_...
Content-Type: application/json
Idempotency-Key: register-mail-domain-1

{"domain": "mail.acmerobotics.com"}
```

```json
{
  "data": {
    "id": "dom_01k2n9v8qf3m7d5b0y6xz4rt3e",
    "object": "receiving_domain",
    "domain": "mail.acmerobotics.com",
    "status": "pending",
    "mx_status": "missing",
    "dkim_status": "pending",
    "required_records": [
      {"type": "MX", "name": "mail.acmerobotics.com", "value": "inbound-smtp.us-east-1.amazonaws.com", "priority": 10}
    ],
    "verified_at": null,
    "activated_at": null,
    "created_at": "2026-08-20T09:00:00.000Z"
  }
}
```

Requires scope `domains:write` and a paid plan with custom-domain capacity remaining (the
free tier has none). `domain` is required — a hostname, lowercased before use, up to 253
characters. Registering provisions a linked sending identity for the hostname in the
background, so the three DKIM CNAME records are added to `required_records` within moments
of this response, not necessarily inside it — poll `GET /v1/domains/{domain}` to pick them
up. Publish every record `required_records` ends up listing at your DNS provider, then keep
polling `GET /v1/domains/{domain}` until `status` is `active`. Once active, pass this
domain's `id` as `domain` on `POST /v1/inboxes` to host an inbox there.

403 `quota_exceeded` if the organization has no custom-domain capacity left on its plan
(including the free tier, which has none). 422 `invalid_request` if the hostname fails
validation or names the platform's own shared domain (which cannot be registered by any
organization); 422 `domain_taken` if the hostname is already registered.

## Fetch a receiving domain

```
GET /v1/domains/dom_01k2n9v8qf3m7d5b0y6xz4rt3e
Authorization: Bearer ak_...
```

Requires scope `domains:read`. Returns the current object — the fastest way to check what
requirement is still outstanding while onboarding a domain. 404 `not_found` for an unknown
or cross-organization id.

## Delete a receiving domain

```
DELETE /v1/domains/dom_01k2n9v8qf3m7d5b0y6xz4rt3e
Authorization: Bearer ak_...
Idempotency-Key: delete-mail-domain-1
```

Requires scope `domains:write`. Returns `{"data": null}`. 409 `invalid_request` if the
domain still hosts one or more inboxes — move or delete them first. 404 `not_found` for an
unknown or cross-organization id.

See [Errors](errors) for the full error-code reference, or [Versioning](versioning) for how
this contract changes over time.


---

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


---

# Versioning

Agent Mail uses URI versioning: every path is prefixed `/v1`, and the version number is
the only compatibility signal — there is no `Accept` header negotiation and no per-field
opt-in.

## Before general availability

Until Agent Mail reaches general availability, `/v1` may still change in ways that are not
strictly additive, because there are no external integrations depending on it beyond the
ones Ackuna is actively working with. Any such change is announced to affected organizations
directly.

## At general availability

The day Agent Mail reaches general availability, `/v1` is **frozen and additive-only**.
From that point on, within `/v1`:

- New endpoints, new optional request fields, and new response fields may be added at any
  time without notice.
- New values may be added to open-ended enumerations (for example, a new webhook
  `event_types` entry) without notice — treat unknown values as "ignore or fall back",
  never as an error.
- Existing fields never change type or meaning, existing endpoints never change their
  required parameters, and nothing that currently returns a value stops returning it.

## Breaking changes go to `/v2`

Anything that would break an existing integration — removing a field, changing a field's
type or semantics, tightening a validation rule, changing an error code for an existing
failure — ships as `/v2` instead of altering `/v1` in place. `/v1` keeps working
unchanged alongside it; there is no forced migration deadline announced as part of this
policy.

## What this means for an integration

Read fields you use by name and ignore fields you do not recognize; do not assume a
response object is exhaustively described by what you saw in testing. Treat every string
enum (`status`, `direction`, `event_types`, error `code`) as open-ended unless
[Errors](errors) states otherwise for that specific field. This is the same posture the
[OpenAPI description](openapi.yaml) and every page in this tree are written to support.


---

# OpenAPI description

```yaml
openapi: 3.1.0
info:
  title: Ackuna Agent Mail API
  version: "1"
  summary: A mail API for agents — inboxes, threads, messages, webhooks and receiving domains.
  description: >
    The public REST surface for Agent Mail. Every path below is relative to the server URL
    `https://agents.ackuna.com/api/v1` — the `/inboxes` path in this document is
    `GET https://agents.ackuna.com/api/v1/inboxes` on the wire. Every request except
    `POST /signup` and `POST /signup/verify` requires a bearer API key, and returns
    `{"data": ...}` on success or `{"error": {...}}` on failure. The organization
    owning the key must have Agent Mail enabled
    (`agent_mail_not_enabled` otherwise, `organization_suspended` while suspended). See
    `/docs/authentication` for key handling, scopes and rate limits, and `/docs/errors` for
    the full error-code reference.
  contact:
    name: Ackuna Agent Mail docs
    url: https://agents.ackuna.com/docs
servers:
  - url: https://agents.ackuna.com/api/v1
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Signup
    description: "Create an organization and a first key without a human: request a code, prove the mailbox."
  - name: Account
    description: Self-discovery — who am I, what can I do, what have I used.
  - name: Inboxes
    description: Mailboxes that receive and send on a domain the organization controls.
  - name: Threads
    description: Read-only conversation grouping over messages in an inbox.
  - name: Messages
    description: Individual inbound or outbound mail items, and their attachments.
  - name: Webhook Endpoints
    description: HTTPS endpoints the platform pushes inbox events to.
  - name: Webhook Deliveries
    description: Delivery attempts for a webhook endpoint's events.
  - name: Receiving Domains
    description: Customer-owned domains onboarded to host inboxes.
paths:
  /me:
    get:
      operationId: getAccount
      tags: [Account]
      summary: Discover the calling key's organization, scopes and quotas
      description: >
        Deliberately unscoped: a key must always be able to learn what it can do, even a
        key with no scopes at all. This is the recommended first call for any new
        integration.
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: Account snapshot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Account'
              example:
                data:
                  object: account
                  organization: {id: org_01k2n9v8qf3m7d5b0y6xz4rt1c, name: Acme Robotics}
                  api_key: {id: key_01k2n9v8qf3m7d5b0y6xz4rt2d, name: "prod agent", scopes: [inboxes:read, inboxes:write, messages:read, messages:send]}
                  access: active
                  quotas:
                    inboxes: {limit: 10, used: 3}
                    outbound_daily: {limit: 500, used: 12, resets_at: "2026-08-20T23:59:59.999999Z"}
                    outbound_daily_shared_domain: {limit: 100, used: 42, workspace_used: 8, resets_at: "2026-08-20T23:59:59.999999Z"}
                    outbound_monthly: {limit: 3000, used: 12, scope: service, service_used: 12, resets_at: "2026-08-31T23:59:59.999999Z"}
                    webhook_endpoints: {limit: 5, used: 1}
                    custom_domains: {limit: 10, used: 1}
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/AgentMailNotEnabled'
        '429':
          $ref: '#/components/responses/RateLimited'
  /inboxes:
    get:
      operationId: listInboxes
      tags: [Inboxes]
      summary: List inboxes in the calling organization
      x-required-scope: inboxes:read
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - name: archived
          in: query
          required: false
          description: >-
            When `true`, list the organization's archived (soft-deleted) inboxes instead
            of its live ones — the set you can restore or, on a custom domain, permanently
            delete.
          schema:
            type: boolean
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: A page of inboxes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InboxListResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/AgentMailNotEnabled'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      operationId: createInbox
      tags: [Inboxes]
      summary: Create an inbox
      x-required-scope: inboxes:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InboxCreateRequest'
            example:
              username: orders-bot
              domain: dom_01k2n9v8qf3m7d5b0y6xz4rt3e
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '201':
          description: The created inbox.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Inbox'
        '403':
          description: Agent Mail not enabled, the organization is suspended, missing scope, or the organization's inbox quota is exhausted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: "`domain_not_ready` — the named receiving domain is not active yet."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: domain_not_ready
                  message: This domain is not active yet; it cannot host inboxes.
                  doc_url: https://agents.ackuna.com/docs/errors.md#domain_not_ready
        '422':
          description: "`invalid_request` (validation failure), `address_taken` (local part already in use on this domain), `local_part_not_allowed` (a reserved username, or one containing a brand, payment or sign-in term, on the shared platform domain) or `domain_not_allowed` (an older shared domain that no longer accepts new inboxes)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
  /inboxes/{inbox}:
    get:
      operationId: getInbox
      tags: [Inboxes]
      summary: Fetch a single inbox
      x-required-scope: inboxes:read
      parameters:
        - $ref: '#/components/parameters/InboxId'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The inbox.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Inbox'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      operationId: updateInbox
      tags: [Inboxes]
      summary: Rename or pause/resume an inbox
      x-required-scope: inboxes:write
      parameters:
        - $ref: '#/components/parameters/InboxId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InboxUpdateRequest'
            example:
              status: paused
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The updated inbox.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Inbox'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: "`invalid_request` (validation failure) or `display_name_not_allowed` (a `display_name` containing a brand, payment or sign-in term, on a shared platform domain inbox)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      operationId: deleteInbox
      tags: [Inboxes]
      summary: Archive an inbox, or permanently delete an archived one
      description: >-
        By default, soft-deletes (archives) a live inbox: its quota slot is freed and it
        stops receiving mail and resolving by id, while its threads and messages are
        kept under the same retention as any other inbox (bodies and attachments 90 days,
        messages 180 days, unless the plan sets other windows). The address is not released — it stays reserved and can never be
        re-created (a later create returns 422 `address_taken`), because the shared
        platform domain would otherwise let another organization claim it and receive the
        prior owner's mail.


        With `?permanent=true`, permanently deletes an already-archived inbox and its
        threads for good. This is allowed only for an inbox on the organization's own
        custom domain; on the shared platform domain the address stays reserved and the
        request is refused with 409 `address_reserved`. A live (not yet archived) id
        returns 404.
      x-required-scope: inboxes:write
      parameters:
        - $ref: '#/components/parameters/InboxId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: permanent
          in: query
          required: false
          description: When `true`, permanently delete an already-archived custom-domain inbox instead of archiving a live one.
          schema:
            type: boolean
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          $ref: '#/components/responses/NullData'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            `address_reserved` — the archived inbox is on the shared platform domain, whose
            addresses stay reserved and cannot be permanently deleted. Restore it, or use a
            custom domain.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /inboxes/{inbox}/restore:
    post:
      operationId: restoreInbox
      tags: [Inboxes]
      summary: Restore an archived inbox
      description: >-
        Un-archives a previously archived inbox: it becomes live again and re-occupies an
        inbox quota slot. Only an archived inbox can be restored — a live, unknown, or
        cross-organization id returns 404. If the organization is already at its inbox
        limit the restore is refused with 403 `quota_exceeded`.
      x-required-scope: inboxes:write
      parameters:
        - $ref: '#/components/parameters/InboxId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The restored inbox.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Inbox'
        '404':
          $ref: '#/components/responses/NotFound'
  /inboxes/{inbox}/threads:
    get:
      operationId: listInboxThreads
      tags: [Threads]
      summary: List threads in an inbox
      x-required-scope: messages:read
      parameters:
        - $ref: '#/components/parameters/InboxId'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: A page of threads, most recently active first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ThreadListResponse'
        '404':
          $ref: '#/components/responses/NotFound'
  /inboxes/{inbox}/messages:
    post:
      operationId: sendMessage
      tags: [Messages]
      summary: Send a message from an inbox, optionally as a reply
      description: >
        Returns 202 once the message is accepted into the send queue. The response carries
        the message id and `status: "queued"`; `provider_message_id` is null until a worker
        has handed the message to the mail provider. Read the outcome from
        `GET /messages/{message}`, or subscribe to `inbox.message.sent` and
        `inbox.message.failed`. A message that will not be delivered ends in
        `status: "failed"` with `error_message` set; `POST /messages/{message}/retry`
        queues it again. A message held by Ackuna's pre-send content check waits in
        `status: "in_review"` until a person reviews it; no webhook fires for the hold.
      x-required-scope: messages:send
      parameters:
        - $ref: '#/components/parameters/InboxId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageSendRequest'
            example:
              reply_to_message_id: msg_01k2n9v8qf3m7d5b0y6xz4rt1c
              text_body: "Thanks, that fixed it."
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '202':
          description: The accepted message, in status queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/MessageFull'
        '403':
          $ref: '#/components/responses/EmailVerificationRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: "`inbox_paused` (the inbox is paused) or `domain_not_ready` (the inbox's domain has no sending identity, or it is reputation-blocked)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: "`invalid_request` (validation failure), `recipient_suppressed` (recipient is on the suppression list), `recipient_not_allowed` (a workspace not yet reviewed has used today's allowance of new recipients) or `recipient_undeliverable` (the recipient address cannot receive mail)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: "`rate_limited` (the per-IP limit, the per-key limit, or the workspace's hourly send limit; no `error.quota`) or `quota_exceeded` (a daily, shared-domain or monthly send cap was reached; `error.quota` says which). Or `queue_full`: too many messages are already waiting to be sent; retry shortly (no `error.quota`)."
          headers:
            Retry-After:
              description: >-
                Seconds until the request can succeed. For `quota_exceeded`, until the cap named
                by `error.quota` resets. For `rate_limited`, until the limit window that was hit
                resets. For `queue_full`, a fixed hint of 60.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: quota_exceeded
                  message: This organization has reached its daily limit of 500 outbound messages.
                  quota: outbound_daily
                  doc_url: https://agents.ackuna.com/docs/errors.md#quota_exceeded
        '503':
          description: "`sending_paused`: outbound sending is paused by the operator, or the inbox's shared domain cannot send yet (its SES account is still in the sandbox)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /threads/{thread}:
    get:
      operationId: getThread
      tags: [Threads]
      summary: Fetch a single thread
      x-required-scope: messages:read
      parameters:
        - $ref: '#/components/parameters/ThreadId'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The thread.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Thread'
        '404':
          $ref: '#/components/responses/NotFound'
  /threads/{thread}/messages:
    get:
      operationId: listThreadMessages
      tags: [Messages]
      summary: List messages in a thread, oldest first
      description: >
        Returns the summary message form: bodies are replaced by `body_preview` because
        mail bodies routinely exceed 100 KB and twenty-five of them per page is not a
        payload worth shipping. Fetch `GET /v1/messages/{message}` for the full body.
      x-required-scope: messages:read
      parameters:
        - $ref: '#/components/parameters/ThreadId'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: A page of messages.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageSummaryListResponse'
        '404':
          $ref: '#/components/responses/NotFound'
  /messages/{message}:
    get:
      operationId: getMessage
      tags: [Messages]
      summary: Fetch a single message with its full body
      x-required-scope: messages:read
      parameters:
        - $ref: '#/components/parameters/MessageId'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The message.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/MessageFull'
        '404':
          $ref: '#/components/responses/NotFound'
  /messages/{message}/retry:
    post:
      operationId: retryMessage
      tags: [Messages]
      summary: Send a failed outbound message again
      description: >
        Queues an outbound message whose `status` is `failed` again, and answers 202 with
        the message back in `queued`. A retry is a new send for quota purposes: it uses a
        daily slot (and, for a message sent from the shared domain, a shared-domain slot
        too), and the same checks as a new send can refuse it (paused sending, a
        paused inbox, a full queue, the daily and monthly allowances, a sending domain
        that cannot send, the new-workspace recipient rule, the suppression list, and
        the recipient's deliverability). A message whose provider outcome was
        uncertain cannot be retried, because it may already have been delivered, and
        neither can a message refused after a content review (`content_rejected`).
      x-required-scope: messages:send
      parameters:
        - $ref: '#/components/parameters/MessageId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '202':
          description: The message, queued again.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/MessageFull'
        '403':
          $ref: '#/components/responses/EmailVerificationRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: "`message_not_retryable` (not an outbound failed message, possibly already delivered, or its inbox is archived), `content_rejected` (not sent after a content review), `inbox_paused`, or `domain_not_ready` (the inbox's domain has no sending identity, or it is reputation-blocked)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: "`recipient_not_allowed`, `recipient_suppressed`, or `recipient_undeliverable`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: "`rate_limited`, `quota_exceeded` or `queue_full`."
          headers:
            Retry-After:
              description: >-
                Seconds until the request can succeed. For `quota_exceeded`, until the cap named
                by `error.quota` resets. For `rate_limited`, until the limit window that was hit
                resets. For `queue_full`, a fixed hint of 60.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: "`sending_paused`: outbound sending is paused by the operator, or the inbox's shared domain cannot send yet (its SES account is still in the sandbox)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /messages/{message}/attachments/{attachment}:
    get:
      operationId: downloadAttachment
      tags: [Messages]
      summary: Download an attachment's bytes
      description: >
        Redirects (302) to a five-minute presigned URL on the storage backend when the
        configured disk can sign one; otherwise streams the file directly with a 200.
      x-required-scope: messages:read
      parameters:
        - $ref: '#/components/parameters/MessageId'
        - name: attachment
          in: path
          required: true
          schema:
            type: string
            pattern: '^att_[0-9a-z]{26}$'
          example: att_01k2n9v8qf3m7d5b0y6xz4rt4f
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The attachment bytes (only when the storage disk cannot sign a temporary URL).
          content:
            application/octet-stream:
              schema:
                type: string
                contentEncoding: binary
        '302':
          description: Redirect to a five-minute presigned download URL.
          headers:
            Location:
              schema:
                type: string
                format: uri
        '404':
          $ref: '#/components/responses/NotFound'
  /webhook-endpoints:
    get:
      operationId: listWebhookEndpoints
      tags: [Webhook Endpoints]
      summary: List the organization's webhook endpoints
      x-required-scope: webhooks:read
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: A page of webhook endpoints. `secret` is never included here.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointListResponse'
    post:
      operationId: createWebhookEndpoint
      tags: [Webhook Endpoints]
      summary: Register a webhook endpoint
      description: >
        The plaintext `secret` is returned exactly twice in an endpoint's life: here, and
        from `rotate-secret`. Store it — it is never returned from a read.
      x-required-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointCreateRequest'
            example:
              url: https://agent.example.com/webhooks/ackuna
              event_types: [inbox.message.received, inbox.message.failed]
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '201':
          description: The created endpoint, including its plaintext secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WebhookEndpointWithSecret'
        '403':
          description: Missing scope or the organization's webhook endpoint quota is exhausted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: "`invalid_request` — validation failure, or the URL failed the SSRF safety check (non-https, credentials in the URL, private/loopback/link-local/metadata address)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /webhook-endpoints/{endpoint}:
    get:
      operationId: getWebhookEndpoint
      tags: [Webhook Endpoints]
      summary: Fetch a single webhook endpoint
      x-required-scope: webhooks:read
      parameters:
        - $ref: '#/components/parameters/EndpointId'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WebhookEndpoint'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      operationId: updateWebhookEndpoint
      tags: [Webhook Endpoints]
      summary: Update a webhook endpoint's URL, subscribed events, or status
      x-required-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/EndpointId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointUpdateRequest'
            example:
              status: disabled
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The updated endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WebhookEndpoint'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      operationId: deleteWebhookEndpoint
      tags: [Webhook Endpoints]
      summary: Delete a webhook endpoint
      description: Deleting an endpoint also deletes its delivery log, and deliveries still pending for it are dropped and never sent. To keep the history, PATCH `{"status":"disabled"}` instead.
      x-required-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/EndpointId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          $ref: '#/components/responses/NullData'
        '404':
          $ref: '#/components/responses/NotFound'
  /webhook-endpoints/{endpoint}/rotate-secret:
    post:
      operationId: rotateWebhookEndpointSecret
      tags: [Webhook Endpoints]
      summary: Rotate a webhook endpoint's signing secret
      description: The new plaintext `secret` is returned once, in this response, and never again.
      x-required-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/EndpointId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The endpoint with its new plaintext secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WebhookEndpointWithSecret'
        '404':
          $ref: '#/components/responses/NotFound'
  /webhook-endpoints/{endpoint}/deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [Webhook Deliveries]
      summary: List delivery attempts for a webhook endpoint
      x-required-scope: webhooks:read
      parameters:
        - $ref: '#/components/parameters/EndpointId'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: A page of deliveries.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventListResponse'
        '404':
          $ref: '#/components/responses/NotFound'
  /webhook-deliveries/{delivery}/redrive:
    post:
      operationId: redriveWebhookDelivery
      tags: [Webhook Deliveries]
      summary: Retry a dead webhook delivery
      description: >-
        Only deliveries whose `status` is `dead` can be redriven, and only while their
        endpoint is enabled. Re-enable a disabled endpoint before redriving its deliveries.
      x-required-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/DeliveryId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The delivery, reset to `pending` and re-queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Event'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: "`invalid_request` — the delivery is not dead, or its endpoint is disabled, so it cannot be redriven."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /domains:
    get:
      operationId: listReceivingDomains
      tags: [Receiving Domains]
      summary: List the organization's receiving domains
      x-required-scope: domains:read
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: A page of receiving domains.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReceivingDomainListResponse'
    post:
      operationId: createReceivingDomain
      tags: [Receiving Domains]
      summary: Register a receiving domain
      description: >
        Records the intent, provisions a linked sending identity for the same hostname, and
        returns the DNS records that must be published: an MX record plus that identity's
        DKIM CNAMEs. The domain moves through `pending` → `verifying` → `active`
        automatically as background jobs confirm MX and DKIM — no manual step. A domain
        that never passes verification within its grace window is marked `stale` and stops
        being probed. Requires a paid plan with custom-domain capacity remaining.
      x-required-scope: domains:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReceivingDomainCreateRequest'
            example:
              domain: mail.acmerobotics.com
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '201':
          description: The registered domain, `pending` verification.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ReceivingDomain'
        '403':
          description: "Agent Mail not enabled, the organization is suspended, missing scope, or `quota_exceeded` — the organization has no custom-domain capacity left on its plan (the free tier has none)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: quota_exceeded
                  message: This organization has reached its limit of 3 custom domains.
                  doc_url: https://agents.ackuna.com/docs/errors.md#quota_exceeded
        '422':
          description: "`invalid_request` (validation failure, or the domain is reserved for the platform) or `domain_taken` (already registered)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: domain_taken
                  message: A receiving domain with this hostname already exists.
                  doc_url: https://agents.ackuna.com/docs/errors.md#domain_taken
  /domains/{domain}:
    get:
      operationId: getReceivingDomain
      tags: [Receiving Domains]
      summary: Fetch a single receiving domain and its verification status
      x-required-scope: domains:read
      parameters:
        - $ref: '#/components/parameters/DomainId'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          description: The domain.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ReceivingDomain'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      operationId: deleteReceivingDomain
      tags: [Receiving Domains]
      summary: Remove a receiving domain
      x-required-scope: domains:write
      parameters:
        - $ref: '#/components/parameters/DomainId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '200':
          $ref: '#/components/responses/NullData'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: "`invalid_request` — the domain still hosts inboxes; move or delete them first."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /signup:
    post:
      operationId: signup
      tags: [Signup]
      summary: Request a sign-up code
      description: >
        No API key required. Mails an 8-character code to the given address; nothing is
        created yet. Call `POST /signup/verify` with the returned `signup_id` and the code
        to create the organization. Rate limited to 5 requests per hour per IP and 3 per
        hour per (normalized) email address, and 5 completed sign-ups per IP per day. While
        a code for the address is live, a repeat request answers 409 `signup_pending` and
        the earlier code stays valid. While the operator has API sign-up closed, this and
        `POST /signup/verify` answer 503 `registration_disabled`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SignupRequest'
            example:
              email: agent@example.com
              organization_name: Robot Co
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '202':
          description: A code was mailed to the (normalized) address.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Signup'
              example:
                data:
                  object: signup
                  signup_id: sgn_01k2n9v8qf3m7d5b0y6xz4rt1c
                  email: agent@example.com
                  expires_at: "2026-09-04T12:15:00.000Z"
        '409':
          description: "`email_already_registered` — the mailbox already has an account; or `signup_pending` — a code for this address is still live (see `Retry-After`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                email_already_registered:
                  value:
                    error:
                      code: email_already_registered
                      message: An account with this email already exists. Sign in to the console and issue an API key there.
                      doc_url: https://agents.ackuna.com/docs/errors.md#email_already_registered
                signup_pending:
                  value:
                    error:
                      code: signup_pending
                      message: A code was already sent to this address and is still valid. Use that code, or ask again after it expires.
                      doc_url: https://agents.ackuna.com/docs/errors.md#signup_pending
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/RegistrationDisabled'
  /signup/verify:
    post:
      operationId: verifySignup
      tags: [Signup]
      summary: Verify the code and create the organization
      description: >
        No API key required. On success, creates the organization on the Free plan with a
        verified owner and returns the first API key — the only time its plaintext is ever
        shown. Rate limited to 30 requests per hour per IP; a wrong code counts against the
        sign-up row's own 5-attempt budget instead.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SignupVerifyRequest'
            example:
              signup_id: sgn_01k2n9v8qf3m7d5b0y6xz4rt1c
              code: AB23CD45
      responses:
        default:
          description: Any other documented failure. See https://agents.ackuna.com/docs/errors for the full code list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '201':
          description: The organization was created; this is the only response that ever carries the plaintext API key.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SignupCompleted'
              example:
                data:
                  object: signup_completed
                  organization: {id: org_01k2n9v8qf3m7d5b0y6xz4rt1c, name: Robot Co}
                  api_key: {key: ak_abc123..., prefix: ak_abc123defg, scopes: [inboxes:read, inboxes:write, messages:read, messages:send, webhooks:read, webhooks:write, domains:read, domains:write]}
                  console_url: https://agents.ackuna.com/login?product=agent-mail
                  docs_url: https://agents.ackuna.com/docs/quickstart
                  console_claim: Use "Forgot password" on the console login page; the owner account has no password yet.
        '409':
          $ref: '#/components/responses/EmailAlreadyRegistered'
        '410':
          $ref: '#/components/responses/SignupExpired'
        '422':
          $ref: '#/components/responses/SignupCodeInvalid'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/RegistrationDisabled'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "ak_<40 random characters>"
      description: >
        A per-organization API key, issued once — by sign-up (`POST /signup`, then
        `POST /signup/verify`), in the Agent Mail console, or out-of-band by an operator —
        and shown in plaintext only at that moment.
        Send it as `Authorization: Bearer ak_...`. The organization must have
        Agent Mail enabled and not suspended, and the key must carry the scope the
        endpoint requires (see `x-required-scope` on each operation).
  parameters:
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque pagination cursor taken from a previous page's `next_cursor`.
      schema:
        type: string
    Limit:
      name: limit
      in: query
      required: false
      description: Page size. Defaults to 25, capped at 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >
        An opaque client-generated key (max 255 characters). Replaying the same key with
        the same request body returns the original stored response with
        `Idempotent-Replay: true` instead of repeating the side effect. One-shot
        credentials (a webhook endpoint's `secret`) are not stored, so a replay omits
        them. Optional, but strongly recommended for every mutating call.
      schema:
        type: string
        maxLength: 255
    InboxId:
      name: inbox
      in: path
      required: true
      schema:
        type: string
        pattern: '^inb_[0-9a-z]{26}$'
      example: inb_01k2n9v8qf3m7d5b0y6xz4rt1c
    ThreadId:
      name: thread
      in: path
      required: true
      schema:
        type: string
        pattern: '^thr_[0-9a-z]{26}$'
      example: thr_01k2n9v8qf3m7d5b0y6xz4rt2d
    MessageId:
      name: message
      in: path
      required: true
      schema:
        type: string
        pattern: '^msg_[0-9a-z]{26}$'
      example: msg_01k2n9v8qf3m7d5b0y6xz4rt3e
    EndpointId:
      name: endpoint
      in: path
      required: true
      schema:
        type: string
        pattern: '^whe_[0-9a-z]{26}$'
      example: whe_01k2n9v8qf3m7d5b0y6xz4rt4f
    DeliveryId:
      name: delivery
      in: path
      required: true
      schema:
        type: string
        pattern: '^evt_[0-9a-z]{26}$'
      example: evt_01k2n9v8qf3m7d5b0y6xz4rt5g
    DomainId:
      name: domain
      in: path
      required: true
      schema:
        type: string
        pattern: '^dom_[0-9a-z]{26}$'
      example: dom_01k2n9v8qf3m7d5b0y6xz4rt6h
  responses:
    Unauthenticated:
      description: "`unauthenticated` — the API key is missing, malformed, revoked or expired."
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: unauthenticated
              message: A valid API key is required.
              doc_url: https://agents.ackuna.com/docs/errors.md#unauthenticated
    AgentMailNotEnabled:
      description: "`agent_mail_not_enabled` — the organization has not enabled Agent Mail."
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: agent_mail_not_enabled
              message: Agent Mail is not enabled for this organization. Enable it in the console under Workspace settings, or upgrade at /billing - every paid plan includes it.
              doc_url: https://agents.ackuna.com/docs/errors.md#agent_mail_not_enabled
    OrganizationSuspended:
      description: "`organization_suspended` — an administrator suspended the organization's Agent Mail access."
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: organization_suspended
              message: Agent Mail access for this organization has been suspended. Contact support to restore it.
              doc_url: https://agents.ackuna.com/docs/errors.md#organization_suspended
    EmailVerificationRequired:
      description: "`email_verification_required` — no owner of the organization has verified their email address yet."
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: email_verification_required
              message: Sending is locked until an organization owner verifies their email address.
              doc_url: https://agents.ackuna.com/docs/errors.md#email_verification_required
    EmailAlreadyRegistered:
      description: "`email_already_registered` — an account with this email already exists."
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: email_already_registered
              message: An account with this email already exists. Sign in to the console and issue an API key there.
              doc_url: https://agents.ackuna.com/docs/errors.md#email_already_registered
    SignupCodeInvalid:
      description: "`signup_code_invalid` — the code does not match."
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: signup_code_invalid
              message: The code is not correct. 4 attempt(s) remaining.
              doc_url: https://agents.ackuna.com/docs/errors.md#signup_code_invalid
    SignupExpired:
      description: "`signup_expired` — the sign-up is unknown, already used, expired, or out of attempts."
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: signup_expired
              message: This sign-up is no longer valid. Request a new code.
              doc_url: https://agents.ackuna.com/docs/errors.md#signup_expired
    RegistrationDisabled:
      description: "`registration_disabled` — API sign-up is closed by the operator; nothing was created or mailed."
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: registration_disabled
              message: New registrations are temporarily unavailable.
              doc_url: https://agents.ackuna.com/docs/errors.md#registration_disabled
    NotFound:
      description: "`not_found` — the identifier is unknown, or belongs to a different organization."
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: not_found
              message: The requested resource does not exist.
              doc_url: https://agents.ackuna.com/docs/errors.md#not_found
    ValidationError:
      description: "`invalid_request` — the request body failed validation."
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: invalid_request
              message: The status field must be one of [active, paused].
              fields:
                status: ["The status field must be one of [active, paused]."]
              doc_url: https://agents.ackuna.com/docs/errors.md#invalid_request
    RateLimited:
      description: "`rate_limited` — too many requests from this key or IP address or, on the send and retry endpoints, over 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)."
      headers:
        Retry-After:
          description: Seconds until the rate limit window resets.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: rate_limited
              message: Too many requests.
              doc_url: https://agents.ackuna.com/docs/errors.md#rate_limited
    NullData:
      description: The resource was deleted.
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: "null"
            required: [data]
          example:
            data: null
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, doc_url]
          properties:
            code:
              type: string
              description: A stable machine-readable error code. See `/docs/errors`.
            message:
              type: string
            fields:
              type: [object, "null"]
              description: Present only for validation failures — field name to list of messages.
              additionalProperties:
                type: array
                items:
                  type: string
            doc_url:
              type: string
              format: uri
              description: Deep link into the markdown source of the errors page, at the heading for this specific code. Drop the `.md` for the HTML page.
            quota:
              type: string
              enum: [outbound_daily, outbound_daily_shared_domain, outbound_monthly]
              description: Present only on 429 quota_exceeded; names the GET /v1/me quotas key that refused the request.
    EmailAddress:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email
        name:
          type: [string, "null"]
    Attachment:
      type: object
      required: [id, object, filename, mime_type, size_bytes]
      properties:
        id:
          type: string
          pattern: '^att_[0-9a-z]{26}$'
        object:
          type: string
          const: attachment
        filename:
          type: string
        mime_type:
          type: string
        size_bytes:
          type: integer
    Inbox:
      type: object
      required: [id, object, address, local_part, domain, status, thread_count, created_at]
      properties:
        id:
          type: string
          pattern: '^inb_[0-9a-z]{26}$'
        object:
          type: string
          const: inbox
        address:
          type: string
          format: email
        local_part:
          type: string
        domain:
          type: string
        display_name:
          type: [string, "null"]
        status:
          type: string
          enum: [active, paused]
        thread_count:
          type: integer
        created_at:
          type: string
          format: date-time
    InboxListResponse:
      type: object
      required: [data, next_cursor, has_more]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Inbox'
        next_cursor:
          type: [string, "null"]
        has_more:
          type: boolean
    Thread:
      type: object
      required: [id, object, inbox_id, participants, message_count, created_at]
      properties:
        id:
          type: string
          pattern: '^thr_[0-9a-z]{26}$'
        object:
          type: string
          const: thread
        inbox_id:
          type: string
          pattern: '^inb_[0-9a-z]{26}$'
        subject:
          type: [string, "null"]
        participants:
          type: array
          items:
            type: string
            format: email
        message_count:
          type: integer
        last_message_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time
    ThreadListResponse:
      type: object
      required: [data, next_cursor, has_more]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Thread'
        next_cursor:
          type: [string, "null"]
        has_more:
          type: boolean
    MessageHeaders:
      type: object
      properties:
        message_id:
          type: [string, "null"]
        in_reply_to:
          type: [string, "null"]
        references:
          type: array
          items:
            type: string
    MessageBase:
      type: object
      required: [id, object, inbox_id, thread_id, direction, status, held, from, to, cc, authentication, created_at]
      properties:
        id:
          type: string
          pattern: '^msg_[0-9a-z]{26}$'
        object:
          type: string
          const: message
        inbox_id:
          type: string
          pattern: '^inb_[0-9a-z]{26}$'
        thread_id:
          type: [string, "null"]
          pattern: '^thr_[0-9a-z]{26}$'
          description: >
            Null for an inbound message whose `status` is `failed` (mail that could not be
            parsed). An outbound message always has a thread and a sender.
        direction:
          type: string
          enum: [inbound, outbound]
        status:
          type: string
          enum: [received, queued, sending, in_review, sent, failed]
        held:
          type: boolean
          description: >
            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 fires it once and sets this to false. An agent that polls and wants
            pause semantics should skip held messages. Held mail follows the plan's
            retention windows like any other mail.
        error_message:
          type: [string, "null"]
          description: >
            Why the message failed, or why its last delivery attempt was deferred. Null
            otherwise.
        from:
          description: >
            Null for an inbound message whose `status` is `failed` (mail that could not be
            parsed). An outbound message always has a thread and a sender.
          oneOf:
            - $ref: '#/components/schemas/EmailAddress'
            - type: "null"
        to:
          type: array
          items:
            $ref: '#/components/schemas/EmailAddress'
        cc:
          type: array
          items:
            $ref: '#/components/schemas/EmailAddress'
        subject:
          type: [string, "null"]
        spam_verdict:
          type: [string, "null"]
        virus_verdict:
          type: [string, "null"]
        authentication:
          $ref: '#/components/schemas/MessageAuthentication'
        provider_message_id:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time
    MessageAuthentication:
      type: object
      description: >
        Sender authentication verdicts SES reported on receipt, uppercase as SES sends them
        (`PASS`, `FAIL`, `GRAY`, `PROCESSING_FAILED`, `DISABLED`), or null when SES sent none.
        Outbound messages carry nulls and `unknown`.
      required: [spf, dkim, dmarc, spam, state]
      properties:
        spf:
          type: [string, "null"]
        dkim:
          type: [string, "null"]
        dmarc:
          type: [string, "null"]
        spam:
          type: [string, "null"]
        state:
          type: string
          enum: [passed, failed, unknown]
          description: >
            `failed` when DMARC or the spam verdict 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
            pass; otherwise `unknown`. A `failed` message is stored and delivered,
            but is never threaded by sender and subject alone, so a forged From cannot join the
            real correspondent's thread. Check this before replying to it.
    MessageSummary:
      description: The shape returned by list endpoints and carried in webhook payloads. Bodies are replaced by a preview.
      allOf:
        - $ref: '#/components/schemas/MessageBase'
        - type: object
          required: [body_preview, attachments]
          properties:
            body_preview:
              type: string
              description: First 500 characters of the text body, or of the HTML body rendered to text when there is no text part.
            attachments:
              type: array
              items:
                $ref: '#/components/schemas/Attachment'
    MessageFull:
      description: The shape returned by the single-message and send endpoints. Carries full bodies and RFC headers.
      allOf:
        - $ref: '#/components/schemas/MessageBase'
        - type: object
          required: [text_body, html_body, headers, attachments]
          properties:
            text_body:
              type: [string, "null"]
            html_body:
              type: [string, "null"]
            headers:
              $ref: '#/components/schemas/MessageHeaders'
            attachments:
              type: array
              items:
                $ref: '#/components/schemas/Attachment'
    MessageSummaryListResponse:
      type: object
      required: [data, next_cursor, has_more]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/MessageSummary'
        next_cursor:
          type: [string, "null"]
        has_more:
          type: boolean
    InboxCreateRequest:
      type: object
      required: [username]
      properties:
        username:
          type: string
          maxLength: 64
          pattern: '^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$'
          description: The local part of the address, lowercased before use.
        domain:
          type: string
          description: >
            Public id (`dom_…`) of an active receiving domain owned by the organization,
            or the name of the shared platform domain (`agentmail.ackuna.com`). Omit to
            host the inbox on the shared domain. An older shared domain keeps its existing
            inboxes, which still send and receive, but accepts no new ones (422
            `domain_not_allowed`): `agents.ackuna.com` can no longer be chosen for a new
            inbox.
    InboxUpdateRequest:
      type: object
      properties:
        display_name:
          type: [string, "null"]
          maxLength: 255
        status:
          type: string
          enum: [active, paused]
          description: >
            `paused` stops sending (409 `inbox_paused`) and holds inbound mail: it is
            stored and readable with `held: true`, but no `inbox.message.received` webhook
            fires. `active` resumes the inbox and releases the held mail's webhooks, once
            each. They are dispatched in arrival order, but deliveries are retried
            independently, so order them by `message.created_at`. 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.
    MessageSendRequest:
      type: object
      description: >
        Every field is individually optional at the JSON Schema level, but two cross-field
        rules are enforced by the send service and returned as 422 `invalid_request` when
        violated: at least one of `text_body` or `html_body` must be present and non-null,
        and `to` is required unless `reply_to_message_id` is present, in which case any `to`
        you send is ignored and the reply always goes to the replied-to 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.
      anyOf:
        - required: [text_body]
          properties:
            text_body:
              not:
                type: "null"
        - required: [html_body]
          properties:
            html_body:
              not:
                type: "null"
      properties:
        reply_to_message_id:
          type: [string, "null"]
          pattern: '^msg_[0-9a-z]{26}$'
          description: >
            Message this reply answers. Must be an **inbound** message in **this same
            inbox** — an outbound message, or a message belonging to a different inbox
            (even in the same organization), fails with 422 `invalid_request`, not 404. An
            id that does not exist at all, or belongs to a different organization, is 404
            `not_found`. When present, the recipient is always taken from the replied-to
            message (any `to` you send is ignored), and `subject` defaults from it only when
            you omit `subject`.
        to:
          type: [string, "null"]
          format: email
          maxLength: 255
          description: >
            Required unless `reply_to_message_id` is present. When it is, any `to` you send
            is ignored — the reply goes to the replied-to message's Reply-To address, or its
            sender address if it carried no Reply-To.
        subject:
          type: [string, "null"]
          maxLength: 255
        text_body:
          type: [string, "null"]
        html_body:
          type: [string, "null"]
    WebhookEndpoint:
      type: object
      required: [id, object, url, event_types, status, created_at]
      properties:
        id:
          type: string
          pattern: '^whe_[0-9a-z]{26}$'
        object:
          type: string
          const: webhook_endpoint
        url:
          type: string
          format: uri
        event_types:
          type: array
          items:
            type: string
            enum: [inbox.message.received, inbox.message.failed, inbox.message.sent]
        status:
          type: string
          enum: [active, disabled]
        created_at:
          type: string
          format: date-time
    WebhookEndpointWithSecret:
      description: A webhook endpoint at the two moments its plaintext secret is disclosed — creation and rotation.
      allOf:
        - $ref: '#/components/schemas/WebhookEndpoint'
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: "The plaintext signing secret, prefixed `whsec_`. Not returned again after this response — an `Idempotency-Key` replay of it omits this field."
    WebhookEndpointListResponse:
      type: object
      required: [data, next_cursor, has_more]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEndpoint'
        next_cursor:
          type: [string, "null"]
        has_more:
          type: boolean
    WebhookEndpointCreateRequest:
      type: object
      required: [url, event_types]
      properties:
        url:
          type: string
          maxLength: 2048
          format: uri
          description: Must be https, credential-free, and resolve only to public addresses.
        event_types:
          type: array
          minItems: 1
          items:
            type: string
            enum: [inbox.message.received, inbox.message.failed, inbox.message.sent]
    WebhookEndpointUpdateRequest:
      type: object
      properties:
        url:
          type: string
          maxLength: 2048
          format: uri
        event_types:
          type: array
          minItems: 1
          items:
            type: string
            enum: [inbox.message.received, inbox.message.failed, inbox.message.sent]
        status:
          type: string
          enum: [active, disabled]
    Event:
      type: object
      required: [id, object, type, status, attempts, created_at]
      properties:
        id:
          type: string
          pattern: '^evt_[0-9a-z]{26}$'
        object:
          type: string
          const: event
        type:
          type: string
          enum: [inbox.message.received, inbox.message.failed, inbox.message.sent]
        status:
          type: string
          enum: [pending, delivered, dead]
        attempts:
          type: integer
        last_error:
          type: [string, "null"]
        delivered_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time
        data:
          type: [object, "null"]
          description: The event payload's `data` object — shape depends on `type`. See `/docs/webhooks`.
    EventListResponse:
      type: object
      required: [data, next_cursor, has_more]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Event'
        next_cursor:
          type: [string, "null"]
        has_more:
          type: boolean
    RequiredDnsRecord:
      description: >
        One DNS record the customer must publish: an `MX` record (enables receiving) or a
        `CNAME` record from the domain's linked sending identity (enables sending, and DKIM
        verification proves control of the domain).
      oneOf:
        - $ref: '#/components/schemas/RequiredMxRecord'
        - $ref: '#/components/schemas/RequiredDkimRecord'
    RequiredMxRecord:
      type: object
      required: [type, name, value, priority]
      properties:
        type:
          type: string
          const: MX
        name:
          type: string
        value:
          type: string
        priority:
          type: integer
    RequiredDkimRecord:
      type: object
      required: [type, host, value, status]
      properties:
        type:
          type: string
          const: CNAME
        host:
          type: string
        value:
          type: string
        status:
          type: string
          enum: [pending, verified, failed]
          description: >
            Verification status of this specific DKIM CNAME, from the same evidence used to
            derive the parent domain's `dkim_status`.
    ReceivingDomain:
      type: object
      required: [id, object, domain, status, required_records, created_at]
      properties:
        id:
          type: string
          pattern: '^dom_[0-9a-z]{26}$'
        object:
          type: string
          const: receiving_domain
        domain:
          type: string
        status:
          type: string
          enum: [pending, verifying, active, disabled, stale]
        mx_status:
          type: [string, "null"]
          description: >
            Outcome of the most recent automated MX probe: `missing`, `ok`, or
            `mismatched`.
        dkim_status:
          type: [string, "null"]
          description: >
            Automated check of the linked sending identity's DKIM CNAMEs: `pending` until
            every CNAME verifies, then `ok`. Drives auto-activation (`status` moves to
            `active` once this is `ok`, `mx_status` is `ok`, and the identity's provider
            binding is ready) — there is no manual activation review.
        required_records:
          type: array
          items:
            $ref: '#/components/schemas/RequiredDnsRecord'
        verified_at:
          type: [string, "null"]
          format: date-time
        activated_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time
    ReceivingDomainListResponse:
      type: object
      required: [data, next_cursor, has_more]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ReceivingDomain'
        next_cursor:
          type: [string, "null"]
        has_more:
          type: boolean
    ReceivingDomainCreateRequest:
      type: object
      required: [domain]
      properties:
        domain:
          type: string
          maxLength: 253
          description: A hostname the organization controls, lowercased before use.
    Quota:
      type: object
      required: [limit, used]
      properties:
        limit:
          type: integer
        used:
          type: integer
        resets_at:
          type: string
          format: date-time
          description: >-
            Present only on time-based quotas. The last instant of the current period in
            UTC, not the first instant of the next one.
    Account:
      type: object
      required: [object, organization, api_key, access, quotas]
      properties:
        object:
          type: string
          const: account
        organization:
          type: object
          required: [id, name]
          properties:
            id:
              type: string
              pattern: '^org_[0-9a-z]{26}$'
            name:
              type: string
        api_key:
          type: object
          required: [id, name, scopes]
          properties:
            id:
              type: string
              pattern: '^key_[0-9a-z]{26}$'
            name:
              type: string
            scopes:
              type: array
              items:
                type: string
                enum:
                  - inboxes:read
                  - inboxes:write
                  - messages:read
                  - messages:send
                  - webhooks:read
                  - webhooks:write
                  - domains:read
                  - domains:write
        access:
          type: string
          enum: [none, active, suspended]
        quotas:
          type: object
          required: [inboxes, outbound_daily, outbound_daily_shared_domain, outbound_monthly, webhook_endpoints, custom_domains]
          properties:
            inboxes:
              $ref: '#/components/schemas/Quota'
            outbound_daily:
              $ref: '#/components/schemas/Quota'
            outbound_daily_shared_domain:
              description: >-
                Messages sent today (UTC) from the shared domains agentmail.ackuna.com and
                agents.ackuna.com (existing inboxes only) together, counted
                across every workspace of this customer. More needs an inbox on a custom
                domain. A send over the limit answers 429 quota_exceeded with
                error.quota = outbound_daily_shared_domain.
              allOf:
                - $ref: '#/components/schemas/Quota'
                - type: object
                  required: [workspace_used]
                  properties:
                    workspace_used:
                      type: integer
                      description: This workspace's share of `used`.
            outbound_monthly:
              allOf:
                - $ref: '#/components/schemas/Quota'
                - type: object
                  required: [scope, service_used]
                  properties:
                    scope: {type: string, enum: [pooled, service]}
                    service_used: {type: integer, description: "Agent Mail's own share when scope is pooled."}
            webhook_endpoints:
              $ref: '#/components/schemas/Quota'
            custom_domains:
              $ref: '#/components/schemas/Quota'
    SignupRequest:
      type: object
      required: [email, organization_name]
      properties:
        email:
          type: string
          format: email
          maxLength: 255
          description: >
            Normalized (lowercased, `+tag` stripped) before use and before the sign-up
            code is mailed. Disposable-mailbox domains are rejected as a validation error.
        organization_name:
          type: string
          maxLength: 255
    Signup:
      type: object
      required: [object, signup_id, email, expires_at]
      properties:
        object:
          type: string
          const: signup
        signup_id:
          type: string
          pattern: '^sgn_[0-9a-z]{26}$'
        email:
          type: string
          format: email
          description: The normalized address the code was mailed to.
        expires_at:
          type: string
          format: date-time
    SignupVerifyRequest:
      type: object
      required: [signup_id, code]
      properties:
        signup_id:
          type: string
          pattern: '^sgn_[0-9a-z]{26}$'
        code:
          type: string
          maxLength: 16
    SignupCompleted:
      type: object
      required: [object, organization, api_key, console_url, docs_url, console_claim]
      properties:
        object:
          type: string
          const: signup_completed
        organization:
          type: object
          required: [id, name]
          properties:
            id:
              type: string
              pattern: '^org_[0-9a-z]{26}$'
            name:
              type: string
        api_key:
          type: object
          required: [key, prefix, scopes]
          description: >
            `key` is the plaintext API key — this is the only response that ever shows
            it. Store it now; it cannot be retrieved again.
          properties:
            key:
              type: string
              pattern: '^ak_[A-Za-z0-9]{40}$'
            prefix:
              type: string
            scopes:
              type: array
              items:
                type: string
                enum:
                  - inboxes:read
                  - inboxes:write
                  - messages:read
                  - messages:send
                  - webhooks:read
                  - webhooks:write
                  - domains:read
                  - domains:write
        console_url:
          type: string
          format: uri
        docs_url:
          type: string
          format: uri
        console_claim:
          type: string
          example: Use "Forgot password" on the console login page; the owner account has no password yet.

```
