Generating invoice PDFs from an API — the complete playbook cover illustration

Generating invoice PDFs from an API — the complete playbook

Invoice PDFs are the single most common workload for HTML-to-PDF APIs. Every SaaS eventually has to produce them; everyone discovers the same pitfalls independently; nobody writes down the learnings. This post is my attempt to write them down.

We’ll build a production-grade invoice-generation pipeline from scratch: HTML template, API call, PDF output, delivery, and the edge cases that show up once you ship. We’ll cover US sales-tax invoicing and EU VAT invoicing in depth — the two regulatory regimes most SaaS teams engineer against — with pointers for other jurisdictions.

TL;DR

  • Invoice PDFs are HTML rendered to PDF — there’s no magic. Template + data + HTML-to-PDF API = invoice.
  • Pre-generate on issue, not on-demand. Once an invoice is issued in most jurisdictions, it’s immutable. Storing the bytes is cheaper than re-rendering and safer under audit.
  • Number sequentially from the database, never client-side. Gaps in the sequence are a legal red flag under EU VAT rules.
  • EU VAT invoices must include seller VAT ID, buyer VAT ID (for B2B), per-rate tax breakdown, and sequential number (Directive 2006/112/EC).
  • US sales-tax invoices are less strict but should break tax out clearly — customers and their accountants expect it.
  • A4 or Letter depending on billing country. Your template should support both via CSS @page.
  • A 40-line HTML-to-PDF API call is enough for the first version. Don’t over-engineer.

The lifecycle of an invoice PDF

Before templates and code, here’s the end-to-end flow most SaaS products eventually settle on:

  1. A billable event happens (subscription charge, order confirmation, usage true-up).
  2. Your service creates an invoice row in your database with the line items, tax breakdown, customer details, and a fresh sequential invoice number.
  3. An HTML renderer (Handlebars, JSX server rendering, Go templates — doesn’t matter) produces the invoice HTML from the row.
  4. You POST the HTML to an HTML-to-PDF API.
  5. The API returns PDF bytes. You store them in S3 (or R2, or GCS, or wherever) and record the storage path on the invoice row.
  6. You email the PDF as an attachment, or expose it via a signed URL for the customer.
  7. For VAT-registered customers in the EU, you may additionally file e-invoicing records with the relevant tax authority (varies by member state; most require PEPPOL / EN 16931 format for public-sector invoices and progressively for private).

The HTML-to-PDF API occupies steps 3-4. The rest is your responsibility regardless of which API you pick. We’ll cover the template and API call in depth, plus a sketch of everything else.

The invoice HTML template

An invoice template is structurally simple — header, line items, totals, footer. The craft is in handling edge cases: long descriptions that wrap badly, line-item counts that page-break, zero-tax rows, multi-currency displays, RTL locales, and the dozen jurisdiction-specific fields (VAT ID, ABN, sales-tax exemption certificate number, etc.).

