HTML to PDF API
POST /api/v1/html-to-pdf turns an HTML string, an uploaded
.html file, or a live URL into a print-ready PDF. Forty-seven rendering options, PDF/A,
e-invoice XML and password protection on the same call.
Don't want to build the markup? Design the document in the visual editor and render it with your data — see Templates.
Authenticate with your API key: Authorization: Bearer YOUR_API_KEY.
Get a key — your first 300 renders are free, no card.
After that, see plans and pricing.
Fonts are installed server-side, so your HTML does not have to ship them: Vietnamese, Chinese, Japanese, Korean, Thai, Arabic, Cyrillic and colour emoji all render. Web fonts your page loads itself (Google Fonts and the like) are fetched and embedded as usual.
freePagesRemaining in a
JSON body, or the X-Free-Pages-Remaining header on a binary response. Renders that failed
are not counted. When it runs out, pick a plan (from $5/mo for 2,500 renders, and the
same plan covers document conversion and OCR) or top up credits.Quick start — pick your input
Three ways to send a document. Same options, same output, same price. Copy one and run it.
1 · An HTML string
When your code generates the document per request — an invoice, a report, a ticket.
curl -X POST "https://api.saypdf.com/api/v1/html-to-pdf" \
-H "Authorization: Bearer $SAYPDF_KEY" \
-H "Content-Type: application/json" \
-o invoice.pdf \
-d '{
"source": { "html": "<h1>Invoice #123</h1><p>Total: $42.00</p>" },
"options": { "format": "A4", "margin": "15mm" }
}'
2 · Upload the HTML file
When the document is a file on disk. No escaping a large template into a JSON string, and no shell quoting to get wrong. Options travel as a JSON string, because multipart has no objects.
curl -X POST "https://api.saypdf.com/api/v1/html-to-pdf" \
-H "Authorization: Bearer $SAYPDF_KEY" \
-F "file=@invoice.html" \
-F 'options={"format":"A4","margin":"15mm"}' \
-o invoice.pdf
3 · A live URL
Loaded the way a browser would, so the page's own stylesheets, images and fonts resolve.
curl -X POST "https://api.saypdf.com/api/v1/html-to-pdf" \
-H "Authorization: Bearer $SAYPDF_KEY" \
-H "Content-Type: application/json" \
-o page.pdf \
-d '{ "source": { "url": "https://example.com" } }'
"options": { "via_crawler": true } and we fetch the page through our crawling
infrastructure — proxy pool, stealth browser, challenges solved — then render what it recovers.
Slower than a direct load, so use it for the pages that need it.Try it from this page
Press Render PDF below and a real PDF comes back — nothing to fill in, no signup, no key to fetch first. A key is minted for you on the first render, good for 25 renders over 24 hours. A free account gets one that does not expire, with 300.
Render some HTML right now
The usual case: a string your code builds. There is a sample below — press Render PDF and you get a PDF back, no typing, no signup. Swap in your own markup whenever you like.
All options — … the API accepts, nothing hidden
Everything here comes straight from the live OpenAPI spec. Leave a field empty and it is not sent, so the API's own default applies.
Or upload a file, a folder, or a .zip
A page is rarely one file. Give it the whole folder — the CSS, images, fonts and scripts
beside it come along and href="css/style.css" resolves exactly as it does on disk.
Uses the test key above; same endpoint as everything else, only the input differs.
index.html, then add its css/ and img/ folders.
Everything you add stays in the list. Or take the parent folder in one go, or a
.zip of it.
application/json to
multipart/form-data.
That worked — here is what it costs to keep going
The test key stops at 25 renders, or tomorrow. A plan continues from the same endpoint with the same header: nothing in the code you just ran has to change.
Plans — subscribe without leaving this page
Your first 300 renders are free. After that, pick the plan that covers your pace. HTML → PDF consumes one render per request; document conversion and OCR use a separate page allowance. The per-render figure is the monthly price divided by the renders it includes — the number to compare against anyone else's.
Loading plans…
Already subscribed? Manage your subscription. Prefer to buy in blocks instead of monthly? Top up credits.
Input — exactly one
| Field | Meaning |
|---|---|
source.html | Raw markup, as JSON. Injected CSS/JS and script stripping apply to this form. |
file | The .html file itself, or a .zip of its folder, as multipart/form-data. Send options as a JSON string alongside it. |
files | Several files at once: the document plus the assets that sit beside it. |
source.url | An absolute, publicly reachable http(s) URL. The page loads from its own origin, so relative stylesheets, images and links resolve normally. Add via_crawler for pages that block a plain load. |
source.url and for sub-resources referenced from inside your HTML.Stylesheets, images and fonts
An HTML file almost never travels alone. There are three ways to make
href="css/style.css" resolve, and all three work:
| Way | When to use it |
|---|---|
Absolute URLshttps://cdn.../style.css |
Simplest, and nothing to upload. Your CSS, fonts and images are fetched from the public web as a browser would. Works with any CDN, and with Google Fonts. |
A .zip of the folderfile=@site.zip |
The one that handles a real folder on disk. Paths inside the zip keep their meaning, so
css/style.css resolves. index.html is the entry point if present,
otherwise the shallowest .html. A zip of a folder is rebased on that
folder, so the file your operating system produces when you compress a directory works
as-is. |
Several files at oncefiles=@page.html files=@style.css |
Fine for assets that sit directly beside the document. The multipart parser strips
directories from filenames, so this alone cannot express css/style.css. |
A folderfiles=@… + paths=[…] |
Send the files plus paths — a JSON array of relative paths, one per file, in the
same order. In a browser that is each file's webkitRelativePath, so an
<input type="file" webkitdirectory> works directly. Renders identically to a
zip of the same folder. |
A .zip here means the document and the folders it references, zipped exactly
as they sit on disk — nothing has to be rearranged:
site.zip index.html <- the entry point (or the shallowest .html if there is no index) css/style.css <- href="css/style.css" resolves, because the path is kept js/app.js img/logo.png
Zipping the folder works too: site/index.html, site/css/style.css …
is rebased on site/, so what your operating system produces when you right-click a
folder and compress it is already the right shape.
curl -X POST https://api.saypdf.com/api/v1/html-to-pdf \
-H "Authorization: Bearer $SAYPDF_KEY" \
-F "file=@site.zip" \
-F 'options={"format":"A4","margin":"15mm"}' \
-o out.pdf
The same folder, from a browser's directory picker:
const fd = new FormData();
const paths = [];
for (const f of input.files) { // <input type="file" webkitdirectory>
fd.append("files", f);
paths.push(f.webkitRelativePath); // "site/css/style.css"
}
fd.append("paths", JSON.stringify(paths));
fd.append("options", JSON.stringify({ format: "A4" }));
await fetch("https://api.saypdf.com/api/v1/html-to-pdf", {
method: "POST", headers: { Authorization: `Bearer ${key}` }, body: fd,
});
Limits: 200 files and 30 MB of assets per render. A missing asset does not fail the
render by default — the page is drawn without it, exactly as a browser would. Set
fail_on_resource if you would rather be told.
File → Save page as
export works: give us the .html and its _files folder, or a zip of both.
Tick saved page in the console above, or send these three options:
{"disable_javascript": true, "use_print": false, "scale": 0.55}.
A saved page is a snapshot, and its own scripts are what ruin it: re-run without the site behind them, a single-page app re-renders the snapshot away — typically into a logged-out screen — and points every image back at an address on the original server that your folder does not contain. Turning the page's JavaScript off keeps what you saved.
use_print off
uses the stylesheet the page was designed with, and scale fits a desktop-width layout
onto the paper.
Those exports also turn every embed into an
<iframe>. A frame pointing at
a file that is not in the upload renders blank rather than failing — unlike an image, a
frame is loaded as a document, and Chromium treats a missing one as fatal. A frame you did upload
renders normally, and an absolute one is left to load over the network.Output — output = inline · url · base64
| Mode | Returns |
|---|---|
inline (default) | The PDF bytes, streamed back. Headers carry X-Pages and X-Free-Pages-Remaining. X-Pages is what the call cost — one render is one unit whatever the document's page count, so a three-page PDF still reports 1. It is not the number of pages in the file. |
url | { success, url, pages, freePagesRemaining } — a hosted link to the result. |
base64 | { success, filename, data, pages, freePagesRemaining } — the bytes as a base64 string. |
Also accepted at the top level: filename (used in Content-Disposition) and
clientReference (echoed back for your own correlation).
Rendering options
All optional. The defaults are A4 portrait, 15 mm margins, backgrounds printed, print stylesheet, no header or footer, untagged.
Paper
| Field | Default | Notes |
|---|---|---|
format | A4 | A0–A6, Letter, Legal, Tabloid, Ledger, a "{w}x{h}" pair such as "210mmx297mm", or "auto" to fit the sheet to the content. |
width, height | — | Override format. Units: in, cm, mm, pt, pc; a bare number is CSS px. |
landscape | false | |
margin | "15mm" | One length for all sides, or { top, right, bottom, left }. |
scale | 1 | 0.1 – 2. |
prefer_css_page_size | true | Your @page { size: … } wins over format — the size as well as the orientation. |
pages | — | Keep a range: "1-5", "1,3,8". |
single_page | false | Emit the whole document as one tall page. |
zoom | 1 | Device scale factor. |
Ink
| Field | Default | Notes |
|---|---|---|
print_background | true | |
omit_background | false | Transparent page background. |
use_print | true | false renders the screen stylesheet instead of the print one. |
print_css_defaults | true | Repeats a table's <thead> on every page, keeps rows and headings off page breaks, prints colours as authored. Your own CSS still wins. Set false to own the print CSS yourself. |
Content
| Field | Notes |
|---|---|
css | Extra CSS injected into <head>. Overrides the print defaults. |
javascript | Extra JS injected before </body>. |
disable_javascript | Strip <script> before rendering. |
header, footer | HTML for the running header/footer. Absent by default. Use the classes pageNumber, totalPages, date, title, url. Leave room for them with margin. |
Waiting for the page
| Field | Notes |
|---|---|
wait_for_selector | Wait until a selector exists: "#chart". When combined with wait_for or lazy_load_images, all conditions must complete. |
wait_for | Wait until a JS expression is truthy: "window.ready === true". |
delay | Wait this many milliseconds after load. |
wait_for_network | Default true. false prints without waiting for network idle. |
lazy_load_images | Scroll the page, then wait for image decoding and fonts before rendering. |
timeout | Overall render timeout in ms (1,000 – 600,000). Default 120,000. |
Loading behaviour
| Field | Notes |
|---|---|
user_agent | |
http_headers | Object of extra headers for the page and its sub-resources. |
cookies | Array of { name, value, domain }. |
auth | { username, password } — sent as HTTP basic. |
raise_for_status | Fail the render when the page returns an error status. true, or a list of codes. |
fail_on_console | Fail on an uncaught JS exception. |
fail_on_resource | Fail when any sub-resource fails to load. |
via_crawler | source.url only. Fetch the page through our crawling infrastructure (proxies, stealth browser, challenge solving) before rendering. For pages a plain load cannot reach. |
PDF flavour
pdfa, pdfua, protection, watermark,
flatten, optimize, facturx) are on for every key, including free
ones, and cost the same one render as a plain page. If the PDF engine is ever down the API answers
503 and names the option, rather than returning a PDF that silently lacks it.| Field | Notes |
|---|---|
tagged | Emit a tagged (accessible) PDF. Roughly 17× larger, so it is off by default. |
outline | Build PDF bookmarks from the headings. |
pdfa | "PDF/A-1b", "PDF/A-2b", "PDF/A-3b". Adds several seconds and a lot of bytes. |
pdfua | PDF/UA conformance. Implies tagged. |
metadata | { "Title": "Invoice 1001", "Author": "ACME" }. |
Finishing
| Field | Notes |
|---|---|
protection | { user_password, owner_password, allow_printing, allow_copying, allow_modifying, allow_annotating, allow_filling_forms, allow_assembling }. |
watermark | A string for a text watermark, or { source: "image"|"pdf", base64 }. Text also takes font_size, color, opacity, rotate. |
flatten | Flatten form fields and annotations into the page. |
optimize | { optimize_images, image_quality }. |
facturx | { xml, conformance } — embed a ZUGFeRD / Factur-X invoice. See below. |
All of it is billed the same: one render uses one HTML/API render allowance, whatever the page count and whatever finishing it asks for.
E-invoices (ZUGFeRD / Factur-X)
Pass your CII XML and the result is a PDF/A-3 with the invoice embedded as
factur-x.xml, the correct AFRelationship and the Factur-X XMP extension
schema — a file a German or French buyer's system will accept.
{
"source": { "html": "<h1>Rechnung RE-2026-1001</h1>" },
"options": {
"facturx": {
"xml": "<rsm:CrossIndustryInvoice>…</rsm:CrossIndustryInvoice>",
"conformance": "EN 16931"
}
}
}
Profiles: MINIMUM, BASIC WL, BASIC, EN 16931
(default), EXTENDED, XRECHNUNG.
See the e-invoicing API docs for generating the XML itself.
JavaScript
const res = await fetch("https://api.saypdf.com/api/v1/html-to-pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAYPDF_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
source: { html: renderInvoiceHtml(order) },
options: {
format: "A4",
margin: { top: "25mm", bottom: "20mm", left: "15mm", right: "15mm" },
footer: '<div style="text-align:center"><span class="pageNumber"></span> / <span class="totalPages"></span></div>',
wait_for_selector: "#chart",
},
}),
});
if (!res.ok) throw new Error(await res.text());
console.log("free pages left:", res.headers.get("X-Free-Pages-Remaining"));
const pdf = Buffer.from(await res.arrayBuffer()); // store / email
Python
import requests
r = requests.post(
"https://api.saypdf.com/api/v1/html-to-pdf",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"source": {"html": html_string},
"options": {"format": "A4", "margin": "15mm", "pdfa": "PDF/A-3b"},
},
)
r.raise_for_status()
print("free pages left:", r.headers.get("X-Free-Pages-Remaining"))
open("out.pdf", "wb").write(r.content)
Templates — design once, render with data
Save a document design and render it with a row of data, instead of building the markup on every call. Design it in the visual editor — drag text, images, tables and barcodes onto the page — or write it as HTML. Templates are free; only rendering one is billed, at the same one render as everything else.
| Endpoint | What it does |
|---|---|
GET /api/v1/templates | Your templates, newest first. |
POST /api/v1/templates | Save one. { name, engine, body, options? } |
GET /api/v1/templates/:id | One template, including its body. |
PUT /api/v1/templates/:id | Update any subset of the fields. |
DELETE /api/v1/templates/:id | Remove it. |
POST /api/v1/templates/:id/render | Render it. { data, options?, output? } — billed as one render. |
Two engines, one endpoint
engine | body is | Use it when |
|---|---|---|
pdfme | The Template JSON the visual editor saves. | Fixed-layout documents: invoices, certificates, labels, delivery notes. Every named box becomes a field your call fills in. |
html | A mustache string. | Flowing documents, or when you already think in HTML/CSS. Filled with your data, then
rendered through exactly the pipeline above — so every option, asset, zip upload,
paged and PDF/A applies unchanged. |
{{name}} containing <script>
renders as text, not as markup — these documents get emailed onward, which is a poor place to find
out otherwise. Use {{{raw}}} deliberately when you mean markup.# save an HTML template with a repeating line-item table
curl -X POST https://api.saypdf.com/api/v1/templates \
-H "Authorization: Bearer $SAYPDF_KEY" -H "Content-Type: application/json" \
-d '{
"name": "Invoice",
"engine": "html",
"body": "<h1>Invoice {{number}}</h1><table>{{#lines}}<tr><td>{{name}}</td><td>{{total}}</td></tr>{{/lines}}</table>",
"options": { "format": "A4", "margin": "18mm" }
}'
# render it
curl -X POST https://api.saypdf.com/api/v1/templates/<id>/render \
-H "Authorization: Bearer $SAYPDF_KEY" -H "Content-Type: application/json" \
-d '{ "data": { "number": "RE-1001", "lines": [ {"name":"Consulting","total":"1.200,00 €"} ] } }' \
-o invoice.pdf
options saved with the template are the defaults; anything you send on the render call
overrides them for that call only — so one page in landscape does not need a second template. For a
pdfme template, sending data as an array of objects renders one
document per entry into a single PDF, which is what a run of 200 certificates is.
Managing templates works with an API key or a signed-in session, because both are real: you edit a design in the browser and render it from a server. Rendering needs an API key.
Long documents — paged, async, webhooks
paged: true — real CSS Paged Media
Pagination starts after your readiness conditions, optional delay, fonts and images complete. Conversion then waits for pagination to finish.
Runs the document through Paged.js before printing. What that buys, measured against this API rather than quoted from a spec:
| CSS | Default (Chromium) | paged: true |
|---|---|---|
@page margin boxes, @page :first | Works | Works |
counter(page) / counter(pages) | Works | Works |
Running headers — string-set + string()"the chapter title at the top of every page" |
Silently ignored | Works |
Cross references — target-counter(attr(href), page)a table of contents that fills in its own page numbers |
Silently ignored | Works |
{
"source": { "html": "<style>@page{@top-center{content:string(ch)}} h2{string-set:ch content()}</style>…" },
"options": { "paged": true }
}
paged applies to source.html and uploads only — the polyfill has to be
injected into the document, which is impossible for a source.url page loaded by the
browser. Margins are handled by your @page rule; the engine prints edge to edge so they
are not applied twice. Expect a second or two more on a long document.
async: true and webhookUrl
A long render should not hold your connection open — every proxy and HTTP client in the chain is entitled to time out on it, and a render that has already been paid for is then thrown away.
curl -X POST https://api.saypdf.com/api/v1/html-to-pdf \
-H "Authorization: Bearer $SAYPDF_KEY" -H "Content-Type: application/json" \
-d '{"source":{"html":"…"},"async":true,"webhookUrl":"https://you.example.com/hook"}'
{ "success": true, "status": "processing", "fileId": "…",
"statusUrl": "…/api/v1/convert/<id>/status", "resultUrl": "…/api/v1/convert/<id>/result" }
Poll statusUrl, or give a webhookUrl and we POST
{ fileId, clientReference, success, status, url } when it finishes — including on
failure, with error. Billed exactly like a synchronous call. The webhook must be a
public http(s) address: private, loopback and cloud-metadata addresses are refused, because a
webhook is an address we connect to.
Knowing what is left
Every metered response carries the allowance, so you never have to poll anything or open a dashboard to find out you are close:
| Header | Meaning |
|---|---|
X-Quota-Scope | grant while you are still on the free 300, then plan. |
X-Quota-Limit | The allowance for that scope. |
X-Quota-Remaining | What is left after this call. |
X-Free-Pages-Remaining | Free grant left, on /html-to-pdf. |
They are on refusals too, so a 400 for quota tells you how far over you are — and the
body names the plan that covers your current pace, rather than sending you to a pricing table.
Programmatic view of the same numbers: GET /api/v1/account/quota, and
GET /api/v1/account/upgrade-advice for the recommendation.
Errors
| Status | Meaning |
|---|---|
400 | An option is invalid, the source is missing or ambiguous, or your quota is exhausted. The body names the option — including when a rendering option was put beside source instead of inside options, which is otherwise easy to miss. Options are validated before metering, so a typo costs you nothing. |
401 | Missing or invalid API key. |
503 | The rendering engine is unavailable, or you asked for something only it can apply (PDF/A, Factur-X, encryption, watermark). Nothing is returned rather than a PDF quietly missing what you asked for. |
Other conversions
POST /api/v1/convert handles everything else — Word,
Excel, PowerPoint, images and SVG to PDF, PDF to Office formats, OCR. It also still accepts
typeConvert: "html_to_pdf" for existing integrations, but new work should use
/api/v1/html-to-pdf: it renders synchronously instead of queueing, documents and
validates the full option surface above, and takes a URL source directly.
Notes
- Max input size: 60 MB.
- One render consumes one HTML/API render allowance, regardless of page count or finishing steps.
inlineandbase64responses are not stored.urloutput is stored so the link works, and deleted on the retention schedule in our privacy policy.- Rate limits and plan quotas are per account — see plans.
What we have actually measured
Every row below was rendered against this API and checked in the resulting PDF — text extracted, image objects counted, page boxes measured — not assumed from a feature list. The ones that failed were fixed; they are listed too, because a list with no failures in it is not a measurement.
| Case | What was checked |
|---|---|
| Character encoding | A file that declares
windows-1252, ISO-8859-1 or Shift_JIS is decoded the
way the file says, not as UTF-8. This was broken and is fixed — pages outside
English rendered as replacement characters, at 201. |
<base href> | References the upload contains resolve to the upload; references it does not contain resolve against the base, as a browser does. This was broken and is fixed — a stylesheet sitting right there was ignored. |
| Scripts & frames | A missing frame renders blank instead of failing the render. A saved page's scripts are turned off for it. Both were broken and are fixed. |
| Fonts & scripts (writing systems) | Chinese, Japanese, Korean, Vietnamese diacritics and colour emoji render as glyphs, not boxes. Arabic joins correctly and lays out right-to-left; Hebrew likewise. |
| Web fonts | A Google Fonts @font-face is fetched and used, not
substituted. |
| Content that appears late | A canvas painted by JavaScript after 400 ms is
blank without a wait — as it is in a browser — and present with any of delay,
wait_for or wait_for_selector. |
| Links | <a href> becomes a real PDF link annotation, external
and in-document both. |
| Long documents | 200 pages in about two seconds. A <thead>
repeats on every page of a table that spans three. |
| Page geometry | @page { size: A5 landscape } with
prefer_css_page_size produces a 210×148 mm page. pages: "2-3"
returns exactly those two. position: fixed repeats on every page. |
| PDF features | tagged writes a structure tree, pdfa
writes the PDF/A identification and output intent, outline writes bookmarks,
protection encrypts. |
| Malformed HTML | An unclosed tag or an unterminated attribute renders the way a browser renders it, rather than erroring. |
-F treats ;
as a parameter separator, so -F 'options={"header":"<div style=\"font-size:9px;…\">"}'
is silently cut in half and comes back as “Expected JSON in that field”. Use
--form-string for any option value containing a semicolon.