PDFPipe

Tutorial

Automate month-end invoice generation with n8n

Month-end billing is the same six steps every month: pull the billable records, group them by customer, lay them out, render a PDF, send it, and file a copy. That is a workflow, not a job for a person. Here is the whole thing in n8n, including the parts that break.

What you end up with

A workflow that runs unattended on the first of the month. It fetches last month's billable records, groups them per customer, builds an HTML invoice for each, renders each to PDF, emails it, and writes the result back so you can see what shipped and what did not. Roughly twenty minutes to build, and it replaces a morning of work every month.

  • Schedule Trigger, first of the month at 07:00
  • Fetch billable records for the previous month
  • Code node to group line items by customer and total them
  • HTML node to lay out the invoice
  • HTTP Request to render it to PDF
  • Send Email, then write the outcome back to your system

1. Trigger on a schedule, not on a person remembering

Add a Schedule Trigger. Set it to Cron and use 0 7 1 * *, which fires at 07:00 on the first of every month. Run it in your billing timezone rather than UTC unless your finance team thinks in UTC, because an invoice dated the 31st when everyone expects the 1st causes an argument every quarter.

text
Schedule Trigger
  Mode:     Custom (Cron)
  Cron:     0 7 1 * *
  Timezone: Europe/Paris   <- your billing timezone, not UTC

2. Fetch the month's billable records

Whatever holds your billable work (Postgres, Airtable, Stripe, a time tracker) goes here. The shape below is what the rest of the workflow assumes: one row per line item, each carrying the customer it belongs to. Grouping happens in the next node, not in your query, so the same workflow survives a change of data source.

json
[
  {
    "customer_id": "cus_8812",
    "customer_name": "Acme Corp",
    "customer_email": "ap@acme.example",
    "billing_address": "12 Rue de la Paix, 75002 Paris",
    "vat_number": "FR40303265045",
    "description": "Design retainer, March",
    "quantity": 1,
    "unit_price": 240000,
    "currency": "EUR"
  },
  {
    "customer_id": "cus_8812",
    "customer_name": "Acme Corp",
    "customer_email": "ap@acme.example",
    "description": "Additional design hours",
    "quantity": 6,
    "unit_price": 12000,
    "currency": "EUR"
  }
]

Store money as integers

unit_price is in minor units: 240000 is 2,400.00 EUR. Every invoicing system that stores money as a float eventually produces a total ending in .9999999 and a customer who screenshots it. Divide by 100 once, at display time, and never before.

3. Group line items by customer

A Code node turns the flat list into one item per customer with a nested lines array and a computed total. Set the node to Run Once for All Items, since it needs the whole set to group correctly.

javascript
// Code node, mode: Run Once for All Items
// Flat line items in, one invoice per customer out.
const byCustomer = new Map();

for (const { json: row } of $input.all()) {
  if (!byCustomer.has(row.customer_id)) {
    byCustomer.set(row.customer_id, {
      customer_id: row.customer_id,
      customer_name: row.customer_name,
      customer_email: row.customer_email,
      billing_address: row.billing_address ?? "",
      vat_number: row.vat_number ?? "",
      currency: row.currency ?? "EUR",
      lines: [],
    });
  }
  byCustomer.get(row.customer_id).lines.push({
    description: row.description,
    quantity: row.quantity,
    unit_price: row.unit_price,
    amount: row.quantity * row.unit_price,
  });
}

// Period is last month, because this runs on the 1st.
const now = new Date();
const period = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const periodLabel = period.toLocaleString("en-GB", { month: "long", year: "numeric" });

return [...byCustomer.values()].map((inv, i) => {
  const subtotal = inv.lines.reduce((sum, l) => sum + l.amount, 0);
  const vatRate = inv.vat_number ? 0 : 0.2;   // reverse charge if VAT registered
  const vat = Math.round(subtotal * vatRate);
  return {
    json: {
      ...inv,
      // Deterministic: re-running the workflow produces the same number, so a
      // retry cannot create a duplicate invoice.
      invoice_number: `INV-${period.getFullYear()}${String(period.getMonth() + 1).padStart(2, "0")}-${String(i + 1).padStart(4, "0")}`,
      period_label: periodLabel,
      issue_date: now.toISOString().slice(0, 10),
      subtotal,
      vat,
      total: subtotal + vat,
    },
  };
});

4. Lay out the invoice as HTML

Use the HTML node. n8n resolves {{ }} expressions inside it, so you can drive the layout straight from the previous node. Two things matter for print: set an explicit @page size and margin, and use break-inside on table rows so a line item never splits across a page.

