API reference
Everything the API does, documented against what is actually implemented. Base URL
is wherever this server runs; the examples use https://backshot.dev. All responses
are the rendered bytes by default; errors are JSON.
Authentication
Every /v1/* endpoint requires an API key, accepted two ways:
# header (recommended for server-side code) curl "https://backshot.dev/v1/screenshot?url=https://example.com" \ -H "Authorization: Bearer $BACKSHOT_KEY" # query param (what makes <img src> embeds possible) <img src="https://backshot.dev/v1/screenshot?url=https://example.com&key=YOUR_KEY">
Query-param keys are visible to anyone who can read the URL: treat a key you embed publicly
as spendable by the public, and use a low-credit key for embeds. Keys are created with the
server-side CLI (node bin/backshot.js keys:create); there is no self-serve signup
yet.
The landing-page playground uses a shared demo key: 50 credits, refilling to 50 every hour, 10 requests/minute. It is deliberately too small to build on; get a real key for real use.
Screenshot
Renders a URL and returns the image bytes with the matching Content-Type.
POST accepts the same parameters as a JSON body (body values win over query values).
| param | type | default | what it does |
|---|---|---|---|
| url | string | required | Page to render. http/https only; see URL security. |
| width | int 16–4096 | 1280 | Viewport width in CSS pixels. |
| height | int 16–4096 | 800 | Viewport height. Ignored visually when full_page=1, but still part of the cache key. |
| full_page | bool | false | Capture the entire scrollable page instead of the viewport. |
| format | png | jpeg | webp | png | Output encoding. jpg is accepted as an alias for jpeg. |
| quality | int 1–100 | 80 | Compression quality for jpeg/webp. Silently ignored for png (png is lossless). |
| delay_ms | int 0–10000 | 0 | Extra wait after page load before the capture, for animations and late JS. |
| wait_until | load | domcontentloaded | networkidle | load | Navigation event that counts as "loaded". networkidle is the patient option. |
| selector | string | — | CSS selector; capture just that element. 422 if it doesn't appear within 5s. |
| dark_mode | bool | false | Emulates prefers-color-scheme: dark. |
| block_cookie_banners | bool | false | Injects CSS hiding common consent managers (OneTrust, Cookiebot, Didomi, Usercentrics, Osano and friends). Cosmetic: nothing is "accepted". |
| scale | float 0.5–3 | 1 | Device scale factor. 2 = retina. Hard-capped at 3. |
| response | "json" | — | Return metadata instead of bytes: {url, cached, credits_remaining, bytes, render_ms}. url points at the stored file. |
Booleans accept 1/true/yes/on (anything else is false). Out-of-range numbers are
clamped, not rejected.
Renders a URL or raw HTML to PDF. Send exactly one of url /
html (both is a 400). Body limit is 2 MB.
| param | type | default | what it does |
|---|---|---|---|
| url | string | — | Page to render. Same security rules as screenshots. |
| html | string | — | Raw HTML to render instead of a URL. Subresources it references are still SSRF-screened. |
| format | A4 | Letter | Legal | A4 | Paper size (case-insensitive). |
| landscape | bool | false | Landscape orientation. |
| margin | string | — | Uniform margin on all four sides, e.g. 1cm, 0.5in, 18px, 10mm. Malformed values are ignored. |
| print_background | bool | true | Include background colors and images. |
| scale | float 0.1–2 | 1 | Print scale. |
| wait_until, delay_ms, response | Same behavior as the screenshot endpoint. |
curl -X POST "https://backshot.dev/v1/pdf" \ -H "Authorization: Bearer $BACKSHOT_KEY" \ -H "Content-Type: application/json" \ -d '{"html": "<h1>Invoice #42</h1><p>Total: $90</p>", "format": "A4", "margin": "1cm"}' \ -o invoice.pdf
Account
Returns the calling key's plan, live credit balance, rate limit, lifetime render count, and its 20 most recent requests:
{
"plan": "free",
"label": "my-project",
"credits": 1961,
"rate_limit_per_min": 60,
"total_renders": 39,
"created_at": 1787810000,
"recent_usage": [ { "endpoint": "screenshot", "cached": false, "ms": 1412, … } ]
}
Stored files
Every render is stored under a content hash of its full parameter set;
response=json returns this path. Files are served without auth: the hash is only
derivable from the exact parameters, and only public http/https content is ever stored. Files
expire with the cache (24h) and then 404.
Caching & billing
One credit buys one fresh render. The cache key is a SHA-256 of the normalized parameter set, TTL 24 hours. A cache hit returns in about a millisecond, costs zero credits, and is marked in the response:
| header | meaning |
|---|---|
| X-Backshot-Cache | HIT or MISS. |
| X-Backshot-Credits-Remaining | Balance after this request. |
| X-Backshot-Render-Ms | Server-side time spent on this request. |
| X-RateLimit-Limit / X-RateLimit-Remaining | Your per-minute budget and what's left of it this window. |
Failed renders (navigation errors, blocked URLs, timeouts) are never billed. Any parameter
change, even width by one pixel, is a different cache entry and a fresh render.
There is no cache-bypass parameter yet; to force a fresh render within the TTL, vary a
parameter.
Rate limits
Each key has a fixed-window per-minute limit (set at key creation; 10/min on the demo key).
Exceeding it returns 429 with a Retry-After header in seconds. The
render queue itself is also bounded: when the box is saturated far beyond its concurrency,
requests return 503 immediately rather than piling up.
URL security
backshot refuses to be a proxy into private networks. For every URL (the one you submit, every redirect hop it takes, and every subresource the page loads):
| check | what gets rejected |
|---|---|
| scheme | Anything but http: and https:; no file:, data:, javascript:, chrome:. |
| hostname | localhost, *.local, *.internal, metadata.google.internal. |
| resolved IPs | 127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 169.254/16 (cloud metadata), 100.64/10, 0/8, multicast, ::1, fc00::/7, fe80::/10, including IPv4-mapped IPv6 spellings. Every DNS record is checked, not just the first. |
| redirects | The full redirect chain is re-validated after navigation; a hop into private space fails the render. |
Blocked URLs return 400 url_blocked with a reason, and are never billed.
Errors
Errors are JSON: {"error": {"code", "message"}}.
| status | code | when |
|---|---|---|
| 400 | missing_url bad_format missing_source ambiguous_source url_blocked | Bad request parameters, or a URL the security screen refused. |
| 401 | missing_key invalid_key | No key, unknown key, or revoked key. |
| 402 | no_credits | Balance is zero. Top up, or wait for the demo refill. |
| 413 | body_too_large | Request body over 2 MB. |
| 422 | render_failed | The page couldn't be rendered: navigation failure, HTTP error from the target, selector never appeared, or output over the 25 MB cap. |
| 429 | rate_limited | Per-minute limit exceeded. Honor Retry-After. |
| 503 | queue_full | Render queue saturated. Retry with backoff. |
| 504 | render_timeout | The render exceeded the 30s hard timeout. |
Health
No auth. Live process state; the landing page's status pill reads from it:
{ "status": "ok", "browser": "up", "queue_depth": 0, "active_renders": 0, "uptime_s": 4210, "version": "0.1.0" }
What's deliberately not here yet: self-serve signup and checkout, webhooks, storage/S3 export, signed URLs, multi-region rendering. The docs describe the build that exists, not the one that's planned.