Here’s a production-grade HTML skeleton I’d use as a starting point:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Invoice INV-2026-2041</title>
    <style>
      @page {
        size: Letter;   /* A4 for EU customers */
        margin: 20mm 15mm 20mm 15mm;
      }
      body {
        font-family: 'Inter', system-ui, sans-serif;
        font-size: 11pt;
        line-height: 1.5;
        color: #0B0B0F;
      }
      .inv-head {
        display: flex;
        justify-content: space-between;
        align-items: flex-start;
        margin-bottom: 32pt;
      }
      .inv-head .brand { font-weight: 700; font-size: 20pt; }
      .inv-head .meta {
        text-align: right;
        font-size: 10pt;
        color: #4A4A6A;
      }
      .parties {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 24pt;
        margin: 24pt 0;
      }
      .parties h3 {
        font-size: 9pt;
        text-transform: uppercase;
        letter-spacing: 0.08em;
        color: #6060A0;
        margin-bottom: 4pt;
      }
      table {
        width: 100%;
        border-collapse: collapse;
        margin: 16pt 0;
        font-size: 10pt;
      }
      th, td { padding: 6pt 8pt; text-align: left; }
      th {
        background: #F4F4F6;
        font-weight: 600;
        border-bottom: 1pt solid #C8C8D0;
      }
      td { border-bottom: 0.5pt solid #E4E4EC; }
      td.num { text-align: right; font-variant-numeric: tabular-nums; }
      tfoot td {
        border-bottom: none;
        border-top: 1pt solid #C8C8D0;
        font-weight: 600;
      }
      .totals {
        margin-left: auto;
        width: 280pt;
        font-size: 11pt;
      }
      .totals .row {
        display: flex;
        justify-content: space-between;
        padding: 4pt 0;
      }
      .totals .grand {
        border-top: 1pt solid #0B0B0F;
        font-weight: 700;
        font-size: 13pt;
        margin-top: 8pt;
        padding-top: 8pt;
      }
      /* Keep line-items table rows together; don't split a row across pages. */
      tr { break-inside: avoid; page-break-inside: avoid; }
      /* Keep totals block with the last line-item row if possible. */
      .totals { break-before: avoid; page-break-before: avoid; }

      footer {
        position: fixed;
        bottom: 10mm;
        left: 15mm;
        right: 15mm;
        font-size: 8pt;
        color: #8080A0;
        border-top: 0.5pt solid #C8C8D0;
        padding-top: 4pt;
      }
    </style>
  </head>
  <body>
    <header class="inv-head">
      <div>
        <div class="brand">Acme Software Inc.</div>
        <div style="font-size:9pt;color:#6060A0;">
          200 Market Street, Suite 400<br>
          San Francisco, CA 94105, USA<br>
          EIN: 87-1234567
        </div>
      </div>
      <div class="meta">
        <div style="font-weight:600;font-size:14pt;">Invoice</div>
        <div>INV-2026-2041</div>
        <div>Date: 24 Apr 2026</div>
        <div>Due: 08 May 2026</div>
      </div>
    </header>

    <section class="parties">
      <div>
        <h3>Billed to</h3>
        <div>Northwind Traders LLC</div>
        <div>14 Brigade Gateway, Unit 204</div>
        <div>Seattle, WA 98101, USA</div>
      </div>
      <div>
        <h3>Payment</h3>
        <div>Net 14 days</div>
        <div>ACH / Card: billing.acme.com/2041</div>
      </div>
    </section>

    <table>
      <thead>
        <tr>
          <th>Description</th>
          <th class="num">Qty</th>
          <th class="num">Rate</th>
          <th class="num">Amount</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>Pro plan · Apr 2026</td>
          <td class="num">1</td>
          <td class="num">$29.00</td>
          <td class="num">$29.00</td>
        </tr>
        <tr>
          <td>Additional seats · 4 × Apr 2026</td>
          <td class="num">4</td>
          <td class="num">$9.00</td>
          <td class="num">$36.00</td>
        </tr>
      </tbody>
    </table>

    <div class="totals">
      <div class="row"><span>Subtotal</span><span>$65.00</span></div>
      <div class="row"><span>Sales tax (WA 10.25%)</span><span>$6.66</span></div>
      <div class="row grand"><span>Total due (USD)</span><span>$71.66</span></div>
    </div>

    <footer>
      Acme Software Inc. · 200 Market Street, Suite 400, San Francisco, CA 94105 · EIN 87-1234567
    </footer>
  </body>
</html>

Several things worth flagging in this template:

@page { size: Letter; margin: 20mm 15mm } — declares Letter (US) at the CSS layer. For EU customers, swap size: A4. If your HTML-to-PDF API also has options.page_size, the CSS wins. Chromium respects the CSS @page rule.

font-variant-numeric: tabular-nums on .num — aligns decimal points in line-item amounts. Invoices look unprofessional without this; the fix is one CSS property.

break-inside: avoid on table rows — prevents the PDF paginator from splitting a single line item across pages. For most invoices with 5-20 items this is enough; for long invoices you may need additional structural breaks.

position: fixed footer — places on every page. Chromium supports this for PDF output. If you’re using Prince/DocRaptor, use @page { @bottom-center { content: "..."; } } instead — it’s the CSS Paged Media idiomatic way.

Currency symbol inline ($) — don’t try to compute it from locale at render time; hard-code the correct symbol for the invoice’s billing currency. Locale-format the number using Intl.NumberFormat on the server, not the PDF renderer.

The API call

Assuming you’re using 21pdf (or any HTTP-clean HTML-to-PDF API), the API call is ~40 lines in any stack language. Here’s a production-grade Node implementation:

// invoice-pdf.ts
import { readFileSync } from 'node:fs';

interface Invoice {
  id: string;
  number: string;
  date: Date;
  // ... plus everything the template needs
}

// Render your invoice HTML any way you like. Handlebars, JSX, eta,
// Go templates — all fine. Output is a string.
function renderInvoiceHtml(inv: Invoice): string { /* ... */ return ''; }

export async function renderInvoicePdf(inv: Invoice): Promise<Buffer> {
  const html = renderInvoiceHtml(inv);
  const key = process.env.QPDF_KEY!;

  // 1. Submit the job.
  const sub = await fetch('https://login.21pdf.com/v1/convert', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${key}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html,
      options: {
        page_size: 'Letter',   // or A4 for EU customers
        margin_top: 20, margin_bottom: 20,
        margin_left: 15, margin_right: 15,
        wait_for_network_idle: true,
      },
    }),
  });

  if (!sub.ok) throw new Error(`submit failed: ${sub.status} ${await sub.text()}`);
  const { job_id } = await sub.json() as { job_id: string };

  // 2. Poll until succeeded or failed.
  const deadline = Date.now() + 60_000;
  while (Date.now() < deadline) {
    await new Promise(r => setTimeout(r, 500));
    const st = await fetch(`https://login.21pdf.com/v1/jobs/${job_id}`, {
      headers: { 'Authorization': `Bearer ${key}` },
    }).then(r => r.json()) as { status: string; message?: string };

    if (st.status === 'succeeded') break;
    if (st.status === 'failed') throw new Error(`render failed: ${st.message}`);
  }

  // 3. Download.
  const pdf = await fetch(`https://login.21pdf.com/v1/jobs/${job_id}/download`, {
    headers: { 'Authorization': `Bearer ${key}` },
  });
  if (!pdf.ok) throw new Error(`download failed: ${pdf.status}`);

  return Buffer.from(await pdf.arrayBuffer());
}

