How to Automate Invoice Processing with n8n

By The LightCube Labs team

This guide builds an n8n pipeline that reads invoice emails, pulls structured fields out of the attached PDF, logs the result to Google Sheets, and sends large invoices to a Slack approval channel.

The Problem

Invoices arrive as PDF attachments in a shared mailbox, and someone retypes the vendor, number, date and total into a spreadsheet by hand. The work is slow, typos creep in, and big invoices can sit unnoticed until the due date passes. Accounting tools with built in capture often cost more than a small team wants to pay for a few dozen invoices a month. An n8n workflow can do the same job with an IMAP trigger, a parser, a few regular expressions and a threshold check, and it leaves a structured log for every invoice it touches.

What You Will Need

Step-by-Step Workflow Setup

1 Watch the invoice mailbox with the IMAP Email Trigger

The n8n workflow begins with the Email Trigger (IMAP) node, which connects to a dedicated mailbox such as invoices@yourcompany.com and emits an item for each matching message. Using a dedicated mailbox keeps the n8n rules simple and keeps personal mail out of the pipeline.

Add an Email Trigger (IMAP) node and configure:

Node: Email Trigger (IMAP)
Mailbox Name: INBOX
Action: Mark as Read
Download Attachments: On
Format: Simple
Options > Custom Email Rules:
  ["UNSEEN", ["OR", ["SUBJECT", "invoice"], ["SUBJECT", "receipt"]]]
Credential: IMAP account (host, port 993, SSL on)

The custom rule is a standard IMAP search expression, so only unread messages whose subject contains invoice or receipt reach n8n. To require an attachment, the next Code node also drops items that carry no binary data; IMAP search alone cannot test for attachments reliably across providers.

Sample:
{
  "subject": "Invoice INV-2026-0412 from Northwind Supply",
  "from": "billing@northwindsupply.com",
  "date": "2026-11-03T14:22:10.000Z",
  "attachments": [{ "filename": "INV-2026-0412.pdf", "contentType": "application/pdf" }]
}

With Download Attachments on, n8n stores each file as binary data under names such as attachment_0. Use app passwords for providers that enforce two factor sign in, and store them in the n8n credential manager, not in node fields.

2 Extract the PDF attachment as base64 with a Code node

An n8n Code node in Run Once for All Items mode reads the binary buffer, checks that the file really is a PDF, and writes a base64 string into the JSON so the next step can send it to a parser. Items without a PDF are skipped here, which doubles as the attachment filter from step 1.

Node: Code
Language: JavaScript
Mode: Run Once for All Items
const out = [];
for (let i = 0; i < $input.all().length; i++) {
  const item = $input.all()[i];
  const keys = Object.keys(item.binary || {});
  const pdfKey = keys.find(k => (item.binary[k].mimeType || '') === 'application/pdf');
  if (pdfKey === undefined) continue;
  const buf = await this.helpers.getBinaryDataBuffer(i, pdfKey);
  out.push({ json: {
    from: item.json.from,
    subject: item.json.subject,
    receivedAt: item.json.date,
    filename: item.binary[pdfKey].fileName,
    pdfBase64: buf.toString('base64'),
    sizeKb: Math.round(buf.length / 1024)
  }});
}
return out;

this.helpers.getBinaryDataBuffer works whether n8n keeps binary data in memory or on the filesystem, which matters on self-hosted installs that use filesystem mode for large files.

Sample:
{
  "from": "billing@northwindsupply.com",
  "subject": "Invoice INV-2026-0412 from Northwind Supply",
  "filename": "INV-2026-0412.pdf",
  "pdfBase64": "JVBERi0xLjQKJcfsj6IKNSAwIG9iag...",
  "sizeKb": 84
}

Reject anything above a size you are comfortable with, for example 10 MB, by adding a condition on sizeKb. Very large scans slow every downstream n8n node, and n8n keeps execution data for each run.

3 Parse the PDF text with an HTTP Request node

Invoices are usually text based PDFs, so a parsing service can return the text layer as JSON. Add an n8n HTTP Request node that posts the base64 string to the service of your choice. The URL below is a placeholder for your own parser or an OCR provider.

