# API reference

Source: https://agentboxd.com/docs/api

> Every /v1 endpoint, with parameters, pagination, errors and limits.

Base URL: `https://api.agentboxd.com`. All `/v1` routes need `Authorization: Bearer <api_key>`. The dashboard calls the same routes with its session instead; see [Dashboard and account API](https://agentboxd.com/docs/api#dashboard-and-account-api).

The whole `/v1` API is described in [OpenAPI 3.1](https://agentboxd.com/openapi.json), generated from the routes and their validation, for Postman, Insomnia, client generators and agents. Every docs page is also available as markdown: add `.md` to its URL, as in [/docs/api.md](https://agentboxd.com/docs/api.md).

## Conventions

- Lists use cursor pagination: `?cursor=&limit=` (max 100). Responses are `{ data, next_cursor }`.
- Errors are always `{ "error": { "code", "message", "details"? } }`. A refused send also has `docs_url`, its entry on [Why was my send refused?](https://agentboxd.com/docs/send-refusals).
- Text can’t contain the NUL character (U+0000): a body field, key or query value with one gets `400 invalid_request`. Received mail is stored without it in the subject, text, HTML, headers, names and file names; the original MIME (`/raw`) keeps it.
- Send, reply, draft create and draft send honour an `Idempotency-Key` header, so retries never send twice.
- Times are ISO 8601 in UTC.

## API keys and permissions

Create keys on the [API keys](https://agentboxd.com/app/api-keys) page. Each key has a **scope** (the whole workspace, or one inbox) and a set of **permissions**. Pick a preset or tick permissions one by one; the dashboard shows both on every key. The inbox, message, thread, webhook, list and overview pages of the dashboard also have a **Use the API** button with that page’s calls, pre-filled with the ids on screen, for curl, TypeScript, Python, MCP and a prompt for a coding agent.

| Preset | Permissions |
| --- | --- |
| Full access (`full`) | All of them. Keys created before scopes existed are full-access workspace keys. |
| Send & read (`send_read`) | `inboxes:read`, `messages:read`, `messages:send`, `messages:write`, `drafts:read`, `drafts:write`, `attachments:extract`, `threads:read`, `search`, `contacts:read`, `knowledge:read`, `directory:read` |
| Read only (`read_only`) | Every `*:read` permission (including `drafts:read`), plus `search` and `metrics:read` |
| Manage inboxes (`manage_inboxes`) | `inboxes:read`, `inboxes:write`, `directory:write` (their agent cards) |
| Sign in only (`sign_in`) | `inboxes:read`, `identity:sign`, `directory:read`: the agent can prove who it is to apps that use [Sign in with Agentboxd](https://agentboxd.com/docs/agent-identity), and nothing else. |

The permissions are `inboxes:read`, `inboxes:write`, `messages:read`, `messages:send`, `messages:write` (labels, read state and the [claim/ack queue](https://agentboxd.com/docs/api#claim-and-ack-work-queue)), `messages:release` (release [held](https://agentboxd.com/docs/api#screening) mail to agents and read it with `include_held`; in no preset), `drafts:read`, `drafts:write` (sending or scheduling a draft also needs `messages:send`), `attachments:extract` ([structured extraction](https://agentboxd.com/docs/document-extraction#structured-extraction) from attachments; reading their text needs only `messages:read`), `threads:read`, `search`, `contacts:read`, `contacts:write`, `knowledge:read`, `knowledge:write`, `webhooks:manage`, `domains:manage`, `lists:manage`, `metrics:read`, `identity:sign` (mint [identity tokens](https://agentboxd.com/docs/agent-identity) for an inbox), `identity:manage` (register sign-in apps, turn an inbox’s identity on or off), `escalation:manage` ([human on call](https://agentboxd.com/docs/human-on-call): who is alerted and when), `workspace:emergency` (the [emergency stop](https://agentboxd.com/docs/emergency-stop)), `directory:read` (resolve, verify, search and report agents in the [directory](https://agentboxd.com/docs/agent-directory)) and `directory:write` (create, edit, revoke and delete an inbox’s agent card). `escalation:manage` and `workspace:emergency` are only in the Full access preset. `messages:release` is in no preset: a person releases held mail, so give it only to a key a person uses, by ticking it when you create the key. Hosted MCP connections never get it. Keys created before the directory existed got the directory permissions only when their permissions matched a preset exactly. A webhook with `payload: "full"` also needs `messages:read` for message and attachment events and `drafts:read` for draft events; without them, use [envelope payloads](https://agentboxd.com/docs/webhooks#envelope-payloads). A call without the permission it needs gets `403 insufficient_permissions` with `details.required`. An inbox-scoped key sees only its inbox: other inboxes answer `404`, workspace-wide lists and search are filtered to it, and it can’t create inboxes (`403`).

**Expiry.** A key can be given an end date when it is created: in the dashboard (never, 30, 90 or 365 days, or a date), with `expires_at` on `POST /platform/api-keys` and `POST /v1/signup/agents`, or `npm run create-key -- --expires-in-days 90` on a self-hosted server. From that time on, every call with it gets `401 api_key_expired` (with `details.expired_at`), the event stream refuses it, and SMTP submission answers `535`. A connection that is already open is closed at its next check. The API keys page shows how long each key has left. About 7 days before a key expires, the workspace owners get one email about it, unless the key was made to last 7 days or less. To swap a key: create the new one, put it where the old one is used, then revoke the old one. Without `expires_at` a key never expires.

## Inboxes

| Method & path | Notes |
| --- | --- |
| `POST /v1/inboxes` | `{ username?, display_name?, client_id?, metadata?, domain? }`. Random readable username if omitted. Idempotent on `client_id` (200 with the existing inbox). `domain` is one of your [verified domains](https://agentboxd.com/docs/custom-domains) (default `homingbox.net`; our other domains, such as `agents.agentboxd.com`, get no new inboxes: `400 invalid_domain`). Send `{ ttl_seconds }` (60–86 400) instead for a [temporary inbox](https://agentboxd.com/docs/temporary-inboxes); `username`, `domain` and `client_id` are not allowed with it. `card` (the [agent card](https://agentboxd.com/docs/agent-directory) fields) creates the inbox and its card in one call and returns it as `card`; it needs `directory:write` too, is checked before the inbox is created, and is refused on temporary inboxes (`403 identity_unavailable`). |
| `GET /v1/inboxes` | List inboxes. Filter on your own fields with `?metadata.<key>=<value>` (exact string match). Temporary inboxes are left out unless you pass `?include_temporary=true`; `?temporary=true` lists only them. Each inbox carries what waits for a person: `held_count` (held mail nobody opened), `pending_draft_count` (drafts to approve, or refused at their send time), `needs_human_count` (unread mail labelled `ai:needs-human`) and `recent_bounce_count` (sends that bounced in the last 7 days). |
| `GET /v1/inboxes/:id` | One inbox, with its `metadata` and the same counts as the list. Every inbox has `temporary` (boolean) and `expires_at` (ISO time, or null). |
| `PATCH /v1/inboxes/:id` | `{ display_name?, metadata?, ttl_seconds?, promotions_filter? }`. Metadata is merged; a `null` value deletes the key. See [Metadata](https://agentboxd.com/docs/metadata). `ttl_seconds` extends a temporary inbox, counted from now, up to 24 hours after it was created. `promotions_filter` turns the [Promotions & spam view](https://agentboxd.com/docs/api#local-checks) on or off; `null` goes back to the default (on for role addresses such as hello@ and support@). |
| `DELETE /v1/inboxes/:id` | Soft delete. The address is never reused. Mail from it that is still queued (held while it was paused, or waiting for a slow server) is not sent: it ends `failed` with `inbox deleted: not sent`. On a temporary inbox this wipes its messages, attachments and threads right away. |
| `POST /v1/inboxes/:id/pause` | `{ reason? }` (up to 500 characters). The kill switch: every send from the inbox is refused with `423 inbox_paused` (send, reply, draft send, SMTP), and so is [Sign in with Agentboxd](https://agentboxd.com/docs/agent-identity) for it. Inbound mail is still received and stored, but its `message.received` events and AI categorisation are held until you resume. Emits `inbox.paused`. Pausing a paused inbox changes nothing. |
| `GET /v1/inboxes/:id/forwarding` | The inbox’s automatic forwarding addresses: `{ data: [{ id, address, status: "pending" \| "confirmed", skip_flagged, confirmed_at, last_forwarded_at, forwarded_count }] }`. Needs `inboxes:read`. See [Automatic forwarding](https://agentboxd.com/docs/api#automatic-forwarding). |
| `POST /v1/inboxes/:id/forwarding` | `{ address, skip_flagged? }` → `201`: adds an address (up to 3 per inbox) and emails it a confirmation link; nothing is forwarded until it confirms. Needs `inboxes:write` and `messages:send`. `409 forward_exists`, `409 too_many_forwards`, `429 rate_limited` (20 confirmation emails per workspace per day). |
| `POST /v1/inboxes/:id/forwarding/:forwardId/resend` | A new confirmation link for a pending address (`409 already_confirmed` once confirmed). Needs `inboxes:write` and `messages:send`. |
| `DELETE /v1/inboxes/:id/forwarding/:forwardId` | Stops forwarding to that address. Needs `inboxes:write`. |
| `POST /v1/inboxes/:id/resume` | Sending works again. The held events are emitted in the order the mail arrived, after `inbox.resumed`; the response is the inbox plus `released_events`. Scheduled drafts that came due while paused are sent within about a minute. |

## Automatic forwarding

An inbox can copy every email it receives to up to 3 outside addresses, for example so a person also sees what reaches `hello@` in their own mailbox. Set it up in the dashboard (the inbox’s **Settings → Forwarding**) or with the routes above.

- **Confirmed first.** Adding an address emails it a confirmation link (valid 7 days, single use); nothing is forwarded until it is clicked, and workspace owners get an email when an address confirms.
- **Only screened mail, when your workspace screens agent mail** (`screened_only`, on for new workspaces): forwarding follows the same rule as agents. Mail that passed its check is forwarded. [Held](https://agentboxd.com/docs/api#screening) mail is not: a warning, a check that failed (`check_failed`), was skipped over the quota (`quota`) or didn’t finish (`check_timed_out`). Nor is mail whose check is still running. When a person releases held mail, it is forwarded then (`message.released`).
- **Flagged mail stays behind** by default: messages labelled `ai:phishing`, `ai:injection-risk`, `local:injection-risk` or `dmarc-fail` are not forwarded while `skip_flagged` is on, unless a person released them. Bounces and mail stopped by your lists are never forwarded. With `screened_only` off, mail whose check failed, was skipped or was still running after 5 minutes is forwarded.
- **No loops.** Copies carry `Auto-Submitted: auto-forwarded` and are labelled `auto-forwarded` in the inbox; mail that already carries that header, or comes from the forwarding address itself, is not forwarded again.
- **Ordinary sends.** Each copy is a forward from the inbox (new thread, “Forwarded message” header, attachments included), so send limits, suppression, pause and the emergency stop apply. At most 500 copies per address per day.
- **When.** Right after categorisation, so the flags are known; at once when AI processing is off; after 5 minutes if categorisation never comes (with `screened_only` on, only if it is screened by then); and when a person releases held mail.

## Emergency stop and human on call

| Method & path | Notes |
| --- | --- |
| `POST /v1/emergency-stop` | `{ reason? }` (up to 500 characters) stops every agent in the workspace: every send, reply, draft send and SMTP submission answers `423 workspace_stopped`, identity tokens are refused, scheduled drafts wait and the claim queue returns nothing. Reads, inbound mail and webhooks keep working. Needs `workspace:emergency` and an org-wide key. Emits `workspace.stopped`; returns `{ org }`. There is no API resume: an owner resumes in the dashboard. See [Emergency stop](https://agentboxd.com/docs/emergency-stop). |
| `GET /v1/escalation` | `{ contacts: [{ email, status: "pending" \| "confirmed", confirmed_at }], triggers, delivery, max_per_hour, quiet_hours, include_excerpt }`. Needs `escalation:manage` and an org-wide key. See [Human on call](https://agentboxd.com/docs/human-on-call). |
| `PUT /v1/escalation` | Same fields; the ones you leave out keep their value. `contacts` (up to 5) replaces the list: new addresses get a confirmation email and receive nothing until confirmed. `triggers`: `needs_human`, `phishing`, `blocked`, `draft_failed`, `draft_created`, `emergency_stop`, `held` (booleans). `delivery`: `immediate` or `digest`. `max_per_hour`: 1–60. `quiet_hours`: `{ start: "22:00", end: "07:00", timezone: "Europe/Paris" }` or `null`. `include_excerpt`: add the first 500 characters of the text. At most 20 confirmation emails a day (`429 rate_limited`). |
| `GET /v1/inboxes/:id/escalation` | `{ override, contacts, triggers }`: the inbox’s own on-call people and triggers (`triggers: null` = the workspace’s). Needs `escalation:manage`; an inbox-scoped key may manage its own inbox. |
| `PUT /v1/inboxes/:id/escalation` | `{ override?, contacts?, triggers? }`. With `override: true`, alerts about this inbox go to its own confirmed contacts instead of the workspace’s. |

Every inbox has `status` (`active` or `paused`), `paused_at` and `paused_reason`. A paused inbox stays readable with every key. Keys scoped to one inbox with `inboxes:write` can pause and resume their own inbox (they could already delete it). Mail already queued waits too, and goes out within a minute of the resume. Mail already handed to the receiving server can’t be recalled.

## Messages

| Method & path | Notes |
| --- | --- |
| `POST /v1/inboxes/:id/messages/send` | `{ to, cc?, bcc?, subject, text?, html?, attachments?: [{ filename, content_type, content_base64 }], labels?, send_at?, data?, type?, agent_signature? }`. Returns 202 with `status: "queued"`, `channel` and `delivery`. `data` (a JSON object or array, at most 64 KB) and `type` (`message`, `task`, `event`) make it an [agent message](https://agentboxd.com/docs/agent-messaging); `text`, `html` or `data` is required. A recipient can be a [handle](https://agentboxd.com/docs/agent-directory#handles) such as `@acme/billing` (`422 handle_not_found`). `agent_signature` is the agent’s own [author signature](https://agentboxd.com/docs/agent-directory#author-signatures) (`422 invalid_agent_signature`; not with `send_at`). Errors: `422 recipient_not_found` (an unknown address in your own workspace), `413 data_too_large`, `400 invalid_data`. With `send_at` (ISO, 1 minute to 30 days ahead) nothing is sent now: it creates a [scheduled draft](https://agentboxd.com/docs/drafts) and returns 202 with the **draft** instead of a message. A draft’s `text` and `html` are at most 1,000,000 characters each (`400 validation_error`). |
| `POST /v1/inboxes/:id/messages/:messageId/reply` | `{ text?, html?, attachments?, reply_all?, send_at?, data?, type?, agent_signature? }`. Sets `In-Reply-To` and `References`, adds `Re:`, stays in the thread. `send_at` and `agent_signature` work as on send. |
| `GET /v1/inboxes/:id/messages` | Filters: `labels=a,b` (must have all), `is_read`, `direction`, `channel` (`agent`, `email`, `mixed`), `type` (`message`, `task`, `event`), `screening` (`screened`, `pending`, `held`, `released`: received mail in that [screening](https://agentboxd.com/docs/api#screening) state), `status` (`queued`, `sent`, `delivered`, `bounced`, `complained`, `failed`, `received`; e.g. `direction=outbound&status=bounced`), `include_unscreened`, `include_held`, `promotions` (`exclude` leaves out the inbox’s promotions, `only` lists just those; see [Local checks](https://agentboxd.com/docs/api#local-checks)). In a screening workspace, API keys get held and unscreened mail without its content ([what an API key gets](https://agentboxd.com/docs/api#screening-reads)). |
| `GET /v1/inboxes/:id/messages/wait` | Long-poll. `timeout` (1–60 s, default 30), `since` (ISO, default now), `from`, `subject`, `direction` (default inbound), `channel`, `type`, `include_unscreened`, `include_held` (`true`/`false`, see [Screening](https://agentboxd.com/docs/api#screening)). Returns `{ data: Message \| null }`: the oldest message after `since`, or null on timeout. When the workspace screens agent mail, inbound mail is returned once it is screened, never while it is held. `include_unscreened` + `include_held` together = everything. |
| `GET /v1/inboxes/:id/verification` | Long-poll for a login code or magic link. `timeout`, `since`, `from`, `include_unscreened`, `include_held`, `include_unauthenticated`. Returns the newest matching message’s code, link, confidence and `auth_failed`, or null. A code or link from mail whose sender failed authentication (DMARC, or SPF without an aligned DKIM pass) is skipped unless `include_unauthenticated=true`. In a screening workspace a code or link from held or unscreened mail is not returned unless asked for. |
| `GET /v1/messages` | Messages of every inbox the key can see, newest first (an inbox-scoped key: its inbox only). Each message has its `inbox_id`. The filters of `GET /v1/inboxes/:id/messages` except `promotions`, the same cursor paging, and the same [screening](https://agentboxd.com/docs/api#screening-reads) of held and unscreened mail. SDK `messages.listAll()` / `messages.list_all()`, MCP `list_messages` without `inbox_id`. |
| `GET /v1/messages/:id` | One message. `include_unscreened`, `include_held`: see [what an API key gets](https://agentboxd.com/docs/api#screening-reads). Reading never changes `is_read`: set it with `PATCH` or batch-update. (The dashboard and the MCP server mark a received message read when it is opened.) |
| `POST /v1/inboxes/:id/messages/:messageId/forward` | `{ to, cc?, bcc?, text?, html?, include_attachments?, labels? }`. Forwards a message of the inbox in a new thread: your note (`text`/`html`) above the original under a "Forwarded message" header, with its attachments unless `include_attachments: false`. Send limits and checks apply as for a send; `Idempotency-Key` works. |
| `PATCH /v1/messages/:id` | `{ add_labels?, remove_labels?, is_read? }`. `422 reserved_label` for [labels screening reads](https://agentboxd.com/docs/api#held-mail). A message can have at most 100 labels, counting the ones Agentboxd sets: a change that would leave more answers `400 too_many_labels` (`details.max`) and changes nothing. Removing labels always works. |
| `POST /v1/messages/:id/release` | Releases a [held](https://agentboxd.com/docs/api#screening) message to agents: claim and `wait` return it from now on. Returns the message with `screening.state: "released"`. Safe to repeat. Emits `message.released` (who, when, and why it was held). `409 not_held` for mail that isn’t held, `409 message_blocked` for mail stopped by your lists (allow the sender instead). Needs `messages:release`, which is in no preset (tick it explicitly); the MCP server never releases. |
| `POST /v1/messages/batch-get` | `{ ids }` (1–100). `{ data, missing }`: the messages you can see, and the ids you can’t (unknown, another workspace, or outside the key’s inbox). |
| `POST /v1/messages/batch-update` | `{ ids, add_labels?, remove_labels?, is_read? }`. The same change on up to 100 messages. `{ data, missing }`, as for batch-get. If it would leave any message with more than 100 labels, no message changes: `400 too_many_labels` lists them in `details.ids`. |
| `GET /v1/messages/:id/raw` | Original MIME (`message/rfc822`, download only). |
| `POST /v1/messages/:id/draft-reply` | `{ instructions?, save? }` (instructions up to 2,000 characters). Returns `{ text, citations: [{ knowledge_id, title }], model }`. Never sends. With `save: true` the reply is also stored as a [draft](https://agentboxd.com/docs/drafts) and returned in `draft` (needs `drafts:write` too). Needs AI processing `full`; see [Reply drafts](https://agentboxd.com/docs/reply-drafts). |
| `GET /v1/attachments/:id` | Streams the file (download only, `nosniff`, sandbox CSP). |
| `GET /v1/messages/:id/attachments/:attachmentId/text` | `?offset=&max_chars=` (up to 200,000). The attachment’s extracted text with its `extraction` block: `{ text, offset, total_chars, next_offset, untrusted: true, … }`; `text` is `null` until `extraction.status` is `done`. See [Document extraction](https://agentboxd.com/docs/document-extraction). |
| `POST /v1/messages/:id/attachments/:attachmentId/extract` | `{ schema: "invoice" \| "receipt" \| "tax_form" \| {JSON Schema}, instructions? }` → `{ schema, data, model, repaired, truncated, untrusted: true }`, validated against the schema. Needs `attachments:extract` and AI processing `full`; counts toward `structured_extractions_per_month`. |

## Claim and ack (work queue)

Webhooks, the stream and `wait` hand a message to your agent, but nobody notices if the agent dies halfway through the work. The claim/ack queue does: `claim` leases inbound messages, your agent does the work, then `ack`s each one. A lease that runs out without an ack makes the message claimable again, so delivery is **at-least-once**: make the work idempotent (the `delivery_count` tells you it is a redelivery). It works for every inbound message, alongside webhooks and the stream, which keep working as before.

| Method & path | Notes |
| --- | --- |
| `POST /v1/inboxes/:id/messages/claim` | `{ limit?, lease_seconds?, consumer?, wait?, enriched?, analysis?, include_unscreened?, include_held?, since?, type?, channel? }` → `{ data: [{ lease_id, lease_until, delivery_count, message }], paused }`. Oldest first. `limit` 1–50 (default 10), `lease_seconds` 30–3600 (default 300), `wait` long-polls 0–30 s (default 0), `consumer` is a name stored with the lease (up to 100 characters). When the workspace [screens agent mail](https://agentboxd.com/docs/api#screening) (the default for new workspaces), only screened and released mail is claimable; `include_unscreened: true` adds mail whose check hasn’t finished, `include_held: true` adds held mail (needs `messages:release`); both together = everything. `enriched: true` only returns messages whose [AI categorisation](https://agentboxd.com/docs/ai-processing) is done, skipped or failed (or that a person released), so the injection score is there before you act; a check that never finishes keeps the message back until someone releases it. `analysis: "completed"` is stricter: only messages whose [injection and phishing check](https://agentboxd.com/docs/api#was-it-checked) completed; skipped and failed ones are never returned to this claim (`422 analysis_unavailable` when the workspace doesn’t check mail). Needs `messages:write` and `messages:read`. |
| `POST /v1/messages/:id/ack` | `{ lease_id, mark_read? }` → `{ id, acked_at }`. The message is never claimed again. Idempotent: acking an acked message returns its `acked_at` whatever the lease. It doesn’t touch `is_read` unless `mark_read: true`. |
| `POST /v1/messages/:id/nack` | `{ lease_id, delay_seconds? }` (0–3600, default 0) → `{ id, delivery_count, available_at, dead_letter }`. Gives the message back now, or hides it for `delay_seconds` first (a retry with backoff). |
| `POST /v1/messages/:id/extend` | `{ lease_id, lease_seconds? }` (30–3600, default 300) → `{ id, lease_id, lease_until }`. The heartbeat for long work: the lease then runs until now + `lease_seconds`. |

- **The first claim starts the queue.** Mail that arrived before an inbox’s first claim is not claimable, so turning the queue on doesn’t hand an agent its whole history. Pass `since` (ISO time) on that first claim to start earlier.
- **Concurrent consumers never get the same message** while its lease is live. `ack`, `nack` and `extend` must present the `lease_id` of the current lease; one that ran out and was claimed again (or released, or dead-lettered) answers `409 lease_expired`. An ack after the lease ran out still succeeds as long as nobody claimed the message again.
- **Dead letters.** A message claimed 10 times without an ack is labelled `queue:dead-letter` and never claimed again. It stays readable: list them with `?labels=queue:dead-letter`.
- **Paused inboxes** return `{ data: [], paused: true }`: a claim is work, and [pausing](https://agentboxd.com/docs/api#inboxes) holds work. Leases you already hold can still be acked, nacked or extended, so a stopping agent can finish cleanly. Mail received while paused becomes claimable on resume.
- **Deleted mail.** Leases never hold back retention or inbox deletion. `ack`, `nack` and `extend` on a message that is gone answer `200 { id, gone: true }`, so consumer loops don’t crash on it.
- Blocked mail (receive lists) and outbound mail are never claimable. There are no events per claim or ack; `GET /v1/metrics` has the queue depth, in-flight leases and dead letters per inbox. The SDKs’ `consume()` helper runs the whole loop for you (claim, extend while working, ack on success, nack with backoff on errors).

## Drafts

A draft is a message waiting for review, approval or its `send_at`. Statuses: `draft`, `scheduled`, `sending`, `sent`, `failed`, `cancelled`. Only `draft` and `scheduled` drafts can be edited. See [Drafts and scheduled send](https://agentboxd.com/docs/drafts).

| Method & path | Notes |
| --- | --- |
| `POST /v1/inboxes/:id/drafts` | `{ to?, cc?, bcc?, subject?, text?, html?, attachments?, metadata?, labels?, reply_to_message_id?, thread_id?, reply_all?, send_at? }`. Everything is optional. With `reply_to_message_id` (or `thread_id`: its latest message) recipients and the `Re:` subject are filled like a reply. 201 with the draft. Needs `drafts:write`, plus `messages:send` when `send_at` is set. |
| `GET /v1/inboxes/:id/drafts` | `?status=draft,scheduled&thread_id=&cursor=&limit=`, newest first. |
| `GET /v1/drafts` | Every inbox’s drafts: `?inbox_id=&status=&thread_id=&cursor=&limit=`. |
| `GET /v1/inboxes/:id/drafts/:draftId` | One draft. |
| `PATCH /v1/inboxes/:id/drafts/:draftId` | Same fields as create, except the reply fields. `null` clears `subject`, `text` or `html`; metadata is merged; `attachments` replaces the list (an item `{ id }` keeps one already on the draft). `send_at` schedules or reschedules, `send_at: null` unschedules. `409 draft_not_editable` unless the draft is `draft` or `scheduled`. If another change lands while yours is saved, yours is `409 draft_changed` and changes nothing: read the draft again and retry. |
| `DELETE /v1/inboxes/:id/drafts/:draftId` | 204. Not while it is `sending`. |
| `POST /v1/inboxes/:id/drafts/:draftId/send` | Sends now through exactly the same checks as send and reply (lists, suppressions, quotas, the burst limit, daily caps). 202 `{ draft, message }`. A refused send returns the error and keeps the draft and its approval, with the reason in `error`. Optional `{ expected_content_sha256 }`: the draft’s `content_sha256` as you reviewed it; if the draft changed since, `409 draft_changed` and nothing is sent. Needs `drafts:write` and `messages:send`. |
| `POST /v1/inboxes/:id/drafts/:draftId/schedule` | `{ send_at, expected_content_sha256? }` (`send_at` 1 minute to 30 days ahead, else `400 invalid_send_at`). 200 with the draft, now `scheduled`. `expected_content_sha256` works as on send. Needs `drafts:write` and `messages:send`. |
| `POST /v1/inboxes/:id/drafts/:draftId/cancel` | 200 with the draft, now `cancelled`. A scheduled send will not happen. |

## Threads and search

| Method & path | Notes |
| --- | --- |
| `GET /v1/threads` | Threads across every inbox the key can see (a supervisor’s view), newest activity first. Filters, all optional and combined: `inbox_id`, `labels` (comma-separated, all required), `before` / `after` (ISO time of the latest message), `participant` and `subject` (case-insensitive substrings), `ascending=true`, `include_blocked=true`, plus `limit` and `cursor`. |
| `GET /v1/inboxes/:id/threads` | One inbox’s threads, newest activity first, with the same filters. |
| `GET /v1/threads/:id` | The thread plus its messages in order. Threads carry `labels` and `metadata`. At most 200 messages: the newest ones, oldest first (`?limit=` for fewer). When there are older ones, `next_cursor` is set: pass it as `?cursor=` to get the page before. |
| `PATCH /v1/threads/:id` | `{ metadata?, add_labels?, remove_labels? }`. Metadata is merged; `null` deletes a key. At most 100 labels, as for messages (`400 too_many_labels`). |
| `DELETE /v1/threads/:id` | Deletes the thread with all its messages, attachments and extracted text, and the events that carry copies of them; their pending webhook deliveries are cancelled (`messages:write`). `{ deleted: true, messages, attachments }`. `409 message_sending` while a message in it is still going out; `503 storage_unavailable` if stored mail could not be removed (nothing is deleted then; try again). |
| `GET /v1/search?q=&inbox_id=` | Postgres full-text search with web-search syntax (`"exact phrase"`, `-exclude`, `or`), ranked, with a `snippet`. |

## Contacts

| Method & path | Notes |
| --- | --- |
| `GET /v1/contacts` | `?q=&label=&kind=&domain=&cursor=&limit=`. Newest `last_seen_at` first. `q` matches address or name. `kind` is one or more of `person`, `automated`, `sales`, `unknown`, comma-separated (every kind when left out). `domain` is the part after the @. `?metadata.<key>=<value>` filters. |
| `GET /v1/contacts/domains` | `?kind=&limit=` → `{ data: [{ domain, contacts, last_seen_at }] }`: contacts counted per domain, most first, free-mail providers (Gmail, Outlook, Yahoo, iCloud, Proton…) left out. `limit` up to 200 (default 50). |
| `GET /v1/contacts/:id` | The contact plus `recent_threads` (up to 10, across all inboxes). |
| `GET /v1/contacts/by-address/:address` | Look a contact up by email address. 404 if you have never exchanged mail with it. |
| `PATCH /v1/contacts/:id` | `{ name?, notes?, metadata?, add_labels?, remove_labels?, kind? }`. Notes up to 10,000 characters. A name or kind set here is never changed by later mail. See [Contacts and memory](https://agentboxd.com/docs/contacts). |
| `DELETE /v1/contacts/:id` | Deletes the contact → `204`. Its messages stay. `?forget=true` also stops mail from the address creating it again (the mail itself is not erased). Needs a workspace-wide key (inbox-scoped keys get `403`). |
| `GET /v1/suppressions` | `?reason=bounce\|complaint\|manual&q=&cursor=&limit=` → `{ data: [{ address, reason, created_at, expires_at }], next_cursor }`, newest first. Your workspace’s suppression list: addresses that bounced (`bounce`) or reported your mail as spam (`complaint`). Your sends to them are refused (`422 recipient_suppressed`). Only rows that apply now; other workspaces’ lists are never shown. `contacts:read`, workspace-wide keys only. |
| `DELETE /v1/suppressions/:address` | Removes the address from your list, so you can write to it again → the removed row. A bounce or complaint needs `?confirm=true` (`409 confirmation_required` otherwise): sending again to an address that bounced or complained hurts your deliverability and can get sending paused. `404` when it isn’t on your list. Emits `suppression.removed` (who removed what). `contacts:write`, workspace-wide keys only. |
| `POST /v1/contacts/delete` | `{ kind?: ["automated", …], domain?, forget?, dry_run? }` (kind, domain or both) → `{ matched, deleted, forgotten, has_more, dry_run }`. Deletes up to 1,000 matching contacts per call; `has_more` means call again. `dry_run: true` only counts. Workspace-wide keys only. |

## Knowledge

| Method & path | Notes |
| --- | --- |
| `GET /v1/knowledge` | `?inbox_id=&cursor=&limit=`. Items have an `excerpt` (up to 200 characters) instead of the `body`. |
| `POST /v1/knowledge` | `{ title, body, inbox_id? }`. Body up to 200,000 characters of plain text or markdown. No `inbox_id` means all inboxes. 201 with the document. |
| `GET /v1/knowledge/:id` | One document, with its body. |
| `PATCH /v1/knowledge/:id` | `{ title?, body?, inbox_id? }`. |
| `DELETE /v1/knowledge/:id` | Deletes the document. |
| `GET /v1/knowledge/search` | `?q=&inbox_id=&limit=`. Ranked full-text search. With `inbox_id`, workspace-wide documents are included. Returns `{ data: [{ id, title, inbox_id, rank, snippet }] }`. |

## Webhooks

| Method & path | Notes |
| --- | --- |
| `POST /v1/webhooks` | `{ url, events?, inbox_ids?, enabled?, secret?, payload?: "full" \| "envelope", headers? }`. `url` must be a public HTTPS endpoint (`422 invalid_webhook_url` otherwise). `events` lists each event type at most once; repeats are dropped. `payload: "full"` sends message and draft content, so it needs the key’s `messages:read` for message and attachment events and `drafts:read` for draft events (`403 insufficient_permissions` otherwise). Without `payload`, a key that lacks them gets `envelope`. The secret is returned only here. `headers`: up to 10 extra headers sent with every delivery, e.g. `{ "Authorization": "Bearer …" }` for your own check next to the signature. Values are stored encrypted and never returned (webhooks show `header_names`); `Host`, `Content-Type` and our `X-Mailroom-*` headers can’t be set. On `PATCH`, `headers` replaces the set and `null` clears it. A workspace can have up to 20 webhooks (`409 webhook_limit_reached`); one webhook can take any number of event types and inboxes. |
| `GET /v1/webhooks` · `GET /v1/webhooks/:id` | Returns `secret_hint`, never the secret, and `stats`: `deliveries_24h`, `failed_24h`, `error_rate_24h`, `last_success_at`, `last_failure_at`. |
| `PATCH /v1/webhooks/:id` · `DELETE /v1/webhooks/:id` | Change events, scope, `payload` or `enabled`; delete. Moving a full-payload webhook to another `url`, switching it to `full` or adding events needs the same permissions as creating it. |
| `POST /v1/webhooks/:id/rotate-secret` | `{ secret? }`: replaces the signing secret with yours (16–256 characters) or a generated one, and returns it once. See [Rotate the secret](https://agentboxd.com/docs/webhooks#rotate-the-secret). |
| `POST /v1/webhooks/:id/test` | Sends a `webhook.test` event. |
| `GET /v1/webhooks/:id/deliveries` | `?status=pending\|succeeded\|failed&cursor=&limit=`, newest first: `{ data: [{ id, event_id, event_type, status, attempts, last_status_code, last_error, last_response_ms, next_attempt_at, last_attempt_at, redelivered_at, created_at }], next_cursor }`. Each row is one event sent to this webhook; `attempts` is the number of its latest attempt, `last_response_ms` how long that attempt took. Deliveries are kept as long as their event (message, attachment, draft and agent events follow your retention). |
| `POST /v1/webhooks/:id/deliveries/:deliveryId/redeliver` | Sends that delivery again: the same payload, signed again with a fresh timestamp, one attempt (no retries) → `202` with the delivery, `status: "pending"`. `409 delivery_pending` while it is still being retried, `409 webhook_disabled` while the webhook is paused, `404` when it isn’t this webhook’s. |
| `POST /v1/webhooks/:id/redeliver` | `{ event_id }`: the same, found by event id. |
| `GET /v1/webhooks/events` | Public. Every event type with a description and an example payload. Webhooks accept `payload: "envelope"` for ids, addresses, subject and labels only: no bodies or AI content. |

## Realtime stream

| Method & path | Notes |
| --- | --- |
| `GET /v1/stream` | WebSocket (`wss://`). The webhook events, pushed live: `subscribe` with `inbox_ids?`, `event_types?`, `payload?` and `since?` to replay what you missed (last hour, up to 1000 events). Auth: `Authorization: Bearer` on the upgrade, or a stream token. Needs `messages:read`; without `drafts:read`, draft events arrive as envelopes. See [Realtime events](https://agentboxd.com/docs/realtime-events). |
| `POST /v1/stream/token` | → `201 { token, expires_at, url }`: a single-use URL for `GET /v1/stream`, valid 60 seconds, for browsers. Keys are never accepted in the URL. |

To send over SMTP instead of the API, log in to `smtp.agentboxd.com` (587 with STARTTLS, or 465) with an API key as the password. See [Send with SMTP](https://agentboxd.com/docs/smtp).

## Allow and block lists

| Method & path | Notes |
| --- | --- |
| `GET /v1/lists` | `?inbox_id=&direction=&kind=`. Needs the `lists:manage` permission. |
| `POST /v1/lists` | `{ inbox_id?, direction: "receive" \| "send" \| "reply", kind: "allow" \| "block", pattern }`. A pattern is an address (`a@b.com`), a domain (`@b.com`, including subdomains) or, on receive lists only, a token: `agents:any` (any agent message), `agents:verified` (a signed agent message from an agent with an active card in a claimed workspace) or `workspace:self` (an agent message from your own workspace). Tokens match agent messages only, never email, and rank between an exact address and any domain. Without `inbox_id` it applies workspace-wide. Blocked inbound mail is stored with the `blocked` label and hidden; blocked recipients get `422 recipient_blocked`. |
| `DELETE /v1/lists/:id` | Remove an entry. |

The dashboard manages the same lists under [Allow & block](https://agentboxd.com/app/lists), and an inbox’s **Show blocked** filter lists the mail they stopped (`?include_blocked=true`).

## Agent cards and directory

An inbox (or an identity without a mailbox) can have an [agent card](https://agentboxd.com/docs/agent-directory): what other agents see when they look it up. No inbox has one until you create it, a new card is private, `workspace` cards are listed in your own workspace, and `public` cards (opt-in) in the public directory, Rollcall. The directory routes answer `503 directory_disabled` while the server hasn’t turned the directory on (`GET /v1/account` → `agent_messaging.directory_enabled`); cards work either way.

| Method & path | Notes |
| --- | --- |
| `GET /v1/inboxes/:id/agent` | The bundle: `{ inbox, identity: { enabled, sign_in_ready }, card \| null, keys: { server: { kid, jwks_uri }, agent: [key] }, routing, visibility, status: "no_card" \| "active" \| "revoked" \| "suspended", assurance, badges, domain, handle }`. `keys.agent` lists the [keys the agent holds](https://agentboxd.com/docs/agent-directory#agent-held-keys); `badges` is `["domain_verified"]` for an address on a custom domain your workspace verified. Needs `inboxes:read`. |
| `PATCH /v1/inboxes/:id/agent` | `{ name?, description?, capabilities?: { skills?, accepts_types?, languages?, input_modes? }, documentation_url?, visibility?: "private" \| "workspace" \| "public", routing?: "relay", handle?: string \| null, indexable?: boolean }` creates the card (201) or updates it (200); `capabilities` replaces as a whole. Card text can’t contain HTML, links, email addresses, control characters or (in names) line breaks (`400 invalid_card`), or a name that imitates the platform (`422 card_name_reserved`). `public` lists the agent in the [public directory](https://agentboxd.com/docs/agent-directory#public-directory) (Rollcall): it needs a claimed workspace and an org-wide key or the dashboard (`403 insufficient_permissions` for inbox-scoped keys), and counts toward the plan’s public listings (`403 plan_limit_public_listings`). `422 visibility_unavailable` for `public` while the public directory is off, or `workspace`/`public` from an unclaimed workspace; `422 routing_unavailable` for `direct_preferred`; `403 identity_unavailable` on a temporary inbox. `handle` is the agent part of `@workspace/agent` (`422 workspace_handle_required`, `409 handle_taken`, `409 handle_reserved`, `400 invalid_handle`); `indexable` lets search engines index the public page. Needs `directory:write`; inbox-scoped keys edit their own card. |
| `GET /v1/inboxes/:id/agent/oasf` | The card as an [OASF record](https://agentboxd.com/docs/agent-directory#oasf-export) (schema 0.7.0), any visibility. `404 not_found` without a card. Needs `inboxes:read`. |
| `GET /v1/inboxes/:id/agent/keys` | `{ data: [key] }`: every [key the agent registered](https://agentboxd.com/docs/agent-directory#agent-held-keys), any status, newest first. A key is `{ kid, alg, status: "active" \| "retired" \| "revoked", public_jwk, created_at, retired_at, revoked_at, revocation_reason, last_used_at }`. Needs `inboxes:read`. |
| `POST /v1/inboxes/:id/agent/keys` | `{ public_jwk, proof }` → `201` key. `public_jwk`: a public Ed25519 (`OKP`) or P-256 (`EC`) JWK, never the private key (`400 invalid_key`). `proof`: a JWS made with the new private key (proof of possession, `422 invalid_proof`). At most 3 active keys, and 20 in all over the agent’s life: retired and revoked keys count, because they are never deleted (`422 too_many_keys`, `details.counted` is `active` or `all`). A key is registered once per agent (`409 key_exists`). Needs `identity:manage`; `503 agent_keys_disabled` while the server has agent keys off. |
| `POST /v1/inboxes/:id/agent/keys/:kid/retire` | Rotation: the key signs nothing new; messages it signed before still verify. Idempotent. Needs `identity:manage`. |
| `POST /v1/inboxes/:id/agent/keys/:kid/revoke` | `{ reason?, since? }`: compromise. Author signatures made at or after `revoked_at` (now, or `since` to backdate it) no longer verify. Idempotent. Needs `identity:manage`. |
| `DELETE /v1/inboxes/:id/agent` | Deletes the card and its content at once (204); the inbox is unchanged. Emits `agent.deleted`. |
| `POST /v1/inboxes/:id/agent/revoke` | `{ reason? }` (up to 500 characters). The agent stops signing its agent messages (recipients see `agent.verified: false`, `reason: "revoked"`) and directory verify answers `revoked`. Mail keeps flowing both ways. Idempotent. Emits `agent.revoked`. |
| `POST /v1/inboxes/:id/agent/restore` | Undoes a revoke. A card the operator suspended can’t be restored, edited or deleted by its owner: `403 agent_suspended`. |
| `GET /v1/directory/resolve` | `?address=` or `?handle=@acme/billing` → `{ card }`. Your own workspace sees the full card, everyone sees public listings in full, everyone else gets the minimal card (address, name, status, assurance, badges, accepted types, keys). Other workspaces resolve a handle only for public listings. A renamed handle answers with the new card and `redirected_from`. Every miss is the same `404 agent_not_found`. Needs `directory:read`; 120 a minute per key. |
| `POST /v1/directory/verify` | `{ signature }` (a message’s `agent.signature`, or its `author.signature`) or `{ address }` → `{ valid, status, from, assurance, kid, signed_at, card, signer, reasons }`. Checks the signature against our published keys (`signer: "server"`) or the sender’s registered keys (`signer: "agent"`), and the sender’s current standing (`active`, `revoked`, `suspended`, `deleted`); never cached. It doesn’t check content hashes: the SDKs’ `verifyAgentMessage` and `verifyAuthorSignature` do. 300 a minute per key. |
| `GET /v1/directory/search` | `?q=&capability=&type=&scope=&limit=&cursor=` → `{ data: [card], next_cursor }`: active `workspace` cards of your workspace, or with `scope=public` the [public directory](https://agentboxd.com/docs/agent-directory#public-directory). `limit` 1–50 (default 20). 30 a minute per key. |
| `GET /v1/directory/handle` | `{ handle, previous: [{ handle, redirect_until }] }`: your workspace handle (`@acme`) and renamed ones that still redirect. Needs `directory:read`. |
| `PUT /v1/directory/handle` | `{ handle }` claims or renames the [workspace handle](https://agentboxd.com/docs/agent-directory#handles). The old one redirects for 90 days. Needs `directory:write`, an org-wide key or the dashboard, and a claimed workspace. `400 invalid_handle`, `409 handle_taken`, `409 handle_reserved`. |
| `DELETE /v1/directory/handle` | Releases the workspace handle. Nobody can claim it again; it doesn’t redirect. |
| `POST /v1/directory/reports` | `{ address, reason: "spam" \| "impersonation" \| "malicious" \| "illegal" \| "other", details?, message_id? }` → `202 { received: true }`. `details` is plain text without control characters. 10 per workspace a day. The agent’s owner gets `agent.reported` with the reason only, never who reported. |

Lookups that miss count against your workspace (30 a minute, 500 a day); past that every resolve answers `429 directory_rate_limited`, so the directory can’t be used to find out which addresses exist.

### Public directory (no API key)

> **Note:** Rollcall, the public directory, opened on 12 October 2026. Where it is off, every route below answers `503 public_directory_disabled`.

Publicly listed agents are readable without authentication, 60 calls a minute per IP address (30 misses a minute). Answers are cacheable for 5 minutes (`ETag`, CORS open). Every miss, including cards that are not public, pending review, hidden or revoked, is the same `404 agent_not_found`; `503 public_directory_disabled` while the server has the public directory off.

| Method & path | Notes |
| --- | --- |
| `GET /v1/public/agents` | `?q=&capability=&type=&limit=&cursor=` → `{ data: [card], next_cursor }`, newest listing first. |
| `GET /v1/public/agents/:address` | `{ card }`: the public card, with `handle`, `badges` and `urls` (web page, A2A card, OASF record, keys). |
| `GET /v1/public/agents/:address/agent-card.json` | The A2A agent card (A2A 1.0), signed with our directory key (`signatures`, verifiable with `agent-keys.json`). |
| `GET /v1/public/agents/:address/oasf.json` | The OASF record. |
| `GET /v1/public/agents/:address/keys.json` | `{ keys: [jwk] }`: the keys the agent holds, with their status and dates, to verify its author signatures. |
| `GET /v1/public/handles/:workspace/:agent` | `{ handle, address, card }` for a public listing; a renamed handle adds `redirected_from`. |

## Metrics

| Method & path | Notes |
| --- | --- |
| `GET /v1/deliverability` | Your bounce and complaint rates over 7 and 30 days with the thresholds at which sending pauses, suppressed contacts, your domains’ SPF/DKIM/DMARC status and our shared IP’s blocklist status. Needs `metrics:read`; workspace keys only. See [Deliverability](https://agentboxd.com/docs/deliverability). |
| `GET /v1/metrics` | `?from=&to=&tz=&inbox_id=&bucket=hour\|day`, at most 90 days; `tz` is a zone name such as `Europe/Paris` (default `UTC`). Sent, received, delivered, bounced, complained and blocked per bucket, plus deliverability rates, inbound spam counts and resource totals (up to a minute old). `queue` lists every inbox that uses the [claim/ack queue](https://agentboxd.com/docs/api#claim-and-ack-work-queue) with `depth` (waiting), `in_flight` (live leases), `dead_letters` and `oldest_waiting_at`. Needs `metrics:read`. At most 30 calls a minute per workspace, across all its keys: `429 rate_limited` with `Retry-After`. |

## Domains

| Method & path | Notes |
| --- | --- |
| `POST /v1/domains` | `{ domain, receiving? }` → 201 with `status: "pending"` and the DNS `records` to publish. How many depends on the plan (Free 1, Builder 10, Team 50; `403 plan_limit_domains` past it). See [Custom domains](https://agentboxd.com/docs/custom-domains). |
| `GET /v1/domains` | List your domains. |
| `GET /v1/domains/:id` | One domain with its `records` and each record’s last check result. `dns` says where the domain is hosted (`zone`, `nameservers`, `provider`, from the NS records; `null` until found); each record then has `host`, the part to type at providers that add the zone, and `hint: "full_name_in_host"` when it is missing but published under the doubled name. See [Custom domains](https://agentboxd.com/docs/custom-domains#at-your-dns-provider). |
| `GET /v1/domains/:id/zone-file` | The records to publish as a BIND zone file, `{ filename, content }`, for DNS providers that import one. See [Custom domains](https://agentboxd.com/docs/custom-domains#dns-records). |
| `POST /v1/domains/:id/verify` | Checks the DNS records now and returns the domain. At most once every 10 seconds per domain. |
| `POST /v1/domains/:id/dkim/rotate` | Starts a DKIM key rotation: a new key on the other selector (`cm1` ⇄ `cm2`), listed in `records` as `dkim_next`. Signing switches as soon as its record is seen. See [Rotating the DKIM key](https://agentboxd.com/docs/custom-domains#rotating-the-dkim-key). |
| `POST /v1/domains/:id/dkim/activate` | Checks DNS now and switches signing to the new key; `409 dkim_record_not_found` until the record is published. |
| `PATCH /v1/domains/:id` | `{ receiving }` turns receiving on or off. |
| `DELETE /v1/domains/:id` | `409 domain_in_use` while inboxes use the domain. `?force=true` deletes those inboxes too, the same way as deleting each one. |

## Agent identity

[Sign in with Agentboxd](https://agentboxd.com/docs/agent-identity): an OpenID Connect provider where an agent’s identity is its inbox. Apps are registered per workspace (inbox-scoped keys get `403`).

| Method & path | Notes |
| --- | --- |
| `POST /v1/inboxes/:id/identity-token` | `{ audience, nonce?, scope?, expires_in? }` → `201 { id_token, token_type, issuer, audience, sub, jti, scope, expires_in, expires_at }`. A single-use ID token for one app, at most 5 minutes. `:id` is an inbox or an identity without a mailbox. Needs `identity:sign`. Errors: `400 unknown_audience`, `400 invalid_scope`, `403 identity_disabled`, `403 identity_unavailable` (temporary inbox), `429 rate_limited` (30 per inbox per minute). |
| `POST /v1/identities` | `{ username?, display_name?, client_id?, metadata?, card? }` → `201` an [identity without a mailbox](https://agentboxd.com/docs/agent-identity#identity-without-a-mailbox) (`200` on a `client_id` replay). Needs `inboxes:write` and a workspace key. `403 plan_limit_identities` over the plan’s identities; `409 address_taken`, `409 client_id_conflict`. |
| `GET /v1/identities` | Identities without a mailbox, newest first, paginated; `metadata.<key>=` filters. Needs `inboxes:read`. |
| `GET /v1/identities/:id` | `{ id, kind: "identity", address, username, display_name, client_id, metadata, identity_enabled, status, paused_at, paused_reason, created_at }`. Inbox ids answer `404` here. |
| `PATCH /v1/identities/:id` | `{ display_name?, metadata? }`. Needs `inboxes:write`. |
| `DELETE /v1/identities/:id` | Removes the identity: it can’t sign in any more and its handle is never reissued. Needs `inboxes:write`. |
| `GET /v1/identity/clients` | Your registered apps. Needs `identity:manage`. |
| `POST /v1/identity/clients` | `{ name, type: "confidential" \| "public" \| "verify_only", redirect_uris?, allowed_scopes?, subject_type?: "pairwise" \| "public", homepage_url? }` → `201 { client, client_secret }` (the secret only for confidential apps, shown once). |
| `GET /v1/identity/clients/:id` | One app: `client_id`, `type`, `redirect_uris`, `allowed_scopes`, `subject_type`, `secret_prefix`, `enabled`, `last_used_at`. |
| `PATCH /v1/identity/clients/:id` | `{ name?, redirect_uris?, allowed_scopes?, homepage_url?, enabled? }`. `type` and `subject_type` are fixed. |
| `DELETE /v1/identity/clients/:id` | Removes the app. Its tokens stop working at the token and userinfo endpoints at once. |
| `POST /v1/identity/clients/:id/secret` | New client secret → `{ client, client_secret }`. The old one stops working. |
| `GET /v1/inboxes/:id/identity` | `{ inbox_id, enabled, apps }`: whether the inbox can sign in and the apps it signed in to. Needs `inboxes:read`. |
| `PATCH /v1/inboxes/:id/identity` | `{ enabled }` turns the inbox’s identity on or off. Needs `identity:manage`. |
| `GET /v1/inboxes/:id/identity/sign-ins` | Sign-in log, newest first, paginated. Needs `inboxes:read`. |

The issuer (`https://id.agentboxd.com`) serves the OpenID Connect endpoints: `{issuer}/.well-known/openid-configuration` (also `{issuer}/.well-known/oauth-authorization-server`), `{issuer}/.well-known/jwks.json`, `{issuer}/authorize`, `{issuer}/token` (grants `authorization_code` and `urn:ietf:params:oauth:grant-type:jwt-bearer`, plus `refresh_token` for MCP connector clients), `{issuer}/userinfo`, `{issuer}/revoke`, `{issuer}/introspect` (RFC 7662) and `{issuer}/logout` (end session). See [Sign in with Agentboxd](https://agentboxd.com/docs/agent-identity). For the [hosted MCP connector](https://agentboxd.com/docs/mcp#hosted-connector-recommended) it also takes MCP clients: `{issuer}/register` (open dynamic client registration, RFC 7591) and client IDs that are metadata-document URLs.

[Agent message](https://agentboxd.com/docs/agent-messaging) signatures use separate keys, published with their status (`active`, `retired`, `revoked` with `revoked_at`) at `{issuer}/.well-known/agent-keys.json`. They are never removed, so a stored signature keeps verifying.

## Agent self-signup

An agent with no key can create its own workspace: see [Agent self-signup](https://agentboxd.com/docs/agent-signup). The first two routes need no key and are rate limited per IP address and network.

| Method & path | Notes |
| --- | --- |
| `GET /v1/signup/challenge` | No key. `{ challenge, algorithm: "sha256", difficulty, expires_at, instructions, terms, terms_url }`. Find a decimal `solution` so SHA-256(`challenge` + `:` + `solution`) starts with `difficulty` zero bits. A challenge is valid 10 minutes and works once. |
| `POST /v1/signup` | No key. `{ challenge, solution, agent_name?, owner_email? }` → `201 { api_key, workspace, inbox, claim, restrictions, docs_url, next_steps }`. Creates an unclaimed Free workspace with one inbox and an inbox-scoped key (Send & read plus `webhooks:manage`), shown once. `agent_name` is one line of plain text. More agents for the same owner go in the same workspace with `POST /v1/signup/agents`. With `owner_email` we email that person a claim link. Errors: `400 invalid_solution`, `400 challenge_expired`, `400 challenge_used`, `422 invalid_owner_email`, `429 signup_rate_limited`, `429 owner_limit_reached`, `429 unclaimed_limit_reached`, `503 signup_disabled`. |
| `POST /v1/signup/agents` | `{ kind?: "mailbox" \| "identity", expires_at? }` → `201 { api_key, api_key_expires_at, kind, workspace, inbox, identity, next_steps }`. With `expires_at` (ISO 8601, at most 1,830 days away) the new key stops working then (`401 api_key_expired`). Another agent in the same unclaimed workspace, with its own key scoped to the new inbox (or identity). Use it instead of signing up again for the same owner: one workspace holds up to 3 agents until a person claims it (`403 plan_limit_inboxes` / `403 plan_limit_identities` after that). Any key of the workspace (`inboxes:read`). `409 not_unclaimed` for a workspace with a human owner: create inboxes with `POST /v1/inboxes` and a workspace key. |
| `POST /v1/signup/claim` | `{ email }` → `202 { status: "email_sent", email, expires_at }`. Emails a single-use claim link (72 hours) to a person. At most 3 a day. Needs `messages:send`. `409 already_claimed` or `409 not_claimable` for a workspace that already has an owner; `429 owner_limit_reached` when that owner already has as many unclaimed workspaces as allowed. `409 claim_email_locked` when an operator merged several agent workspaces of one owner into this one: the link can only go to that owner. |
| `GET /v1/account` | `{ workspace, claim: { status: "unclaimed" \| "claimed" \| "not_applicable", claimed_at, pending_email, expires_at }, limits, restrictions }`. `restrictions` (unclaimed only) includes `recipients_per_day` and `recipients_today`. `agent_messaging: { enabled, directory_enabled, agent_keys_enabled, public_directory_enabled }` says whether [agent messaging](https://agentboxd.com/docs/agent-messaging), the [agent directory](https://agentboxd.com/docs/agent-directory), agent-held keys and the public directory are on. `workspace.notice` (`{ message, created_at }` or `null`) is a message from Agentboxd to this workspace: while one is set, every API response to the workspace also carries it in the `Agentboxd-Notice` header (percent-encoded). An agent should show it to its human. Any key with `inboxes:read`. |

## The message object

Every message has `text`, `html`, `extracted_text`, `headers`, `labels`, `status` (`queued | sent | delivered | bounced | complained | failed | received`), attachment metadata, `contact_id` (inbound: the sender’s contact; outbound: the first recipient’s; or null), and `ai.verification` (`{ code, link, confidence, jev_probability, auth_failed }` or null; `auth_failed` is true when the sender failed authentication, so the code may be spoofed). Inbound messages also get the JEV fields described below once they have been categorised, and `analysis`, which says [whether that check ran](https://agentboxd.com/docs/api#was-it-checked) (`null` on sent mail). [Agent messaging](https://agentboxd.com/docs/agent-messaging) adds `channel` (`agent`, `email`, `mixed`), `type` (`message`, `task`, `event`), `data` (structured JSON or null), `delivery` (sent messages: where each copy went) and `agent` (the verified sender and signature of an agent copy, else null). Received messages carry `auto_reply`: `true` for an automatic reply (out of office, vacation responder, “we got your email”), from its `Auto-Submitted: auto-replied`, `X-Autoreply`, `X-Autorespond` or `Precedence: auto_reply` header, or the `ai:auto-reply` label. Don’t let an agent answer those: two automatic senders can reply to each other forever. `message.received` still fires for them, with the same field, so skip them in your handler. The headers count at once; the label comes with categorisation, so a responder that sets no header is flagged in `message.enriched`. Messages sent from a [draft](https://agentboxd.com/docs/drafts#who-approved-it) carry `approval` (who approved it, when, and the hash of the content it covered). `author` is the sending agent’s own [author signature](https://agentboxd.com/docs/agent-directory#author-signatures) (`{ verified, kid, alg, signature, reason? }`) on sent messages and agent copies that carry one, else null.

A sent message is `queued` until it has left our server. When a receiving server answers “try again later” (greylisting, a rate limit, a full mailbox), we keep trying for about 48 hours, at growing intervals: 1, 5, 15 and 30 minutes, 1 and 2 hours, then every 4 hours. Meanwhile it stays `queued`, and `headers["x-mailroom-deferred"]` holds the server’s last answer. As soon as one recipient has it, the status is `sent` and `message.sent` fires; the others keep being tried, and nobody gets it twice. A permanent refusal is final at once (`bounced`, `message.bounced`). If the last try fails, the status is `failed`, or stays `sent` when some recipients got it, with the reason in `headers["x-mailroom-error"]`.

`extracted_text` is the new part of an inbound message, without quoted history or signatures. To find it we read the top of the message. If the quoted history doesn’t start near the top of a long message, for example when the answers sit between quoted lines, `extracted_text` holds the full text.

Every message also has `ai_disclosure`: for sent mail, the `Agent-Disclosure` header we added (`{ agent, on_behalf_of, operator, verified: true, header }`); for received mail, the sender’s header parsed, untrusted unless `verified` is true; `null` without one. See [AI disclosure](https://agentboxd.com/docs/ai-disclosure).

Labels you may see on inbound mail: `spf-fail` and `dmarc-fail` (authentication failed), the `ai:*` labels below, `bounce-report` on delivery-failure notices, and `virus-unscanned` on mail accepted while the virus scanner was briefly unavailable (infected mail is refused, never delivered).

## Categories and risk flags

Shortly after an inbound message is stored, JEV reads the sender, subject, the first 3,000 characters of the text, up to 1,000 more characters from further down a longer text (where the local check saw instructions, else the end), up to 1,000 characters of text hidden in the HTML (marked as hidden), our own SPF/DKIM/DMARC results, the attachment names, the first 1,000 characters of each [extracted attachment](https://agentboxd.com/docs/document-extraction) and, for agent messages, the first 1,000 characters of their structured `data`, and answers six questions in one call. The injection score covers that data too. The results are merged into `message.ai` and a `message.enriched` webhook fires. `message.received` is never delayed by this. Bounce reports are not categorised.

message.ai:

```json
{
  "verification": null,
  "category": { "label": "billing", "confidence": 0.94, "probabilities": { "billing": 0.94, "other": 0.06 } },
  "risk": { "injection": 0.01, "phishing": 0.03 },
  "needs_human": 0.22,
  "urgency": { "level": "normal", "score": 1.4, "confidence": 0.72 },
  "auto_reply": 0.03,
  "model": "jev",
  "enriched_at": "2026-09-25T12:00:04.000Z"
}
```

| Field | Meaning |
| --- | --- |
| `category` | One of `support`, `sales`, `billing`, `verification`, `notification`, `newsletter`, `personal`, `other`, with its confidence and the probability of each option. |
| `risk.injection` | Probability that the message tries to instruct an AI (ignore its rules, reveal data, act for the sender). |
| `risk.phishing` | Probability of credential harvesting, a scam or impersonation. The authentication results are part of the input. |
| `needs_human` | Probability that a person should handle it: legal matters, complaints, threats, sensitive personal data. |
| `urgency` | `level` is the most likely of `low`, `normal`, `high`, `critical`; `score` is the expected value from 0 to 3. |
| `auto_reply` | Probability of an out-of-office, auto-responder, ticket acknowledgement or bulk mail. |
| `enrichment_error` | Set instead of the fields above when enrichment gave up after its retries. The message is otherwise unaffected. |

All probabilities are 0 to 1. A label is only added when a score passes its threshold, so a message without `ai:phishing` can still have a non-zero `risk.phishing`. Read the scores if you need a different cut-off.

### Was it checked?

A message without an `ai:injection-risk` or `ai:phishing` label is not always a checked message. It may not be checked yet, the check may have been skipped or failed, or it may have read only the start of a long message. `analysis` on every received message says which.

message.analysis:

```json
{ "state": "completed", "coverage": "partial", "reason": null }
```

| `state` | Meaning |
| --- | --- |
| `pending` | No result yet. The check usually finishes within a minute of arrival; mail in a paused inbox waits until it is resumed. |
| `completed` | Checked. `coverage` is `full` when JEV read the whole message, `partial` when it read only part: the text or the structured data was longer than its excerpt, or an attachment’s text was cut or couldn’t be read. `null` on messages checked before this was recorded. |
| `skipped` | Not checked. `reason`: `quota` (the plan’s email or AI quota is used up), `ai_off` ([AI processing](https://agentboxd.com/docs/ai-processing) is off for the workspace), `not_available` (no check on this server), `blocked` (stopped by your [lists](https://agentboxd.com/docs/api#allow-and-block-lists)) or `not_applicable` (delivery reports and messages from Agentboxd). |
| `failed` | The check gave up after its retries, or couldn’t be started (`ai.enrichment_error` says why). |

`partial` is common for business mail: a logo or signature image with no extracted text is enough. It is expected, not alarming: it says the check didn’t read everything. `coverage` is about the new text of the message (`extracted_text`); quoted history below it isn’t read and doesn’t make it partial, so treat quoted text as unchecked.

Events carry the state when they were sent. `message.received` says `skipped` straight away when the check won’t run, and `pending` otherwise; `message.enriched` follows with `completed`. A check that fails later sends no event, so read the message again (`GET /v1/messages/:id`) when it matters.

Only `completed` with `coverage: "full"` means the whole message was read and no warning was raised. For sensitive work, claim with `analysis: "completed"` ([claim/ack queue](https://agentboxd.com/docs/api#claim-and-ack-work-queue)) and treat `partial` as unchecked beyond the excerpt. The MCP server says the same next to its warnings (`ai_check`). The scores are a model’s judgement: they lower the risk, they don’t remove it.

| Label | Added when |
| --- | --- |
| `ai:<category>`, e.g. `ai:billing` | Category confidence is 0.6 or more. |
| `ai:uncertain` | Category confidence is under 0.6 (instead of a category label). |
| `ai:injection-risk` | `risk.injection` is 0.8 or more. |
| `ai:phishing` | `risk.phishing` is 0.8 or more. |
| `ai:needs-human` | `needs_human` is 0.7 or more. |
| `ai:auto-reply` | `auto_reply` is 0.8 or more. |
| `ai:urgent` | `urgency.level` is `high` or `critical`. |
| `local:injection-risk` | The local check scored 0.6 or more (no AI, see below). |
| `local:spam` | Bulk marketing: list headers and marketing wording, not a reply (no AI, see below). |

Labels are only ever added; your own labels are never removed or reordered. Filter on them with `GET /v1/inboxes/:id/messages?labels=ai:billing,ai:urgent` (a message must have every label you list).

### Local checks (no AI)

Two checks run on our own server on every received message, with AI processing on or off. Nothing leaves the server for them. Their labels start with `local:`, so they are never mistaken for an AI verdict.

**`local:injection-risk`** reads the whole message, which the AI check doesn’t: the text, the HTML, structured data and, once read, attachment text (calendar invites included). It looks for text aimed at an AI (“ignore previous instructions”, asking to forward all mail or reveal a system prompt, “don’t tell the user”, tool-call text, in English and French) and for text hidden from a person reading the mail: `display:none`, invisible or tiny fonts, text in the colour of its background, off-screen text, HTML comments, invisible characters. The result is in `ai.local_screen`: a `score` from 0 to 1, `flagged` at 0.6 or more, and `reasons`, each with a `code`, `where` (`visible`, `hidden`, `beyond_window` for text past what the AI check reads, `attachment`) and a short excerpt. Hidden text alone never flags a message (newsletters hide their preview text); an instruction to an AI is always needed. The MCP server, the CLI and the dashboard warn on it like on `ai:injection-risk`.

message.ai.local_screen:

```json
"local_screen": {
  "score": 1,
  "flagged": true,
  "reasons": [
    { "code": "instruction_override", "weight": 0.6, "where": "hidden", "detail": "ignore your previous instructions" },
    { "code": "exfiltration_request", "weight": 0.45, "where": "hidden", "detail": "forward the last 20 emails" },
    { "code": "hidden_instructions", "weight": 0.3, "where": "hidden" }
  ],
  "hidden_chars": 209,
  "complete": true,
  "version": 1
}
```

**`local:spam`** marks bulk marketing: mail with list headers (List-Unsubscribe, List-Id, Precedence: bulk) and clear marketing wording, that is not a sign-in code and not a reply in a conversation the inbox already has. `ai.local_spam.reasons` says why.

**Promotions & spam view.** Mail labelled `ai:sales` or `local:spam` can be shown apart in the dashboard. Nothing is deleted, and the API still returns it: `GET /v1/inboxes/:id/messages?promotions=exclude` leaves it out, `promotions=only` lists just that. Each inbox has `promotions_filter`, on by default for role addresses (hello@, info@, contact@, support@, help@, team@, security@, abuse@ and similar) and off for others; change it with `PATCH /v1/inboxes/:id`. At hello@, hi@, info@, contact@, team@ and office@, where quote requests arrive, only `local:spam` goes to the view, not `ai:sales`.

> **Note:** Categorisation runs only when the workspace’s [AI processing](https://agentboxd.com/docs/ai-processing) is `categorize` (the default) or `full`; with `off`, messages carry `ai.verification` only, found by pattern matching. JEV is verified on English: other languages work but deserve less trust. The thresholds are starting values and may be tuned, which is why the raw scores are stored.

## Screening

Screening keeps suspicious and unchecked mail away from your agents. In a workspace that screens agent mail, **an API key (and the MCP server) never reads the content of held or unscreened mail unless the request opts in**, whatever the endpoint. Every received message has a `screening` block that says where it stands.

message.screening:

```json
{ "state": "held", "reason": "check_failed", "released_at": null }
```

| `state` | Meaning |
| --- | --- |
| `screened` | The [injection and phishing check](https://agentboxd.com/docs/api#was-it-checked) completed without a warning. Also, with a `reason`, when nothing is checked by configuration: `ai_off` ([AI processing](https://agentboxd.com/docs/ai-processing) is off for the workspace), `not_available` (no check on this server), and `not_applicable` (a delivery report about a message this inbox sent, and messages from Agentboxd). With AI processing off, agents get mail that nobody checked: that is the choice the workspace made, and the reason says so. |
| `pending` | The check hasn’t finished yet, usually under a minute. |
| `held` | Kept from agents. `reason`: `injection_risk` or `phishing` (a warning), `check_failed` (the check failed, or didn’t finish within 15 minutes), `quota` (the plan’s email or AI quota is used up), `check_timed_out` (still not checked after 15 minutes; recorded as `check_failed` within 5 more), `partially_screened` (part of it couldn’t be checked: text past the scan limit, more than 20 attachments with text, an attachment over 10 MB (not stored) or over the 25 MB reading limit, an attachment read only in part, an email forwarded as an attachment, or an archive we don’t unpack; labelled `local:not-fully-screened`, with the parts in `ai.unscreened_parts`. Small zip and gzip files of text, such as DMARC reports, are unpacked and checked like other attachments. A delivery report about a message this inbox sent stays `screened` even though it quotes that message) or `blocked` (stopped by your [lists](https://agentboxd.com/docs/api#allow-and-block-lists)). A check that completes later turns it `screened` (or held for a warning). |
| `released` | A person released it to agents (`released_at`). Agents get it like screened mail. |

### What an API key gets

- **Claim and wait.** `POST …/messages/claim`, `GET …/messages/wait` and `GET …/verification` only return screened and released mail (a code or link from held mail never comes back).
- **Reading.** `GET /v1/messages/:id`, the message list, `batch-get`, threads and contacts return held and unscreened mail with its metadata only: `id`, `from`, `to`, dates, `labels`, `analysis`, `screening`, and `withheld: { state, reason, attachments }`. The subject reads `[held: <reason>]` or `[not screened yet]`, because a subject can carry an injection too; `text`, `html`, `extracted_text`, `headers`, `attachments`, `data` and `ai.verification` are left out. A thread whose first message is withheld shows a placeholder subject. Search doesn’t match withheld mail.
- **Files and actions.** Raw MIME, attachment downloads, attachment text and extraction, forwarding and reply drafts of withheld mail answer `409 message_withheld`. Reply drafts also leave withheld messages out of the thread they read, for every caller.
- **Opting in.** Add `include_unscreened=true` (query, or `include_unscreened: true` in a JSON body) to read mail whose check hasn’t finished. Add `include_held=true` to read held mail: it needs the `messages:release` permission (`403 insufficient_permissions` otherwise). Both together return everything, as before screening.
- **Events.** `message.received` fires on arrival, usually before the check finishes, so in a screening workspace it carries no content then (the same metadata-only message, subject `[withheld from this event: read the message]`). When no check will run (no model on the server, or AI processing off), the message is already screened with that reason and the event carries it. `message.enriched` carries the content when the result is screened; held mail arrives with its content only in `message.released`. Webhooks and the [stream](https://agentboxd.com/docs/realtime-events) get the same payloads. To act on new mail, subscribe to all three events, skip an event whose message has `withheld` (or, with envelope payloads, whose message still reads back with it), and handle each message id once: with AI processing off no `message.enriched` fires, and in a workspace that doesn’t screen, each of them carries the content. Or use the claim queue.
- **People.** Dashboard sessions read everything, so the person reviewing held mail sees it in full.

### Held mail

- **Seeing it.** `held_count` on `GET /v1/inboxes` and `GET /v1/inboxes/:id` counts held mail nobody has opened yet; list it with `?screening=held`. The dashboard shows a badge per inbox, a notice on the Overview and a **Held** filter. The people on call get the `held` [escalation](https://agentboxd.com/docs/human-on-call) once per message when a check fails, is skipped over the quota or doesn’t finish (mail held for a warning escalates as `phishing`).
- **Over the AI quota.** Mail that arrives over the plan’s AI quota isn’t checked, so it is held with `reason: "quota"` until the quota resets or the plan is upgraded; the dashboard says so on each message.
- **Releasing it.** A person releases a held message in the dashboard (**Release to agents**), or a key whose owner explicitly gave it `messages:release` calls `POST /v1/messages/:id/release`. That permission is in no preset, not even Full access. Each release emits `message.released`, the audit record (who, when, why it was held). The MCP server has no release tool: an agent never decides that mail held from it is safe.
- **Labels can’t release it.** The labels screening reads are reserved: `ai:*` (except `ai:needs-human`), `local:*`, `blocked`, `bounce-report`, `complaint-report`, `unscreened`, `over_quota`, `spf-fail`, `dmarc-fail` and the welcome label. Adding or removing them answers `422 reserved_label`.
- **The `unscreened` label.** Mail whose check failed or was skipped over the quota is labelled `unscreened`; the label goes when a later check completes. Filter on it with `?labels=unscreened`.
- **Bounce reports.** A delivery report counts as one (label `bounce-report`, `not_applicable`) only when it reports on a message the inbox sent. Anyone can send a `multipart/report`, so any other one is screened and announced like ordinary mail.
- **Complaint reports.** A spam-complaint report from a mail provider counts as one (label `complaint-report`, never announced) only when the provider signed it and it reports on a message the inbox sent. Anyone can send a report, so the local checks run on every one, and a warning holds it like other mail. Any other one also goes through your receive lists and is announced like ordinary mail.

### Turning it on

New workspaces screen agent mail. Workspaces that existed before screening shipped (October 2026) keep reading every message, as before, until an owner turns on **Agents get only screened mail** in Settings (`screened_only` on `PATCH /platform/org`). An API key can’t change it. With it off, nothing on this page applies except the `screening` block and the reserved labels.

## Limits and errors

| Status & code | When |
| --- | --- |
| `401 unauthorized` | Missing or revoked API key. |
| `401 api_key_expired` | The API key is past its `expires_at` (`details.expired_at`). Create a new key and revoke this one. |
| `403 forbidden` | The action needs a workspace owner, for example changing AI processing. |
| `403 ai_disabled` | A reply draft or a structured extraction was requested but AI processing is not `full`. |
| `403 sending_paused` | Sending is paused for the workspace (hard-bounce rate over 5% or complaint rate over 0.1% in the last 7 days, or an operator). Receiving keeps working and queued mail waits. Clean the recipient list, then press **Request review** on the dashboard banner. See [Sending health](https://agentboxd.com/docs/send-refusals#sending-health). |
| `403 org_suspended` | The workspace is suspended (clear abuse, a second pause within 30 days, or an operator). Queued mail is not sent. Request a review on the dashboard banner, or write to [support@agentboxd.com](mailto:support@agentboxd.com). |
| `403 temporary_inbox_receive_only` | Send or reply from a temporary inbox. They only receive. |
| `423 inbox_paused` | The inbox is paused. Resume it with `POST /v1/inboxes/:id/resume` (or in the dashboard) to send again. |
| `423 workspace_stopped` | The workspace is under an [emergency stop](https://agentboxd.com/docs/emergency-stop): no sends, identity tokens or scheduled sends until an owner resumes it in the dashboard. Don’t retry. |
| `403 domain_not_verified` | Send from an inbox whose domain failed its DNS checks. Fix the records and check the domain again. |
| `409 domain_taken` · `409 domain_in_use` | The domain is verified by another workspace; or you tried to delete a domain that inboxes still use. |
| `413` | Message or attachment over the size limit. |
| `422 attachment_infected` | An attachment contains malware (the [Customs](https://agentboxd.com/customs) virus scan); the message names the file and the signature. Nothing was sent. |
| `503 scanner_unavailable` | The virus scanner could not check the attachments. Retry shortly. |
| `422 recipient_suppressed` | The address hard-bounced or complained after mail from your workspace. |
| `422 invalid_webhook_url` | The webhook URL isn’t a public HTTPS endpoint: it uses `http://`, contains a username or password, uses a port other than 443 or 1024–65535, or points at a private or reserved address or an internal name. See [Webhooks](https://agentboxd.com/docs/webhooks#endpoint-requirements). |
| `402 plan_limit_emails` | The plan’s monthly emails are used up. Receiving keeps working. See [Plans and limits](https://agentboxd.com/docs/plans) for every plan error. |
| `403 plan_limit_inboxes` | The plan’s inboxes are all in use. |
| `429 daily_send_limit_exceeded` | This inbox’s daily send limit reached. Resets at 00:00 UTC. |
| `429 org_daily_limit_exceeded` | The workspace’s daily send cap (from its plan) reached. Resets at 00:00 UTC. A new workspace’s cap is 20 for its first 3 days, on any plan (`new_workspace_limits_until` on the org). |
| `429 rate_limited` | Too many requests for this API key in a minute; or, on send, reply and draft send, the workspace’s burst limit (sends per 5 minutes, from its plan). The burst error has `details: { limit: "sends_per_5min", max, window_seconds, retry_after_seconds }` and a `Retry-After` header. Wait that long, then retry. |
| `400 draft_incomplete` | A draft was sent without a recipient or any text (`details.missing`). |
| `400 invalid_send_at` | `send_at` is less than 1 minute or more than 30 days away. |
| `422 attachment_missing` | A file on the draft is no longer stored, so the draft can’t be sent (`details.attachment_id`). Attach the file again. A scheduled draft ends `failed`. |
| `409 draft_not_editable` · `409 draft_already_sent` | The draft’s status doesn’t allow that change (`details.status`), or it was already sent (`details.sent_message_id`). |
| `409 draft_changed` | The draft changed since the version you named in `expected_content_sha256` (or someone scheduled or unscheduled it while you sent it). Nothing happened. Read the draft again and review it. |
| `429 temporary_inbox_limit` | 25 temporary inboxes are already active in the workspace. Creating them is also limited to 60 an hour. |
| `503 llm_unavailable` | The drafting model didn’t answer. Nothing was sent; try again later. |
| `429 unclaimed_recipient_limit` | An agent-created workspace that no one has claimed yet sent to its 20 different recipients for the day. Replies to people who emailed the agent in a thread they started still work. A claim lifts it; see [Agent self-signup](https://agentboxd.com/docs/agent-signup). |
| `403 unclaimed_workspace` | Webhooks, custom domains, temporary inboxes and identity tokens need a claimed workspace. |
| `429 signup_rate_limited` · `429 unclaimed_limit_reached` · `503 signup_disabled` | Too many self-signups from one IP address or network, too many unclaimed workspaces from the last 7 days held by one, or self-signup is off on this server. |
| `400 too_many_labels` | A label change would leave a message or thread with more than 100 labels (`details.max`). Labels Agentboxd sets count too. Nothing was changed: remove some labels first. Removing labels always works. |
| `409 lease_expired` | An ack, nack or extend presented a `lease_id` that is no longer the message’s current lease: it ran out and the message was claimed again, or it was released or dead-lettered. The work may be done twice; see [Claim and ack](https://agentboxd.com/docs/api#claim-and-ack-work-queue). |
| `409 extraction_pending` · `409 extraction_unavailable` | Structured extraction on an attachment whose text isn’t ready yet, or has no text (see [Document extraction](https://agentboxd.com/docs/document-extraction)). |
| `400 invalid_schema` · `422 structured_output_invalid` | A custom extraction schema was refused; or the model’s answer didn’t match the schema after one retry (`details.errors`). |
| `402 plan_limit_structured_extractions` | The plan’s structured extractions for this period are used up (Free includes none). |
| `403 identity_disabled` · `400 unknown_audience` | An identity token was asked for an inbox whose sign-in is off, or for an app (`audience`) that doesn’t exist or is disabled. |
| `422 identity_only` | Send, reply or drafts on an [identity without a mailbox](https://agentboxd.com/docs/agent-identity#identity-without-a-mailbox): it has no mail. The claim queue too, unless [agent messaging](https://agentboxd.com/docs/agent-messaging) is on (identities then receive native agent messages). |
| `422 invalid_agent_signature` | The `agent_signature` on a send or reply doesn’t verify: `details.reason` is `malformed`, `wrong_type`, `unknown_key`, `key_retired`, `key_revoked`, `bad_signature`, `wrong_sender`, `stale` (older than 5 minutes), `replayed` or `content_mismatch` (`details.field`). See [Author signatures](https://agentboxd.com/docs/agent-directory#author-signatures). |
| `400 invalid_key` · `422 invalid_proof` · `409 key_exists` · `422 too_many_keys` | Registering an [agent key](https://agentboxd.com/docs/agent-directory#agent-held-keys): not a public Ed25519/P-256 key, the proof of possession failed (`details.reason`), the key was registered before, or the agent already has 3 active keys or 20 keys in all. |
| `503 agent_keys_disabled` · `503 public_directory_disabled` | Agent-held keys, or the public directory and handles, are not on for this server yet. |
| `400 invalid_handle` · `409 handle_taken` · `409 handle_reserved` · `422 workspace_handle_required` · `422 handle_not_found` | [Handles](https://agentboxd.com/docs/agent-directory#handles): wrong syntax; taken or used before (handles are never reused); reserved (`details.reason`: `reserved` or `domain`); an agent handle before the workspace has one; a recipient handle nobody answers to (`details.handles`). |

## Dashboard and account API

The dashboard at agentboxd.com uses these routes with a session cookie (`cm_session`) instead of an API key. Requests that change data (POST, PATCH, DELETE) must send `X-Requested-With: agentboxd-web`, or they get `403 csrf`. You don’t need them to build an agent; they are listed so nothing the dashboard does is hidden.

| Method & path | Notes |
| --- | --- |
| `POST /platform/auth/magic-link` | `{ email }`. `204` whether or not an account exists. Emails a sign-in link from login@agentboxd.com, valid 15 minutes, usable once. At most 3 links an hour to one address and 20 an hour from one IP: over that, `429 rate_limited` with `Retry-After`. |
| `POST /platform/auth/verify` | `{ token }` from the link. `200 { user, org }` and sets the session cookie; `400 invalid_token` if the link expired or was used. The link opens a page with a Continue button, and only the button calls this: opening the link signs nobody in, so a mail scanner that opens it doesn’t use it up. |
| `GET /platform/auth/github` | Starts GitHub sign-in (`?next=/app`). Not enabled yet: it redirects to `/login?error=github_unavailable`. |
| `POST /platform/auth/logout` | `204`. Ends the session. |
| `POST /platform/auth/logout-all` | `204`. Ends every session of your account, on every device, this one included. A session also ends 90 days after its sign-in, however often it is used. |
| `GET /platform/account/deletion` | `{ workspaces: [{ id, name, role, members, outcome }], blocked }`: what deleting your account would do to each workspace you belong to. `delete`: you are its only member, so it is deleted with your account. `leave`: you leave it and it keeps working. `blocked`: you are its last owner and others use it (`blocked_by: "last_owner"`), or it would be deleted but has a paid subscription that still renews (`"subscription"`) or runs Inbox Heist (`"game_workspace"`). |
| `POST /platform/account/delete` | `{ confirm_email }` (your address, typed) → `{ deleted_workspaces, left_workspaces, sessions }`. Deletes your account: you leave shared workspaces (`member.removed`, reason `account_deleted`; the API keys you created stay with them) and every workspace you are the only member of is deleted: its API keys are revoked, its inboxes, mail, files, webhooks, domains, contacts and settings are removed, inbox addresses stay reserved. Every session ends, your mail app (IMAP) passwords stop working and a confirmation email goes to your address. `400 confirmation_mismatch`; `409 last_owner` (with `details.workspaces`) when you are the last owner of a workspace others use: make someone else an owner first; `409 subscription_active` when a workspace that would be deleted has a paid subscription that still renews: cancel it under Manage billing first; `409 game_workspace` when a workspace that would be deleted runs Inbox Heist: invite someone to it and make them an owner first. You can sign up again with the same address later, as a new account. |
| `GET /platform/me` | `{ user, org, role, workspaces }`, or `401 unauthorized`. `role` is yours in this workspace (`owner` or `member`); `workspaces` lists every workspace you belong to as `{ id, name, role }`, oldest first. The org includes `status`, `daily_send_limit`, `new_workspace_limits_until`, `ai_processing`, `retention_days`, `identity_share_workspace`, `local_processing`, `ai_disclosure`, `emergency_stopped_at`, `emergency_stop_reason`, and `sending_paused_at` and `sending_paused_reason` while [sending is paused](https://agentboxd.com/docs/send-refusals#sending-health). |
| `POST /platform/session/workspace` | `{ org_id }` → `{ user, org, role, workspaces }`. Moves this session to another workspace you belong to (the account menu’s switcher); `404` if you are not a member. Sign-in opens the workspace you joined first. |
| `GET /platform/members` | Everyone in the workspace, oldest first: `{ data: [{ user_id, email, name, role, joined_at, api_keys }] }`. `api_keys` counts the live keys the person created; they stay with the workspace if the person leaves. Every member can read it. |
| `PATCH /platform/members/:userId` | `{ role: "owner" \| "member" }` → `{ member }`. Owners only (`403 forbidden`). A workspace keeps at least one owner: demoting the last one is `409 last_owner`. Making an owner a member also revokes the invites they sent that were not accepted yet. Emits `member.role_changed`, and `member.invite_revoked` for each invite revoked. |
| `DELETE /platform/members/:userId` | `204`. An owner removes someone, or you leave (your own id). Their sessions in this workspace end at once (their event streams of it within two minutes), the mail app (IMAP) passwords they created there stop working and the invites they sent that were not accepted yet are revoked; the API keys they created stay, and the keys list still shows who created them. The last owner can’t leave or be removed (`409 last_owner`). Emits `member.removed`, and `member.invite_revoked` for each invite revoked. |
| `GET /platform/invites` | Invites not yet accepted or revoked, newest first: `{ data: [{ id, email, role, status: "pending" \| "expired", invited_by, created_at, sent_at, expires_at }] }`. Every member can read it. |
| `POST /platform/invites` | `{ email, role?: "member" \| "owner" }` → `201 { invite }`. Owners only, so only owners invite owners. Emails a link from login@agentboxd.com, valid 7 days and usable once; only a hash of it is stored. Inviting an address again gives it a new link and 7 more days. Members plus pending invites count as seats: over the plan’s seats, `403 plan_limit_seats`. `409 already_member`; `403 unclaimed_workspace` for an agent-created workspace no person claimed; `403 org_suspended` while the workspace is suspended; at most 20 invite emails a day per workspace and 3 per address (`429 rate_limited`). Emits `member.invited`. |
| `POST /platform/invites/:id/resend` | `{ invite }`. Owners only. A new link (the old one stops working) and 7 more days; counts toward the same daily limits, and is refused the same way while the workspace is suspended. Emits `member.invited` with `resent: true`. |
| `DELETE /platform/invites/:id` | `204`. Owners only. The link stops working and the seat is free again. Emits `member.invite_revoked`. |
| `POST /platform/invites/preview` | `{ token }` from an invite link → `{ workspace: { name }, role, email, invited_by, expires_at, signed_in_as, email_matches }`. Public; consumes nothing. `400 invalid_token` if the link expired, was used or revoked, or the person who sent it is no longer an owner of the workspace. |
| `POST /platform/invites/accept` | `{ token }` → `200 { user, org, role }` and the session cookie. Public but always needs `X-Requested-With: agentboxd-web`. The link proves you own the address, like a sign-in link: you are signed in to the workspace, and a new address gets an account (with no workspace of its own). With a session for another address: `403 invite_email_mismatch`. The seat is checked again (`403 plan_limit_seats`). `400 invalid_token` for the same reasons as the preview. The invite page calls it only when you press Accept. Emits `member.joined`. |
| `GET /platform/api-keys` | API keys: name, prefix, created, last used, revoked, `expires_at` (null = never), `created_by`, `scope`, `inbox_id`, `inbox_address`, `permissions` and the matching `preset` (or `custom`). Never the secret. `created_by` is `{ email, member }`: the person who created the key, and `member: false` once they are no longer in the workspace (their keys keep working until revoked). `email` is null for a deleted account; `created_by` is null for a key no person created, such as the one made at sign-up. |
| `POST /platform/api-keys` | `{ name?, scope?: "org" \| "inbox", inbox_id?, preset? \| permissions?, expires_at? }`. `expires_at` is an ISO 8601 time with its offset, in the future and at most 1,830 days away; null or left out, the key never expires. Without a name the key is called something like `key-7f3a`; without a preset or permissions it gets full access. `201 { api_key, key }`: the full key is shown this once. See [API keys and permissions](https://agentboxd.com/docs/api#api-keys-and-permissions). |
| `DELETE /platform/api-keys/:id` | Revokes the key. `204`. |
| `GET /platform/usage` | `?days=30` (up to 90). Sends and receives today, the limits, the inbox count and a daily series. |
| `GET /platform/messages/:id/hidden-text` | What a received HTML email hides from a person (CSS, hidden attributes, comments, alt text), as plain text: `{ text, chars, truncated, kinds }`, at most 5,000 characters. The dashboard’s “View hidden content”. Sessions only. |
| `GET /platform/identity/authorization-requests/:id` | The consent page of a browser sign-in: `{ request: { id, client: { client_id, name, homepage_url, redirect_host }, scopes, expires_at }, inboxes: [{ id, address, display_name, identity_enabled }] }`. `404` once the request expired (10 minutes) or was answered. |
| `POST /platform/identity/authorization-requests/:id` | `{ decision: "approve", inbox_id }` or `{ decision: "deny" }` → `{ redirect_to }`: the app’s registered `redirect_uri` with `code`, `state` and `iss`, or `error=access_denied`. Once per request. See [Sign in with Agentboxd](https://agentboxd.com/docs/agent-identity#browser-sign-in). For an MCP connector request (`kind: "connector"`) the body is `{ decision: "approve", preset?: "standard" \| "full" \| "read_only", inbox_id? }`; see [MCP server](https://agentboxd.com/docs/mcp#hosted-connector-recommended). |
| `GET /platform/connectors` | Connected MCP apps (the hosted connector): `{ data: [{ id, client_id, client_name, app_host, registration, preset, permissions, scope, inbox_id, inbox_address, created_by, created_at, last_used_at, expires_at }] }`, live connections only. |
| `DELETE /platform/connectors/:id` | Disconnects the app at once: its next MCP request gets `401` and its refresh token stops working. `204`; `404` if it is not a live connection of this workspace. |
| `GET /platform/inboxes/:id/imap` | Reading an inbox in a mail app ([IMAP](https://agentboxd.com/docs/imap)): `{ enabled: false }` when this server has IMAP off, else `{ enabled: true, settings: { host, port, security, username }, passwords: [{ id, name, prefix, created_by, created_at, last_used_at, expires_at, revoked_at, status }] }`. `created_by` is the email of the person who created it (null once their account is deleted). Never a password. Every member can read it. |
| `POST /platform/inboxes/:id/imap/passwords` | `{ name?, expires_at? }` → `201 { app_password, password, settings }`. Owners only (`403 forbidden`). The password (`ap_…`) is in this response only; we keep a hash. At most 10 live app passwords per inbox (`409 too_many_app_passwords`); `404 imap_disabled` while IMAP is off. |
| `DELETE /platform/inboxes/:id/imap/passwords/:passwordId` | `204`. Owners only. The password stops working at once; a mail app still connected with it is cut off within two minutes. |
| `POST /platform/claim/preview` | `{ token }` from a claim link → `{ workspace, inbox_address, email, expires_at, signed_in_as, email_matches }`. Consumes nothing; `400 invalid_token` if the link expired, was used or the workspace is already claimed. |
| `POST /platform/claim/confirm` | `{ token }` → `200 { user, org }`. Makes the person the owner of the agent’s workspace and signs them in to it (the link proves they own the address). Always needs `X-Requested-With: agentboxd-web`. With a session for another address: `403 claim_email_mismatch`. |
| `POST /platform/waitlist` | The early-access form on [aSIM](https://agentboxd.com/asim). `{ product: "agent-network", name, email, company?, use_case }` → `202 { status: "received" }`. Public, no session, but always needs `X-Requested-With: agentboxd-web`. The entry is emailed to hello@agentboxd.com and not stored; at most 5 an hour per IP and 3 a day per address (`429 rate_limited`). |
| `POST /platform/subprocessor-updates/subscribe` | The “Notify me of changes” form on [Sub-processors](https://agentboxd.com/legal/subprocessors). `{ email }` → `202 { status: "received" }` and a confirmation link from login@agentboxd.com (double opt-in). Public, no session, needs `X-Requested-With: agentboxd-web`. The same answer whether or not the address is already subscribed; at most 5 an hour per IP and 3 a day per address (`429 rate_limited`). |
| `POST /platform/subprocessor-updates/confirm` | `{ token }` from the confirmation link → `200 { status: "confirmed" }`. Single use; expires after 7 days (`400 invalid_token`). |
| `POST /platform/subprocessor-updates/unsubscribe` | `{ token }` from the link in every notice → `200 { status: "unsubscribed" }`. Works any number of times; the stored address is deleted at once. |
| `POST /platform/emergency-stop` | `{ reason? }` → `{ org }`. Owners only. Stops every agent in the workspace, like `POST /v1/emergency-stop`; see [Emergency stop](https://agentboxd.com/docs/emergency-stop). |
| `POST /platform/emergency-stop/resume` | `{ org }`. Owners only: the only way to lift an emergency stop. Emits `workspace.resumed`. |
| `POST /platform/forwarding/confirm` | `{ token }` from the link in the forwarding confirmation email → `{ confirmed: true, address, inbox, workspace }`. Public (the destination may have no account) but always needs `X-Requested-With: agentboxd-web`. Links last 7 days and work once (`400 invalid_token`). |
| `POST /platform/escalation/confirm` | `{ token }` from the link in the on-call confirmation email → `{ confirmed: true, email, workspace, inbox }`. Public (the contact may have no account) but always needs `X-Requested-With: agentboxd-web`. Links last 7 days and work once (`400 invalid_token`). |
| `GET /platform/org/review` | `{ reviewable, review }`. `reviewable` is `sending_paused` or `suspended` when a review can be asked for, else `null`; `review` is the latest request (`{ id, state, status, what_happened, what_fixed, created_at, decided_at, decision_note }`, `status` `open`, `resumed` or `declined`) or `null`. See [Sending health](https://agentboxd.com/docs/send-refusals#sending-health). |
| `POST /platform/org/review` | `{ what_happened, what_fixed }` (10 to 2,000 characters each) → `201 { review }`. Owners only. Asks a person at Agentboxd to lift a sending pause or a suspension. `409 nothing_to_review` when sending works, `409 review_requested` while one is open, `429 rate_limited` after 3 in 7 days. |
| `PATCH /platform/org` | `{ name?, ai_processing?, retention_days?, identity_share_workspace?, local_processing?, screened_only?, ai_disclosure? }`. `ai_disclosure: { visible_line?: "off" \| "text" \| "html", text?: string \| null, share_workspace_name?: boolean }` is merged with the current settings; see [AI disclosure](https://agentboxd.com/docs/ai-disclosure). Owners only (`403 forbidden` otherwise). `ai_processing` is `off`, `categorize` or `full`. `retention_days` is 1 to 3650 days, capped at your plan’s retention, or `null` for the plan default. See [Retention](https://agentboxd.com/docs/plans#retention). `identity_share_workspace: true` lets apps that ask for the `workspace` scope receive the workspace name and id. `local_processing: false` stops attachment text extraction and OCR on our servers for new mail (default `true`; see [Document extraction](https://agentboxd.com/docs/document-extraction#local-processing)). `screened_only` turns [screening](https://agentboxd.com/docs/api#screening) of agent mail on or off (on for new workspaces, off for workspaces created before it shipped). |