html
<style>
  @page { size: A4; margin: 18mm 16mm; }
  body { font: 12px/1.5 system-ui, sans-serif; color: #111; }
  h1 { font-size: 20px; margin: 0 0 4px; }
  .meta { color: #666; margin-bottom: 24px; }
  table { width: 100%; border-collapse: collapse; margin-top: 20px; }
  th { text-align: left; border-bottom: 1.5px solid #111; padding: 6px 0; }
  td { padding: 6px 0; border-bottom: 1px solid #eee; }
  tr { break-inside: avoid; }          /* never split a line item */
  .num { text-align: right; font-variant-numeric: tabular-nums; }
  .total { font-weight: 700; border-top: 1.5px solid #111; }
</style>

<h1>Invoice {{ $json.invoice_number }}</h1>
<p class="meta">
  {{ $json.period_label }} &middot; Issued {{ $json.issue_date }}<br>
  {{ $json.customer_name }}<br>
  {{ $json.billing_address }}
  {{ $json.vat_number ? "<br>VAT: " + $json.vat_number : "" }}
</p>

<table>
  <thead>
    <tr><th>Description</th><th class="num">Qty</th><th class="num">Amount</th></tr>
  </thead>
  <tbody>
    {{ $json.lines.map(l =>
      `<tr>
         <td>${l.description}</td>
         <td class="num">${l.quantity}</td>
         <td class="num">${($json.currency)} ${(l.amount / 100).toFixed(2)}</td>
       </tr>`
    ).join("") }}
    <tr class="total">
      <td>Total due</td><td></td>
      <td class="num">{{ $json.currency }} {{ ($json.total / 100).toFixed(2) }}</td>
    </tr>
  </tbody>
</table>

5. Render it

An HTTP Request node posts the HTML and gets the PDF back. Set Response Format to File so n8n keeps it as binary rather than mangling it into a string, and name the binary property so the email node can find it.

text
HTTP Request
  Method:           POST
  URL:              https://api.pdfpipe.xyz/v1/pdf
  Authentication:   Header Auth
    Name:           Authorization
    Value:          Bearer pp_live_...        <- store as an n8n credential
  Send Body:        on, JSON
  Body:
    {
      "html": "{{ $json.html }}",
      "options": { "format": "A4", "printBackground": true }
    }
  Response Format:  File
  Binary Property:  data

Or skip the HTTP node entirely

There is a community node, n8n-nodes-pdfpipe, that wraps this. Install it from Settings, Community Nodes, and you get a PDFPipe node with the credential handling and binary output already wired up. The HTTP Request version above is worth understanding first, because when something fails you will be reading the raw request.

6. Send it, then record what happened

Attach the binary property to a Send Email node. Then, and this is the step people skip, write the outcome back to your billing system: invoice number, total, sent timestamp. Without it you cannot answer 'did Acme get March's invoice' without opening your sent folder.

Attach

Send Email, Attachments, binary property 'data'. Filename from the invoice number so support can find it later.

Record

Write invoice_number, total and sent_at back to your database in the same run.

Archive

Copy each PDF to storage. Tax authorities in most jurisdictions want them retained for years.

The part tutorials leave out: one customer will fail

On a fifty-customer run, something will break for exactly one of them. A missing billing address, an address that bounces, a line item with a null description. The default n8n behaviour stops the whole workflow, so customers 1 to 23 get invoiced and 24 to 50 do not, and nobody notices until someone asks why they were not billed.

  • Set On Error to Continue on the render and email nodes, so one failure does not halt the run.
  • Route failures to a branch that writes them somewhere you will actually look.
  • Because invoice_number is derived from the period and index rather than a counter, re-running is safe: the same customer gets the same number instead of a duplicate.
  • Send yourself a one-line summary at the end: how many rendered, how many sent, how many failed.

Why not just print to PDF from the browser

Because nobody is there at 07:00 on the first. Browser print is the right tool when a person is clicking a button and can see the result. It is the wrong tool for anything that runs unattended, needs consistent page breaks across fifty documents, and has to embed the same fonts every time. That is the whole distinction, and it is worth being honest that for a handful of invoices a month, printing by hand is genuinely fine.

Try the render call on its own first

Before wiring the whole workflow, check the render step in isolation. The free tier covers 100 documents a month with no card, which is more than a month-end run for most teams.

bash
curl -X POST https://api.pdfpipe.xyz/v1/pdf \
  -H "Authorization: Bearer pp_live_..." \
  -H "Content-Type: application/json" \
  -d '{"html": "<h1>Invoice INV-202603-0001</h1>", "options": {"format": "A4"}}' \
  --output invoice.pdf

Keep reading

The rest of the series, for when this one leaves a question open.

100 free documents a month, flat pricing after that, and a live playground you can try without signing up.