In Python, the same flow:

import os, time, requests

def render_invoice_pdf(html: str) -> bytes:
    key = os.environ["QPDF_KEY"]
    h = {"Authorization": f"Bearer {key}"}

    sub = requests.post(
        "https://login.21pdf.com/v1/convert",
        headers={**h, "Content-Type": "application/json"},
        json={
            "html": html,
            "options": {
                "page_size": "Letter",
                "margin_top": 20, "margin_bottom": 20,
                "margin_left": 15, "margin_right": 15,
                "wait_for_network_idle": True,
            },
        },
    )
    sub.raise_for_status()
    job_id = sub.json()["job_id"]

    deadline = time.time() + 60
    while time.time() < deadline:
        time.sleep(0.5)
        st = requests.get(f"https://login.21pdf.com/v1/jobs/{job_id}", headers=h).json()
        if st["status"] == "succeeded": break
        if st["status"] == "failed": raise RuntimeError(st.get("message", ""))

    pdf = requests.get(f"https://login.21pdf.com/v1/jobs/{job_id}/download", headers=h)
    pdf.raise_for_status()
    return pdf.content

In Go:

package pdf

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

type submitResp struct {
    JobID string `json:"job_id"`
}

type statusResp struct {
    Status  string `json:"status"`
    Message string `json:"message"`
}

