v1

API reference

One endpoint. Send a URL, get an image back — rendered in a real Chrome with the emoji set you asked for.

POST https://webshotter.com/api/v1/webshots

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.

http
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:

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

http
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 redirectscurl -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.

json
{
  "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.

json
{
  "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.

bash
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
<?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'));
}
javascript
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`)
python
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")
ruby
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 android android
windows 1920×1080 microsoft windows
desktop-2k 2560×1440 android android
laptop 1536×864 android android
laptop-hd 1366×768 android android
macbook-air default 1440×900 apple apple
macbook-pro-14 1512×982 apple apple
macbook-pro-16 1728×1117 apple apple
galaxy 360×780 yes samsung oneui
iphone-se 375×667 yes apple apple
iphone 393×852 yes apple apple
iphone-pro-max 430×932 yes apple apple
ipad 820×1180 yes apple apple
ipad-pro-11 834×1194 yes apple apple
ipad-pro-13 1024×1366 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.

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.

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

Ads and cookie banners

Two tools, because they fail differently and most pages need both.

block drops requests to known ad networks, analytics and consent platforms before they load. Broad, no per-site knowledge — but a banner served by the site itself has no third-party script to block, and a site that gates its content behind consent renders empty rather than clean.

hide_selectors hides what is left, by selector. Precise and predictable, and it needs you to know the selector — which is the right trade when you webshot the same sites repeatedly.

bash
curl -X POST https://webshotter.com/api/v1/webshots \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://example.com",
        "block": ["ads", "trackers", "consent"],
        "hide_selectors": "#onetrust-banner-sdk, .cookie-notice, [id*=\"cookie\"]"
      }' \
  -o clean.jpg

Hiding uses display: none !important rather than removing the node. The space collapses, so a banner does not leave a blank rectangle; the DOM is untouched, so page scripts holding a reference keep working; and the !important wins against a script that puts the banner back with an inline style.

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.

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

bash
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

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
🚀 👋 🎉 ❤️ 🇫🇷 😀 🤔 🙏 🔥 ✅ rendered with the android set
apple Apple Color Emoji
🚀 👋 🎉 ❤️ 🇫🇷 😀 🤔 🙏 🔥 ✅ rendered with the apple set
blobmoji Blobmoji
🚀 👋 🎉 ❤️ 🇫🇷 😀 🤔 🙏 🔥 ✅ rendered with the blobmoji set
twemoji Twemoji
🚀 👋 🎉 ❤️ 🇫🇷 😀 🤔 🙏 🔥 ✅ rendered with the twemoji set
whatsapp WhatsApp Emoji
🚀 👋 🎉 ❤️ 🇫🇷 😀 🤔 🙏 🔥 ✅ rendered with the whatsapp set
samsung Samsung One UI
🚀 👋 🎉 ❤️ 🇫🇷 😀 🤔 🙏 🔥 ✅ rendered with the samsung set
facebook Facebook / Messenger
🚀 👋 🎉 ❤️ 🇫🇷 😀 🤔 🙏 🔥 ✅ rendered with the facebook set
microsoft Microsoft Fluent
🚀 👋 🎉 ❤️ 🇫🇷 😀 🤔 🙏 🔥 ✅ rendered with the microsoft set

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 for wait_for_selector, settling, any delay_ms you asked for, and capturing the webshot.
  • ·We hold the connection for up to 60 seconds waiting for your render. Past that you get a 503 naming the render's id — it may still finish, and the image will be there if you sent store: true. Set your own client timeout above that, not below it.
  • ·20 MB of inline html per 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-Ms on 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: true are 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.