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.com

Endpoints

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,000120,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/screenshot and /v1/scrape. Width 3203,840, height 2404,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. format on /v1/pdf A4 (default), Letter, Legal.
  • selectors125 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.

codestatusretry?meaning
invalid_api_key401noMissing, malformed, unknown or revoked key.
plan_required402noThis 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_reached429noBrowser minutes or requests exhausted for the period.
concurrency_limit_reached429yesAlready at your plan's simultaneous-browser limit. Retry once one of your own sessions finishes.
rate_limited429yesToo many requests too quickly. The message says how long to wait.
invalid_url400noThe url was missing or unparseable.
blocked_target403noThe target resolves to a private or link-local address.
invalid_request400noA field was the wrong shape or outside its range. The message names it.
navigation_timeout504yesThe page did not finish loading in time. Billed — a real browser was held for the whole timeout.
browser_launch_failed503yesWe could not start a browser for this request. Not billed. Retry.
page_error502noThe browser could not load that page, including when a redirect or subresource was refused by the destination filter.
service_unavailable503yesBrowsers are busy or restarting. Not billed. Retry.
internal_error500yesSomething 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.

 StarterProAgency
Browser minutes per period1506002,500
Requests per period3,00012,00050,000
Concurrent browsers123
Longest single session5 min10 min15 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_unavailable and 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.