Node: HTTP Request
Method: POST
URL: https://pdf-parser.example.com/v1/extract-text
Authentication: Generic Credential Type > Header Auth
  Name: Authorization
  Value: Bearer YOUR_API_KEY
Send Body: On
Body Content Type: JSON
Specify Body: Using JSON
JSON: { "file": "{{ $json.pdfBase64 }}", "format": "text" }
Options > Timeout: 30000

If you prefer not to call an external service, self-hosted n8n can run pdf-parse inside a Code node. Set NODE_FUNCTION_ALLOW_EXTERNAL=pdf-parse in the n8n environment, install the package in the n8n container, and call require('pdf-parse') on the buffer. n8n Cloud does not allow extra npm packages, so use the HTTP Request route there. Either way, the n8n workflow stays the same after this step.

Sample:
{
  "text": "NORTHWIND SUPPLY CO
Invoice Number: INV-2026-0412
Invoice Date: 11/03/2026
Qty Description Amount
10 Widget A 450.00
4 Widget B 1,200.00
Total Due: USD 6,250.00"
}

Scanned images need OCR rather than a plain text extractor. If the response comes back with an empty text field, route those items to a manual review sheet instead of failing silently.

4 Extract invoice fields with regex in a second Code node

A second n8n Code node converts the raw text into structured fields. Each pattern is deliberately tolerant of spacing and case. Keep every pattern in one place so you can adjust a single vendor format without touching the rest of the n8n logic.

