Generate Invoice PDFs in Node.js: Numbering, VAT, Webhooks and Email
Blog / HTML to PDF
HTML to PDF

Generate Invoice PDFs in Node.js: Numbering, VAT, Webhooks and Email

Demetra Theodorou · Oct 24, 2026

A tested Node.js walkthrough. Sequential numbering, integer money with VAT by rate, an HTML template rendered to PDF with page numbers, a webhook that is safe to retry, email delivery and month-end batches.

An invoice is the one document a customer keeps, forwards to their accountant and compares against a bank statement months later. Generating it by hand works for the first twenty orders and then becomes the task someone forgets on a Friday. This page builds the automated version in Node.js, end to end. A payment arrives, an invoice number is reserved, an HTML template is filled from the order, the HTML to PDF API turns it into a PDF with page numbers, the file is stored under the invoice number, and the customer gets it by email with a download link.

Every block on this page ran against the sandbox before publication. The webhook route was started as a server and called with curl, twice with the same event to prove the duplicate check, and the month-end batch rendered five invoices in 3.6 seconds under the plan’s concurrency limit. The numbers quoted in the text are the numbers the code produced. The same flow in C#, Java, Python or PHP changes only the SDK call, and those versions will follow as their own posts.

Key takeaways

  • Decide what the invoice must contain before touching the template. The number sequence and the VAT breakdown are legal requirements, not design choices.
  • Keep money as integers in minor units, round VAT once per line, and format only when building the HTML.
  • Render once, store the document id, and serve the stored file forever after. An issued invoice never changes.
  • Webhooks arrive more than once. Look the order up and reserve the number before rendering, so a retry returns the existing invoice instead of a second one.

What the invoice has to contain

Most of the trouble with automated invoices is not in the PDF. It is in the fields that were never written down, so the template drifts from what the finance team or the tax office expects. Settle the list first and the rest is plumbing. For a UK or EU business the invoice needs:

  • A unique, sequential number with no gaps. Sequences are the first thing an auditor checks.
  • The issue date and the due date or payment terms.
  • Your legal name, address and VAT number, and the customer’s name and address, plus their VAT number for business customers inside the EU.
  • Each line with a description, quantity, unit price and the VAT rate that applies to it.
  • The net total, the VAT amount per rate, and the gross amount due.
  • How to pay. Bank details and the reference the customer should quote.

If you are not sure which of these apply to you, or what a “compliant” invoice looks like in practice, Jobbit’s guide on how to write an invoice that gets paid walks through the required fields and the UK rules with a worked example. Once the list is fixed, the template below renders every item on it, and the code never has to guess.

Two rules follow from the list and shape the code. The number is reserved from a counter that lives in your database, in the same transaction that records the order, so two webhooks cannot claim the same number. And an invoice is immutable once issued. If a line was wrong, you issue a credit note or a new invoice. You do not re-render the old one with fixed data, which means the PDF must be stored and served from storage on every later download.

Install

The sample uses the Node.js SDK for the API calls, Express for the webhook route, and Nodemailer for the email. Nothing else. The API key goes in PDFGATE_API_KEY; a sandbox key renders the same documents with a watermark, which is what the test runs used.

1npm install pdfgate express nodemailer

Money without floating point

Prices arrive as decimals and leave as formatted strings, but in between they must be integers. A quantity of 12 at 45.00 is fine in floating point. A column of 300 lines at 18.90 with 20% VAT is not, because the rounding error lands on the gross total and the accountant finds it. The module below keeps every amount in minor units, rounds VAT once per line, and groups the VAT by rate so the totals block can show one row per rate. The last function is the only place formatting happens.

1// All amounts are integers in minor units (cents, pence). Floats are never used for money.2export function lineTotals(line, vatRate) {3  const net = line.quantity * line.unitPriceCents;4  const vat = Math.round((net * vatRate) / 100);5  return { net, vat, gross: net + vat };6}78export function invoiceTotals(lines) {9  const byRate = new Map();10  let net = 0;11  let vat = 0;12  for (const line of lines) {13    const totals = lineTotals(line, line.vatRate);14    net += totals.net;15    vat += totals.vat;16    const bucket = byRate.get(line.vatRate) ?? { net: 0, vat: 0 };17    bucket.net += totals.net;18    bucket.vat += totals.vat;19    byRate.set(line.vatRate, bucket);20  }21  return { net, vat, gross: net + vat, byRate: [...byRate.entries()].sort((a, b) => a[0] - b[0]) };22}2324export function formatMoney(cents, currency, locale = "en-GB") {25  return new Intl.NumberFormat(locale, { style: "currency", currency }).format(cents / 100);26}

