# API · Inboxes

{/* // /v1/inboxes */}

All inbox endpoints require an agent key with the relevant permissions. An inbox's **canonical key is its opaque `id`**
(`pmbx_…`); treat it as an opaque string. The `{inbox_id}` path slot accepts either that id **or** the
inbox's email **address** as a within-project alias (URL-encode the address:
`agent7%40extrovertmail.com`). Every inbox belongs to one **org** and one **project**, and the response
carries both (`org_id`, `project_id`) plus an `object: "inbox"` discriminator.

Scope is in the **key** and narrowed by the **path**, never by a custom scope header. The canonical
project-prefixed form is `/v1/projects/{project_id}/inboxes/{inbox_id}`. The bare
`/v1/inboxes/{inbox_id}` form resolves to the key's bound project. A project or inbox key sees only its own project. An **org** key
can narrow to any project in its subtree or list the whole subtree via the wildcard
`/v1/projects/-/inboxes`. See the [project-prefixed form](#project-prefixed-form) below.

## `POST /v1/inboxes`: create

Requires `mailbox:create`. Send an `Idempotency-Key` header to make the create exactly-once.

Omitting `domain` selects the platform shared domain for the account's plan. Paid accounts use
`extrovertmail.com`. Free accounts use `free.extrovertmail.com` when free signup is enabled.

### Request

```json
{
  "username": "support",          // optional; omit for a generated local part
  "display_name": "Support Bot",  // optional
  "webhook_url": "https://my-agent.example.com/inbound", // optional
  "metadata": { "team": "support", "tier": 2, "vip": true }, // optional
  "return_credentials": false     // optional; default false
}
```

| Field | Type | Notes |
|---|---|---|
| `username` | string? | Local part. Omit for a generated one. Shared-domain names are normalized, must contain at least five characters, and cannot use a reserved name. |
| `domain` | string? | Must be within the key's allowed domains. Omit for the plan's platform shared domain. |
| `display_name` | string? | Friendly name on outbound mail. |
| `webhook_url` | string? | Register an inbound webhook for this inbox. |
| `metadata` | object? | Arbitrary key-value state stored on the inbox (see [Inbox metadata](#inbox-metadata)). |
| `project_id` | string? | Optional assertion that must match the key's bound project. It never selects a different project. |
| `client_id` | string? | Body alias for `Idempotency-Key`. Prefer the header. |
| `return_credentials` | boolean? | Default `false`. A `true` value requires a paid account and `mailbox:credentials`, and returns the password only on a newly created inbox. |

For `extrovertmail.com` and `free.extrovertmail.com`, Extrovert normalizes the local part to
lowercase and removes spaces and unsupported characters before validation. The normalized value must
contain at least five characters. These exact values are reserved:

`postmaster`, `admin`, `webadmin`, `legal`, `fraudmark`, `fraudmarc`, `keith`,
`melissa`, `richard`, `sydney`, `syd`, `john`, and `johnny`.

```bash frame="terminal"
# bare form (resolves to the key's bound project)
curl -sS -X POST "$EXTROVERT_API_BASE_URL/v1/inboxes" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "display_name": "Support Bot" }'

# canonical project-prefixed form (an org key MUST name the project)
curl -sS -X POST "$EXTROVERT_API_BASE_URL/v1/projects/prj_9a8b/inboxes" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "display_name": "Support Bot" }'
```

### Response `201`

```json
{
  "object": "inbox",
  "id": "pmbx_8f3c2a1b",
  "org_id": "org_1f2e",
  "project_id": "prj_9a8b",
  "address": "agent7@extrovertmail.com",
  "agent_id": "agent_Q1x",
  "display_name": "Support Bot",
  "smtp_host": "smtp.extrovert.dev",
  "smtp_port": 587,
  "imap_host": "smtp.extrovert.dev",
  "imap_port": 993,
  "daily_send_limit": 75,
  "direct_smtp_enabled": false,
  "metadata": {},
  "created_at": "2026-06-18T18:04:11Z"
}
```

The opaque `id` (`pmbx_…`) is the canonical key for subsequent calls. The `address` is its
within-project alias.
**Credentials are opt-in:** Ordinary create calls do not return a password. Set `return_credentials: true` only when the paid
  account and key have the required credential-export permission. The new inbox response then includes
  the password once. Idempotent reuse does not return it. The credentials endpoint remains available
  to eligible paid accounts.
**Counts against the quota:** Each successful create increments the enrollment token's `used_count`. At `max_mailboxes` the next
  create returns `403 quota_exceeded`. Deleting an inbox does **not** refund the slot.
**x402 paid actions return 402 first:** When an org has paid provisioning enabled, `POST /v1/inboxes?paid=true` (or
  [`POST /v1/purchase`](https://docs.extrovert.dev/payments/x402-test-mode/)) answers `402 Payment Required` with a
  `PAYMENT-REQUIRED` challenge. Sign the EIP-3009 authorization and retry. Provisioning is gated on
  settlement, not on receipt of the signed payload.

## `GET /v1/projects/{project_id}/inboxes`: list

Requires `mailbox:read`. Returns the [one list envelope](https://docs.extrovert.dev/api/overview/#list-pagination--cursors).
Paginate with `?limit=` (1–100, default 50) and the opaque `next_cursor` (`?cursor=`).

```bash frame="terminal"
# project key (or bare /v1/inboxes sugar) → that project's inboxes
curl -sS "$EXTROVERT_API_BASE_URL/v1/projects/prj_9a8b/inboxes?limit=20" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY"

# org key, org-wide subtree list
curl -sS "$EXTROVERT_API_BASE_URL/v1/projects/-/inboxes?limit=20" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY"
```

```json
{
  "object": "list",
  "data": [
    {
      "object": "inbox",
      "id": "pmbx_8f3c2a1b",
      "org_id": "org_1f2e",
      "project_id": "prj_9a8b",
      "address": "agent7@extrovertmail.com",
      "agent_id": "agent_Q1x",
      "direct_smtp_enabled": false,
      "created_at": "2026-06-18T18:04:11Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

The bare `GET /v1/inboxes` form is sugar that resolves to a project key's bound project. An **org** key
on the bare list returns `400 breadth_required` (pick `/v1/projects/{id}/inboxes` or the wildcard
`/v1/projects/-/inboxes`); a non-org key on the wildcard is `403 forbidden_scope`.

## `GET /v1/inboxes/{inbox_id}`: get

Requires `mailbox:read`. `{inbox_id}` is the opaque id or the URL-encoded address. The response carries
the inbox's `metadata` object (`{}` when none is set).

```bash frame="terminal"
# by opaque id
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY"

# by address (within-project alias), URL-encoded
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY"

# canonical project-prefixed form
curl -sS "$EXTROVERT_API_BASE_URL/v1/projects/prj_9a8b/inboxes/pmbx_8f3c2a1b" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY"
```

### `effective_review_policy`: know before you send

The single-inbox `GET` (not the list) carries `effective_review_policy`: the **resolved** review policy
for this inbox. It uses the per-inbox override, then the account default, then the `require_review` floor.

| Value | What a `send` / `reply` / `forward` does |
|---|---|
| `require_review` | Queued for a human. **Without an `intent` the request is rejected `422 intent_required`** and nothing is sent or queued. With one: `202 queued_for_review`. |
| `allow_direct` | A bare send goes out immediately (`202 {status:"sent", …}`); a send that carries `mode` / `intent` / `category_id` is still queued. |
| `auto_send_graduated` | A send in a graduated category that clears every gate auto-sends; anything else is queued, with `gate_outcome: "held:<reason>"` recording why. An `intent` is still required. |

Read it at startup so the agent can include the required intent and follow the correct send flow.

```bash frame="terminal"
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY" | jq -r .effective_review_policy
# require_review
```

The policy is a human setting in the console, with an account default and optional per-inbox override.
An agent can read the policy but cannot widen it.

## `PATCH /v1/inboxes/{inbox_id}`: update

Changing `display_name`, `webhook_url`, or metadata requires `mailbox:create`. Changing
`daily_send_limit` requires `mailbox:quota`. Every field is optional, and an omitted field remains
unchanged. An empty display name or webhook URL clears that setting.

### Request

```json
{
  "display_name": "Support",                 // optional
  "webhook_url": "https://…/inbound",        // optional; "" clears it
  "daily_send_limit": 250,                    // optional; requires mailbox:quota
  "metadata": { "tier": 3, "vip": null }     // optional; merge; see below
}
```

| Field | Type | Notes |
|---|---|---|
| `display_name` | string? | Sender / "From" name. Propagated to the inbox and the authenticated sender. |
| `webhook_url` | string? | Replace the inbound webhook target; empty string clears it. |
| `daily_send_limit` | integer? | Rolling 24-hour recipient cap, from 1 through 10,000. Requires `mailbox:quota`. |
| `metadata` | object \| null? | Shallow-merge patch of the inbox metadata (see [Inbox metadata](#inbox-metadata)). |
| `project_id` | string? | Optional assertion that must match the key's bound project. |

```bash frame="terminal"
curl -sS -X PATCH "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "display_name": "Support", "metadata": { "tier": 3 } }'
```

The `200` response is the updated inbox, including its current `metadata`.

## Inbox metadata

Every inbox carries an arbitrary key-value `metadata` object for state such as a routing tag, ticket
id, or tier. It is returned on
create / get / list / update and is **project-scoped**: an agent key can only read or mutate metadata
for inboxes in its own project.

- **Values** may be `string`, `number`, or `boolean`. Nested objects and arrays are rejected (`400`).
- **Caps:** at most 256 keys per inbox; each key ≤256 chars; each string value ≤256 chars.
- **Create** echoes the metadata back on the response, and an idempotent retry replays the original
  metadata-bearing create result.
- **Update is a shallow merge.** Omit `metadata` to leave it unchanged. Send an object to merge keys,
  set one key to `null` to delete it, or set top-level `metadata` to `null` to clear everything.
- Responses always return an object. After a clear, the value is `{}`, not `null`.

```bash frame="terminal"
# set two keys
curl -sS -X PATCH "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY" -H "Content-Type: application/json" \
  -d '{ "metadata": { "team": "support", "tier": 2 } }'

# bump one, delete the other (null), leave the rest untouched
curl -sS -X PATCH "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY" -H "Content-Type: application/json" \
  -d '{ "metadata": { "tier": 3, "team": null } }'

# clear everything → metadata becomes {}
curl -sS -X PATCH "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY" -H "Content-Type: application/json" \
  -d '{ "metadata": null }'
```

## `GET /v1/inboxes/{inbox_id}/credentials`: connection settings

Requires the dedicated `mailbox:credentials` scope and a paid plan. Free accounts cannot export
portable IMAP/SMTP credentials, even when the key has that scope. The response contains the host,
port, security mode, username, and password for a standard mail client. The API re-checks ownership
before it opens the stored credential.

Credentials do not authorize direct SMTP submission. They allow IMAP access, but SMTP remains blocked
unless a human administrator enables `direct_smtp_enabled` for this inbox and the account retains
paid entitlement. Agent keys can read the field but cannot change it. API, SDK, and MCP sends continue
through the Review Loop regardless of this setting.

```bash frame="terminal"
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b/credentials" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY"
```

```json
{
  "address": "agent7@extrovertmail.com",
  "username": "agent7@extrovertmail.com",
  "password": "…",
  "imap": { "host": "smtp.extrovert.dev", "port": 993, "security": "tls" },
  "smtp": { "host": "smtp.extrovert.dev", "port": 587, "security": "starttls" }
}
```

## `DELETE /v1/inboxes/{inbox_id}`: delete

Requires `mailbox:delete` or `mailbox:create`. This permanently deletes the inbox and its authenticated
sender. The operation cannot be reversed.

```bash frame="terminal"
curl -sS -X DELETE "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY"
```

Returns `204 No Content` on success.

## `POST /v1/inboxes/{inbox_id}/send`: send

Requires `mailbox:send`. Send an `Idempotency-Key` header to make the send exactly-once.
**A human reviews it first, by default:** Every agent-plane `send` / `reply` / `forward` is governed by the inbox's
  [`effective_review_policy`](#effective_review_policy-know-before-you-send). Accounts start at
  `require_review`, so **the normal outcome of a send is `202 queued_for_review`, not delivery**; the
  message waits for a human to approve, edit or reject it. Include an `intent` on every send: without
  one, a `require_review` inbox rejects the request `422 intent_required` and nothing is sent or
  queued. The agent plane cannot bypass this policy.

### Request

```json
{
  "to": ["ops@acme.test"],          // at least one recipient required
  "subject": "agent online",
  "text": "Reporting in.",          // plain text; the canonical field name
  "intent": {                       // the human reviewer's context; required under require_review
    "summary": "Tell ops the deploy agent is live and give them the on-call address",
    "meta": { "goal": "notify", "recipient": "ops@acme.test", "urgency": "normal" }
  },
  "in_reply_to": "<orig@acme.test>",// optional; Message-ID to thread under
  "attachments": [                  // optional; carried through review to delivery
    { "filename": "report.pdf", "content_type": "application/pdf", "content_base64": "…" }
  ]
}
```

| Field | Notes |
|---|---|
| `text` | The plain-text part. **Canonical**; the same name `reply`, `forward`, `submit_revision` and the read-side `Message.text` use. |
| `body` | **Deprecated permanent alias** for `text`. Still accepted, and it will never be removed, but do not write it in new code. Sending both with **different** content is `400 bad_request` with `errors[].code = "conflicting_alias"`; the server never guesses which bytes to relay. Both with identical content is accepted. |
| `intent` | `{ summary, meta? }` for the human reviewer. Required whenever the resolved mode is review. |
| `mode` | `review` \| `direct`. A per-send assertion; the account/inbox policy may downgrade `direct` to `review`, never the reverse. |
| `category_id` / `category_confidence` | Opaque `cat_…` from `list_categories` plus your 0..1 confidence. Feeds the auto-send gate; we never score. |
| `composition_token` | Opaque proof from a fresh, unfiltered `GET /v1/rules` for this agent, project, and category. Required when the current rule policy requires fresh composition. |
| `reply_to`, `headers`, `attachments`, `cc`, `bcc`, `html` | All carried through the review row to delivery; the human reviews, and the recipient receives, the same message. |
| `idempotency_key` | **Deprecated** body-level alias for the `Idempotency-Key` header, tolerated for `@extrovert.dev/sdk` ≤ 0.1.0. Send the header; when both are present the header wins. |

```bash frame="terminal"
curl -sS -X POST "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b/send" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["ops@acme.test"],
    "subject": "agent online",
    "text": "Reporting in.",
    "intent": { "summary": "Tell ops the deploy agent is live and give them the on-call address" }
  }'
```

### Response: three outcomes, all `2xx`

**`202`: queued for a human** (the default under `require_review`, and whenever a gate holds a
would-be auto-send):

```json
{
  "kind": "queued_for_review",
  "review": { "id": "rr_8Tz4kP", "state": "needs_review", "effective_mode": "review" }
}
```

Nothing has been delivered yet. Follow the `rr_…` handle with
[`wait_for_review_event` / `list_review_events`](https://docs.extrovert.dev/review-loop/agent-contract/#nudge-an-element-of-list_review_events)
until you receive a terminal `sent` or `send_failed` event, or poll
`GET /v1/reviews/{id}` and read `closed`.

**`200`: sent immediately, review-loop shape** (returned when you asserted `mode` / `intent` /
`category_id` and the policy permitted a direct or graduated release):

```json
{
  "kind": "sent",
  "message": { "id": "msg_5dRk" },
  "review": { "id": "rr_8Tz4kP", "state": "auto_sent" }
}
```

**`202`: sent immediately, legacy shape** (a bare send against an `allow_direct` inbox). Byte-for-byte
what it always was, plus the additive `review_id`:

```json
{ "status": "sent", "message_id": "msg_5dRk", "review_id": "rr_8Tz4kP" }
```

`review_id` is on **every** agent-plane send now, so an agent that crashes after issuing the request can
call `GET /v1/reviews/{id}` and read `closed` / `sent_message_id` instead of guessing whether the
message went out.

### Rejections worth handling

| Status | `code` | Why |
|---|---|---|
| `422` | `intent_required` | The resolved policy requires a human and you sent no `intent`. **Nothing sent, nothing queued**; the `detail` carries the whole fix. See [Errors](https://docs.extrovert.dev/api/errors/#422-intent_required). |
| `400` | `bad_request` (`conflicting_alias`) | You sent both `text` and `body` with different content. |
| `403` | `recipient_blocked` | A recipient is on the inbox's contact-list block. Checked **before** the intent gate. |
| `422` | `recipient_suppressed` | A recipient opted out; `errors[]` names them. Also checked before the intent gate. |
| `403` | `quota_exceeded` | The plan's outbound recipient quota is exhausted. |
| `503` | `unavailable` | We could not read the review policy, so the send was refused rather than relayed unsupervised. Retryable; honor `Retry-After`. |

Contact-list, suppression and quota are now enforced at **submit** as well as at delivery, so a human
no longer approves a draft that then fails on the way out.

Delivery goes out through the authenticated sender; SPF + DKIM aligned. Sends count against the
inbox's [24h send-rate limit](https://docs.extrovert.dev/concepts/deliverability-and-limits/); over the per-key rate limit returns
`429` with `Retry-After`.

## Project-prefixed form

Every inbox endpoint above has a canonical project-prefixed twin under
`/v1/projects/{project_id}/inboxes/{inbox_id}` (the SDK's `extrovert.projects.inboxes.*` chain). The
same handlers serve both forms; the bare `/v1/inboxes/...` paths are sugar that fill in the key's bound
project.

| Bare (sugar) | Canonical project-prefixed |
|---|---|
| `POST /v1/inboxes` | `POST /v1/projects/{project_id}/inboxes` |
| `GET /v1/inboxes` | `GET /v1/projects/{project_id}/inboxes` (or `/v1/projects/-/inboxes` org-wide) |
| `GET /v1/inboxes/{inbox_id}` | `GET /v1/projects/{project_id}/inboxes/{inbox_id}` |
| `PATCH /v1/inboxes/{inbox_id}` | `PATCH /v1/projects/{project_id}/inboxes/{inbox_id}` |
| `DELETE /v1/inboxes/{inbox_id}` | `DELETE /v1/projects/{project_id}/inboxes/{inbox_id}` |
| `GET /v1/inboxes/{inbox_id}/credentials` | `GET /v1/projects/{project_id}/inboxes/{inbox_id}/credentials` |

- A **project** key may use a concrete `{project_id}` only for its own bound project (any other is
  `404 not_found`).
- An **org** key uses `{project_id}` to narrow to any project in its subtree, or `-` for the org-wide
  wildcard list.
- A create or update body may include `project_id` only as an assertion that it matches the key's bound
  project. It does not select scope. The path remains authoritative.

## Next

- [Messages & threads](https://docs.extrovert.dev/api/messages-and-threads/): reading, replying, forwarding, and attachments
- [wait_for_email](https://docs.extrovert.dev/api/wait/): blocking receive
- [Errors](https://docs.extrovert.dev/api/errors/): status codes and problem+json `code`s