Skip to content

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.

{
"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"
}
FieldMeaning
typeA stable, dereferenceable URI under https://extrovert.dev/problems/{code}.
titleShort human summary of the problem class.
statusThe HTTP status code, repeated in the body.
detailHuman-readable detail for this occurrence (safe to log, not to parse). May be omitted.
codeStable, machine-readable code from the closed enum below. Branch on this.
request_idPer-request id; quote it in support requests. Echoes any X-Request-Id you send.
errorsOptional array of { field, code, detail } field-level hints (e.g. breadth_required names the next call).

code is a closed set. Adding a member is a contract change, so a client switch on it never falls through unexpectedly.

codeTypical statusMeaning
bad_request400Malformed / invalid request (validation, bad cursor, disallowed ?include=).
unauthorized401Missing / invalid / expired / revoked credential.
forbidden_scope403The 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_found404No such resource, or one outside your ceiling (a sibling project/org/inbox is 404, never a leak).
conflict409An unclassified state conflict. Treat it as wrong_state: do not loop on it.
idempotency_conflict409Same Idempotency-Key, different request body.
breadth_required400An 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_exceeded403Inbox/auto-send/plan quota exhausted.
rate_limited429Over the per-key window (see Retry-After).
domain_not_allowed400The requested domain is outside the key’s allowed domains.
recipient_blocked403The recipient is blocked by the inbox’s own contact-list block, not an unsubscribe.
recipient_suppressed422The send was rejected because one or more recipients opted out (List-Unsubscribe). errors names the suppressed addresses; see Unsubscribes & suppression.
intent_required422The resolved review policy requires a human, and the send carried no intent. Nothing was sent and nothing was queued; see below.
wrong_state409This verb is illegal from the review’s current state, but the draft is still live.
terminal409The review is already finished (sent / auto_sent / cancelled). Nothing will ever succeed on it.
stale409The revision / version you named is no longer current; a human moved the draft. Nothing was mutated.
born_stale409A redraft built against an older rule high-water. Nothing was mutated; you were re-nudged.
send_needs_reconciliation409A prior send attempt is unconfirmed and the review is parked for recover-by-Message-ID. Do not resend.
graduation_locked409Graduation requested for a category a human pinned at supervised (never_graduate).
maturity_gate_unmet409Graduation requested before the rung is earned (more clean approvals / category age needed).
scope_taken409A review link already exists for that scope; one link per inbox/account.
not_configured503A capability this deployment does not have at all (e.g. self-signup, payments). Not retryable.
unavailable503A dependency we could not read, so the request failed closed rather than being served on a guess. Retryable; carries Retry-After.
domain_unavailable409The domain could not be purchased/onboarded.
internal500Server 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.

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.

codeMeansWhat to do
staleThe (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_staleYour 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_stateThis 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.
terminalThe 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_reconciliationA prior delivery may have reached the recipient and is parked for recovery by Message-ID.Do not resend. Poll the review instead.
idempotency_conflictSame Idempotency-Key, different request body.Caller bug. Do not retry with that key.
conflictUnclassified.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.

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"].detail into 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.

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;
}
}

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.

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.