Browsa API

The identity layer for AI agents. Provision a CDP-attached cloud browser identity in two seconds; drive it with Playwright, Puppeteer, or raw CDP. Or skip the wiring entirely and tell us what to do in plain English — the agent does the rest.

Two modes, one endpoint

  • Burner — ephemeral, 5 min–1 hour TTL, self-destructs, pay-per-call. Best for scrape jobs, one-off automations, and CI runs.
  • Persistent — durable, vault-synced profile (cookies, localStorage, fingerprint), monthly-billed, sessions opened on demand. Best for long-running agents that maintain logged-in state across runs.
Every endpoint lives under https://browsa.io. Authenticate with X-API-Key: agt_live_… — mint one in the dashboard.

Quickstart

Three lines to a cloud browser the agent can drive. No infrastructure, no fingerprint tuning, no proxies to wire.

# Provision a burner agent — returns CDP URL you can attach to.
curl https://browsa.io/v1/agents \
  -H "X-API-Key: agt_live_…" \
  -H "Content-Type: application/json" \
  -d '{"ttl":"10m","initial_url":"https://example.com"}'

That's it. The agent gets destroyed when the TTL hits zero (or when you DELETE it). You're only charged for the time it was actually running.

Authentication

Every request needs an API key in the X-API-Key header. Keys are minted in the dashboard and look like agt_live_ followed by 24 hex characters.

X-API-Key: agt_live_aabbccddeeff00112233445566778899

Bearer auth is supported too (same key shape) for clients that always set the Authorization header.

Keep keys secret. The raw secret is shown once when minted — we store only sha256(secret). If a key leaks, revoke it from the dashboard and mint a new one.

Idempotency

