Skip to content

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.

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.

{
"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
}
FieldTypeNotes
usernamestring?Local part. Omit for a generated one. Shared-domain names are normalized, must contain at least five characters, and cannot use a reserved name.
domainstring?Must be within the key’s allowed domains. Omit for the plan’s platform shared domain.
display_namestring?Friendly name on outbound mail.
webhook_urlstring?Register an inbound webhook for this inbox.
metadataobject?Arbitrary key-value state stored on the inbox (see Inbox metadata).
project_idstring?Optional assertion that must match the key’s bound project. It never selects a different project.
client_idstring?Body alias for Idempotency-Key. Prefer the header.
return_credentialsboolean?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.

Terminal window
# 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" }'
{
"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=).

Terminal window
# 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"
{
"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.

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

Terminal window
# 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

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.

ValueWhat a send / reply / forward does
require_reviewQueued 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_directA bare send goes out immediately (202 {status:"sent", …}); a send that carries mode / intent / category_id is still queued.
auto_send_graduatedA 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.

Terminal window
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.

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.

{
"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
}
FieldTypeNotes
display_namestring?Sender / “From” name. Propagated to the inbox and the authenticated sender.
webhook_urlstring?Replace the inbound webhook target; empty string clears it.
daily_send_limitinteger?Rolling 24-hour recipient cap, from 1 through 10,000. Requires mailbox:quota.
metadataobject | null?Shallow-merge patch of the inbox metadata (see Inbox metadata).
project_idstring?Optional assertion that must match the key’s bound project.
Terminal window
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.

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

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.

Terminal window
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" }
}

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

Terminal window
curl -sS -X DELETE "$EXTROVERT_API_BASE_URL/v1/inboxes/pmbx_8f3c2a1b" \
-H "Authorization: Bearer $EXTROVERT_API_KEY"

Returns 204 No Content on success.

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

{
"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": "" }
]
}
FieldNotes
textThe plain-text part. Canonical; the same name reply, forward, submit_revision and the read-side Message.text use.
bodyDeprecated 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.
modereview | direct. A per-send assertion; the account/inbox policy may downgrade direct to review, never the reverse.
category_id / category_confidenceOpaque cat_… from list_categories plus your 0..1 confidence. Feeds the auto-send gate; we never score.
composition_tokenOpaque 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, htmlAll carried through the review row to delivery; the human reviews, and the recipient receives, the same message.
idempotency_keyDeprecated 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.
Terminal window
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" }
}'

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.

StatuscodeWhy
422intent_requiredThe resolved policy requires a human and you sent no intent. Nothing sent, nothing queued; the detail carries the whole fix. See Errors.
400bad_request (conflicting_alias)You sent both text and body with different content.
403recipient_blockedA recipient is on the inbox’s contact-list block. Checked before the intent gate.
422recipient_suppressedA recipient opted out; errors[] names them. Also checked before the intent gate.
403quota_exceededThe plan’s outbound recipient quota is exhausted.
503unavailableWe 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.

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/inboxesPOST /v1/projects/{project_id}/inboxes
GET /v1/inboxesGET /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}/credentialsGET /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.