Screenshot a URL
Send a URL, get the PNG or JPEG back — every argument, every limit, every refusal string the endpoint can answer with.
By LeonidPublished 7 min read
- Method & path
- POST /v1/screenshot
- Auth
- Bearer key
- Quota
- 1 call
- Cache
- 24 h
- Rev
- 2026-08-31
What this endpoint does
Send a URL, get back a picture of that page. POST /v1/screenshot opens the address you supply in a
pooled Chromium, waits for the page to reach the state you asked for, and answers with the image
bytes themselves — no wrapper object, no base64 field to decode, just image/png or image/jpeg
with a content-length you can stream to a file. One request is one screenshot and spends one call
from your monthly allowance.
It is one of four render endpoints that share the same pipeline, the same key and the same limits. Use PDF when you want paged output, HTML to image when you already hold the markup and have no URL to point at, and OG images when the input is a few lines of text rather than a page. If you have not made a call yet, getting started covers minting a key and sending the first request.
Arguments
The body is JSON. url is the only required field; everything else has a default. Every number has a
ceiling, because a request may ask for a large image but not for an unbounded one — 2000 by 2000 at a
device scale factor of 2 is the largest bitmap this endpoint will produce, and that bound is checked
before a browser is touched.
| Field | Type | Default | Range or values |
|---|---|---|---|
url | string, required | — | 1 to 2048 characters; an absolute http: or https: address on the public internet |
width | integer | 1280 | 1 to 2000 |
height | integer | 800 | 1 to 2000 |
fullPage | boolean | false | whole scroll height, clipped at 8000 px |
deviceScaleFactor | number | 1 | 1 to 2 |
waitUntil | string | load | load, domcontentloaded, networkidle |
omitBackground | boolean | false | transparent ground instead of white |
format | string | png | png, jpeg |
quality | integer | unset | 1 to 100, and only with format: "jpeg" |
Two of those interact. quality sent alongside format: "png" is a validation failure, not a
silently ignored field: the body is rejected with quality applies to format=jpeg only. And
fullPage does not remove the height ceiling, it replaces it — the viewport is still the width you
asked for, the capture runs to the bottom of the document, and a page that scrolls for ever is cut
at 8000 pixels rather than being allowed to ask the browser for a bitmap that would take the renderer
down with it.
Pick waitUntil deliberately, since it is the field that decides whether you get a finished page or
a spinner. domcontentloaded returns soonest and suits server-rendered pages; load waits for
sub-resources and is the default; networkidle waits for the traffic to stop and is the right choice
for a client-rendered dashboard, at the cost of every slow tracker on the page being on your critical
path.
What comes back
A success is the raw image, with x-cache set to hit or miss so you can tell whether anything
was rendered for this call. The response also carries x-ratelimit-limit and
x-ratelimit-remaining, and cache-control: no-store — the bytes are your render, and the cache
that is meant to hold them is ours rather than an intermediary's.
One header is worth handling. If the page rendered but some of what it asked for pointed at an
address the guard refuses, x-render-blocked carries the number of connections that were turned
down. That is the difference between a screenshot with holes in it and a screenshot with holes in it
that says why. It is computed from the record of the render that just happened, so a response served
from cache does not carry it — the same image explains itself on the miss and stays quiet on the hit
for the next 24 hours.
Why an address can be refused
The renderer is not allowed to reach anything on our side of the network, and that rule is not enforced by inspecting your string and hoping. Every byte the browser fetches — the page, each sub-resource, and each redirect hop — goes through a proxy running inside the API process. The proxy resolves the hostname itself and dials the address it just resolved, so the address that was checked is the address that gets connected to. There is no second lookup in between for a hostile record with a one-second lifetime to win, redirects are covered because following one means opening a new connection through the same guard, and HTTPS is covered because the tunnel request names its destination before any encryption exists.
Refusals answer 422 with url_rejected and a reason. The URL is never echoed back, because a query
string can carry somebody's token.
bad_scheme— anything that is nothttp:orhttps:, sofile:anddata:never reach a page.credentials_in_url— a user name or password embedded in the address. It is refused rather than stripped, because you meant to send it and a target URL must never reach a log line.bad_host— an empty or unparseable host, or a name that is internal by definition:localhostand anything under.internal,.local,.localhostor.home.arpa. These are refused without a lookup.private_address— the name resolved, and at least one address it returned sits in a special- purpose range: loopback, the private ranges, carrier-grade NAT, link-local (where the cloud metadata service lives), multicast, the documentation blocks, and the IPv6 forms that wrap an IPv4 address inside them. Every address a name returns has to pass, not merely the first, so a host that answers with one public address and one loopback address is refused outright.denied_host— a public address that is nonetheless ours, so a render cannot be pointed back at this API or at anything else on the box. A range table cannot catch that, which is why the list exists.dns_failure— the name did not resolve, or resolved to nothing usable.
A refusal that happens partway through — a redirect that lands somewhere private — comes back with the same vocabulary and the same status, so you never have to work out whether a 502 was really a guard refusal in disguise. What you may and may not point the renderer at otherwise is on acceptable use.
Every other failure
| Status | error | What to do |
|---|---|---|
| 400 | (a validation report) | The body did not validate. There is no short code on this one: the body is the validator's own report, success: false plus an error whose message names every failing field by path. |
| 401 | unauthorized | Missing, unknown or revoked key. All three answer identically. |
| 413 | payload_too_large | The body is over 4 MB. |
| 422 | url_rejected | See the reasons above. Retrying the same URL will not help. |
| 429 | rate_limited | Too many calls in a 60-second window. retry-after says how long to wait. |
| 429 | quota_exceeded | The month is spent. The body carries resetsAt. |
| 429 | concurrent_limit | Your key already has its allowed renders in flight. Retry when one finishes. |
| 502 | render_failed | The page answered but could not be rendered. |
| 502 | render_too_large | The output went past the byte budget for one render. |
| 503 | busy | Every render slot on the machine is taken. retry-after: 1, so retry. |
| 503 | guard_unverified | This instance refuses to render because its start-up self-test has not passed. Ours to fix, and not billed to you. |
| 504 | render_timeout | The page did not settle inside the budget. Try domcontentloaded. |
Both 503s are our shortage rather than your mistake, so neither is metered. A 502 and a 504 are billed: your URL was tried and the work was done.
The 24-hour cache
An identical request inside 24 hours is served from storage instead of being rendered again. Identical is exact: the key is a hash of the URL with its fragment removed — the query is kept, because it changes the page — together with every option you sent, so changing the width by one pixel or switching from PNG to JPEG is a different entry. The key does not include your API key, which is why two customers asking for the same public page with the same options share one entry, and is safe precisely because a hit needs the full URL and every option, which anyone holding them could have fetched themselves.
A hit is still metered. The plan sells API calls rather than renders, so a hot URL is not a free
unlimited ping, and your usage figures do not quietly collapse as the cache warms. What a hit changes
is latency, not the bill. x-cache tells you which one you got.
Quota, rate and concurrency
Three separate limits sit in front of the renderer, and all three are checked before any work happens, so a refusal costs you nothing: no usage row is written and no quota is spent. The monthly allowance runs on the UTC calendar month and the free plan carries 100 calls; the per-minute limit is a burst guard on a fixed 60-second window rather than the thing being sold; and concurrency caps how many renders one key may have running at once, so that no single caller can take the renderer away from everyone else. The numbers per plan are on the plans, and the response headers tell you where you stand before you hit anything.
About the strip below
At the foot of this sheet is a commissioning test: the exact request for this endpoint, and the status it returns. It is not decoration and it is not typed by hand into this page. That request is run against the live API in continuous integration before this page is allowed to ship, so if it stopped returning 200 you would not be reading this build. The one substitution is the target address, since the test renderer has no route out to the public internet and renders our own fixture page instead — which is stated under the frame rather than glossed over.
Sources
- Chrome DevTools Protocol — Page domain (chromedevtools.github.io)
- Playwright — Screenshots (playwright.dev)
- WHATWG URL Standard (url.spec.whatwg.org)
- IANA IPv4 Special-Purpose Address Registry (www.iana.org)
Commissioning test
Recorded · checked on every build
curl -s -X POST https://api.softaleo.com/v1/screenshot \
-H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{"url":"https://example.com","fullPage":true,"width":1280}' \
-o screenshot.png200 · image/png
Every request on this site is run against the API in CI before the page ships; if this one stopped returning 200, this build would not exist. The url is run in CI against our own test page instead of this address — the CI renderer has no route to the public internet.
No button here: /v1/screenshot renders a URL you choose, and a public button on it would render anything for anyone. Take the curl, or run it on the free plan — 100 renders a month.
Operations
| Method | Path | What it does |
|---|---|---|
| POST | /v1/html-to-image | Render an HTML string to a PNG |
| GET | /v1/og | Render a templated Open Graph image |
| POST | /v1/pdf | Render a URL or an HTML string to a PDF |
| GET | /v1/ping | Liveness ping |
| POST | /v1/screenshot | Render a URL to a PNG or JPEG |