Skip to content

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.

FieldTypeDefaultRange or values
urlstring, required1 to 2048 characters; an absolute http: or https: address on the public internet
widthinteger12801 to 2000
heightinteger8001 to 2000
fullPagebooleanfalsewhole scroll height, clipped at 8000 px
deviceScaleFactornumber11 to 2
waitUntilstringloadload, domcontentloaded, networkidle
omitBackgroundbooleanfalsetransparent ground instead of white
formatstringpngpng, jpeg
qualityintegerunset1 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 not http: or https:, so file: and data: 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: localhost and anything under .internal, .local, .localhost or .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

StatuserrorWhat 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.
401unauthorizedMissing, unknown or revoked key. All three answer identically.
413payload_too_largeThe body is over 4 MB.
422url_rejectedSee the reasons above. Retrying the same URL will not help.
429rate_limitedToo many calls in a 60-second window. retry-after says how long to wait.
429quota_exceededThe month is spent. The body carries resetsAt.
429concurrent_limitYour key already has its allowed renders in flight. Retry when one finishes.
502render_failedThe page answered but could not be rendered.
502render_too_largeThe output went past the byte budget for one render.
503busyEvery render slot on the machine is taken. retry-after: 1, so retry.
503guard_unverifiedThis instance refuses to render because its start-up self-test has not passed. Ours to fix, and not billed to you.
504render_timeoutThe 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

  1. Chrome DevTools Protocol — Page domain (chromedevtools.github.io)
  2. Playwright — Screenshots (playwright.dev)
  3. WHATWG URL Standard (url.spec.whatwg.org)
  4. 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.png

200 · 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

Every operation the API publishes, read from its OpenAPI document
MethodPathWhat it does
POST/v1/html-to-imageRender an HTML string to a PNG
GET/v1/ogRender a templated Open Graph image
POST/v1/pdfRender a URL or an HTML string to a PDF
GET/v1/pingLiveness ping
POST/v1/screenshotRender a URL to a PNG or JPEG

Written and maintained by Softaleo. Found a mistake? Use the feedback button on this page — corrections are fixed and dated above.