API reference
Four endpoints, one header. Every request is a POST with a JSON body and returns either the file you asked for or a JSON error.
Authentication
Create a key in the dashboard and send it as a bearer token. It is shown once, at creation, and stored only as a hash — we cannot read it back to you, so if you lose it, revoke it and make another.
Authorization: Bearer bv_live_…Base URL
https://api.browsevra.comEndpoints
POST /v1/screenshotimage/png or image/jpeg{
"url": "https://example.com",
"fullPage": true,
"scrollPage": true,
"type": "png",
"width": 1280,
"height": 900,
"waitUntil": "load",
"timeout": 30000
}POST /v1/pdfapplication/pdf{
"url": "https://example.com",
"format": "A4",
"landscape": false,
"printBackground": true
}POST /v1/contenttext/html — the DOM after JavaScript has run{
"url": "https://example.com",
"waitUntil": "networkidle2"
}POST /v1/scrapeapplication/json — one entry per selector, each match with its text and its position on the page (top, left, width, height){
"url": "https://example.com",
"selectors": ["h1", "p.lead", "a[href^='/pricing']"],
"width": 1280,
"height": 900
}Response
[
{
"selector": "h1",
"results": [
{
"text": "Example Domain",
"top": 128,
"left": 320,
"width": 640,
"height": 48,
"clickable": false,
"styled": false
}
]
},
{
"selector": "p.lead",
"results": []
}
]Common options
waitUntil—load(default),domcontentloaded,networkidle0,networkidle2.timeout— 1,000–120,000 ms. Defaults to 30,000. Your plan's maximum session length still applies and is the lower of the two.width/height— the viewport, on/v1/screenshotand/v1/scrape. Width 320–3,840, height 240–4,320, defaulting to 1280×900. Pair a scrape with a screenshot at the SAME viewport or the boxes will not describe the picture.type—png(default),jpeg.formaton/v1/pdf—A4(default),Letter,Legal.selectors— 1–25 CSS selectors on/v1/scrape. Each returns at most 50 matches, text truncated to 200 characters, and invisible elements (zero-size,display:none, off-canvas, screen-reader-only) are skipped — they have real bounding boxes and measuring them reports things that are not on the page.
Anything outside these ranges is rejected as invalid_request, and the message names the field.
Errors
Every error is JSON with a stable error code, a human message, and a request_id that appears in our logs. Branch on the code, not the message.
| code | status | retry? | meaning |
|---|---|---|---|
invalid_api_key | 401 | no | Missing, malformed, unknown or revoked key. |
plan_required | 402 | no | This key needs a paid plan. The free tier runs in the dashboard playground only, so an API key on level 0 is refused here rather than part-way through a run. |
usage_limit_reached | 429 | no | Browser minutes or requests exhausted for the period. |
concurrency_limit_reached | 429 | yes | Already at your plan's simultaneous-browser limit. Retry once one of your own sessions finishes. |
rate_limited | 429 | yes | Too many requests too quickly. The message says how long to wait. |
invalid_url | 400 | no | The url was missing or unparseable. |
blocked_target | 403 | no | The target resolves to a private or link-local address. |
invalid_request | 400 | no | A field was the wrong shape or outside its range. The message names it. |
navigation_timeout | 504 | yes | The page did not finish loading in time. Billed — a real browser was held for the whole timeout. |
browser_launch_failed | 503 | yes | We could not start a browser for this request. Not billed. Retry. |
page_error | 502 | no | The browser could not load that page, including when a redirect or subresource was refused by the destination filter. |
service_unavailable | 503 | yes | Browsers are busy or restarting. Not billed. Retry. |
internal_error | 500 | yes | Something failed on our side. Not billed. Retry, and quote the request_id. |
“Retry” means the same request may succeed unchanged. Nothing here is retried for you, and there is no Retry-After header — for rate_limited the wait is in the message. Back off before retrying concurrency_limit_reached: it clears when one of your own sessions finishes, so an immediate retry just spends another request against your quota.
What your plan allows
The numbers behind usage_limit_reached and concurrency_limit_reached. Minutes are the meter — they measure what the work actually costs to serve; the request count exists so a flood of cheap calls cannot cost more to serve than it earns.
| Starter | Pro | Agency | |
|---|---|---|---|
| Browser minutes per period | 150 | 600 | 2,500 |
| Requests per period | 3,000 | 12,000 | 50,000 |
| Concurrent browsers | 1 | 2 | 3 |
| Longest single session | 5 min | 10 min | 15 min |
Without an active Evra subscription you get ten browser minutes a month, usable in the dashboard playground only. An API key on that level is refused with plan_required at authentication, before it can start a session — so a script finds out on its first call rather than part-way through a run. One Evra subscription covers Browsevra at the matching level alongside every other product, and turns the keys you already have on.
Metering
You are billed for browser time actually spent, returned on every successful response as x-browsevra-browser-ms — the same number that reaches your usage page.
- Waiting is not billed. If every browser is busy, your request queues — and time spent queued is excluded, because no browser was running your page. Charges are capped at the timeout you asked for plus a few seconds of overhead.
- Our failures are not billed.
service_unavailableand anything else caused by our capacity costs you nothing. You should not pay twice for our bad day. - Your page's failures are. A navigation that times out held a real browser for that whole timeout, so it is billed — otherwise a request designed to fail would be the cheapest browser time available. Failed requests also count against your monthly request quota.
Where requests can go
Every request the browser makes — the page you asked for, redirects it follows, scripts and images it loads, fetch() it runs — passes through a filter that resolves the destination and refuses private, loopback, link-local and cloud-metadata addresses, on a restricted set of ports. A hostname that resolves to both a public and a private address is refused outright, and connections are made to the address that was checked, so DNS cannot change under us between the check and the connection.
A URL you send that resolves to a private address is refused up front, as blocked_target. A page that REDIRECTS somewhere private, or loads a private subresource, is stopped by the filter rather than by the URL check — the navigation fails and you get page_error. Either way nothing from that address reaches you, and a blocked request is never reported as a success.
Puppeteer and Playwright
Connect a real CDP session over WebSocket and drive the browser yourself. The key goes in the query string because Puppeteer sends no custom headers on connect; Playwright can send an Authorization header instead, which is the better place for it.
import puppeteer from "puppeteer-core";
const browser = await puppeteer.connect({
browserWSEndpoint: "wss://api.browsevra.com/v1/cdp?key=bv_live_…",
});
const page = await browser.newPage();
await page.goto("https://example.com");
await page.screenshot({ path: "shot.png" });
await browser.close();- A session counts as one request and one concurrent browser, and is billed for as long as it stays open — close it, or you pay for the idle time until your plan's session cap ends it.
- The cap is the smaller of your plan's maximum session length and the browser time you have left this period, so a session cannot overspend your allowance on the way past it.
- The same destination filter applies. You drive navigation, so the URL check above cannot; what stops a page reaching a private address is the filter, and it is the reason this endpoint exists at all.
- Refusals arrive as an HTTP status on the upgrade, with the same error codes as the REST endpoints — 401, 402 and 429 all appear here.