You already have an invoice template. It is HTML and CSS, it renders correctly in a browser, and the accountant wants a PDF. So you install Puppeteer, and now your container carries a Chromium build, your serverless bundle blows past the size limit, and you own a font package and a pile of shared libraries you never asked for.
The rendering is not the hard part. Owning the browser is. SnapPDF takes a public URL and hands back PDF bytes, so the browser lives on someone else's box.
One GET, raw bytes back
The whole API surface is a single endpoint:
GET /v1/pdf?url=https://EXAMPLE.com
Point it at the URL where your invoice already renders:
curl -o inv-2043.pdf \
--get "https://snappdf.dedyn.io/v1/pdf" \
--data-urlencode "url=https://example.com/invoices/inv-2043" \
--data-urlencode "format=A4" \
--data-urlencode "background=true" \
--data-urlencode "wait_for_selector=#invoice-total"
Use --get with --data-urlencode rather than pasting a query string. Your invoice URL probably carries a signed token, and an unescaped & will silently truncate it.
The optional params: format (A4 by default; A3, A5, Letter, Legal, and Tabloid also work), landscape, background, scale, and wait_for_selector.
From Node, with global fetch
No SDK. Build the URL, read the body as an ArrayBuffer.
import { writeFile } from 'node:fs/promises';
async function renderInvoicePdf(invoiceUrl) {
const endpoint = new URL('https://snappdf.dedyn.io/v1/pdf');
endpoint.searchParams.set('url', invoiceUrl);
endpoint.searchParams.set('format', 'A4');
endpoint.searchParams.set('background', 'true');
endpoint.searchParams.set('wait_for_selector', '#invoice-total');
const res = await fetch(endpoint);
// Success is application/pdf. Failure is a JSON body with an `error` field.
if (!res.ok) throw new Error(`snappdf ${res.status}: ${await res.text()}`);
return Buffer.from(await res.arrayBuffer());
}
const pdf = await renderInvoicePdf('https://example.com/invoices/inv-2043');
await writeFile('inv-2043.pdf', pdf);
console.log(pdf.subarray(0, 5).toString()); // %PDF-
Two things trip people up here. The response is not JSON, so res.json() throws on the happy path. And a 200 with a body that does not start with %PDF- means you rendered someone's login wall, not your invoice.
What the response actually is
Content-Type: application/pdf, and the body is the file. Nothing to unwrap, no base64, no job ID to poll. You can stream it straight to S3, pipe it into a res.send(), or attach it to an email without touching disk.
background=true matters more than it sounds. Chromium drops background colors when printing by default, which turns the colored header row on your line-item table into white on white. Turn it on and your CSS survives.
The thing worth building
Wire it into invoice generation instead of calling it by hand.
When an invoice moves to finalized, mint a short-lived signed URL for the HTML view you already render for the customer, pass that URL to SnapPDF, and write the returned bytes to object storage keyed by invoice ID. The PDF is now a cached artifact, not a render you repeat on every download click. Your template stays one HTML file that designers can edit, and the PDF follows it.
The important detail: your invoice route has to be reachable from the public internet. SnapPDF refuses loopback, private, and link-local addresses, so http://localhost:3000/invoices/1 returns an error rather than a PDF. In development, run a tunnel. In production, a signed URL that expires in sixty seconds is enough.
Rough edges I hit
wait_for_selector is best effort. If the selector never appears, you still get a PDF of whatever managed to load, which is the right behavior for a rendering service and the wrong behavior if you assumed it would fail loudly. Check for your total in the output before you email it to a customer.
scale is clamped to Chromium's own 0.1 to 2.0 range. Values outside that get pulled back in rather than rejected.
Long-polling or websocket-heavy pages take longer than static ones, because the renderer waits for the network to settle before printing. Invoices are static. Dashboards are not.
Working curl and Node examples live at github.com/clause-netizen/snappdf-api if you want to copy something that runs.
Top comments (0)