API · 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 below.
POST /v1/inboxes: create
Section titled “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
Section titled “Request”{ "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). |
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.
# 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
Section titled “Response 201”{ "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.
GET /v1/projects/{project_id}/inboxes: list
Section titled “GET /v1/projects/{project_id}/inboxes: list”Requires mailbox:read. Returns the one list envelope.
Paginate with ?limit= (1–100, default 50) and the opaque next_cursor (?cursor=).
# project key (or bare /v1/inboxes sugar) → that project's inboxescurl -sS "$EXTROVERT_API_BASE_URL/v1/projects/prj_9a8b/inboxes?limit=20" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"
# org key, org-wide subtree listcurl -sS "$EXTROVERT_API_BASE_URL/v1/projects/-/inboxes?limit=20" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"{ "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
Section titled “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).
# by opaque idcurl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"
# by address (within-project alias), URL-encodedcurl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/agent7%40extrovertmail.com" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"
# canonical project-prefixed formcurl -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
Section titled “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.
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b" \ -H "Authorization: Bearer $EXTROVERT_API_KEY" | jq -r .effective_review_policy# require_reviewThe 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
Section titled “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
Section titled “Request”{ "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). |
project_id | string? | Optional assertion that must match the key’s bound project. |
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
Section titled “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, orboolean. 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
metadatato leave it unchanged. Send an object to merge keys, set one key tonullto delete it, or set top-levelmetadatatonullto clear everything. - Responses always return an object. After a clear, the value is
{}, notnull.
# set two keyscurl -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 untouchedcurl -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
Section titled “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.
curl -sS "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b/credentials" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"{ "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
Section titled “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.
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
Section titled “POST /v1/inboxes/{inbox_id}/send: send”Requires mailbox:send. Send an Idempotency-Key header to make the send exactly-once.
Request
Section titled “Request”{ "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. |
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
Section titled “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):
{ "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
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):
{ "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:
{ "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
Section titled “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. |
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; over the per-key rate limit returns
429 with Retry-After.
Project-prefixed form
Section titled “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 is404 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_idonly as an assertion that it matches the key’s bound project. It does not select scope. The path remains authoritative.
- Messages & threads: reading, replying, forwarding, and attachments
- wait_for_email: blocking receive
- Errors: status codes and problem+json
codes