Intl.NumberFormat knows the currency symbol, the decimal separator and the grouping for the locale, so a German customer can get 1.268,00 € from the same data that prints £1,268.00 for a British one. The sample invoice is in pounds, which is why the totals below read that way.

The template

The template is a function that returns HTML, with no templating library. Template literals already interpolate, and the one thing a library would add, escaping, is three lines. Every value that came from a customer or an order passes through escape, because a product description containing <script> or a stray ampersand should print as text, not run or break the markup.

1import { formatMoney, invoiceTotals, lineTotals } from "./money.js";23const escape = (value) =>4  String(value).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);56const formatDate = (iso, locale = "en-GB") =>7  new Intl.DateTimeFormat(locale, { day: "2-digit", month: "short", year: "numeric" }).format(new Date(iso));89export function renderInvoiceHtml(invoice) {10  const { currency } = invoice;11  const money = (cents) => formatMoney(cents, currency);12  const totals = invoiceTotals(invoice.lines);1314  const rows = invoice.lines15    .map((line) => {16      const t = lineTotals(line, line.vatRate);17      return `<tr>18        <td>${escape(line.description)}</td>19        <td class="num">${line.quantity}</td>20        <td class="num">${money(line.unitPriceCents)}</td>21        <td class="num">${line.vatRate}%</td>22        <td class="num">${money(t.net)}</td>23      </tr>`;24    })25    .join("");2627  const vatRows = totals.byRate28    .map(([rate, t]) => `<tr><td>VAT ${rate}% on ${money(t.net)}</td><td class="num">${money(t.vat)}</td></tr>`)29    .join("");3031  return `<!doctype html>32<html lang="en">33<head>34<meta charset="utf-8">35<style>36  @page { size: A4; }37  body { font-family: Helvetica, Arial, sans-serif; font-size: 11pt; color: #1f2933; margin: 0; }38  .top { display: flex; justify-content: space-between; align-items: flex-start; }39  h1 { font-size: 24pt; margin: 0 0 4px; letter-spacing: 1px; }40  .meta td { padding: 1px 12px 1px 0; color: #52606d; }41  .meta td:last-child { color: #1f2933; font-weight: 600; }42  .parties { display: flex; gap: 48px; margin: 28px 0 24px; }43  .parties h2 { font-size: 9pt; text-transform: uppercase; letter-spacing: 1.5px; color: #829ab1; margin: 0 0 6px; }44  .parties p { margin: 0; line-height: 1.45; }45  table.lines { width: 100%; border-collapse: collapse; }46  table.lines th { text-align: left; font-size: 9pt; text-transform: uppercase; letter-spacing: 1px; color: #829ab1; border-bottom: 2px solid #d9e2ec; padding: 8px 6px; }47  table.lines td { padding: 9px 6px; border-bottom: 1px solid #e4e7eb; vertical-align: top; }48  tr { break-inside: avoid; }49  thead { display: table-header-group; }50  .num { text-align: right; white-space: nowrap; }51  .totals { margin-left: auto; margin-top: 16px; width: 45%; border-collapse: collapse; }52  .totals td { padding: 5px 6px; }53  .totals tr.due td { border-top: 2px solid #1f2933; font-weight: 700; font-size: 13pt; padding-top: 10px; }54  .terms { margin-top: 36px; padding-top: 16px; border-top: 1px solid #d9e2ec; color: #52606d; font-size: 10pt; line-height: 1.5; }55  .terms strong { color: #1f2933; }56</style>57</head>58<body>59  <div class="top">60    <div>61      <h1>INVOICE</h1>62      <table class="meta">63        <tr><td>Invoice number</td><td>${escape(invoice.number)}</td></tr>64        <tr><td>Issue date</td><td>${formatDate(invoice.issuedAt)}</td></tr>65        <tr><td>Due date</td><td>${formatDate(invoice.dueAt)}</td></tr>66        <tr><td>Order reference</td><td>${escape(invoice.orderId)}</td></tr>67      </table>68    </div>69    <div style="text-align:right">70      <strong>${escape(invoice.seller.name)}</strong><br>71      ${invoice.seller.address.map(escape).join("<br>")}<br>72      VAT ${escape(invoice.seller.vatNumber)}73    </div>74  </div>7576  <div class="parties">77    <div>78      <h2>Billed to</h2>79      <p><strong>${escape(invoice.customer.name)}</strong><br>${invoice.customer.address.map(escape).join("<br>")}${80        invoice.customer.vatNumber ? `<br>VAT ${escape(invoice.customer.vatNumber)}` : ""81      }</p>82    </div>83  </div>8485  <table class="lines">86    <thead>87      <tr><th>Description</th><th class="num">Qty</th><th class="num">Unit price</th><th class="num">VAT</th><th class="num">Net</th></tr>88    </thead>89    <tbody>${rows}</tbody>90  </table>9192  <table class="totals">93    <tr><td>Net total</td><td class="num">${money(totals.net)}</td></tr>94    ${vatRows}95    <tr class="due"><td>Amount due</td><td class="num">${money(totals.gross)}</td></tr>96  </table>9798  <div class="terms">99    <p><strong>Payment terms.</strong> ${escape(invoice.paymentTerms)}</p>100    <p><strong>Bank transfer.</strong> ${escape(invoice.seller.bank)} &middot; Reference <strong>${escape(invoice.number)}</strong></p>101  </div>102</body>103</html>`;104}

