Generate branded PDF invoices from HTML
Stripe's default invoice PDF is fine. It's also unmistakably Stripe. The moment your customer forwards it to their accounts payable department, everyone sees "generated by Stripe" and it undermines the "small serious business" vibe you spent months building.
Meanwhile, the "generate PDFs from HTML" story in every language has the same three unappealing options:
- Install a native PDF renderer (WeasyPrint, wkhtmltopdf). Works but forks a subprocess, ships a 100 MB binary, and doesn't render modern CSS correctly.
- Install Puppeteer or Playwright yourself. Works but ships a 500 MB Chromium next to your Lambda and you now maintain a browser pool.
- Sign up for DocRaptor or PDFCrowd. Works but the pricing scales badly and neither has a proper OpenAPI spec.
This post shows a fourth option: point your HTML template at POST /v1/screenshot with format: "pdf", get the bytes back, save or serve them. Same billing per PDF as per PNG (1 credit). Fully-branded, multi-page, with proper headers/footers.
Design your invoice as HTML
Any HTML page works. Here's a minimal invoice template — deploy it at https://myapp.com/invoices/[id] and it renders on the server with real invoice data injected:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
/* Print-optimized styles. Note the @page rule for margins */
@page { size: A4; margin: 0; }
body { margin: 40mm 20mm; font-family: -apple-system, sans-serif;
font-size: 11pt; color: #111; }
.header { display: flex; justify-content: space-between; margin-bottom: 32pt; }
.brand h1 { margin: 0; font-size: 20pt; color: #4f46e5; }
.brand p { margin: 4pt 0 0; color: #6b7280; font-size: 10pt; }
.meta { text-align: right; }
.meta table { border-collapse: collapse; }
.meta td { padding: 3pt 12pt 3pt 0; font-size: 10pt; }
.meta td.label { color: #6b7280; text-transform: uppercase; font-size: 9pt; }
h2 { font-size: 12pt; margin: 24pt 0 8pt; text-transform: uppercase; color: #6b7280; }
.lines { width: 100%; border-collapse: collapse; margin-top: 12pt; }
.lines th { text-align: left; border-bottom: 1pt solid #d1d5db;
padding: 6pt 4pt; font-size: 10pt; color: #6b7280; }
.lines td { padding: 8pt 4pt; border-bottom: 1pt solid #f3f4f6; }
.totals { margin-top: 20pt; margin-left: auto; width: 40%; }
.totals td { padding: 4pt 8pt; }
.totals .grand { font-size: 14pt; font-weight: 600; border-top: 1pt solid #111; }
.terms { margin-top: 32pt; padding-top: 12pt; border-top: 1pt solid #e5e7eb;
font-size: 9pt; color: #6b7280; line-height: 1.6; }
</style>
</head>
<body>
<div class="header">
<div class="brand">
<h1>Your Company Ltd.</h1>
<p>VAT LT100019193415 · Vilnius, Lithuania</p>
</div>
<div class="meta">
<table>
<tr><td class="label">Invoice</td><td>INV-2026-0114</td></tr>
<tr><td class="label">Issued</td><td>20 Sept 2026</td></tr>
<tr><td class="label">Due</td><td>20 Oct 2026</td></tr>
</table>
</div>
</div>
<h2>Billed to</h2>
<p>Customer Ltd.<br>
100 Anywhere St, City, Country<br>
VAT DE123456789</p>
<h2>Charges</h2>
<table class="lines">
<thead>
<tr><th>Description</th><th>Qty</th><th>Rate</th><th>Amount</th></tr>
</thead>
<tbody>
<tr><td>getSnap Pro plan — September 2026</td><td>1</td><td>€29.00</td><td>€29.00</td></tr>
<tr><td>Metered captures (14,203 × €0.0018)</td><td> </td><td> </td><td>€25.57</td></tr>
</tbody>
</table>
<table class="totals">
<tr><td>Subtotal</td><td style="text-align:right">€54.57</td></tr>
<tr><td>VAT 21%</td><td style="text-align:right">€11.46</td></tr>
<tr class="grand"><td>Total</td><td style="text-align:right">€66.03</td></tr>
</table>
<div class="terms">
<p><strong>Payment terms:</strong> Due within 30 days. Bank details:
IBAN LT12 3456 7890 1234 5678, SWIFT ABCDLT2X.
Include the invoice number in the payment reference.</p>
<p>Questions? Reply to this email or contact support@yourcompany.com.</p>
</div>
</body>
</html>
Render the PDF via the API
curl form (the atomic unit):
curl -X POST https://api.getsnap.dev/v1/screenshot \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-H "Accept: application/pdf" \
-o invoice.pdf \
-d '{
"url": "https://myapp.com/invoices/INV-2026-0114",
"format": "pdf",
"pdf_page_size": "A4",
"pdf_orientation": "portrait",
"pdf_margin_top": "0mm",
"pdf_margin_bottom": "0mm",
"pdf_margin_left": "0mm",
"pdf_margin_right": "0mm",
"pdf_background": true,
"response_type": "binary"
}'
Notice the zero margins: they're handled in CSS via @page so the PDF renderer doesn't add its own padding on top of yours.
Node.js form — installing the SDK and streaming to disk:
// npm install getsnap
import GetSnap from "getsnap";
import { writeFile } from "node:fs/promises";
const snap = new GetSnap(process.env.GETSNAP_KEY!);
const bytes = await snap.screenshotBinary({
url: "https://myapp.com/invoices/INV-2026-0114",
format: "pdf",
pdf_page_size: "A4",
pdf_orientation: "portrait",
pdf_margin_top: "0mm",
pdf_margin_bottom: "0mm",
pdf_margin_left: "0mm",
pdf_margin_right: "0mm",
pdf_background: true,
pdf_title: "Invoice INV-2026-0114",
pdf_subject: "Your Company Ltd. - Invoice",
pdf_author: "Your Company Ltd.",
pdf_creator: "Your Company Ltd.",
pdf_keywords: "invoice,accounting,2026",
});
await writeFile("invoice.pdf", Buffer.from(bytes));
Python:
# pip install getsnap
from getsnap import GetSnap
snap = GetSnap(api_key=os.environ["GETSNAP_KEY"])
bytes_ = snap.screenshot_binary(
url="https://myapp.com/invoices/INV-2026-0114",
format="pdf",
pdf_page_size="A4",
pdf_margin_top="0mm",
pdf_margin_bottom="0mm",
pdf_margin_left="0mm",
pdf_margin_right="0mm",
pdf_background=True,
pdf_title="Invoice INV-2026-0114",
pdf_subject="Your Company Ltd. - Invoice",
pdf_author="Your Company Ltd.",
)
with open("invoice.pdf", "wb") as f:
f.write(bytes_)
Page numbers on multi-page invoices
If your invoice has more than one page — itemized long-form invoices for enterprise customers — you probably want "Page X of Y" in the footer. Chromium's PDF renderer supports HTML templates for this with special class names:
const bytes = await snap.screenshotBinary({
url: "https://myapp.com/invoices/INV-2026-0114",
format: "pdf",
pdf_page_size: "A4",
pdf_show_footer: true,
pdf_footer: `
<div style="font-size:8pt;color:#9ca3af;width:100%;padding:0 20mm;
display:flex;justify-content:space-between;">
<span>Invoice INV-2026-0114 · Your Company Ltd.</span>
<span>Page <span class="pageNumber"></span>
of <span class="totalPages"></span></span>
</div>
`,
pdf_margin_bottom: "20mm", // reserve room for the footer
});
The pageNumber and totalPages classes are magic strings the renderer substitutes at print time. Chromium respects them; no external tooling needed.
Serve the PDF directly (streaming download)
If your users want to click "Download invoice" and get a PDF back without hitting your own storage, wire the API response straight through:
// Express route
import express from "express";
import GetSnap from "getsnap";
const snap = new GetSnap(process.env.GETSNAP_KEY!);
const app = express();
app.get("/invoices/:id.pdf", async (req, res) => {
const templateUrl = `https://myapp.com/invoices/${req.params.id}`;
const bytes = await snap.screenshotBinary({
url: templateUrl,
format: "pdf",
pdf_page_size: "A4",
pdf_background: true,
});
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition",
`attachment; filename="invoice-${req.params.id}.pdf"`);
res.send(Buffer.from(bytes));
});
Or the same pattern in FastAPI:
from fastapi import FastAPI
from fastapi.responses import Response
from getsnap import GetSnap
app = FastAPI()
snap = GetSnap(api_key=os.environ["GETSNAP_KEY"])
@app.get("/invoices/{id}.pdf")
def download_invoice(id: str):
template = f"https://myapp.com/invoices/{id}"
pdf = snap.screenshot_binary(
url=template,
format="pdf",
pdf_page_size="A4",
pdf_background=True,
)
return Response(
content=pdf,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="invoice-{id}.pdf"'},
)
Authenticate the invoice URL
Your /invoices/[id] page shouldn't be public. If it is, anyone who guesses an invoice ID can read someone else's billing details.
Two workable approaches:
Short-lived signed URLs
Generate a JWT or HMAC-signed URL for the invoice that expires in 60 seconds. Pass that to the API. The API calls it, gets the HTML, renders the PDF. The URL expires before anyone else can hit it.
import { createHmac } from "node:crypto";
function signedInvoiceUrl(id: string): string {
const exp = Math.floor(Date.now() / 1000) + 60; // 60s TTL
const payload = `${id}.${exp}`;
const sig = createHmac("sha256", process.env.INVOICE_SIG_SECRET!)
.update(payload).digest("hex");
return `https://myapp.com/invoices/${id}?exp=${exp}&sig=${sig}`;
}
// then in the render call:
await snap.screenshotBinary({
url: signedInvoiceUrl("INV-2026-0114"),
format: "pdf",
});
Cookie-based auth
Pass a session cookie in the API call. getsnap.dev accepts cookies in the request body; the renderer sends them with the initial navigation.
await snap.screenshotBinary({
url: "https://myapp.com/invoices/INV-2026-0114",
format: "pdf",
cookies: [{
name: "session",
value: "abcd1234-encrypted-session-token",
domain: "myapp.com",
path: "/",
httpOnly: true,
secure: true,
}],
});
This gives the render the same authenticated view your logged-in user would see.
Storage and delivery
Small services can stream on demand every time. Larger ones want to cache. Two options:
- Trust the getsnap.dev cache. Every PDF response is keyed by the request body, so identical invoice URLs return
cached: trueon the second call and don't consume quota. Cache hits arrive in ~12 ms — usually faster than pulling from your own S3. - Push to your own S3. Add
s3_upload: { access_key_id, secret_access_key, bucket, region }to the request body and the PDF is uploaded to your bucket in the same round-trip. Handy for compliance archives (7 years of invoices for audit).
Ship your first branded PDF invoice today
Free tier: 100 PDFs / month. Same code paths as the screenshot API, same caching, same CDN.
Get free API keyWhy not the alternatives?
- wkhtmltopdf. Uses ancient WebKit. Modern CSS Grid, flexbox, SVG fonts — all broken. Development effectively frozen. Skip.
- WeasyPrint. Solid but Python-only, and doesn't render JavaScript. If your invoice template has any dynamic client-side rendering, you need a real browser.
- Puppeteer / Playwright locally. Works. Ships 500 MB of Chromium next to your Lambda. Requires a browser pool. Requires font management. Requires memory tuning. Requires you to think about it.
- DocRaptor / PDFCrowd. Fine services. About 3–4x the price per PDF at getsnap.dev volumes, and neither has a full OpenAPI spec.
An API call is one line of code. If you've already spent an afternoon fighting a headless browser, you know the value proposition.
Related reading
- Rendering images from an HTML template — the same pattern applied to social images