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:

  1. Install a native PDF renderer (WeasyPrint, wkhtmltopdf). Works but forks a subprocess, ships a 100 MB binary, and doesn't render modern CSS correctly.
  2. Install Puppeteer or Playwright yourself. Works but ships a 500 MB Chromium next to your Lambda and you now maintain a browser pool.
  3. 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.

1

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>
2

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_)
3

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.

4

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"'},
    )
5

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:

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 key

Why not the alternatives?

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