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日"

Different countries mandate specific information on invoices:

CountryRequired Fields
USBusiness name, address, invoice number, date, line items, totals
EUAll above + VAT number, customer VAT number, VAT rate per item, reverse charge notice (B2B)
CanadaAll above + GST/HST number, PST number (if applicable)
AustraliaAll above + ABN (Australian Business Number), GST notice
UKAll 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)

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):

  1. Open template editor
  2. Drag logo, invoice number, date fields
  3. Add table for line items
  4. Add totals section
  5. Preview with sample data
  6. 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

ApproachSingle Invoice100 Invoices (parallel)1000 Invoices
Puppeteer (Lambda)5-15s10-30 minCrashes
Puppeteer (EC2)3-8s5-15 min1-3 hours
Template API300-800ms30-90s5-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));
    }
  }
}

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.