A few of the CSS rules are there for print rather than for looks. @page declares the paper size so the layout is computed for A4 from the start. thead is set to table-header-group, which makes Chrome repeat the column headings when a long invoice spills onto a second page. break-inside: avoid on the rows keeps a line item from splitting across pages. The totals table is pushed right with margin-left: auto instead of floats, which print engines handle more predictably. If you need finer control over where pages break, the page breaks post covers the properties in detail.

Rendering it

One function turns an invoice object into a stored PDF. The options are the ones that matter for an invoice. pageSizeType is set explicitly because the default is Ledger. The margins leave room for a footer. The footer fragment carries the invoice number and the page count, and the renderer fills the pageNumber and totalPages elements on every page. printBackground keeps the table rules and shading, which Chrome drops by default when printing. metadata is stored with the document, so a support engineer can find the file by invoice number later. And preSignedUrlExpiresIn returns a download link that is valid for 24 hours, the maximum.

1import PdfGate, { PageSizeType } from "pdfgate";2import { renderInvoiceHtml } from "./template.js";34const client = new PdfGate(process.env.PDFGATE_API_KEY);56const footer = `7  <div style="width:100%;font-size:8pt;color:#829ab1;padding:0 15mm;display:flex;justify-content:space-between;font-family:Helvetica,Arial,sans-serif">8    <span>{{invoiceNumber}}</span>9    <span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>10  </div>`;1112export async function renderInvoice(invoice) {13  return client.generatePdf({14    html: renderInvoiceHtml(invoice),15    pageSizeType: PageSizeType.a4,16    margin: { top: "18mm", bottom: "22mm", left: "15mm", right: "15mm" },17    footer: footer.replace("{{invoiceNumber}}", invoice.number),18    printBackground: true,19    // Stored with the document, so the file can be found by invoice later.20    metadata: { invoiceNumber: invoice.number, orderId: invoice.orderId },21    // A download link valid for 24 hours, the maximum. The stored file outlives22    // the link; getDocument() mints a fresh one whenever a customer asks again.23    preSignedUrlExpiresIn: 24 * 3600,24  });25}2627export const downloadInvoice = (documentId) => client.getFile({ documentId });28export const lookupInvoice = (documentId) =>29  client.getDocument({ id: documentId, preSignedUrlExpiresIn: 24 * 3600 });

The response is a document object, not the PDF bytes. That is deliberate. The id is what you store against the order, getFile fetches the bytes whenever you need them, and getDocument returns the record again with a fresh link, so a customer who lost the email can get a new one without a re-render. This needs file storage enabled on the account, which the dashboard controls, since without storage there is nothing to fetch later.

1import { writeFile } from "node:fs/promises";2import { renderInvoice, downloadInvoice, lookupInvoice } from "./render.js";3import { sampleInvoice } from "./sample-invoice.js";45const document = await renderInvoice(sampleInvoice);6console.log("rendered", { id: document.id, status: document.status, size: document.size, metadata: document.metadata, hasUrl: Boolean(document.fileUrl) });7await writeFile("invoice.pdf", await downloadInvoice(document.id));8const again = await lookupInvoice(document.id);9console.log("lookup", { id: again.id, status: again.status, metadata: again.metadata });

Running that against the sandbox produced a 65 KB single page invoice with a net total of £6,812.50, £1,268.00 of VAT at 20% on £6,340.00, nothing at 0% on the printed books, and £8,080.50 due, with INV-2026-000184 in the footer on the left and “Page 1 of 1” on the right. For a longer footer or a header with a logo, the headers and footers guide has the details, and the page size guide covers paper sizes other than A4.

Retrying the right errors

An API call can fail for two very different reasons. A 400 or 422 means the request is wrong, usually a bad option or HTML the renderer refused, and sending it again changes nothing. A 429 means the account is at its concurrency limit, and a 5xx means the service had a problem. Those two are worth a second attempt after a short wait. The helper below retries only those, three times with doubling delays, and throws everything else straight away. The SDK’s errors carry a statusCode, so the decision is one comparison.

1// Retry only what is worth retrying: rate limits and server errors.2// A 400 or 422 means the request itself is wrong and will fail again.3export async function withRetry(task, { attempts = 3, baseDelayMs = 500 } = {}) {4  for (let attempt = 1; ; attempt++) {5    try {6      return await task();7    } catch (error) {8      const status = error.name === "PdfGateApiError" ? error.statusCode : undefined;9      const retryable = status === 429 || (status !== undefined && status >= 500);10      if (!retryable || attempt === attempts) throw error;11      await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** (attempt - 1)));12    }13  }14}

Triggering it from a payment webhook

The natural trigger is the payment provider’s webhook. Stripe, Mollie, Paddle and the others all deliver a payment.succeeded style event with the order and customer data, and all of them retry the delivery when your endpoint does not answer 2xx quickly. That second delivery is the case the route has to handle, or every network blip produces a duplicate invoice with the next number in the sequence.

The route looks the order up first and returns the existing invoice if there is one. Otherwise it reserves the next number and records the order as rendering before any API call is made, so a second delivery that arrives during the render finds the reservation. If the render fails after the retries, the reservation is released so the provider’s next attempt can succeed. The second route serves the stored PDF by invoice number for the customer’s account page. It fetches by document id and never re-renders.

1import express from "express";2import { renderInvoice, downloadInvoice } from "./render.js";3import { sendInvoiceEmail } from "./email.js";4import { withRetry } from "./retry.js";5import { invoices, nextInvoiceNumber } from "./store.js";6import { sampleInvoice } from "./sample-invoice.js";78const app = express();9app.use(express.json());1011// Payment providers retry webhooks, so the same payment can arrive twice.12// One invoice per order: look it up first, and reserve the number before rendering.13app.post("/webhooks/payments", async (req, res, next) => {14  const event = req.body;15  if (event.type !== "payment.succeeded") return res.sendStatus(204);1617  const existing = invoices.get(event.orderId);18  if (existing) {19    return res.status(200).json({ invoice: existing.number, documentId: existing.documentId, duplicate: true });20  }2122  const invoice = {23    ...sampleInvoice, // seller, terms and bank details come from your settings24    number: nextInvoiceNumber(),25    orderId: event.orderId,26    currency: event.currency,27    issuedAt: new Date().toISOString().slice(0, 10),28    dueAt: new Date(Date.now() + 14 * 86400000).toISOString().slice(0, 10),29    customer: event.customer,30    lines: event.lines,31  };32  invoices.set(event.orderId, { number: invoice.number, status: "rendering" });3334  try {35    const document = await withRetry(() => renderInvoice(invoice));36    invoices.set(event.orderId, { number: invoice.number, documentId: document.id, status: "sent" });3738    const pdf = await downloadInvoice(document.id);39    const mail = await sendInvoiceEmail({ to: event.customer.email, invoice, pdf, link: document.fileUrl });40    console.log("email", JSON.parse(mail.message).subject, "->", event.customer.email);4142    res.status(201).json({ invoice: invoice.number, documentId: document.id });43  } catch (error) {44    invoices.delete(event.orderId); // let the provider's retry try again45    next(error);46  }47});4849// Customers re-download from their account page. The stored file is fetched by id,50// so nothing is re-rendered and the invoice can never change after it was issued.51app.get("/invoices/:number/pdf", async (req, res, next) => {52  const record = [...invoices.values()].find((i) => i.number === req.params.number);53  if (!record?.documentId) return res.sendStatus(404);54  try {55    const pdf = await downloadInvoice(record.documentId);56    res.type("application/pdf").attachment(`${record.number}.pdf`).send(pdf);57  } catch (error) {58    next(error);59  }60});6162app.listen(3101, () => console.log("listening on 3101"));

The in-memory Map and the counter stand in for your database. In production both live in one table and the reservation is an insert with a unique constraint on the order id, which is what makes it safe under concurrent deliveries. Nodemailer attaches the bytes and includes the 24 hour link in the body. With the JSON transport, which the test used, it returns the message it would have sent instead of sending it.

The test sequence against the sandbox went like this. The first delivery returned 201 with INV-2026-000185 and a document id. The same event sent again returned 200 with the same number and duplicate: true, with no second render. The re-download route returned the stored 61 KB PDF with the right filename. A payment.refunded event got a 204 and nothing else happened. And the server log showed one email, with the subject carrying the invoice number, addressed to the customer.

Month-end batches

Subscription businesses also render in bulk, once a month, for every active account. Firing a thousand requests at once gets a 429 for most of them, because each plan has a concurrency limit. The helper below works through the list with a fixed number of workers, retries where it makes sense, and records a result per invoice instead of stopping at the first failure. Set the worker count to your plan’s concurrency limit.

1import { renderInvoice } from "./render.js";2import { withRetry } from "./retry.js";34// Month-end runs: render many invoices without exceeding the plan's concurrency limit.5export async function renderAll(invoices, concurrency = 3) {6  const queue = [...invoices];7  const results = [];8  const worker = async () => {9    while (queue.length) {10      const invoice = queue.shift();11      try {12        const document = await withRetry(() => renderInvoice(invoice));13        results.push({ number: invoice.number, documentId: document.id, ok: true });14      } catch (error) {15        results.push({ number: invoice.number, ok: false, error: error.message });16      }17    }18  };19  await Promise.all(Array.from({ length: concurrency }, worker));20  return results;21}

Five invoices with three workers took 3.6 seconds on the sandbox, all five stored with their own document id. At that rate a thousand invoices is about twelve minutes on the smallest plan and under three on a plan with ten concurrent renders, which is why month-end should be a job, not a request handler.

Where to go next

Frequently asked questions

How do I generate an invoice PDF automatically after a payment?

Handle the payment provider's webhook, build the invoice from the order data, render an HTML template to PDF through the API, store the returned document id against the order, then email the file. The webhook section on this page shows the whole route in Express, including the duplicate check that providers' retries make necessary.

Why should amounts be stored as integers instead of decimals?

Binary floating point cannot represent most decimal fractions, so 0.1 plus 0.2 is not 0.3 and a column of line totals drifts by a cent. Keep every amount in minor units as an integer, round VAT once per line, and format for display only when the HTML is built.

Can an invoice be re-rendered later if the template changes?

It should not be. An issued invoice is a record that both sides keep, so the file sent to the customer must stay byte for byte the same. Store the document id and serve the stored file on every later download. Corrections go on a credit note or a new invoice with its own number.

How do page numbers and the invoice number get into the footer?

Pass a footer HTML fragment in the request. Elements with the pageNumber and totalPages classes are filled in by the renderer on every page, and the invoice number is inserted into the fragment before the request is sent. Leave enough bottom margin for the footer to sit in.

What if the PDF render fails while handling the payment provider's webhook?

The render call is wrapped in the retry helper, so rate limits and server errors get a second and third attempt with a short backoff, while validation errors fail at once because the same request would fail again. If every attempt fails, the route releases the reserved order and answers with an error, so Stripe, Mollie or whichever provider you use redelivers the event later and the invoice is created then. The error also reaches your logging through next(error).

Ready to send your first automated invoice? Create a free account on the HTML to PDF API page, drop the sandbox key into the sample, and the webhook route above runs as is.

This website uses cookies to offer a better browsing experience. By clicking on the "Accept" button you accept our Cookie policy.