API reference
One endpoint. Send a URL, get an image back — rendered in a real Chrome with the emoji set you asked for.
Authentication
Every request carries a bearer token, created at API tokens. The token is shown once and stored only as a hash — if you lose it, revoke it and make another.
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
A token can be given an expiry, a
domain allowlist and an
IP allowlist when you create it — single
addresses or CIDR ranges, IPv4 or IPv6. A call from outside the list is refused with
403 rather than
401, so "wrong place" is
distinguishable from "wrong key". Every call records the address it came from, shown on the
token's page — which is how you find out what to put in the list.
The domain allowlist bounds what a key may be pointed at:
example.com matches that host
exactly and *.example.com its
subdomains — deliberately not the apex, because those are different permissions. A
Entries in either list may carry a # note
after them — 203.0.113.7 # CI runner —
which is the difference between a list you can maintain and one nobody dares touch. A key can
also be forbidden from sending inline HTML at all, independently of any domain list. A
domain-restricted key also cannot send
inline HTML:
with no host to check, that would be a one-parameter way around the whole restriction.
Take a webshot
The smallest request that works:
curl -X POST https://webshotter.com/api/v1/webshots \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}' \
-o webshot.jpg
The response body is the image: no envelope to unwrap and no second request to
fetch it. Send "response": "json"
when you would rather have the record — see Response.
Parameters
| Field | Type | Default | Notes |
|---|---|---|---|
| url | string | required* | http/https only. Private and local targets are refused. *Required unless you send html. |
| html | string | required* | A complete document to render instead of fetching a URL. Must be self-contained — nothing is fetched for it. Up to 20 MB, though every megabyte is carried through the upload, the queue and the browser before rendering starts. |
| omit_background | boolean | false | Transparent where the page paints no background of its own. Sending it without a format renders PNG, since JPEG has no transparency. |
| locale | string | null | Drives Accept-Language and navigator.language together. A preference list works: fr-FR,fr;q=0.9,en;q=0.8 |
| timezone | string | null | An IANA zone, e.g. Europe/Paris. Without it the page renders in UTC. |
| format | string | jpeg | png · jpeg · webp · pdf. JPEG by default — a webshot is a picture, and a lossless one costs megabytes. PNG is lossless. PDF prints the page instead of photographing it: selectable text and vector edges, one long page with full_page and the first screen without it. It cannot be combined with clip_selector or omit_background, and has no thumbnail. |
| block | array | all three | Any of ads · trackers · consent — on by default. Send [] to turn it off. |
| hide_selectors | string | null | CSS selector list. Matching elements are hidden and their space collapsed. |
| compress | boolean | true | On by default. Each format shrinks its own way: PNG losslessly, jpeg/webp with a stronger lossy quality. Send false for maximum fidelity. |
| store | boolean | false | Keep a copy and return its URL in X-Webshot-Url. Subject to retention — see Limits. |
| response | string | image | image · json. With json the body is the render described — id, format, bytes, timings, image_url — carrying the picture base64 in image, instead of being the bytes themselves. |
| priority | boolean | false | Routes to the priority-renders lane. Entitlement gating is still TODO — any caller can send it today. |
| emoji_set | string | from the preset | One of android · apple · blobmoji · twemoji · whatsapp · samsung · facebook · microsoft. Defaults to what the chosen device would render; sending one overrides that. |
| preset | string | macbook-air | A whole device: viewport, resolution, touch and user agent. Or "custom". |
| width | integer | 1280 | 320–3840. Only with preset=custom. |
| height | integer | 800 | 320–3840. Only with preset=custom. |
| scale | integer | 2 | 1× standard · 2× retina · 3× super retina. Only with preset=custom. |
| color_scheme | string | null | light or dark — renders as a visitor with that system setting sees it. |
| full_page | boolean | false | Capture the whole document, not the viewport. |
| clip_selector | string | null | Capture just the first matching element. |
| wait_for_selector | string | null | Wait for this element before capturing. |
| delay_ms | integer | 0 | Stand still this long after the page has settled — images decoded, network quiet, fonts painted — and before the shutter. For what no selector can wait for: an intro animation, a counter that ticks up, a banner that slides in a second later. It is spent inside timeout_ms, so a delay at or past the whole budget is refused. |
| timeout_ms | integer | 15000 | 1000–30000, the whole render budget (load, settling, delay_ms, and capture). |
Response
200 returns the image bytes, with
the time the render itself took beside them:
HTTP/2 200
Content-Type: image/jpeg
X-Render-Ms: 462
Send "store": true and one more
header appears. Its presence is the whole answer — the image was kept, and this is where
it is. Fetch it with the same bearer token.
X-Webshot-Url: https://webshotter.com/api/v1/webshots/01JZ8M4K7QW3XY6ND2VBRT9F0C/image
That URL answers 302 with a
short-lived link to the image in object storage, so follow redirects —
curl -L, and the default in most
HTTP clients. The bearer token authorises the redirect, not the link it points at; that
link expires in minutes and is not worth storing.
Storing is a side effect, never a reason to fail: if a copy could not be kept you still get your PNG in the body, and simply no URL.
The record instead of the bytes
Send "response": "json" and the body
becomes the render described, with the picture inside it. Useful when the webshot is one
field of something else you are assembling, or when you want the id and the timings without
pulling three headers off a binary response.
{
"id": "01JZ8M4K7QW3XY6ND2VBRT9F0C",
"status": "completed",
"pending": false,
"code": null,
"message": null,
"retryable": null,
"url": "https://example.com",
"format": "jpeg",
"width": 1440,
"height": 900,
"scale": 2,
"full_page": false,
"bytes": 184320,
"ms": 462,
"queued_ms": 38,
"image_url": null,
"created_at": "2026-02-11T09:14:02+00:00",
"finished_at": "2026-02-11T09:14:03+00:00",
"content_type": "image/jpeg",
"image": "/9j/4AAQSkZJRgABAQ…"
}
image is base64 and always present —
the body is no longer the picture, so asking for JSON must not be a way to lose it. That costs
about a third more on the wire than the bytes alone, which for anything large is an argument
for "store": true and fetching
image_url instead.
Every field except those last two is what
GET /v1/webshots/{id} answers with,
so following a render up afterwards needs no second parser. Errors are unaffected: they have
always been JSON, whatever you asked the success to be.
Errors
Errors are JSON, and the status tells you whose problem it is.
| Status | Meaning | What to do |
|---|---|---|
| 401 | Missing, revoked or expired token. | Check the Authorization header. |
| 403 | Valid token, but used from an address outside its IP allowlist, or pointed at a host outside its domain allowlist. | The response names what it refused. Widen the list, or call correctly. |
| 422 | Your request or your page — bad parameter, unresolvable host, selector never appeared, private target. | Read the code; retrying unchanged will fail the same way. |
| 429 | Rate limit: 60 requests per minute per token. | Back off and retry. |
| 503 | Ours — no worker took the render, or something on our side failed after it did. The code says which. | Retry shortly unless retryable is false. |
The distinction that matters: 422 is about your request and will not fix itself, while 503 is about us and usually will.
{
"message": "target URL host could not be resolved",
"code": "target_unresolvable",
"retryable": false,
"errors": { "url": ["target URL host could not be resolved"] }
}
code is the one to branch on.
message is written for a person and we
reword it; the code is a contract and we do not. retryable
says whether sending the same request again could plausibly work. Every error answers this
shape — a refused key and a failed render alike. A render that was simply still in the queue
when we stopped waiting has a
null code: nothing went wrong with it,
we just ran out of patience.
The codes for calls that never reached a render:
| Code | Status | What happened |
|---|---|---|
| unauthenticated | 401 final | No key, or one that is unknown, expired or revoked. Which of the four is deliberately not said — send a valid bearer token. |
| ip_not_allowed | 403 final | The key is real but it is not allowed to be used from the address that called. Check its IP allowlist; the key's page lists the address it last called from. |
| domain_not_allowed | 403 final | The key is restricted to particular hosts and the URL is not one of them. Check its domain allowlist — a wildcard entry does not admit the apex. |
| html_not_allowed | 403 final | The key may not send inline HTML. A domain-restricted key never may, because there would be no host to check. |
| forbidden | 403 final | The key may not do that. The message names what was refused. |
| bad_request | 400 final | The request could not be read at all — usually a body that is not the JSON it claims to be. |
| validation_failed | 422 final | A parameter is missing, malformed, or contradicts another. The errors object in the response names each field. |
| payload_too_large | 413 final | The request body exceeded what this API accepts. Inline documents are capped at 20 MB, images and fonts included. |
| unsupported_media_type | 415 final | The request was not sent as JSON. Send Content-Type: application/json. |
| not_found | 404 final | No such endpoint, or no such render. A stored render is only readable with a key belonging to the team that took it. |
| method_not_allowed | 405 final | The endpoint exists but not for that HTTP verb. Webshots are taken with POST and read back with GET. |
| rate_limited | 429 | The key has spent its budget for the minute. The Retry-After header says how long to wait; the budget is per key, not per address. |
| service_unavailable | 503 | The service could not take the call right now. Nothing is wrong with the request — retry. |
| server_error | 500 | Something went wrong on our side that nobody anticipated. It is logged for us; retrying is reasonable. |
And the reasons a render itself can fail:
| Code | Status | What happened |
|---|---|---|
| target_private | 422 final | Private target The address behind that URL is private, loopback or link-local, and we will not render it. This applies to public hostnames that resolve to a private address too, which is usually a split-horizon DNS setup rather than anything you did. Send the page as inline HTML if it only exists on your own network. |
| target_unresolvable | 422 final | Host not resolved DNS gave us nothing for that hostname. Check the spelling, and check that the name resolves from the public internet rather than only from inside your network. |
| target_unreachable | 422 final | Host unreachable The host resolved but would not talk to us: the connection was refused, the address was unroutable, or the TLS certificate did not check out. A firewall that allows your office but not the open internet produces exactly this. |
| target_timeout | 422 final | Target timed out The page did not finish loading inside the budget you gave it. Raise timeout_ms — up to 30 seconds — or look at what the page is waiting for. A page that never stops loading will never render, however long the budget. |
| target_http_error | 422 final | Target returned an error The page answered with an error status of its own. We took it at its word rather than photographing an error page. Sites often answer 403 to unfamiliar clients, so a device preset with a browser user agent may be what this needs. |
| target_domain_not_allowed | 422 final | Redirected off the allowlist The URL was allowed, and then it redirected to a host this key is not. We render what the browser ended up on, so the allowlist is checked against that too — otherwise an open redirect on one of your own domains would be a way around it. The message names the host we stopped at. |
| selector_missing | 422 final | Selector not found We waited for the element you named and it never appeared. Check the selector against the rendered page — an element that only exists after a script runs needs wait_for_selector, and one that never exists needs a different selector. |
| selector_invalid | 422 final | Invalid selector One of the selectors you sent is not valid CSS, so the browser refused it before anything could be hidden. The message names the list we could not parse. |
| document_too_large | 422 final | Document too large The inline document exceeded 20 MB. Inlined images and fonts count towards that ceiling — a picture of a page rarely needs the application bundle behind it. |
| document_unrenderable | 422 final | Page could not be rendered The browser could not produce an image of this page, for a reason that does not fit any of the others. The message says what it reported. |
| no_worker_online | 503 | No worker online No renderer for that emoji set is running right now. Nothing is wrong with your request. Retry in a moment, or ask for a set that is up — the emoji sets page lists which. |
| all_workers_refused | 503 | All workers refused Every renderer for that set turned this render down — busy, restarting, or failing. Retry shortly; if it persists, it is an outage on our side rather than anything about your request. |
| dispatch_deadline | 503 | Dispatch deadline reached We spent the whole dispatch budget trying renderers without any of them answering in time. The fleet is slow rather than absent, so a retry in a few seconds usually lands. |
| payload_unwritable | 503 | Request could not be stored We could not store your request long enough to queue it, so we did not queue it. This is ours, not yours. Retry. |
| payload_missing | 503 | Request no longer available Your request was accepted and queued, and then lost before a renderer picked it up. This is ours. Send it again. |
| image_unstorable | 503 | Image could not be stored The page rendered and we could not write the image. The webshot itself was fine; the failure is entirely on our side. Retry. |
| never_started | 503 | Never started Your render sat in the queue and no renderer ever ran it. That is a fault on our side rather than a busy period. Send it again. |
| never_finished | 503 | Never finished A renderer started this and never reported back. Whatever happened to it happened mid-render, on our side. Send it again. |
| worker_crashed | 503 | Render crashed The render died part-way through for a reason we did not anticipate. Ours, and it has been logged for us to look at. Retry. |
Examples
The same call — a full-page Twemoji capture — in five places.
curl -sS -X POST https://webshotter.com/api/v1/webshots \
-H "Authorization: Bearer $WEBSHOTTER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"emoji_set": "twemoji",
"full_page": true,
"scale": 2
}' \
-o example.jpg
<?php
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('WEBSHOTTER_TOKEN'))
->timeout(35)
->post('https://webshotter.com/api/v1/webshots', [
'url' => 'https://example.com',
'emoji_set' => 'twemoji',
'full_page' => true,
'scale' => 2,
]);
if ($response->successful()) {
file_put_contents('example.jpg', $response->body());
echo $response->header('X-Render-Ms').' ms'.PHP_EOL;
} else {
// 422 is your request, 503 is our capacity — only one is worth retrying.
report(new RuntimeException($response->json('message') ?? 'render failed'));
}
import { writeFile } from 'node:fs/promises'
const response = await fetch('https://webshotter.com/api/v1/webshots', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WEBSHOTTER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://example.com',
emoji_set: 'twemoji',
full_page: true,
scale: 2,
}),
})
if (!response.ok) {
const { message } = await response.json()
throw new Error(`${response.status}: ${message}`)
}
await writeFile('example.jpg', Buffer.from(await response.arrayBuffer()))
console.log(`${response.headers.get('x-render-ms')} ms`)
import os
import httpx
response = httpx.post(
"https://webshotter.com/api/v1/webshots",
headers={"Authorization": f"Bearer {os.environ['WEBSHOTTER_TOKEN']}"},
json={
"url": "https://example.com",
"emoji_set": "twemoji",
"full_page": True,
"scale": 2,
},
timeout=35,
)
response.raise_for_status()
with open("example.jpg", "wb") as handle:
handle.write(response.content)
print(response.headers["x-render-ms"], "ms")
require "net/http"
require "json"
uri = URI("https://webshotter.com/api/v1/webshots")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('WEBSHOTTER_TOKEN')}"
request["Content-Type"] = "application/json"
request.body = {
url: "https://example.com",
emoji_set: "twemoji",
full_page: true,
scale: 2
}.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 35) do |http|
http.request(request)
end
raise JSON.parse(response.body)["message"] unless response.is_a?(Net::HTTPSuccess)
File.binwrite("example.jpg", response.body)
puts "#{response['X-Render-Ms']} ms"
Devices
A preset is a whole device, not a viewport. Phones and tablets bring touch and a matching user agent with them, because sites branch on all three — a narrow viewport alone returns the desktop layout squeezed, which is the failure that looks like it worked.
| Preset | Viewport | Resolution | Touch | Emoji | Text fonts |
|---|---|---|---|---|---|
| desktop | 1920×1080 | 1× | — | android | android |
| windows | 1920×1080 | 1× | — | microsoft | windows |
| desktop-2k | 2560×1440 | 1× | — | android | android |
| laptop | 1536×864 | 1× | — | android | android |
| laptop-hd | 1366×768 | 1× | — | android | android |
| macbook-air default | 1440×900 | 2× | — | apple | apple |
| macbook-pro-14 | 1512×982 | 2× | — | apple | apple |
| macbook-pro-16 | 1728×1117 | 2× | — | apple | apple |
| galaxy | 360×780 | 3× | yes | samsung | oneui |
| iphone-se | 375×667 | 2× | yes | apple | apple |
| iphone | 393×852 | 3× | yes | apple | apple |
| iphone-pro-max | 430×932 | 3× | yes | apple | apple |
| ipad | 820×1180 | 2× | yes | apple | apple |
| ipad-pro-11 | 834×1194 | 2× | yes | apple | apple |
| ipad-pro-13 | 1024×1366 | 3× | yes | apple | apple |
| custom | your own width, height and scale | ||||
A device brings its text fonts as well as
its emoji. Ask for an iPhone and a page that says
-apple-system or
system-ui is drawn with Apple's UI
face rather than with whatever the container happened to have — the same idea as the emoji
sets, applied to type. Nothing is injected into your page: the profile decides which font
wins, your own @font-face still
loads and still wins over it.
Where a platform's own face cannot be installed, the profile falls back to Noto rather than to a serif. A font change or a Chrome bump can legitimately shift pixels, so treat a webshot as a picture taken at a moment rather than as a stable fingerprint.
width,
height and
scale are only yours to set with
preset: "custom". A request that
names a phone and then contradicts its dimensions is refused rather than resolved by some
precedence rule you would have to learn.
iphone
quietly became a newer model, your webshots would change without you touching anything —
and unlike a Chrome bump there is no version string to notice it in. New devices get new names.Language and time
Render a page as someone else sees it. One
locale drives both halves of what
"language" means to a page: the Accept-Language
header a server negotiates on, and the navigator.language
client-side code reads. Sites use one, the other, or both — setting only the header is the common
half-fix, and it silently does nothing on a page that asks the browser.
curl -X POST https://webshotter.com/api/v1/webshots \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"locale": "fr-FR,fr;q=0.9,en;q=0.8",
"timezone": "Europe/Paris"
}' \
-o fr.jpg
A list is accepted because Accept-Language
is a preference order; the first tag becomes
navigator.language, which is a single
value rather than a ranking.
timezone takes an IANA identifier.
Without it every date, clock and "3 hours ago" on the page renders in UTC, which is rarely what
the webshot is meant to show.
Waiting for the page
Every capture waits for the page to stop changing before the shutter fires. You do not have to ask for this and there is no parameter to turn it on — it is what a webshot means here. In order:
- · The load event — Everything the document itself declared — stylesheets, scripts, images in the markup — has finished. Navigation alone only reaches DOMContentLoaded, which is the HTML parsed and nothing fetched.
- · A pass down the page — Only on full_page captures. Lazy images load when they approach the viewport, so a capture that never scrolls photographs empty boxes where the pictures go.
- · Network idle — What the scripts asked for after load: XHR and fetch, CSS background images, fonts loaded by JavaScript. None of it is covered by the load event, and on a modern page most of the content is in here.
- · Images decoded — complete means the bytes arrived; decode() means the browser can actually draw them. The gap between the two is a half-painted image.
- · Fonts painted — document.fonts.ready, not merely requested. With font-display: swap the page renders in a fallback face first, and a webshot taken then is the right words in the wrong type.
- · Two animation frames — Layout and paint for everything the steps above changed.
Each step is bounded and none of them can fail your render. A page with an open websocket
is never network-idle, an ad iframe polls forever, and an image behind a paywall never
loads — waiting on those until the budget ran out would mean losing a webshot that was
already good. They share one deadline, so the whole phase fits inside
timeout_ms.
Two things that remain yours to say. Use
wait_for_selector when the thing
you are photographing appears on its own schedule — a chart your dashboard draws, a table
that arrives with the second request. Use
delay_ms when nothing in the DOM
marks the moment: an intro animation, a counter ticking up, a banner that slides in a
second after everything else went quiet. The delay is counted from the end of the settling
above, and it is spent inside your budget — so raise
timeout_ms with it.
curl -X POST https://webshotter.com/api/v1/webshots \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/dashboard",
"wait_for_selector": "#revenue-chart svg",
"delay_ms": 1500,
"timeout_ms": 20000
}' \
-o dashboard.jpg
Inline HTML
Send html instead of
url when the page cannot be
reached from the internet — a developer's laptop, a CI runner, anything behind your
network. We render the document you hand us.
curl -X POST https://webshotter.com/api/v1/webshots \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"html": "<!doctype html><body style=\"font:64px system-ui\">🚀 Ship it</body>",
"emoji_set": "twemoji",
"omit_background": true
}' \
-o inline.jpg
We render it from nowhere — there is no base URL and no access to your network,
so a <script src="/app.js">
or <img src="/logo.png">
resolves to nothing and renders blank. Inline your CSS and JS, and embed images and
fonts as data: URIs.
Public https URLs still work.
Inline renders carry no URL, so they appear in your history as inline HTML rather than under a link.
Emoji sets
A device preset also chooses the emoji: Apple devices render Apple's set, everything else
renders android (Noto Color Emoji,
which is also what most Linux distributions ship). Send an explicit
emoji_set to override it — comparing
sets on one device is the whole reason they exist.
Each set is a separate container image with exactly one colour-emoji font installed and fontconfig rebuilt around it. Every emoji codepoint on the page resolves to that set — while the page's own web fonts load untouched. No CSS is injected, ever.
android
Noto Color Emoji
apple
Apple Color Emoji
blobmoji
Blobmoji
twemoji
Twemoji
whatsapp
WhatsApp Emoji
samsung
Samsung One UI
facebook
Facebook / Messenger
microsoft
Microsoft Fluent
microsoft cannot render country flags. Microsoft's Fluent artwork ships none — Windows draws the two letters using a system font we do not install, and a render container carries exactly one emoji font by design. A flag through this set comes out blank. If you need flags, every other set draws them.
samsung and facebook do not compose keycaps.
Their fonts carry no ASCII, so #️⃣
and 1️⃣ render as the plain
character rather than a key. Flags, families and skin tones all work.
A set with no live worker answers 503
rather than quietly falling back to another set — a webshot with the wrong emoji is worse
than no webshot.
Limits
- ·60 requests per minute per token. Over that is a
429. - ·30 seconds is the longest render budget you can ask for, with
timeout_ms. That budget covers loading the page, waiting forwait_for_selector, settling, anydelay_msyou asked for, and capturing the webshot. - ·We hold the connection for up to 60 seconds waiting for your render. Past that you get a
503naming the render's id — it may still finish, and the image will be there if you sentstore: true. Set your own client timeout above that, not below it. - ·20 MB of inline
htmlper request. Everything the page needs has to be inline, so that ceiling is real — but it is a ceiling, not a target: a document is uploaded, queued and handed to a browser before rendering starts, and a megabyte of that is a megabyte through each step. Inline the markup, styles and fonts the picture needs; leave out the application bundle it does not. - ·
X-Render-Mson every success is the time Chrome itself spent, so you can tell a slow page from a busy fleet. It excludes the queue and the transfer either way. - ·3840 px in either dimension, at up to 3× density.
- ·Private, loopback and link-local targets are refused — including public hostnames that resolve to them.
- ·API renders are not stored unless you send
store: true. The bytes stream to you either way; without it we keep only the usage record. - ·Stored images stay until the usage record itself is pruned (90 days). There is no separate age or per-team count cap. Renders taken without
store: trueare swept shortly after the response, since their file only ever existed to carry the bytes to you.
Ready to try it?
Create a token, or take one from the form first.