Chromium does not print your document’s <header> or <footer> on every page. It prints a separate header/footer HTML fragment in the page margin. On RelayPDF that fragment is options.headerTemplate and options.footerTemplate on POST /v1/pdf. Set extra top and bottom margin or Chromium clips them. The documented placeholders are pageNumber, totalPages, date, and title. That is the whole feature. Product page: https://relaypdf.com/html-to-pdf. Field list: https://relaypdf.com/docs/options.
What the option actually is
POST /v1/pdf takes exactly one source: html, url, markdown, or templateId. HTML is a UTF-8 JSON string, not Base64. Headers and footers are not a second source. They are Chromium print options, stored the same way on templates, with identical field names in JSON, Node, and Python (headerTemplate, not header_template).
The rendering-options table is explicit: headerTemplate / footerTemplate are HTML; supplying them enables Chromium headers and footers. There is no separate displayHeaderFooter flag in the public contract. If you omit both templates, you get a normal print with no chrome in the margin.
The same options object also has format (default letter; letter, A4, legal, tabloid, or a named Chromium format), landscape, printBackground (default true), preferCSSPageSize, scale (0.1–2), margin, pageRanges, width/height, waitUntil (default networkidle0), and timeout (milliseconds, max 60000). None of those generate a running page number except the header/footer templates. CSS counter(page) is a different recipe; it belongs in the sibling post on page numbers, not here.
Placeholders the docs list
RelayPDF documents four Chromium placeholders: pageNumber, totalPages, date, title. The Node SDK sample injects them as empty spans with those class names. Do not invent {{page}} mustache tokens, url, or a first-page-only flag. Those are not on https://relaypdf.com/docs/options.
| Token (class name) | What Chromium fills |
|---|---|
| pageNumber | Current printed page |
| totalPages | Page count of this job |
| date | Print date |
| title | Document title Chromium has for the job |
title is whatever Chromium treats as the document title for that print. If you care about the string, put a literal in the template HTML instead of relying on the token. date is Chromium’s date, not a timezone you pass in options.
Margins are not optional
The options page says to use extra margin so headers and footers are not clipped. margin is top / right / bottom / left as CSS lengths (10mm, 0.5in). The header lives in the top margin box; the footer in the bottom. A 10mm top margin and a 24px header will lose the bottom of the header. The SDK sample uses 20mm top and bottom. That is a starting point, not a guarantee. Measure the fragment, then set the margin larger than the fragment.
Left and right margins still apply to the body. They do not automatically inset the header HTML. The official sample sets width:100% and text-align:center on a wrapper div. If you need a left logo and a right page number, that is CSS inside the template string, not a second API field.
The sample also sets font-size:9px. Copy that habit. Chromium’s header/footer fragment is not your document stylesheet. It does not inherit body CSS, webfonts you loaded in html, or @page rules. Inline styles on the template HTML are the reliable path.
curl
Binary response writes the PDF to disk. Auth is a bearer key. The API host is https://api.relaypdf.com. Failed renders return render_failed and are not billed.
curl https://api.relaypdf.com/v1/pdf
-H "Authorization: Bearer pdf_live_..."
-H "Content-Type: application/json"
-d '{
"html": "<h1>Invoice #1042</h1><p>Line items continue for several pages.</p>",
"filename": "invoice.pdf",
"options": {
"format": "letter",
"printBackground": true,
"margin": { "top": "20mm", "right": "12mm", "bottom": "20mm", "left": "12mm" },
"headerTemplate": "<div style=\"font-size:9px;width:100%;text-align:center;\">Invoice</div>",
"footerTemplate": "<div style=\"font-size:9px;width:100%;text-align:center;\">Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
}
}'
--output invoice.pdf
The default response is application/pdf with x-relaypdf-id, x-relaypdf-size, and content-disposition. Set response to url if you want JSON with a public download that expires in 24 hours. Set response to async if you want 202 and a poll. Those modes do not change how headerTemplate is applied.
Node
Package is @relaypdf/sdk. Field names on options match REST. fromHtml sends the HTML string as JSON. The snippet below is the documented header/footer example on https://relaypdf.com/docs/sdks/node, plus a filename so the binary result has a name.
import { RelayPDF } from "@relaypdf/sdk";
const client = new RelayPDF({
apiKey: process.env.RELAYPDF_API_KEY!,
});
const pdf = await client.pdf.fromHtml("<h1>Invoice</h1>", {
filename: "invoice.pdf",
options: {
headerTemplate:
'<div style="font-size:9px;width:100%;text-align:center;">Invoice</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", bottom: "20mm" },
},
});
await pdf.save("invoice.pdf");
The same options object works on fromUrl, fromMarkdown, and fromTemplate. A public URL still has to be public; private, loopback, and metadata hosts are rejected. Markdown is GitHub-flavoured HTML printed by the same Chromium path, so the footer spans still increment. A published Handlebars template does not get a different header syntax: templateData fills the body; the chrome is still headerTemplate / footerTemplate.
What this is not
- Not CSS Paged Media running headers. preferCSSPageSize honors @page size. It does not turn @top-center into a Chromium header.
- Not a running element cloned from the body. If the invoice title is in the HTML, copy the string into headerTemplate or use the title placeholder and set a real <title>.
- Not per-page HTML. One header string and one footer string for the job. pageRanges (e.g. 1-3,5) still uses those strings on the pages you print.
- Not a stamp. POST /v1/pdf/stamp watermarks an existing PDF. Different endpoint.
- Not image capture. POST /v1/images has no headerTemplate.
A working layout
Keep the body HTML free of a fake header table you hope will repeat. It will not. Put identity in headerTemplate (name, document type). Put pagination in footerTemplate with pageNumber and totalPages. Leave at least 20mm top and bottom until you have measured a render. If a table still collides with the footer, that is a page-break problem, not a token problem — see the later how-to on breaks.
For invoices that only need “Page 3 of 12,” stop here. For a longer walk through tokens versus CSS counters, use the sibling: https://relaypdf.com/blog/html-to-pdf-page-numbers. The endpoint contract is https://relaypdf.com/docs/pdf. The option names stay on https://relaypdf.com/docs/options. Send HTML at https://relaypdf.com/html-to-pdf.