API docs

Hosted Chromium with a Browserbase-compatible REST subset. Base URL https://api.thebrowserfarm.com. No sign-in is required to read this page. Create a key from sign-up or API keys. The quickstart page prints the base URL configured for this deployment and, right after sign-up, your key. Examples here always use the public API and the placeholder $BROWSERFARM_API_KEY.

Machine-readable spec: https://api.thebrowserfarm.com/openapi.json. Tool-using agents (open a browser, click, extract) use MCP, which is a different surface from this REST API.

Quickstart

curl

curl -X POST https://api.thebrowserfarm.com/v1/sessions \
  -H "X-BB-API-Key: $BROWSERFARM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"projectId":"default"}'

Node (Browserbase SDK + Playwright)

pnpm add @browserbasehq/sdk playwright-core
import Browserbase from "@browserbasehq/sdk";
import { chromium } from "playwright-core";

const bb = new Browserbase({
  apiKey: process.env.BROWSERFARM_API_KEY,
  baseURL: "https://api.thebrowserfarm.com",   // <- the only line you change
});

const session = await bb.sessions.create({
  projectId: process.env.BROWSERFARM_PROJECT_ID ?? "default",
});

// connectUrl is wss://<api host>/<id>/cdp?signingKey=<bfsk_…> on the
// public API (ws:// in local dev). Pass the URL through unchanged.
const browser = await chromium.connectOverCDP(session.connectUrl);
const page = (browser.contexts()[0] ?? (await browser.newContext())).pages()[0]
  ?? await browser.newPage();

await page.goto("https://example.com");
console.log(await page.title());

await browser.close();
// Closing the last CDP connection ends the session (keepAlive defaults to
// false). The explicit release is idempotent and also covers a session you
// created with keepAlive: true or never connected to.
await bb.sessions.update(session.id, {
  projectId: process.env.BROWSERFARM_PROJECT_ID ?? "default",
  status: "REQUEST_RELEASE",
});

Python

pip install playwright. connect_over_cdp does not need a local browser install.

import json, os, urllib.request
from playwright.sync_api import sync_playwright

BASE = "https://api.thebrowserfarm.com"
KEY = os.environ["BROWSERFARM_API_KEY"]

