Skip to content

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

fieldtypedefaultlimit
htmlstringrequired1 to 1,000,000 UTF-16 code units
widthinteger12801 to 2000
heightinteger8001 to 2000
deviceScaleFactornumber11 to 2, fractions allowed
omitBackgroundbooleanfalsemust be a JSON boolean
waitUntilstring"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 error code: the body is the validator's own report, success: false plus an error whose message names every failing field by path. Nothing was rendered or charged; a string where a boolean belongs is the usual cause.
  • 401 unauthorized, with www-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 (with limit, windowSec, retryAfterSec and a retry-after header), quota_exceeded (with limit, month, resetsAt), or concurrent_limit (with limit). Read the error field, 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_rejected with a reason — a sub-resource walked into something the guard refuses and the render failed rather than merely losing an asset. reason is one of bad_scheme, credentials_in_url, bad_host, private_address, denied_host or dns_failure.
  • 502 render_failed, or render_too_large when the PNG exceeds the deployment's RENDER_MAX_BYTES. Both stay billed: your document was tried and the work was done.
  • 503 busy (every render slot is taken; the answer carries retry-after: 1) or guard_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 under networkidle is 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

  1. HTML Standard — Origin (html.spec.whatwg.org)
  2. RFC 2397: The "data" URL scheme (datatracker.ietf.org)
  3. CSS Fonts Module Level 4 (www.w3.org)
  4. 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.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.

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

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.