Rendering a page to PDF
POST /v1/pdf takes either a public URL or an HTML document you send and answers with the PDF file — every field, limit and refusal.
By LeonidPublished 8 min read
- Method & path
- POST /v1/pdf
- Auth
- Bearer key
- Quota
- 1 call
- Cache
- 24 h
- Rev
- 2026-08-31
What the endpoint does
POST /v1/pdf turns one page into one PDF and answers with the file itself. The response body is the
PDF, the content type is application/pdf, and nothing is wrapped in JSON. There is no job to poll
and no callback to wait for: the render happens on a pooled Chromium while your request is open, and
the bytes come back on the same connection.
The thing that separates this endpoint from screenshots and
HTML to image is that it takes either shape of input. Send url and we fetch the
page. Send html and we print the document you wrote. You may not send both, and you may not send
neither — the schema demands exactly one, and the message it returns when you get that wrong reads:
give exactly one of url or html. A request that could mean two things is refused rather than guessed
at, and the refusal is a 400 with nothing rendered and no render slot spent.
The html form has one consequence worth planning around. Your document is loaded on a page that has
never navigated anywhere, so it stays at about:blank with an opaque origin. A relative reference —
an image at /logo.png, a stylesheet at css/print.css — resolves against nothing and fetches
nothing. Use absolute https:// addresses for every image, stylesheet and font, or inline them as
data URLs. Nothing else carries over either: no cookie, no storage and no service worker survives a
render.
Every field, and where it stops
| Field | Type | Default | Ceiling |
|---|---|---|---|
url | string | none | 1–2048 characters, absolute http: or https:, mutually exclusive with html |
html | string | none | 1–1,000,000 characters, mutually exclusive with url |
format | enum | A4 | Letter, Legal, Tabloid, Ledger, A0–A6 |
landscape | boolean | false | — |
printBackground | boolean | true | — |
scale | number | 1 | 0.1 to 2 |
margin | object | omitted | top, right, bottom, left; each a length like 10mm, 0.5in, 12px, 2.25cm |
pageRanges | string | omitted, meaning every page | digits, commas, hyphens and spaces only, 40 characters |
preferCSSPageSize | boolean | false | — |
media | enum | print | print or screen |
waitUntil | enum | load | load, domcontentloaded, networkidle |
Margins are matched against a pattern rather than passed through, because the value reaches Chromium
directly: up to three digits, an optional decimal part, and one of px, in, cm or mm. A bare
number, an em value and anything containing an expression are all refused. Page ranges get the same
treatment — 1-3,7 is accepted, a semicolon or a letter is not.
Three fields decide what the printed page looks like rather than how big it is. preferCSSPageSize
hands the size decision to your own @page rule, so a stylesheet that declares its own sheet wins
over format. printBackground is on by default here, the opposite of what a browser's print dialog
usually does, because a card or an invoice with its background dropped is rarely what anyone meant.
media is the field people miss: a PDF is rendered with print CSS unless you ask otherwise, so a
print stylesheet that hides your navigation applies whether or not you remembered it. Ask for
screen when the on-screen styling was the point. Pages are laid out in a viewport 1280 by 800
before they are paginated, so a design whose mobile breakpoint sits below that width prints in its
desktop form.
What comes back with the file
Two headers on a 200 are worth reading. x-cache is hit or miss and tells you whether anything
was actually rendered. x-render-blocked appears only when it is not zero, and counts the
sub-resources the guard refused during this render — which is how a PDF with a hole where a logo
should be explains itself instead of leaving you guessing. That header is computed for a live render,
so a cache hit does not carry it; the miss that filled the cache did.
x-ratelimit-limit and x-ratelimit-remaining ride along on successes and on refusals alike, so a
client can slow down before it hits a wall rather than after. The response carries cache-control: no-store: the bytes are your render, and the cache that matters is ours.
Why a URL can be refused
Every byte the browser fetches — the page itself, each sub-resource, and every redirect hop — goes through a guard that resolves the hostname itself and then dials the address it resolved. There is no second lookup in between, which is what closes DNS rebinding, and a redirect is only another trip through the same gate.
What it refuses is the set of addresses that could only ever mean our infrastructure or yours: the
IANA special-purpose ranges, which cover loopback, the RFC 1918 private blocks, carrier-grade NAT,
multicast, and the link-local range that holds the cloud metadata service at 169.254.169.254. The
IPv6 forms that carry a v4 address inside them are unwrapped and checked again, so loopback wearing a
hat is still loopback. Names that are internal by definition are refused without being resolved at
all: localhost, and anything ending .internal, .local, .localhost or .home.arpa. Every
address a name resolves to has to pass, not the first one.
A refusal is a 422 with url_rejected and a reason, one of private_address, bad_scheme,
credentials_in_url, bad_host, denied_host or dns_failure. Your URL is never echoed back in
the body or written to a log line, because its query string may carry a token. The check runs before
any render slot is taken, so pointing at a private address costs one DNS lookup and nothing else.
All of that applies to the url form. The html form has no target to check, but the document's
sub-resources go through the same guard, which is why x-render-blocked can be non-zero on a render
you sent the markup for. What you may point the renderer at is set out on the
acceptable use sheet.
How much you can send
Two ceilings sit on top of each other, and they are measured in different units. The html field is
capped at 1,000,000 characters, counted as UTF-16 code units — the length JavaScript reports for a
string. The whole request body is capped at 4 MB, counted in bytes.
The gap between those units is real and it favours you. A character above U+07FF — most of Chinese, Japanese, Korean, Thai, Devanagari, and pointed Hebrew — is a single UTF-16 code unit but three bytes of UTF-8. A document written in one of those scripts weighs about three times as much per character as the same length of Latin text, so a document at the full million-character limit can arrive as roughly 3 MB of body. The byte ceiling was set at 4 MB rather than 2 MB precisely so that a legal document is not refused for the sole reason that it was not written in English, while a runaway multi-hundred-megabyte body still is.
A body over the ceiling is answered with 413 and payload_too_large. That check runs before your key
is looked up and before any slot is taken, so an oversized request costs you no quota.
The cache, and what a repeat call costs
Successful renders are kept for 24 hours. The key is a hash of the whole request: the paper format,
orientation, background flag, scale, all four margins in a fixed order, page ranges,
preferCSSPageSize, media, waitUntil, and either the normalised URL or a hash of your HTML. Two
consequences follow. Reordering the fields of your JSON does not change the key, and neither does a
fragment on the URL, since no fragment is ever sent to a server. Changing any option at all does
change it, so a Letter version of yesterday's A4 render is a fresh render.
A cache hit is still metered, on purpose. What a plan sells is API calls, not renders, and a free
unlimited ping on a warm URL would be a different product. What a hit saves you is time, and it shows
up as x-cache: hit and a much shorter response.
Quota is counted per UTC calendar month and resets at the start of the next one. The free plan is 100 calls a month with one render in flight at a time; the paid volumes and concurrency are on the plans. Alongside the monthly figure there is a per-key burst limit on a 60-second window, which is an abuse guard rather than the thing being sold. Getting a key and making a first call is covered on getting started.
Every failure, and what to do about it
A 400 means the body did not validate — a scale outside 0.1 to 2, a margin in a unit the renderer
does not take, a paper size that does not exist, or both of url and html at once. The message
names the constraint that failed; fix the field and retry immediately.
A 401 means the key was missing, unknown or revoked. All three answer identically, on purpose.
A 429 comes in three flavours, told apart by the error code and never by the status. rate_limited
means the 60-second window is full and the body carries retryAfterSec; wait it out.
quota_exceeded means the month is spent and the body carries resetsAt; nothing but a new month or
a bigger plan clears it. concurrent_limit means this key already has as many renders in flight as
its plan allows; finish one before starting another. None of the three is billed or counted against
your quota.
A 502 is one of two things. render_failed means the page could not be rendered. render_too_large
means the finished PDF went past the maximum render size, which is usually an unbounded document —
cap it with pageRanges or a shorter data set. A 504 is render_timeout: the page did not settle
inside the budget. If the page holds a socket or a poller open forever, networkidle will never
arrive, and load or domcontentloaded is the setting that will. Both statuses are billed, because
your input was tried and the work was done.
A 503 is ours, not yours, and is never billed. busy means every render slot on the box is taken;
the response carries retry-after and a retry a second later usually succeeds. guard_unverified
means that instance's boot-time self-test has not passed, and it will not render anything until it
does.
The strip at the foot of this sheet
Below this text is the commissioning test for this endpoint: the exact request, the status it
returns, and the file it writes. It is not typed by hand into this page — it is generated from the
same catalogue entry that CI runs against the built API before this page is allowed to ship, so a
request printed here and a request that works cannot drift apart. Copy it, put your own key in
$KEY, and point it at a page of your own.
Sources
- Chrome DevTools Protocol — Page.printToPDF (chromedevtools.github.io)
- CSS Paged Media Module Level 3 (www.w3.org)
- MDN — @page (developer.mozilla.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/pdf \
-H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{"url":"https://example.com","format":"A4","printBackground":true}' \
-o page.pdf200 · application/pdf
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/pdf renders a URL or 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 |