Skip to content

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

FieldTypeDefaultCeiling
urlstringnone1–2048 characters, absolute http: or https:, mutually exclusive with html
htmlstringnone1–1,000,000 characters, mutually exclusive with url
formatenumA4Letter, Legal, Tabloid, Ledger, A0A6
landscapebooleanfalse
printBackgroundbooleantrue
scalenumber10.1 to 2
marginobjectomittedtop, right, bottom, left; each a length like 10mm, 0.5in, 12px, 2.25cm
pageRangesstringomitted, meaning every pagedigits, commas, hyphens and spaces only, 40 characters
preferCSSPageSizebooleanfalse
mediaenumprintprint or screen
waitUntilenumloadload, 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

  1. Chrome DevTools Protocol — Page.printToPDF (chromedevtools.github.io)
  2. CSS Paged Media Module Level 3 (www.w3.org)
  3. MDN — @page (developer.mozilla.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/pdf \
  -H "authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"url":"https://example.com","format":"A4","printBackground":true}' \
  -o page.pdf

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

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.