> Documentation index: https://unbrowse.ai/llms.txt. Fetch it to find every page.

# Errors & statuses

> Two kinds of failure. A request Unbrowse refuses is an HTTP error with a code. A run that started but did not succeed is a normal response whose `status` says so. Check the run's `status`, not only the HTTP code.

## Error shape

Refused REST requests return a JSON body with a stable `code` and a human `message`:

```json
{ "error": { "code": "quota_exceeded", "message": "…" } }
```

Branch on `code`. The `message` may change and sometimes carries the next step (a top-up link, how long to wait).

A run that fails comes back as a run, with `status: "failed"` and `error: { code, message }`:

```json
{ "runId": "lrun_…", "status": "failed", "error": { "code": "rate_limited", "message": "…" }, "signIn": null }
```

`POST /api/v1/runs` answers `200` for a finished run whether it succeeded or failed, and `202` while it is `accepted`, `working` or `input_required`. A site tool call (`POST /api/v1/sites/<host>/call/<tool>`) answers `202` for `input_required`, otherwise `200`.

## Run statuses

| status | meaning | what to do |
|---|---|---|
| `accepted`, `working` | Still running. | Poll `GET /api/v1/runs/<runId>`. |
| `succeeded` | The declared outcome was verified. A 200 from the site is not enough. | Use `result`. The only status that bills. |
| `input_required` | Waiting on a requirement: a choice, a value, an approval. Not a failure. | Answer the open `requirements` on the same run: `POST /api/v1/runs/<runId>/responses` or MCP `unbrowse.resume`. Don't start a new run. |
| `failed` | Did not succeed. `error.code` says why (below). | Act on the code. Never billed. |
| `outcome_unknown` | A write may have landed but no answer came back. | Check the site before retrying. Automatic retries of the write stay off. |
| `cancelled` | Stopped at your request. | A request already sent can't be unsent. Known effects are kept. |

## Auth and billing

| code | HTTP | when | what to do |
|---|---|---|---|
| `unauthorized` | 401 | No key, or a key Unbrowse doesn't know. | Send `Authorization: Bearer ub_live_…`. Mint one at [MCP & keys](/app/keys). |
| `revoked` | 403 | The key was revoked. | Mint a new key. |
| `forbidden` | 403 | No grant on this workspace, or `X-Unbrowse-End-User` sent with a key that isn't an org key. | Ask the workspace owner, or use an org key. |
| `quota_exceeded` | 402 | Free calls used up and no paid credits left. Checked before anything is sent. | Buy credits at [Billing](/app/billing). Failed calls never bill. |
| `insufficient_paid_credits` | 402 | The call costs more than the paid balance. | Buy credits. |

## Request shape

| code | HTTP | when | what to do |
|---|---|---|---|
| `bad_request` | 400 | The body isn't JSON. | Send a JSON object. |
| `unknown_argument` | 422 | The tool doesn't take an argument you sent. The message lists the arguments it does take. | Rename or drop the argument. |
| `invalid_input` | 400 / 422 | A run input matches no slot, or a choice isn't one of the options offered. | Use the names and options from the message. |
| `capability_not_found` | 404 | The `capability` id you pinned doesn't exist. | Search again, or run by `task`. |
| `idempotency_conflict` | 409 | The same `Idempotency-Key` was used with different input. | Use a new key per distinct call. |
| `not_in_scope` | 403 | The connection is in a tool scope (`/mcp/<slug>`, `?scope=`, `?apps=`) and the tool or capability is outside it. | Use a connection whose scope includes it, or add the app or tool to the scope in [Apps & tools](/app/tools). |
| `unknown_scope` | 404 | No tool scope with that slug in this workspace. | Create it in [Apps & tools](/app/tools) or `POST /api/v1/scopes`. |
| `invalid_scope`, `too_many_scopes` | 400 | A scope's slug isn't 1-40 lowercase letters, digits or dashes, it lists more than 200 apps and tools, or the workspace already has 20 scopes. | Fix the body, or delete a scope. |
| `not_found` | 404 | No such route, site, tool or run. | Check the path. `GET /api/v1/sites/<host>` lists a site's tools. |
| `internal` | 500 | Something broke on our side. | Retry once. If it repeats, report the `runId`. |

## Capacity and limits

| code | HTTP | when | what to do |
|---|---|---|---|
| `browser_capacity` | 429 | Every cloud browser is busy. | Retry in about 30 seconds, or finish an open browse session. |
| `too_many_sessions` | 429 | Your workspace already has 3 browse sessions open. | Finish or close one. |
| `index_busy` | 429 | An index job is already running for you. | Wait for it: `GET /api/v1/index/<id>`. |
| `index_limit` | 429 | Daily index-job limit reached (5 a day). | Try again tomorrow. |
| `slot_unavailable`, `unavailable` | 503 | A server is starting or failing over. | Retry after the `Retry-After` header (1 to 5 seconds). |

## Why a run failed