def call(method, path, body=None):
    data = None if body is None else json.dumps(body).encode()
    req = urllib.request.Request(
        BASE + path,
        data=data,
        method=method,
        headers={"X-BB-API-Key": KEY, "Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req) as res:
        return json.load(res)

session = call("POST", "/v1/sessions", {"projectId": "default"})

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(session["connectUrl"])
    context = browser.contexts[0]
    page = context.pages[0] if context.pages else context.new_page()
    page.goto("https://example.com")
    print(page.title())
    browser.close()  # ends the session unless it was created with keepAlive

call("POST", "/v1/sessions/" + session["id"], {"status": "REQUEST_RELEASE"})

Device flow

An agent that cannot paste a key can ask for one. It calls POST /api/device/code and shows you a code. Approve that code on /device, and the agent receives a normal API key the next time it calls POST /api/device/poll — shown to the agent once, revocable from API keys. The approval page shows the key name, when the code was requested, and the requesting IP and client: only approve a request you started yourself. Approval and denial need a signed-in browser session and use POST /api/device/approve and POST /api/device/deny with the code; a code can still be denied after approval until the agent polls. Codes expire after 10 minutes, and a denied or expired code never yields a key. The key itself is a normal key: no scope, no expiry on the key, no separate cap.

Auth

Every /v1 request needs X-BB-API-Key: <key> or Authorization: Bearer <key>. If both are sent, X-BB-API-Key wins, including when that header is empty. MCP accepts the same two headers. The key is shown once at creation and starts with bf_live_; only its hash is stored. A missing or unknown key is 401. A revoked key is 403.

Create a session

POST /v1/sessions returns 201 and one session object. An empty body is valid. Fields that do something:

  • projectId: echoed back. Missing or empty becomes "default". There is no project store.
  • userMetadata: an object, echoed back. It is not searchable.
  • browserSettings.viewport: { "width": 1280, "height": 720 } with positive numbers. Anything else is ignored.
  • browserSettings.fingerprint.devices: include "mobile" for a mobile viewport hint. Otherwise desktop.
  • keepAlive: default false. See Timeouts, keepAlive and idle reaping.
  • isolation: "context" (the default) or "vm". Anything else is 400. context is a browser context in a shared Chromium. vm is a Firecracker microVM of its own, on the dev tier only; that pool is small, so when it is full create returns 503 with Retry-After. The production API currently serves context only: vm is accepted there and runs as a context session.

Ignored, not errors: proxies, region, timeout, api_timeout, browserSettings.blockAds, solveCaptchas, recordSession, contextId, extensionId.

The session object has id (UUID), createdAt, updatedAt, startedAt, endedAt (null until it ends), projectId, status (RUNNING while the browser is yours, then COMPLETED, TIMED_OUT, or ERROR), keepAlive, region, connectUrl, userMetadata, seleniumRemoteUrl: null, and signingKey. There is no expiresAt and no proxyBytes.

Connect over CDP

connectUrl is wss://<api host>/<id>/cdp?signingKey=<bfsk_… token>. Pass it through unchanged to Playwright connectOverCDP or Puppeteer connect. The path id is not a credential. The handshake requires that signingKey, or the owning customer's API key in the X-BB-API-Key header (not as a query parameter). A presented signingKey that does not match does not fall through to the API key. A live session with no valid credential is 401 (plain text).

signingKey is a per-session bfsk_… token, the same value as the signingKey query on connectUrl, valid for 24 hours from issue; GET the session for a fresh connectUrl. To revoke a leaked connectUrl, release the session: the key is stateless until it expires. It is not a Selenium secret. A released or unknown id is 404 even with a valid key. A non-WebSocket request to that path returns 426.

curl -s https://api.thebrowserfarm.com/v1/sessions/$SESSION_ID \
  -H "X-BB-API-Key: $BROWSERFARM_API_KEY"

List and get

GET /v1/sessions returns a JSON array of this account's sessions (not wrapped in an object). ?status= filters to PENDING, RUNNING, COMPLETED, ERROR, TIMED_OUT; omitting it returns every status, and an unknown value is 400. ?q= is ignored. GET /v1/sessions/<id> returns one session, or 404 session not found (including a malformed id).

This list is the gateway process's memory, not a durable archive. On the public API a restart drops it, including running sessions. A terminal session may still include connectUrl; reconnecting returns 404 unknown or released session (plain text, not JSON).

curl -s "https://api.thebrowserfarm.com/v1/sessions?status=RUNNING" \
  -H "X-BB-API-Key: $BROWSERFARM_API_KEY"

Release

POST /v1/sessions/<id> with {"status":"REQUEST_RELEASE"} ends the session. The response is 200 and the session object with status COMPLETED. Live CDP sockets are closed. Releasing again is 200 and does not double-destroy. Any other status is 400.

curl -s -X POST https://api.thebrowserfarm.com/v1/sessions/$SESSION_ID \
  -H "X-BB-API-Key: $BROWSERFARM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status":"REQUEST_RELEASE"}'

Errors

Every error body is one JSON object with a message string. There is no error or code field on /v1.

StatusWhenBody
401No API key{"message":"missing API key: send X-BB-API-Key or Authorization: Bearer <key>"}
401Key not recognized{"message":"unknown API key"}
403Key revoked{"message":"API key has been revoked"}
400Body is not JSON{"message":"invalid JSON body"}
400Update status is not REQUEST_RELEASE{"message":"unsupported update: only { \"status\": \"REQUEST_RELEASE\" } is implemented in Phase 0a"}
400?status= is not PENDING, RUNNING, COMPLETED, ERROR, or TIMED_OUT{"message":"invalid status \"NOPE\": expected one of PENDING, RUNNING, COMPLETED, ERROR, TIMED_OUT"}
400isolation is present and is not context or vm{"message":"invalid isolation \"process\": expected one of context, vm"}
404No such session for this account, including a malformed id (someone else's session looks the same){"message":"session not found"}
404A /v1 route we do not implement (logs, /debug, contexts, projects, …){"message":"not implemented in Phase 0a"}
429Account is at its concurrent-session cap. Header Retry-After: 5{"message":"concurrency limit reached: N of M sessions running. Release a session (or wait for one to idle out) and retry."}
503No browser capacity, including a full vm pool on the dev tier. Header Retry-After (seconds; 5 when the host does not name one){"message":"no browser capacity available right now; retry shortly"}
502The browser could not be allocated for any other reason{"message":"failed to allocate a browser session"}
426Plain HTTP GET on /<id>/cdp. That path is a WebSocket{"message":"upgrade required: this endpoint speaks WebSocket"}
500Unexpected failure{"message":"internal error"}

Concurrency

The only enforced limit is how many sessions an account may have running at once. The default is 10 (concurrentCap on the customer; all of that account's keys share it). At the cap, create returns 429 and Retry-After: 5. There is no requests-per-second or other rate limit. 503 is different: the farm has no capacity to place a browser, and it also sends Retry-After.

Timeouts, keepAlive and idle reaping

Closing CDP ends the session unless it was created with keepAlive: true. When the last CDP connection closes (Playwright or Puppeteer browser.close(), or a dropped or failed connection) the status becomes COMPLETED and the next connect is 404. With keepAlive: true the session stays RUNNING after a disconnect and the same connectUrl accepts another client until you release it or it idles out. A CDP Browser.close command, which is what Puppeteer's browser.close() sends, ends the session either way. There is no maximum lifetime; timeout on create is ignored. A client that keeps sending CDP frames, including a heartbeat, holds the session indefinitely.

  • 10 minutes without a CDP frame (CONTEXT_IDLE_TIMEOUT_MS, default 600000) releases the session as TIMED_OUT. The reaper sweeps every 30 seconds, so the cut lands on the next sweep after those 10 minutes.
  • 15 minutes (BROWSER_IDLE_TIMEOUT_MS) is how long a shared Chromium stays up after its last session ends, in the in-process and sidecar setups. It is not a session lifetime. Hosted placement does that browser-level idle in the host daemon, not as a second timer on your session.
  • The public API does not restore sessions after a restart. Where a gateway does restore a running placement, one nobody reconnects to is reaped after a 60 second grace, not the full 10 minutes.

Region

region in the response is always the string us-west-2. Nothing in the API chooses a region. A region on the create body is ignored. Treat the field as informational so typed clients do not choke on it.

Health

GET /healthz and GET /api/health need no API key. Both return HTTP 200, including when the browser backend is down. The body's status is "ok" or "degraded", service is "gateway", and sessions counts live and total sessions. environment is the process NODE_ENV, not which site you called.

curl -s https://api.thebrowserfarm.com/healthz

Not implemented

These are not live. Calling the REST paths returns 404 with {"message":"not implemented in Phase 0a"}.

  • Session logs (GET /v1/sessions/<id>/logs).
  • Live view (GET /v1/sessions/<id>/debug) and replay / recording.
  • Contexts, projects, extensions, uploads, and downloads.
  • Residential egress. Proxies on create are ignored.