API · Errors
The typed error surface is an RFC-9457 problem+json body,
served as Content-Type: application/problem+json, with a closed, machine-readable code you can
branch on without parsing prose. Everything in the closed enum below arrives
in that shape, including every send, reply, forward, and Review Loop rejection.
The envelope
Section titled “The envelope”{ "type": "https://extrovert.dev/problems/quota_exceeded", "title": "Quota Exceeded", "status": 403, "detail": "mailbox quota exhausted for this enrollment token", "code": "quota_exceeded", "request_id": "req_8Tz4kP"}| Field | Meaning |
|---|---|
type | A stable, dereferenceable URI under https://extrovert.dev/problems/{code}. |
title | Short human summary of the problem class. |
status | The HTTP status code, repeated in the body. |
detail | Human-readable detail for this occurrence (safe to log, not to parse). May be omitted. |
code | Stable, machine-readable code from the closed enum below. Branch on this. |
request_id | Per-request id; quote it in support requests. Echoes any X-Request-Id you send. |
errors | Optional array of { field, code, detail } field-level hints (e.g. breadth_required names the next call). |
The closed code enum
Section titled “The closed code enum”code is a closed set. Adding a member is a contract change, so a client switch on it never
falls through unexpectedly.
code | Typical status | Meaning |
|---|---|---|
bad_request | 400 | Malformed / invalid request (validation, bad cursor, disallowed ?include=). |
unauthorized | 401 | Missing / invalid / expired / revoked credential. |
forbidden_scope | 403 | The key/identity lacks the required scope or tier; e.g. a non-org key on the org wildcard, or a create that would escalate. |
not_found | 404 | No such resource, or one outside your ceiling (a sibling project/org/inbox is 404, never a leak). |
conflict | 409 | An unclassified state conflict. Treat it as wrong_state: do not loop on it. |
idempotency_conflict | 409 | Same Idempotency-Key, different request body. |
breadth_required | 400 | An org key/operator hit a bare list that needs a breadth pick. The errors/detail name the next call (/v1/projects/{id}/... or /v1/projects/-/...). |
quota_exceeded | 403 | Inbox/auto-send/plan quota exhausted. |
rate_limited | 429 | Over the per-key window (see Retry-After). |
domain_not_allowed | 400 | The requested domain is outside the key’s allowed domains. |
recipient_blocked | 403 | The recipient is blocked by the inbox’s own contact-list block, not an unsubscribe. |
recipient_suppressed | 422 | The send was rejected because one or more recipients opted out (List-Unsubscribe). errors names the suppressed addresses; see Unsubscribes & suppression. |
intent_required | 422 | The resolved review policy requires a human, and the send carried no intent. Nothing was sent and nothing was queued; see below. |
wrong_state | 409 | This verb is illegal from the review’s current state, but the draft is still live. |
terminal | 409 | The review is already finished (sent / auto_sent / cancelled). Nothing will ever succeed on it. |
stale | 409 | The revision / version you named is no longer current; a human moved the draft. Nothing was mutated. |
born_stale | 409 | A redraft built against an older rule high-water. Nothing was mutated; you were re-nudged. |
send_needs_reconciliation | 409 | A prior send attempt is unconfirmed and the review is parked for recover-by-Message-ID. Do not resend. |
graduation_locked | 409 | Graduation requested for a category a human pinned at supervised (never_graduate). |
maturity_gate_unmet | 409 | Graduation requested before the rung is earned (more clean approvals / category age needed). |
scope_taken | 409 | A review link already exists for that scope; one link per inbox/account. |
not_configured | 503 | A capability this deployment does not have at all (e.g. self-signup, payments). Not retryable. |
unavailable | 503 | A dependency we could not read, so the request failed closed rather than being served on a guess. Retryable; carries Retry-After. |
domain_unavailable | 409 | The domain could not be purchased/onboarded. |
internal | 500 | Server error. |
A 429 carries a Retry-After header with the number of seconds until the per-key window resets.
503 unavailable also carries this header and is safe to retry.
signup_disabled and signup_gate_unavailable currently use the legacy { error, message } envelope,
not the closed problem+json enum. They mean that free signup is paused or that the signup gate could
not be read safely. Enrollment-token redemption remains available.
The 409 taxonomy
Section titled “The 409 taxonomy”Before this split every Review Loop conflict answered a bare 409 conflict, so a stale compare-and-set,
an illegal verb, and a review a human had already sent were indistinguishable on the wire. A single
if (status === 409) retry handler retried all three, including the one that can never succeed.
Each code below exists because your agent must take a different action on it. Only stale and
born_stale are retryable, and each only a bounded number of times.
code | Means | What to do |
|---|---|---|
stale | The (revision[, version]) you named is no longer current because a human moved the draft. No mutation happened. | Retry, bounded (≤3). Re-read get_review + get_review_feedback, re-apply your edit on top of theirs, and resubmit with the new parent_revision. The problem already carries the current state / revision / version, so the retry needs no extra GET. |
born_stale | Your redraft was built against an older rule high-water. No mutation happened, and you were re-nudged. | Retry, ≤1 per rule high-water. Run get_rules, re-apply the rules, and resubmit. Use restamp_review when nothing needs to change. |
wrong_state | This verb is illegal from the current state, but the draft is still live and some other verb is legal. | Never retry the same verb. Read the state hint and the repeated allowed_action hints and pick a legal one. |
terminal | The review is finished; sent, auto_sent or cancelled. | Stop. A front_run_next review event is waiting on your queue with the outcome. |
send_needs_reconciliation | A prior delivery may have reached the recipient and is parked for recovery by Message-ID. | Do not resend. Poll the review instead. |
idempotency_conflict | Same Idempotency-Key, different request body. | Caller bug. Do not retry with that key. |
conflict | Unclassified. | Treat as wrong_state; do not loop. |
A 409 from the Review Loop carries its recovery facts as repeated errors[] entries, so you never
have to guess:
{ "type": "https://extrovert.dev/problems/wrong_state", "title": "Conflict", "status": 409, "code": "wrong_state", "detail": "this verb is not legal from the review's current state, but the draft is still live; read the state and allowed_action hints and pick a legal verb; do not retry this one", "request_id": "req_7f3a…", "errors": [ { "field": "state", "code": "approved", "detail": "the draft's current state" }, { "field": "revision", "code": "4", "detail": "current revision; use as parent_revision" }, { "field": "version", "code": "11", "detail": "current row version" }, { "field": "allowed_action", "code": "get_review", "detail": "re-read the draft" }, { "field": "allowed_action", "code": "list_review_events", "detail": "wait for the delivery outcome" } ]}allowed_action is derived from the transition table itself, so it can never advertise a verb that
would 409. A terminal problem swaps the CAS keys for sent_message_id, the one fact that
still matters once the message is gone.
422 intent_required
Section titled “422 intent_required”Queueing for human review is the default. Every account starts with require_review. A send,
reply, or forward without an intent is rejected with 422
intent_required. It is the first error most agents will ever see, and the fix is one field, so the
entire remediation lives in detail (the MCP tool surface renders only the message, and would drop
anything that existed only in errors[]).
{ "type": "https://extrovert.dev/problems/intent_required", "title": "Intent Required", "status": 422, "code": "intent_required", "detail": "This inbox requires human review before sending (review policy: require_review, from the account default; no per-inbox override). Nothing was sent and nothing was queued. Retry the SAME request with an `intent` object added: {\"intent\":{\"summary\":\"<one sentence: who you are writing to, what you want, and why now>\"}}. That summary is the first thing the human reviewer reads; 8-200 characters. Optional: intent.meta {goal, recipient, prior_touches, urgency} for reviewer context, and category_id (cat_...) from list_categories. On success you get 202 queued_for_review with a review id (rr_...); then monitor it with wait_for_review_event / list_review_events until you receive a `sent` or `send_failed` event.", "request_id": "req_9f2c41a0b7d34e58", "errors": [ { "field": "intent.summary", "code": "required", "detail": "One sentence for the human reviewer: who / what / why. 8-200 chars." }, { "field": "intent.meta", "code": "optional", "detail": "{goal, recipient, prior_touches, urgency}; improves the reviewer's decision context." }, { "field": "category_id", "code": "optional", "detail": "cat_... from list_categories. A graduated category can auto-send without a human." }, { "field": "policy", "code": "review_policy", "detail": "require_review (source: the account default; no per-inbox override)" }, { "field": "retry_with", "code": "example", "detail": "{\"intent\":{\"summary\":\"Follow up with vp@acme.com on the Q3 pilot; 2 prior touches\"}}" } ]}Three things the body always states, and why each matters:
- The resolved policy and where it came from; “your account defaults to
require_review” and “an operator pinned this inbox” send the human behind your agent to two different console screens. Both are your own settings; nothing cross-tenant leaks. - That nothing was sent and nothing was queued. The gate fires before any row is written, so the retry is safe. There is no phantom review or partial send.
- The exact field to add, its length budget, and what happens next. Splice the JSON from
errors[field="retry_with"].detailinto your original request and re-POST: that is a one-turn fix.
Read effective_review_policy on
GET /v1/inboxes/{inbox_id} once at start-up and you can compose the intent up front instead of
learning about the policy from a rejection.
A blocked or suppressed recipient is checked before the intent gate, so
403 recipient_blocked / 422 recipient_suppressed still win over intent_required; an intent
would not fix those, and answering intent_required first would send you to retry into the same wall.
SDK error classes
Section titled “SDK error classes”The TypeScript SDK throws a typed error for every non-2xx, extending ApiError. Each carries the
problem+json fields; read err.code (the closed enum), err.status, err.detail, and
err.requestId:
import { ApiError, AuthenticationError, // 401 unauthorized PermissionError, // 403; ForbiddenScopeError extends it for forbidden_scope BreadthRequiredError, // 400 breadth_required NotFoundError, // 404 not_found ConflictError, // every 409; branch on err.code for the taxonomy ValidationError, // every 422; including intent_required RecipientSuppressedError, // 422 recipient_suppressed; err.suppressedRecipients PaymentRequiredError, // 402; x402 challenge in err.paymentRequired RateLimitError, // 429 rate_limited; err.retryAfter (seconds) ConnectionError, // network failure before a response TimeoutError, // request timed out / aborted} from "@extrovert.dev/sdk";
try { await extrovert.inboxes.create();} catch (err) { if (err instanceof RateLimitError) { await sleep((err.retryAfter ?? 1) * 1000); } else if (err instanceof ApiError) { console.error(err.status, err.code, err.detail, err.requestId); } else { throw err; }}Every ApiError carries status, code, detail, body, requestId, and
isClientError / isServerError.
The class is the status; err.code is the decision. The whole 409 family lands on
ConflictError and the whole 422 family on ValidationError, so a switch on err.code is what
separates “re-read and retry” from “stop forever”:
try { await extrovert.reviews.revise(reviewId, { parent_revision: rev, ...draft });} catch (err) { if (!(err instanceof ApiError)) throw err; switch (err.code) { case "stale": case "born_stale": return redraftOnTopOfTheirs(reviewId); // bounded retry; nothing was mutated case "terminal": return stop(reviewId); // a front_run_next event carries the outcome case "wrong_state": return pickALegalVerb(err); // never retry this verb; read allowed_action case "send_needs_reconciliation": return poll(reviewId); // never resend case "intent_required": return resubmitWithIntent(); // nothing was sent, nothing was queued default: throw err; }}Retries
Section titled “Retries”GET and DELETE (idempotent) retry automatically on 429 / 5xx / network errors with jittered
backoff that honors Retry-After. For exactly-once writes, send an Idempotency-Key header on the
POST: a retry with the same key replays the original response, and the same key with a different body
is 409 idempotency_conflict. enroll is additionally idempotent on agent_handle.
The header is honored on enroll, inbox create, webhook register, send / reply / forward,
and the review writes /revision, /cancel, /restamp, /chat. A request with no
key is not replayed at all; the middleware is inert without one.
404 vs 403: tenancy and ceilings
Section titled “404 vs 403: tenancy and ceilings”A resource outside your ceiling (another org, a project your key isn’t bound to, a sibling agent’s
inbox under a project/inbox key) returns 404 not_found; Extrovert never confirms the existence of
resources outside your scope. A 403 forbidden_scope means the resource is reachable but your key lacks
the required scope or tier (e.g. a non-org key on the org wildcard, or a create that would
escalate). An org key that must pick a breadth on a bare list gets 400 breadth_required.
- Rate limits & quotas: what triggers
429and which headers to read - x402 test mode: the
402flow - Enrollment: enrollment and signup errors