func RenderInvoice(ctx context.Context, html string) ([]byte, error) {
    key := os.Getenv("QPDF_KEY")
    client := &http.Client{Timeout: 90 * time.Second}

    body, _ := json.Marshal(map[string]any{
        "html": html,
        "options": map[string]any{
            "page_size":             "Letter",
            "margin_top":            20,
            "margin_bottom":         20,
            "margin_left":           15,
            "margin_right":          15,
            "wait_for_network_idle": true,
        },
    })

    req, _ := http.NewRequestWithContext(ctx, "POST",
        "https://login.21pdf.com/v1/convert", bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+key)
    req.Header.Set("Content-Type", "application/json")
    resp, err := client.Do(req)
    if err != nil { return nil, err }
    defer resp.Body.Close()

    var sub submitResp
    if err := json.NewDecoder(resp.Body).Decode(&sub); err != nil { return nil, err }

    deadline := time.Now().Add(60 * time.Second)
    for time.Now().Before(deadline) {
        time.Sleep(500 * time.Millisecond)
        r, _ := http.NewRequestWithContext(ctx, "GET",
            "https://login.21pdf.com/v1/jobs/"+sub.JobID, nil)
        r.Header.Set("Authorization", "Bearer "+key)
        sr, err := client.Do(r); if err != nil { return nil, err }
        var st statusResp
        json.NewDecoder(sr.Body).Decode(&st); sr.Body.Close()
        if st.Status == "succeeded" { break }
        if st.Status == "failed" { return nil, fmt.Errorf("render: %s", st.Message) }
    }

    r, _ := http.NewRequestWithContext(ctx, "GET",
        "https://login.21pdf.com/v1/jobs/"+sub.JobID+"/download", nil)
    r.Header.Set("Authorization", "Bearer "+key)
    d, err := client.Do(r); if err != nil { return nil, err }
    defer d.Body.Close()
    return io.ReadAll(d.Body)
}

Three languages, same shape. The code that matters for correctness is the template, not the API plumbing.

Numbering invoices correctly

This is where I’ve seen more production bugs than anywhere else in invoice pipelines.

Rules that actually apply

  • Monotonic, no gaps, within each fiscal year (or book, depending on jurisdiction).
  • Restart at 1 each fiscal year is permitted by most authorities; you can also keep one continuous series forever. Pick one and stick with it.
  • Prefix with the fiscal year (INV-2026-0001) for readability — not legally required but standard and helpful for auditors.
  • Never reuse a number — even if an invoice is cancelled, issue a credit note; don’t reissue with the same number.

Implementation

Generate the number from a Postgres sequence or an ordered table:

-- Option A: sequence per fiscal year. Simple, gapless is best-effort
-- (if a transaction rolls back after `nextval`, you get a gap — usually
-- OK under most jurisdictions provided you can explain it during audit).
CREATE SEQUENCE invoice_num_2026;

INSERT INTO invoices (id, number, ...)
VALUES (uuid_generate_v4(),
        'INV-2026-' || lpad(nextval('invoice_num_2026')::text, 4, '0'),
        ...);
-- Option B: counter table with advisory lock. Gapless guaranteed but
-- serialises issue within a fiscal year. Fine for < 1000 invoices/day.
CREATE TABLE invoice_counter (fy text PRIMARY KEY, counter int NOT NULL);

WITH u AS (
  UPDATE invoice_counter
    SET counter = counter + 1
    WHERE fy = '2026'
    RETURNING counter
)
INSERT INTO invoices (id, number, ...)
SELECT uuid_generate_v4(),
       'INV-2026-' || lpad((SELECT counter FROM u)::text, 4, '0'),
       ...;

Option A is what most fast-growing SaaS use. Option B is what auditors prefer; it’s slower but gapless under concurrency. Under EU VAT (Article 226), gaps are permitted if explainable — most tax authorities accept the rollback-gap pattern.

Never derive invoice numbers client-side. JavaScript + network latency + eventual consistency + distributed load balancers = duplicate or out-of-order numbers, which is a serious audit issue.

US sales-tax invoicing

