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.
Schedule Trigger
Mode: Custom (Cron)
Cron: 0 7 1 * *
Timezone: Europe/Paris <- your billing timezone, not UTC2. 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.
[
{
"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.
// 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.
<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 }} · 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.
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: dataOr 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.
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.pdfKeep reading
The rest of the series, for when this one leaves a question open.
How to create PDFs with Puppeteer
A clean example, the print options that matter, and the production pitfalls to expect.
How to generate PDFs at scale
Batching, async webhooks, idempotency, and retries for thousands of documents.
How to run Puppeteer in production
Recycling, concurrency limits, sandbox flags, and the failure modes to plan for.
100 free documents a month, flat pricing after that, and a live playground you can try without signing up.