Getting started
Shotbolt turns a URL or a block of HTML into a PNG, a JPEG, a WebP or a PDF. Three endpoints, one engine, and the response body is the file — there is no job to poll and no bucket to clean up.
Base URL https://shotbolt.app. Every endpoint accepts GET with query parameters and POST with a JSON body; the names are identical, so anything here works either way. /api/v1/* is an alias for /v1/*.
curl -G https://shotbolt.app/v1/screenshot \ -H "Authorization: Bearer $SHOTBOLT_KEY" \ --data-urlencode "url=https://example.com" \ -o shot.png
Get a key at /dashboard — email only, no password, no card. Each key carries 100 free renders a month.
Authentication
Keys are sb_live_ followed by 32 hex characters. Send one of:
Authorization: Bearer sb_live_8f2c... # preferred x-api-key: sb_live_8f2c... GET /v1/og?key=sb_live_8f2c... # for <img src> and quick tests
Only a sha256 of the key is stored, so a lost key is rotated, never recovered. The query form exists because an <img> tag cannot send headers — use it with a key whose rate limit you are happy to expose, and rotate it from the dashboard when you are not.
No key → 401 missing_api_key. Unknown or revoked → 401 invalid_api_key.
GET · POST /v1/screenshot
Renders the viewport, the full scroll height, or one element. PNG by default; JPEG and WebP take a quality.
| parameter | type | default | notes |
|---|---|---|---|
| width | int px | 1280 | Viewport width, 100–3840. |
| height | int px | 800 | Viewport height, 100–3840. Ignored for the image height when full_page=true. |
| viewport | WxH | — | Shorthand for width and height, e.g. 1440x900. |
| scale | float | 1 | Device pixel ratio, 0.5–3. 2 gives a retina image at twice the bytes. |
| full_page | bool | false | Capture the whole scroll height. Cannot be combined with selector. |
| format | enum | png | png · jpeg · webp. |
| quality | int | 80 | 1–100, jpeg and webp only. Sending it with png is a 400. |
| selector | css selector | — | Clip to the first match. 422 selector_not_found when nothing is visible. |
| hide | csv selectors | — | Up to 20 selectors to hide before the shot — cookie banners, chat bubbles. |
| transparent | bool | false | Omit the page background. png and webp only. |
Plus every shared parameter below.
# Full-page WebP, ads blocked, cookie banner hidden curl -G https://shotbolt.app/v1/screenshot \ -H "Authorization: Bearer $SHOTBOLT_KEY" \ --data-urlencode "url=https://vercel.com" \ -d "full_page=true" -d "format=webp" -d "quality=85" -d "block_ads=true" \ --data-urlencode "hide=#cookie-banner,.intercom-launcher" \ -o vercel.webp # One element, retina curl -G https://shotbolt.app/v1/screenshot \ -H "Authorization: Bearer $SHOTBOLT_KEY" \ --data-urlencode "url=https://news.ycombinator.com" \ --data-urlencode "selector=table.itemlist" -d "scale=2" \ -o hn.png # Your own HTML, no URL involved curl -X POST https://shotbolt.app/v1/screenshot \ -H "Authorization: Bearer $SHOTBOLT_KEY" \ -H "Content-Type: application/json" \ -d '{"html":"<h1>Hello</h1>","width":800,"height":400}' \ -o hello.png
GET · POST /v1/pdf
Chromium's print pipeline, so it honours @media print and @page. Backgrounds are kept by default, which is what an invoice almost always wants.
| parameter | type | default | notes |
|---|---|---|---|
| page_size | enum | A4 | A3 · A4 · A5 · Legal · Letter · Tabloid · Ledger. |
| page_width | css length | — | With page_height, overrides page_size. e.g. 210mm. |
| page_height | css length | — | Required when page_width is set. |
| margin | css length | 10mm | All four margins at once. |
| margin_top | css length | margin | Also margin_right, margin_bottom, margin_left. |
| landscape | bool | false | Rotate the page. |
| print_background | bool | true | Keep background colours and images. Chromium drops them by default; we do not. |
| pdf_scale | float | 1 | Render scale, 0.1–2. |
| page_ranges | string | — | e.g. 1-3,8. Empty means every page. |
| header_html | string | — | Header template. Supports Chromium classes: date, title, url, pageNumber, totalPages. |
| footer_html | string | — | Footer template, same classes. |
| display_header_footer | bool | auto | Defaults to true when a header or footer is given. |
| prefer_css_page_size | bool | false | Let the page’s own @page size win over page_size. |
Plus every shared parameter below.
# Invoice from your own HTML, page numbers in the footer curl -X POST https://shotbolt.app/v1/pdf \ -H "Authorization: Bearer $SHOTBOLT_KEY" \ -H "Content-Type: application/json" \ -d '{ "html": "<h1>Invoice 1042</h1><p>Due in 14 days.</p>", "page_size": "A4", "margin": "18mm", "footer_html": "<div style=\"font:10px sans-serif;width:100%;text-align:center\"><span class=\"pageNumber\"></span> / <span class=\"totalPages\"></span></div>" }' \ -o invoice.pdf # A landscape report straight off a URL curl -G https://shotbolt.app/v1/pdf \ -H "Authorization: Bearer $SHOTBOLT_KEY" \ --data-urlencode "url=https://example.com/report" \ -d "landscape=true" -d "page_size=Letter" -d "margin=12mm" \ -o report.pdf
GET · POST /v1/og
A 1200×630 social card. Pass fields and pick one of three templates, or pass html and we render exactly that at card size. Because this endpoint takes a key in the query string, the URL can go straight into a <meta property="og:image">.
| parameter | type | default | notes |
|---|---|---|---|
| title | string | — | Required unless you send html. Up to 240 characters; the face shrinks to fit. |
| subtitle | string | — | Second line, up to 320 characters. |
| eyebrow | string | — | Small label above the title — a section, a brand, a tag. |
| template | enum | gradient | gradient · terminal · split. |
| theme | enum | dark | dark · light. |
| accent | hex | #6366f1 | Accent colour, with or without the #. |
| logo | https url | — | Logo image, drawn at 44px tall. http(s) only and SSRF-checked like any other target. |
| html | string | — | Your own card markup instead of a template. Rendered at width × height. |
| width | int px | 1200 | Card width, 200–2400. |
| height | int px | 630 | Card height, 200–2400. |
| format | enum | png | png · jpeg · webp. |
| scale | float | 1 | Device pixel ratio, 0.5–2. |
curl -G https://shotbolt.app/v1/og \ -H "Authorization: Bearer $SHOTBOLT_KEY" \ --data-urlencode "title=Postgres indexes, explained badly" \ --data-urlencode "subtitle=Nine minutes, one diagram, no B-tree maths" \ -d "template=terminal" -d "theme=dark" -d "accent=%2337d399" \ -o og.png
Response headers
The meter is in the headers, so you never have to call a usage endpoint to know what a render cost.
| header | meaning |
|---|---|
| x-shotbolt-cache | hit, miss or bypass. hit means Chromium was never touched and nothing was charged. |
| x-shotbolt-credits-charged | 0 or 1. Always 0 on a cache hit or a 304. |
| x-shotbolt-free-remaining | Renders left in this key’s monthly free allowance. |
| x-shotbolt-credit-balance | Paid credits left on the account. |
| x-shotbolt-duration-ms | How long the render took, server side. |
| x-shotbolt-render-id | The usage row id. Quote it in support mail. |
| x-shotbolt-final-url | Where the target ended up after redirects. |
| etag | The cache key. Send it back as If-None-Match for a free 304. |
| retry-after | On a 429 only: seconds to wait. |
Caching & ETags
Every request is hashed — sha256 over the normalised parameters — and the result is kept for cache_ttl seconds (24h by default). An identical request inside that window never reaches Chromium, never spends a credit, and comes back with x-shotbolt-cache: hit.
The ETag is the cache key. Send it back and you get a 304 with no body, which is the cheapest render there is:
# First call: miss, 1 credit curl -sD- -o shot.png -G https://shotbolt.app/v1/screenshot \ -H "Authorization: Bearer $KEY" --data-urlencode "url=https://example.com" \ | grep -i 'etag\|x-shotbolt-cache' # etag: "6f1c6e2b9c4a..." # x-shotbolt-cache: miss # Same call with the etag: 304, 0 credits, no bytes curl -sD- -o /dev/null -G https://shotbolt.app/v1/screenshot \ -H "Authorization: Bearer $KEY" \ -H 'If-None-Match: "6f1c6e2b9c4a..."' \ --data-urlencode "url=https://example.com" # HTTP/1.1 304 Not Modified
Pass cache=false when you need a fresh render of a page that changes — that spends a credit every time. Pass a short cache_ttl when you want both.
Credits & metering
- — One successful render is one credit, whichever endpoint.
- — A cache hit or a 304 is free.
- — A failed render is refunded: a 5xx, a timeout, a dead target, a selector that never showed up.
- — Each key gets 100 free renders a month, reset on the 1st, and the free allowance is spent before paid credits.
- — Credits from one-off packs do not expire. Subscription credits land on every renewal.
- — Out of both →
402 out_of_credits.
| plan | price | credits | per 1k renders |
|---|---|---|---|
| Free | $0 | 100/mo | — |
| Starter | $9 | 5,000 | $1.80 |
| Pro | $19/mo | 25,000 | $0.76 |
| Scale | $79/mo | 200,000 | $0.40 |
Rate limits & capacity
Two different limits, two different 429s. The per-key one is requests per minute (60/min on the free tier, higher on paid plans) and returns rate_limited. The engine one is concurrency: 2 renders at a time with a bounded waiting room, and when both are full you get capacity.
Both carry Retry-After in seconds, and neither spends a credit. A request is never left hanging: if it cannot be served it is refused in milliseconds.
async function render(body, attempt = 0) { const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }) if (response.status === 429 && attempt < 4) { const wait = Number(response.headers.get("retry-after") ?? 2) await new Promise((resolve) => setTimeout(resolve, wait * 1000)) return render(body, attempt + 1) } return response }
What we refuse to render
This endpoint takes arbitrary URLs from the internet, so it is locked down on purpose. These are 400s, not bugs:
- — Anything that is not
httporhttps:file://,chrome://,data:,javascript:,about:. - — Hostnames that resolve into loopback, link-local or RFC1918 space, including cloud metadata at 169.254.169.254 and tricks like
127.0.0.1.nip.io. The check runs on the resolved address, not on the string. - — Internal names: localhost, bare labels, *.internal, *.local.
- — Credentials in the URL.
- — More than 5 redirects, or a response over 10 MB. Every redirect hop is re-checked.
The same check runs on every subresource the page loads, so a public page cannot pull a private one in through an iframe or an image.
Error codes
Errors are JSON: { "error": "code", "message": "what to do", "docs": "/docs#errors" }
| status | error | meaning | charged |
|---|---|---|---|
| 400 | missing_target | Neither url nor html was sent. | no |
| 400 | conflicting_target | Both url and html were sent. | no |
| 400 | blocked_scheme | url is not http(s) — file:, chrome:, data: and the rest are refused. | no |
| 400 | blocked_private_host | The hostname is an internal name (localhost, *.internal, a bare label). | no |
| 400 | blocked_private_address | The hostname resolves to loopback, link-local or an RFC1918 range. | no |
| 400 | too_many_redirects | The target redirected more times than the cap. | refunded |
| 400 | response_too_large | The target is bigger than the response cap. | refunded |
| 400 | invalid_parameter | A parameter is the wrong type, or out of range. The message names it. | no |
| 401 | missing_api_key | No Authorization header, x-api-key or ?key=. | no |
| 401 | invalid_api_key | The key is malformed, unknown or revoked. | no |
| 402 | out_of_credits | Free allowance spent and the credit balance is zero. | no |
| 422 | target_error | The page itself replied 4xx or 5xx. | refunded |
| 422 | selector_not_found | selector matched nothing visible. | refunded |
| 422 | wait_for_timeout | wait_for never appeared. | refunded |
| 429 | rate_limited | Over the key’s requests-per-minute. Retry-After says when. | no |
| 429 | capacity | Every render slot and the queue are full. Retry-After says when. | no |
| 500 | render_failed | Our fault. The credit is refunded; retrying is safe. | refunded |
| 504 | render_timeout | The render passed the timeout. | refunded |
TypeScript SDK
One file, no dependencies, works on Node 18+, Bun, Deno and workers. It lives in sdk/ in the repository.
import { Shotbolt } from "shotbolt" const shotbolt = new Shotbolt(process.env.SHOTBOLT_KEY!) const png = await shotbolt.screenshot({ url: "https://example.com", full_page: true }) const pdf = await shotbolt.pdf({ html: "<h1>Invoice</h1>", page_size: "A4" }) const og = await shotbolt.og({ title: "Hello", template: "split" }) // Every call can also hand back the meter: const { bytes, meta } = await shotbolt.screenshotWithMeta({ url: "https://example.com" }) console.log(meta.cache, meta.creditsCharged, meta.durationMs)
RapidAPI
Shotbolt is also listed on RapidAPI, which handles the subscription, the billing and the buyer. Those requests arrive at /api/rapidapi/<endpoint> and are authenticated by RapidAPI's own x-rapidapi-proxy-secret header instead of a Shotbolt key. The parameters and the responses are identical.
If you subscribed through RapidAPI you need no account here. If you want the free tier, the dashboard and the cache headers, take a key instead.