Errors
When a request to the Browser Agent API fails (/api/v1/responses, /api/v1/runs, wrappers), you get a nested error object. Branch your handling on error.code — it is stable; message text is not.
Error envelope
Browser Agent API error envelope. Branch on error.code (stable); the full code table with remediations lives at /docs/api/errors.
| Field | Type | Req | Description |
|---|---|---|---|
| error | object | — | — |
| error.message | string | yes | — |
| error.type | string | yes | — |
| error.code | string | yes | — |
| error.param | string | — | — |
| error.doc_url | string | — | — |
| error.hint | string | — | — |
| error.remediation | string | — | Short next-step guidance for agents. |
| error.claim_url | string | — | Present on account_unclaimed — open it to claim the account. |
Generated from OpenAPI. Do not hand-maintain this table.
Browser Agent API shape
{
"error": {
"message": "Missing or invalid Banana Peel API key. …",
"type": "authentication_error",
"code": "invalid_api_key",
"param": null,
"doc_url": "https://bananapeel.com/docs/agents",
"hint": "export BANANA_PEEL_API_KEY=bp_live_…",
"remediation": "Run `npx -y @banana-peel/cli init --agent --json` to mint a key, or use the HTTP fallback …"
}
}code— stable machine id (full table below)hint— present when a short recovery tip helps (auth, routing, credits)remediation— next-step guidance for agents; present for every code in the tables belowdoc_url— points at /docs/agentsparam— JSON field to fix when relevant, elsenull
Request error codes
Returned as the envelope above with a non-2xx HTTP status. Validation failures fail free — no runner was attempted and nothing was billed. 429s carry a Retry-After header.
| Code | HTTP | When | What to do |
|---|---|---|---|
| invalid_api_key | 401 | Missing or invalid `Authorization: Bearer bp_live_…` / `x-api-key`. | Run `npx -y @banana-peel/cli init --agent --json` to mint a key, or if npx is unavailable use the supported HTTP fallback: GET /api/v1/agent-signup/challenge → solve the instruction → POST /api/v1/agent-signup with {challenge_id, challenge_answer} (see /docs/agents/signup). Then pass Authorization: Bearer bp_live_… |
| account_unclaimed | 403 | Agent account froze after the trial window without a human claim. | Open the claim_url, sign in, then keep using the same stored API key. Do not paste the key into chat. |
| invalid_json | 400 | Body is not parseable JSON. | Send a JSON body with Content-Type: application/json. |
| payload_too_large | 413 | Request body exceeds the 1 MB create-body limit. | Send a JSON body of at most 1 MB (live-run, Responses, Messages, session submit). Nothing was created or billed. |
| unsafe_url | 400 | Target URL failed the public-https / SSRF checks (hostname, DNS, or redirect). | Provide a public http(s) URL. Private, link-local, metadata, and names that resolve to those addresses (including nip.io / sslip.io) are rejected on every path. |
| missing_input | 400 | Neither `input` nor `url` was provided. | Provide input (task text or {url, goal}) and/or url. |
| missing_url | 400 | routing: "learning" without a resolvable target URL. | Learning mode probes the runner pool against a specific domain — pass `url` (or include an https:// URL in the task text). Nothing was billed. |
| invalid_routing | 400 | Malformed `routing` value. | Use routing: smart | named runner | SOC2|HIPAA|EU | [runner, …]. |
| missing_output_schema | 400 | normalize enabled without a schema. | Set output_schema (or text.format / response_format) when normalize is enabled. Build one in Console → Normalize. |
| invalid_output_schema | 400 | `output_schema` is not a usable JSON Schema / property map. | Provide a JSON Schema object (with `properties`) or a property map like { "order_id": { "type": "string" } }. Build one in Console → Normalize; nothing was billed. |
| invalid_max_cost_usd | 400 | `max_cost_usd` is not a positive number. | Pass max_cost_usd as a positive USD number (e.g. 2.50) — the per-run spend ceiling. Nothing was billed. |
| invalid_max_fallbacks | 400 | `max_fallbacks` is not an integer in 0–12. | Pass max_fallbacks as an integer from 0 (first pick only) to 12. Default is 5 additional runners after the primary. Nothing was billed. |
| invalid_list_filter | 400 | A list query parameter is invalid: unknown `status` or `routing` filter value, or a `created_after` / `created_before` timestamp that is not ISO 8601. | On GET /api/v1/runs use status=running|needs_human|succeeded|failed|blocked|empty|cancelled, routing=smart|learning|learning-all|category|runner|chain, and ISO-8601 timestamps (e.g. 2026-08-01T00:00:00Z) for created_after / created_before. Nothing was billed. |
| idempotency_key_mismatch | 400 | The `Idempotency-Key` header and the body `idempotency_key` are both set but differ. | Send the same key in both places, or only one of them (the header is the conventional choice). Nothing was created or billed. |
| invalid_idempotency_key | 400 | Idempotency key longer than 255 characters. | Use a key of at most 255 characters (a UUID is ideal). Nothing was created or billed. |
| idempotency_conflict | 422 | Idempotency key reused with a DIFFERENT request body within its 24h window. | A key replays its original run only for an identical request — this usually means a retry loop is mutating its payload. Use a fresh key for the new request, or resend the exact original body to replay. Nothing was created or billed. |
| idempotency_in_flight | 409 | Duplicate create with the same idempotency key while the original create is still being admitted. | Honor Retry-After (~1s) and resend the identical request — you will receive the original run once it exists. No duplicate was created and nothing was billed. |
| invalid_credential | 400 | Credential create/update body failed validation. | Provide domain, username, and password as strings within the documented length caps — see /docs/api/credentials. |
| credential_not_found | 404 | `credential_id` does not exist in this workspace. | Use a vault credential id owned by this API key — list yours with GET /api/v1/credentials or create one with POST /api/v1/credentials (also Console → Vault). |
| invalid_custody | 400 | Malformed `custody` policy string. | Use custody: any | trusted_runners | deck_only | pinned:<runner>[,<runner>…]. |
| custody_unsatisfiable | 409 | Credential custody policy and requested routing share no runner. | The credential custody policy and the requested routing have no runner in common. Loosen the policy (Console → Vault or PATCH /api/v1/credentials/{id}) or route within the allowed set; nothing was billed. |
| insufficient_credits | 402 | Workspace balance cannot cover the run. | Top up in Console → Billing (face-value credits). |
| response_not_found | 404 | Unknown response id for this workspace. | Poll the id returned from create; ids are per-workspace. |
| explanation_not_found | 404 | No routing decision card exists for this run. | Decision cards are recorded for routing: "smart" runs (the default). Pinned runners, chains, and races have no routing decision to explain. |
| not_waiting_for_input | 409 | POST …/input while the run is not waiting on a human answer. | Poll GET /api/v1/responses/{id} until status is requires_action, then POST {"answer": "…"} to /api/v1/responses/{id}/input. |
| artifact_expired | 410 | Artifact bytes aged out of the retention window. | Copied screenshots and artifacts are retained for the workspace window (default 7 days). Re-run the task if you still need the files. |
| runner_unavailable | 503 | Pinned runner (or every runner in a chain) is unavailable. | Pin another runner or use routing: smart. |
| saturated | 429 | Per-instance live-run concurrency cap reached. | Honor Retry-After and reduce concurrency. |
| learning_capacity | 429 | Learning fan-out capacity saturated on this instance. | Learning fan-out capacity on this instance is saturated. Honor Retry-After, or stagger learning creates; smart creates are unaffected. |
| rate_limited | 429 | Too many requests — including more concurrent Learning-family runs (`routing: "learning"` + `"learning-all"`, one shared cap) than the per-account limit (default 3). | Honor Retry-After and back off. For Learning-family creates ("learning" / "learning-all"), wait for an in-flight Learning run to finish (poll GET /v1/responses/{id}) or cancel one before starting another. |
| enterprise_required | 403 | Custom retention on a non-Enterprise workspace. | Custom retention is Enterprise-only. See /docs/security/retention or contact sales. |
| invalid_retention | 400 | Retention value not one of the self-serve windows. | Use retention "24h", "7d", or "30d" on an Enterprise workspace. |
| retention_not_self_serve | 400 | Requested window is outside the self-serve set. | Self-serve Enterprise retention is "24h", "7d", or "30d". Zero-retention and longer windows are negotiated contract terms — contact sales. |
| feature_disabled | 403 | The requested feature is disabled on this deployment. Currently: multi-entity long-task orchestration (POST /api/v1/long-tasks) — every task runs as a single run. | Submit each task as its own run via POST /api/v1/responses — smart routing treats every task the same. Existing lt_… objects stay readable (GET /api/v1/long-tasks, …/{id}, …/{id}/runs). Nothing was created or billed. |
| long_task_not_found | 404 | Unknown long-task id for this workspace. | Use the lt_… id returned from POST /api/v1/long-tasks; ids are per-workspace. List active ones with GET /api/v1/long-tasks. |
| not_multi_entity | 400 | The task does not look multi-entity, so it was rejected by the long-task submit gate. | Single-shot tasks belong on POST /api/v1/responses (zero orchestration overhead). If you KNOW the job covers several entities, pass `expected_entities` (2–500). Nothing was billed. |
| invalid_expected_entities | 400 | `expected_entities` is outside the accepted range. | Pass an integer between 2 and 500 — the number of entities (accounts, properties, …) you expect the job to cover, or omit it to let detection decide. Nothing was billed. |
| webhook_not_found | 404 | Unknown webhook id for this workspace. | Use a webhook id owned by this API key — list yours with GET /api/v1/webhooks. |
| invalid_webhook_url | 400 | Endpoint URL failed the public-https / SSRF checks. | Provide a public https URL. Private, metadata, .internal, and localhost/loopback hosts are rejected in production (http://localhost is allowed only in local development). |
| invalid_webhook_event | 400 | `events` contains an unknown event type. | events must be a subset of response.completed, response.failed, response.requires_action, response.cancelled. |
| invalid_webhook_secret | 400 | Caller-supplied `secret` is too short. | Pass secret as a string of at least 16 characters, or omit it and let Banana Peel mint a whsec_… secret (returned once). |
| webhook_limit | 400 | Account already has the maximum number of webhook endpoints. | Each account can register at most 25 webhook endpoints. Delete unused endpoints (DELETE /api/v1/webhooks/{id}) or update one in place with PATCH. |
| webhook_delivery_not_found | 404 | Unknown delivery id for this endpoint. | Use a delivery id from GET /api/v1/webhooks/{id}/deliveries for this endpoint. |
Run outcomes vs transport errors
Not every failure is an HTTP error. A run that executed but didn't accomplish the task returns HTTP 200 (or 502) with status: "failed" on the response object — read error / banana_peel.error on that object, not the request-error envelope above. Always check status, not just the HTTP code. Terminal error.code values on the response object:
| Code | HTTP | When | What to do |
|---|---|---|---|
| run_timeout | — | Run exceeded its active time contract: an explicit `time_budget_ms` plus grace, the legacy pinned/chain TTL, a dead-executor watchdog, or the absolute safety ceiling. See `banana_peel.time_budget_ms` for the contract that governed this run. | Smart/category/learning runs without time_budget_ms are UNCAPPED — only a dead executor (stale progress) or the 24h safety ceiling stops them; pinned/chain runs keep the legacy hard TTL (default 20 min). The upstream runner hung or the background worker was interrupted. Retry the task; only attempts that actually executed were billed. |
| budget_exhausted | — | Run stopped at its spend ceiling (`max_cost_usd`, the $20 Learning default, or the routing plan budget). | Attempts that executed are billed and their partial results are under banana_peel.routing_attempts. Raise max_cost_usd (or narrow the task) and retry if you want more attempts. |
| run_cancelled | — | Run was cancelled via POST /api/v1/responses/{id}/cancel. | Attempts that already executed are billed; nothing was dispatched after the cancel. |
| blocked | — | Target site blocked or challenged every attempt. | Retry with routing: smart or a stealth/residential runner (steel, hyperbrowser, browserbase), or answer MFA via POST …/input. |
| run_failed | — | Run executed but did not accomplish the task. | Inspect banana_peel.screenshot / session.live_url, refine the task, and retry — routing: smart avoids the failing runner next time. |
| schema_violation | — | Run finished but its output did not satisfy the supplied `output_schema` (missing/empty required keys, wrong types, or undeclared extra keys). The run is failed — never billed-and-claimed-successful on drifted keys. | The nonconforming output is kept under banana_peel.output for inspection and the violations are listed in error.message. Attempts that executed are billed; the output-normalization add-on is not. Loosen the schema (mark fields optional / allow additionalProperties), improve the task instruction, or retry. |
Agent signup fallback codes
GET /api/v1/agent-signup/challenge and POST /api/v1/agent-signup (see agent signup) use the same envelope with these codes, plus rate_limited (429) from the table above:
| Code | HTTP | When | What to do |
|---|---|---|---|
| missing_user_agent | 400 | Challenge request without a User-Agent header. | Send a User-Agent (bananapeel-cli/…, Cursor, Claude Code, Codex, or any HTTP client). |
| generic_user_agent | 400 | Signup from a generic client UA (curl, python-requests, …). | Credit-granting signup requires a non-generic User-Agent (bananapeel-cli/…, Cursor, Claude Code, Codex). Set one on the signup POST. |
| missing_challenge | 400 | Signup POST without challenge_id / challenge_answer. | Provide challenge_id and challenge_answer from GET /api/v1/agent-signup/challenge. |
| invalid_challenge | 400 | Challenge unknown, expired, already used, or bound to another IP. | Request a fresh challenge (TTL is two minutes, one use, IP-bound) and retry the signup from the same client. |
| wrong_challenge_answer | 400 | challenge_answer did not solve the instruction. | Re-read the challenge instruction and recompute the answer exactly, or request a new challenge. |
Console billing endpoints
The signed-in console billing endpoints (POST /api/billing/checkout, POST /api/billing/portal) return the same error envelope: auth_required (401, sign in), invalid_credits_usd (400, top-up amount out of range), stripe_error (502, upstream Stripe failure — retry or contact support), internal_error (500).
Browserbase-compatible sessions
{ "statusCode": 401, "error": "Unauthorized", "message": "Unauthorized" }/api/v1/sessions mirrors Browserbase's { statusCode, error, message } wire shape so the official SDK builds thrown errors from message as usual.