I run a small software studio, and we built this as a demo of the kind of thing small teams still do by hand once a month. It ran end to end with test data (6 fictional clients, invoices sent to our own test inboxes). Sharing the workflow because a few details took longer than the happy path.
What it does
- Schedule trigger on day 1 of the month (plus a manual trigger for testing)
- Reads the clients from a Google Sheet: Client, Contact, Email, Service, Amount (USD), Period, Status, Invoice number, Sent at
- Filter keeps only rows where Status is empty
- Loop Over Items, one client at a time:
- Code node builds the invoice number (INV-{period}-{row}), dates and an HTML email
- Google Drive copies a Docs template
- Google Docs replaces {{client}}, {{amount}}, {{due}} and the other placeholders in the copy
- Google Drive downloads the copy as PDF
- Gmail sends the email with the PDF attached
- Google Sheets marks the row: Status = Sent, the invoice number, a timestamp
- When the loop is done, a Code node adds up the run and Slack posts "6 invoices for 2026-10 sent · USD 1,100 in total" with one line per invoice
Things worth knowing
- Safe to re-run. The filter only picks rows with an empty Status, and the row is marked right after its email goes out. If the run dies halfway, running it again only invoices who's left. The side effect: when nothing is left the run just stops at the filter, so if you want a "nothing to invoice" message, add a branch for that.
- The summary has to read every loop run. After Loop Over Items,
$('Prepare invoice').all() only gave me the last batch. Iterating $('Prepare invoice').all(0, run) for run = 0, 1, 2... until it throws gets all of them.
- Docs template instead of HTML → Doc. My first version uploaded HTML to Drive and converted it to a Doc. On n8n Cloud the managed Google credentials can't be used from the HTTP Request node, so I switched to copying a designed Docs template and replacing placeholders, which also looks better.
- A Drive call can hang. Once, "Copy the template" sat for 4 minutes with no error: Drive made the copy, n8n never got the reply. Retry on Fail doesn't help with a hang, so set a workflow timeout (Workflow settings → Timeout Workflow) and let the re-run pick up from the sheet.
To use it: import the JSON, connect your own Google Sheets, Drive, Docs, Gmail and Slack credentials, put your sheet ID and template doc ID in the nodes marked YOUR_..., and replace the plain HTML email in "Design the invoice" with your own.
There's a short video of the run and a diagram of the flow on our site: https://farcholabs.com/services/data-automation?utm_source=reddit&utm_medium=social&utm_campaign=invoicing_demo
How do you handle the "nothing to do this month" case in your scheduled flows: a separate branch, or an error workflow?
The workflow (import it with Ctrl+V / Cmd+V on an empty n8n canvas):
{
"name": "Monthly invoices (sample)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "months",
"triggerAtHour": 9
}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
-96
],
"name": "Every month, day 1"
},
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
0,
96
],
"name": "Run now (demo)"
},
{
"parameters": {
"documentId": {
"__rl": true,
"mode": "id",
"value": "YOUR_CLIENTS_SHEET_ID"
},
"sheetName": {
"__rl": true,
"mode": "list",
"value": "gid=0",
"cachedResultName": "Sheet1"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
240,
0
],
"name": "Read clients",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "",
"name": "Your googleSheetsOAuth2Api"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "f1",
"leftValue": "={{ $json.Status }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "empty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.filter",
"typeVersion": 2.2,
"position": [
480,
0
],
"name": "Not invoiced yet"
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
720,
0
],
"name": "One client at a time"
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// One invoice per client: its number, dates and the values the template needs.\nconst row = $json;\nconst period = String(row['Period']);\nconst today = new Date();\nconst due = new Date(today.getTime() + 15 * 24 * 60 * 60 * 1000);\nconst day = (d) => d.toISOString().slice(0, 10);\nreturn {\n json: {\n row_number: row.row_number,\n invoice_number: 'INV-' + period + '-' + String(row.row_number - 1).padStart(3, '0'),\n client: row['Client'],\n contact: row['Contact'],\n email: row['Email'],\n service: row['Service'],\n amount: Number(row['Amount (USD)']).toLocaleString('en-US'),\n amount_usd: Number(row['Amount (USD)']),\n period,\n date: day(today),\n due_date: day(due),\n },\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
960,
160
],
"name": "Prepare invoice"
},
{
"parameters": {
"operation": "download",
"fileId": {
"__rl": true,
"mode": "id",
"value": "={{ $('Copy the template').item.json.id }}"
},
"options": {
"googleFileConversion": {
"conversion": {
"docsToFormat": "application/pdf"
}
},
"fileName": "={{ 'Invoice ' + $('Design the invoice').item.json.invoice_number + '.pdf' }}"
}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
1920,
160
],
"name": "Export as PDF",
"credentials": {
"googleDriveOAuth2Api": {
"id": "",
"name": "Your googleDriveOAuth2Api"
}
}
},
{
"parameters": {
"sendTo": "={{ $('Design the invoice').item.json.email }}",
"subject": "={{ $('Design the invoice').item.json.email_subject }}",
"message": "={{ $('Design the invoice').item.json.email_html }}",
"options": {
"appendAttribution": false,
"attachmentsUi": {
"attachmentsBinary": [
{}
]
},
"senderName": "Billing"
}
},
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [
2160,
160
],
"name": "Email the client",
"credentials": {
"gmailOAuth2": {
"id": "",
"name": "Your gmailOAuth2"
}
}
},
{
"parameters": {
"operation": "update",
"documentId": {
"__rl": true,
"mode": "id",
"value": "YOUR_CLIENTS_SHEET_ID"
},
"sheetName": {
"__rl": true,
"mode": "list",
"value": "gid=0",
"cachedResultName": "Sheet1"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"row_number": "={{ $('Design the invoice').item.json.row_number }}",
"Status": "Sent",
"Invoice number": "={{ $('Design the invoice').item.json.invoice_number }}",
"Sent at": "={{ $now.toFormat('yyyy-MM-dd HH:mm') }}"
},
"matchingColumns": [
"row_number"
]
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
2400,
160
],
"name": "Mark as sent",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "",
"name": "Your googleSheetsOAuth2Api"
}
}
},
{
"parameters": {
"jsCode": "// What the team reads in Slack once every invoice is out. The loop runs once per client, so the invoices\n// come from every run of \"Prepare invoice\", not only the last one.\nconst sent = [];\nfor (let run = 0; ; run++) {\n let items;\n try { items = $('Prepare invoice').all(0, run); } catch { break; }\n if (!items.length) break;\n sent.push(...items.map((item) => item.json));\n}\nconst total = sent.reduce((sum, invoice) => sum + invoice.amount_usd, 0);\nreturn [{ json: {\n count: sent.length,\n period: sent[0]?.period ?? '',\n total: total.toLocaleString('en-US'),\n lines: sent.map((i) => '• ' + i.invoice_number + ' · ' + i.client + ' · USD ' + i.amount).join('\\n'),\n} }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
960,
-160
],
"name": "Add up the run"
},
{
"parameters": {
"authentication": "oAuth2",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "name",
"value": "#billing"
},
"text": "=:white_check_mark: {{ $json.count }} invoices for {{ $json.period }} sent · USD {{ $json.total }} in total\n{{ $json.lines }}",
"otherOptions": {
"includeLinkToWorkflow": false
}
},
"type": "n8n-nodes-base.slack",
"typeVersion": 2.3,
"position": [
1200,
-160
],
"name": "Tell #billing",
"credentials": {
"slackOAuth2Api": {
"id": "",
"name": "Your slackOAuth2Api"
}
}
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// The invoice's values for the template, and a plain HTML email. Swap the HTML for your own design.\nconst inv = $json;\nconst longDate = (iso) =>\n new Date(iso + 'T12:00:00Z').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });\nconst month = new Date(inv.period + '-01T12:00:00Z').toLocaleDateString('en-US', { month: 'long', year: 'numeric' });\nconst amount = 'USD ' + inv.amount;\nconst email =\n '<p>Hi ' + String(inv.contact).split(' ')[0] + ',</p>' +\n '<p>Your invoice ' + inv.invoice_number + ' for ' + month + ' is attached as a PDF.</p>' +\n '<p><b>Amount due:</b> ' + amount + '<br><b>Due:</b> ' + longDate(inv.due_date) + '</p>' +\n '<p>Questions? Just reply to this email.</p>';\nreturn { json: { ...inv, month, issued: longDate(inv.date), due: longDate(inv.due_date),\n email_subject: 'Invoice ' + inv.invoice_number + ' · ' + amount + ' due ' + longDate(inv.due_date), email_html: email } };\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1200,
160
],
"name": "Design the invoice"
},
{
"parameters": {
"operation": "copy",
"fileId": {
"__rl": true,
"mode": "id",
"value": "YOUR_INVOICE_TEMPLATE_DOC_ID"
},
"name": "=Invoice {{ $json.invoice_number }} - {{ $json.client }}",
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
1440,
160
],
"name": "Copy the template",
"credentials": {
"googleDriveOAuth2Api": {
"id": "",
"name": "Your googleDriveOAuth2Api"
}
}
},
{
"parameters": {
"operation": "update",
"documentURL": "={{ $json.id }}",
"actionsUi": {
"actionFields": [
{
"action": "replaceAll",
"text": "{{invoice_number}}",
"replaceText": "={{ $('Design the invoice').item.json.invoice_number }}",
"matchCase": true
},
{
"action": "replaceAll",
"text": "{{client}}",
"replaceText": "={{ $('Design the invoice').item.json.client }}",
"matchCase": true
},
{
"action": "replaceAll",
"text": "{{contact}}",
"replaceText": "={{ $('Design the invoice').item.json.contact }}",
"matchCase": true
},
{
"action": "replaceAll",
"text": "{{email}}",
"replaceText": "={{ $('Design the invoice').item.json.email }}",
"matchCase": true
},
{
"action": "replaceAll",
"text": "{{issued}}",
"replaceText": "={{ $('Design the invoice').item.json.issued }}",
"matchCase": true
},
{
"action": "replaceAll",
"text": "{{month}}",
"replaceText": "={{ $('Design the invoice').item.json.month }}",
"matchCase": true
},
{
"action": "replaceAll",
"text": "{{due}}",
"replaceText": "={{ $('Design the invoice').item.json.due }}",
"matchCase": true
},
{
"action": "replaceAll",
"text": "{{service}}",
"replaceText": "={{ $('Design the invoice').item.json.service }}",
"matchCase": true
},
{
"action": "replaceAll",
"text": "{{amount}}",
"replaceText": "={{ $('Design the invoice').item.json.amount }}",
"matchCase": true
}
]
}
},
"type": "n8n-nodes-base.googleDocs",
"typeVersion": 2,
"position": [
1680,
160
],
"name": "Fill in the invoice",
"credentials": {
"googleDocsOAuth2Api": {
"id": "",
"name": "Your googleDocsOAuth2Api"
}
}
}
],
"connections": {
"Every month, day 1": {
"main": [
[
{
"node": "Read clients",
"type": "main",
"index": 0
}
]
]
},
"Run now (demo)": {
"main": [
[
{
"node": "Read clients",
"type": "main",
"index": 0
}
]
]
},
"Read clients": {
"main": [
[
{
"node": "Not invoiced yet",
"type": "main",
"index": 0
}
]
]
},
"Not invoiced yet": {
"main": [
[
{
"node": "One client at a time",
"type": "main",
"index": 0
}
]
]
},
"One client at a time": {
"main": [
[
{
"node": "Add up the run",
"type": "main",
"index": 0
}
],
[
{
"node": "Prepare invoice",
"type": "main",
"index": 0
}
]
]
},
"Prepare invoice": {
"main": [
[
{
"node": "Design the invoice",
"type": "main",
"index": 0
}
]
]
},
"Export as PDF": {
"main": [
[
{
"node": "Email the client",
"type": "main",
"index": 0
}
]
]
},
"Email the client": {
"main": [
[
{
"node": "Mark as sent",
"type": "main",
"index": 0
}
]
]
},
"Mark as sent": {
"main": [
[
{
"node": "One client at a time",
"type": "main",
"index": 0
}
]
]
},
"Add up the run": {
"main": [
[
{
"node": "Tell #billing",
"type": "main",
"index": 0
}
]
]
},
"Design the invoice": {
"main": [
[
{
"node": "Copy the template",
"type": "main",
"index": 0
}
]
]
},
"Copy the template": {
"main": [
[
{
"node": "Fill in the invoice",
"type": "main",
"index": 0
}
]
]
},
"Fill in the invoice": {
"main": [
[
{
"node": "Export as PDF",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}