API / Inboxes
All inbox endpoints require a valid agent key or API-audience connection 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.
For legacy keys, 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.
An explicit connection instead uses its consented resource reach and actions. List and get use the same boundary, including another owner’s inbox when explicitly granted. Selected-inbox reach excludes new inbox creation. See Connections and access.
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? | Sender name for API mail; see sender names. |
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 name for future API mail. Empty string clears it; see sender names. |
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 rechecks the
connection’s resource boundary or legacy key’s ownership ceiling before opening the credential.
Credentials do not authorize direct SMTP submission. They allow IMAP access, but SMTP remains blocked
unless an authorized customer administrator or explicit Full account control connection enables
direct_smtp_enabled for this inbox and the account retains paid entitlement. Ordinary 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: wait for a matching incoming message
- Errors: status codes and problem+json
codes
Sender names
Section titled “Sender names”Set display_name when creating or updating an inbox to choose the name recipients
see on API sends, replies, and forwards. For example, Support Bot appears as
"Support Bot" <support@example.com>. The address and sending permissions do not change.
Use your name or organization, up to 60 Unicode characters after normalization.
International and bilingual names are supported. Spaces are normalized and accents
are stored in canonical Unicode form. Names cannot contain emoji, unsupported invisible characters,
bidirectional controls, embedded email addresses, styled compatibility
letters, excessive combining marks, or fake thread/unread indicators such as Re:
or Support (2). Mixing unrelated scripts within one space-delimited token,
including across punctuation, is rejected. Use one decimal digit system per name.
Parenthetical qualifiers must be balanced, contain a letter, and number at most two.
Contextually valid Persian and Indic join controls are supported; other invisible
characters and variation selectors are not. Legitimate Thai/Lao spellings and
digraph letters are preserved. These checks do not verify that
a sender owns a personal or organization name.
On create, an empty or omitted name defaults to Agent {username}. For example,
maya becomes Agent maya, agent007 becomes Agent 007, and alice_bot becomes
Agent alice-bot. If a generated default cannot pass validation, it falls back to Agent.
Invalid explicit names are rejected. Only generated defaults replace underscores with hyphens;
explicit custom names are validated as entered. On update, omit
display_name (or send null) to leave it unchanged; send "" to clear it and use
a bare address in API mail. The returned value is the normalized name. An invalid
name returns a validation error; correct the name and retry instead of recreating
the inbox. Custom From headers cannot override the inbox identity.
Each review captures its sender name in from_display_name when submitted. The
review detail shows that name alongside the sender address. Updating the inbox affects
new submissions, not messages already awaiting approval.
For SMTP clients, configure the sender name in the client. Extrovert preserves a
valid submitted From name even when it differs from the saved inbox name; a bare
address stays bare. Invalid names are rejected rather than silently rewritten.
The header and envelope addresses must still match the authenticated inbox. SMTP
access follows the inbox’s existing plan and permission requirements.
If a sender name is flagged
Section titled “If a sender name is flagged”A blocked name returns a message explaining where to change it. Update
display_name through the inbox update API, or open Manage inbox -> Sender name
in the console. For SMTP messages, update the sender name in your email client.
Then submit the message again. If the flagged name was saved with a message
already in review, submit a new message with the corrected name; changing
inbox settings does not change that saved review.
A name rejection returns HTTP 400 with an errors hint whose field is
display_name and code is display_name_flagged. Read detail for the recovery
instructions. If sender-name validation is unavailable, the response is HTTP 503;
try again later rather than changing the name.