# Errors

> Every error code, its HTTP status, what it means and what to do next.

Errors are always JSON, with the same shape everywhere:

```json
{ "error": { "code": "forbidden", "message": "Only an owner can refund a live payment. This is an agent key: ask a person to do it in the console.", "details": { "reason": "owner_only", "next_step": "Tell the user: the account owner does this in the console ▸ the project ▸ Payments ▸ the payment ▸ Refund (or with an owner key). Retrying with this key will not work." } } }
```

- `code` is stable: branch on it, never on `message`.
- `details.reason` refines a code (`owner_only`, `payouts_not_ready`, `daily_spend`…).
- `details.next_step`, when present, is what to tell the person. Agents pass it on instead of retrying.
- Every response has an `X-Request-Id`; quote it to support.
- AI routes add OpenAI's `type` and `param` so OpenAI SDKs read them.

## Codes

| Code | Status | Meaning | Next step |
| --- | --- | --- | --- |
| `invalid_request` | 400 | The request is malformed or a field is wrong. `details` names the field (`issues[].path`, `param`, `problems`) | Fix the field; don't retry unchanged |
| `content_filtered` | 400 | An AI content filter refused the request | Change the input |
| `context_length_exceeded` | 400 | The AI input is longer than the model's window | Shorten it or use a larger model |
| `unauthorized` | 401 | Missing, unknown or revoked key; an app key on an account route | Check the key and its kind |
| `forbidden` | 403 | Not allowed for this key. With `reason: owner_only`: a person must do it | Pass `details.next_step` to the owner |
| `no_account` | 403 | Signed in to the console with no account yet | Create an account or link a key in the console |
| `not_found` | 404 | No such object on this account or in this environment | Check the id and `env` |
| `conflict` | 409 | The object's state doesn't allow it (`details.reason` says why), or a concurrent request with the same `Idempotency-Key` is still running | Read the object again; act on `reason` |
| `domain_taken` | 409 | The slug or domain belongs to someone else | Pick another |
| `dependency_not_ready` | 409 | A resource this needs is missing or still provisioning | Add it or wait, then retry |
| `insufficient_balance` | 409 | The project's balance can't cover a refund | Wait for more sales, or refund less |
| `payment_required` | 402 | The account is out of credits for a paid call (`details.reason`: `insufficient_balance`, or `past_due` for deploys after a week at zero) | Pass `details.next_step` and `top_up_url` to the person; don't retry until credits are added |
| `quota_exceeded` | 413 | A stored quota is full (replay) | Delete or wait for retention |
| `payload_too_large` | 413 | The body or upload is over its cap | Send less |
| `idempotency_mismatch` | 422 | The same `Idempotency-Key` was used with a different body | Use a new key for a new request |
| `rate_limited` | 429 | A limit was reached; `details` says which and `resets_at` | Wait (`Retry-After` or `resets_at`); don't loop |
| `client_outdated` | 426 | The Vibiz CLI that called is older than the API supports; `details.minimum` | Update it: `npm install -g @vibiz/cli@latest`, then retry |
| `internal` | 500 | Our bug | Retry once; then contact support with `X-Request-Id` |
| `provider_failed` | 502 | The work was refused downstream (a refund refused; your own AI key rejected) | Read `message`; retrying rarely helps |
| `provider_unavailable` | 503 | The service is unavailable right now, or the call timed out (`details.reason`) | Retry later with backoff |
| `model_unavailable` | 503 | No route can run the model you asked for right now. Vibiz never swaps in another model | Retry after `details.retry_after_seconds`, or pick another model |
| `not_configured` | 503 | This deployment doesn't offer that capability now | Use another model or capability |

## Retries and idempotency

Any `POST` accepts `Idempotency-Key`. The first successful JSON response is stored for 24 hours and replayed for a retry with the same body, so a retried payment checkout, email or AI call can't happen twice. A different body under the same key is `idempotency_mismatch`; a concurrent request with the same key while the first is running is `conflict`. Streams and audio aren't replayed. Server errors aren't stored, so they can be retried.

Retry `429` (after the reset), `502`/`503` and network errors with backoff. Never retry `400`, `401`, `403` or `409` unchanged.

## In the MCP server

Tool errors are `isError: true` results whose text is the same JSON. A failed build waited on by `vibiz_deploy` comes back as `deployment_failed`.