US invoicing requirements are light compared to VAT regimes. There’s no federal invoice schema; requirements come from accounting norms and state sales-tax rules.

  1. Words “Invoice” clearly visible
  2. Seller’s name, address, and EIN (federal tax ID). EIN isn’t strictly required on invoices but helps with B2B bookkeeping.
  3. Sequential invoice number
  4. Date of issue and due date
  5. Buyer’s name, address, and (optional) tax ID for B2B
  6. Itemised line items with quantity, rate, description
  7. Subtotal (pre-tax)
  8. Sales tax broken out clearly. If multiple jurisdictions apply (state + city + district), show each line separately or summed with a note.
  9. Total due
  10. Payment terms (Net 14, Net 30, etc.) and payment instructions

Sales-tax nexus

You only charge sales tax in states where you have economic nexus. Since the 2018 Wayfair decision, this usually means you’ve either (a) done more than $100K of business in that state in a year, or (b) had more than 200 separate transactions there. Thresholds vary by state.

Most SaaS teams use a tax engine (Stripe Tax, TaxJar, Avalara) to compute nexus and tax rates automatically. Your invoice template receives the breakdown from the tax engine and renders it.

Sales-tax exemption

Some buyers (resellers, non-profits, government) are exempt. They’ll provide an exemption certificate (e.g. form ST-120 in NY). Store the certificate number on the customer record and print it on the invoice:

<tr>
  <td>Sales tax (exempt — certificate ST-120-...)</td>
  <td class="num">$0.00</td>
</tr>

EU VAT invoicing

EU VAT is the strictest mainstream invoicing regime. Mandatory fields per Council Directive 2006/112/EC Article 226:

  1. Issue date
  2. Sequential invoice number
  3. Seller’s VAT ID number (e.g. DE123456789, FR12345678901)
  4. Buyer’s VAT ID number — required for B2B intra-EU sales (enables the reverse charge)
  5. Seller’s full name and address
  6. Buyer’s full name and address
  7. Quantity and nature of goods/services
  8. Date of supply (if different from issue date)
  9. Taxable amount per rate and any exemption justification
  10. VAT rate applied
  11. VAT amount in the invoice currency (must be in the member state’s currency)
  12. Reference to reverse charge if applicable (phrase “reverse charge” for B2B intra-EU)

EU VAT rates (2026)

VAT rates vary by country and by goods/services. Standard rates range from 17% (Luxembourg) to 27% (Hungary). Reduced rates for certain categories (books, food, etc.). Your tax engine handles this.

For digital services sold to EU consumers (B2C), you’re generally required to register for OSS (One Stop Shop) and charge VAT at the buyer’s country rate. For B2B sales to VAT-registered buyers, use the reverse charge — don’t charge VAT, note “VAT reverse charge” on the invoice, and the buyer handles it in their VAT return.

E-invoicing (EU)

The EU is progressively mandating e-invoicing (structured XML invoices per EN 16931, typically via PEPPOL networks) for public-sector and increasingly B2B. Italy, France, Poland, and Belgium already require it in various B2B contexts; more member states follow under ViDA (VAT in the Digital Age).

If you sell to EU customers:

  • For public-sector (B2G), e-invoicing via PEPPOL is mandatory EU-wide.
  • For private B2B, check the member state. France and Italy require it today (with exemptions during phased rollout); Germany mandates receiving e-invoices from 2025.
  • The PDF you generate is supplementary to the structured XML — the XML is the legal document, the PDF is for humans.

Tools like Factur-X combine a PDF/A-3 with embedded structured XML, satisfying both requirements in one file. If your business is EU-heavy, look into Factur-X generators — some HTML-to-PDF APIs can produce PDF/A-3 output (21pdf does not today).

Other jurisdictions

Brief pointers for other common regimes:

  • UK — separate from EU since Brexit but similar invoicing rules (Making Tax Digital). VAT rates unchanged.
  • Canada — GST/HST per province. Invoice requirements light; similar to US.
  • Australia — GST at 10% on most goods/services. ABN (Australian Business Number) required on B2B invoices.
  • India — GST with per-state CGST/SGST split; mandatory e-invoicing (IRN) above a turnover threshold. Complex; out of scope here.
  • Brazil — Nota Fiscal Eletrônica (NF-e) mandatory for most invoices; requires government signature. Use a specialised service, not a generic HTML-to-PDF API.

