> 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). When a wait applies, the error also carries `retryAfter` (seconds) beside the `Retry-After` header.

One exception to this shape: an OAuth access token that is expired, or was issued for another resource, is refused OAuth-style (RFC 6750) with a `WWW-Authenticate` header:

```json
{ "error": "invalid_token", "error_description": "Sign in to Unbrowse (OAuth) or send an Unbrowse API key." }
```

A token is issued for either `https://unbrowse.ai/mcp` or `https://unbrowse.ai/api` and works only there. Sign in again for the URL you call, or send an API key.

Browser MCP calls (`snapshot`, `act`, `finish`, and `close`) require the exact `sessionId` returned by
`unbrowse_browse_open`. A missing, blank, or non-string ID is `invalid_argument`: supply the ID of the
existing session. This does not mean that the browser ended or that another browser needs to be opened.

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`, or when it hands requests to you with `egress_required` ([Run from your own IP](/docs/client-egress)). 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. |
| `no_org` | 404 | An org route (`/api/v1/org…`) called by someone who isn't in an org. | Create one at [Org](/app/org). |
| `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 |
|---|---|---|---|
| `invalid_body`, `bad_request` | 400 | The body isn't a JSON object. | Send a JSON object. |
| `body_too_large` | 413 | The body is over 256 KB. | Send less; use `select` to shrink answers, not requests. |
| `idempotency_key_conflict` | 400 | The `Idempotency-Key` header and an `idempotencyKey` in the body differ. | Send one, or make them match. |
| `invalid_allowed_destinations` | 400 | `allowedDestinations` isn't a list of at most 100 hostnames. | Fix the list. |
| `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 bad `select` path or `deadlineMs` (400), or an answer that fills no open field (422). Run inputs a tool doesn't take are ignored on `POST /api/v1/runs`; site tool calls reject them with `unknown_argument`. | 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 or 503 | Every cloud browser is busy. 503 means the call already waited 30 seconds for a free browser. | Retry after the `Retry-After` header (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 | You already have the most sites indexing at once (8). Asking for a site that is already indexing returns that job instead. | Wait for one to finish: `GET /api/v1/index/<id>`. |
| `index_limit` | 429 | Daily index-job limit reached (200 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). |
| `scrape_timeout` | 504 | A scrape exceeded its overall deadline. Expired work cannot start a browser or bill a result. | Retry or increase `deadlineMs` (5 to 120 seconds; default 45). Nothing was billed. |
| `run_timeout` | 504 | A run (`POST /api/v1/runs`, `POST /api/v1/sites/<host>/call/<tool>`, MCP `unbrowse_run` or a site tool) did not finish within its deadline: 60 seconds by default, 5 to 300 via the `x-unbrowse-deadline-ms` header or `deadlineMs`. The run keeps going. The error carries `runId` and `poll`. | Poll `GET /api/v1/runs/<runId>` (MCP: `unbrowse_inspect`). A late success is billed once; a failure is never billed. A retry with the same `Idempotency-Key` joins the same run. |

## 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. Legacy tools that only read UI translation dictionaries are also refused. | Check your inputs. For a dictionary-only tool, browse the intended results and learn their data request again. 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. |
| `not_sent` | A write never left Unbrowse's network: the network exit refused the connection (Unbrowse moved to the account's next exit and tried once more when the exit itself was dead), or the site's name, port or certificate failed before anything was sent. Nothing reached the site. | Safe to run again. Unlike `outcome_unknown`, nothing needs checking first. |
| `browser_capacity` | The page needed a browser but no browser slot was available. | Retry after the supplied cooldown (about 30 seconds by default). |
| `render_failed` | The available renderers could not read the page. | Inspect the failure; this does not necessarily mean browser capacity is exhausted. |
| `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 (the `responses` form). The short `answers` form and MCP `unbrowse_resume` answer `422 invalid_answer` instead. | `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 |
|---|---|---|---|
| `login_required` | 401 | The page is behind a sign-in: the read was sent to a sign-in page, or the page itself is one (a private claude.ai artifact, a Notion page, a Google Doc), and Unbrowse has no usable sign-in for the site (none kept, reuse of the kept one set to Ask me or Never, or the one it sent is signed out). A kept session is marked signed out only when a signed-in read was sent to the sign-in page. Never billed, never reported as `blocked`. `details`: `site`, `finalUrl`, `sessionSent`, `reason` (`login_wall`, `redirect_to_login`, or `reddit_signed_out` for Reddit's JSON), `next`. | Give Unbrowse the sign-in, then ask again: `unbrowse cookies sync --domain <site>` (or `unbrowse cookies daemon start --domain <site>`, or the extension's **Sync**), or save a login / sign in once in `unbrowse_browse_open`. `--from-here` does not carry a sign-in. See [Use your own browser and logins](/docs/browser-extension#when-a-page-is-behind-a-sign-in). |
| `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. |
| `login_destination_denied` | 403 | A saved login was asked for a field in a frame from another site than the page (an ad, a widget, a neighbour tenant of a shared host): a saved login is only filled into the site it belongs to. Nothing was filled and no link to save a login is made. `details.frameOrigin`: the frame's origin. | Fill the page's own sign-in form (or a same-site sign-in frame) instead; never type the password into that frame. |
| `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 2 minutes, finished, or the host restarted. | Open a new session, or finish this one: what it recorded is kept and learned when you finish. |
| `stale_ref` | 409 | The `@ref` isn't in the latest snapshot, its element changed, or the iframe it was in (a ref like `f1e3`) is gone. | Take a new snapshot and use its refs. |
| `target_unavailable` | 409 | The element an action needed is gone, covered or not ready, or a login field left its page (the page or its frame navigated) between finding the saved login and filling it: nothing was filled. | Take a new snapshot and act on its refs. |
| `browser_network_failed` | 502 | Chromium could not load the result of a browser action. | The outcome is unknown and the session remains open. Check for confirmation before repeating a submission; taking another snapshot does not replay it. Details include `sessionId`, `outcome: "unknown"`, and `retrySafe: false`. |
| `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`. |
| `egress_lost` | 503 | The session's network dropped mid-session and reconnecting it (twice) did not bring the site back. Details: `sessionId`, `reason`, `attempts`, `retryAfter`. | Whatever the page was sending may or may not have arrived: check the site before repeating a submission. Call the session again after `retryAfter` seconds (Unbrowse reconnects it once more), or close it and open a new one. |
| `page_reopened` | 409 | The session reconnected (its network had dropped) while this action ran. | The action may or may not have taken effect: take a new snapshot and check before repeating it. A successful reconnect is otherwise reported once as `recovered` on the next snapshot or act. |
| `host_unreachable` | 502 | A page load to another host (a link, a sign-in hop) failed at the network exit while the session's own site still answers. Details: `url`, `reason`, `page`. | The session is fine and back on `page`: take a new snapshot and continue there, or reach that host another way. |
| `blocked` | 502 (429 from `POST /api/v1/scrape`) | A bot check or an empty page over both HTTP and the browser. A sign-in page is `login_required`, not `blocked`. A scrape whose page has no readable content (under 20 characters once extracted) also fails `blocked`, with `reason: "empty_page"`. Never billed. | Don't retry soon. For `empty_page`, check the URL, or try `render: "always"` for a page built by JavaScript. |

## Raw reads and connector timeouts

Raw scrapes preserve ordinary upstream errors: `upstream_not_found` (404),
`upstream_conflict` (409), `upstream_unavailable` (502 for an upstream 5xx),
or `upstream_error` (the other upstream 4xx status). Error details include
`httpStatus` and `httpOnly`. Check the endpoint and parameters; these errors
alone do not indicate a bot challenge. Actual challenges retain `blocked`.

A timed-out `/api/v1/compose/v1/call` answers 504 with
`{ "ok": false, "code": "upstream_timeout", "outcome": "unknown", "error": "…" }`.
Check the app before repeating an action: the remote call may have completed.
Unbrowse does not automatically retry it.

Operator runner status and the `ensure` response include
`provisioning.lastFailure` with a controlled `code`, `operation`, `at` timestamp,
and recovery message. `fleet_limit` means the provider refused new sandboxes.
The diagnostic survives pool eviction and clears after a later provisioning
attempt succeeds; an older concurrent success cannot clear a newer failure.
A clear diagnostic does not prove a browser is ready: also inspect pool states.

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