These arrive as `error.code` on a run with `status: "failed"`.

| code | when | what to do |
|---|---|---|
| `no_capability` | No tool fits the task yet. `result.next.tool` is `unbrowse.index`. | `POST /api/v1/index` with the URL, then `GET /api/v1/index/{jobId}`. Done means `status: done` and `indexed > 0`. `model_unavailable` means the indexing model is out of credit, not that the site refused and not that the workspace quota is spent. `unbrowse.browse.open` is an MCP tool: call it only when it is in this session's tool list. There is no `POST /api/v1/browse/open`, and the CLI does not open a browser. |
| `session_or_permission` | The site answered 401/403 or showed a sign-in page. The run carries `signIn.url` when you have no login saved. | Open `signIn.url` in the person's browser, save the login, then call again. Unbrowse signs in by itself from then on. |
| `mfa_required` | The site asked for a second factor. Also carries `signIn`. | Same as above. Save a TOTP seed with the login to make it unattended. |
| `rate_limited` | The site returned 429. The wait is in the message (the site's `Retry-After`, else 60 seconds). | Wait that long, then retry. |
| `challenge` | A bot check appeared in an `unattended` run. | Run it again as `interactive`. |
| `challenge_budget_exhausted` | Bot checks kept coming back. | Retry later. |
| `schema_or_auth_drift` | The site returned a page where it used to return data: it changed, or the session is gone. | Browse the flow once more so Unbrowse re-learns it. |
| `outcome_not_verified` | The site answered, but with an empty page or without the fields the tool promises. | Check your inputs. Don't treat it as success. |
| `not_found` | The site returned 404 or 410 for these inputs. | Fix the inputs. |
| `upstream_error`, `network_error` | The site erred or couldn't be reached. | Retry later. |
| `browser_capacity`, `render_failed` | The page needed a browser and none could render it. | Retry in about 30 seconds. |
| `destination_denied` | The URL is private, loopback, or outside the tool's site. | Don't retry. |
| `policy_denied` | The task or page tried to move a secret somewhere it shouldn't go. | Don't retry. |
| `declined` | A required field was declined. | Start a new run. |
| `worker_lost`, `resource_locked` | The worker stopped, or another agent holds the same resource. | Retry. |

## Answering an `input_required` run

| code | HTTP | when | what to do |
|---|---|---|---|
| `conflict` | 409 | The run isn't waiting for input. | `GET` the run and act on its status. |
| `stale_revision` | 409 | The run changed since you read it. | `GET` it again and send the current `stateRevision`. |
| `stale_requirement`, `expired_requirement` | 409 | That requirement was replaced or expired (after 15 minutes). | `GET` the run and answer its current requirements. |
| `unknown_requirement`, `invalid_option`, `invalid_answer` | 422 | The answer names no open requirement, or the value isn't one of the options. | Answer from the run's `requirements`. |

## Logins

| code | HTTP | when | what to do |
|---|---|---|---|
| `credential_required` | 412 | A browse `autofill` found no saved login for the site. The error carries a one-time `url`. | Open the `url`, save the login, repeat the action. The agent never sees the value. |
| `no_login_form` | 422 | No login fields are visible on the page. | Go to the sign-in page first. |
| `request_closed` | 409 | The one-time link was already used, declined, or expired (after 30 minutes). | Ask for a new link. |

## Browse sessions

| code | HTTP | when | what to do |
|---|---|---|---|
| `session_expired` | 410 | The session ended: idle for more than 5 minutes, finished, or the host restarted. | Open a new session. What it learned is kept. |
| `stale_ref` | 409 | The `@ref` isn't in the latest snapshot. | Take a new snapshot and use its refs. |
| `site_unreachable`, `page_unresponsive` | 504 | The page didn't load in 30 seconds, or the snapshot took longer than 15. | Wait, then retry. |
| `egress_unavailable` | 502 | The residential network refused the connection from three IPs in a row. | Retry in a minute, or pass another `country`. |
| `blocked` | 502 | A bot check or an empty page over both HTTP and the browser. | Don't retry soon. |

## MCP

Tool failures come back as JSON-RPC errors. The string code is in `error.data.code`, the same code as in REST. The numeric code follows the HTTP status: 401 → `-32001`, 403 → `-32003`, 404 → `-32004`, anything else → `-32000`. On MCP, `error.data.details` also carries fields like `retryAfter`.

A run with `status: "failed"` is not a JSON-RPC error. It is a normal result with `isError: true`.

**`-32042` (URL elicitation).** If your client declares `capabilities.elicitation.url` at initialize, a missing login or an empty balance comes back as `-32042`, with `data.elicitations[0].url`. Show that link to the person. When they finish, call again. Clients without URL elicitation get the plain error, or a run result carrying `signIn`.

An unauthenticated request to `/mcp` gets `401` with a `WWW-Authenticate` header that points to OAuth discovery. OAuth clients sign in from there. Other clients send `Authorization: Bearer ub_live_…`.