For any non-trivial international footprint, use a dedicated invoicing/tax service (Stripe Tax, Avalara, Sovos, LaunchFast) that handles the regulatory edge cases. Your HTML-to-PDF API just renders the output.

Delivery: email, portal, or both

Once you have the PDF bytes, you need to get them to the customer. The three common patterns:

1. Email as attachment

Most common. Render → attach to transactional email → send via Postmark/SES/Mailgun/Resend. Pros: customer has the PDF in their inbox forever. Cons: 10-15MB limit on most email providers; attachments sometimes get stripped by corporate mail gateways.

// Example using Resend (works similarly with Postmark/SES).
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_KEY);

await resend.emails.send({
  from: '[email protected]',
  to: customer.email,
  subject: `Invoice ${invoice.number} from Acme`,
  html: emailBodyHtml,
  attachments: [
    {
      filename: `${invoice.number}.pdf`,
      content: pdfBuffer.toString('base64'),
    },
  ],
});

2. Portal (signed URL)

Store the PDF in S3/R2/GCS, generate a long-lived signed URL, display it in your customer dashboard. Pros: no attachment size issues, easy to update if invoice is voided. Cons: customers tend to want the file on disk anyway.

Email the PDF as an attachment and expose it in the portal. 99% of customers grab it from email; the remaining 1% download it from the portal six months later when their accountant asks.

Edge cases that bite in production

After three years of document infrastructure, these are the issues I’ve seen most often.

Long line-item descriptions breaking layout

A customer edits their project name to include “Full enterprise deployment including 24/7 SLA support for all environments…” and suddenly the table column overflows. Fix: set a max-width on the description column, use overflow-wrap: anywhere or word-break: break-word, and design the template expecting long strings.

Numbers misaligned in columns

Caused by proportional digits. Fix: font-variant-numeric: tabular-nums on numeric columns. Takes one line of CSS, fixes it permanently.

Page breaks landing mid-row

Default Chromium paginator may break a row across pages, leaving orphaned cells. Fix: break-inside: avoid on <tr> elements. For very long rows that don’t fit on a single page, this will force a new page before the row — which is usually what you want.

Fonts loading late → wrong output

The @font-face src URL takes 800ms to resolve and Chromium captures the PDF at 500ms; customer gets Arial where they wanted Inter. Fix: wait_for_network_idle: true in the API call. Consider self-hosting fonts inline (base64 in CSS) for maximum reliability.

Currency formatting localised wrong

Server runs in UTC with LANG=C.UTF-8, Intl.NumberFormat('en-US', ...) works but en-GB format yields commas in the wrong places, and your €100.000 renders as €100,000. Fix: explicitly pass locale strings, test with Intl.NumberFormat in your unit tests, not just integration.

Invoice PDF changes hash after re-render

You re-rendered the same invoice and the bytes differ — usually because the PDF includes a timestamp in metadata. This fails your byte-stability guarantees during audit. Fix: pass metadata: { creation_date: invoice.issued_at } if your API supports it; otherwise post-process with qpdf or similar to set a fixed creation date.

Multi-page invoice misses header on page 2

Default Chromium doesn’t repeat <header> elements across pages. Fix: use CSS @page { @top-left { content: ...; } } instead of a <header> in the DOM. Different API/engines handle this differently — test with your specific vendor.

Draft vs issued invoice PDFs look identical

Customers download a “draft” from your portal and think it’s an issued invoice. Fix: watermark drafts with a subtle diagonal “DRAFT” text (use CSS ::before with transform: rotate(-30deg)), or add the issued status prominently. Treat this as a UX bug; it causes real customer-support issues.

EU buyer without VAT ID, you charged them B2C VAT

