# API · Errors

{/* // TYPED ERRORS · RFC-9457 */}

The typed error surface is an [RFC-9457](https://www.rfc-editor.org/rfc/rfc9457) **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](#the-closed-code-enum) below arrives
in that shape, including every send, reply, forward, and Review Loop rejection.
**Some legacy paths still use the old envelope:** A handful of older handlers (some request-shape `400`s outside the send path, and the console-authenticated
  plane under `/v1/admin/*`) still emit the pre-problem+json envelope
  `{ "error": "invalid", "message": "…" }`, with **no** `code` from the enum. Read `code` when it is
  present and fall back to `status` when it is not. This is what the SDK's `ApiError` does
  for you. New surfaces are problem+json only. The legacy envelope is being retired, never extended.

## The envelope

```json
{
  "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

`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](https://docs.extrovert.dev/concepts/unsubscribes-and-suppression/). |
| `intent_required` | `422` | The resolved [review policy](https://docs.extrovert.dev/review-loop/agent-contract/) requires a human, and the send carried no `intent`. **Nothing was sent and nothing was queued**; see [below](#422-intent_required). |
| `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

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](https://docs.extrovert.dev/review-loop/agent-contract/#nudge-an-element-of-list_review_events) 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:

```json
{
  "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`

**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[]`).

```json
{
  "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`](https://docs.extrovert.dev/api/inboxes/#effective_review_policy-know-before-you-send) 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

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`:

```ts
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":

```ts
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

`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.
**The replay key hashes the raw body:** The stored key is scoped to a hash of the **bytes you sent**, not to their meaning. So the same
  logical message retried once with `text` and once with the deprecated `body` alias under one
  `Idempotency-Key` is a body mismatch and answers `409 idempotency_conflict`. Pick one spelling
  (`text`) and keep it for the life of the key.

## `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`.

## Next

- [Rate limits & quotas](https://docs.extrovert.dev/operating/limits/): what triggers `429` and which headers to read
- [x402 test mode](https://docs.extrovert.dev/payments/x402-test-mode/): the `402` flow
- [Enrollment](https://docs.extrovert.dev/api/enrollment/): enrollment and signup errors