HTML to image
Send a complete HTML document, get a PNG back — every field, the opaque origin that breaks relative URLs, the guard on sub-resources, and each error.
By LeonidPublished 6 min read
- Method & path
- POST /v1/html-to-image
- Auth
- Bearer key
- Quota
- 1 call
- Cache
- 24 h
- Rev
- 2026-08-31
What you send, and when it beats a screenshot
POST /v1/html-to-image takes a complete HTML document in the request body and answers with
image/png. There is no url field, on purpose: rendering a page you point at is
a screenshot, a different endpoint with a different set of problems. Here you supply
the document, so there is no page to be blocked by a login wall, no consent banner in front of the
content, no third-party rate limit between you and the pixels.
Reach for it for anything you generate: a receipt, a certificate, a chart card, a share image whose
layout is yours rather than one of our OG templates. For paper — page breaks, margins, A4 —
send the same document to the PDF endpoint, which takes an html body too. With no key yet,
start at getting started.
Every field
| field | type | default | limit |
|---|---|---|---|
html | string | required | 1 to 1,000,000 UTF-16 code units |
width | integer | 1280 | 1 to 2000 |
height | integer | 800 | 1 to 2000 |
deviceScaleFactor | number | 1 | 1 to 2, fractions allowed |
omitBackground | boolean | false | must be a JSON boolean |
waitUntil | string | "load" | load, domcontentloaded, networkidle |
The three numeric fields are coerced, so a string of digits is accepted. omitBackground is not:
send true, not "true", or the body fails to validate.
The image is width by height CSS pixels multiplied by deviceScaleFactor, and there is no
full-page option, because the viewport is the picture: 1000 by 600 at a scale factor of 2 answers a
2000 by 1200 PNG, and anything laid out below 600 CSS pixels is off the bottom. Size the document to
the frame. omitBackground drops the white backdrop so the PNG carries transparency, which shows
only if your own CSS leaves the page background unset. waitUntil decides when the capture is
taken: load suits a self-contained document, domcontentloaded fires earlier when a late asset is
decorative, and networkidle waits for the document's requests to go quiet, which is what you want
when a remote image has to be in the shot.
The document has an opaque origin
The page is never navigated to — your markup is written into a blank page — so the document URL
stays about:blank and the origin is opaque: an internal value the HTML Standard defines as
same-origin with nothing, itself included. The consequence that catches everybody: relative URLs
do not resolve. There is no base URL to resolve them against, so nothing is fetched and no request
is attempted. This renders a broken image and an unstyled body:
<!doctype html>
<meta charset="utf-8">
<link rel="stylesheet" href="styles.css">
<img src="/logo.png" width="200">
Nothing in the answer complains, because nothing failed: no connection was tried, so you get a clean
200 with two things missing. Write every reference as an absolute https:// URL and both arrive:
<!doctype html>
<meta charset="utf-8">
<link rel="stylesheet" href="https://cdn.example.com/styles.css">
<img src="https://cdn.example.com/logo.png" width="200">
No cookie, storage or service worker survives a render either, and the render carries none of your credentials — a private asset has to be inlined, or signed into the URL you send.
Images, fonts and the guard
Every sub-resource your document asks for is a new connection, checked exactly as a screenshot
target is. We resolve the name ourselves and dial the address we resolved, so a name answering with
a private address is refused however it is spelled — decimal, octal, or an IPv6 form with a v4
address hidden inside it. Hosts under .internal, .local, .localhost and .home.arpa are
refused without being looked up, and a URL carrying credentials is refused rather than stripped. An
<img>, a @font-face source or a stylesheet has to live at a public address a stranger's browser
could also reach. When the page renders but something was refused, the answer carries
x-render-blocked with the count — treat it as "this image has holes in it".
Inlining removes the whole class of problem. A data: URL (RFC 2397) is not a connection, so it
cannot be refused, cannot be slow, and cannot make your render depend on someone else's uptime.
Embed fonts the same way, as a base64 @font-face source: CSS Fonts Module Level 4 is explicit that
an unloaded face leaves text in a fallback, and a fallback changes line breaks — a webfont that
arrives late is a different layout, not a cosmetic difference.
What comes back
A 200 is the PNG, with content-type: image/png and cache-control: no-store. x-cache is hit
or miss. x-ratelimit-limit and x-ratelimit-remaining ride on every answer, refusals included,
so a client can back off before it hits a wall.
When it fails
- 400 — the body did not validate. This is the one refusal with no short
errorcode: the body is the validator's own report,success: falseplus anerrorwhosemessagenames every failing field by path. Nothing was rendered or charged; a string where a boolean belongs is the usual cause. - 401
unauthorized, withwww-authenticate: Bearer. Missing, malformed, unknown and revoked keys all get this one answer. - 413
payload_too_large— the body is over 4 MB, checked before your key is looked up. - 429
rate_limited(withlimit,windowSec,retryAfterSecand aretry-afterheader),quota_exceeded(withlimit,month,resetsAt), orconcurrent_limit(withlimit). Read theerrorfield, never the status: all three are 429 and they want different things from you — slow down, wait for the month, or stop overlapping renders. - 422
url_rejectedwith areason— a sub-resource walked into something the guard refuses and the render failed rather than merely losing an asset.reasonis one ofbad_scheme,credentials_in_url,bad_host,private_address,denied_hostordns_failure. - 502
render_failed, orrender_too_largewhen the PNG exceeds the deployment'sRENDER_MAX_BYTES. Both stay billed: your document was tried and the work was done. - 503
busy(every render slot is taken; the answer carriesretry-after: 1) orguard_unverified(this instance will not render). Both are our failure, so neither is metered. - 504
render_timeout— the document did not settle inside the budget. A remote asset that never answers undernetworkidleis the common cause.
The cache
An identical request inside 24 hours is served from storage. The key is a hash of the whole request:
a version number, the endpoint, a content hash of your html, then width, height,
deviceScaleFactor, omitBackground and waitUntil. One byte different in the document is a
different hash and a fresh render, so templating a timestamp into the markup means you never see a
hit. The key excludes your API key, which is safe because a hit needs your exact bytes, and whoever
can produce them already has the document. A hit is still metered: the plan sells API calls, not
renders. One known gap — x-render-blocked describes the render that happened, so a cached answer
says nothing about refusals recorded on the miss 20 hours earlier.
Quota, rate and concurrency
Every answer that reaches the handler is one call against your monthly quota, which runs on the UTC calendar month. The free plan is 100 calls a month with one render in flight at a time; the paid volumes, burst limits and concurrency are on the plans, printed from what this process enforces rather than retyped. Refusals before the handler — 401, 413 and all three flavours of 429 — write nothing and spend nothing. What you may render is on acceptable use.
Four megabytes of body, a million code units of html
The two ceilings are measured in different units. html is capped at 1,000,000 UTF-16 code units,
roughly JavaScript's String.length; the request body is capped at 4,194,304 bytes of UTF-8. A BMP
character above U+07FF is one code unit and three UTF-8 bytes under RFC 3629, so a Chinese, Thai or
Hindi document weighs three times what its length suggests and 1,000,000 code units of it is 3 MB
before the rest of the JSON. Hebrew and Arabic sit below that boundary at two bytes per code unit,
but Hebrew set with niqqud spends an extra code unit on every point, so it meets the html cap
sooner than the letters on screen imply. An English document runs out of code units first, a CJK
document runs out of bytes first, and a large base64 data: URL counts against both.
Running it yourself
The framed request at the foot of this sheet is the one to copy. Its document is self-contained with
no external assets, so CI runs it exactly as printed against the built image before this page ships;
if it stopped answering 200, this build would not exist. Substitute your own key, change the markup,
and keep width and height in step with the document you write.
Sources
- HTML Standard — Origin (html.spec.whatwg.org)
- RFC 2397: The "data" URL scheme (datatracker.ietf.org)
- CSS Fonts Module Level 4 (www.w3.org)
- RFC 3629: UTF-8, a transformation format of ISO 10646 (datatracker.ietf.org)
Commissioning test
Recorded · checked on every build
curl -s -X POST https://api.softaleo.com/v1/html-to-image \
-H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{"html":"<!doctype html><meta charset=\"utf-8\"><body style=\"margin:0;font:600 48px system-ui;display:grid;place-items:center;height:600px;background:#0e3050;color:#eaf1f5\">Ready to ship</body>","width":1000,"height":600}' \
-o card.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.
No button here: /v1/html-to-image renders a document you send, 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 |