Customer claims they’re a business, expects reverse charge, but didn’t provide a VAT ID at signup. You charged consumer VAT. Fix: validate VAT IDs at signup using the VIES API — the EU’s free VAT-ID validator. If the buyer provides a VAT ID later, issue a credit note for the VAT and reissue without.

Performance considerations

For most SaaS volumes (<10k invoices/day), invoice generation is I/O-bound on the API call, not on the HTML render. Optimisation ideas:

  • Batch renders during business hours: if you issue 1000 invoices at month-end, don’t fire 1000 parallel API calls. Use your concurrency limit.
  • Async generation: once the invoice row is committed in Postgres, enqueue a job rather than blocking the user-facing request. Most billing services already work this way.
  • Cache the HTML template (the rendered template for a given invoice), but don’t cache the PDF — regenerating the PDF should be idempotent from the stored HTML, so you can re-issue if needed.

Storage and retention

Invoices are retained by law. In the EU, Council Directive 2006/112/EC mandates invoices be stored for at least 10 years (member states can extend). In the US, the IRS recommends 3 years but tax-law practitioners typically keep 7+. In the UK, 6 years under Making Tax Digital.

In practice: store invoice PDFs for at least 10 years. That means S3 (or equivalent) with Glacier/Archive lifecycle policies after 1 year — not a single-VM MinIO.

Do not rely on the HTML-to-PDF API vendor’s retention. 21pdf, like most vendors, has a retention window (ours is governed by PDF_RETENTION_DAYS and is measured in days, not years). The PDF bytes are yours; store them in your own bucket.

Render invoice PDFs with 21pdf

Flat USD subscriptions — $0 Free (100 PDFs/mo), $9 Starter, $29 Pro (10,000/mo), $69 Business (50,000/mo). Every plan has the full API surface.

Get API key → See pricing

Example: a minimal end-to-end invoice pipeline

Putting it all together — here’s a realistic invoice-issuance function that you might actually have in production:

// bill/issue-invoice.ts
import { db } from './db';
import { renderInvoiceHtml } from './template';
import { renderInvoicePdf } from './pdf';
import { uploadToStorage } from './storage';
import { sendInvoiceEmail } from './email';

interface IssueInvoiceInput {
  customerId: string;
  lineItems: Array<{ description: string; qty: number; rate: number }>;
  billingCountry: string;
}

export async function issueInvoice(input: IssueInvoiceInput): Promise<string> {
  return db.transaction(async (tx) => {
    // 1. Get next number atomically.
    const { rows: [{ counter }] } = await tx.query<{ counter: number }>(
      "UPDATE invoice_counter SET counter = counter + 1 WHERE fy = $1 RETURNING counter",
      ['2026'],
    );
    const number = `INV-2026-${String(counter).padStart(4, '0')}`;

    // 2. Insert row with calculated tax.
    const taxBreakdown = computeTaxBreakdown(input); // US sales tax or EU VAT
    const invoice = await tx.insert('invoices', {
      id: crypto.randomUUID(),
      number,
      customer_id: input.customerId,
      line_items: input.lineItems,
      billing_country: input.billingCountry,
      ...taxBreakdown,
      issued_at: new Date(),
    });

    // 3. Render HTML + PDF outside the DB transaction.
    return invoice.id;
  }).then(async (invoiceId) => {
    const invoice = await db.selectOne('invoices', { id: invoiceId });
    const html = renderInvoiceHtml(invoice);
    const pdf = await renderInvoicePdf(html);

    const path = `invoices/2026/${invoice.number}.pdf`;
    await uploadToStorage(path, pdf);

    await db.update('invoices', { id: invoiceId }, {
      pdf_storage_path: path,
      pdf_generated_at: new Date(),
    });

    await sendInvoiceEmail(invoice, pdf);

    return invoiceId;
  });
}

120 lines that do the right thing. Database transaction for atomic numbering, PDF render outside the transaction (because HTTP calls inside transactions are how you discover idle_in_transaction alarms at 3am), storage + email after.

