Every "generate a PDF invoice" ticket ends the same way: you already have the invoice as an HTML template that looks right in the browser, and now you need bytes you can email or store. The usual answer is Puppeteer, which drags a ~300MB Chromium download into your image, needs a pile of shared libraries on slim base images, and falls over on the first serverless deploy that has a 250MB bundle limit. The alternative is rewriting your invoice layout in a PDF drawing library, which means maintaining the same document twice.
A hosted renderer sidesteps both. You keep the HTML template, and the PDF is one HTTP GET.
One request
SnapPDF takes a public URL and returns PDF bytes:
curl -o invoice.pdf \
"https://snappdf.dedyn.io/v1/pdf?url=https://example.com"
No headers, no auth dance on the direct base URL, no JSON envelope to unwrap. The response body is the PDF.
Query params, all optional:
| Param | Values | Default |
|---|---|---|
format |
A4, Letter, etc. |
A4 |
landscape |
true / false
|
false |
background |
true / false
|
true |
scale |
number | 1 |
wait_for_selector |
CSS selector | none |
background matters more than it sounds for invoices. Chromium drops CSS backgrounds when printing by default, so your header bar and zebra-striped line items vanish unless it's on.
wait_for_selector is the one that saves you. If your invoice template fetches line items client-side, the renderer will otherwise happily print an empty table. Have the template set something like <div id="invoice-ready"> once totals are painted, and point the param at #invoice-ready.
curl -o invoice.pdf \
"https://snappdf.dedyn.io/v1/pdf?url=https%3A%2F%2Fexample.com&format=Letter&background=true&wait_for_selector=%23invoice-ready"
Node, with global fetch
Node 18+ has fetch built in, so there's no dependency at all:
import { writeFile } from "node:fs/promises";
async function renderPdf(targetUrl, opts = {}) {
const qs = new URLSearchParams({ url: targetUrl, ...opts });
const res = await fetch(`https://snappdf.dedyn.io/v1/pdf?${qs}`);
if (!res.ok) {
throw new Error(`SnapPDF ${res.status}: ${await res.text()}`);
}
const type = res.headers.get("content-type") || "";
if (!type.includes("application/pdf")) {
throw new Error(`Expected a PDF, got ${type}`);
}
return Buffer.from(await res.arrayBuffer());
}
const pdf = await renderPdf("https://example.com", {
format: "Letter",
background: "true",
});
await writeFile("invoice.pdf", pdf);
URLSearchParams handles the encoding, which you want — invoice URLs usually carry a token, and an unencoded & in there silently truncates the target.
The content-type check isn't paranoia. Any failure path that returns a JSON error will otherwise get written to disk as invoice.pdf, and you'll find out when a customer opens it.
What you actually get back
A 200 with Content-Type: application/pdf and the raw file in the body. Nothing to parse, nothing to base64-decode. Treat it as a Buffer (or a stream) and hand it to whatever comes next: S3, a Nodemailer attachment, res.send().
The one constraint worth planning around: the renderer fetches the URL over the public internet, so localhost:3000/invoices/1042 won't work and neither will a page behind your session cookie. The pattern that does work is a signed, short-lived, unauthenticated route.
The thing I'd build with it
An /invoices/:id/pdf endpoint that reuses the template you already have:
import express from "express";
import crypto from "node:crypto";
const app = express();
// Public, signed, no session required — this is what the renderer loads.
app.get("/render/invoice/:id", (req, res) => {
if (!validSignature(req.params.id, req.query.sig)) return res.sendStatus(403);
res.send(renderInvoiceHtml(req.params.id)); // your existing template
});
app.get("/invoices/:id/pdf", requireAuth, async (req, res) => {
const { id } = req.params;
const sig = crypto
.createHmac("sha256", process.env.RENDER_SECRET)
.update(id)
.digest("hex");
const target = `https://yourapp.com/render/invoice/${id}?sig=${sig}`;
const pdf = await renderPdf(target, {
format: "Letter",
background: "true",
wait_for_selector: "#invoice-ready",
});
res.type("application/pdf")
.set("Content-Disposition", `attachment; filename="invoice-${id}.pdf"`)
.send(pdf);
});
Two routes, one template, zero browser binaries. The HTML invoice stays the source of truth — when finance asks for the tax line to move, you edit CSS and both the web view and the PDF change together.
Same shape works for packing slips, signed quotes, and monthly reports. Anything where you already built the HTML and someone downstream wants a file.
Working examples and the full param list: github.com/clause-netizen/snappdf-api
Top comments (0)