Send HTML in one request
Your app already knows how to build HTML. It does it for every web page it serves. An HTML to PDF API lets you reuse that skill for paper: you post the markup and a stylesheet, and the response is the PDF file itself.
The request body needs one field, html. Add css for styles, and page options such as format and margins if the defaults do not suit you. Here is a short packing list sent from the command line:
curl https://api.castpdf.com/v1/pdf \
-H "Authorization: Bearer $CASTPDF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Packing list</h1><table><thead><tr><th>SKU</th><th>Item</th><th>Qty</th></tr></thead><tbody><tr><td>LMP-204</td><td>Brass desk lamp</td><td>2</td></tr></tbody></table>",
"css": "body { font-family: Noto Sans, sans-serif; } th { text-align: left; }",
"format": "Letter",
"margins": "18mm"
}' \
--output packing-list.pdfReal documents are built from data. Send a data object with the HTML and the HTML becomes a Liquid template, so loops and filters run before the page is printed. The next two examples make a multi-page sales table with a page counter in the footer.
import { writeFile } from 'node:fs/promises';
const html = `<h1>September sales</h1>
<table>
<thead><tr><th>Date</th><th>Store</th><th>Revenue</th></tr></thead>
<tbody>
{% for row in rows %}<tr><td>{{ row.date }}</td><td>{{ row.store }}</td><td>{{ row.revenue | money: "EUR", "pt-PT" }}</td></tr>{% endfor %}
</tbody>
</table>`;
const css = '@page { @bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 8pt; } } th { text-align: left; }';
const rows = [
{ date: '2026-09-01', store: 'Lisbon Baixa', revenue: 18420.5 },
{ date: '2026-09-01', store: 'Porto Ribeira', revenue: 9310 },
];
const res = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.CASTPDF_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ html, css, data: { rows }, mode: 'print', format: 'A4', margins: '20mm 18mm 24mm' }),
signal: AbortSignal.timeout(60_000),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
await writeFile('september-sales.pdf', Buffer.from(await res.arrayBuffer()));
console.log('pages:', res.headers.get('x-pages'));import os
import requests
html = """
<h1>September sales</h1>
<table>
<thead><tr><th>Date</th><th>Store</th><th>Revenue</th></tr></thead>
<tbody>
{% for row in rows %}<tr><td>{{ row.date }}</td><td>{{ row.store }}</td><td>{{ row.revenue | money: "EUR", "pt-PT" }}</td></tr>{% endfor %}
</tbody>
</table>
"""
css = '@page { @bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 8pt; } }'
rows = [
{"date": "2026-09-01", "store": "Lisbon Baixa", "revenue": 18420.5},
{"date": "2026-09-01", "store": "Porto Ribeira", "revenue": 9310},
]
res = requests.post(
"https://api.castpdf.com/v1/pdf",
headers={"Authorization": f"Bearer {os.environ['CASTPDF_API_KEY']}"},
json={"html": html, "css": css, "data": {"rows": rows}, "mode": "print", "format": "A4", "margins": "20mm 18mm 24mm"},
timeout=60,
)
res.raise_for_status()
with open("september-sales.pdf", "wb") as f:
f.write(res.content)
print("pages:", res.headers["x-pages"])By default the body of the response is the PDF. The x-pages header tells you how many pages it has, and x-document-id lets you look the document up later. Prefer a link? Add "response": "url" and you get JSON with a signed download address that works without your key until it expires.
There is no package to install. Any HTTP client works, and the Node.js and Python pages add error handling and retries for production.
Long documents that behave like paper
Page one is easy. The trouble starts when a table runs past the bottom edge. A row gets sliced in half, the column names vanish on page two, and nobody can tell page 3 from page 7. That is the most common complaint about HTML to PDF tools, and it is what print mode is built to fix.
- Headers that repeat. Put your column names in a
<thead>and they appear again at the top of every continued page. The repeating table header guide shows the markup. - Rows that stay whole. Table rows and images avoid splitting across pages by default. Add the class
keep-togetherto any block that must stay on one page, such as a totals box or a signature. - Page numbers in the margin. "Page 2 of 6" comes from standard CSS margin boxes and the
pageandpagescounters. See page numbers in HTML to PDF. - Breaks where you want them.
break-before: pagestarts a chapter on a fresh page, and the olderpage-break-*rules work too. The page breaks guide covers the edge cases. - Running headers. A chapter title can follow the reader across pages with
string-set, a feature that browsers alone do not offer.
Print mode also protects you from silent damage. If some content would be cut off, the request fails with content_lost and tells you why. You never receive a PDF with a missing paragraph that nobody notices until a customer does.
Coming from Puppeteer’s headerTemplate and footerTemplate? The Puppeteer header and footer guide shows how the same headers look as print CSS.
Fast mode or print mode
Every request runs in one of two modes. HTML defaults to fast mode, because many HTML documents are short. Switch to print mode with "mode": "print" when the document needs the features above.
| Fast mode | Print mode | |
|---|---|---|
| How pages are made | Chromium’s own print engine | Paged.js lays out the pages first, then Chromium prints them |
| Default for | Requests that send html | Requests that send a template_id |
| Page size, margins and break rules | Yes | Yes |
| Repeating table headers | Chromium’s built-in behaviour | CastPDF’s own handler, tested on long invoices |
| Page counters and margin boxes | Use print mode for these | Yes |
| Running headers and page numbers in a table of contents | No | Yes |
| Stops a PDF with cut-off content | No | Yes, with content_lost |
| Speed | Quicker, most of all on long documents | A little slower, because of the layout step |
A simple rule works well. Use fast mode for one-page letters, labels and receipts. Use print mode for anything with a long table, a footer with page numbers or chapters.
Modern CSS works, because it is Chromium
The renderer is the same engine as the Chrome browser. Flexbox, grid, custom properties, calc(), web fonts and SVG all behave as they do on screen. If your layout looks right in Chrome’s print preview, it will look very close to that in the PDF.
Backgrounds and colours print exactly as written, with no extra setting. JavaScript in your page runs before printing, within the same time limit, so a small script that formats dates or draws an SVG still works. An error in your script does not stop the render.
Utility CSS is fine too. If you use a framework such as Tailwind, send the compiled stylesheet in css or link it from a public address. Keep the file lean: the whole document, HTML and CSS together, may be up to 5 MB after any Liquid has run.
Page size, margins and orientation
Without any options you get A4 portrait pages with 20mm margins. These request fields change that for one document:
| Field | Values | Notes |
|---|---|---|
format | "A4", "A5", "Letter", "Legal", or { "width": "100mm", "height": "150mm" } | A custom size takes lengths such as millimetres or inches. |
orientation | "portrait" or "landscape" | Turns a named format on its side. A custom size in landscape puts its wider side first. |
margins | One to four lengths, such as "20mm" or "1in 0.75in" | Works like the CSS margin shorthand, in mm, cm, in, px or pt. |
mode | "fast" or "print" | Fast is the default for HTML. |
filename | Up to 100 characters | The name the browser suggests when someone downloads the file. |
These options become an @page rule placed before your CSS. An @page size or margin written in your own CSS wins. Keep one in your stylesheet only if the document must always print the same way, whatever the request says.
@page {
size: A4;
margin: 22mm 18mm 26mm;
@bottom-center { content: "Page " counter(page) " of " counter(pages); font-size: 8pt; color: #64748b; }
}
@page :first {
@bottom-center { content: none; }
}
.cover { break-after: page; }
.totals { break-inside: avoid; }Margin boxes live inside the page margin, so give the bottom margin enough room for the footer text. The print CSS reference lists every margin box and counter.
Fonts and images
Missing fonts are the second classic PDF bug. The preview shows your brand font, the server falls back to Times, and the invoice looks like a ransom note. CastPDF avoids most of this, because common font families are already installed on the renderer.
- Installed and ready: Noto Sans and Noto Serif with the Noto families for other scripts, Noto CJK for Chinese, Japanese and Korean, Noto Color Emoji, Liberation and DejaVu.
- Any other font: link a stylesheet from a public font service, or write an
@font-facerule with a publichttpsaddress. The renderer waits for fonts to load before it prints. - Fully offline: embed the font file as a
data:URL. It needs no network request, but it does make the HTML bigger. - Images: use public
httporhttpsaddresses, ordata:URLs. Each document may load up to 300 resources and 20 MB in total. - Never reachable:
localhost, private network addresses and files behind a login. The renderer refuses them on purpose, to keep your network safe.
A refused or failed image does not fail the whole document. It is simply missing, and the response counts it in the x-blocked-resources header. A plain 404 from your own server is not counted there, so open your test PDFs and look. The fonts in HTML to PDF guide goes deeper on loading and fallbacks.
Free test PDFs and the Free plan
Test keys start with cpdf_test_. They make unlimited PDFs for free, with a diagonal "CASTPDF TEST" watermark on each page, at up to 20 requests a minute. Use them while you build and in your automated tests.
Live keys start with cpdf_live_ and make clean PDFs. The Free plan includes 100 live PDFs a month with no card, with a small "Made with CastPDF" line at the bottom of each page. Starter costs $19 a month for 2,500 PDFs, and Growth costs $49 for 10,000.
You pay per finished document, never per page. A 40-page report counts the same as a one-page receipt. Failed requests are never counted. The pricing page lists every plan.
HTML or a stored template: which to pick
The same endpoint takes either raw HTML or the ID of a template saved in CastPDF. Both use the same renderer and the same print features. The difference is where the design lives and who can change it.
| Send `html` | Send a `template_id` | |
|---|---|---|
| Where the layout lives | In your code, next to the rest of your app | In CastPDF, with a live preview editor |
| Who can change it | Developers, with a deploy | Anyone with dashboard access, without a deploy |
| Data | Optional: add data to run Liquid on the HTML | Required in practice: the template fills itself from your JSON |
| History | Your own version control | Versions saved on every change, the latest 50 kept, and one can be pinned |
| Default mode | Fast | |
| Good for | Pages your app already renders, one-off exports, quick tests | Invoices, certificates and other documents you make every day |
Many teams start with HTML and move the busiest document into a template later. The template editor has a Simple mode form for the sample data, so a colleague can check a change without touching code. The design itself stays HTML and CSS. The templates documentation covers versions and pinning.
Migrating from wkhtmltopdf or Puppeteer
Most people who look for an HTML to PDF API already make PDFs somehow. The move is usually small, because your HTML stays the same. What changes is who runs the browser.
From wkhtmltopdf
The wkhtmltopdf project was archived in January 2023, and its engine is an old version of WebKit. Layouts written around its quirks often used floats and tables instead of flexbox or grid. Those layouts still render in Chromium, so you can migrate first and modernise later.
- Page size and orientation flags become the
formatandorientationfields. - The four margin flags become one
marginsstring, such as"15mm 12mm". - Header and footer options become CSS margin boxes, rendered in print mode.
- Fonts installed on your old server may not exist here. Check the installed list above, or load them from a public address.
Our wkhtmltopdf alternatives page compares the self-hosted and hosted options side by side.
From Puppeteer
Puppeteer gives you full control of a browser. You also own its memory use, its crashes and its fonts. When you call CastPDF instead, the options of page.pdf() map onto request fields:
| Puppeteer | CastPDF request |
|---|---|
page.setContent(html) | "html": "...", plus "css" if you keep styles apart |
format: "A4" | "format": "A4" |
landscape: true | "orientation": "landscape" |
margin: { top, right, bottom, left } | "margins": "top right bottom left" as one string |
printBackground: true | Not needed: backgrounds always print |
headerTemplate and footerTemplate | @page margin boxes with "mode": "print" |
One difference matters. CastPDF takes HTML, not a web address, so there is no field for "open this URL". If you used page.goto() on your own pages, render that HTML on your server and send it. The CastPDF vs Puppeteer page weighs up when self-hosting is still the better choice.
Before you go live
- Keep the key on your server. Never put an API key in browser or mobile code. Your backend calls CastPDF and hands the PDF or the link to the user.
- Allow enough time. A render may take up to 30 seconds, so set your HTTP client timeout to at least 60 seconds.
- Know the ceilings. A document can have up to 50 pages and 40 MB, and a request body up to 5 MB.
- Retry the right errors. Wait for the
Retry-Afterheader on429, back off on503, and retry network errors with the sameIdempotency-Key. - Read the error code. Every error returns a code, a message and a link to its documentation, so your logs say what went wrong.
The quickstart gets a first PDF out in a few minutes, and the API reference lists every field and error.