# Errors

Failed requests return a JSON body with one `error` object:

```json
{
  "error": {
    "code": "spend_limit",
    "message": "This purchase is above the per purchase limit of this API key.",
    "details": { "limitCents": 2000 },
    "request_id": "c3a7e1f0-52d8-4b69-8e14-7f0a9d6b2c35"
  }
}
```

- `code` is stable. Branch on it in code.
- `message` is for people. Do not parse it, and expect the wording to change.
- `details` is optional and depends on the code. It can hold the field that failed, a limit, or a retry time.
- `request_id` matches the `X-Request-Id` header on the same response. Log it, and quote it when you contact support.

## Codes

| Code | HTTP status | Meaning | What to do |
| --- | --- | --- | --- |
| `validation` | 422 | The request is malformed or breaks a rule. `details.issues` lists each field path and message. Also used when a shipping rule blocks a shipment (`details.blockedByRule`). | Fix the request. Do not retry unchanged. |
| `unauthenticated` | 401 | The key is missing, malformed, unknown, revoked or expired. | Check the key. |
| `forbidden` | 403 | The key lacks the permission, or the request came from an address outside its allowlist. | Use a key with the right permission or from an allowed address. |
| `not_found` | 404 | No such endpoint or object in your account. | Check the id. |
| `conflict` | 409 | The request clashes with the current state: an already purchased shipment, expired rates (`details.expired`), a refund already requested, or an idempotency key still in use. | Read the current state, then decide. |
| `insufficient_funds` | 402 | The wallet cannot cover the purchase. | Add funds, then try again. |
| `spend_limit` | 402 | The purchase would break a per label or per day limit on the key or the account. `details` holds the limit in cents. | Raise the limit in the app, or wait for the day to reset. |
| `org_suspended` | 403 | The account is not active. | Contact support. |
| `rate_limited` | 429 | Too many requests. The `Retry-After` header says how many seconds to wait. | Wait, then retry. See [Rate limits](/docs/rate-limits). |
| `maintenance` | 503 | The platform is in maintenance and refuses changes. Reads still work. | Retry later. |
| `not_configured` | 501 | A needed service is not set up, for example no carrier is available. | Retry later or contact support. |
| `upstream` | 502 | A carrier failed or returned something unusable. | Retry with backoff. |
| `internal` | 500 | Something went wrong on our side. The message is generic on purpose. | Retry with backoff. Quote the `request_id` if it persists. |

An unsupported method on a real path returns `405` with an `Allow` header.

## Retrying

- Retry `429`, `502`, `503` and `500` with exponential backoff and some jitter. For `429`, wait at least `Retry-After` seconds.
- Do not retry `422`, `401`, `403`, `404` unchanged. They will fail the same way.
- For a purchase, always retry with the **same** `Idempotency-Key`. See [Buying labels safely](/docs/buying-labels).
- A `5xx` response to a request with an idempotency key does not lock the key. You can send the same request again.

**Note:** The OpenAPI file lists the same error shape for every endpoint. A `request_id` is present on every error, including ones that never reach a route, such as a bad key.