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.

Your first 300 pages are free. A one-off grant on your account, not a monthly reset. Every response tells you where you stand — 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" } }'
Blocked by a bot wall? Add "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.

Loading…

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.

Drop the .html and its folders here — together or add them one at a time
A browser dialog can offer files or a folder, never both — so add 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.
for the console below. To upload a file there, change Request body from application/json to multipart/form-data.

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

FieldMeaning
source.htmlRaw markup, as JSON. Injected CSS/JS and script stripping apply to this form.
fileThe .html file itself, or a .zip of its folder, as multipart/form-data. Send options as a JSON string alongside it.
filesSeveral files at once: the document plus the assets that sit beside it.
source.urlAn 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.
Private, loopback, link-local and cloud-metadata addresses are refused — both for 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:

WayWhen to use it
Absolute URLs
https://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 folder
file=@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 once
files=@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 folder
files=@… + 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.

Pages saved from a browser. A 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

ModeReturns
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

FieldDefaultNotes
formatA4A0–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.
landscapefalse
margin"15mm"One length for all sides, or { top, right, bottom, left }.
scale10.1 – 2.
prefer_css_page_sizetrueYour @page { size: … } wins over format — the size as well as the orientation.
pages—Keep a range: "1-5", "1,3,8".
single_pagefalseEmit the whole document as one tall page.
zoom1Device scale factor.

Ink

FieldDefaultNotes
print_backgroundtrue
omit_backgroundfalseTransparent page background.
use_printtruefalse renders the screen stylesheet instead of the print one.
print_css_defaultstrueRepeats 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

FieldNotes
cssExtra CSS injected into <head>. Overrides the print defaults.
javascriptExtra JS injected before </body>.
disable_javascriptStrip <script> before rendering.
header, footerHTML 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

The renderer does not wait for a JavaScript timer on its own. If your page paints a chart after load, say so — that is a deliberate choice, because an engine that silently waits "long enough" is one that silently stops waiting long enough.
FieldNotes
wait_for_selectorWait until a selector exists: "#chart". When combined with wait_for or lazy_load_images, all conditions must complete.
wait_forWait until a JS expression is truthy: "window.ready === true".
delayWait this many milliseconds after load.
wait_for_networkDefault true. false prints without waiting for network idle.
lazy_load_imagesScroll the page, then wait for image decoding and fonts before rendering.
timeoutOverall render timeout in ms (1,000 – 600,000). Default 120,000.

Loading behaviour

FieldNotes
user_agent
http_headersObject of extra headers for the page and its sub-resources.
cookiesArray of { name, value, domain }.
auth{ username, password } — sent as HTTP basic.
raise_for_statusFail the render when the page returns an error status. true, or a list of codes.
fail_on_consoleFail on an uncaught JS exception.
fail_on_resourceFail when any sub-resource fails to load.
via_crawlersource.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

No add-on, no request. The options in this section and the next (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.
FieldNotes
taggedEmit a tagged (accessible) PDF. Roughly 17× larger, so it is off by default.
outlineBuild PDF bookmarks from the headings.
pdfa"PDF/A-1b", "PDF/A-2b", "PDF/A-3b". Adds several seconds and a lot of bytes.
pdfuaPDF/UA conformance. Implies tagged.
metadata{ "Title": "Invoice 1001", "Author": "ACME" }.

Finishing

FieldNotes
protection{ user_password, owner_password, allow_printing, allow_copying, allow_modifying, allow_annotating, allow_filling_forms, allow_assembling }.
watermarkA string for a text watermark, or { source: "image"|"pdf", base64 }. Text also takes font_size, color, opacity, rotate.
flattenFlatten 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.

EndpointWhat it does
GET /api/v1/templatesYour templates, newest first.
POST /api/v1/templatesSave one. { name, engine, body, options? }
GET /api/v1/templates/:idOne template, including its body.
PUT /api/v1/templates/:idUpdate any subset of the fields.
DELETE /api/v1/templates/:idRemove it.
POST /api/v1/templates/:id/renderRender it. { data, options?, output? } — billed as one render.

Two engines, one endpoint

enginebody isUse it when
pdfmeThe Template JSON the visual editor saves. Fixed-layout documents: invoices, certificates, labels, delivery notes. Every named box becomes a field your call fills in.
htmlA 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.
Values are escaped. {{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:

CSSDefault (Chromium)paged: true
@page margin boxes, @page :firstWorksWorks
counter(page) / counter(pages)WorksWorks
Running headers — string-set + string()
"the chapter title at the top of every page"
Silently ignoredWorks
Cross references — target-counter(attr(href), page)
a table of contents that fills in its own page numbers
Silently ignoredWorks
{
  "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:

HeaderMeaning
X-Quota-Scopegrant while you are still on the free 300, then plan.
X-Quota-LimitThe allowance for that scope.
X-Quota-RemainingWhat is left after this call.
X-Free-Pages-RemainingFree 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

StatusMeaning
400An 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.
401Missing or invalid API key.
503The 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

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.

CaseWhat was checked
Character encodingA 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 & framesA 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 fontsA Google Fonts @font-face is fetched and used, not substituted.
Content that appears lateA 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 documents200 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 featurestagged writes a structure tree, pdfa writes the PDF/A identification and output intent, outline writes bookmarks, protection encrypts.
Malformed HTMLAn unclosed tag or an unterminated attribute renders the way a browser renders it, rather than erroring.
A curl trap worth knowing. -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.