Closing

Invoice PDFs are the boring, critical end of document infrastructure. They don’t impress anyone; getting them wrong will absolutely cost you auditing grief, customer complaints, or worse.

The playbook isn’t fancy:

  1. Template your HTML well.
  2. Number invoices from the database.
  3. Include the right field set for your customer’s jurisdiction (VAT ID for EU B2B, sales-tax breakdown for US).
  4. Use an HTML-to-PDF API rather than rolling your own.
  5. Pre-generate on issue; store the bytes for 10+ years.
  6. Email the PDF as an attachment; expose it in the portal too.

If you pick 21pdf, step 4 is 40 lines of code and $0 for the first 100 invoices/month. If you pick one of the others we surveyed in the 2026 comparison, it’s a similar amount of code with a different base URL. The vendor choice is smaller than it looks.

What matters is the template, the numbering, and the tax compliance — and those are on you regardless.

— 21pdf Engineering

Frequently asked questions

What's the fastest way to generate an invoice PDF from HTML?

Render an HTML template with your data, POST it to an HTML-to-PDF API, and store the returned PDF. For low volume, a managed API like 21pdf gets you from zero to first PDF in ~10 minutes. The code is ~40 lines in any mainstream language.

What are the EU invoicing requirements for VAT?

Every EU VAT invoice must include: sequential invoice number, issue date, seller's name/address/VAT ID, buyer's name/address (plus VAT ID for B2B), description of goods/services, taxable amount per rate, VAT rate and amount, and total. Directive 2006/112/EC (Articles 218-240) is the authoritative reference; most modern e-invoicing standards (PEPPOL, Factur-X) map cleanly to these fields.

Do I need to print sales tax on US invoices?

US sales tax requirements vary by state, but best practice is to show: invoice number, date, seller + buyer addresses, itemised goods/services, subtotal, sales tax (broken out by jurisdiction if applicable), and total. Unlike VAT in the EU, US sales tax usually doesn't require a registered tax ID on the invoice, but showing a clear tax breakdown is strongly expected by accounting teams.

Should invoice PDFs be generated on-demand or pre-generated?

Pre-generate on issue. Most jurisdictions require invoices to be immutable once issued (EU VAT rules, Sarbanes-Oxley in the US, etc.). On-demand is fine only for drafts or previews. Pre-generation also gives you byte-stable invoices for audit.

How do I number invoices correctly?

Use a strictly monotonic, gapless sequence. Most jurisdictions require this — gaps are an audit red flag under EU VAT rules and an accounting concern everywhere else. Generate numbers from a single database sequence or an ordered table with a uniqueness constraint; never derive them client-side. Prefix with your fiscal year (e.g. `INV-2026-0001`) for easier accounting.

What's the right paper size for invoices?

Letter in the US and Canada. A4 in Europe, Asia, and most of the world. Your template should be agnostic — use CSS `@page { size: auto }` or explicit `size: Letter` / `size: A4` and let the PDF API match. Many SaaS products ship one template rendered to both sizes based on billing country.

How do I handle invoices in multiple currencies?

Template the currency symbol and locale-format the numbers. Don't do currency conversion at render time — always template in the billed currency with the locale-formatted amount. Use `Intl.NumberFormat` in Node/browser or equivalent on the server. For exchange-rate disclosure (common on cross-border invoices), include the rate and the spot-date in a footnote.

Do I need a special service for invoices, or is a generic HTML-to-PDF API enough?

A generic HTML-to-PDF API is enough for 95% of cases. Dedicated invoice services (Stripe Invoicing, Zoho Invoice, Invoiced.com) add customer management, payment tracking, and dunning — but if you already have those in your own product, bolting on a second system is more friction than value.

How do I email the invoice PDF to the customer?

Render the PDF to a byte buffer, attach it to an SMTP message, send. Use a transactional email service (Postmark, SES, Resend, Mailgun) to avoid delivery problems. Attach the PDF inline rather than linking to a signed URL — customers save invoices to disk, and links rot.