Your app already knows how to draw an invoice. There is a route — /invoices/:id — that pulls the line items, applies the tax rules, formats the currency and renders a page a customer can read. It is behind a login, because of course it is.
Then someone asks for a PDF, and the usual answer is to build the document a second time: a templating layer for a PDF library, a second set of layout primitives, a second place for the tax rounding to drift. Six months later the HTML invoice and the PDF invoice disagree about the total and nobody can say which one is right.
There is a shorter path: keep the one template you have and let a headless browser print it. The only genuinely hard part is auth, because the page is protected and the renderer is not your user.
The pattern
Four steps, all server-side:
-
A render route — the page you already have, or a print-flavoured sibling (
/invoices/:id/print) with the nav and the download button stripped out. - A one-time credential — your backend mints a token: sixty seconds, single use, scoped to one invoice id.
- One API call — your backend POSTs the URL plus that credential to a rendering API.
- PDF bytes back — stream them, email them, or push them to object storage.
The customer's session is never involved and the token is dead before anyone could replay it.
Express, end to end
The token first. A random string in Redis with a 60-second TTL gives you real single use, because you can delete it on first read.
import crypto from "node:crypto";
const pending = new Map(); // in production: Redis, with a TTL
function mintRenderToken(invoiceId) {
const token = crypto.randomBytes(32).toString("base64url");
pending.set(token, { invoiceId, expires: Date.now() + 60_000 });
return token;
}
// The print route trusts the token and nothing else.
app.get("/invoices/:id/print", (req, res, next) => {
const key = req.get("x-render-token");
const entry = pending.get(key);
pending.delete(key); // single use
if (!entry || entry.expires < Date.now() || entry.invoiceId !== req.params.id) {
return next(); // fall through to the session check
}
res.send(renderInvoiceHtml(loadInvoice(req.params.id)));
});
Now the render call. I use snaplab for this — hosted Chromium behind one REST endpoint — but the shape is the same wherever you send it.
app.get("/invoices/:id/pdf", requireSession, async (req, res) => {
const token = mintRenderToken(req.params.id);
const r = await fetch("https://snaplab.dev/api/render", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SNAPLAB_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: `https://app.example.com/invoices/${req.params.id}/print`,
format: "pdf",
headers: { "X-Render-Token": token },
filename: `invoice-${req.params.id}.pdf`,
waitForSelector: "[data-invoice-ready]",
pdf: {
format: "A4",
media: "print",
printBackground: true,
margin: { top: "18mm", right: "14mm", bottom: "20mm", left: "14mm" },
footerTemplate: "@pageNumbers",
},
}),
});
if (!r.ok) throw new Error(`render failed ${r.status}: ${await r.text()}`);
res.type("application/pdf");
res.setHeader("content-disposition", `attachment; filename="invoice-${req.params.id}.pdf"`);
res.send(Buffer.from(await r.arrayBuffer()));
});
That is the whole integration. One template, and the PDF is by construction the document the customer sees in the browser.
Session cookies instead
Some apps are easier to hand a cookie than to teach a new header:
{
"url": "https://app.example.com/reports/q3",
"format": "pdf",
"cookies": [
{ "name": "session", "value": "…", "domain": "app.example.com", "path": "/", "secure": true, "sameSite": "Lax" }
],
"pdf": { "format": "A4", "footerTemplate": "@pageNumbersUrl" }
}
Mint a fresh read-only service session for this rather than borrowing a live customer one. A cookie you created for a render is a cookie you can revoke the second it returns.
Python
import os, requests
def invoice_pdf(invoice_id: str) -> bytes:
token = mint_render_token(invoice_id) # 60s, single use, scoped to this id
r = requests.post(
"https://snaplab.dev/api/render",
headers={"Authorization": f"Bearer {os.environ['SNAPLAB_API_KEY']}"},
json={
"url": f"https://app.example.com/invoices/{invoice_id}/print",
"format": "pdf",
"headers": {"X-Render-Token": token},
"filename": f"invoice-{invoice_id}.pdf",
"waitForSelector": "[data-invoice-ready]",
"pdf": {
"format": "A4",
"media": "print",
"printBackground": True,
"margin": {"top": "18mm", "right": "14mm", "bottom": "20mm", "left": "14mm"},
"footerTemplate": "@pageNumbers",
},
},
timeout=60,
)
r.raise_for_status()
return r.content
Sending a credential to a third party
Worth being precise about, because "just POST your session cookie somewhere" is not advice anyone should take on trust.
-
Host-scoped.
headersandbasicAuthare applied to the target host only. If the invoice page pulls a font from Google or a logo from a CDN, those subrequests go out clean — the token is not sprayed across every third-party asset.cookiesobey thedomainyou set, exactly as a browser would. - Never persisted. Credentials live in the request body, are handed to that one browser context, and go away with it. Not written to the render record, not logged.
-
Never cached. A render that carried
cookies,headersorbasicAuthis excluded from the shared response cache — two customers' statements cannot collide on a fingerprint, and nobody else can get a cache hit on your document. - Short-lived. Scope the token to one record, not to an account. A leaked one is then worthless within the minute.
Page numbers
A four-page statement with no "Page 2 of 4" on it is an accident waiting to happen. Chromium draws header/footer templates in the paper margin, substituting the classes pageNumber, totalPages, date, title and url:
"footerTemplate": "<div style='font-size:9px;width:100%;padding:0 14mm;text-align:right;color:#666'>Page <span class='pageNumber'></span> of <span class='totalPages'></span></div>"
Because that is tedious to write inline, "@pageNumbers" and "@pageNumbersUrl" are presets for the two common cases. Two gotchas: templates inherit none of the page's CSS and start near zero font-size, so always set font-size; and give pdf.margin.top/bottom room to hold them, or they are silently clipped.
The rest of the page setup lives on the same object — pageRanges, width/height for non-standard stock, tagged for an accessible structure tree, outline to turn headings into PDF bookmarks.
Print CSS still decides the pagination
Set pdf.media: "print" (the default is screen, for backwards compatibility) and your print stylesheet is live:
@page { margin: 0; } /* let pdf.margin own the paper edge */
@media print {
nav, .app-shell__sidebar, .download-btn { display: none !important; }
.invoice-line, .totals-card { break-inside: avoid; }
thead { display: table-header-group; } /* repeat headers on every page */
h2 { break-after: avoid; }
p { orphans: 3; widows: 3; }
}
display: table-header-group is the one people miss: without it, a long line-item table is labelled on page one and anonymous everywhere after.
Month-end is nine hundred invoices
Rendering those inside a request handler will time out. Queue them instead: POST /api/captures takes the same body, answers immediately, and calls a webhook per document with an HMAC signature you verify before trusting the payload. One caveat specific to this pattern — a queued render happens later, so batch tokens need minutes of life, not sixty seconds.
Five things that will bite you
-
Fonts arriving after the capture. Symptom: a PDF in Times New Roman. Wait on a marker your page sets from
document.fonts.ready(waitForSelector: "[data-invoice-ready]"), or add a smalldelayMs. -
Lazy images.
loading="lazy"below the fold may never load, because printing does not scroll. Drop the attribute for print, or scroll first. -
Cookie domain and flags. A wrong
domain, orsecure: trueagainst anhttpURL, means the cookie is silently not sent — and you get your login page as a beautifully typeset PDF. -
Redirects to login. Renderers follow them. Return
401from the print route instead of redirecting, so a bad token is an API error rather than a wrong document. -
Relative URLs when you send raw
html. There is no base to resolve against: inline the CSS, use absolute image URLs, embed small assets as data URIs.
Full write-up with the security details and the async batch flow: snaplab.dev/blog/pdf-from-protected-route. Free tier is 100 renders a month, no card, if you want to try the pattern before committing to it.
Top comments (0)