What Is a Browserless API and When Should You Use One?
A browserless api is a hosted headless browser you call over HTTP instead of installing Chrome on your own machine. You POST a URL and a few options to an endpoint, the vendor runs the browser, and you get back an image, a PDF or rendered HTML. The REST endpoints need no browser library at all. This walkthrough builds a working client in Python and Node, then adds retries, a backend proxy, batch parallelism and a realistic cost estimate.
📌 TL;DR Executive Summary
- Core Takeaway: Every REST call is one URL, one task, one browser, then the session closes — no cookies survive the response.
- Key Risk/Challenge: The token rides in the query string, so calling the API from frontend JavaScript exposes it and fails on CORS. Route calls through your own backend.
- Recommended Solution: Use REST for screenshots, PDFs and rendered HTML, switch to a CDP connection only when a flow needs state, and retry 429s with exponential backoff.
There are two ways in. REST endpoints are stateless one-shot jobs; a WebSocket/CDP connection hands you a browser object that stays open while your script clicks, types and waits. Most tutorials blur the two, which is why a login vanishes between calls.
REST endpoints vs a CDP connection: pick by state, not by taste
| Question | REST endpoint | CDP / WebSocket connection |
|---|---|---|
| Libraries needed | Any HTTP client (requests, fetch, cURL) | puppeteer-core or Playwright |
| State between calls | None — cookies and storage discarded | Kept for the life of the connection |
| Best for | Screenshot, PDF, rendered HTML, structured extract | Multi-step flows, logins, clicks, waits |
| Unit cost | One unit per call (up to 30 seconds) | One unit per 30-second block; a reconnect starts a fresh connection and a fresh unit |
If your job is “give me a PNG of this page”, stay on REST. If it is “log in, open the billing tab, download the invoice”, you need the connection. There is a longer comparison of hosted browser services in REST versus CDP sessions if you are still deciding which shape fits your workload.
Prerequisites: What You Need Before the First Call
- Python 3.9+ with
requests(pip install requests), or Node 18+, which ships a globalfetch. - For the connection examples only:
playwrightorpuppeteer-core. The REST steps need neither. - For the backend route:
express(npm i express), and an ES module setup, since the snippets use top-level await. - A Browserless account and API token. An account can hold up to 20 named keys, each revocable on its own, all drawing from the same unit balance and concurrency limit.
- The right host. REST calls go to
https://production-sfo.browserless.ioplus a path segment; connections usewss://production-sfo.browserless.io?token=.... Endpoints also run in London and Amsterdam. - URLs you own or are authorised to access. Automated access is still bound by the target site’s terms.
How Send.win Helps With Browserless Api
Send.win is a browser profile manager for professionals and teams: every account gets its own isolated browser profile.
- Isolated profiles – separate cookies, storage and sessions per profile
- Fingerprint protection – each profile keeps its own consistent browser fingerprint, so profiles never share one browser identity
- Desktop app + cloud browser – native app for Windows, macOS and Linux, or run profiles in the cloud with no install
- Residential proxies included – on Pro and Team, with timezone and locale matched to the proxy
- Team sharing – share a signed-in profile with a teammate without sharing the password
Try the free cloud demo — no install — or download the desktop app. Paid plans start at $19/month, and your first card purchase is covered by a 7-day money-back guarantee (see pricing).
If you are wiring this into an existing pipeline, the sequencing in integrating cloud browser APIs saves you rebuilding the same glue code twice.
Step 1 — Keep the Token Out of Your Code
Every REST request authenticates with a ?token= query parameter, so the token ends up in URLs. Keep it in an environment variable, never in a committed file, and give each service its own named key so you can revoke one without breaking the rest.
import os
import requests
TOKEN = os.environ["BROWSERLESS_TOKEN"]
BASE = os.environ.get("BROWSERLESS_BASE", "https://production-sfo.browserless.io")
def rest(path: str, payload: dict, timeout: int = 90) -> requests.Response:
"""POST to a Browserless REST endpoint and raise on HTTP errors."""
resp = requests.post(
f"{BASE}{path}",
params={"token": TOKEN},
json=payload,
timeout=timeout,
)
resp.raise_for_status()
return resp
That helper is the whole authentication story: token as a query parameter, JSON body, generous timeout. The timeout matters more than it looks — a cold browser start plus a heavy page can take 20 seconds or more, and leaving it unset will hang a worker forever.
Step 2 — Take Your First Screenshot
The /screenshot endpoint is a POST that returns a binary image body, not JSON. It takes a JSON body with either url or html, plus an options object where fullPage and type (png, jpeg or webp) do most of the work.
from pathlib import Path
payload = {
"url": "https://example.com",
"options": {
"fullPage": True,
"type": "png",
},
}
resp = rest("/screenshot", payload)
Path("example.png").write_bytes(resp.content)
print("saved", len(resp.content), "bytes")
Write resp.content, not resp.text. Decoding a PNG as text corrupts the file, and the error you get points at the image rather than at your code.
Render HTML you already have
Send html instead of url — never both in the same request — and inject CSS or script before the capture with addStyleTag and addScriptTag. This turns a generated invoice template into a clean PNG without hosting a preview page.
payload = {
"html": "<h1>Invoice 1042</h1><p>Region: EU</p>",
"options": {"type": "png", "fullPage": True},
"addStyleTag": [
{"content": "body{font-family:system-ui;padding:40px}"}
],
}
resp = rest("/screenshot", payload)
Path("invoice.png").write_bytes(resp.content)
The same call in Node
// Save as shot.mjs — top-level await needs an ES module.
import { writeFile } from "node:fs/promises";
const TOKEN = process.env.BROWSERLESS_TOKEN;
const BASE = process.env.BROWSERLESS_BASE ?? "https://production-sfo.browserless.io";
const res = await fetch(`${BASE}/screenshot?token=${TOKEN}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://example.com",
options: { fullPage: true, type: "png" },
}),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}
await writeFile("example.png", Buffer.from(await res.arrayBuffer()));
/screenshot usually means you sent both url and html, or a field the endpoint does not accept. Retrying a malformed body five times just costs you five units.
Step 3 — Render HTML and Extract Structured Data
Three endpoints cover most read-only jobs. /content returns page HTML after JavaScript has run, which is what you want when a table is rendered client-side. /scrape takes a list of selectors and returns only those fields. /smart-scrape returns a whole-page representation when you have no selector list to start from. The same family includes /pdf, /function, /download, /performance and a beta /crawl that follows links from one starting URL.
import json
scrape_payload = {
"url": "https://example.com/pricing",
"elements": [
{"selector": "h1"},
{"selector": "table tbody tr"},
],
}
resp = rest("/scrape", scrape_payload)
print(json.dumps(resp.json(), indent=2))
pdf_payload = {
"url": "https://example.com/report",
"options": {"printBackground": True},
}
Path("report.pdf").write_bytes(rest("/pdf", pdf_payload).content)
One request handles one URL — there is no batch array — so fifty pages means fifty requests, run in parallel up to your plan’s concurrency limit and queued beyond it. Since August 2026 there is also an /agent/run endpoint in beta on Cloud plans that accepts a plain-English task and returns a run ID, with an optional webhook for results; it is a different shape of tool, not a replacement for these endpoints.
Step 4 — Keep State Across Calls
REST calls are stateless by design: each request launches a browser, runs one task and closes the session, so cookies, localStorage and logins are gone the moment you have the response. If your flow needs more than one step, open a connection and do everything inside it.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(
f"wss://production-sfo.browserless.io?token={TOKEN}"
)
context = browser.contexts[0]
page = context.pages[0] if context.pages else context.new_page()
page.goto("https://example.com/login")
page.fill("#email", "you@example.com")
page.fill("#password", "…")
page.click("button[type=submit]")
page.wait_for_url("**/dashboard")
print(page.title())
browser.close()
// Save as connect.mjs — ESM for top-level await.
import puppeteer from "puppeteer-core";
const browser = await puppeteer.connect({
browserWSEndpoint: `wss://production-sfo.browserless.io?token=${process.env.BROWSERLESS_TOKEN}`,
});
const page = await browser.newPage();
await page.goto("https://example.com/dashboard", { waitUntil: "networkidle2" });
console.log(await page.title());
await browser.close();
Two conversion rules keep existing scripts working: replace chromium.launch() with connect_over_cdp() in Playwright, and swap puppeteer.launch() for puppeteer.connect({ browserWSEndpoint }). Everything after that line is unchanged.
Where a login has to outlive a single script run, the vendor’s Session API is the documented route: create a session, keep cookies and localStorage across disconnects, reconnect to the same session. The concept maps onto browser profile management API patterns, where the profile — not the script — is the unit carrying identity.
Step 5 — Call It From a Backend Route, Not the Browser
Calling the REST endpoints from frontend JavaScript fails twice: the browser blocks the cross-origin request, and any workaround you find puts your token in the page source where every visitor can read it. Put a server route in front of the API and let your frontend talk to that.
// npm i express — run with node server.mjs
import express from "express";
const app = express();
app.use(express.json({ limit: "32kb" }));
const ALLOWED = new Set(["example.com", "docs.example.com"]);
app.post("/api/screenshot", async (req, res) => {
let target;
try {
target = new URL(req.body?.url ?? "");
} catch {
return res.status(400).json({ error: "url must be absolute" });
}
if (!["http:", "https:"].includes(target.protocol) || !ALLOWED.has(target.hostname)) {
return res.status(400).json({ error: "host not allowed" });
}
const upstream = await fetch(
`${process.env.BROWSERLESS_BASE}/screenshot?token=${process.env.BROWSERLESS_TOKEN}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: target.toString(),
options: { fullPage: true, type: "png" },
}),
}
);
if (!upstream.ok) {
return res.status(upstream.status).json({ error: await upstream.text() });
}
res.set("Content-Type", "image/png").send(Buffer.from(await upstream.arrayBuffer()));
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
The host allowlist is not decoration. A screenshot endpoint that accepts any URL will happily render an internal address on your network if someone posts one, and rate limiting on your route is the only thing between a public form and your unit balance. Authenticate the route too — it is your token being spent.
Step 6 — Retry 429s Without Hammering the Service
429 means you hit the plan’s concurrency ceiling; 502, 503 and 504 mean a transient blip. Both are worth retrying, a 400 or 401 is not. Use exponential backoff with jitter, cap the attempts, and remember every retry is another call against your unit balance.
import random
import time
RETRYABLE = {429, 502, 503, 504}
def rest_with_retry(path: str, payload: dict, attempts: int = 5):
for attempt in range(attempts):
resp = requests.post(
f"{BASE}{path}",
params={"token": TOKEN},
json=payload,
timeout=90,
)
if resp.status_code in RETRYABLE and attempt < attempts - 1:
delay = min(2 ** attempt + random.uniform(0, 0.75), 30)
print(f"{resp.status_code} on {path}, retrying in {delay:.1f}s")
time.sleep(delay)
continue
resp.raise_for_status()
return resp
raise RuntimeError(f"gave up on {path} after {attempts} attempts")
Jitter matters with parallel workers: without it, ten workers that fail together retry together and hit the same ceiling in lockstep. If you see 429s on the first attempt rather than after a burst, lower your worker count — the cap is concurrency, not rate.
What a Batch Costs: Units, Concurrency and a Worked Estimate
Billing is in units, and one unit covers up to 30 seconds of browser time. That sentence explains most invoice surprises. Proxy traffic bills the same way: residential traffic costs 6 units per MB and datacenter 2 units per MB, so 100 MB through a residential proxy is 600 units — more than 500 screenshots.
| Plan | Price | Units / month | Concurrent browsers | Overage per unit |
|---|---|---|---|---|
| Free | $0 | 1,000 | Lowest cap of any tier | None — stops at the limit |
| Prototyping | $25/mo | 20,000 | 3 | $0.0020 |
| Starter | $140/mo | 180,000 | 20 | $0.0017 |
| Scale | $350/mo | 500,000 | 50 | $0.0015 |
Worked example: 500 product pages, one screenshot each, average call around six seconds. Each REST call is one connection, so the batch costs roughly 500 units — the 30-second rounding only bites on pages that run longer than that. The free plan’s 1,000 units covers that batch twice a month; Prototyping’s 20,000 units covers it forty times over. Now run the same 500 pages inside one long CDP session at four minutes: eight units, about sixty times less. That is the argument for looping work inside a connection whenever the flow allows it.
Prices and caps change, and third-party trackers lag the official page, so confirm the tier and concurrency limit before you size a job around them.
Self-Hosting With Docker (and the Licence Trap)
If data cannot leave your network, the same image runs locally in one command and listens on port 3000:
docker run --rm -p 3000:3000 ghcr.io/browserless/chromium
Point a client at ws://localhost:3000 and your Puppeteer or Playwright code works unchanged. The catch is licensing, not technology: the image ships under SSPL-1.0 or a commercial licence, and closed-source commercial or CI use needs the commercial licence. The repository’s “free” wording has misled teams into assuming a permissive open-source licence, so read the licence text before it lands in a production pipeline.
Self-hosting also moves operational cost onto you. Each headless Chrome instance can use 500 MB of RAM or more — vendor-reported, but the order of magnitude holds — so fifty concurrent browsers is a fleet of servers, not a side project.
When a Screenshot Comes Back Blank
A blank or half-rendered capture is usually a timing problem, not a browser problem. Lazy-loaded images have not fetched, a consent overlay covers the content, a web font swaps after the capture, or the page renders below the fold because fullPage is false. Fixes in order of effort: set fullPage true, wait on a specific selector inside a CDP connection instead of a fixed sleep, apply addStyleTag to hide overlays, and check the viewport width for mobile-only layouts.
If the page loads for you and not for the service, check whether it needs a login, geoblocks your exit region, or blocks automated clients in its terms. That last case is not a configuration puzzle. Read the terms, use the site’s official API or a permitted data feed where one exists, and ask the owner for access. No setting changes what a site’s terms allow.
Where a Browserless API Stops and a Profile Manager Starts
A hosted browser is the right tool for producing artefacts at scale: thumbnails, PDFs, rendered HTML, scheduled exports. It is the wrong tool for work that belongs to a signed-in human — opening a client’s ad account, checking a storefront as that client, or verifying a support flow as a specific user. Those tasks need a real browser profile with its own cookies, storage, proxy and consistent fingerprint, opened by a person.
That is where Send.win sits, and the boundary is worth drawing plainly: it is a browser profile manager that a person opens and works in, not a hosted browser you call from code. Sendwin Browser is a desktop app for Windows, macOS and Linux, with a cloud browser that runs profiles on an EU server in Helsinki. Each profile keeps its own cookies, storage and fingerprint settings, and timezone, locale and geolocation follow the proxy’s exit IP. Nothing in this tutorial touches it — the work happens in a window that a person drives. If you are weighing scripted extraction against profile-based work, choosing a scraping approach walks through the trade-offs.
Common Errors and What They Actually Mean
| Symptom | Cause | Fix |
|---|---|---|
| CORS error in the console | Calling the API from frontend JavaScript | Proxy through your own backend route |
| 401 or 403 | Missing, wrong or revoked token, or the wrong host path | Check the ?token= value, the key’s status and the endpoint path |
400 on /screenshot |
Both url and html sent, or a bad field |
Send one source, keep the options object flat |
| Corrupt image file | Wrote resp.text instead of bytes |
Use resp.content / arrayBuffer() |
| 429 | Concurrency ceiling for the plan | Reduce workers, then back off with jitter |
| Login lost between calls | REST is stateless by design | Switch to a CDP connection or a persisted session |
| Client timeout at 30–60 s | Heavy page, cold start, or a long wait condition | Raise the client timeout and trim the waits |
The Full Script
Put the pieces together and you get a batch runner: it reads URLs from a file, screenshots each through the REST endpoint, retries transient failures with backoff, writes PNGs to a folder and reports what the run implied in units. Set MAX_WORKERS below your plan’s concurrency cap.
#!/usr/bin/env python3
"""Batch screenshots through the Browserless REST API.
Usage:
export BROWSERLESS_TOKEN="your-token"
python batch_shots.py urls.txt out/
"""
import os
import random
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from urllib.parse import urlparse
import requests
TOKEN = os.environ["BROWSERLESS_TOKEN"]
BASE = os.environ.get("BROWSERLESS_BASE", "https://production-sfo.browserless.io")
MAX_WORKERS = int(os.environ.get("MAX_WORKERS", "3"))
RETRYABLE = {429, 502, 503, 504}
def slug(url: str) -> str:
parsed = urlparse(url)
name = (parsed.netloc + parsed.path).strip("/").replace("/", "-")
return (name or "page")[:80]
def screenshot(url: str, out_dir: Path, attempts: int = 5) -> Path:
payload = {"url": url, "options": {"fullPage": True, "type": "png"}}
for attempt in range(attempts):
resp = requests.post(
f"{BASE}/screenshot",
params={"token": TOKEN},
json=payload,
timeout=90,
)
if resp.status_code in RETRYABLE and attempt < attempts - 1:
delay = min(2 ** attempt + random.uniform(0, 0.75), 30)
print(f"{resp.status_code} for {url}, retrying in {delay:.1f}s")
time.sleep(delay)
continue
resp.raise_for_status()
target = out_dir / f"{slug(url)}.png"
target.write_bytes(resp.content)
return target
raise RuntimeError(f"gave up on {url} after {attempts} attempts")
def main() -> int:
if len(sys.argv) != 3:
print("usage: batch_shots.py urls.txt out/", file=sys.stderr)
return 2
urls_file, out_dir = Path(sys.argv[1]), Path(sys.argv[2])
out_dir.mkdir(parents=True, exist_ok=True)
urls = [line.strip() for line in urls_file.read_text().splitlines() if line.strip()]
failures = 0
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = {pool.submit(screenshot, url, out_dir): url for url in urls}
for future in as_completed(futures):
url = futures[future]
try:
print(f"ok {url} -> {future.result()}")
except Exception as exc:
failures += 1
print(f"fail {url}: {exc}", file=sys.stderr)
saved = len(urls) - failures
print(f"{saved}/{len(urls)} saved, roughly {len(urls)} units before retries")
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(main())
Run it once with three workers on a five-URL file before you point it at five hundred. That first run gives you average latency, whether the site renders correctly through the service, and how many retries you actually see — three numbers that decide worker count and plan tier better than any estimate.
🏆 Send.win Verdict
A hosted rendering service answers one question well: how do I produce a screenshot, PDF or rendered page without running Chrome myself. It is a poor fit for the other half of agency work — the client accounts a person has to sign into, keep signed in, and open alongside other accounts without mixing sessions. Send.win covers that half with isolated desktop and cloud browser profiles, each with its own cookies, storage, fingerprint settings and proxy, and a profile shared with a paid teammate opens already signed in. You drive those profiles by hand in a window; the batch rendering in this tutorial stays on the hosted service.
See Send.win plans — Pro from $19/mo with 150 profiles and 5 GB of proxy bandwidth, or start with the free cloud demo and 10 minutes a day.
Frequently Asked Questions
What is a browserless API and how does it differ from Puppeteer?
Puppeteer is a library that drives a browser on your machine. A browserless API is a hosted browser you reach over HTTP or a WebSocket, so the vendor handles Chrome versions, memory and concurrency. You can still use Puppeteer or Playwright with it — you connect to a remote endpoint instead of launching locally.
Do I need Playwright or Puppeteer to use the REST endpoints?
No. The REST endpoints take a POST with a JSON body and return an image, a PDF or HTML, so any HTTP client works, including requests, fetch and cURL. You need a browser library only when the job requires state across several steps.
Why do I get a CORS error calling it from my frontend?
A frontend call is blocked as a cross-origin request, and any workaround leaves your token in page source where visitors can read it. Call the API from a backend route and let the frontend talk to that route, with authentication and a host allowlist on your side.
Can I send many URLs in one request?
No. Each REST call handles exactly one URL, so you send one request per page and run them in parallel up to your plan’s concurrency limit. Past that limit you get 429s, which is the signal to queue work rather than to retry harder.
How do I keep cookies and a login across calls?
Not with stateless REST calls, because the session closes after each response. Open a CDP connection with connect_over_cdp() or puppeteer.connect() and run every step inside it, or persist the session through the Session API and reconnect to the same session.
What is the difference between /scrape, /content and /smart-scrape?
/content returns the page’s full HTML after JavaScript runs. /scrape takes a list of CSS selectors and returns only those fields, which is tidier for structured data. /smart-scrape returns a whole-page representation when you have no selector list to start from.
How is Browserless billed, and what exactly is a unit?
One unit is up to 30 seconds of browser time. A longer run consumes another unit per 30 seconds, and a reconnect counts as a new connection with a fresh unit. Residential proxy traffic costs 6 units per MB and datacenter 2 units per MB, so bandwidth can outweigh compute on proxied jobs.
How do I connect existing Playwright code to a remote browser?
Replace chromium.launch() with chromium.connect_over_cdp() and pass the WebSocket URL with your token as a query parameter. With Puppeteer, swap puppeteer.launch() for puppeteer.connect({ browserWSEndpoint }). Everything after the connection line stays as it was.