# API · Errors

{/* // TYPED ERRORS */}

Every non-2xx response uses the same envelope, with a **stable `error.code`** you can branch on
without parsing prose.

## The envelope

```json
{
  "error": {
    "code": "enrollment_token_exhausted",
    "message": "This enrollment key is exhausted — it minted its max of 5 mailboxes. Issue a new key.",
    "details": null,
    "request_id": "req_7Yc2"
  }
}
```

| Field | Meaning |
|---|---|
| `code` | Stable, machine-readable. Branch on this. |
| `message` | Human-readable; safe to log, not to parse. |
| `details` | Field-level validation info on `422`. |
| `request_id` | Also returned as `X-Postern-Request-Id`. Quote it in support requests. |

## Status codes

| Status | Meaning | Typical `error.code` |
|---|---|---|
| `400` | Malformed request | `bad_request` |
| `401` | Missing / invalid / expired / revoked key | `invalid_api_key`, `invalid_enrollment_token`, `enrollment_token_revoked`, `enrollment_token_expired` |
| `402` | x402 payment required (test mode) | `payment_required` |
| `403` | Scope denied | `scope_denied` |
| `404` | No such resource (or not yours) | `not_found` |
| `409` | Conflict | `enrollment_token_exhausted`, `idempotency_conflict` |
| `422` | Validation failed | `validation_failed` (see `details`) |
| `429` | Rate-limited / over a cap | `rate_limited` (see `Retry-After`) |
| `5xx` | Server error | `internal_error` |

## SDK error classes

The TypeScript SDK throws a typed error for every non-2xx, extending `ApiError`:

```ts
import {
  ApiError,
  AuthenticationError,  // 401
  PermissionError,      // 403
  NotFoundError,        // 404
  ConflictError,        // 409 — e.g. enrollment key exhausted
  ValidationError,      // 422 — see err.body.error.details
  PaymentRequiredError, // 402 — x402 challenge in err.paymentRequired
  RateLimitError,       // 429 — err.retryAfter (seconds)
  ConnectionError,      // network failure before a response
  TimeoutError,         // request timed out / aborted
} from "@postern/sdk";

try {
  await postern.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.message, err.requestId);
  } else {
    throw err;
  }
}
```

Every `ApiError` carries `status`, `code`, `requestId`, `body`, and `isClientError` / `isServerError`.

## Retries

`GET` and `DELETE` (idempotent) retry automatically on `429` / `5xx` / network errors with jittered
backoff that honors `Retry-After`. For `POST`s, use `client_id` (create) or `idempotency_key`
(send/reply) so a retry can't duplicate.

## `404` vs `403` — tenancy

A resource that belongs to **another org** returns `404`, not `403` — Postern never confirms the
existence of resources outside your tenant. A `403` means the resource is yours but your key lacks the
required scope.

## Next

- [Rate limits & quotas](https://docs.agents.mszazu.com/operating/limits/) — what triggers `429`, and the headers.
- [x402 test mode](https://docs.agents.mszazu.com/payments/x402-test-mode/) — the `402` flow.
- [Enrollment](https://docs.agents.mszazu.com/api/enrollment/) — the `401` / `409` enrollment cases.