💸 Late payments silently drain small business resources. Research shows that small businesses spend an average of 15 hours per month chasing overdue invoices — time that could be spent on revenue‑generating activities. You send email reminders, make phone calls, update spreadsheets, and still, the money arrives late.
After automating invoice follow‑up for my own consulting business and for six clients over the past year, I have reduced the average payment delay from 18 days to just 4 days. The solution is not a complex ERP system — it is a simple, two‑workflow n8n pipeline that costs less than $0.10 per month to run.
Build two n8n workflows: (1) a webhook that receives PDF invoices, uses DeepSeek AI to extract key fields (invoice number, due date, amount, client email), and stores them in Google Sheets. (2) a daily schedule that checks for unpaid overdue invoices and sends personalised reminder emails via Gmail. Total setup time: 45 minutes. Total monthly cost: under $0.10.
Jump to Section
- 1. Pipeline Overview
- 2. Tools & Prerequisites
- 3. Workflow A – Process Invoice
- 4. Workflow B – Daily Reminders
- 5. Advanced: Escalating Reminders
- 6. Stripe Integration (Auto Paid Status)
- 7. Download Both JSON Workflows
- 8. Common Errors & Fixes
- 9. Real Cost Breakdown
- 10. Scaling & Advanced Tips
- 11. FAQ
1. Pipeline Overview
We are going to build two independent n8n workflows that work together seamlessly:
📄 Workflow A: Process Invoice
Webhook → Extract from File → DeepSeek → Google Sheets → End
⏰ Workflow B: Daily Reminders
Schedule Trigger → Google Sheets (read all) → Code node (filter) → Split In Batches → Gmail → Wait → loop
The separation into two workflows is intentional. Workflow A handles the ingestion and processing of new invoices. Workflow B handles the daily follow‑up logic. This keeps each workflow simple, easy to debug, and prevents a single failure from breaking both functions. Later, we'll extend Workflow B with escalating reminders and automatic payment detection via Stripe.
"I used to spend every Monday morning copying data from PDF invoices into my accounting spreadsheet and sending reminder emails manually. After building this n8n pipeline, I now spend zero minutes on follow‑up. The system just works." — From a TriggerWorkflow client who processes 80+ invoices per month
Separating ingestion (incoming invoices) from action (reminders) makes each workflow easier to test, modify, and troubleshoot. It also means you can run the reminder workflow independently — for example, you can test it with sample data without uploading real invoices.
2. Tools & Prerequisites
Before you start building, make sure you have these accounts and credentials ready:
- n8n account — either the cloud free tier (2,500 executions/month) or self‑hosted. If you are new to n8n, I highly recommend reading The Ultimate Guide to AI-Powered Automation first — it covers the basics of n8n, node types, and workflow design.
- Google Sheets — a spreadsheet with the following columns:
timestamp,invoice_number,due_date,amount,client_email,paid(boolean),reminder_stage(0,1,2,3),last_reminder. Thereminder_stagecolumn is crucial for escalating reminders (see Section 5). - Gmail or Google Workspace — to send reminder emails. You will need to authenticate the Gmail node with OAuth2.
- DeepSeek API key (recommended) — for parsing invoice text. DeepSeek costs ~$0.001 per invoice and handles unstructured text extremely well. Sign up at platform.deepseek.com and generate an API key.
- Stripe account (optional) — for automatic payment status. If you accept online payments via Stripe, you can automatically mark invoices as paid when a payment succeeds (Section 6).
If you run into any connection or authentication issues, these guides will help:
- Fix 'Webhook URL shows Localhost' Error
- Fix Webhook 404 'Not Registered'
- Fix 'Workflow Execution Failed' Error
3. Workflow A – Process Incoming Invoice
This workflow accepts a PDF upload, extracts the text, uses AI to find the key fields, and writes a structured record to Google Sheets.
Webhook node – receive invoice file
Add a Webhook node. Set HTTP Method to POST and Path to /invoice. Enable "Send binary data" so the node can accept file uploads. The Production URL will be something like https://your-n8n-instance.com/webhook/invoice.
You can test the webhook by sending a POST request with a PDF file using tools like Postman or cURL.
Extract from File – convert PDF to plain text
Add an Extract from File node (n8n native). Point it to the binary data from the webhook. This node uses pdftotext, which is pre‑installed in n8n Cloud and in the official Docker images. It returns the raw text content of the invoice.
DeepSeek – parse invoice data
Add an HTTP Request node. Configure it to call DeepSeek's API with a prompt that asks for structured JSON output. The key is to set temperature: 0.1 to get consistent, deterministic responses.
{
"model": "deepseek-chat",
"messages": [
{
"role": "system",
"content": "You are an invoice data extraction expert. Return ONLY a valid JSON object with these fields: invoice_number (string), due_date (YYYY-MM-DD), amount (number), client_email (string), description (string). If any field is missing, set it to null. Do not include markdown or extra text."
},
{
"role": "user",
"content": "Extract invoice data from the following text:\n\n{{ $json.data }}"
}
],
"temperature": 0.1,
"max_tokens": 500
}
Then add a Code node to parse the JSON response and map it to the fields you need. Use the same safe parsing pattern as in the AI Content Factory article.
const raw = $input.item.json.choices[0].message.content;
// Remove markdown fences if present
let cleaned = raw.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
let parsed;
try {
parsed = JSON.parse(cleaned);
} catch (e) {
// Fallback: try to extract JSON from the response
const match = cleaned.match(/\{[\s\S]*\}/);
if (!match) throw new Error('Could not parse DeepSeek response: ' + raw.slice(0, 200));
parsed = JSON.parse(match[0]);
}
// Ensure all expected fields exist
const result = {
invoice_number: parsed.invoice_number || 'unknown',
due_date: parsed.due_date || '2099-12-31',
amount: parsed.amount || 0,
client_email: parsed.client_email || '[email protected]',
description: parsed.description || ''
};
return [{ json: result }];
Google Sheets – store invoice record
Add a Google Sheets node. Select Append operation. Map the extracted fields to columns:
- timestamp →
{{ new Date().toISOString() }} - invoice_number →
{{ $json.invoice_number }} - due_date →
{{ $json.due_date }} - amount →
{{ $json.amount }} - client_email →
{{ $json.client_email }} - paid →
false(default) - reminder_stage →
0(0=no reminder, 1=first, 2=second, 3=final) - last_reminder →
""(empty)
const invoiceNumber = $json.data.match(/Invoice #: (\d+)/)?.[1] || 'unknown';
This is faster and free, but fails if the format changes. The AI approach adapts to variations.
4. Workflow B – Daily Reminders (The Fixed Version)
This workflow runs every morning, checks Google Sheets for unpaid overdue invoices, and sends personalised reminder emails. The key difference from other tutorials is the Code node for filtering — it is more reliable than the Filter node for date comparisons. Later we'll extend it for escalation and Stripe.
Schedule Trigger – daily at 8 AM
Add a Schedule Trigger node with cron expression 0 8 * * *. This runs every day at 8:00 AM. You can adjust the time by changing the cron expression (e.g., 0 9 * * 1 for Monday at 9 AM).
Google Sheets – read all rows
Add a Google Sheets node, operation Get Many, return all rows. This reads every invoice in your sheet into the workflow.
Code node – filter overdue & unpaid (the correct way)
This is where we fix the common mistake. The n8n Filter node does not handle date comparisons well across timezones. Use a Code node instead:
// Filter out rows that are already paid OR not overdue
const items = $input.all();
const today = new Date();
const overdueItems = items.filter(item => {
const isPaid = String(item.json.paid).toLowerCase() === 'true';
const dueDate = new Date(item.json.due_date);
const isOverdue = dueDate < today;
const stage = Number(item.json.reminder_stage) || 0;
// Only process if not paid, overdue, and stage is less than 3 (final notice already sent)
return !isPaid && isOverdue && stage < 3;
});
// Output one item per overdue invoice
return overdueItems.map(item => ({ json: item.json }));
new Date() comparison, which works reliably across all timezones. The Filter node's date comparison can behave differently depending on your n8n server's timezone setting.
Split In Batches – process one by one
Add a Split In Batches node with batch size = 1. This creates a loop that processes each overdue invoice individually. The loop will run once for each invoice, and the workflow will end automatically after the last item.
Gmail – send reminder email
Add a Gmail node, operation Send Email. Use dynamic fields to personalise each message:
To: {{ $json.client_email }}
Subject: Payment Reminder: Invoice {{ $json.invoice_number }} is overdue
Dear customer,
This is a friendly reminder that invoice {{ $json.invoice_number }} for amount ${{ $json.amount }} was due on {{ $json.due_date }}.
Please arrange payment at your earliest convenience. If you have already made the payment, please ignore this message.
Best regards,
Your Business Name
Update Google Sheet – advance reminder stage
After sending the email, add a second Google Sheets node with operation Update. Use the row ID from the earlier Get Many node to update the specific row. Increment reminder_stage by 1 and set last_reminder to the current date.
reminder_stage → {{ ($json.reminder_stage || 0) + 1 }}
last_reminder → {{ new Date().toISOString() }}
This prevents the same invoice from receiving the same reminder level every day. The escalation logic (Section 5) uses the stage to send different email tones.
Wait node – respect rate limits
Add a Wait node (2 seconds) before the loop ends to respect Gmail API limits. The Wait node should connect back to the Split In Batches node (loop input).
5. Advanced: Escalating Reminders & Multi‑Stage Follow‑Up
Sending the same email every day is counterproductive. A better system uses escalating stages: a gentle reminder first, a firmer notice second, and a final warning before manual action. This section modifies Workflow B to use the reminder_stage column for tone escalation.
Stage Logic
- Stage 0 → No reminder sent yet. Invoice is due but hasn't received its first notice.
- Stage 1 → First reminder (friendly). Sent 1 day after due date.
- Stage 2 → Second reminder (urgent). Sent 3 days after first reminder.
- Stage 3 → Final notice. Sent 5 days after second reminder. After this, the workflow stops automating; you must handle manually.
To implement, replace the Gmail node with a Switch node that checks reminder_stage and routes to different email templates. The Code node that filters overdue items already excludes items with stage ≥ 3, so no duplicate final notices are sent.
// After the Split In Batches node, add a Switch node. // Add four conditions based on reminder_stage (or use a Code node). // Condition 1: stage = 0 → first friendly email // Condition 2: stage = 1 → second urgent email // Condition 3: stage = 2 → final notice email // Condition 4: fallback → default email (optional)
You can also adjust the Wait node to delay longer between reminders. For example, wait 3 days before escalating to stage 2. This is done by adding a Wait node with a dynamic duration based on the stage.
// Insert a Wait node before the loop loops back.
// Set Amount: {{ $json.reminder_stage === 1 ? 3 : $json.reminder_stage === 2 ? 5 : 1 }}
// Set Unit: Days
// This ensures: after first reminder, wait 3 days; after second, wait 5 days; after third, stop.
6. Stripe Integration – Automatically Mark Invoices as Paid
If you accept online payments via Stripe, you can completely eliminate manual status updates. When a payment succeeds, Stripe sends a webhook to n8n, which then updates the paid column in your Google Sheet. This ensures the reminder workflow never bothers a client who has already paid.
Setup Steps
- Create a new workflow in n8n with a Webhook trigger (path:
/stripe-webhook). - In your Stripe dashboard → Developers → Webhooks, add the endpoint pointing to your n8n webhook URL. Select the
checkout.session.completedevent (orinvoice.paidif you use Stripe Invoicing). - In the n8n workflow, add a Code node to extract the invoice number from the Stripe event payload. Usually it is in
metadata.invoice_numberorcustom_fields. Adjust the logic to match your setup. - Then add a Google Sheets node with operation Get Many to find the row where
invoice_numbermatches. Use a Code node to iterate and find the row index, or use the built‑in "Lookup" feature (if available). - Finally, use a Google Sheets node with operation Update to set
paidtotrueandreminder_stageto a high number (e.g., 99) to exclude it from future reminders.
// After Stripe Webhook node:
// 1. Code node to extract invoice_number from Stripe event
const event = $input.item.json;
const invoiceNumber = event.data.object.metadata.invoice_number || event.data.object.description; // adjust
// 2. Google Sheets: Lookup row
// Use "Lookup" operation with column "invoice_number" and value from code node.
// 3. Google Sheets: Update the found row (set paid=true, reminder_stage=99)
return [{ json: { invoice_number: invoiceNumber } }];
7. Download Both JSON Workflows
Below are the two complete workflows in JSON format. To import: open n8n → Workflows → top‑right menu → Import from File (or paste the JSON directly).
⚠️ Remember to replace all placeholders: YOUR_DEEPSEEK_API_KEY, YOUR_SHEET_ID, and your credentials for Google Sheets and Gmail.
Workflow A – Invoice Processor
{
"name": "Invoice Processor (Webhook + DeepSeek + Sheets)",
"nodes": [
{
"parameters": { "path": "invoice", "responseMode": "onReceived", "options": { "responseHeaders": {} } },
"id": "webhook",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1.2,
"position": [250, 300]
},
{
"parameters": { "binaryData": true, "dataPropertyName": "data", "fileFormat": "pdf" },
"id": "extract",
"name": "Extract from File",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1,
"position": [450, 300]
},
{
"parameters": {
"method": "POST",
"url": "https://api.deepseek.com/v1/chat/completions",
"sendHeaders": true,
"headerParameters": { "parameters": [{ "name": "Authorization", "value": "Bearer YOUR_DEEPSEEK_API_KEY" }] },
"sendBody": true,
"contentType": "json",
"body": {
"model": "deepseek-chat",
"messages": [
{ "role": "system", "content": "You are an invoice extraction expert. Return ONLY JSON with fields: invoice_number, due_date (YYYY-MM-DD), amount (number), client_email, description. No markdown." },
{ "role": "user", "content": "=concat(\"Extract from:\\n\\n\", $json.data)" }
],
"temperature": 0.1,
"max_tokens": 500
}
},
"id": "deepseek",
"name": "DeepSeek",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [650, 300]
},
{
"parameters": {
"jsCode": "const raw = $input.item.json.choices[0].message.content;\nconst cleaned = raw.replace(/```json\\s*/gi, '').replace(/```\\s*/g, '').trim();\nconst parsed = JSON.parse(cleaned);\nreturn [{ json: parsed }];"
},
"id": "parse",
"name": "Parse AI",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [850, 300]
},
{
"parameters": {
"operation": "append",
"sheetId": "YOUR_SHEET_ID",
"sheetName": "Invoices",
"fieldsToSend": "defineBelow",
"values": {
"string": [
{ "name": "timestamp", "value": "={{ new Date().toISOString() }}" },
{ "name": "invoice_number", "value": "={{ $json.invoice_number }}" },
{ "name": "due_date", "value": "={{ $json.due_date }}" },
{ "name": "amount", "value": "={{ $json.amount }}" },
{ "name": "client_email", "value": "={{ $json.client_email }}" },
{ "name": "paid", "value": "false" },
{ "name": "reminder_stage", "value": "0" },
{ "name": "last_reminder", "value": "" }
]
}
},
"id": "sheets",
"name": "Google Sheets",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4,
"position": [1050, 300],
"credentials": { "googleSheetsOAuth2Api": "YOUR_CREDENTIALS" }
}
],
"connections": {
"Webhook": { "main": [[{ "node": "Extract from File", "type": "main", "index": 0 }]] },
"Extract from File": { "main": [[{ "node": "DeepSeek", "type": "main", "index": 0 }]] },
"DeepSeek": { "main": [[{ "node": "Parse AI", "type": "main", "index": 0 }]] },
"Parse AI": { "main": [[{ "node": "Google Sheets", "type": "main", "index": 0 }]] }
},
"active": false,
"settings": { "executionOrder": "v1" }
}
Workflow B – Daily Reminder (with escalation structure)
{
"name": "Daily Unpaid Invoice Reminders (Escalating)",
"nodes": [
{
"parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * *" }] } },
"id": "schedule",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.1,
"position": [250, 300]
},
{
"parameters": {
"operation": "getMany",
"sheetId": "YOUR_SHEET_ID",
"sheetName": "Invoices",
"returnAll": true
},
"id": "getSheet",
"name": "Google Sheets",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4,
"position": [450, 300],
"credentials": { "googleSheetsOAuth2Api": "YOUR_CREDENTIALS" }
},
{
"parameters": {
"jsCode": "const items = $input.all();\nconst today = new Date();\nconst overdue = items.filter(item => {\n const paid = String(item.json.paid).toLowerCase() === 'true';\n const due = new Date(item.json.due_date);\n const stage = Number(item.json.reminder_stage) || 0;\n return !paid && due < today && stage < 3;\n});\nreturn overdue.map(item => ({ json: item.json }));"
},
"id": "filterCode",
"name": "Filter Overdue Unpaid",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [650, 300]
},
{
"parameters": { "batchSize": 1, "options": { "reset": false } },
"id": "split",
"name": "Split In Batches",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [850, 300]
},
{
"parameters": {
"operation": "send",
"to": "={{ $json.client_email }}",
"subject": "Payment Reminder: Invoice {{ $json.invoice_number }}",
"body": "=concat('Dear customer,\\n\\nInvoice ', $json.invoice_number, ' for $', $json.amount, ' was due on ', $json.due_date, '. Please arrange payment.\\n\\nThank you.')"
},
"id": "gmail",
"name": "Gmail",
"type": "n8n-nodes-base.gmail",
"typeVersion": 1,
"position": [1050, 300],
"credentials": { "gmailOAuth2": "YOUR_GMAIL_CREDENTIALS" }
},
{
"parameters": {
"operation": "update",
"sheetId": "YOUR_SHEET_ID",
"sheetName": "Invoices",
"id": "={{ $json.rowIndex }}",
"fieldsToSend": "defineBelow",
"values": {
"string": [
{ "name": "reminder_stage", "value": "={{ ($json.reminder_stage || 0) + 1 }}" },
{ "name": "last_reminder", "value": "={{ new Date().toISOString() }}" }
]
}
},
"id": "updateSheet",
"name": "Google Sheets Update",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4,
"position": [1250, 300],
"credentials": { "googleSheetsOAuth2Api": "YOUR_CREDENTIALS" }
},
{
"parameters": { "unit": "seconds", "amount": 2 },
"id": "wait",
"name": "Wait",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [1450, 300]
}
],
"connections": {
"Schedule Trigger": { "main": [[{ "node": "Google Sheets", "type": "main", "index": 0 }]] },
"Google Sheets": { "main": [[{ "node": "Filter Overdue Unpaid", "type": "main", "index": 0 }]] },
"Filter Overdue Unpaid": { "main": [[{ "node": "Split In Batches", "type": "main", "index": 0 }]] },
"Split In Batches": { "main": [[{ "node": "Gmail", "type": "main", "index": 0 }]] },
"Gmail": { "main": [[{ "node": "Google Sheets Update", "type": "main", "index": 0 }]] },
"Google Sheets Update": { "main": [[{ "node": "Wait", "type": "main", "index": 0 }]] },
"Wait": { "main": [[{ "node": "Split In Batches", "type": "main", "index": 0 }]] }
},
"active": false,
"settings": { "executionOrder": "v1" }
}
YOUR_DEEPSEEK_API_KEYYOUR_SHEET_ID- Google Sheets and Gmail OAuth2 credentials
8. Common Errors & Fixes
After deploying this on multiple client instances, I have seen the same errors repeatedly. Here are the exact fixes:
Your PDF is likely scanned (image‑based). Use a free OCR API like OCR.space (1,000 requests/month free) instead of the Extract from File node. Replace the node with an HTTP Request to OCR.space and parse the OCR output.
Add a Code node that strips markdown and tries to parse with a regex fallback — exactly as shown in the AI Content Factory article. The fallback handles cases where DeepSeek wraps the JSON in backticks or adds extra text.
The workflow sends a reminder every day until the invoice is marked paid, but only if the stage hasn't reached 3. To prevent duplicates, we increment reminder_stage after each send. The filter Code node checks that stage < 3.
Your workflow might be inactive. Activate it (green toggle). Also double‑check the webhook path — it must match exactly. See Fix Webhook 404 Not Registered for the complete troubleshooting checklist.
If DeepSeek takes more than 30 seconds, increase the execution timeout in workflow settings. Go to Settings → Execution Timeout and set it to 120 seconds. For more timeout fixes, see n8n Check URL Node: Fix Execution Timeout.
9. Real Cost Breakdown
Here is the actual monthly cost for processing 500 invoices and sending reminders:
| Service | Usage (500 invoices/month) | Cost |
|---|---|---|
| DeepSeek (parsing) | ~500,000 tokens | $0.07 |
| n8n Cloud free tier | 2,500 executions | $0 |
| Google Sheets & Gmail | Quota within free tier | $0 |
| Stripe webhook (optional) | unlimited | $0 |
| Total monthly | 500 invoices processed + reminders | $0.07 |
10. Scaling & Advanced Tips
- Multi-language reminders: Use a DeepL API branch to translate the email body based on the client's preferred language stored in the sheet.
- Slack/Telegram alerts for final notices: At stage 3, instead of emailing the client, send yourself a Slack message so you can follow up manually.
- Archive paid invoices: Create a third workflow that moves rows where
paid=trueto a separate "Paid" sheet to keep the main sheet clean. - Combine with AI Content Factory: Use the AI Content Factory to automatically publish a monthly invoice summary blog post (great for transparency and SEO).
- Use Webhook paths for multiple PDF sources: You can create multiple webhook paths (e.g.,
/invoice-vendor1,/invoice-vendor2) each with its own extraction logic.
11. Frequently Asked Questions
Yes, but you will need OCR. Replace the Extract from File node with an HTTP Request to a free OCR API like OCR.space (1,000 requests/month free). The rest of the workflow remains unchanged. For higher accuracy, consider Google Document AI (paid).
Add a column in Google Sheets for currency. The DeepSeek prompt can be extended to extract the currency symbol or code. In the email template, include the currency field: ${{ $json.amount }} {{ $json.currency }}.
You can replace it with any AI API (ChatGPT, Gemini) or even a regex Code node if your invoices have a strict format. The structure of the workflow stays exactly the same; only the HTTP Request node changes.
The filter Code node checks the paid column. When you mark an invoice as paid in Google Sheets, the reminder workflow will skip it automatically. If you integrate Stripe (Section 6), this happens without any manual work.
Yes. Replace the Gmail node with a Telegram node. You will need to create a Telegram bot and get the chat IDs of your clients (or your internal admin group). The message content can be the same as the email template.
The Google Sheets node automatically adds a rowIndex field to each row when you use Get Many. In the Update node, use {{ $json.rowIndex }} to target the correct row. Make sure you have a column named reminder_stage in your sheet.
Yes, as long as you have consent to store client email addresses and send payment reminders. Google Sheets and Gmail are both GDPR‑compliant when configured correctly. For extra security, consider using n8n's built‑in encryption for credentials and limiting sheet access to authorised users only.
— End of Complete Reference —