Invoice PDF Generation from HTML

Updated October 30, 2026 ยท 8 min read

Invoices are layout-sensitive. A total split from its line items, a missing page, or a signature on the wrong page can delay payment. This guide covers a production invoice pipeline from HTML.

1. Build the invoice HTML

Use a semantic table for line items, a totals block, payment terms, and a footer. Keep each row atomic so it never splits across pages.

tr, .totals, .signature, .payment-terms {
  break-inside: avoid;
  page-break-inside: avoid;
}

thead {
  display: table-header-group;
}

2. Generate the invoice

Save the invoice layout as a template and pass the invoice data. Format currency and dates in your backend before passing strings to the template.

const invoice = await client.generate({
  templateId: 'YOUR_INVOICE_TEMPLATE_ID',
  data: {
    invoiceNumber: 'INV-2026-1042',
    customer: 'Acme Ltd',
    issueDate: '2026-10-30',
    dueDate: '2026-11-29',
    currency: 'USD',
    items: [
      { description: 'Design retainer', quantity: 1, amount: '$840.00' },
      { description: 'Support', quantity: 2, amount: '$400.00' }
    ],
    subtotal: '$1,240.00',
    tax: '$0.00',
    total: '$1,240.00'
  },
  downloadBuffer: true
});

Keep the expanded template under the 20 MB request limit. See Limits & file sizes.

3. Keep line items and totals together

Compare the last line item's page with the totals block. If they differ, return a compact patch or fail the request with a clear reason.

const rows = doc.querySelectorAll('table.invoice tbody tr');
const totals = doc.querySelector('.totals');

if (!rows.length || !totals) {
  return { success: false, reason: 'Missing line items or totals' };
}

const lastRowPage = rows[rows.length - 1].pages[0];

if (lastRowPage !== totals.pages[0]) {
  return {
    success: false,
    patch: { compact: true },
    reason: 'Totals split from line items'
  };
}

return { success: true, rows: rows.length };

4. Handle multi-page invoices

Repeat table headers with thead { display: table-header-group; }, add page numbers, and keep totals on the final page. Use a testCase to assert the row count and total.

const rows = doc.querySelectorAll('table.invoice tbody tr');
const total = doc.querySelector('#invoice-total');

return {
  success: Boolean(rows.length && total),
  rowCount: rows.length,
  total: total ? total.innerText.trim() : null
};

5. Signatures and payment details

Query the signature block after layout and return its page and bounding box. Keep it on the same page as the payment terms and final total.

const signature = doc.querySelector('#signature');
const terms = doc.querySelector('.payment-terms');

return {
  success: Boolean(signature && terms && signature.pages[0] === terms.pages[0]),
  signaturePage: signature ? signature.pages[0] + 1 : null
};

See Extract element coordinates for e-signatures.

6. Currency, tax, and locale

Format amounts in your backend with Intl.NumberFormat and pass strings to the template. Use fonts that cover the currency symbol and locale, and avoid floating-point rounding inside the template.

const format = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD'
});

const total = format.format(1240); // '$1,240.00'

7. Validate every invoice

Assert the row count, total, page count, and signature presence. Return custom keys in testResults and fail CI or the delivery job when an assertion breaks.

Automated PDF layout testing

8. Production checklist

  • Use a stable invoice number and idempotency key.
  • Store the generated PDF and its metadata.
  • Use webhooks for batch invoicing.
  • Test long descriptions, large totals, and multiple currencies.
  • Check the request, PDF, and testResults limits.

Generate a sample invoice

Start with the generation options and add a layout check.

Generate PDF API