Every POST /v1/quick/* endpoint accepts an Idempotency-Key header (Stripe-compatible). Pass a UUID per logical request and the platform caches the full response for 24h. A retry with the same key returns the cached body byte-for-byte with Idempotent-Replay: true — no second burner provisioned, no second charge.

curl -X POST https://browsa.io/v1/quick/scrape \
  -H "X-API-Key: agt_live_…" \
  -H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'

Use this for any retry path your client library does automatically (e.g. requests.Session with backoff). Without it, a transient network blip can re-execute the scrape and charge twice.

Request IDs

Every response (success or error) carries a X-Request-Id header. Error envelopes also include request_id in the JSON body. Quote it to support@browsa.io for any incident — it short-circuits triage by ~30 minutes.

Agents

An agent is one cloud browser identity. Burner agents run for a short TTL and self-destruct; persistent agents stick around and you open sessions on them as needed.

Provision an agent

POST /v1/agents Returns 201 with CDP URL ready to attach

Mode is auto-detected: if you set persona or warmup_recipe without a TTL, the agent is provisioned persistent. Otherwise it's a burner. Override with explicit mode.

Request body

FieldTypeNotes
ttlstringDuration: "5m", "300s", "1h". Burner only, capped at 1h.
ttl_secondsintegerNumeric alternative to ttl.
modestringburner or persistent.
initial_urlstringURL the browser navigates to on boot.
countrystringISO-2, e.g. "us", "de". Picks the proxy region.
os_typestringOS_TYPE_MACOS, OS_TYPE_WINDOWS, OS_TYPE_LINUX. Empty = backend default (macOS).
personastringPersistent mode — freeform label, e.g. "us-shopper-30s".
warmup_recipestringamazon, google, social, or empty.

Response

{
  "id": "agt_aabbccddeeff00112233",
  "status": "running",
  "mode": "burner",
  "cdp_url": "wss://browsa.io/api/v1/cdp/<profile>/ws/page/<page-id>",
  "cdp_http": "https://browsa.io/api/v1/cdp/<profile>",
  "expires_at": "2026-06-06T15:32:00Z",
  "trust_score": 920,
  "cost_credits": 1
}

List agents

GET /v1/agents Returns {agents: [...]}, newest first

Workspace-scoped automatically. Supports ?mode=burner or ?mode=persistent to filter, and ?limit=1..200 (default 50). The dashboard's Identities view is built on top of this.

Get an agent

GET /v1/agents/{id} Latest status + CDP URLs (if running)

Returns the same shape as the create response. status transitions queued → running → destroyed, or failed on a provisioning error.

Destroy an agent

DELETE /v1/agents/{id} Stops the browser, releases credits

Returns 204. Burner agents self-destruct on TTL expiry, so you only need this if you finish early and want to stop the meter.

Get CDP URLs

GET /v1/agents/{id}/cdp For reconnecting after a network blip

Returns cdp_url, cdp_http, profile_id, expires_at. 409 if the agent isn't currently running (destroyed, queued, or failed).

Open a session on a persistent identity

POST /v1/agents/{id}/sessions Persistent agents only

Opens a fresh cloud session against a persistent identity. The profile (cookies, localStorage, fingerprint) is restored from the vault; only the cloud session is new. Charges PersistentSessionCredits.

Trust score

Every agent returns a trust_score between 0 and 1000. It's the platform's running estimate of how likely the browser is to clear anti-bot detection at the moment it was provisioned. It rolls up the latest pass rate on our open detection harness across the five reference sites, weighted by the matching os_type / country / proxy combination.

ScoreBandReading
≥ 850UndetectedClears Cloudflare / Akamai / iphey baseline checks. Safe for production scraping.
700 – 849SuspiciousPasses most basic checks but flagged by stricter heuristics (e.g. FPJS Pro Bot detection). Usable; expect a small failure rate.
< 700FlaggedOne or more detectors are firing. Try a different os_type or country, or switch the burner to persistent mode with a warmup recipe.

The dashboard's Playground displays the score live during a task run so you can see what the platform delivered for the picks you made.

Trust score breakdown

GET /v1/agents/{id}/trust Per-factor breakdown of how the score was computed

Returns the labeled list of inputs that produced the agent's trust_score — baseline, mode, OS bonus, warmup, country penalty, age, session count, harness signal. Tells you which knob to turn to raise the score for the next provision (e.g. add a warmup_recipe, or promote burner → persistent).

curl https://browsa.io/v1/agents/agt_/trust \
  -H "X-API-Key: agt_live_…"

Fingerprint preview

GET /v1/agents/{id}/fingerprint The exact wire fingerprint this identity will present

Returns the navigator + screen + WebGL + timezone + canvas + audio + TLS fingerprint preview the burner will emit. Lets you pin an identity and validate it against your own anti-fingerprint test suite before running tasks. See the full per-OS spec on the anti-detect spec page.

curl https://browsa.io/v1/agents/agt_/fingerprint \
  -H "X-API-Key: agt_live_…"

Detection-harness signal

GET /v1/agents/{id}/detection-signal Live harness verdict for this fingerprint shape

Returns the most-recent detection-harness pass rate for the identity's fingerprint shape — the empirical evidence behind the trust_score. Includes sites_tested + sites_passed + per-site verdicts when available. Returns {signal: null} when no recent harness data exists (then the score falls back to the static formula). The harness publishes new signals daily.

curl https://browsa.io/v1/agents/agt_/detection-signal \
  -H "X-API-Key: agt_live_…"

AI tasks

If you don't want to wire Playwright yourself, hand the task to us as natural language. We provision a burner, point a Claude or GPT agent at it, and return the structured result.

POST /v1/tasks Async — returns a job id
curl https://browsa.io/v1/tasks \
  -H "X-API-Key: agt_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "task": "On Amazon, search wireless earbuds under $50, return top 5 ASINs.",
    "llm": "anthropic:claude-haiku-4-5",
    "max_steps": 20,
    "initial_url": "https://www.amazon.com",
    "os_type": "OS_TYPE_MACOS",
    "country": "us"
  }'

Request body

FieldTypeNotes
taskstringRequired. Natural-language goal.
llmstringanthropic:claude-haiku-4-5, openai:gpt-4o-mini, etc. Defaults to gpt-4.1-mini.
llm_api_keystringRequired (BYOK). Your Anthropic / OpenAI key. Used once, never stored.
max_stepsintegerHow many browser actions before the agent gives up. Default 20, cap 100.
initial_urlstringURL the browser opens before the agent starts.
os_typestringBurner fingerprint family: OS_TYPE_MACOS, OS_TYPE_WINDOWS, OS_TYPE_LINUX. Empty = default (macOS).
countrystringISO-2 ("us", "de", …). Routes egress through the matching geo proxy; +1 credit non-US.

Response is a Job envelope (see Jobs) with kind: "task", plus framework, live_url (noVNC for the running burner), agent_id, and step_count. To inspect the burner's anti-detect quality, GET /v1/agents/{agent_id} for the trust_score — see Trust score below.

Quick scrape

POST /v1/quick/scrape HTML + text + links in one call

One-call shape for "give me the content of this URL". Pass async=true for a job queue + webhook flow; otherwise the call blocks until the page is rendered (60s cap).

curl https://browsa.io/v1/quick/scrape \
  -H "X-API-Key: agt_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://news.ycombinator.com",
    "formats": ["html", "links"],
    "async": true
  }'

Formats (default ["html"]):

ValueReturns
htmlRendered HTML of the landed URL as a string.
textVisible text only, capped at max_chars (default 100,000).
linksArray of every href the page exposed.
screenshotFull-page PNG, base64-encoded in screenshot_png_b64.

Unknown format strings are silently ignored. markdown is not currently supported — convert from html client-side if you need it.

Quick extract

POST /v1/quick/extract Page text + a schema/prompt for structured extraction

Returns the rendered page's clean text plus either the JSON schema or natural-language prompt you supplied — ready for a client-side LLM extraction pass. Either schema or prompt is required (not both). The LLM step happens client-side using your BYOK key; the platform doesn't consume LLM tokens here.

curl https://browsa.io/v1/quick/extract \
  -H "X-API-Key: agt_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "prompt": "return the H1 text"
  }'

Or use a strict schema:

{
  "url": "https://example.com",
  "schema": {
    "type": "object",
    "properties": {
      "title": { "type": "string" },
      "h1": { "type": "string" }
    }
  }
}

Response includes url (landed), text (cleaned page text), schema (echoed/derived), agent_id, trust_score, cost_credits.

Quick screenshot

POST /v1/quick/screenshot Full-page PNG, base64-encoded

Returns a base64-encoded PNG in screenshot_png_b64. Useful for visual regression / archival. Default full_page: true grabs the entire scrollable area; pass false for viewport-only.

curl https://browsa.io/v1/quick/screenshot \
  -H "X-API-Key: agt_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "full_page": true
  }'

Quick batch

POST /v1/quick/batch Fan-out N scrapes against one job

Submits up to a few hundred URLs in one call. Always async — returns a job_id; poll /v1/jobs/{id} for progress and aggregate results. Per-URL failures don't fail the batch; each result row carries its own status.

curl https://browsa.io/v1/quick/batch \
  -H "X-API-Key: agt_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "urls": [
      "https://example.com",
      "https://example.org"
    ],
    "formats": ["text", "links"]
  }'

Response includes job_id, kind: "batch", progress_total (URL count), status_url, result_url. Concurrency is capped server-side; oversized batches are accepted but stream through 4-8 parallel workers per workspace.

Smart router

If you don't know whether your task needs a full LLM agent or just a plain fetch, send it to POST /v1/auto and the platform picks the fastest path. Fetch-only prompts return in ~5 seconds (skips the LLM agent entirely); interactive prompts fall through to the regular /v1/tasks async flow.

POST /v1/auto Heuristic-routed: synchronous scrape OR async task
curl https://browsa.io/v1/auto \
  -H "X-API-Key: agt_live_…" \
  -H "Content-Type: application/json" \
  -d '{"task": "Go to https://example.com and return the h1 text."}'

The response carries routed_via + routing_reason so you can branch your client code:

routed_viaHTTPBodyLatency
"scrape"200 OKSynchronous scrape result (url, http_status, html, text, agent_id, trust_score, cost_credits, duration_ms)~5 s
"task"202 AcceptedJob envelope — same shape as POST /v1/tasks. Poll /v1/jobs/{id}.job-dependent

Heuristic: the platform routes to scrape when the task contains a single URL plus a fetch verb (go to, visit, fetch, return, extract, read, navigate to, open) AND has none of the interactive verbs that need a real agent: click, type, fill, submit, log in, sign up, scroll, press, select, choose, hover, search for, filter, sort by, add to cart, checkout. Any whiff of interactivity falls through to the full agent. False negatives (we routed to task when scrape would have worked) waste 25-60s; false positives (we routed to scrape when the task needed clicks) would produce a wrong answer — the heuristic is conservative on purpose.

The fetch-only path does not require an llm_api_key (no LLM call is made). The interactive path requires it, same as /v1/tasks.

Jobs

Every async call (/v1/tasks, /v1/quick/scrape with async=true, batch, crawl) creates a job that runs in the background. Poll it, or subscribe to job.completed / job.failed webhooks.

Get a job

GET /v1/jobs/{id} Full envelope incl. result

Returns the full Job envelope. statusqueued | running | completed | failed | cancelled | input_required. On completed, the result object is populated with the kind-specific payload. On failed, error_code + error_message describe the failure.

Happy path — valid UUID, terminal job:

HTTP/1.1 200 OK
content-type: application/json
x-request-id: c40ac45a-34ed-48a4-b2ab-8059c3527a12

{
  "id":              "252e8eac-ea4f-415c-bd8f-242e33c78422",
  "status":          "completed",
  "kind":            "task",
  "framework":       "browser-use",
  "step_count":      18,
  "progress_done":   18,
  "progress_total":  25,
  "credits_charged": 6,
  "started_at":      "2026-06-08T16:18:36Z",
  "completed_at":    "2026-06-08T16:22:11Z",
  "live_url":        "https://browsa.io/stream-view/.../...",
  "result": {
    "final_result": "Top HN story: 'Show HN: …'",
    "trust_score":  863,
    "steps_taken":  18
  }
}

Error envelope — same shape across every non-2xx:

GET /v1/jobs/not-a-uuid  →  HTTP/1.1 400 Bad Request

{
  "code":       "INVALID_ID",
  "error":      "job id must be a UUID (e.g. 252e8eac-…)",
  "request_id": "1d79bb6e-91f1-4f07-8b30-f6b1567977df"
}

Every error carries code + error + request_id and the same x-request-id header — log that pair on your side; support tickets are way faster to triage with it.

Long-poll — pass ?wait=<seconds> (1–120) to block server-side until the job reaches a terminal state. Saves you a polling loop when you can afford to wait:

curl "$API/v1/jobs/$ID?wait=30" -H "X-API-Key: $KEY"

Poll lightweight status

GET /v1/jobs/{id}/status Cheap shape for polling loops

Returns { id, status, kind, progress_done, progress_total } only — no result body. Use when you're polling in a tight loop and don't want to pull the full result every tick.

Cancel a job

POST /v1/jobs/{id}/cancel Cooperative — finishes the current step then stops

The agent stops between steps once it sees the cancel flag. You're billed only for steps actually taken. Returns 409 if the job is already terminal.

Webhooks

Register a callback URL and we'll POST signed JSON every time a job hits a terminal state — no polling required.

Scope: webhooks fire only on async jobs — that is, any call that returns a job_id (POST /v1/tasks, /v1/quick/* with async: true, /v1/quick/batch, /v1/quick/crawl). Synchronous /v1/quick/* calls do NOT fire webhooks — the customer already has the response in-hand, so there is nothing to push. Use async: true on quick endpoints if you need webhook notifications.
POST /v1/webhooks Register a subscription
curl https://browsa.io/v1/webhooks \
  -H "X-API-Key: agt_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/hooks/browsa",
    "events": ["job.completed", "job.failed"]
  }'

Verify the signature

Every delivery carries an X-Agents-Signature header: an HMAC-SHA256 over the raw request body, keyed by your workspace's signing secret. Fetch the secret with GET /v1/webhooks/signing-secret (or copy it from the dashboard).

import hmac, hashlib

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)
At-least-once delivery. The same event id may arrive twice (network blips, our retries). Dedupe by storing the id field of each event payload.

Event types

Every event delivery is a POST with this envelope:

{
  "id":           "evt_a1b2c3d4e5f6…",
  "type":         "job.completed",
  "created_at":   "2026-06-09T08:12:44.523Z",
  "workspace_id": "<uuid>",
  "data":         { ...event-specific shape... }
}

The full event catalogue:

typefires whendata shape
agent.provisioned An agent finished booting and is ready to drive {agent_id, mode, profile_id, trust_score, expires_at, cost_credits, cdp_url?, live_url?}
agent.destroyed TTL expired or DELETE /v1/agents/{id} was called {agent_id, mode, destroyed_at, reason}
agent.failed Provisioning failed before the agent reached running {agent_id, error_code, error_message}
agent.session.started Persistent-mode: new cloud session opened on a saved profile {agent_id, session_id, cdp_url, started_at, cost_credits}
agent.session.ended Persistent-mode: cloud session ended (TTL or stop) {agent_id, session_id, ended_at, reason}
agent.renewed Persistent agent's monthly billing cycle completed {agent_id, renewal_credits, next_renewal_at}
agent.renewal_failed Persistent agent renewal failed (usually insufficient credits) — agent will be destroyed {agent_id, error_code, error_message}
job.completed Any async job (/v1/tasks, /v1/quick/* async=true, batch, crawl) reached terminal success {job_id, kind, progress_done, progress_total, credits_charged, result}
job.failed Async job ended in failed state {job_id, kind, error_code, error_message}error_code is one of the job failure codes
webhook.test You called POST /v1/webhooks/{id}/ping (or /test — alias) {message} — synthetic ping payload

Filtering: pass events: ["job.completed", "agent.session.started"] on registration to receive only those types. Empty array ([]) or unset = all event types delivered.

Test a delivery

POST /v1/webhooks/{id}/ping fires a synthetic webhook.test event at the URL so you can verify your handler before relying on it in production. (Aliased to POST /v1/webhooks/{id}/test; both work identically.)

Inspect deliveries

GET /v1/webhooks/{id}/deliveries Per-delivery audit log, newest first

Returns the last limit (default 50, max 200) delivery attempts: event_id, event_type, status_code, delivered_at, duration_ms, error, attempt. Use this to debug a receiver that's silently 5xx'ing.

Credits

GET /v1/credits Current balance + per-unit prices

Returns your current balance in credits, plus the price table for each agent kind (burner, persistent session, task step). Fresh accounts get 100 credits on signup, no card required.

Ledger

GET /v1/credits/ledger Last 100 debits and credits

Returns the recent ledger entries with running balance — what was spent, when, and why. Mirrors the Credits tab.

API keys

Mint, list, and revoke from the dashboard or programmatically.

GET/v1/keysList active keys (no secrets)
POST/v1/keysMint — returns the secret once
DELETE/v1/keys/{id}Revoke immediately
The raw secret is shown exactly once. We store only sha256(secret). If you lose it, revoke + mint a new one.

Each list item carries a _links.revoke hint — an HTTP method + relative path you can hit directly. The field is present only on active keys (omitted from rows where revoked_at is non-null). Client typing pattern:

{
  "id": "7a4185d4-…",
  "name": "ci-pipeline",
  "prefix": "agt_live_9328d",
  "created_at": "2026-06-09T04:42:37Z",
  "last_used_at": null,
  "revoked_at": null,
  "_links": {
    "revoke": { "method": "DELETE", "href": "/v1/keys/7a4185d4-…" }
  }
}

SDKs

Zero-dependency wrappers around the REST API with typed responses, long-poll waitForJob, HMAC webhook signature verify, and the full {code, error, request_id} envelope mapped to typed errors. Identical surface in Python and TypeScript.

Python

pip install browsa
from browsa import Client

c = Client(api_key="agt_live_...")
job = c.run_task(
    task="Go to news.ycombinator.com and return the top story title.",
    llm="claude-opus-4-7",
    llm_api_key="sk-ant-...",
)
print(job.final_result)
print("watch live:", job.live_url)

Source: sdks/neout-agents-python — Python 3.9+, zero deps, MIT.

TypeScript / Node

npm i browsa
import { Client } from "browsa";

const c = new Client({ apiKey: "agt_live_..." });
const job = await c.runTask({
  task: "Go to news.ycombinator.com and return the top story title.",
  llm: "claude-opus-4-7",
  llmApiKey: "sk-ant-...",
});
console.log(job.finalResult);
console.log("watch live:", job.liveUrl);

Source: sdks/neout-agents-ts — Node 18+, zero deps, ESM + CJS, full .d.ts, MIT.

MCP server — Claude Desktop, Cursor, Cline

Drive the platform from any MCP-compatible client with one config block. The agent gets 9 tools: run_task, get_job, cancel_job, respond_to_job, credits, list_webhooks, create_webhook, delete_webhook, list_identities.

{
  "mcpServers": {
    "browsa": {
      "command": "npx",
      "args": ["-y", "@browsa/mcp"],
      "env": {
        "BROWSA_API_KEY": "agt_live_...",
        "BROWSA_LLM_API_KEY": "sk-ant-..."
      }
    }
  }
}

Drop this in ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows), then restart Claude Desktop. Ask: "Go to news.ycombinator.com and tell me the top story title."

Source: sdks/neout-mcp-agents — works in Claude Desktop, Cursor, Cline, Continue, Zed, MIT.

Examples

Working scripts on GitHub: python examples — quickstart, webhook receiver. TypeScript examples — same surface area.

OpenAPI spec

Import into Postman, Insomnia, Swagger UI, or any OpenAPI 3.1 client — the full surface (18 endpoints) is published at https://browsa.io/openapi.yaml.

# Generate a typed client in any language with openapi-generator-cli
npx @openapitools/openapi-generator-cli generate \
  -i https://browsa.io/openapi.yaml \
  -g typescript-axios -o ./neout-client

Errors

All error responses share a single envelope:

{
  "error": "insufficient credit",
  "code": "INSUFFICIENT_CREDIT",
  "request_id": "req_01HQ8Z…"
}
HTTPCodeWhat it means
400BAD_REQUESTValidation failed — check the error field
401UNAUTHORIZED / INVALID_API_KEYKey missing, revoked, or malformed
403FORBIDDENKey exists but lacks permission for this workspace
404NOT_FOUNDResource not in this workspace
402INSUFFICIENT_CREDITTop up at /dashboard/credits
409AGENT_NOT_RUNNINGYou called /cdp on a destroyed agent
429TOO_MANY_REQUESTSPer-key rate limit — respect Retry-After
502UPSTREAM_UNAVAILABLECloud runner unreachable — safe to retry
504UPSTREAM_TIMEOUTProvisioning took too long — retry with longer client timeout
500INTERNAL_ERROROur bug — quote request_id to support

Job failure codes

When a job terminates with status="failed", the error_code field tells you why. These are different from the HTTP codes above — they describe what went wrong during agent execution, not what happened to your HTTP request. error_message carries human-readable detail.

CodeWhat it meansHow to fix
STALLEDThe LLM agent ran step_count steps but never called done() with a final result. error_message distinguishes "ran out of steps" (raise max_steps) from "agent decided to give up" (LLM got lost or hit anti-bot).Raise max_steps, narrow the task wording, or use a more capable LLM (sonnet/opus instead of haiku).
LLM_ERRORThe LLM provider returned an error on every step. Usually wrong key, wrong model name, or upstream provider rate limit. error_message includes the upstream provider's verbatim error.Verify your BYOK key, model name, and that the provider isn't throttling you.
TIMEOUTThe wallclock budget (default 900s) was exceeded before the agent terminated.Pass a higher wallclock_seconds on the create request, or split the task into smaller steps.
CANCELLEDYou fired POST /v1/jobs/{id}/cancel mid-run. No charge for the partial work.Re-submit if cancellation was unintentional.
INSUFFICIENT_CREDITWorkspace balance hit 0 before provisioning could complete.Top up at /dashboard/credits.
INVALID_API_KEYThe BYOK LLM key was rejected by the provider mid-run.Re-check the key, base URL, and model name.
RATE_LIMITThe BYOK LLM provider rate-limited and exhausted our retry budget.Lower task concurrency or upgrade the LLM-side plan.
INTERNAL_ERRORUnexpected platform failure during the run.Quote the job's request_id to support@browsa.io.

Rate limits

Today there is no enforced per-key request-rate limit — if your workspace has credits and is under the concurrent-burner cap, request volume is not metered. The platform applies two real caps:

  • Concurrent burner cap — provisioning more burners than your workspace allows returns 429 TOO_MANY_REQUESTS with Retry-After: 30. The cap rises automatically as existing burners expire (default 5min TTL). Email support to lift it.
  • Daily credit budget — once your balance hits 0 you get 402 INSUFFICIENT_CREDIT on the next provisioning attempt.

Per-key request-rate limits are on the near-term roadmap. Until then, well-behaved clients should respect Retry-After on any 4xx/5xx response.