Shotbolt

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/*.

your first render
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:

three ways, same key
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.

parametertypedefaultnotes
widthint px1280Viewport width, 100–3840.
heightint px800Viewport height, 100–3840. Ignored for the image height when full_page=true.
viewportWxH—Shorthand for width and height, e.g. 1440x900.
scalefloat1Device pixel ratio, 0.5–3. 2 gives a retina image at twice the bytes.
full_pageboolfalseCapture the whole scroll height. Cannot be combined with selector.
formatenumpngpng · jpeg · webp.
qualityint801–100, jpeg and webp only. Sending it with png is a 400.
selectorcss selector—Clip to the first match. 422 selector_not_found when nothing is visible.
hidecsv selectors—Up to 20 selectors to hide before the shot — cookie banners, chat bubbles.
transparentboolfalseOmit 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.

parametertypedefaultnotes
page_sizeenumA4A3 · A4 · A5 · Legal · Letter · Tabloid · Ledger.
page_widthcss length—With page_height, overrides page_size. e.g. 210mm.
page_heightcss length—Required when page_width is set.
margincss length10mmAll four margins at once.
margin_topcss lengthmarginAlso margin_right, margin_bottom, margin_left.
landscapeboolfalseRotate the page.
print_backgroundbooltrueKeep background colours and images. Chromium drops them by default; we do not.
pdf_scalefloat1Render scale, 0.1–2.
page_rangesstring—e.g. 1-3,8. Empty means every page.
header_htmlstring—Header template. Supports Chromium classes: date, title, url, pageNumber, totalPages.
footer_htmlstring—Footer template, same classes.
display_header_footerboolautoDefaults to true when a header or footer is given.
prefer_css_page_sizeboolfalseLet 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">.

parametertypedefaultnotes
titlestring—Required unless you send html. Up to 240 characters; the face shrinks to fit.
subtitlestring—Second line, up to 320 characters.
eyebrowstring—Small label above the title — a section, a brand, a tag.
templateenumgradientgradient · terminal · split.
themeenumdarkdark · light.
accenthex#6366f1Accent colour, with or without the #.
logohttps url—Logo image, drawn at 44px tall. http(s) only and SSRF-checked like any other target.
htmlstring—Your own card markup instead of a template. Rendered at width × height.
widthint px1200Card width, 200–2400.
heightint px630Card height, 200–2400.
formatenumpngpng · jpeg · webp.
scalefloat1Device 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

Shared parameters

Accepted by all three endpoints.

parametertypedefaultnotes
urlstring—The page to render. http and https only; one of url or html is required.
htmlstring—Raw HTML to render instead of a URL, up to 2 MB. Reference assets with absolute URLs.
delayint ms0Extra wait after load, 0–10000. Use wait_for first; a fixed delay is a guess.
wait_forcss selector—Wait until this selector is attached. 422 wait_for_timeout if it never appears.
wait_untilenumloadload · domcontentloaded · networkidle · commit. networkidle is slower and not always reachable.
block_adsboolfalseDrops requests to ~35 known ad and analytics hosts. Faster and usually cleaner.
darkboolfalseReports prefers-color-scheme: dark to the page.
timeoutint ms20000Per-render ceiling. Cannot exceed the server limit (RENDER_TIMEOUT_MS).
cachebooltruefalse renders fresh and does not store the result. The credit is still spent.
cache_ttlint s86400How long this result stays cacheable, 0–2592000. Also sets max-age.
user_agentstringShotbolt UAOverride the User-Agent string.
headersobject—Extra request headers (POST only). host, cookie and content-length are ignored.
cookiesarray—POST only: [{ name, value, domain, path }]. For rendering behind a session.
localestring—e.g. en-GB. Sets Accept-Language and the page locale.
timezonestring—IANA name, e.g. Europe/Dublin.

Response headers

The meter is in the headers, so you never have to call a usage endpoint to know what a render cost.

headermeaning
x-shotbolt-cachehit, miss or bypass. hit means Chromium was never touched and nothing was charged.
x-shotbolt-credits-charged0 or 1. Always 0 on a cache hit or a 304.
x-shotbolt-free-remainingRenders left in this key’s monthly free allowance.
x-shotbolt-credit-balancePaid credits left on the account.
x-shotbolt-duration-msHow long the render took, server side.
x-shotbolt-render-idThe usage row id. Quote it in support mail.
x-shotbolt-final-urlWhere the target ended up after redirects.
etagThe cache key. Send it back as If-None-Match for a free 304.
retry-afterOn 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:

conditional request
# 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.
planpricecreditsper 1k renders
Free$0100/mo—
Starter$95,000$1.80
Pro$19/mo25,000$0.76
Scale$79/mo200,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.

a retry that respects the header
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 http or https: 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" }

statuserrormeaningcharged
400missing_targetNeither url nor html was sent.no
400conflicting_targetBoth url and html were sent.no
400blocked_schemeurl is not http(s) — file:, chrome:, data: and the rest are refused.no
400blocked_private_hostThe hostname is an internal name (localhost, *.internal, a bare label).no
400blocked_private_addressThe hostname resolves to loopback, link-local or an RFC1918 range.no
400too_many_redirectsThe target redirected more times than the cap.refunded
400response_too_largeThe target is bigger than the response cap.refunded
400invalid_parameterA parameter is the wrong type, or out of range. The message names it.no
401missing_api_keyNo Authorization header, x-api-key or ?key=.no
401invalid_api_keyThe key is malformed, unknown or revoked.no
402out_of_creditsFree allowance spent and the credit balance is zero.no
422target_errorThe page itself replied 4xx or 5xx.refunded
422selector_not_foundselector matched nothing visible.refunded
422wait_for_timeoutwait_for never appeared.refunded
429rate_limitedOver the key’s requests-per-minute. Retry-After says when.no
429capacityEvery render slot and the queue are full. Retry-After says when.no
500render_failedOur fault. The credit is refunded; retrying is safe.refunded
504render_timeoutThe 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.

five lines
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.