If you need html to pdf page numbers on RelayPDF, put them in options.footerTemplate (or headerTemplate) as empty spans with class pageNumber and class totalPages. That is the documented path. CSS counter(page) in the body HTML is a different print feature; Chromium’s print pipeline does not treat it as a substitute for those tokens. Product page: https://relaypdf.com/html-to-pdf. Field list: https://relaypdf.com/docs/options. How the chrome itself is wired: https://relaypdf.com/blog/html-to-pdf-headers-footers.
Two mechanisms, one API
POST /v1/pdf prints HTML (or a URL, Markdown, or a published template) with Chromium. Page numbers are not a separate endpoint and not a body-source field. They appear because Chromium fills placeholders inside the header/footer fragment you pass on options. Supplying headerTemplate or footerTemplate enables that chrome. There is no displayHeaderFooter flag in the public contract.
The rendering-options table lists four placeholders: pageNumber, totalPages, date, title. Those names are Chromium class names on empty spans, the same way the Node SDK sample writes them. Field names are identical in JSON, Node, and Python. Do not invent {{page}}, {page}, url, or a first-page-only switch. They are not on https://relaypdf.com/docs/options.
| Mechanism | Where it lives | What RelayPDF documents |
|---|---|---|
| Header/footer tokens | options.headerTemplate / footerTemplate | pageNumber, totalPages, date, title |
| CSS counter(page) | Your HTML / @page CSS | Not an options field. preferCSSPageSize honors @page size only. |
Use the tokens when you want “Page 3 of 12” in the margin on every printed page. Treat counter(page) as experimental decoration inside the flow, not as the invoice footer.
What the tokens actually fill
pageNumber is the current printed page for this job. totalPages is the page count of this job after Chromium paginates the source. They update per page; you do not increment them in JavaScript. date is Chromium’s print date. title is whatever Chromium treats as the document title for that print. If the string matters, put a literal in the template HTML instead of relying on title.
pageRanges (for example 1-3,5) still uses the same footer string on the pages you print. It does not renumber those pages as 1–4 unless Chromium does; the docs do not promise remapping. One header string and one footer string per job.
Invoice footer sample
The Node SDK documents a centered footer: font-size 9px, width 100%, “Page” plus the two spans. That is the invoice footer. Pair it with extra bottom margin so Chromium does not clip the line. The SDK sample uses 20mm top and bottom. Left and right margins still apply to the body; they do not automatically inset the footer HTML.
import { RelayPDF } from "@relaypdf/sdk";
const client = new RelayPDF({
apiKey: process.env.RELAYPDF_API_KEY!,
});
const html = `
<!doctype html>
<html>
<head><title>Invoice INV-1042</title></head>
<body>
<h1>Invoice INV-1042</h1>
<p>Acme Supplies — net 30</p>
<table>
<tr><th>Item</th><th>Qty</th><th>Amount</th></tr>
<tr><td>Widget A</td><td>12</td><td>$480.00</td></tr>
</table>
</body>
</html>
`;
const pdf = await client.pdf.fromHtml(html, {
filename: "invoice-inv-1042.pdf",
options: {
format: "letter",
printBackground: true,
headerTemplate:
'<div style="font-size:9px;width:100%;text-align:center;">Invoice INV-1042</div>',
footerTemplate:
'<div style="font-size:9px;width:100%;text-align:center;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
margin: { top: "20mm", right: "12mm", bottom: "20mm", left: "12mm" },
},
});
await pdf.save("invoice-inv-1042.pdf");
The same footerTemplate works on fromUrl, fromMarkdown, and fromTemplate. A published Handlebars template fills the body with templateData; pagination chrome is still the options fragment, not a Handlebars helper. Markdown is GitHub-flavoured HTML on the same Chromium path, so the spans still increment.
curl
Binary response writes application/pdf. Auth is a bearer key on https://api.relaypdf.com. Failed renders return render_failed and are not billed. options.timeout is the Chromium render budget (max 60000 ms), not an SDK HTTP timeout.
curl https://api.relaypdf.com/v1/pdf
-H "Authorization: Bearer pdf_live_..."
-H "Content-Type: application/json"
-d '{
"html": "<h1>Invoice INV-1042</h1><p>Line items continue.</p>",
"filename": "invoice-inv-1042.pdf",
"options": {
"format": "letter",
"margin": { "top": "20mm", "bottom": "20mm", "left": "12mm", "right": "12mm" },
"footerTemplate": "<div style=\"font-size:9px;width:100%;text-align:center;\">Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
}
}'
--output invoice-inv-1042.pdf
Why CSS counter(page) is the wrong default
Paged Media defines counter(page) and counter(pages) so a box in the document (or in an @page margin) can show the current page. Chromium’s HTML-to-PDF path implements a subset of @page. RelayPDF exposes preferCSSPageSize so a CSS @page size can win over format / width / height. That option is about paper size. The docs do not say it enables running headers, @top-center, or a working page counter in the body.
A body element with content: counter(page) often prints as 0, stays on the first page only, or never increments, because the element is in the document flow, not in Chromium’s header/footer chrome. A fixed-position “footer” in your HTML is painted into the page box; it does not become a per-page margin fragment. That is why people see one “Page 1” under the last table instead of a number on every sheet.
If you still put counter(page) in a running header experiment, keep the invoice’s legal pagination on pageNumber / totalPages. Do not document a RelayPDF option that does not exist. Do not mix @page margin boxes with headerTemplate and expect one system to defer to the other. The sibling post covers the chrome contract: extra margin, inline styles, no inherited body CSS.
Layout rules that actually affect the number
- Margin must be larger than the footer fragment. 20mm bottom is the documented starting point. 10mm plus a 24px line will clip.
- Set font-size on the template HTML (the SDK uses 9px). The fragment does not inherit the invoice stylesheet or webfonts.
- width:100% and text-align:center are CSS inside the string. There is no align option.
- printBackground defaults to true. It does not draw page numbers by itself.
- format defaults to letter. A4, legal, tabloid, or a named Chromium format change pagination, which changes totalPages. That is expected.
- scale is 0.1–2. Scaling the page changes how much content fits, so the count moves. Tokens still report the printed pages.
What this is not
- Not a JavaScript page index. window.print pagination is not exposed as an API field.
- Not POST /v1/pdf/stamp. Stamp watermarks an existing PDF.
- Not image capture. POST /v1/images has no headerTemplate.
- Not different odd/even or first-page-only chrome. One pair of strings per job.
- Not a timezone on date. If you need a dated line you control, write the string into footerTemplate.
When to stop
For invoices, statements, and packed reports, footerTemplate with pageNumber and totalPages is the complete answer. Put the document id in headerTemplate or as a literal next to the tokens. Leave the body HTML free of a fake repeating header table. If a table still collides with the footer, that is a break problem, not a counter problem.
Send HTML at https://relaypdf.com/html-to-pdf. Endpoint contract: https://relaypdf.com/docs/pdf. Option names and the four tokens: https://relaypdf.com/docs/options. Node sample: https://relaypdf.com/docs/sdks/node. Header/footer chrome without the counter comparison: https://relaypdf.com/blog/html-to-pdf-headers-footers.