Node: Code
Mode: Run Once for Each Item
const t = $json.text || '';
const pick = (re) => { const m = t.match(re); return m ? m[1].trim() : null; };
const vendor = t.split('\n')[0].trim();
const invoiceNumber = pick(/invoice\s*(?:number|no\.?|#)\s*[:#]?\s*([A-Z0-9-]+)/i);
const date = pick(/invoice\s*date\s*[:]?\s*([0-9\/.-]{8,10})/i);
const totalRaw = pick(/total\s*(?:due|amount)?\s*[:]?\s*(?:USD|EUR|GBP|\$|€|£)?\s*([0-9,]+\.[0-9]{2})/i);
const currency = (t.match(/\b(USD|EUR|GBP)\b/) || [null, 'USD'])[1];
const lineItems = [...t.matchAll(/^(\d+)\s+(.+?)\s+([0-9,]+\.[0-9]{2})$/gm)]
  .map(m => ({ qty: Number(m[1]), description: m[2], amount: Number(m[3].replace(/,/g, '')) }));
const total = totalRaw ? Number(totalRaw.replace(/,/g, '')) : null;
const complete = Boolean(vendor && invoiceNumber && date && total);
return { json: { vendor, invoiceNumber, date, currency, total, lineItems,
  parseStatus: complete ? 'ok' : 'needs_review',
  source: $('Extract PDF').item.json.filename } };

The parseStatus field is the structured logging hook: every row written later carries ok or needs_review, so a reviewer can filter the sheet for rows that need human eyes. In n8n, rename the node referenced in $('Extract PDF') to match your first Code node.

Sample:
{
  "vendor": "NORTHWIND SUPPLY CO",
  "invoiceNumber": "INV-2026-0412",
  "date": "11/03/2026",
  "currency": "USD",
  "total": 6250,
  "lineItems": [
    { "qty": 10, "description": "Widget A", "amount": 450 },
    { "qty": 4, "description": "Widget B", "amount": 1200 }
  ],
  "parseStatus": "ok"
}

5 Log every invoice to Google Sheets

The n8n Google Sheets node appends one row per invoice. Writing the row before any approval decision gives you a complete n8n audit log in the sheet, even for invoices that are later rejected.

Node: Google Sheets
Resource: Sheet Within Document
Operation: Append Row
Document: Invoice Log
Sheet: Invoices
Mapping Column Mode: Map Each Column Manually
Columns:
  logged_at: {{ $now.toISO() }}
  vendor: {{ $json.vendor }}
  invoice_number: {{ $json.invoiceNumber }}
  invoice_date: {{ $json.date }}
  currency: {{ $json.currency }}
  total: {{ $json.total }}
  line_items: {{ JSON.stringify($json.lineItems) }}
  parse_status: {{ $json.parseStatus }}

JSON.stringify keeps the line items in a single cell so the sheet stays flat. Add a header row first with the same column names, because the Sheets node matches columns by header text.

Sample:
{
  "spreadsheetId": "1AbC...xyz",
  "updatedRange": "Invoices, rows A42 to H42",
  "updatedRows": 1
}

Add a unique check on invoice_number if the same vendor resends a message. A Google Sheets lookup before the append, followed by an IF node, prevents duplicate rows in n8n.

6 Apply the approval threshold with an IF node

An n8n IF node compares the parsed total with your approval limit. In n8n, anything above 5000 goes to the approval branch; smaller invoices end after logging, or continue to your accounting export.

Node: IF
Conditions:
  Value 1: {{ $('Parse Fields').item.json.total }}
  Operation: Number > Larger
  Value 2: 5000
Combine: AND
Also require:
  {{ $('Parse Fields').item.json.parseStatus }}  equals  ok

The extra condition keeps half parsed invoices out of the approval channel; those rows stay in the sheet with needs_review. Store the limit as an n8n variable or a Set node field if finance wants to change it without editing the workflow.

Sample:
{
  "branch": "true",
  "total": 6250,
  "threshold": 5000
}

7 Post the approval request to Slack

The true branch ends with an n8n Slack node that posts a compact summary to #finance-approvals. Include the sheet row so the approver can click straight to the full log.

Node: Slack
Resource: Message
Operation: Send
Send Message To: Channel
Channel: finance-approvals
Text: Approval needed: *{{ $('Parse Fields').item.json.vendor }}*
Invoice: {{ $('Parse Fields').item.json.invoiceNumber }}
Total: {{ $('Parse Fields').item.json.currency }} {{ $('Parse Fields').item.json.total }}
Date: {{ $('Parse Fields').item.json.date }}
Items: {{ $('Parse Fields').item.json.lineItems.length }} line items
From: {{ $('Extract PDF').item.json.from }}

In this n8n workflow Slack is a notification, not an approval button. If you want one click approvals, add a second n8n workflow with a Webhook node and Slack interactive buttons. Activate the n8n workflow and send a sample invoice to confirm the whole chain.

Sample:
{
  "ok": true,
  "channel": "C08FINAPPR",
  "ts": "1730730123.001900"
}

Troubleshooting

The n8n IMAP trigger fires but no attachment is found

Some mail servers return attachments only when Download Attachments is on and the format is Simple. Check the n8n trigger output for a binary object. Inline images and signature logos also appear as attachments, so the Code node filters by application/pdf and ignores everything else.

Regex returns null for the invoice total

Vendors place labels differently: Amount Due, Balance, Grand Total. Print the raw text from the n8n HTTP Request node, find the exact wording, and add it to the alternation in the total pattern. Keep one regex per field so a failure shows which field broke, and let parseStatus flag the row as needs_review.

The same invoice is logged twice

IMAP rules that omit UNSEEN reprocess old mail after a restart, and vendors often resend. Keep Action: Mark as Read, then look up invoice_number in the sheet before appending. A Google Sheets Get Row(s) node followed by an IF node on the result count stops duplicates in n8n.

Free n8n Workflow Resources

Get started with the free workflow pack on the LightCube Labs templates page.

Production-Ready Workflow Packs

Get this workflow and more, with setup guides, editable configurations, and a shared failure-alert system.

View the Starter ($29) and Pro ($99) Packs

Frequently Asked Questions

Can n8n read scanned invoices?

Only with OCR added to n8n. A text extractor returns nothing for a flat image PDF. Use an OCR capable service in the HTTP Request step, and send empty results to the needs_review path so a person handles them.

How do I change the approval limit from 5000?

Edit the number in the n8n IF node, or store it as an n8n variable or a Set node field and reference it in the expression. Finance can then update one value without opening the rest of the workflow.

Does this n8n workflow handle multiple currencies?

The regex captures USD, EUR or GBP and stores it in the currency column. The IF node compares the raw total, so add a conversion step before it if your threshold is in one currency and invoices arrive in several.

Where do n8n parsing errors go?

Every invoice is logged by n8n, and incomplete parses get needs_review in the parse_status column. Filter the sheet on that value for a daily review list.