# Get account billing details Source: https://docs.driver.dev/api-reference/account/get-account-billing-details /api-reference/openapi.json get /v1/account/billing Returns the authenticated account’s complete billing summary, including its plan limits, current usage period, available wallet balance, and subscription state. Organization accounts use Driver Billing data. Unmigrated legacy accounts return their wallet balance with `plan`, `period`, and `subscription` set to null. # Get balance Source: https://docs.driver.dev/api-reference/account/get-balance /api-reference/openapi.json get /v1/account/balance Retrieve the current balance. # Delete an extension Source: https://docs.driver.dev/api-reference/extensions/delete-an-extension /api-reference/openapi.json delete /v1/browser/extensions/{extensionId} Delete an uploaded extension. Requires account-level allowlist access. # Get an extension Source: https://docs.driver.dev/api-reference/extensions/get-an-extension /api-reference/openapi.json get /v1/browser/extensions/{extensionId} Get details of a specific extension. Requires account-level allowlist access. # List extensions Source: https://docs.driver.dev/api-reference/extensions/list-extensions /api-reference/openapi.json get /v1/browser/extensions List all uploaded extensions. Requires account-level allowlist access. # Update an extension Source: https://docs.driver.dev/api-reference/extensions/update-an-extension /api-reference/openapi.json put /v1/browser/extensions/{extensionId} Update an extension by uploading a new ZIP file. The extension ID remains the same. Requires account-level allowlist access. # Upload an extension Source: https://docs.driver.dev/api-reference/extensions/upload-an-extension /api-reference/openapi.json post /v1/browser/extensions Upload a Chrome extension ZIP file. The extension will be packed into CRX format and made available for use in browser sessions. Requires account-level allowlist access — returns 403 if your account is not enabled. # API overview Source: https://docs.driver.dev/api-reference/overview Base URL, authentication, conventions, and the live OpenAPI document these pages come from. ## Base URL ```text theme={"dark"} https://api.driver.dev ``` ## Authentication Every endpoint takes a workspace API key as a bearer token. Keys come from **Settings → API keys** in the [dashboard](https://app.driver.dev). ```bash theme={"dark"} Authorization: Bearer $DRIVER_API_KEY ``` ## Conventions * Bodies are JSON, except extension uploads (`multipart/form-data`). * Identifiers go in the query string (`?sessionId=…`, `?profileName=…`), not the path. Extensions and pools are the exception (`/v1/browser/pools/{poolId}`). * Errors are `{ "error": "message" }` with a meaningful HTTP status. The create call's `503` also carries a `code`. Pools answer `409` for state conflicts: duplicate name, over the warm cap, nothing ready, active leases. See [Errors](/docs/sessions/errors). * Session timestamps are ISO 8601. Billing periods are Unix epoch milliseconds (fields ending in `_ms`, plus the subscription period bounds). Money is cents, or millicents where exactness matters. Bandwidth is bytes on sessions and megabytes on billing. * Unknown fields in the create body are a `400`. The update call ignores them. * Create answers `200` with the session. Extension upload and pool create answer `201`. ## Endpoints | Group | Endpoints | | - | - | | Sessions | `POST/GET/PATCH/DELETE /v1/browser/session`, `GET /v1/browser/sessions` | | Pools | `POST/GET /v1/browser/pools`, `GET/PATCH/DELETE /v1/browser/pools/{poolId}`, `POST /v1/browser/pools/{poolId}/acquire`, `…/release`, `…/flush` | | Profiles | `GET /v1/browser/profiles`, `DELETE /v1/browser/profile` | | Network | `GET /v1/network/ips` (dedicated IPs) | | Account | `GET /v1/account/balance`, `GET /v1/account/billing` | | Extensions | `POST/GET /v1/browser/extensions`, `GET/PUT/DELETE /v1/browser/extensions/{extensionId}` | ## Source of truth These pages are generated from the live OpenAPI document at `https://api.driver.dev/doc` (rendered at [api.driver.dev/scalar](https://api.driver.dev/scalar)), with corrections where the generator is wrong: `duration` accepts 60 (the live document says strictly more), `balance_cents` can be negative, `PATCH` answers `400` for an ended session, `GET /v1/browser/sessions` answers `400` for a bad status filter, the `503` carries `Retry-After` and a `code`, paging has defaults and a maximum, and pool create answers `201` only. On those points this reference is right. On everything else the live document wins. ## Client code There's no SDK. The endpoints are plain HTTP; [For agents](/docs/start/for-agents#reference-implementation) has a small client module for TypeScript and Python you can copy. # Acquire a browser Source: https://docs.driver.dev/api-reference/pools/acquire-a-browser /api-reference/openapi.json post /v1/browser/pools/{poolId}/acquire Take a ready browser. From here on the session belongs to the workspace like any other; the pool warms a replacement. Its createdAt is when the browser was warmed. # Create a pool Source: https://docs.driver.dev/api-reference/pools/create-a-pool /api-reference/openapi.json post /v1/browser/pools Create a browser pool and begin warming ordinary browser sessions. # Delete a pool Source: https://docs.driver.dev/api-reference/pools/delete-a-pool /api-reference/openapi.json delete /v1/browser/pools/{poolId} Delete a pool. Active leases require force=true. # Flush warm browsers Source: https://docs.driver.dev/api-reference/pools/flush-warm-browsers /api-reference/openapi.json post /v1/browser/pools/{poolId}/flush Stop every ready browser and warm fresh ones. Leased browsers are not touched. Answers how many were stopped. # Get a pool Source: https://docs.driver.dev/api-reference/pools/get-a-pool /api-reference/openapi.json get /v1/browser/pools/{poolId} Get browser pool state. # List pools Source: https://docs.driver.dev/api-reference/pools/list-pools /api-reference/openapi.json get /v1/browser/pools List browser pools. # Release a browser Source: https://docs.driver.dev/api-reference/pools/release-a-browser /api-reference/openapi.json post /v1/browser/pools/{poolId}/release End a leased browser. The session is stopped; the answer carries status "ended". Idempotent: releasing the same lease again answers the same body. # Update a pool Source: https://docs.driver.dev/api-reference/pools/update-a-pool /api-reference/openapi.json patch /v1/browser/pools/{poolId} Resize, pause, resume, or reconfigure a browser pool. # Delete a profile Source: https://docs.driver.dev/api-reference/profiles/delete-a-profile /api-reference/openapi.json delete /v1/browser/profile Delete a browser profile by name. # List profiles Source: https://docs.driver.dev/api-reference/profiles/list-profiles /api-reference/openapi.json get /v1/browser/profiles List all browser profiles. # Create a session Source: https://docs.driver.dev/api-reference/sessions/create-a-session /api-reference/openapi.json post /v1/browser/session Create a browser session and wait for CDP readiness. hosted, hosted_stealth, and hosted_privacy are hosted browsers; hosted_stealth applies the conservative stealth policy and hosted_privacy the aggressive one. Hosted Canadian sessions without an explicit proxy egress from Driver's Canadian ISP network. Country defaults to US; omitted locale and proxy values are resolved coherently. # Get a session Source: https://docs.driver.dev/api-reference/sessions/get-a-session /api-reference/openapi.json get /v1/browser/session Retrieve an existing browser session. # List sessions Source: https://docs.driver.dev/api-reference/sessions/list-sessions /api-reference/openapi.json get /v1/browser/sessions List browser sessions with pagination. # Stop a session Source: https://docs.driver.dev/api-reference/sessions/stop-a-session /api-reference/openapi.json delete /v1/browser/session Stop an existing browser session. # Update a session note Source: https://docs.driver.dev/api-reference/sessions/update-a-session-note /api-reference/openapi.json patch /v1/browser/session Update an existing browser session. Currently supports updating the session note. # API keys Source: https://docs.driver.dev/docs/account/api-keys Keys are per workspace, created in the dashboard, sent as a bearer token. Every workspace has a root key and can create named keys under **Settings → API keys** in the [dashboard](https://app.driver.dev). Your own name, email and password live under [Account](/docs/account/dashboard). All keys of a workspace share its plan, credits and concurrency limit. ```bash theme={"dark"} Authorization: Bearer $DRIVER_API_KEY ``` ## Handling keys * Keep the key in an environment variable named `DRIVER_API_KEY`. Don't commit it or paste it into prompts, tickets or logs. * One key per integration, so a leaked key can be rolled without touching the others. Rolling invalidates the old value immediately. * The root key can't be deleted, only rolled. * Keys belong to the workspace, not the person. Removing someone from the workspace doesn't invalidate keys they created. Roll them if that matters. ## Which workspace am I in A key belongs to one workspace. Every session it creates is billed to that workspace and listed under its Telemetry. If you're in several workspaces, check the switcher at the top of the dashboard before creating a key. ## Errors `401`: the key is missing, malformed or rolled. `402` and `403` are about the workspace's credits and plan, not the key. See [Errors](/docs/sessions/errors). # Billing Source: https://docs.driver.dev/docs/account/billing Sessions are metered by bandwidth against the workspace's credits. Two endpoints expose balance and plan. Driver bills on **bandwidth**: bytes a session moves, metered per megabyte against the workspace's credits. A plan includes a monthly allowance and a concurrency limit; usage past the allowance draws on purchased credits. A session that moves no bytes costs nothing but holds a concurrency slot until it stops. | Plan | Price | Included bandwidth | Concurrent browsers | | - | - | - | - | | Free | \$0 | 1,024 MB | 5 | | Advanced | \$49 / month | 10,240 MB | 30 | | Scale | \$129 / month | 51,200 MB | 100 | Bandwidth is $5.00 per 1,024 MB, and a plan's allowance is granted as credits at that rate: $5.00 on Free, $50.00 on Advanced, $250.00 on Scale. Scale's credits are worth more than its price, which is the volume deal. Included credits expire at the end of the period; purchased credits roll over. Cost control is short sessions, stopped when the job is done. `duration` on create caps what a forgotten session can cost. Warm browsers in a [pool](/docs/sessions/pools) aren't billed and hold no slot; a browser becomes a billed, counted session when acquired, and the lease timeout caps how long it can be held. [Dedicated IPs](/docs/options/proxies#dedicated-ips) are the one thing not paid in credits: a subscription line on your card at \$5 per address per month. Adding one mid-month is charged right away, prorated; switching off Renews keeps it until the end of the paid month, with no refund. Bandwidth through them is metered like any other session. ## Balance ```bash cURL theme={"dark"} curl https://api.driver.dev/v1/account/balance -H "Authorization: Bearer $DRIVER_API_KEY" ``` ```typescript TypeScript theme={"dark"} const { balance } = await fetch("https://api.driver.dev/v1/account/balance", { headers }) .then((r) => r.json()); ``` ```python Python theme={"dark"} balance = requests.get("https://api.driver.dev/v1/account/balance", headers=headers).json() balance = balance["balance"] ``` ```json theme={"dark"} { "balance": { "balance_cents": 1250, "network_mb_remaining": 500, "status": "active" } } ``` `balance_cents` can go below zero. `network_mb_remaining` (allowance left this period) and `status` (the plan key, for example `free`) are present on workspace accounts and absent on legacy accounts. Sessions are refused with `402` once the balance is negative and the allowance is gone. ## Billing details `GET /v1/account/billing` returns everything: `orgId`; `status` (`legacy` for an account not yet on workspace billing); the plan (`plan_key`, `display_name`, `price_monthly_cents`, `included_network_mb`, `overage_millicents_per_mb`, `concurrent_browsers`, `max_session_seconds`); the current period (`start_ms`, `end_ms`, `included_network_mb`, `network_mb_used`, `network_mb_remaining`); the wallet (`balance_cents`, can be negative, and `balance_millicents`); and the subscription if there is one (`subscription_id`, `provider`, `status`, `current_period_start`, `current_period_end`, `cancel_at_period_end`). `plan`, `period` and `subscription` are `null` on a legacy account. Fields ending in `_ms` and the subscription period bounds are Unix epoch milliseconds. ```bash theme={"dark"} curl https://api.driver.dev/v1/account/billing -H "Authorization: Bearer $DRIVER_API_KEY" ``` Use it for usage in your own tooling, or to stop launching before you hit the limit. Amounts are cents; `millicents` fields are for exact arithmetic (1000 millicents = 1 cent). ## Where to change things Plan changes, credits, auto top-up, invoices and the card on file are under **Billing** in the [dashboard](https://app.driver.dev/settings/billing). There's no API for payments. # Dashboard Source: https://docs.driver.dev/docs/account/dashboard Sessions and live view, the Workbench, profiles, pools, keys, members, your account and billing. The [dashboard](https://app.driver.dev) is the other half of the API. Everything is per workspace; switch at the top of the sidebar. | Area | What's there | | - | - | | **Overview** | Running browsers against the plan limit, today's sessions, bandwidth this period, credits, a 30-day chart. **Launch browser** takes the same options as [`POST /v1/browser/session`](/docs/sessions/create#options) (network or proxy, country with its timezone and language, browser type, length, profile, CAPTCHA solver, ad blocking, extensions, start URL, window size, node, note), or a warm browser from a pool, and opens the live view. | | **Workbench** | Script editor, live browser, output log. Pick a template, set the session options (the same fields as the create call), press Run. The options reach the script's session exactly as the API receives them, so what works here works in code. | | **Profiles** | The workspace's saved [profiles](/docs/options/profiles). Launch a session on one to sign in by hand. | | **Browser pools** | [Pools](/docs/sessions/pools) of warm browsers: size, country, profile, start URL, lease timeout. Pause, flush or delete them; see how many are ready and in use. **Launch browser** on the Overview can take a browser from any ready pool. | | **Network** | Reserve and manage [dedicated IPs](/docs/options/proxies#dedicated-ips): country, quantity, label; the Renews switch per address; launch a session on one. | | **Sessions** (Telemetry) | Every session, filterable by status. Each record: live view while running, then node, country, bandwidth, cost and timestamps. Stop one, or stop all. | | **Account** | Your own name, email address and password. Changing the email or the password asks for your current password first. If you signed up with Google you may never have set one: the page can email you a link to set a password instead, and Google sign-in keeps working either way. Changing your email sends a six-digit code to the new address to confirm it. | | **Audit records** | Admin-only: who did what in the workspace. | | **Workspace** | Name, members, invitations. Admins manage members, keys and billing. Admins can also change another member's role between Admin and Member. | | **API keys** | The root key and named keys: create, roll, delete. Also a ready-to-paste briefing for your coding agent. | | **Billing** | Plan, credits, auto top-up, credit history, and the billing portal for invoices and cards. | | **Support** | Book a call or write to the team. | ## Hand off to an agent **Copy agent prompt** on the Overview and API keys pages gives Claude Code, Cursor or any coding agent the endpoints, the rules and, if you opt in, your API key. Pair it with [For agents](/docs/start/for-agents). # Browser Use Source: https://docs.driver.dev/docs/frameworks/browser-use Run Browser Use agents on a Driver browser by passing the session's cdpUrl. [Browser Use](https://browser-use.com) runs autonomous agents on a Chromium it controls. Pass it a Driver session's `cdpUrl` and it drives real Chrome on an ISP network instead. ```bash theme={"dark"} pip install browser-use requests ``` ```python Python theme={"dark"} import asyncio, os, requests from browser_use import Agent, Browser, ChatBrowserUse API = "https://api.driver.dev" headers = {"Authorization": f"Bearer {os.environ['DRIVER_API_KEY']}"} async def main(): r = requests.post( f"{API}/v1/browser/session", headers=headers, json={"country": "US", "duration": 900} ) r.raise_for_status() session = r.json() try: browser = Browser(cdp_url=session["cdpUrl"]) agent = Agent( task="Find the current price of Bitcoin", llm=ChatBrowserUse(), browser=browser ) result = await agent.run() print(result) finally: requests.delete( f"{API}/v1/browser/session", headers=headers, params={"sessionId": session["sessionId"]} ) asyncio.run(main()) ``` `ChatBrowserUse` takes a Browser Use Cloud model key (`BROWSER_USE_API_KEY`). Any LangChain-style chat model works in its place. ## Notes * Agents take many steps. Set `duration` to cover the task (up to 3600 seconds) and stop the session in `finally`. * Browser Use talks CDP directly, not through Playwright, so Patchright doesn't apply. The fingerprint problem is the same, and an agent framework issues more CDP commands than a plain script. Expect challenges on protected sites. * Pin the `browser-use` version you tested with. The `Browser` and `Agent` constructors have changed between releases. Passing `cdp_url` is what stops it from launching or downloading its own browser. * Watch the agent through the session's [live view](/docs/sessions/live-view). It's the fastest way to see where it goes wrong. # Crawl4AI Source: https://docs.driver.dev/docs/frameworks/crawl4ai Point Crawl4AI's managed browser at a Driver session to crawl from real Chrome on an ISP network. [Crawl4AI](https://crawl4ai.com) turns pages into LLM-ready Markdown. Configure it to attach to a Driver session instead of launching its own browser. ```bash theme={"dark"} pip install crawl4ai requests ``` ```python Python theme={"dark"} import asyncio, os, requests from crawl4ai import AsyncWebCrawler, BrowserConfig API = "https://api.driver.dev" headers = {"Authorization": f"Bearer {os.environ['DRIVER_API_KEY']}"} async def main(): r = requests.post(f"{API}/v1/browser/session", headers=headers, json={"country": "US"}) r.raise_for_status() session = r.json() try: config = BrowserConfig(browser_mode="custom", cdp_url=session["cdpUrl"], use_managed_browser=True) async with AsyncWebCrawler(config=config) as crawler: result = await crawler.arun(url="https://www.nbcnews.com/business") print(result.markdown) finally: requests.delete( f"{API}/v1/browser/session", headers=headers, params={"sessionId": session["sessionId"]} ) asyncio.run(main()) ``` ## Notes * `browser_mode="custom"` with `cdp_url` attaches Crawl4AI to the browser you give it. `use_managed_browser=True` makes it drive that browser over CDP instead of through a Playwright launch. Leaving the `async with` block closes Crawl4AI's connection only; the session runs until you stop it. * Crawl4AI is Playwright underneath and injects its own scripts, so it carries the stock-Playwright fingerprint. Use it on sites that don't challenge automation. * Pin the `crawl4ai` version you tested with. Its post-install step downloads a browser you won't use. Harmless. * Crawl several URLs in one session. Creating the session is the expensive step. # Patchright Source: https://docs.driver.dev/docs/frameworks/patchright Playwright with the automation fingerprints removed. Use it for every Driver integration. Node and Python. [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) is a patched build of Playwright. Same API, same docs, same selectors. The difference is what it sends over CDP. ## Why Patchright Stock Playwright and Puppeteer drive the browser with CDP calls a normal Chrome never makes on its own. Anti-bot vendors look for exactly those: * `Runtime.enable`, which exposes every page's JavaScript world and leaves traces in the page. * Console and binding hooks (`Runtime.addBinding`, `Page.addScriptToEvaluateOnNewDocument`) that Playwright installs for its own bookkeeping. * Command-line switches and `navigator.webdriver` in launched browsers. Not relevant when connecting to Driver, but Patchright removes them anyway. Driver gives you real Chrome on real hardware. If the client then announces itself over CDP, the session gets challenged anyway. Patchright removes those signals and keeps the Playwright API. ## Install ```bash npm theme={"dark"} npm install patchright ``` ```bash pip theme={"dark"} pip install patchright ``` No browser download. You connect to Driver's Chrome. ## Connect ```typescript TypeScript theme={"dark"} import { chromium } from "patchright"; const API = "https://api.driver.dev"; const headers = { Authorization: `Bearer ${process.env.DRIVER_API_KEY}`, "Content-Type": "application/json", }; const res = await fetch(`${API}/v1/browser/session`, { method: "POST", headers, body: JSON.stringify({ country: "US" }), }); if (!res.ok) throw new Error(`Driver ${res.status}: ${(await res.json().catch(() => ({}))).error ?? res.statusText}`); const session = await res.json(); try { const browser = await chromium.connectOverCDP(session.cdpUrl); try { const context = browser.contexts()[0] ?? (await browser.newContext()); const page = context.pages()[0] ?? (await context.newPage()); await page.goto("https://example.com"); console.log(await page.title()); } finally { await browser.close(); // your connection only; the session runs until you stop it } } finally { await fetch(`${API}/v1/browser/session?sessionId=${session.sessionId}`, { method: "DELETE", headers, }); } ``` ```python Python theme={"dark"} import os, requests from patchright.sync_api import sync_playwright API = "https://api.driver.dev" headers = {"Authorization": f"Bearer {os.environ['DRIVER_API_KEY']}"} session = requests.post(f"{API}/v1/browser/session", headers=headers, json={"country": "US"}) session.raise_for_status() session = session.json() try: with sync_playwright() as p: browser = p.chromium.connect_over_cdp(session["cdpUrl"]) context = browser.contexts[0] if browser.contexts else browser.new_context() page = context.pages[0] if context.pages else context.new_page() page.goto("https://example.com") print(page.title()) browser.close() finally: requests.delete( f"{API}/v1/browser/session", headers=headers, params={"sessionId": session["sessionId"]} ) ``` ```python Python (async) theme={"dark"} import asyncio, os, requests from patchright.async_api import async_playwright API = "https://api.driver.dev" headers = {"Authorization": f"Bearer {os.environ['DRIVER_API_KEY']}"} async def main(): session = requests.post(f"{API}/v1/browser/session", headers=headers, json={"country": "US"}) session.raise_for_status() session = session.json() try: async with async_playwright() as p: browser = await p.chromium.connect_over_cdp(session["cdpUrl"]) context = browser.contexts[0] if browser.contexts else await browser.new_context() page = context.pages[0] if context.pages else await context.new_page() await page.goto("https://example.com") print(await page.title()) await browser.close() finally: requests.delete( f"{API}/v1/browser/session", headers=headers, params={"sessionId": session["sessionId"]}, ) asyncio.run(main()) ``` `page.evaluate` runs in an isolated world, one of the things that keeps Patchright quiet: it sees the DOM but not the page's own JavaScript variables. Read what you need from the DOM. ## Migrating from Playwright Change the import. That's the whole migration. ```diff theme={"dark"} - import { chromium } from "playwright"; + import { chromium } from "patchright"; ``` ```diff theme={"dark"} - from playwright.sync_api import sync_playwright + from patchright.sync_api import sync_playwright ``` Delete any `chromium.launch()` and any `playwright install` step. Driver provides the browser. Coming from Puppeteer is a rewrite, not a rename: Patchright has Playwright's API. Either keep Puppeteer with the [connect-only setup](/docs/frameworks/puppeteer) and accept its fingerprint, or port. ## What Patchright doesn't fix Patchright fixes the client, not your script. A fresh context per page, broad `page.route` interception, injected init scripts, overridden `navigator` properties or a thousand clicks a minute still look like a bot. See [best practices](/docs/start/best-practices). # Playwright Source: https://docs.driver.dev/docs/frameworks/playwright Stock Playwright connects to Driver over CDP but carries fingerprints that get sessions blocked. Prefer Patchright. Stock Playwright issues CDP commands anti-bot systems detect (`Runtime.enable` and its bookkeeping hooks), and protected sites block the session. Use [Patchright](/docs/frameworks/patchright): same API, one import change. If you've measured that your targets don't care, stock Playwright works unchanged: ```typescript TypeScript theme={"dark"} import { chromium } from "playwright"; const browser = await chromium.connectOverCDP(session.cdpUrl); const context = browser.contexts()[0] ?? (await browser.newContext()); const page = context.pages()[0] ?? (await context.newPage()); await page.goto("https://example.com"); console.log(await page.title()); await browser.close(); ``` ```python Python theme={"dark"} from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.connect_over_cdp(session["cdpUrl"]) context = browser.contexts[0] if browser.contexts else browser.new_context() page = context.pages[0] if context.pages else context.new_page() page.goto("https://example.com") print(page.title()) browser.close() ``` Install with `npm install playwright` or `pip install playwright`. Don't run `playwright install`; the browser is Driver's. ## Everything else Contexts, tabs, disconnecting versus stopping, reconnecting and timeouts work the same as with Patchright. See [Connect over CDP](/docs/sessions/connect). # Puppeteer Source: https://docs.driver.dev/docs/frameworks/puppeteer puppeteer-core connects to Driver with browserWSEndpoint. Same detectability caveat as stock Playwright. Puppeteer makes the same fingerprintable CDP calls as stock Playwright. For protected sites use [Patchright](/docs/frameworks/patchright). If your code base is Puppeteer and the targets aren't hostile, this works. Install `puppeteer-core`, not `puppeteer`. Driver provides the browser; nothing should be downloaded. ```bash theme={"dark"} npm install puppeteer-core ``` ```typescript TypeScript theme={"dark"} import puppeteer from "puppeteer-core"; const API = "https://api.driver.dev"; const headers = { Authorization: `Bearer ${process.env.DRIVER_API_KEY}`, "Content-Type": "application/json", }; const res = await fetch(`${API}/v1/browser/session`, { method: "POST", headers, body: JSON.stringify({ country: "US" }), }); if (!res.ok) throw new Error(`Driver ${res.status}: ${(await res.json().catch(() => ({}))).error ?? res.statusText}`); const session = await res.json(); try { // defaultViewport: null keeps the window's real size. Puppeteer's default 800x600 viewport // would undo the windowSize you set on create and mismatch outerWidth. const browser = await puppeteer.connect({ browserWSEndpoint: session.cdpUrl, defaultViewport: null }); try { const pages = await browser.pages(); const page = pages[0] ?? (await browser.newPage()); // reuse the open tab await page.goto("https://example.com"); console.log(await page.title()); } finally { await browser.disconnect(); // your connection only } } finally { await fetch(`${API}/v1/browser/session?sessionId=${session.sessionId}`, { method: "DELETE", headers, }); } ``` ## Notes * End your connection with `browser.disconnect()`, then stop the session through the API. `browser.close()` tells the remote Chrome to quit, so the session record closes from the browser's side instead of by your stop call. * `browser.pages()` returns the tab the session opened with. Reuse it. * Connect with `defaultViewport: null` and set `windowSize` on create instead of `page.setViewport`. See [Window size](/docs/options/window-size). * Hooks to avoid, in Puppeteer's names: `page.setRequestInterception`, `page.evaluateOnNewDocument`, `page.exposeFunction`, `page.setUserAgent`, `page.emulateTimezone`, `page.emulate`. See [Best practices](/docs/start/best-practices). * Pin the `puppeteer-core` version you tested with. Each release targets a Chrome revision. # Stagehand Source: https://docs.driver.dev/docs/frameworks/stagehand Natural-language act and extract on a Driver browser: connect Stagehand v4 to the session's cdpUrl. [Stagehand](https://docs.stagehand.dev) adds `act`, `extract` and `observe` on top of a browser it attaches to. With v4, connect it to a Driver session's `cdpUrl` through `localBrowser.connect`. ```bash theme={"dark"} npm install @browserbasehq/stagehand zod@~4.4.3 ``` ```typescript TypeScript theme={"dark"} import { localBrowser, Stagehand } from "@browserbasehq/stagehand"; import { z } from "zod/v4"; const API = "https://api.driver.dev"; const headers = { Authorization: `Bearer ${process.env.DRIVER_API_KEY}`, "Content-Type": "application/json", }; const res = await fetch(`${API}/v1/browser/session`, { method: "POST", headers, body: JSON.stringify({ country: "US" }), }); if (!res.ok) throw new Error(`Driver ${res.status}: ${(await res.json().catch(() => ({}))).error ?? res.statusText}`); const session = await res.json(); try { // Attach to Driver's Chrome. Stagehand never launches or closes a browser it didn't start. const browser = await localBrowser.connect({ cdpUrl: session.cdpUrl }); const stagehand = await Stagehand.create({ browser, model: { modelName: "openai/gpt-5.6-sol", apiKey: process.env.OPENAI_API_KEY }, }); const [page] = await browser.context.pages(); // the tab the session opened with await page.goto("https://example.com"); const data = await stagehand.extract( "Extract the page title and main heading", z.object({ title: z.string(), heading: z.string() }), ); console.log(data); await stagehand.close(); // ends the Stagehand connection; the session keeps running } finally { await fetch(`${API}/v1/browser/session?sessionId=${session.sessionId}`, { method: "DELETE", headers, }); } ``` Stagehand reads no environment variables. Pass the model provider key in `model.apiKey`. ## Notes * This is the v4 API (`localBrowser.connect`, `Stagehand.create`). On 2.x the equivalent is `new Stagehand({ env: "LOCAL", localBrowserLaunchOptions: { cdpUrl } })`. * Stagehand injects scripts and drives the browser over CDP, which is what protected sites look for. On those sites use plain [Patchright](/docs/frameworks/patchright) with explicit selectors. * Pin the Stagehand version you tested with. The connect API changed between 2.x and v4. * Reuse the page from `browser.context.pages()` instead of opening a new one. # Browser types Source: https://docs.driver.dev/docs/options/browser-types type picks the browser class: hosted by default, plus two stealth variants. `type` on [create](/docs/sessions/create) picks which Chrome serves the session. Leave it out unless a site gives you a reason. | Type | What it is | When to use it | | - | - | - | | `hosted` (default) | Real Chrome on a hosted machine, native behaviour. | Almost everything. | | `hosted_stealth` | Same browser, conservative stealth policy. | A site challenges the default and you want the smallest change. | | `hosted_privacy` | Same browser, aggressive stealth policy. | Hostile targets. Try `hosted_stealth` first; more masking isn't always better. | ```json Request theme={"dark"} { "type": "hosted_stealth", "country": "GB" } ``` ```typescript TypeScript theme={"dark"} const session = await createSession({ type: "hosted_stealth", country: "GB" }); ``` ```python Python theme={"dark"} session = create_session(type="hosted_stealth", country="GB") ``` ## What "real Chrome" means here Consumer Chrome on real hardware with a real display, not a headless container. GPU, fonts, media devices and timing are what a person's machine shows, and the network is an ISP connection in the country you asked for. That's why [best practices](/docs/start/best-practices) say not to patch fingerprints from your script: the browser is already genuine, and patches make it less so. ## Choosing between the stealth policies The policies change how the browser answers certain probes. `hosted_stealth` rarely breaks sites. `hosted_privacy` masks more and can change page behaviour on sites that use the masked APIs. Measure against your target instead of defaulting to the strongest one. ## Related * [Location](/docs/options/location): country, timezone and language * [Proxies](/docs/options/proxies): your own egress # CAPTCHA solving and ad blocking Source: https://docs.driver.dev/docs/options/captcha-and-adblock Two flags on create: captchaSolver loads Driver's solver into the session, adblock blocks ads. Both flags default to `false`. ## CAPTCHA solving `captchaSolver: true` loads Driver's solver extension into the session's Chrome. Challenges that appear during the session are solved in the browser; your script doesn't change. ```json Request theme={"dark"} { "captchaSolver": true } ``` ```typescript TypeScript theme={"dark"} const session = await createSession({ captchaSolver: true }); ``` ```python Python theme={"dark"} session = create_session(captchaSolver=True) ``` Solving takes time. Give steps that may hit a challenge a generous timeout and rely on your framework's auto-waiting, not fixed sleeps. If a challenge isn't solved, a person can finish it in the same session through the [live view](/docs/sessions/live-view). ## Ad blocking `adblock: true` blocks ads in the session. Pages load faster and move fewer bytes, which is what you're billed on. Some sites detect ad blockers and change behaviour, so leave it off when fidelity matters. ```json Request theme={"dark"} { "adblock": true } ``` ```typescript TypeScript theme={"dark"} const session = await createSession({ adblock: true }); ``` ```python Python theme={"dark"} session = create_session(adblock=True) ``` # Countries Source: https://docs.driver.dev/docs/options/countries Every country you can pass on create, with the timezones and languages it accepts. Generated from the API's locale catalog. `country` accepts any of the 249 codes below. `timezone` must be one of that country's timezones and `language` one of its languages, or the request is a `400`. Leave both out and you get the first-listed timezone (or a managed-network region where Driver has one) and the country's primary language. US sessions are always `en-US`. [Location](/docs/options/location) covers how the three stay coherent. Machine availability varies by country. No free machine in a country is a `503`. | Code | Country | Timezones | Languages | | - | - | - | - | | `AD` | Andorra | `Europe/Andorra` | `ca-ES` | | `AE` | United Arab Emirates | `Asia/Dubai` | `ar-SA` | | `AF` | Afghanistan | `Asia/Kabul` | `en-GB` | | `AG` | Antigua & Barbuda | `America/Antigua` | `en-US` | | `AI` | Anguilla | `America/Anguilla` | `en-US` | | `AL` | Albania | `Europe/Tirane` | `en-GB` | | `AM` | Armenia | `Asia/Yerevan` | `ru-RU` | | `AO` | Angola | `Africa/Luanda` | `pt-PT` | | `AQ` | Antarctica | `Antarctica/McMurdo`, `Antarctica/Casey`, `Antarctica/Davis`, `Antarctica/DumontDUrville`, `Antarctica/Mawson`, `Antarctica/Palmer`, `Antarctica/Rothera`, `Antarctica/Syowa`, `Antarctica/Troll`, `Antarctica/Vostok` | `en-GB` | | `AR` | Argentina | `America/Argentina/Buenos_Aires`, `America/Argentina/Cordoba`, `America/Argentina/Salta`, `America/Argentina/Jujuy`, `America/Argentina/Tucuman`, `America/Argentina/Catamarca`, `America/Argentina/La_Rioja`, `America/Argentina/San_Juan`, `America/Argentina/Mendoza`, `America/Argentina/San_Luis`, `America/Argentina/Rio_Gallegos`, `America/Argentina/Ushuaia` | `es-MX` | | `AS` | American Samoa | `Pacific/Pago_Pago` | `en-US` | | `AT` | Austria | `Europe/Vienna` | `de-DE`, `hr-HR`, `sl-SI`, `hu-HU` | | `AU` | Australia | `Australia/Lord_Howe`, `Antarctica/Macquarie`, `Australia/Hobart`, `Australia/Melbourne`, `Australia/Sydney`, `Australia/Broken_Hill`, `Australia/Brisbane`, `Australia/Lindeman`, `Australia/Adelaide`, `Australia/Darwin`, `Australia/Perth`, `Australia/Eucla` | `en-GB` | | `AW` | Aruba | `America/Aruba` | `nl-NL` | | `AX` | Åland Islands | `Europe/Mariehamn` | `sv-SE` | | `AZ` | Azerbaijan | `Asia/Baku` | `en-GB` | | `BA` | Bosnia & Herzegovina | `Europe/Sarajevo` | `hr-HR`, `sr-Latn-RS` | | `BB` | Barbados | `America/Barbados` | `en-US` | | `BD` | Bangladesh | `Asia/Dhaka` | `en-GB` | | `BE` | Belgium | `Europe/Brussels` | `nl-NL`, `fr-FR`, `de-DE` | | `BF` | Burkina Faso | `Africa/Ouagadougou` | `fr-FR` | | `BG` | Bulgaria | `Europe/Sofia` | `bg-BG` | | `BH` | Bahrain | `Asia/Bahrain` | `ar-SA` | | `BI` | Burundi | `Africa/Bujumbura` | `fr-FR`, `en-GB` | | `BJ` | Benin | `Africa/Porto-Novo` | `fr-FR` | | `BL` | St. Barthélemy | `America/St_Barthelemy` | `fr-FR` | | `BM` | Bermuda | `Atlantic/Bermuda` | `en-US` | | `BN` | Brunei | `Asia/Brunei` | `zh-TW` | | `BO` | Bolivia | `America/La_Paz` | `es-MX` | | `BQ` | Caribbean Netherlands | `America/Kralendijk` | `nl-NL` | | `BR` | Brazil | `America/Noronha`, `America/Belem`, `America/Fortaleza`, `America/Recife`, `America/Araguaina`, `America/Maceio`, `America/Bahia`, `America/Sao_Paulo`, `America/Campo_Grande`, `America/Cuiaba`, `America/Santarem`, `America/Porto_Velho`, `America/Boa_Vista`, `America/Manaus`, `America/Eirunepe`, `America/Rio_Branco` | `pt-BR` | | `BS` | Bahamas | `America/Nassau` | `en-US` | | `BT` | Bhutan | `Asia/Thimphu` | `en-GB` | | `BV` | Bouvet Island | `Europe/Oslo` | `en-GB` | | `BW` | Botswana | `Africa/Gaborone` | `en-GB` | | `BY` | Belarus | `Europe/Minsk` | `ru-RU` | | `BZ` | Belize | `America/Belize` | `en-US` | | `CA` | Canada | `America/St_Johns`, `America/Halifax`, `America/Glace_Bay`, `America/Moncton`, `America/Goose_Bay`, `America/Blanc-Sablon`, `America/Toronto`, `America/Iqaluit`, `America/Atikokan`, `America/Winnipeg`, `America/Resolute`, `America/Rankin_Inlet`, `America/Regina`, `America/Swift_Current`, `America/Edmonton`, `America/Cambridge_Bay`, `America/Inuvik`, `America/Vancouver`, `America/Creston`, `America/Dawson_Creek`, `America/Fort_Nelson`, `America/Whitehorse`, `America/Dawson` | `en-US`, `fr-CA` | | `CC` | Cocos (Keeling) Islands | `Indian/Cocos` | `en-GB` | | `CD` | Congo - Kinshasa | `Africa/Kinshasa`, `Africa/Lubumbashi` | `fr-FR` | | `CF` | Central African Republic | `Africa/Bangui` | `fr-FR` | | `CG` | Congo - Brazzaville | `Africa/Brazzaville` | `fr-FR` | | `CH` | Switzerland | `Europe/Zurich` | `de-DE`, `fr-FR`, `it-IT` | | `CI` | Côte d’Ivoire | `Africa/Abidjan` | `fr-FR` | | `CK` | Cook Islands | `Pacific/Rarotonga` | `en-GB` | | `CL` | Chile | `America/Santiago`, `America/Coyhaique`, `America/Punta_Arenas`, `Pacific/Easter` | `es-MX` | | `CM` | Cameroon | `Africa/Douala` | `fr-FR`, `en-GB` | | `CN` | China | `Asia/Shanghai`, `Asia/Urumqi` | `zh-CN`, `ko-KR` | | `CO` | Colombia | `America/Bogota` | `es-MX` | | `CR` | Costa Rica | `America/Costa_Rica` | `es-MX` | | `CU` | Cuba | `America/Havana` | `es-MX` | | `CV` | Cape Verde | `Atlantic/Cape_Verde` | `pt-PT` | | `CW` | Curaçao | `America/Curacao` | `nl-NL` | | `CX` | Christmas Island | `Indian/Christmas` | `en-GB` | | `CY` | Cyprus | `Asia/Nicosia`, `Asia/Famagusta` | `el-GR`, `tr-TR` | | `CZ` | Czechia | `Europe/Prague` | `cs-CZ` | | `DE` | Germany | `Europe/Berlin`, `Europe/Busingen` | `de-DE` | | `DJ` | Djibouti | `Africa/Djibouti` | `fr-FR`, `ar-SA` | | `DK` | Denmark | `Europe/Copenhagen` | `da-DK`, `de-DE` | | `DM` | Dominica | `America/Dominica` | `en-US` | | `DO` | Dominican Republic | `America/Santo_Domingo` | `es-MX` | | `DZ` | Algeria | `Africa/Algiers` | `ar-SA`, `fr-FR` | | `EC` | Ecuador | `America/Guayaquil`, `Pacific/Galapagos` | `es-MX` | | `EE` | Estonia | `Europe/Tallinn` | `et-EE` | | `EG` | Egypt | `Africa/Cairo` | `ar-SA` | | `EH` | Western Sahara | `Africa/El_Aaiun` | `ar-SA` | | `ER` | Eritrea | `Africa/Asmara` | `en-GB`, `ar-SA` | | `ES` | Spain | `Europe/Madrid`, `Africa/Ceuta`, `Atlantic/Canary` | `es-ES`, `ca-ES`, `gl-ES`, `eu-ES` | | `ET` | Ethiopia | `Africa/Addis_Ababa` | `en-GB` | | `FI` | Finland | `Europe/Helsinki` | `fi-FI`, `sv-SE` | | `FJ` | Fiji | `Pacific/Fiji` | `en-GB` | | `FK` | Falkland Islands | `Atlantic/Stanley` | `en-US` | | `FM` | Micronesia | `Pacific/Chuuk`, `Pacific/Pohnpei`, `Pacific/Kosrae` | `en-US` | | `FO` | Faroe Islands | `Atlantic/Faroe` | `en-GB` | | `FR` | France | `Europe/Paris` | `fr-FR` | | `GA` | Gabon | `Africa/Libreville` | `fr-FR` | | `GB` | United Kingdom | `Europe/London` | `en-GB` | | `GD` | Grenada | `America/Grenada` | `en-US` | | `GE` | Georgia | `Asia/Tbilisi` | `ru-RU` | | `GF` | French Guiana | `America/Cayenne` | `fr-FR` | | `GG` | Guernsey | `Europe/Guernsey` | `en-GB` | | `GH` | Ghana | `Africa/Accra` | `en-GB` | | `GI` | Gibraltar | `Europe/Gibraltar` | `en-GB` | | `GL` | Greenland | `America/Nuuk`, `America/Danmarkshavn`, `America/Scoresbysund`, `America/Thule` | `da-DK` | | `GM` | Gambia | `Africa/Banjul` | `en-GB` | | `GN` | Guinea | `Africa/Conakry` | `fr-FR` | | `GP` | Guadeloupe | `America/Guadeloupe` | `fr-FR` | | `GQ` | Equatorial Guinea | `Africa/Malabo` | `es-ES`, `fr-FR`, `pt-PT` | | `GR` | Greece | `Europe/Athens` | `el-GR` | | `GS` | South Georgia & South Sandwich Islands | `Atlantic/South_Georgia` | `en-US` | | `GT` | Guatemala | `America/Guatemala` | `es-MX` | | `GU` | Guam | `Pacific/Guam` | `en-US` | | `GW` | Guinea-Bissau | `Africa/Bissau` | `pt-PT` | | `GY` | Guyana | `America/Guyana` | `en-US` | | `HK` | Hong Kong SAR China | `Asia/Hong_Kong` | `zh-TW`, `en-GB` | | `HM` | Heard & McDonald Islands | `Indian/Kerguelen` | `en-GB` | | `HN` | Honduras | `America/Tegucigalpa` | `es-MX` | | `HR` | Croatia | `Europe/Zagreb` | `hr-HR`, `it-IT` | | `HT` | Haiti | `America/Port-au-Prince` | `fr-FR` | | `HU` | Hungary | `Europe/Budapest` | `hu-HU` | | `ID` | Indonesia | `Asia/Jakarta`, `Asia/Pontianak`, `Asia/Makassar`, `Asia/Jayapura` | `id-ID` | | `IE` | Ireland | `Europe/Dublin` | `en-GB` | | `IL` | Israel | `Asia/Jerusalem` | `he-IL`, `ar-SA` | | `IM` | Isle of Man | `Europe/Isle_of_Man` | `en-GB` | | `IN` | India | `Asia/Kolkata` | `en-GB` | | `IO` | British Indian Ocean Territory | `Indian/Chagos` | `en-GB` | | `IQ` | Iraq | `Asia/Baghdad` | `ar-SA` | | `IR` | Iran | `Asia/Tehran` | `en-GB` | | `IS` | Iceland | `Atlantic/Reykjavik` | `en-GB` | | `IT` | Italy | `Europe/Rome` | `it-IT`, `fr-FR` | | `JE` | Jersey | `Europe/Jersey` | `en-GB` | | `JM` | Jamaica | `America/Jamaica` | `en-US` | | `JO` | Jordan | `Asia/Amman` | `ar-SA` | | `JP` | Japan | `Asia/Tokyo` | `ja-JP` | | `KE` | Kenya | `Africa/Nairobi` | `en-GB` | | `KG` | Kyrgyzstan | `Asia/Bishkek` | `ru-RU` | | `KH` | Cambodia | `Asia/Phnom_Penh` | `en-GB` | | `KI` | Kiribati | `Pacific/Tarawa`, `Pacific/Kanton`, `Pacific/Kiritimati` | `en-GB` | | `KM` | Comoros | `Indian/Comoro` | `ar-SA`, `fr-FR` | | `KN` | St. Kitts & Nevis | `America/St_Kitts` | `en-US` | | `KP` | North Korea | `Asia/Pyongyang` | `ko-KR` | | `KR` | South Korea | `Asia/Seoul` | `ko-KR` | | `KW` | Kuwait | `Asia/Kuwait` | `ar-SA` | | `KY` | Cayman Islands | `America/Cayman` | `en-US` | | `KZ` | Kazakhstan | `Asia/Almaty`, `Asia/Qyzylorda`, `Asia/Qostanay`, `Asia/Aqtobe`, `Asia/Aqtau`, `Asia/Atyrau`, `Asia/Oral` | `ru-RU` | | `LA` | Laos | `Asia/Vientiane` | `en-GB` | | `LB` | Lebanon | `Asia/Beirut` | `ar-SA` | | `LC` | St. Lucia | `America/St_Lucia` | `en-US` | | `LI` | Liechtenstein | `Europe/Vaduz` | `de-DE` | | `LK` | Sri Lanka | `Asia/Colombo` | `en-GB` | | `LR` | Liberia | `Africa/Monrovia` | `en-GB` | | `LS` | Lesotho | `Africa/Maseru` | `en-GB` | | `LT` | Lithuania | `Europe/Vilnius` | `lt-LT` | | `LU` | Luxembourg | `Europe/Luxembourg` | `fr-FR`, `de-DE` | | `LV` | Latvia | `Europe/Riga` | `lv-LV` | | `LY` | Libya | `Africa/Tripoli` | `ar-SA` | | `MA` | Morocco | `Africa/Casablanca` | `ar-SA`, `fr-FR` | | `MC` | Monaco | `Europe/Monaco` | `fr-FR` | | `MD` | Moldova | `Europe/Chisinau` | `ro-RO` | | `ME` | Montenegro | `Europe/Podgorica` | `sr-Latn-RS` | | `MF` | St. Martin | `America/Marigot` | `fr-FR` | | `MG` | Madagascar | `Indian/Antananarivo` | `fr-FR`, `en-GB` | | `MH` | Marshall Islands | `Pacific/Majuro`, `Pacific/Kwajalein` | `en-US` | | `MK` | North Macedonia | `Europe/Skopje` | `tr-TR` | | `ML` | Mali | `Africa/Bamako` | `fr-FR` | | `MM` | Myanmar (Burma) | `Asia/Yangon` | `en-GB` | | `MN` | Mongolia | `Asia/Ulaanbaatar`, `Asia/Hovd` | `en-GB` | | `MO` | Macao SAR China | `Asia/Macau` | `zh-TW`, `pt-PT` | | `MP` | Northern Mariana Islands | `Pacific/Saipan` | `en-US` | | `MQ` | Martinique | `America/Martinique` | `fr-FR` | | `MR` | Mauritania | `Africa/Nouakchott` | `ar-SA` | | `MS` | Montserrat | `America/Montserrat` | `en-US` | | `MT` | Malta | `Europe/Malta` | `en-GB` | | `MU` | Mauritius | `Indian/Mauritius` | `fr-FR`, `en-GB` | | `MV` | Maldives | `Indian/Maldives` | `en-GB` | | `MW` | Malawi | `Africa/Blantyre` | `en-GB` | | `MX` | Mexico | `America/Mexico_City`, `America/Cancun`, `America/Merida`, `America/Monterrey`, `America/Matamoros`, `America/Chihuahua`, `America/Ciudad_Juarez`, `America/Ojinaga`, `America/Mazatlan`, `America/Bahia_Banderas`, `America/Hermosillo`, `America/Tijuana` | `es-MX` | | `MY` | Malaysia | `Asia/Kuala_Lumpur`, `Asia/Kuching` | `en-GB` | | `MZ` | Mozambique | `Africa/Maputo` | `pt-PT` | | `NA` | Namibia | `Africa/Windhoek` | `en-GB` | | `NC` | New Caledonia | `Pacific/Noumea` | `fr-FR` | | `NE` | Niger | `Africa/Niamey` | `fr-FR` | | `NF` | Norfolk Island | `Pacific/Norfolk` | `en-GB` | | `NG` | Nigeria | `Africa/Lagos` | `en-GB` | | `NI` | Nicaragua | `America/Managua` | `es-MX` | | `NL` | Netherlands | `Europe/Amsterdam` | `nl-NL` | | `NO` | Norway | `Europe/Oslo` | `nb-NO` | | `NP` | Nepal | `Asia/Kathmandu` | `en-GB` | | `NR` | Nauru | `Pacific/Nauru` | `en-GB` | | `NU` | Niue | `Pacific/Niue` | `en-GB` | | `NZ` | New Zealand | `Pacific/Auckland`, `Pacific/Chatham` | `en-GB` | | `OM` | Oman | `Asia/Muscat` | `ar-SA` | | `PA` | Panama | `America/Panama` | `es-MX` | | `PE` | Peru | `America/Lima` | `es-MX` | | `PF` | French Polynesia | `Pacific/Tahiti`, `Pacific/Marquesas`, `Pacific/Gambier` | `fr-FR` | | `PG` | Papua New Guinea | `Pacific/Port_Moresby`, `Pacific/Bougainville` | `en-GB` | | `PH` | Philippines | `Asia/Manila` | `en-US` | | `PK` | Pakistan | `Asia/Karachi` | `en-GB` | | `PL` | Poland | `Europe/Warsaw` | `pl-PL`, `de-DE`, `lt-LT` | | `PM` | St. Pierre & Miquelon | `America/Miquelon` | `fr-FR` | | `PN` | Pitcairn Islands | `Pacific/Pitcairn` | `en-GB` | | `PR` | Puerto Rico | `America/Puerto_Rico` | `es-MX`, `en-US` | | `PS` | Palestinian Territories | `Asia/Gaza`, `Asia/Hebron` | `ar-SA` | | `PT` | Portugal | `Europe/Lisbon`, `Atlantic/Madeira`, `Atlantic/Azores` | `pt-PT` | | `PW` | Palau | `Pacific/Palau` | `en-US` | | `PY` | Paraguay | `America/Asuncion` | `es-MX` | | `QA` | Qatar | `Asia/Qatar` | `ar-SA` | | `RE` | Réunion | `Indian/Reunion` | `fr-FR` | | `RO` | Romania | `Europe/Bucharest` | `ro-RO` | | `RS` | Serbia | `Europe/Belgrade` | `sr-Latn-RS`, `hu-HU`, `ro-RO`, `hr-HR`, `sk-SK`, `uk-UA` | | `RU` | Russia | `Europe/Kaliningrad`, `Europe/Moscow`, `Europe/Kirov`, `Europe/Volgograd`, `Europe/Astrakhan`, `Europe/Saratov`, `Europe/Ulyanovsk`, `Europe/Samara`, `Asia/Yekaterinburg`, `Asia/Omsk`, `Asia/Novosibirsk`, `Asia/Barnaul`, `Asia/Tomsk`, `Asia/Novokuznetsk`, `Asia/Krasnoyarsk`, `Asia/Irkutsk`, `Asia/Chita`, `Asia/Yakutsk`, `Asia/Khandyga`, `Asia/Vladivostok`, `Asia/Ust-Nera`, `Asia/Magadan`, `Asia/Sakhalin`, `Asia/Srednekolymsk`, `Asia/Kamchatka`, `Asia/Anadyr` | `ru-RU` | | `RW` | Rwanda | `Africa/Kigali` | `en-GB`, `fr-FR` | | `SA` | Saudi Arabia | `Asia/Riyadh` | `ar-SA` | | `SB` | Solomon Islands | `Pacific/Guadalcanal` | `en-GB` | | `SC` | Seychelles | `Indian/Mahe` | `fr-FR`, `en-GB` | | `SD` | Sudan | `Africa/Khartoum` | `en-GB`, `ar-SA` | | `SE` | Sweden | `Europe/Stockholm` | `sv-SE`, `fi-FI` | | `SG` | Singapore | `Asia/Singapore` | `en-GB`, `zh-CN` | | `SH` | St. Helena | `Atlantic/St_Helena` | `en-GB` | | `SI` | Slovenia | `Europe/Ljubljana` | `sl-SI` | | `SJ` | Svalbard & Jan Mayen | `Arctic/Longyearbyen` | `nb-NO` | | `SK` | Slovakia | `Europe/Bratislava` | `sk-SK` | | `SL` | Sierra Leone | `Africa/Freetown` | `en-GB` | | `SM` | San Marino | `Europe/San_Marino` | `it-IT` | | `SN` | Senegal | `Africa/Dakar` | `fr-FR` | | `SO` | Somalia | `Africa/Mogadishu` | `ar-SA` | | `SR` | Suriname | `America/Paramaribo` | `nl-NL` | | `SS` | South Sudan | `Africa/Juba` | `en-GB` | | `ST` | São Tomé & Príncipe | `Africa/Sao_Tome` | `pt-PT` | | `SV` | El Salvador | `America/El_Salvador` | `es-MX` | | `SX` | Sint Maarten | `America/Lower_Princes` | `en-US`, `nl-NL` | | `SY` | Syria | `Asia/Damascus` | `ar-SA` | | `SZ` | Eswatini | `Africa/Mbabane` | `en-GB` | | `TC` | Turks & Caicos Islands | `America/Grand_Turk` | `en-US` | | `TD` | Chad | `Africa/Ndjamena` | `ar-SA`, `fr-FR` | | `TF` | French Southern Territories | `Indian/Kerguelen` | `fr-FR` | | `TG` | Togo | `Africa/Lome` | `fr-FR` | | `TH` | Thailand | `Asia/Bangkok` | `th-TH` | | `TJ` | Tajikistan | `Asia/Dushanbe` | `ru-RU` | | `TK` | Tokelau | `Pacific/Fakaofo` | `en-GB` | | `TL` | Timor-Leste | `Asia/Dili` | `pt-PT` | | `TM` | Turkmenistan | `Asia/Ashgabat` | `ru-RU` | | `TN` | Tunisia | `Africa/Tunis` | `ar-SA`, `fr-FR` | | `TO` | Tonga | `Pacific/Tongatapu` | `en-GB` | | `TR` | Türkiye | `Europe/Istanbul` | `tr-TR` | | `TT` | Trinidad & Tobago | `America/Port_of_Spain` | `en-US` | | `TV` | Tuvalu | `Pacific/Funafuti` | `en-GB` | | `TW` | Taiwan | `Asia/Taipei` | `zh-TW` | | `TZ` | Tanzania | `Africa/Dar_es_Salaam` | `en-GB` | | `UA` | Ukraine | `Europe/Simferopol`, `Europe/Kyiv` | `uk-UA`, `ru-RU` | | `UG` | Uganda | `Africa/Kampala` | `en-GB` | | `UM` | U.S. Outlying Islands | `Pacific/Midway`, `Pacific/Wake` | `en-US` | | `US` | United States | `America/New_York`, `America/Detroit`, `America/Kentucky/Louisville`, `America/Kentucky/Monticello`, `America/Indiana/Indianapolis`, `America/Indiana/Vincennes`, `America/Indiana/Winamac`, `America/Indiana/Marengo`, `America/Indiana/Petersburg`, `America/Indiana/Vevay`, `America/Chicago`, `America/Indiana/Tell_City`, `America/Indiana/Knox`, `America/Menominee`, `America/North_Dakota/Center`, `America/North_Dakota/New_Salem`, `America/North_Dakota/Beulah`, `America/Denver`, `America/Boise`, `America/Phoenix`, `America/Los_Angeles`, `America/Anchorage`, `America/Juneau`, `America/Sitka`, `America/Metlakatla`, `America/Yakutat`, `America/Nome`, `America/Adak`, `Pacific/Honolulu` | `en-US` | | `UY` | Uruguay | `America/Montevideo` | `es-MX` | | `UZ` | Uzbekistan | `Asia/Samarkand`, `Asia/Tashkent` | `ru-RU` | | `VA` | Vatican City | `Europe/Vatican` | `it-IT` | | `VC` | St. Vincent & Grenadines | `America/St_Vincent` | `en-US` | | `VE` | Venezuela | `America/Caracas` | `es-MX` | | `VG` | British Virgin Islands | `America/Tortola` | `en-US` | | `VI` | U.S. Virgin Islands | `America/St_Thomas` | `en-US` | | `VN` | Vietnam | `Asia/Ho_Chi_Minh` | `vi-VN` | | `VU` | Vanuatu | `Pacific/Efate` | `en-GB`, `fr-FR` | | `WF` | Wallis & Futuna | `Pacific/Wallis` | `fr-FR` | | `WS` | Samoa | `Pacific/Apia` | `en-GB` | | `YE` | Yemen | `Asia/Aden` | `ar-SA` | | `YT` | Mayotte | `Indian/Mayotte` | `fr-FR` | | `ZA` | South Africa | `Africa/Johannesburg` | `en-GB` | | `ZM` | Zambia | `Africa/Lusaka` | `en-GB` | | `ZW` | Zimbabwe | `Africa/Harare` | `en-GB` | # Extensions Source: https://docs.driver.dev/docs/options/extensions Upload your own Chrome extensions and load them into hosted sessions. Enabled per account. Enabled per account. Without access the endpoints answer `403` (`Extensions API is not enabled for this account`); write to [alex@driver.dev](mailto:alex@driver.dev) to request it. An upload without a file fails first with `No file uploaded. Send as multipart field "extension".` ## Upload an extension Zip the directory containing `manifest.json` and upload it. Driver packs it as a CRX and returns an `extensionId` for create. ```bash cURL theme={"dark"} curl -X POST https://api.driver.dev/v1/browser/extensions \ -H "Authorization: Bearer $DRIVER_API_KEY" \ -F "extension=@my-extension.zip" # → { "extension": { "extensionId": "abc123def456", "crxExtensionId": "…", # "name": "…", "version": "1.2.0", … } } ``` ```typescript TypeScript theme={"dark"} const form = new FormData(); const zip = new Blob([await fs.promises.readFile("my-extension.zip")]); form.append("extension", zip, "my-extension.zip"); const { extension } = await fetch("https://api.driver.dev/v1/browser/extensions", { method: "POST", headers: { Authorization: `Bearer ${process.env.DRIVER_API_KEY}` }, body: form, }).then((r) => r.json()); ``` ```python Python theme={"dark"} with open("my-extension.zip", "rb") as f: extension = requests.post( "https://api.driver.dev/v1/browser/extensions", headers={"Authorization": f"Bearer {os.environ['DRIVER_API_KEY']}"}, files={"extension": f}, ).json()["extension"] ``` ## Load it into a session Pass the internal `extensionId` (not `crxExtensionId`) in `extensionIds`. Works on every browser type. ```json theme={"dark"} { "extensionIds": ["abc123def456"] } ``` ## Manage extensions | Call | What it does | | - | - | | `GET /v1/browser/extensions` | List your extensions. | | `GET /v1/browser/extensions/{extensionId}` | One extension. | | `PUT /v1/browser/extensions/{extensionId}` with a new zip | Replace the code; the id stays. | | `DELETE /v1/browser/extensions/{extensionId}` | Remove it. | The dashboard's **Extensions** page (shown once your account has access) does the same. ## Notes * Manifest V3 is expected. `name` and `version` come from the manifest. * An extension that opens tabs, injects scripts everywhere or rewrites requests changes how the browser looks to sites. Keep it narrow, for the same reasons as [CDP hooks](/docs/start/best-practices#keep-cdp-hooks-narrow). # Location, timezone and language Source: https://docs.driver.dev/docs/options/location Pass a country; the network, timezone and language agree. Override timezone and language only within that country. Pass a two-letter `country` and the session runs on a machine egressing from that country, with a timezone and browser language that belong there. The three always agree, which is what sites check. Default `US`. ```json Request theme={"dark"} { "country": "DE" } ``` ```typescript TypeScript theme={"dark"} const session = await createSession({ country: "DE" }); ``` ```python Python theme={"dark"} session = create_session(country="DE") ``` ## Overriding timezone and language Set `timezone` (IANA name) or `language` (BCP-47) when the country's default isn't what you want. Both must come from that country's lists on [Countries](/docs/options/countries), or the request is a `400`: `Unsupported timezone "Europe/Berlin" for country US.` An unknown `country` fails the same way: `Unsupported country "XX".` Without `timezone`, you get the country's dominant zone, and where Driver runs a managed network there, traffic is routed through that region so the network and the clock agree. ```json theme={"dark"} { "country": "CA", "timezone": "America/Vancouver", "language": "fr-CA" } ``` US sessions are always `en-US`; any other `language` is rejected. Elsewhere, leaving `language` out gets the country's primary language. Chrome reports the nearest variant it ships: `fr-CA` came back as `navigator.language` `fr-FR`, with `fr` and `en-US` as fallbacks. `Accept-Language` follows the same choice. A `de-DE` session sends `de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7`. Hosted sessions get matching text-to-speech voices where available; where none exists, Web Speech is disabled so that surface can't contradict the rest. ## With your own proxy A `proxyUrl` without `timezone` or `language` makes Driver geolocate the proxy's exit and derive country and timezone from it. A `country` you pass must match that exit. See [Proxies](/docs/options/proxies). ## Availability No free machine in the requested country is a `503` with `Retry-After: 2`. Back off with a cap, then tell the user or pick another country. ## Related * [Countries](/docs/options/countries): every supported country with its timezones and languages * [Browser types](/docs/options/browser-types) * [Best practices](/docs/start/best-practices): don't override timezone or locale from your script # Node reuse Source: https://docs.driver.dev/docs/options/node-reuse nodeId runs a session on the machine that served an earlier one. Rarely needed; overrides country and type. Every session response includes `servedBy`, the id of the machine that ran it. Pass it as `nodeId` on a later create to land on the same machine. ```typescript TypeScript theme={"dark"} const first = await createSession({ country: "US" }); // … work, stop … const again = await createSession({ nodeId: first.servedBy }); ``` ```python Python theme={"dark"} first = create_session(country="US") # ... work, stop ... again = create_session(nodeId=first["servedBy"]) ``` ## When it helps * A site keyed something to the machine. Rare; most keying is by cookies, which [profiles](/docs/options/profiles) handle better. * Reproducing a problem that only happens on one machine. ## Rules * `nodeId` overrides `country` and `type`. You get that machine, wherever it is. * If the machine is busy or gone, the request fails. No fallback to another machine. For most workloads leave `nodeId` out and let placement pick a machine in the country you asked for. # Profiles Source: https://docs.driver.dev/docs/options/profiles Save a session's cookies and browser state under a name. Later sessions on that name start signed in. A profile is a full browser profile stored under a name you choose. Create a session with `profile: { name, persist: true }`, sign in, stop it: the state is saved. The next session with that name opens signed in. A [browser pool](/docs/sessions/pools) can load a profile into every warm browser; keep `persist` off there, or the browsers overwrite one another. ```json Request theme={"dark"} { "profile": { "name": "shop-account", "persist": true } } ``` ```typescript TypeScript theme={"dark"} // First run: sign in and save. const first = await createSession({ profile: { name: "shop-account", persist: true } }); // … sign in through the browser, then stop the session … // Later: the same name, already signed in. const next = await createSession({ profile: { name: "shop-account", persist: true } }); ``` ```python Python theme={"dark"} first = create_session(profile={"name": "shop-account", "persist": True}) # ... sign in, stop ... next_session = create_session(profile={"name": "shop-account", "persist": True}) ``` | Field | Type | Meaning | | - | - | - | | `name` | string | The profile to load. Created on first use. | | `persist` | boolean | Save when the session ends. Default `false`: read-only, changes are discarded. | Profiles work on every browser type. ## Listing and deleting ```bash cURL theme={"dark"} curl https://api.driver.dev/v1/browser/profiles -H "Authorization: Bearer $DRIVER_API_KEY" # → { "profiles": [ { "profileId": "…", "name": "shop-account", "createdAt": "…" } ] } curl -X DELETE "https://api.driver.dev/v1/browser/profile?profileName=shop-account" \ -H "Authorization: Bearer $DRIVER_API_KEY" ``` ```typescript TypeScript theme={"dark"} const { profiles } = await fetch("https://api.driver.dev/v1/browser/profiles", { headers }) .then((r) => r.json()); await fetch("https://api.driver.dev/v1/browser/profile?profileName=shop-account", { method: "DELETE", headers, }); ``` ```python Python theme={"dark"} profiles = requests.get("https://api.driver.dev/v1/browser/profiles", headers=headers).json() requests.delete( "https://api.driver.dev/v1/browser/profile", headers=headers, params={"profileName": "shop-account"} ) ``` Delete is idempotent: an unknown name also answers `{ "success": true }`. The list isn't paginated and returns every profile in the workspace, so delete what you no longer need. The dashboard's **Profiles** page shows the same list and can launch a session on a profile so you sign in by hand through the live view. ## Practical notes * One profile, one session at a time. Two concurrent sessions on the same persisted profile overwrite each other's state when they stop. Nothing refuses the second. * The profile is written as the session ends. Wait for `GET /v1/browser/session` to report `completed` before starting the next session on the same name. * Keep the same `country` across runs. A profile that signed in from Germany and comes back from Brazil looks like a stolen session. * A profile holds everything Chrome's does: cookies, local storage, IndexedDB, service workers. Delete it when the account is no longer needed. * As in desktop Chrome, cookies without an expiry (session cookies) are dropped when the browser closes. Sites that keep you signed in set persistent cookies, which carry over; a login that only sets a session cookie has to be repeated each run. * The next session on a profile can run on a different machine. The state travels with the name. # Proxies Source: https://docs.driver.dev/docs/options/proxies Sessions egress from Driver's ISP networks by default. proxyUrl routes through your own SOCKS5 endpoint, or one of your dedicated IPs. Traffic leaves from an ISP connection in the requested country by default. Set `proxyUrl` when you need a specific egress: your own provider, a sticky IP, or one of your dedicated addresses. ```json Request theme={"dark"} { "proxyUrl": "socks5://user:pass@proxy.example.com:1080" } ``` ```typescript TypeScript theme={"dark"} const session = await createSession({ proxyUrl: "socks5://user:pass@proxy.example.com:1080" }); ``` ```python Python theme={"dark"} session = create_session(proxyUrl="socks5://user:pass@proxy.example.com:1080") ``` ## Accepted URLs | Scheme | Notes | | - | - | | `socks5://user:pass@host:port` | DNS resolved on the session's machine. | | `socks5h://user:pass@host:port` | DNS resolved through the proxy. Use it when the proxy's location should decide DNS answers too. | | `dedicated://`, `dedicated://any` | One of the workspace's [dedicated IPs](#dedicated-ips). No credentials: Driver holds them. | The proxy must be reachable from Driver's machines. HTTP proxies aren't supported. ## Location coherence Given a SOCKS URL and no `timezone` or `language`, Driver geolocates the proxy's exit before launch and derives the session's country and timezone from it. The browser won't claim New York while its packets come from Frankfurt. A `country` you also pass must match the exit, or the request is a `400`. ## Canada Hosted sessions in Canada without a proxy egress from Driver's Canadian ISP network. Nothing to configure. ## If the proxy cannot be reached Driver connects through the proxy before launch to find its exit. A proxy that doesn't answer is a `400`: `Could not determine SOCKS5 proxy egress location; verify the proxy or specify both timezone and language.` Passing both `timezone` and `language` skips the lookup; the session then starts even with a dead proxy, and pages fail to load inside it. An `http://` URL is rejected outright. Credentials travel in the create request only. They aren't stored on the session record. ## Dedicated IPs A dedicated IP is a public address reserved to your workspace: no other customer can reserve or pin it, and your sessions can ask for it by address. Reserve them under **Browsers → Network** in the [dashboard](https://app.driver.dev/network): pick a country and a quantity, up to however many that country has free. The pool is 100 United States and 100 Canadian addresses. \$5 per address per month, billed on your card as a subscription line. Adding an address mid-month is charged right away, prorated to your billing date. You never see the SOCKS5 URL behind an address. On session create, name the address: ```bash cURL theme={"dark"} curl -X POST https://api.driver.dev/v1/browser/session \ -H "Authorization: Bearer $DRIVER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "proxyUrl": "dedicated://65.86.104.7" }' ``` ```typescript TypeScript theme={"dark"} // a specific address, or any of yours, optionally in one country const session = await createSession({ proxyUrl: "dedicated://65.86.104.7" }); const anyUs = await createSession({ proxyUrl: "dedicated://any~country-US" }); ``` ```python Python theme={"dark"} session = create_session(proxyUrl="dedicated://65.86.104.7") any_us = create_session(proxyUrl="dedicated://any~country-US") ``` | Spec | Meaning | | - | - | | `dedicated://` | This address. | | `dedicated://any` | Any address you own, chosen at random. | | `dedicated://any~country-US` | Any of yours in that country. | The address decides the session's `country` and `timezone`. Leave them out; a `country` you pass has to match. `GET /v1/network/ips` lists your addresses with country, city, network type, label and status. Copy an address from there, or from the Network page. | Status | Code | Meaning | | - | - | - | | `400` | `invalid_spec` | Not one of the forms above, or a `country` that contradicts the address. | | `403` | `dedicated_ip_not_owned` | The address belongs to another workspace, or the key isn't linked to one. | | `404` | `no_dedicated_ip` | Nothing matches: no addresses, or none in that country. | | `503` | `dedicated_ip_unhealthy` | The address failed its last health check. Use another one and report it. | Every address has a **Renews** switch on the Network page. Switch it off and the address stays yours until the end of the month you paid for, then leaves the workspace and goes back to the pool; the seat drops at that point. No refund for the current month. Switch it back on before then and it renews as before. The page shows what renews on the next billing date and what that will cost. If a renewal payment fails, the addresses keep working for 2 days while the card is retried, then they're released. # Window and display size Source: https://docs.driver.dev/docs/options/window-size windowSize sets the outer browser window; displaySize sets the virtual display behind it. Both fields take `WIDTHxHEIGHT`, width 320 to 16384, height 200 to 16384. | Field | What it sets | Notes | | - | - | - | | `windowSize` | The outer Chrome window, what the page sees as `window.outerWidth/Height`. | Default depends on the machine. | | `displaySize` | The virtual display the window sits on. | Leave it alone unless a site needs a screen larger than the window. | ```json Request theme={"dark"} { "windowSize": "1920x1040", "displaySize": "1920x1080" } ``` ```typescript TypeScript theme={"dark"} const session = await createSession({ windowSize: "1920x1040" }); ``` ```python Python theme={"dark"} session = create_session(windowSize="1920x1040") ``` ## Sizes people actually use | `windowSize` | Looks like | | - | - | | `1920x1040` | Full HD desktop with a taskbar | | `1536x864` | Scaled 1080p laptop | | `1440x900` | 13-inch MacBook | | `1366x768` | Budget laptop | | `390x844` | Phone-sized window (still desktop Chrome) | Pick a size the session's machine would plausibly have. A `windowSize` larger than the display is clamped. ## Resizing after connecting Set the size on create. `page.setViewportSize` (Playwright) and `page.setViewport` (Puppeteer) resize only the content area and leave `outerWidth` alone, a mismatch sites look for. Puppeteer applies an 800x600 viewport on connect unless you pass `defaultViewport: null`, so always pass it. # Connect over CDP Source: https://docs.driver.dev/docs/sessions/connect Attach your framework to a running session through its cdpUrl. `cdpUrl` is a WebSocket endpoint to the session's Chrome. Any CDP client can attach. You never launch a browser. Use [Patchright](/docs/frameworks/patchright), not stock Playwright or Puppeteer. It's Playwright's API minus the CDP calls anti-bot systems fingerprint; Playwright code moves over by changing one import, Puppeteer code needs porting. Stock frameworks get blocked on sites that look for automation. ## Patchright (recommended) ```typescript TypeScript theme={"dark"} import { chromium } from "patchright"; const browser = await chromium.connectOverCDP(session.cdpUrl); const context = browser.contexts()[0] ?? (await browser.newContext()); const page = context.pages()[0] ?? (await context.newPage()); await page.goto("https://example.com"); console.log(await page.title()); await browser.close(); // your connection only; the session runs until you stop it ``` ```python Python theme={"dark"} from patchright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.connect_over_cdp(session["cdpUrl"]) context = browser.contexts[0] if browser.contexts else browser.new_context() page = context.pages[0] if context.pages else context.new_page() page.goto("https://example.com") print(page.title()) browser.close() ``` ## Other clients Works, with the caveats above. `puppeteer-core` with `browserWSEndpoint`. Agents on a Driver browser. Natural-language actions over CDP. ## Reuse the context and tab The browser opens with one context and one tab. Reuse them; fresh incognito contexts and blank pages don't look like a person's Chrome. ```typescript theme={"dark"} const context = browser.contexts()[0] ?? (await browser.newContext()); const page = context.pages()[0] ?? (await context.newPage()); ``` Need more tabs? `context.newPage()` on the existing context. ## Mouse and keyboard Your framework's `click`, `fill` and `type` work as usual. To drive the mouse and keyboard directly (move a cursor, press buttons and keys, scroll), send [native input](/docs/sessions/native-input) commands on the same connection. ## Disconnecting versus stopping `browser.close()` (Patchright, Playwright) and `browser.disconnect()` (Puppeteer) end your connection only. The session runs, and bills, until [`DELETE /v1/browser/session`](/docs/sessions/lifecycle#stop-a-session) or its `duration`. Reconnect to the same `cdpUrl` as often as you like while it's active. ## Reconnecting If your process crashes, [`GET /v1/browser/session`](/docs/sessions/lifecycle#get-a-session): while `status` is `active` the `cdpUrl` still works. If the session ended, a profile with `persist: true` carries the state into a new one. Several clients can attach at once (your script and the [live view](/docs/sessions/live-view), say), but two scripts driving one tab fight each other: one automation client per session. `cdpUrl` is a credential. Its hostname is a relay near the machine, not the session's network; egress is from the country you asked for whatever host you connect to. ## Timeouts and waits Real machines on real networks: page loads take real time. Use the framework's auto-waiting (`page.click`, `locator.waitFor`), not fixed sleeps, and raise the navigation timeout for slow sites instead of retrying in a loop. # Create a session Source: https://docs.driver.dev/docs/sessions/create POST /v1/browser/session starts a Chrome and returns its CDP URL once it's reachable. One browser on one machine for up to an hour. Every field is optional; the defaults give you a hosted Chrome in the US. If a few seconds of start-up is too slow, acquire from a [browser pool](/docs/sessions/pools) instead. ```bash cURL theme={"dark"} curl -X POST https://api.driver.dev/v1/browser/session \ -H "Authorization: Bearer $DRIVER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "country": "US", "duration": 600, "windowSize": "1920x1080", "profile": { "name": "my-profile", "persist": true }, "note": "price check, client X" }' ``` ```typescript TypeScript theme={"dark"} const res = await fetch("https://api.driver.dev/v1/browser/session", { method: "POST", headers: { Authorization: `Bearer ${process.env.DRIVER_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ country: "US", duration: 600, windowSize: "1920x1080", profile: { name: "my-profile", persist: true }, note: "price check, client X", }), }); if (!res.ok) throw new Error(`Driver ${res.status}: ${(await res.json().catch(() => ({}))).error ?? res.statusText}`); const session = await res.json(); ``` ```python Python theme={"dark"} r = requests.post( "https://api.driver.dev/v1/browser/session", headers={"Authorization": f"Bearer {os.environ['DRIVER_API_KEY']}"}, json={ "country": "US", "duration": 600, "windowSize": "1920x1080", "profile": {"name": "my-profile", "persist": True}, "note": "price check, client X", }, ) r.raise_for_status() session = r.json() ``` ## Response ```json theme={"dark"} { "sessionId": "0b1f5c1e6d3a4f7c9e2b8d4a6c1e3f5a", "status": "active", "servedBy": "adapt-ensure-swim", "createdAt": "2026-09-17T14:02:11.000Z", "stoppedAt": null, "cdpUrl": "wss://…", "note": "price check, client X" } ``` The call blocks until Chrome is reachable (measured: two to three seconds), so `cdpUrl` is set and `status` is `active`. It gives up after 20 seconds on most machines, three minutes on the newest, with a `504`. Set a client timeout of at least three minutes on this call; everything else answers within seconds. The browser opens on `about:blank`, or your `url`, with one context and one tab. If you get `starting`, poll [`GET /v1/browser/session`](/docs/sessions/lifecycle#get-a-session) until `cdpUrl` appears. Session ids are opaque (32 hex characters today); don't validate the format. `cdpUrl` is a credential: anyone holding it can drive the browser. ## Options | Field | Type | Default | What it does | | - | - | - | - | | `type` | string | `hosted` | `hosted`, `hosted_stealth` or `hosted_privacy`. See [Browser types](/docs/options/browser-types). | | `country` | string | `US` | Two-letter code from [Countries](/docs/options/countries). Placement, network and locale follow it. See [Location](/docs/options/location). | | `timezone` | string | chosen for the country | IANA name from that country's list on [Countries](/docs/options/countries). | | `language` | string | chosen for the country | BCP-47 tag from that country's list on [Countries](/docs/options/countries). US sessions are always `en-US`. | | `duration` | integer | `3600` | Seconds until the session stops on its own, 60 to 3600 inclusive. | | `url` | string | | Opened right after launch. | | `windowSize` | string | node default | Outer window size, `WIDTHxHEIGHT`. See [Window size](/docs/options/window-size). | | `displaySize` | string | | Virtual display size, `WIDTHxHEIGHT`. | | `proxyUrl` | string | Driver's network | Your own `socks5://` or `socks5h://` URL, or `dedicated://…` for one of your [dedicated IPs](/docs/options/proxies#dedicated-ips). See [Proxies](/docs/options/proxies). | | `profile` | object | | `{ "name": "…", "persist": true }` loads and saves browser state. `persist` defaults to `false` (load only). See [Profiles](/docs/options/profiles). | | `captchaSolver` | boolean | `false` | Load Driver's CAPTCHA solver. | | `adblock` | boolean | `false` | Block ads. | | `extensionIds` | string\[] | | Uploaded extension ids to load. Needs extension access. See [Extensions](/docs/options/extensions). | | `nodeId` | string | | Run on the machine that served an earlier session. Overrides `country` and `type`. See [Node reuse](/docs/options/node-reuse). | | `browserCheck` | boolean | `false` | Run a browser validation check during creation. | | `note` | string | | Up to 256 characters, shown in the dashboard. | Unknown fields are a `400` (`window_size is not supported by this endpoint`), as are out-of-range values (`duration: Too small: expected number to be >=60`, `note: Too big: expected string to have <=256 characters`) and options that contradict each other: a `timezone` or `language` not listed for the `country` (`Unsupported timezone "Europe/Berlin" for country US.`), a `country` that doesn't match your proxy's exit, a proxy that can't be reached. The message names the field. ## Errors | Status | Meaning | | - | - | | `400` | Invalid or incompatible options; the message names the field. | | `401` | Missing or invalid API key. | | `402` | The workspace is out of credits. | | `403` | Concurrency limit, or a feature your plan doesn't include. | | `429` | Too many requests. | | `500` | Something failed on Driver's side. | | `503` | No browser capacity. Carries `Retry-After: 2` and `"code": "browser_capacity_unavailable"`. | | `504` | A machine was selected but Chrome wasn't reachable before the startup deadline (20 s, up to 3 min on some machines). | What to retry is on [Errors](/docs/sessions/errors). ## Next * [Connect over CDP](/docs/sessions/connect) * [Lifecycle](/docs/sessions/lifecycle): get, list, label and stop sessions * [Browser pools](/docs/sessions/pools): warm browsers with these options preset # Errors Source: https://docs.driver.dev/docs/sessions/errors Every status the API returns, what it means, and whether retrying helps. Errors are JSON with an `error` message; the create call's `503` also has a `code`. A `429` or `502` from the edge in front of the API may not be JSON, so read the status first and parse the body defensively. ```json theme={"dark"} { "error": "Insufficient balance" } ``` ```json theme={"dark"} { "error": "No eligible node found", "code": "browser_capacity_unavailable" } ``` Common bodies: `401` `Unauthorized: Missing or invalid Authorization header` or `Unauthorized: Invalid token`; `402` `Insufficient balance`; `403` `Session limit reached` (concurrency) or `Extensions API is not enabled for this account`; `400` names the field, for example `Unsupported timezone "Europe/Berlin" for country US.` or `windowSize must be WIDTHxHEIGHT with width 320–16384 and height 200–16384`. Unknown fields in a body are a `400` too. ## Status codes The dedicated-IP codes here (`invalid_spec`, `dedicated_ip_not_owned`, `no_dedicated_ip`, `dedicated_ip_unhealthy`) come back when `proxyUrl` names a `dedicated://` address. The dashboard resolves the address before the create call, so Launch browser never returns them. | Status | Meaning | Retry? | | - | - | - | | `400` | Invalid or incompatible options, an unknown field on create, a missing `sessionId`, an update to an ended session, or a malformed dedicated IP spec (`code: invalid_spec`). The message names the problem. | No. Fix the request. | | `401` | Missing or invalid API key. | No. Check `Authorization: Bearer …`. | | `402` | The workspace is out of credits. | No. Add credits in the dashboard. | | `403` | Concurrent-browser limit (`plan.concurrent_browsers` on `GET /v1/account/billing`; `starting` sessions count, a stop frees the slot as soon as it returns), a feature (extensions) the account isn't enabled for, or a [dedicated IP](/docs/options/proxies#dedicated-ips) the workspace doesn't own (`code: dedicated_ip_not_owned`). | Only after a session ends. Never in a loop. | | `404` | Session, extension, pool, lease or account not found: unknown id, or one belonging to another workspace. On create, no dedicated IP matches the spec (`code: no_dedicated_ip`). Deleting an unknown profile isn't a 404; it answers `{ "success": true }`. | No. | | `409` | Browser pools only: a duplicate pool name, a size over the account's warm-browser cap, an acquire that found nothing ready within `waitMs` (`No browser is currently ready in this pool`), an acquire on a paused pool, or a delete with active leases. See [Browser pools](/docs/sessions/pools#errors). | Only the acquire one, as for `503`: back off, cap, then fall back to a plain create or report. | | `429` | Too many requests, from the edge; the API itself doesn't rate-limit today. | Yes: a few attempts, a couple of seconds apart and growing. | | `502` | The edge couldn't reach the API. Not JSON. | Once, after a couple of seconds. | | `500` | Something failed on Driver's side. | Once. | | `503` | No browser capacity. Carries `Retry-After: 2` and `"code": "browser_capacity_unavailable"`. Or the session service is unavailable. With `code: dedicated_ip_unhealthy`, the dedicated IP failed its last health check: use another address. | Yes: back off with jitter, cap the attempts, then tell the user. | | `504` | A machine was selected but Chrome wasn't reachable before the startup deadline (20 s, up to 3 min on some machines). | Once. | ## Guidance for agents and automations * Never loop on `402` or `403`. Surface the message; a person has to act. * `503` is transient: start at the `Retry-After` of 2 seconds, grow with jitter, cap the attempts, then report. Retry `504` and `500` once. * After a `504` nothing is running; no stop call needed. * `DELETE` on an ended session still returns `{ "success": true }`. A `404` means the id was never a session of this workspace. ## Framework-side failures Some failures show up in the framework, not as an HTTP status: | Symptom | Likely cause | | - | - | | `Target closed` / `Browser closed` mid-run | The session reached its `duration`, or was stopped from the dashboard. Check `GET /v1/browser/session`. | | Connection refused on `cdpUrl` | The session isn't `active` yet or has ended. | | The site shows a challenge or block page | Framework fingerprint or automation tactics. Switch to [Patchright](/docs/frameworks/patchright) and remove CDP hooks; then escalate: `type: "hosted_stealth"`, `"hosted_privacy"`, `captchaSolver: true`, and finally a person through the [live view](/docs/sessions/live-view). | | `net::ERR_SOCKS_CONNECTION_FAILED` on every navigation | The session's egress isn't reachable. Stop it and create another, in a different country if it persists; report it if it keeps happening. | # Lifecycle Source: https://docs.driver.dev/docs/sessions/lifecycle Get, list, label and stop sessions. What each status means and how long a session lives. `starting` → `active` → `completed`, or `error` if the browser failed. A session ends when you stop it, at its `duration`, or when its machine goes away. One acquired from a [browser pool](/docs/sessions/pools) is the same object (`createdAt` is when the browser was warmed) and also ends when its lease is released or expires. | Status | Meaning | | - | - | | `starting` | The machine is launching Chrome. `cdpUrl` is `null`. | | `active` | Chrome is reachable at `cdpUrl`. | | `completed` | Stopped, or reached its duration. `stoppedAt` is set. | | `error` | Failed to start, ended abnormally, or the machine went away. `stoppedAt` is set. | ## Get a session Same shape as create, plus `bandwidthBytes`: `null` while running, the metered total after (final by the time a stop returns; `0` if nothing moved). `cdpUrl` is `null` after the session ends. ```bash cURL theme={"dark"} curl "https://api.driver.dev/v1/browser/session?sessionId=$SESSION_ID" \ -H "Authorization: Bearer $DRIVER_API_KEY" ``` ```typescript TypeScript theme={"dark"} const url = `https://api.driver.dev/v1/browser/session?sessionId=${id}`; const session = await fetch(url, { headers }).then((r) => r.json()); ``` ```python Python theme={"dark"} session = requests.get( "https://api.driver.dev/v1/browser/session", headers=headers, params={"sessionId": id} ).json() ``` ## List sessions Newest first. `status` matches exactly (`starting` isn't in `?status=active`); anything outside `starting`, `active`, `completed`, `error` is a `400`. `page` defaults to 1, `pageSize` to 20, max 100 (larger is clamped). ```bash cURL theme={"dark"} curl "https://api.driver.dev/v1/browser/sessions?page=1&pageSize=20&status=active" \ -H "Authorization: Bearer $DRIVER_API_KEY" ``` ```typescript TypeScript theme={"dark"} const { sessions, total, totalPages } = await fetch( "https://api.driver.dev/v1/browser/sessions?status=active", { headers }, ).then((r) => r.json()); ``` ```python Python theme={"dark"} data = requests.get( "https://api.driver.dev/v1/browser/sessions", headers=headers, params={"status": "active"} ).json() ``` Response: `sessions`, `total`, `totalPages`, `page`, `pageSize`. Items: `sessionId`, `status`, `servedBy`, `type`, `country`, `createdAt`, `stoppedAt`, `bandwidthBytes`, `note`. This is the only place the API reports `country`; `type` is `null` for the default type. ## Label a session Notes show in the dashboard's session list and record. Set one on create, or later: ```bash cURL theme={"dark"} curl -X PATCH "https://api.driver.dev/v1/browser/session?sessionId=$SESSION_ID" \ -H "Authorization: Bearer $DRIVER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"note":"nightly price check, client X"}' ``` ```typescript TypeScript theme={"dark"} await fetch(`https://api.driver.dev/v1/browser/session?sessionId=${id}`, { method: "PATCH", headers, body: JSON.stringify({ note: "nightly price check, client X" }), }); ``` ```python Python theme={"dark"} requests.patch( "https://api.driver.dev/v1/browser/session", headers=headers, params={"sessionId": id}, json={"note": "nightly price check, client X"}, ) ``` Returns `{ "success": true }`. Max 256 characters (longer is a `400`); an empty string clears it. An ended session can't be updated (`400`, `Cannot update a terminal session`). ## Stop a session Releases the browser and ends billing. Do it in a `finally`. A `starting` session can be stopped too. ```bash cURL theme={"dark"} curl -X DELETE "https://api.driver.dev/v1/browser/session?sessionId=$SESSION_ID" \ -H "Authorization: Bearer $DRIVER_API_KEY" ``` ```typescript TypeScript theme={"dark"} await fetch(`https://api.driver.dev/v1/browser/session?sessionId=${id}`, { method: "DELETE", headers, }); ``` ```python Python theme={"dark"} requests.delete( "https://api.driver.dev/v1/browser/session", headers=headers, params={"sessionId": id} ) ``` Returns `{ "success": true }`, also for a session that already ended, so a stop in a `finally` is always safe. ## Duration `duration` is the lifetime in seconds, 60 to 3600 inclusive, default 3600. There's no extension call: pick one that covers the job, and start a new session (with a persisted [profile](/docs/options/profiles) if you need the state) when it runs out. ## Waiting for `active` Create returns with `cdpUrl` set. If you see `starting`, poll. The [reference client](/docs/start/for-agents#reference-implementation) has both of these. ```typescript TypeScript theme={"dark"} const getSession = (id: string) => fetch(`https://api.driver.dev/v1/browser/session?sessionId=${id}`, { headers }).then((r) => r.json()); async function waitForActive(id: string, timeoutMs = 60_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const s = await getSession(id); if (s.status === "active" && s.cdpUrl) return s; if (s.status === "completed" || s.status === "error") { throw new Error(`session ended: ${s.status}`); } await new Promise((r) => setTimeout(r, 1000)); } throw new Error("timed out waiting for the browser"); } ``` ```python Python theme={"dark"} import time def get_session(session_id): return requests.get( "https://api.driver.dev/v1/browser/session", headers=headers, params={"sessionId": session_id} ).json() def wait_for_active(session_id, timeout=60): deadline = time.time() + timeout while time.time() < deadline: s = get_session(session_id) if s["status"] == "active" and s.get("cdpUrl"): return s if s["status"] in ("completed", "error"): raise RuntimeError(f"session ended: {s['status']}") time.sleep(1) raise TimeoutError("timed out waiting for the browser") ``` # Live view Source: https://docs.driver.dev/docs/sessions/live-view Watch a running session, take the controls, and read the record afterwards. Watch a session while it runs, inspect it after it ends. Look before you add logging or hooks, and hand a login or CAPTCHA to a person mid-run. ## In the dashboard **Telemetry → Sessions** in the [dashboard](https://app.driver.dev), pick a running session. You can scroll, click and type in it. The **Workbench** has the same view next to a script editor for trying options before writing code. ## From a viewer URL ```text theme={"dark"} https://viewer.driver.dev?ws= ``` Paste `cdpUrl` in as is, no encoding; the viewer reads everything after `ws=`. Print the URL as soon as the session exists so the person has time to open it. It shows the tab your script is driving. ```typescript TypeScript theme={"dark"} const viewerUrl = `https://viewer.driver.dev?ws=${session.cdpUrl}`; ``` ```python Python theme={"dark"} viewer_url = f"https://viewer.driver.dev?ws={session['cdpUrl']}" ``` | Parameter | Description | | - | - | | `ws` | The session's `cdpUrl`. | | `theme` | Optional: `light` or `dark`. | Anyone with the URL can drive the browser; treat it like the API key. It stops working when the session ends. ## The session record After a session ends its dashboard page keeps: status and duration, bandwidth and cost, node and country, how it was started (type, profile, note, API key), timestamps. Log the `sessionId` so you can find a run later. The API keeps the essentials: [`GET /v1/browser/session`](/docs/sessions/lifecycle#get-a-session) has `status`, `stoppedAt` and `bandwidthBytes`; the [list](/docs/sessions/lifecycle#list-sessions) adds `type` and `country`. Cost, network and node details are dashboard-only. # Native input Source: https://docs.driver.dev/docs/sessions/native-input Move a cursor, click, scroll and type in the session's browser, over the CDP connection you already have. `NativeInput` is a CDP domain in every new session, [pool](/docs/sessions/pools) browsers included. There's nothing to turn on: connect to `cdpUrl`, open a page-level CDP session, send commands. It keeps its own cursor inside the browser: it doesn't move the machine's mouse or draw anything in the [live view](/docs/sessions/live-view). ## Quickstart ```typescript TypeScript theme={"dark"} import { chromium } from "patchright"; const browser = await chromium.connectOverCDP(session.cdpUrl); const context = browser.contexts()[0]; const page = context.pages()[0]; // NativeInput isn't in the stock CDP typings, so the session is untyped here. const input: any = await context.newCDPSession(page); try { const { state } = await input.send("NativeInput.getState"); await input.send("NativeInput.moveCursor", { x: state.width / 2, y: state.height / 2 }); await input.send("NativeInput.mouseDown", { button: "left" }); await input.send("NativeInput.mouseUp", { button: "left" }); } finally { await input.send("NativeInput.reset").catch(() => {}); await input.detach().catch(() => {}); await browser.close(); // your connection only; the session runs until you stop it } ``` ```python Python theme={"dark"} from contextlib import suppress from patchright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.connect_over_cdp(session["cdpUrl"]) context = browser.contexts[0] page = context.pages[0] input_session = context.new_cdp_session(page) try: state = input_session.send("NativeInput.getState")["state"] input_session.send("NativeInput.moveCursor", {"x": state["width"] / 2, "y": state["height"] / 2}) input_session.send("NativeInput.mouseDown", {"button": "left"}) input_session.send("NativeInput.mouseUp", {"button": "left"}) finally: with suppress(Exception): input_session.send("NativeInput.reset") with suppress(Exception): input_session.detach() browser.close() # your connection only; the session runs until you stop it ``` Any CDP client works, no SDK needed: send the commands on a session attached to the page. The domain name is case-sensitive (`NativeInput`, not `nativeInput`) and there's no `enable` call. Input goes to the active tab; with several open, attach to the one you mean to drive. The examples below use the TypeScript `input` from the quickstart; in Python, `input_session.send` takes the same methods and parameters. ## Mouse There's no single `click` command. Move, then press and release: ```typescript theme={"dark"} await input.send("NativeInput.moveCursor", { x: 100, y: 120 }); await input.send("NativeInput.mouseDown", { button: "left" }); await input.send("NativeInput.mouseUp", { button: "left" }); ``` | To | Send | | - | - | | Right-click | `button: "right"` on both down and up. Also `middle`, `back`, `forward`. | | Double-click | Two press/release pairs: `clickCount: 1` on the first `mouseDown`, `clickCount: 2` on the second. A single pair with `clickCount: 2` fires `dblclick` without the first click, which pages that count clicks will notice. | | Drag | `mouseDown`, several `moveCursor` steps, `mouseUp`. Keep one coordinate space throughout. | | Scroll | `moveCursor` over page content, then `scroll` with `deltaX` / `deltaY` in pixels, each between `-32767` and `32767`. Positive `deltaY` scrolls down. | ```typescript theme={"dark"} // Drag, then scroll down await input.send("NativeInput.moveCursor", { x: 100, y: 120 }); await input.send("NativeInput.mouseDown", { button: "left" }); await input.send("NativeInput.moveCursor", { x: 160, y: 150 }); await input.send("NativeInput.moveCursor", { x: 220, y: 180 }); await input.send("NativeInput.mouseUp", { button: "left" }); await input.send("NativeInput.scroll", { deltaX: 0, deltaY: 240 }); ``` Always move before pressing a button or scrolling, especially after attaching or reconnecting. Viewport coordinates are the CSS pixels `getBoundingClientRect()` reports at default zoom, so an element's center is a click target; the numbers in these examples are placeholders. ## Keyboard ### Text Click into a field, then insert the text in one call. It goes to the focused text control as typed text, not as a DOM value or one key event per character. Any Unicode works, up to 16,384 UTF-8 bytes. ```typescript theme={"dark"} await input.send("NativeInput.moveCursor", { x: 140, y: 180 }); await input.send("NativeInput.mouseDown", { button: "left" }); await input.send("NativeInput.mouseUp", { button: "left" }); await input.send("NativeInput.insertText", { text: "Hello — 你好 👋" }); ``` Release any held keys or buttons first; `insertText` refuses to run with a modifier down. ### Keys and shortcuts Keys are physical DOM codes: `KeyA`, `Digit1`, `Enter`, `Tab`, `Escape`, `ArrowLeft`, `ShiftLeft`, `ControlLeft`. Every `keyDown` needs its `keyUp`, and left and right modifiers are separate keys. ```typescript theme={"dark"} // Select all, replace, submit await input.send("NativeInput.keyDown", { code: "ControlLeft" }); await input.send("NativeInput.keyDown", { code: "KeyA" }); await input.send("NativeInput.keyUp", { code: "KeyA" }); await input.send("NativeInput.keyUp", { code: "ControlLeft" }); await input.send("NativeInput.insertText", { text: "Replacement text" }); await input.send("NativeInput.keyDown", { code: "Enter" }); await input.send("NativeInput.keyUp", { code: "Enter" }); ``` * Keys follow a US layout plus the modifiers you hold. Pass `key` to override the character, for example `{ code: "KeyA", key: "ä" }`. Use `insertText` for emoji. * A key repeats only when you send another `keyDown`, never on a timer. * Shortcuts reach Chrome's own UI too: `ControlLeft` + `KeyL` focuses the address bar. Release the chord before inserting a URL, and wait for the navigation separately. * Keys and text go wherever Chrome's focus is. After `Ctrl+L` or a click on the toolbar, focus stays in the address bar until you click into the page. * CapsLock is tracked per session and cleared by `reset`. NumLock, ScrollLock and IME or dead-key composition aren't supported. ## Coordinates and state Every command answers `{ state }`. `getState` reads it without changing anything. ```json theme={"dark"} { "x": 100, "y": 120, "hasPosition": true, "coordinateSpace": "viewport", "width": 1280, "height": 640, "buttons": [], "keys": [], "capsLock": false, "ownsControl": true, "backend": "chromium-ui" } ``` | Field | Meaning | | - | - | | `x`, `y`, `hasPosition` | Where the cursor is. `hasPosition` is `false` until the first move. | | `coordinateSpace` | The space of the last move. `viewport` (the default): the page content. `window`: the whole browser client area, Chrome's toolbar included. | | `width`, `height` | Size of that surface in device-independent pixels. Points must sit inside it: `0 <= x < width`, `0 <= y < height`. | | `buttons`, `keys`, `capsLock` | What's held down right now. | | `ownsControl` | Whether this connection is the one sending input. | Read `width` and `height` rather than assuming them, and read them again after a resize ([window size](/docs/options/window-size) sets the starting size). `window` coordinates aren't screen coordinates, and screenshot pixels or CSS pixels under zoom or device emulation aren't necessarily the same units. To show the cursor in your own viewer, place your indicator from the returned `x` and `y`, not your local mouse. If your image covers the same surface scaled but uncropped, that's `x * imageWidth / width` and `y * imageHeight / height`. State isn't pushed: read it from command responses or call `getState`. ## One controller at a time State belongs to the browser window, not your connection: a new connection starts from the last cursor position, coordinate space and focus. Each window takes input from one connection. The first command that sends input takes control; other connections can still call `getState` and see the same state with `ownsControl: false`. To hand over, call `NativeInput.reset` on the current controller or detach it. Reset releases anything held, clears CapsLock and gives up control, but keeps the cursor where it is: move again before the next click. Disconnecting does the same cleanup. Reset before moving a controlled tab to another window. Send commands one at a time and await each. A response means the input was dispatched, not that the page reacted: wait for navigation or elements with your framework's usual waits. A timeout doesn't mean the input never arrived. Don't retry clicks or text blindly; reconnect, check the page and `getState`, then decide what's left to do. ## Raw CDP On the browser-level WebSocket, find the page with `Target.getTargets`, attach with `Target.attachToTarget` and `flatten: true`, and put the returned session ID on each command: ```json theme={"dark"} { "id": 10, "sessionId": "PAGE_CDP_SESSION_ID", "method": "NativeInput.moveCursor", "params": { "x": 100, "y": 120 } } ``` The reply carries the same `id` and `sessionId`, with the state in `result.state`; check for `error` first. On a WebSocket opened straight to the page target (`cdpUrl` with `/devtools/browser/` replaced by `/devtools/page/`), leave `sessionId` out. This CDP session ID is not the Driver `sessionId` you use with the API. ## Commands | Command | Parameters | | - | - | | `NativeInput.getState` | `{}` | | `NativeInput.moveCursor` | `{ x, y, coordinateSpace?: "viewport" \| "window" }` | | `NativeInput.mouseDown` | `{ button, clickCount?: 1 \| 2 \| 3 }` | | `NativeInput.mouseUp` | `{ button }` | | `NativeInput.scroll` | `{ deltaX, deltaY }` | | `NativeInput.keyDown` | `{ code, key? }` | | `NativeInput.keyUp` | `{ code }` | | `NativeInput.insertText` | `{ text }` | | `NativeInput.reset` | `{}` | `button` is `left`, `middle`, `right`, `back` or `forward`. `clickCount` defaults to `1`. ## Troubleshooting | Error | Fix | | - | - | | `'NativeInput.getState' wasn't found` | Send on a page-level CDP session, not the browser target, and spell it `NativeInput`. On a session that started before native input shipped, create a new one. | | `Attach NativeInput to an active browser page` | Attach to the active top-level tab, not a background or closed one. | | `This browser window already has an input owner` | Another connection holds control. `reset` or detach it first. | | `moveCursor is required before mouse buttons` | Send `moveCursor` first. | | `Scroll needs a cursor position and bounded finite deltas` | Move first, and keep each delta between `-32767` and `32767`. | | `Precise scrolling requires the page viewport` | Move the cursor over page content, not the toolbar. | | `Cursor coordinates are outside the input surface` | Keep `0 <= x < width` and `0 <= y < height` for the space you're using; read them again after a resize. | | `Focus an editable Chromium control before insertText` | Click an editable field first. | | `Release held modifiers/buttons before insertText` | Finish the shortcut or drag first. | | `Text must be valid nonempty UTF-8, at most 16384 bytes` | Split long text across several calls. | | `This connection does not hold that key`, `Mouse button is already in the requested state` | Pair every down with an up. Check `getState`, and `reset` your own controller if it's out of step. | | Cursor lands in the wrong place | Check the coordinate space, scaling, cropping and the current `width` and `height`. | | Timeout, or the page went away | Stop sending, reconnect, check the page and state before retrying. | Native input drives the browser only, not the machine's desktop. Native OS dialogs such as file pickers, pointer lock and raw relative mouse movement aren't covered, and it isn't guaranteed to look like physical hardware input to every site or widget. # Browser pools Source: https://docs.driver.dev/docs/sessions/pools Keep browsers warm and take one in under a second. Create a pool, acquire a lease, release or stop. A pool keeps browsers running with the options you chose. Acquire hands you one in under a second as an ordinary session, and the pool warms a replacement. Use one when start-up latency matters (an agent loop, a request handler, a burst of short jobs) or when every job wants the same start page, profile or country. A nightly batch that can wait a few seconds per browser is fine with plain [create](/docs/sessions/create). Pools are also in the [dashboard](https://app.driver.dev/pools) under **Browsers → Browser pools**; **Launch browser** on the Overview can take one from any ready pool. ## Create a pool ```bash cURL theme={"dark"} curl -X POST https://api.driver.dev/v1/browser/pools \ -H "Authorization: Bearer $DRIVER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "checkout-us", "size": 3, "browser": { "country": "US", "url": "https://shop.example.com", "note": "checkout pool" }, "leaseTimeoutSeconds": 900, "maxReadyAgeSeconds": 3600 }' ``` ```typescript TypeScript theme={"dark"} const res = await fetch("https://api.driver.dev/v1/browser/pools", { method: "POST", headers, body: JSON.stringify({ name: "checkout-us", size: 3, browser: { country: "US", url: "https://shop.example.com", note: "checkout pool" }, leaseTimeoutSeconds: 900, maxReadyAgeSeconds: 3600, }), }); if (!res.ok) throw new Error(`Driver ${res.status}: ${(await res.json().catch(() => ({}))).error ?? res.statusText}`); const pool = await res.json(); // pool.id is what the other calls take ``` ```python Python theme={"dark"} r = requests.post( "https://api.driver.dev/v1/browser/pools", headers=headers, json={ "name": "checkout-us", "size": 3, "browser": {"country": "US", "url": "https://shop.example.com", "note": "checkout pool"}, "leaseTimeoutSeconds": 900, "maxReadyAgeSeconds": 3600, }, ) r.raise_for_status() pool = r.json() # pool["id"] is what the other calls take ``` Answers `201` at once with the pool `warming`; measured, it was `ready` two to five seconds later. ```json theme={"dark"} { "id": "pool_63e5b6af14d344a38921d097ac6b101b", "name": "checkout-us", "size": 3, "status": "warming", "ready": 0, "starting": 0, "leased": 0, "browser": { "country": "US", "url": "https://shop.example.com", "note": "checkout pool" }, "leaseTimeoutSeconds": 900, "maxReadyAgeSeconds": 3600, "lastError": null, "createdAt": "2026-09-20T15:56:56.877Z", "updatedAt": "2026-09-20T15:56:56.877Z" } ``` ### Fields | Field | Type | Default | What it does | | - | - | - | - | | `name` | string | required | 1 to 64 characters, unique in the workspace (`409` `A pool with that name already exists`). | | `size` | integer | required | Browsers to keep ready, at least 1. 30 warm per account across all pools, paused ones included (`409` `Pool capacity 32 exceeds your pool limit of 30`, also on a resize); write to support to raise it. | | `browser` | object | `{}` | The create options every browser starts with: `country`, `timezone`, `language`, `url`, `windowSize`, `displaySize`, `proxyUrl`, `profile`, `adblock`, `captchaSolver`, `extensionIds`, `nodeId`, `note`. Same meaning as on [Create a session](/docs/sessions/create#options). `type`, `duration` and `browserCheck` aren't accepted. | | `leaseTimeoutSeconds` | integer | `900` | How long an acquired browser may be held, 30 to 86400. The session is stopped when the lease expires. | | `maxReadyAgeSeconds` | integer | `3600` | How long a browser may sit ready before it's swapped for a fresh one, 30 to 86400. The swap takes two to three seconds, during which `ready` is one lower. | Unknown fields, top-level or inside `browser`, are a `400` (`bogus is not supported by this endpoint`), and so is a country the platform doesn't serve (`Unsupported country "ZZ"`). Pool responses echo the template with what the platform resolved for it (`timezone`, and a `proxyUrl` with the credentials redacted). `lastError` is set when browsers fail to start. `profile` works as on a session. Leave `persist` off (the default) so every browser loads it read-only; several browsers writing one profile would overwrite each other. ## Status and counters | `status` | Meaning | | - | - | | `warming` | Just created or resumed; browsers are starting, none ready yet. | | `ready` | `ready` equals `size`. | | `degraded` | Fewer ready than `size`: normal right after an acquire, a flush or a lease expiring, while replacements start. A pool that can't start browsers stays here with `lastError` set. | | `paused` | Warm browsers stopped, none started. Acquire is refused. | | `deleting` | Being torn down. | `ready`, `starting` and `leased` count browsers in each state. Ready browsers aren't sessions yet: `GET /v1/browser/sessions` doesn't list them and they hold no concurrency slot until acquired. ## Acquire a browser ```bash cURL theme={"dark"} curl -X POST "https://api.driver.dev/v1/browser/pools/$POOL_ID/acquire" \ -H "Authorization: Bearer $DRIVER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "waitMs": 10000 }' ``` ```typescript TypeScript theme={"dark"} const lease = await fetch(`https://api.driver.dev/v1/browser/pools/${poolId}/acquire`, { method: "POST", headers, body: JSON.stringify({ waitMs: 10000 }), }).then((r) => (r.ok ? r.json() : Promise.reject(new Error(`Driver ${r.status}`)))); const { leaseId, session } = lease; // session.cdpUrl is live now ``` ```python Python theme={"dark"} r = requests.post( f"https://api.driver.dev/v1/browser/pools/{pool_id}/acquire", headers=headers, json={"waitMs": 10000} ) r.raise_for_status() lease = r.json() lease_id, session = lease["leaseId"], lease["session"] ``` ```json theme={"dark"} { "poolId": "pool_63e5b6af14d344a38921d097ac6b101b", "leaseId": "lease_ff68375dfc3b494299cc4a1d5ab0e36a", "acquiredAt": "2026-09-20T15:57:01.132Z", "expiresAt": "2026-09-20T16:12:01.132Z", "session": { "sessionId": "51b1815315c14580bb0fc7ab327999fe", "status": "active", "servedBy": "fresh-alien-program", "createdAt": "2026-09-20T15:56:56.892Z", "stoppedAt": null, "cdpUrl": "wss://…", "note": "checkout pool" } } ``` `waitMs` (0 to 30000, default 0) is how long to wait when nothing is ready; a replacement warms in two to three seconds, so a few seconds is enough. The body is required: `{}` for no wait (no body is a `400` `Malformed JSON in request body`). Nothing ready in time: `409` `No browser is currently ready in this pool`. Paused pool: `409` `Pool is paused` at once. Treat the first like a create `503`: back off, cap, then fall back to a plain create or report. From here `session` is an ordinary session: listed, readable with `GET /v1/browser/session?sessionId=…`, counted as a running browser, watchable in the [live view](/docs/sessions/live-view). `createdAt` is when the browser was warmed, not when you acquired it, and the page is already on the pool's `url`. Connect with Patchright as usual: ```typescript TypeScript theme={"dark"} import { chromium } from "patchright"; const lease = await acquire(poolId, 10_000); try { const browser = await chromium.connectOverCDP(lease.session.cdpUrl); try { const page = browser.contexts()[0].pages()[0]; // already on the pool's start URL await page.goto("https://shop.example.com/cart"); console.log(await page.title()); } finally { await browser.close(); } } finally { await release(poolId, lease.leaseId); } ``` ```python Python theme={"dark"} from patchright.sync_api import sync_playwright lease = acquire(pool_id, wait_ms=10000) try: with sync_playwright() as p: browser = p.chromium.connect_over_cdp(lease["session"]["cdpUrl"]) page = browser.contexts[0].pages[0] # already on the pool's start URL page.goto("https://shop.example.com/cart") print(page.title()) browser.close() finally: release(pool_id, lease["leaseId"]) ``` `acquire` and `release` are the two calls on this page; the [reference client](/docs/start/for-agents#reference-implementation) has both. ## Release or stop A leased browser ends one of three ways; the pool warms a replacement each time. * **Release**: `POST /v1/browser/pools/{poolId}/release` with `{ "leaseId": "…" }`. Stops the session; answers `{ "poolId", "leaseId", "sessionId", "status": "ended", "returnedAt" }`. Do it in a `finally`. * **Stop** it with `DELETE /v1/browser/session?sessionId=…`. Same effect; the lease closes within a few seconds. * **Let the lease expire.** At `expiresAt` (`leaseTimeoutSeconds` after acquire) the session is stopped whether or not you're done. Pick a lease that covers the job. Release ends billing, like a stop. It's idempotent (same body twice); an unknown lease, or one from another pool, is `404` `Pool lease not found`. A browser is never handed back for another job; the next acquire gets a fresh one. ```bash cURL theme={"dark"} curl -X POST "https://api.driver.dev/v1/browser/pools/$POOL_ID/release" \ -H "Authorization: Bearer $DRIVER_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"leaseId\": \"$LEASE_ID\" }" ``` ```typescript TypeScript theme={"dark"} await fetch(`https://api.driver.dev/v1/browser/pools/${poolId}/release`, { method: "POST", headers, body: JSON.stringify({ leaseId }), }); ``` ```python Python theme={"dark"} requests.post( f"https://api.driver.dev/v1/browser/pools/{pool_id}/release", headers=headers, json={"leaseId": lease_id} ) ``` ## Manage a pool | Call | What it does | | - | - | | `GET /v1/browser/pools` | `{ "pools": [ … ] }`, every pool in the workspace with its counters. | | `GET /v1/browser/pools/{poolId}` | One pool. | | `PATCH /v1/browser/pools/{poolId}` | Change `name`, `size`, `browser`, `leaseTimeoutSeconds`, `maxReadyAgeSeconds` or `paused` (`true` stops the warm browsers and refuses acquires; `false` warms them again). Send only what changes. A new `browser` replaces the template as a whole and the warm browsers are replaced with ones built from it; a rename, resize or timeout change leaves the ready browsers alone (a resize up warms more, a resize down stops the surplus). | | `POST /v1/browser/pools/{poolId}/flush` | Stops every ready browser and warms fresh ones. Answers `{ "success": true, "stopped": n }`. Leased browsers are untouched. | | `DELETE /v1/browser/pools/{poolId}` | Stops the warm browsers and removes the pool. With leased browsers: `409` `Pool has active leases; pass force=true to stop them`; `?force=true` (literal `true`; `1` is a `400`) stops those sessions too. Afterwards the pool is `404`. | Pool ids go in the path. Session ids go in the query string. ## Errors | Status | Body | Meaning | | - | - | - | | `400` | names the field | A missing or out-of-range field (`size: Too small: expected number to be >0`, `leaseTimeoutSeconds: Too small: expected number to be >=30`), or an unknown field. | | `404` | `Browser pool not found` | Unknown pool id, or one belonging to another workspace. | | `409` | `A pool with that name already exists` | Names are unique per workspace. | | `409` | `Pool capacity 32 exceeds your pool limit of 30` | The new size would put the account over its warm-browser cap. | | `409` | `No browser is currently ready in this pool` | Acquire waited `waitMs` and nothing became ready. Back off, cap, then fall back or report. | | `409` | `Pool has active leases; pass force=true to stop them` | Delete while browsers are leased. | | `503` | | The browser service is unavailable. Back off as for a create `503`. | ## Cost and limits Warm browsers aren't sessions: not listed, not billed, no concurrency slot. Acquired, a browser is a session like any other: it counts toward the concurrency limit and its bandwidth is metered until release, stop or lease expiry. The 30-warm cap per account is separate from the concurrency limit. ## Next * [Lifecycle](/docs/sessions/lifecycle): what you can do with the acquired session. * [Create a session](/docs/sessions/create#options): every option the `browser` template accepts. * [For agents](/docs/start/for-agents#reference-implementation): the reference client has `acquireFromPool` and `releaseLease`. # Best practices Source: https://docs.driver.dev/docs/start/best-practices How to keep sessions unblocked and cheap: the right framework, no CDP noise, coherent location, reliable cleanup. The browser is real and the network is real. Most blocks come from the automation side, and they're avoidable. ## Keep the logic minimal The shortest script that gets the result is the best one. It's faster, it fails in obvious ways, and it gives sites the least to notice. Every retry loop, event listener, request interceptor, "stealth" helper or defensive wait is a place for bugs to hide and a signal that this isn't a person. Add a mechanism after a specific site has shown you that you need it, and add the smallest one that works. ## Go fast, then verify Before writing browser steps, ask whether the result is reachable faster: * Go straight to the URL that has the result instead of clicking through navigation. * Do several pages in one session. Creating a session is the expensive step. * When start-up itself is the bottleneck (a request handler, an agent loop), keep a [browser pool](/docs/sessions/pools) warm and acquire from it. * Sign in once on a persisted [profile](/docs/options/profiles) and reuse it. * No fixed sleeps. Rely on the framework's auto-waiting and stop the session the moment you have the result. Then run it against the live target and check the result, not the exit code: the data is right, the action took effect, the session was stopped. The record under **Telemetry → Sessions** shows duration and bandwidth, which is how you know a run was actually fast. ## Use Patchright Stock Playwright and Puppeteer enable CDP domains (`Runtime.enable`, console and binding hooks) that anti-bot vendors fingerprint. [Patchright](/docs/frameworks/patchright) is Playwright with those removed and the same API. Use it for every Driver integration. Use stock Playwright only if you've measured that your target doesn't care. ## Keep the browser's defaults * Reuse `browser.contexts()[0]` and `context.pages()[0]`. The browser opened with a context and a tab already. * Keep the default viewport, user agent, locale, permissions and flags. Set [`windowSize`](/docs/options/window-size), [`country`, `timezone` and `language`](/docs/options/location) on the create call instead; Driver keeps them coherent with the network the session egresses from. * Don't add timeouts and navigation settings until a specific site needs them. ## Never launch a browser Driver provides the browser. Connect to the returned `cdpUrl`. Not in a Driver integration: * `chromium.launch()`, `chromium.launchPersistentContext()`, `puppeteer.launch()` * `playwright install`, or any browser download * invented `localhost` CDP ports or CDP hosts Driver didn't return ## Keep CDP hooks narrow Broad listeners, request routing and injected scripts make the browser behave unusually, and that's detectable. Use them only when the task genuinely needs them. Avoid by default (Playwright and Patchright names, Puppeteer equivalents in brackets): * `page.on(...)`, `context.on(...)`, `browser.on(...)` * `page.route(...)`, `context.route(...)` (`page.setRequestInterception(true)`) * `page.exposeBinding(...)`, `page.exposeFunction(...)` (`page.exposeFunction`) * `page.addInitScript(...)`, `context.addInitScript(...)` (`page.evaluateOnNewDocument`) * Puppeteer's `page.setUserAgent`, `page.emulateTimezone`, `page.emulate`, `page.setViewport`: fingerprint patches, see below Prefer direct actions and reads: `page.goto`, `page.click`, `page.fill`, `page.locator`, `page.title`, `page.textContent`. When a hook is unavoidable, listen for one event type, route one URL pattern, and remove it as soon as the step is done. ## Don't patch fingerprints The point of Driver is that the browser is genuine. Overriding `navigator`, the user agent, WebGL, canvas, timezone or locale from your script makes a real browser look fake. If you need a different location, ask for it on the create call. ## Debug with the live view first When a page behaves unexpectedly, open the session's [live view](/docs/sessions/live-view) and look before adding hooks or logging every event. You can take the mouse and keyboard yourself, which is often the fastest way to see what a site is doing. ## Always stop sessions Wrap browser work in `try` / `finally` so the session is stopped even when navigation or extraction fails. Stopping releases the browser and ends billing. ```typescript TypeScript theme={"dark"} const session = await createSession({ country: "US" }); try { // connect and automate } finally { await stopSession(session.sessionId); } ``` ```python Python theme={"dark"} session = create_session(country="US") try: ... # connect and automate finally: stop_session(session["sessionId"]) ``` Set a `duration` that matches the job. Sessions end on their own at the duration (an hour by default), which caps the cost of anything you forget. A browser from a [pool](/docs/sessions/pools) is released the same way, in the `finally`; its lease timeout plays the role of `duration`. ## Reuse logins with profiles Signing in on every run is slow and looks suspicious. Sign in once under a named [profile](/docs/options/profiles) with `persist: true`, then start later sessions with the same name. They open signed in. ## Handle the errors that matter | Status | Meaning | What to do | | - | - | - | | `402` | Out of credits | Tell the user. Retrying won't help. | | `403` | Plan limit: concurrency or a feature not enabled | Wait for a session to finish, or upgrade. Don't loop. | | `429` | Rate limited | Back off and retry. | | `500` | Something failed on our side | Retry once. | | `503` | No browser capacity (`Retry-After: 2`) | Back off with jitter, cap the attempts, then tell the user. | | `504` | Browser didn't become reachable in time | Retry once. | Full list on [Errors](/docs/sessions/errors). ## Label your sessions Pass `note` on create, or `PATCH` it later, with something a teammate would recognise ("nightly price check, client X"). It shows in the dashboard's session list and record. # For agents Source: https://docs.driver.dev/docs/start/for-agents For coding agents integrating Driver: the rules that keep sessions unblocked, the three calls, the mistakes. Written for an AI coding agent (Claude Code, Cursor, Codex) that's been asked to integrate Driver into a project. Humans welcome. The docs also serve `/llms.txt` (index) and `/llms-full.txt` (every page). ## The rules 1. **Patchright, not stock Playwright or Puppeteer.** Install `patchright` (npm or pip) and import `chromium` from it. Same API as Playwright, so existing code works unchanged. Stock Playwright issues CDP commands (`Runtime.enable`, console and binding hooks) that anti-bot systems detect. Patchright removes them. See [Why Patchright](/docs/frameworks/patchright#why-patchright). 2. **Never launch a browser.** Connect to the `cdpUrl` from the create call with `chromium.connectOverCDP(cdpUrl)`. No `chromium.launch()`, `launchPersistentContext()`, `puppeteer.launch()`, no `playwright install`. 3. **Reuse the browser's context and tab.** `browser.contexts()[0]` and `context.pages()[0]` (properties in Python: `browser.contexts[0]`, `context.pages[0]`). Create new ones only if none exist. Extra contexts look like automation. 4. **No broad CDP hooks.** `page.route`, `context.route`, `page.on('request')`, `page.exposeFunction`, `page.addInitScript`, `context.addInitScript`: only when the task can't be done otherwise, and then as narrow as possible. They change how the browser behaves and that's detectable. 5. **Don't patch fingerprints.** It's real Chrome on real hardware. Overriding `navigator`, the user agent, WebGL, canvas, timezone or locale makes it look fake. Set `country`, `timezone` and `language` on the create call instead. 6. **Always stop the session.** `try / finally`, with `DELETE /v1/browser/session?sessionId=…` in the `finally`. Sessions are metered by bandwidth and end on their own at `duration` (an hour by default); until then a forgotten session holds a concurrency slot and its `cdpUrl` stays live. `browser.close()` doesn't stop it. 7. **Keep the `sessionId`.** Log it with the run. Every session has a record under Telemetry → Sessions with node, network, timing and cost. If the person wants to watch, print `https://viewer.driver.dev?ws=` as soon as the session exists. See [Live view](/docs/sessions/live-view). 8. **Report what a person must fix; retry only what time fixes.** `402` (out of credits) and `403` (concurrency or plan limit): report, never loop. `503` (no capacity; `Retry-After: 2`, `"code": "browser_capacity_unavailable"`): back off with jitter, cap the attempts, then report. `504` (Chrome didn't start in time) and `500`: retry once. `429`: back off. Full table on [Errors](/docs/sessions/errors). 9. **Stay under the concurrency limit.** `GET /v1/account/billing` reports it as `plan.concurrent_browsers`; sessions beyond it get 403. 10. **Use a pool when start-up latency matters.** A [pool](/docs/sessions/pools) keeps browsers warm with fixed options. `POST /v1/browser/pools/{poolId}/acquire` with `{ "waitMs": 10000 }` answers in under a second with `{ leaseId, session }`; `session` is an ordinary session from there. Release with `POST /v1/browser/pools/{poolId}/release` `{ leaseId }` in the same `finally` where you'd stop a session. Don't create pools without asking: they hold warm browsers against an account-wide cap. 11. **When a site challenges or blocks, escalate in this order.** Remove every hook and tactic. Then `type: "hosted_stealth"`, then `"hosted_privacy"`, then `captchaSolver: true`. When a step needs a person, give them the live view URL. See [Browser types](/docs/options/browser-types) and [CAPTCHA solving](/docs/options/captcha-and-adblock). ## How to write the automation * **Minimal logic wins.** Go to the page, do the action, read the result. No retries, waits, listeners, request routing or "stealth" helpers until a specific failure on a specific site demands one, and then the smallest thing that fixes it. * **Fast first.** Before writing browser steps: is there a direct URL? Can one session do several pages? Can a persisted [profile](/docs/options/profiles) replace logging in every run? Then keep the steps short: no fixed sleeps, use the framework's auto-waiting, stop the session the moment you have the result. * **Test for real, every time.** Run against the live target through Driver and check the result, not the exit code: the data is right, the action happened, the session was stopped (`GET /v1/browser/session?sessionId=…` shows `status: "completed"`, `stoppedAt`, `bandwidthBytes`). A run that "worked" but read the wrong element is worse than one that failed loudly. * **Unattended jobs clean up after earlier runs.** A crash before `finally` leaves a session running until its `duration`. At start-up, list `status=active` and `status=starting` (`listSessions` / `list_sessions` in the reference client), stop the ones your job created (match on `note`), then start the new one. Exit non-zero when the result wasn't verified. * **Don't pollute the session.** Every CDP feature you switch on, every injected script, every overridden property is a signal. If you didn't need it, take it out. ## Handoff prompt Give this to a coding agent with the target and the outcome you want. It's enough to start; the rest of this page is for once it's writing code. ```text theme={"dark"} Integrate Driver (driver.dev), hosted real-Chrome browser infrastructure, into this project. - Docs: https://docs.driver.dev — agents start at https://docs.driver.dev/docs/start/for-agents Endpoints: https://api.driver.dev/scalar (OpenAPI at https://api.driver.dev/doc) - Auth: Authorization: Bearer $DRIVER_API_KEY (read from the environment; never commit it). JSON bodies with Content-Type: application/json. Ids go in the query string, not the path: GET /v1/browser/session?sessionId=, DELETE /v1/browser/session?sessionId=. - Most tasks need no options: POST https://api.driver.dev/v1/browser/session with {} gives a hosted US Chrome and returns { sessionId, status, cdpUrl }. Use "country" (two-letter code, for example "DE") for geo-specific sites; "profile" {name, persist:true} to keep logins (persistent cookies and storage carry over, session-only cookies do not, as in desktop Chrome); "duration" (60–3600 s, default 3600) to cap how long a forgotten session lives. - Drive the browser only with Patchright (npm install patchright / pip install patchright; no browser download): chromium.connectOverCDP(cdpUrl), then reuse browser.contexts()[0] and context.pages()[0]. Never launch a browser. Never use stock Playwright or Puppeteer. - No CDP or automation tactics that leak or get sites to block: no page.route, no init scripts, no exposed functions, no fingerprint patches. Minimal automation logic is always best: https://docs.driver.dev/docs/start/best-practices - If a site challenges or blocks: remove every hook and tactic first, then try type "hosted_stealth", then "hosted_privacy", then captchaSolver: true; when a step needs a person, hand them the live view. - Live view: https://viewer.driver.dev?ws= — print it as soon as the session exists so the user can watch; it works until the session ends. - If a browser pool exists for the job (GET /v1/browser/pools, or the dashboard's Browser pools page), take a browser from it instead of creating one: POST /v1/browser/pools//acquire with {"waitMs": 10000} returns { leaseId, session }; release it in the finally with POST /v1/browser/pools//release {"leaseId": ...}. Do not create pools unasked. - Check whether the outcome can be reached faster: direct URLs, one session for many pages, a persisted profile instead of logging in. Avoid slow browser logic and fixed sleeps. - Always stop the session in a finally: DELETE /v1/browser/session?sessionId=. browser.close() only disconnects; the session runs until DELETE or its duration. Keep the sessionId in logs. - 402 (no credits) and 403 (limit; plan.concurrent_browsers on GET /v1/account/billing): report. 503: back off, cap, report. 504 and 500: retry once. - Test the automation against the real target and verify the result before calling it done: GET /v1/browser/session?sessionId= must show status "completed". ``` ## The three calls Base URL `https://api.driver.dev`. Every call carries `Authorization: Bearer $DRIVER_API_KEY`. Keep the key in an environment variable named `DRIVER_API_KEY`. Node's global `fetch` has no timeout, which suits the create call; if you add one, allow three minutes. | Step | Call | Notes | | - | - | - | | Create | `POST /v1/browser/session` with a JSON body | All fields optional. Returns `{ sessionId, status, cdpUrl, servedBy, createdAt, stoppedAt, note }`. The call waits for Chrome (up to 20 s, up to 3 min on some machines), so `cdpUrl` is set and `status` is `active`. If you get `starting`, poll `GET` until `cdpUrl` appears. | | Use | `chromium.connectOverCDP(cdpUrl)` | From Patchright. Reuse the first context and page. | | Stop | `DELETE /v1/browser/session?sessionId=` | Returns `{ success: true }`. | Also useful: `GET /v1/browser/session?sessionId=` for status and bandwidth, `GET /v1/browser/sessions?status=active` to list, `PATCH /v1/browser/session?sessionId=` with `{ "note": "…" }` to label a run. Every create option is on [Create a session](/docs/sessions/create#options). With a [pool](/docs/sessions/pools), `POST /v1/browser/pools/{poolId}/acquire` replaces the create call and `POST /v1/browser/pools/{poolId}/release` the stop; pool ids go in the path. Dedicated IPs are reserved in the dashboard and named on a create call as `proxyUrl: "dedicated://"`, or `dedicated://any` for whichever of yours is free. ## Reference implementation ```typescript driver.ts theme={"dark"} import { chromium, type Browser, type Page } from "patchright"; const API = "https://api.driver.dev"; export interface CreateOptions { type?: "hosted" | "hosted_stealth" | "hosted_privacy"; country?: string; // "US", "GB", "DE", … (see Countries) timezone?: string; // IANA name from the country's list language?: string; // BCP-47 tag from the country's list duration?: number; // seconds, 60–3600, default 3600 url?: string; // opened right after launch profile?: { name: string; persist?: boolean }; proxyUrl?: string; // socks5://user:pass@host:port windowSize?: string; // "1920x1040" displaySize?: string; // "1920x1080" captchaSolver?: boolean; adblock?: boolean; extensionIds?: string[]; nodeId?: string; browserCheck?: boolean; note?: string; // up to 256 characters } export interface Session { sessionId: string; status: "starting" | "active" | "completed" | "error"; cdpUrl: string | null; servedBy: string; createdAt: string; stoppedAt: string | null; note: string | null; bandwidthBytes?: number | null; // on GET: null while running, the metered total afterwards } /** Every non-2xx answer: `status` is the HTTP status, `code` the machine-readable code when there is one. */ export class DriverError extends Error { constructor(public status: number, message: string, public code?: string, public retryAfter?: number) { super(`Driver ${status}: ${message}`); } } const headers = () => ({ Authorization: `Bearer ${process.env.DRIVER_API_KEY}`, "Content-Type": "application/json", }); async function call(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${API}${path}`, { method, headers: headers(), body: body ? JSON.stringify(body) : undefined }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { error?: string; code?: string }; const retryAfter = Number(res.headers.get("retry-after")) || undefined; throw new DriverError(res.status, err.error ?? res.statusText, err.code, retryAfter); } return res.json() as Promise; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** * The error policy from the Errors page: 402 and 403 are reported at once; 503 backs off from Retry-After * with jitter and a cap; 504 and 500 are retried once; 429 backs off a few times. */ export async function createSession(opts: CreateOptions = {}): Promise { let attempt = 0; for (;;) { try { return await call("POST", "/v1/browser/session", opts); } catch (e) { if (!(e instanceof DriverError)) throw e; attempt++; if (e.status === 503 && attempt <= 5) { await sleep((e.retryAfter ?? 2) * 1000 * attempt + Math.random() * 1000); continue; } if ((e.status === 504 || e.status === 500) && attempt <= 1) continue; if (e.status === 429 && attempt <= 3) { await sleep(2000 * attempt + Math.random() * 1000); continue; } throw e; // 400, 401, 402, 403, or out of attempts: a person has to act } } } export const getSession = (id: string) => call("GET", `/v1/browser/session?sessionId=${encodeURIComponent(id)}`); export const listSessions = (status?: Session["status"], page = 1, pageSize = 20) => call<{ sessions: Array; total: number; totalPages: number; page: number; pageSize: number }>( "GET", `/v1/browser/sessions?page=${page}&pageSize=${pageSize}${status ? `&status=${status}` : ""}`); /** * Idempotent. In a finally, a failed stop must not replace the error you are already handling, so this * logs instead of throwing; the session ends on its own at `duration` anyway. */ export async function stopSession(id: string): Promise { try { const r = await call<{ success: boolean }>("DELETE", `/v1/browser/session?sessionId=${encodeURIComponent(id)}`); if (!r.success) console.error(`Driver: stop of ${id} did not succeed`); } catch (e) { console.error(`Driver: stop of ${id} failed: ${(e as Error).message}`); } } /** * Browser pools (see Browser pools in the docs): a warm browser in under a second. The returned session is an * ordinary session; release it (or stop it) in a finally. A 409 means nothing became ready within waitMs. */ export interface PoolLease { poolId: string; leaseId: string; acquiredAt: string; expiresAt: string; // the session is stopped at this time unless released earlier session: Session & { cdpUrl: string }; } export const acquireFromPool = (poolId: string, waitMs = 10_000) => call("POST", `/v1/browser/pools/${encodeURIComponent(poolId)}/acquire`, { waitMs }); export async function releaseLease(poolId: string, leaseId: string): Promise { try { await call("POST", `/v1/browser/pools/${encodeURIComponent(poolId)}/release`, { leaseId }); } catch (e) { console.error(`Driver: release of ${leaseId} failed: ${(e as Error).message}`); } } /** Create normally returns an active session; this covers the rare `starting` answer. */ export async function waitForActive(s: Session, timeoutMs = 60_000): Promise { const deadline = Date.now() + timeoutMs; while (!(s.status === "active" && s.cdpUrl)) { if (s.status === "completed" || s.status === "error") throw new DriverError(500, `session ended: ${s.status}`); if (Date.now() > deadline) throw new DriverError(504, "timed out waiting for the browser"); await sleep(1000); s = await getSession(s.sessionId); } return s as Session & { cdpUrl: string }; } /** * Runs `fn` against a fresh Driver session, always stops it afterwards, and verifies the stop the way the * checklist asks (`status: "completed"`). `fn` receives the session too, for logging the id and building the * live view URL: `https://viewer.driver.dev?ws=${session.cdpUrl}`. */ export async function withBrowser( opts: CreateOptions, fn: (page: Page, browser: Browser, session: Session & { cdpUrl: string }) => Promise, ): Promise { const session = await createSession(opts); try { const active = await waitForActive(session); const browser = await chromium.connectOverCDP(active.cdpUrl); try { const context = browser.contexts()[0] ?? (await browser.newContext()); const page = context.pages()[0] ?? (await context.newPage()); return await fn(page, browser, active); } finally { await browser.close(); // your connection only } } finally { await stopSession(session.sessionId); const after = await getSession(session.sessionId).catch(() => null); if (after?.status !== "completed") console.error(`Driver: session ${session.sessionId} is ${after?.status ?? "unknown"} after stop`); else console.log(`Driver: session ${session.sessionId} completed, ${after.bandwidthBytes ?? 0} bytes`); } } ``` ```python driver.py theme={"dark"} import os import random import time from contextlib import contextmanager import requests from patchright.sync_api import sync_playwright API = "https://api.driver.dev" class DriverError(RuntimeError): """Every non-2xx answer: .status is the HTTP status, .code the machine-readable code when there is one.""" def __init__(self, status, message, code=None, retry_after=None): super().__init__(f"Driver {status}: {message}") self.status, self.code, self.retry_after = status, code, retry_after def _headers(): return {"Authorization": f"Bearer {os.environ['DRIVER_API_KEY']}"} def _call(method, path, **kwargs): r = requests.request(method, f"{API}{path}", headers=_headers(), timeout=200, **kwargs) if not r.ok: body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} raise DriverError(r.status_code, body.get("error", r.reason), body.get("code"), r.headers.get("Retry-After")) return r.json() def create_session(**opts): """opts: type, country, timezone, language, duration, url, profile={"name": …, "persist": True}, proxyUrl, windowSize, displaySize, captchaSolver, adblock, extensionIds, nodeId, browserCheck, note. Applies the error policy from the Errors page: 402/403 raise at once; 503 backs off with a cap; 504/500 retry once; 429 backs off a few times.""" attempt = 0 while True: try: return _call("POST", "/v1/browser/session", json=opts) except DriverError as e: attempt += 1 if e.status == 503 and attempt <= 5: time.sleep(float(e.retry_after or 2) * attempt + random.random()) elif e.status in (504, 500) and attempt <= 1: pass elif e.status == 429 and attempt <= 3: time.sleep(2 * attempt + random.random()) else: raise # 400, 401, 402, 403, or out of attempts: a person has to act def get_session(session_id): return _call("GET", "/v1/browser/session", params={"sessionId": session_id}) def list_sessions(status=None, page=1, page_size=20): params = {"page": page, "pageSize": page_size} if status: params["status"] = status return _call("GET", "/v1/browser/sessions", params=params) def stop_session(session_id): """Idempotent. In a finally, a failed stop must not replace the error you are handling, so this logs.""" try: _call("DELETE", "/v1/browser/session", params={"sessionId": session_id}) except DriverError as e: print(f"Driver: stop of {session_id} failed: {e}") def acquire_from_pool(pool_id, wait_ms=10000): """Browser pools: a warm browser in under a second. Returns {poolId, leaseId, acquiredAt, expiresAt, session}; the session is an ordinary session. A 409 means nothing became ready within wait_ms.""" return _call("POST", f"/v1/browser/pools/{pool_id}/acquire", json={"waitMs": wait_ms}) def release_lease(pool_id, lease_id): """Ends the leased session. Idempotent; logs instead of raising, for use in a finally.""" try: _call("POST", f"/v1/browser/pools/{pool_id}/release", json={"leaseId": lease_id}) except DriverError as e: print(f"Driver: release of {lease_id} failed: {e}") def wait_for_active(session, timeout=60): """Create normally returns an active session; this covers the rare 'starting' answer.""" deadline = time.time() + timeout while not (session["status"] == "active" and session.get("cdpUrl")): if session["status"] in ("completed", "error"): raise DriverError(500, f"session ended: {session['status']}") if time.time() > deadline: raise DriverError(504, "timed out waiting for the browser") time.sleep(1) session = get_session(session["sessionId"]) return session @contextmanager def browser_page(**opts): """with browser_page(country="US") as page: ... — always stops the session and verifies the stop. The session dict is on page.driver_session (id, cdpUrl for the live view: https://viewer.driver.dev?ws=).""" session = create_session(**opts) try: session = wait_for_active(session) with sync_playwright() as p: browser = p.chromium.connect_over_cdp(session["cdpUrl"]) context = browser.contexts[0] if browser.contexts else browser.new_context() page = context.pages[0] if context.pages else context.new_page() page.driver_session = session try: yield page finally: browser.close() # your connection only finally: stop_session(session["sessionId"]) after = get_session(session["sessionId"]) if after.get("status") != "completed": print(f"Driver: session {session['sessionId']} is {after.get('status')} after stop") ``` ## Checklist before you finish * [ ] `patchright` is the dependency, not `playwright` or `puppeteer`. * [ ] No `launch()` anywhere; the only browser entry point is `connectOverCDP(cdpUrl)`. * [ ] The first context and page are reused. * [ ] The session is stopped in a `finally`. * [ ] `DRIVER_API_KEY` comes from the environment and isn't committed. * [ ] 402 and 403 are reported; 503 is retried with a cap and a growing delay. * [ ] The result was verified through `GET /v1/browser/session` (status `completed`), not assumed. * [ ] A browser from a pool is released (or stopped) in the same `finally`; no pool was created without asking. ## Machine-readable resources * `https://docs.driver.dev/llms.txt` lists every page; `https://docs.driver.dev/llms-full.txt` is all of them in one file. Any page is Markdown with `.md` appended to its URL. * `https://api.driver.dev/doc` is the live OpenAPI document; `https://api.driver.dev/scalar` renders it. # Introduction Source: https://docs.driver.dev/docs/start/index Driver runs real Chrome on real hardware and gives you a CDP URL. Create a session, connect, do the work, stop it. Driver is hosted browser infrastructure. A session is one real Chrome window on one real machine, on an ISP connection in the country you pick, reachable over the Chrome DevTools Protocol a few seconds after you ask for it. You write the automation; we run the browser. Create a session, connect with Patchright, stop it. Five minutes. Coding agent? Start here: the rules, the endpoints, the mistakes. How to stay unblocked: framework, CDP hygiene, cleanup. Every endpoint, generated from the live OpenAPI document. ## How it works `POST /v1/browser/session` with the options you need. You get a `sessionId` and a `cdpUrl`. Point your framework at `cdpUrl`. Use [Patchright](/docs/frameworks/patchright); stock Playwright and Puppeteer leave fingerprints that get sessions blocked. Navigate, fill, click, extract. It's a normal Chrome: no headless quirks, no emulated hardware. `DELETE /v1/browser/session?sessionId=…`. You pay for bandwidth, so stop what you're done with. ## What you get | | | | - | - | | **Real Chrome** | Consumer Chrome on real hardware, not a headless container. | | **ISP networks** | Sessions egress from ISP connections in the country you choose. Bring your own SOCKS5 proxy if you need to. | | **Profiles** | Save cookies and local state under a name and reuse them, so logins survive between sessions. | | **Browser pools** | Keep browsers warm with fixed options and take one in under a second. | | **CAPTCHA solving** | One flag loads our solver into the session. | | **Live view** | Watch and drive any running session from the dashboard or a viewer URL. | ## Base URL and authentication Everything goes to `https://api.driver.dev` with your workspace API key as a bearer token. Keys are under **Settings → API keys** in the [dashboard](https://app.driver.dev). ```bash theme={"dark"} Authorization: Bearer $DRIVER_API_KEY ``` ## Next * [Quickstart](/docs/start/quickstart): create and stop a session end to end. * [Create a session](/docs/sessions/create#options): every field you can pass. * [Browser pools](/docs/sessions/pools): warm browsers, acquired in under a second. * [Frameworks](/docs/frameworks/patchright): connection code for Patchright, Playwright, Puppeteer, Browser Use, Stagehand and Crawl4AI. # Quickstart Source: https://docs.driver.dev/docs/start/quickstart Create a session, connect with Patchright, read a page title, stop the session. Sign in to the [dashboard](https://app.driver.dev), open **Settings → API keys**, create a key. Put it in your environment as `DRIVER_API_KEY`. Don't commit it. ```bash theme={"dark"} export DRIVER_API_KEY="dr_…" ``` Patchright is Playwright with the CDP fingerprints removed. Use it instead of stock Playwright or sites that watch for automation will block the session. Details in [Why Patchright](/docs/frameworks/patchright#why-patchright). ```bash npm theme={"dark"} npm install patchright ``` ```bash pip theme={"dark"} pip install patchright requests ``` No `playwright install`, no browser download. Driver provides the browser. The TypeScript examples use top-level `await` and the global `fetch`: Node 18+, in an ES module (`"type": "module"` in `package.json`, or a `.mts` file), run with `npx tsx file.ts`. Python examples need 3.8+. ```typescript TypeScript theme={"dark"} import { chromium } from "patchright"; const API = "https://api.driver.dev"; const headers = { Authorization: `Bearer ${process.env.DRIVER_API_KEY}`, "Content-Type": "application/json", }; // 1. Create a session. Every field is optional; this one asks for a US browser for ten minutes. const res = await fetch(`${API}/v1/browser/session`, { method: "POST", headers, body: JSON.stringify({ country: "US", duration: 600 }), }); if (!res.ok) throw new Error(`Driver ${res.status}: ${(await res.json().catch(() => ({}))).error ?? res.statusText}`); const session = await res.json(); try { // 2. Connect over CDP and reuse the browser's own context and tab. const browser = await chromium.connectOverCDP(session.cdpUrl); try { const context = browser.contexts()[0] ?? (await browser.newContext()); const page = context.pages()[0] ?? (await context.newPage()); // 3. Do the work. await page.goto("https://example.com"); console.log(await page.title()); } finally { await browser.close(); // ends your connection; the session is still running } } finally { // 4. Stop the session. This is what ends billing. await fetch(`${API}/v1/browser/session?sessionId=${session.sessionId}`, { method: "DELETE", headers, }); } ``` ```python Python theme={"dark"} import os import requests from patchright.sync_api import sync_playwright API = "https://api.driver.dev" headers = {"Authorization": f"Bearer {os.environ['DRIVER_API_KEY']}"} # 1. Create a session. Every field is optional; this one asks for a US browser for ten minutes. session = requests.post(f"{API}/v1/browser/session", headers=headers, json={"country": "US", "duration": 600}) session.raise_for_status() session = session.json() try: # 2. Connect over CDP and reuse the browser's own context and tab. with sync_playwright() as p: browser = p.chromium.connect_over_cdp(session["cdpUrl"]) context = browser.contexts[0] if browser.contexts else browser.new_context() page = context.pages[0] if context.pages else context.new_page() # 3. Do the work. page.goto("https://example.com") print(page.title()) browser.close() finally: # 4. Stop the session. This is what ends billing. requests.delete( f"{API}/v1/browser/session", headers=headers, params={"sessionId": session["sessionId"]}, ) ``` ```bash cURL theme={"dark"} # Create curl -s -X POST https://api.driver.dev/v1/browser/session \ -H "Authorization: Bearer $DRIVER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"country":"US","duration":600}' # → {"sessionId":"…","status":"active","cdpUrl":"wss://…", …} # Stop curl -s -X DELETE "https://api.driver.dev/v1/browser/session?sessionId=$SESSION_ID" \ -H "Authorization: Bearer $DRIVER_API_KEY" ``` Every session has a page under **Telemetry → Sessions** in the dashboard: live view while it runs, then the record (node, country, bandwidth, cost). See [Live view](/docs/sessions/live-view). From the API, `GET /v1/browser/session?sessionId=…` shows `status` `completed`, `stoppedAt`, and `bandwidthBytes` once it's done. ## What happened The create call returned once Chrome was reachable over CDP, a few seconds in. It waits up to 20 s (three minutes on some machines), then answers `504`. `cdpUrl` is a WebSocket endpoint to that one browser. `connectOverCDP` attaches to it, which is why there's no `chromium.launch()` and nothing to download. The browser came up with one context and one tab. Reusing them looks like a person who opened Chrome and started browsing. Creating extra contexts is one of the things sites notice. Stopping the session releases the machine and closes the bill. Sessions also stop on their own at `duration`, an hour by default. ## Next * [For agents](/docs/start/for-agents) if a coding agent is doing the integration. * [Create a session](/docs/sessions/create#options) for every field: browser type, location, proxies, profiles, window size. * [Best practices](/docs/start/best-practices) before you scale up. * [Browser pools](/docs/sessions/pools) when a few seconds of start-up per browser is too slow.