Invoice PDF Generation API for Developers
Invoice PDF generation breaks with DIY HTML approaches because invoices have variable-length line items that span multiple pages, tax calculations that vary by region, legal requirements that differ by country, and subtotal/discount/tax positioning that fails when content reflows. After your first invoice with 50+ line items, you'll see page breaks that split rows, footers that don't appear on the last page only, and totals that overlap content.
What Makes Invoices Hard
Unlike simple documents (receipts, certificates), invoices have complex structural requirements that break standard HTML-to-PDF workflows:
Variable-Length Line Items (Pagination Issues)
The problem: You don't know how many line items an invoice will have until runtime.
- 5 items: Fits on one page
- 50 items: Spans 2-3 pages
- 200 items: Spans 10+ pages
What breaks:
- HTML tables split rows across pages (half a row on page 1, half on page 2)
- Headers need to repeat on every page
- Footers should appear only on last page
- Subtotals/totals must appear after last item, not at fixed position
CSS attempt (doesn't work reliably):
/* Doesn't prevent row splitting in most PDF tools */
table tr {
page-break-inside: avoid;
}
/* Doesn't ensure footer on last page only */
.footer {
position: fixed;
bottom: 0;
}
Tax Calculations and Display
The problem: Tax rules vary by:
- Country (VAT in EU, GST in Canada, Sales Tax in US)
- State/Province (US has 50 different rates)
- Product type (food, clothing, digital goods taxed differently)
- Customer type (B2B vs B2C, tax-exempt organizations)
What you need to display:
- Line item subtotals (before tax)
- Tax rate (percentage)
- Tax amount (calculated)
- Total (subtotal + tax)
- Multiple tax types (federal + state, VAT + customs)
Example complexity:
// US: State sales tax + local tax
subtotal: 1000.00
state_tax (8.25%): 82.50
local_tax (1.5%): 15.00
total: 1097.50
// EU: VAT included in price
price_including_vat: 120.00
vat_rate: 20%
net_price: 100.00
vat_amount: 20.00
Multiple Currencies and Localization
The problem: B2B SaaS invoices often cross borders.
Requirements:
- Currency symbols ($, €, £, ¥)
- Decimal separators (1,000.00 vs 1.000,00)
- Date formats (MM/DD/YYYY vs DD/MM/YYYY)
- Number formatting (1,000 vs 1 000)
- Language (labels in customer's language)
Example:
// US customer
total: "$1,500.00"
date: "12/29/2025"
// German customer
total: "1.500,00 €"
date: "29.12.2025"
// Japanese customer
total: "¥150,000"
date: "2025年12月29日"
Legal Requirements (Vary by Country)
Different countries mandate specific information on invoices:
| Country | Required Fields |
|---|---|
| US | Business name, address, invoice number, date, line items, totals |
| EU | All above + VAT number, customer VAT number, VAT rate per item, reverse charge notice (B2B) |
| Canada | All above + GST/HST number, PST number (if applicable) |
| Australia | All above + ABN (Australian Business Number), GST notice |
| UK | All above + Company registration number, VAT number, registered office address |
Missing required fields = invoice is invalid, customer can refuse payment.
Subtotals, Discounts, Taxes, Totals (Layout Challenges)
The problem: These must appear in a specific visual hierarchy, but their position depends on how many line items fit on each page.
Correct layout:
[Page 1]
Line Item 1
Line Item 2
...
Line Item 20
[Page break]
[Page 2]
Line Item 21
...
Line Item 45
[No subtotals here - not last page]
[Page break]
[Page 3]
Line Item 46
...
Line Item 50
---------------
Subtotal: $10,000
Discount: -$500
Tax (8.25%): $783.75
---------------
TOTAL: $10,283.75
What breaks with fixed positioning:
- Subtotals appear on every page (not just last)
- Or subtotals appear on first page (not last)
- Or content overlaps subtotals
- Or page breaks split the totals section
Common Edge Cases
Real invoices encounter edge cases that break simple HTML generation:
Invoice with 100+ Line Items
Scenario: Consulting firm bills 100 hours across different tasks.
What breaks:
- HTML table generation: 100
<tr>elements = slow rendering - Page breaks: Rows split across pages, unreadable
- Memory: Puppeteer crashes after rendering 50+ page PDF
- File size: 100-page PDF = 5-10MB (slow to email)
Solution requirements:
- Efficient pagination
- Repeating headers on each page
- "Continued on next page" indicators
- Page numbers: "Page 5 of 12"
Multi-Currency Invoices
Scenario: International company invoices in EUR but also shows USD equivalent.
Display requirements:
Line Item 1: €100.00 (≈ $110.00)
Line Item 2: €200.00 (≈ $220.00)
---
Subtotal: €300.00 (≈ $330.00)
Tax (19%): €57.00 (≈ $62.70)
---
Total: €357.00 (≈ $392.70)
Exchange rate: 1 EUR = 1.10 USD (as of 2025-12-29)
HTML generation complexity:
- Format numbers for each currency
- Calculate conversions
- Display exchange rate and date
- Handle rounding differences
Partial Payments and Credits
Scenario: Customer paid $500 of $1,000 invoice, then received $100 credit.
Display requirements:
Original Total: $1,000.00
Payment (2025-11-15): -$500.00
Credit Note #CN-123: -$100.00
---
Balance Due: $400.00
What breaks:
- HTML templates don't handle conditional "payments" section
- Calculations: must sum payments and credits correctly
- Credits can exceed total (refund owed)
Recurring vs One-Time Charges
Scenario: SaaS invoice with monthly subscription + one-time setup fee.
Display requirements:
Recurring Charges (Monthly)
- Pro Plan (Jan 2025): $99.00
- Extra Users (5 × $10): $50.00
One-Time Charges
- Setup Fee: $199.00
- Custom Integration: $500.00
---
Subtotal: $848.00
Tax (8%): $67.84
---
Total This Invoice: $915.84
Next Invoice (Feb 1, 2025): $149.00/month
What breaks:
- HTML templates mix recurring and one-time items
- Totals confuse "this month" vs "ongoing"
- Customer asks "why is my next invoice different?"
Notes and Terms Sections
Scenario: Invoice needs custom payment terms, late fee policy, notes.
Variable content:
- Some invoices: 2-3 lines of notes
- Some invoices: Full page of terms and conditions
What breaks:
- Fixed-height footer doesn't fit long terms
- Terms push totals to next page
- Or terms get cut off
Why DIY HTML Generation Breaks
Problem 1: HTML Tables Don't Break Pages Cleanly
Attempt:
<table>
<thead>
<tr><th>Description</th><th>Qty</th><th>Price</th></tr>
</thead>
<tbody>
<!-- 100 items here -->
</tbody>
</table>
What happens:
- Page break occurs mid-row (half the row on page 1, half on page 2)
- Headers don't repeat on page 2
- Total is 10 pages away from last item
CSS "fix" (doesn't work):
tr { page-break-inside: avoid; } /* Ignored by most PDF tools */
thead { display: table-header-group; } /* Partial support */
Problem 2: Totals Positioning When Items Span Pages
Fixed positioning approach:
<style>
.totals {
position: absolute;
bottom: 100px; /* Fixed position */
}
</style>
What breaks:
- If items fit on one page: totals appear correctly
- If items span 2 pages: totals appear on page 1 (before all items)
- If items span 5 pages: totals overlap page 4 content
Problem 3: Different Tax Rules by Region
Naive approach:
function calculateTax(subtotal, region) {
if (region === 'US-CA') return subtotal * 0.0825;
if (region === 'US-NY') return subtotal * 0.08875;
if (region === 'EU-DE') return subtotal * 0.19;
// ... 200 more regions
}
What breaks:
- Hard to maintain (tax rates change)
- Doesn't handle multiple tax types (federal + state)
- Doesn't handle tax exemptions
- Doesn't handle reverse charge (B2B EU)
Problem 4: Footer on Last Page Only
Goal: Show "Thank you for your business!" only on last page.
CSS attempt:
@page :last {
@bottom-center {
content: "Thank you!";
}
}
Reality: :last pseudo-class not supported in most HTML-to-PDF tools.
The Template Approach
Instead of generating HTML with embedded business logic, separate data from presentation:
Architecture
┌─────────────────────────┐
│ Your App │
│ (handles business logic)│
└───────────┬─────────────┘
│
│ 1. Calculate tax, format data
│
▼
┌─────────────────────────┐
│ Invoice Data (JSON) │
│ - line items │
│ - subtotal, tax, total │
│ - customer info │
└───────────┬─────────────┘
│
│ 2. POST to API
│
▼
┌─────────────────────────┐
│ Template API │
│ (handles pagination, │
│ formatting, rendering) │
└───────────┬─────────────┘
│
│ 3. Merge data + template
│
▼
┌─────────────────────────┐
│ PDF Binary │
└─────────────────────────┘
Key separation:
- Your app: Business logic (tax calculation, discounts, currency conversion)
- Template API: Presentation logic (pagination, headers/footers, number formatting)
Data Structure for Invoices
Define a clear contract between your app and the PDF API:
{
"templateId": "invoice-template",
"data": {
"invoice": {
"number": "INV-2025-001234",
"date": "2025-12-29",
"dueDate": "2026-01-28",
"status": "due"
},
"company": {
"name": "Acme Corp",
"address": "123 Market St, San Francisco, CA 94103",
"phone": "+1 (555) 123-4567",
"email": "billing@acme.com",
"taxId": "US-123456789",
"logo": "https://acme.com/logo.png"
},
"customer": {
"name": "Beta Industries",
"contactPerson": "John Doe",
"address": "456 Oak Ave, New York, NY 10001",
"email": "john@beta.com",
"taxId": "US-987654321"
},
"lineItems": [
{
"id": "item-1",
"description": "Professional Services - December 2025",
"quantity": 40,
"unit": "hours",
"unitPrice": 150.00,
"subtotal": 6000.00,
"taxRate": 0.0825,
"taxAmount": 495.00,
"total": 6495.00
},
{
"id": "item-2",
"description": "Cloud Infrastructure (AWS)",
"quantity": 1,
"unit": "month",
"unitPrice": 850.00,
"subtotal": 850.00,
"taxRate": 0.0825,
"taxAmount": 70.13,
"total": 920.13
}
],
"subtotal": 6850.00,
"discount": {
"description": "Early payment discount (2%)",
"amount": -137.00
},
"taxSummary": [
{
"description": "California Sales Tax (8.25%)",
"rate": 0.0825,
"amount": 565.13
}
],
"total": 7278.13,
"currency": "USD",
"payments": [],
"balanceDue": 7278.13,
"notes": "Payment due within 30 days. Late payments subject to 1.5% monthly interest.",
"terms": "Net 30. Accepted payment methods: Bank transfer, credit card, check."
}
}
Benefits of this structure:
- Validated: API validates structure, catches missing fields
- Versioned: Change schema over time without breaking old invoices
- Testable: Unit test data generation separately from PDF rendering
- Reusable: Same structure for all invoices
Visual Editor Benefits
Instead of coding HTML, use visual editor to design invoice once:
Traditional approach (code):
// 200+ lines of HTML generation code
function generateInvoiceHTML(data) {
let html = '<html><head><style>';
html += 'body { font-family: Arial; }';
html += '.header { display: flex; }';
// ... 150 more lines
return html;
}
Template approach (visual):
- Open template editor
- Drag logo, invoice number, date fields
- Add table for line items
- Add totals section
- Preview with sample data
- Publish template
When designer says "make logo bigger":
- Traditional: Edit CSS in code, test, deploy (1 hour)
- Template: Resize in editor, save (2 minutes)
When legal says "add VAT notice for EU customers":
- Traditional: Add conditional logic in HTML generation (2 hours)
- Template: Add text field with conditional visibility (10 minutes)
Code Example: Complete Integration
Step 1: Calculate Invoice Data
// invoice-service.js
// Your business logic (not PDF generation)
function calculateInvoice(order) {
const lineItems = order.items.map(item => {
const subtotal = item.quantity * item.unitPrice;
const taxRate = getTaxRate(order.customer.region, item.taxCategory);
const taxAmount = subtotal * taxRate;
return {
description: item.description,
quantity: item.quantity,
unit: item.unit,
unitPrice: item.unitPrice,
subtotal: subtotal,
taxRate: taxRate,
taxAmount: taxAmount,
total: subtotal + taxAmount
};
});
const subtotal = lineItems.reduce((sum, item) => sum + item.subtotal, 0);
const discount = calculateDiscount(subtotal, order.customer.discountTier);
const taxAmount = lineItems.reduce((sum, item) => sum + item.taxAmount, 0);
const total = subtotal - discount.amount + taxAmount;
return {
invoice: {
number: generateInvoiceNumber(),
date: new Date().toISOString().split('T')[0],
dueDate: calculateDueDate(order.paymentTerms)
},
company: getCompanyInfo(),
customer: formatCustomerInfo(order.customer),
lineItems: lineItems,
subtotal: subtotal,
discount: discount,
taxSummary: summarizeTaxes(lineItems),
total: total,
balanceDue: total,
currency: order.currency,
notes: order.notes || getDefaultNotes(),
terms: order.paymentTerms || getDefaultTerms()
};
}
function getTaxRate(region, taxCategory) {
// Your tax logic (could be external service)
const taxRules = {
'US-CA': { standard: 0.0825, food: 0.0, digital: 0.0825 },
'US-NY': { standard: 0.08875, food: 0.0, digital: 0.08875 },
'EU-DE': { standard: 0.19, food: 0.07, digital: 0.19 }
};
return taxRules[region]?.[taxCategory] || 0;
}
Step 2: Generate PDF via API
// pdf-service.js
// Handles only PDF generation (not business logic)
async function generateInvoicePDF(invoiceData) {
const response = await fetch('https://api.hundreddocs.com/v1/pdf', {
method: 'POST',
headers: {
'X-API-Key': process.env.HUNDRED_DOCS_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
templateId: 'invoice-template', // Created once in editor
data: invoiceData
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(`PDF generation failed: ${error.message}`);
}
return await response.arrayBuffer();
}
Step 3: Complete Workflow
// api/orders/[id]/invoice.js
// API endpoint: POST /api/orders/123/invoice
export default async function handler(req, res) {
const { id } = req.query;
try {
// 1. Fetch order from database
const order = await db.orders.findById(id);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
// 2. Calculate invoice data (business logic)
const invoiceData = calculateInvoice(order);
// 3. Generate PDF (presentation logic)
const pdfBuffer = await generateInvoicePDF(invoiceData);
// 4. Save PDF reference in database
await db.invoices.create({
orderId: id,
invoiceNumber: invoiceData.invoice.number,
amount: invoiceData.total,
currency: invoiceData.currency,
generatedAt: new Date()
});
// 5. Return PDF
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="invoice-${invoiceData.invoice.number}.pdf"`);
res.send(Buffer.from(pdfBuffer));
} catch (error) {
console.error('Invoice generation error:', error);
res.status(500).json({ error: 'Failed to generate invoice' });
}
}
Lines of code comparison:
- DIY HTML generation: 300-500 lines (HTML templating + CSS + pagination logic)
- Template API approach: 100-150 lines (business logic only)
Integration Patterns
Pattern 1: Generate on Payment Confirmation
// webhook from Stripe/PayPal/etc.
async function handlePaymentSuccess(event) {
const payment = event.data.payment;
// Generate invoice immediately
const invoiceData = calculateInvoice(payment.order);
const pdfBuffer = await generateInvoicePDF(invoiceData);
// Email to customer
await sendEmail({
to: payment.customer.email,
subject: `Invoice ${invoiceData.invoice.number}`,
body: 'Thank you for your payment. Invoice attached.',
attachments: [{
filename: `invoice-${invoiceData.invoice.number}.pdf`,
content: pdfBuffer
}]
});
}
Pattern 2: Batch Generation for Monthly Billing
// Cron job: runs on 1st of each month
async function generateMonthlyInvoices() {
const subscriptions = await db.subscriptions.findActive();
for (const subscription of subscriptions) {
try {
const invoiceData = calculateSubscriptionInvoice(subscription);
const pdfBuffer = await generateInvoicePDF(invoiceData);
// Store in S3
await s3.upload({
Bucket: 'invoices',
Key: `${subscription.customerId}/${invoiceData.invoice.number}.pdf`,
Body: pdfBuffer
});
// Email to customer
await sendInvoiceEmail(subscription.customer, pdfBuffer);
} catch (error) {
console.error(`Failed to generate invoice for ${subscription.id}:`, error);
await alertOps(error); // Don't fail entire batch
}
}
}
Batch performance:
- 1000 invoices
- API approach: 100 parallel requests = 10-20 seconds
- Puppeteer approach: 1000 sequential renders = 2-3 hours (or memory crash)
Pattern 3: Email Attachment Workflow
// Send invoice immediately after generation
async function sendInvoiceToCustomer(orderId) {
const order = await db.orders.findById(orderId);
const invoiceData = calculateInvoice(order);
// Generate PDF
const pdfBuffer = await generateInvoicePDF(invoiceData);
// Send via email service (SendGrid, Mailgun, etc.)
await emailService.send({
to: order.customer.email,
from: 'billing@company.com',
subject: `Invoice ${invoiceData.invoice.number} from Company`,
html: `
<p>Hi ${order.customer.name},</p>
<p>Thank you for your business. Your invoice is attached.</p>
<p>Amount due: ${formatCurrency(invoiceData.total, invoiceData.currency)}</p>
<p>Due date: ${invoiceData.invoice.dueDate}</p>
`,
attachments: [{
content: Buffer.from(pdfBuffer).toString('base64'),
filename: `invoice-${invoiceData.invoice.number}.pdf`,
type: 'application/pdf',
disposition: 'attachment'
}]
});
}
Performance and Reliability
Response Times
| Approach | Single Invoice | 100 Invoices (parallel) | 1000 Invoices |
|---|---|---|---|
| Puppeteer (Lambda) | 5-15s | 10-30 min | Crashes |
| Puppeteer (EC2) | 3-8s | 5-15 min | 1-3 hours |
| Template API | 300-800ms | 30-90s | 5-15 min |
Error Handling
// Robust error handling
async function generateInvoicePDFWithRetry(invoiceData, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await generateInvoicePDF(invoiceData);
} catch (error) {
console.error(`Attempt ${attempt} failed:`, error);
if (attempt === maxRetries) {
// Final attempt failed, alert and throw
await alertOps(`Invoice PDF generation failed after ${maxRetries} attempts`, {
invoiceNumber: invoiceData.invoice.number,
error: error.message
});
throw error;
}
// Exponential backoff before retry
await sleep(1000 * Math.pow(2, attempt));
}
}
}
Related Content
- JSON to PDF Architecture - How data/presentation separation works
- Bulk PDF Report Generation - Generating thousands of invoices at scale
- HTML to PDF Footer Header Issue - Why totals positioning is hard
- Serverless PDF Generation - Architectural considerations for invoice APIs
- How to Create a PDF Invoice from JSON - A step-by-step procedural guide.
Technical takeaway: Invoice PDFs require variable-length line items with clean pagination, tax calculations by region, legal compliance fields, and totals positioned after the last item (not at a fixed location). DIY HTML generation mixes business logic with presentation, making invoices brittle and hard to maintain. Template-based APIs separate concerns: your app handles tax calculation and data formatting, the API handles pagination and rendering. This results in 50-70% less code, faster rendering (300-800ms vs 3-15s), and non-technical users can update invoice designs without code deployment.