Most "AI invoice automation" demos stop at the fun part: a model reads a PDF and spits out JSON. The hard part is what happens next. Who decides the invoice gets paid? What stops a fake "we changed our bank details" email from going straight through?
We built an accounts payable workflow in n8n around one rule: the model reads the invoice, code decides what happens to it. The model never approves anything. This post walks through the pattern, with the actual checks we run.
The shape of the workflow
- A Gmail trigger catches emails with a PDF attached.
- The workflow calls itself once per email (Execute Workflow), so every invoice gets its own run, its own logs and its own failure.
- The PDF is filed in Google Drive, then one Claude call turns it into fixed JSON fields.
- A Code node runs every check and picks a route:
auto_approve,approve(a person in Slack) orhold. - Approved invoices become QuickBooks bills. Held ones get the right follow-up. Everything lands in a Google Sheet log.
Splitting per email matters more than it looks. If one batch run handles five invoices and the third one throws, you get a half-processed mess. One execution per invoice means one red run you can retry.
Step 1: ask the model for fields, not decisions
The extraction prompt asks for a fixed schema: vendor name, tax ID, invoice number, dates, currency, subtotal, tax, total, PO number, bank details, line items, plus two fields that do a lot of work:
-
is_invoice: statements, quotes, receipts and payment reminders look like invoices. Asking the model to say so up front stops them from becoming bills. -
confidence: a 0 to 1 self-rating. It is not calibrated, but very low values reliably flag blurry scans and unusual layouts.
There is no "should we pay this?" field. That question never goes to the model.
Step 2: normalise before you compare
Half the bugs in invoice matching are string comparisons. "Acme, Inc." and "ACME Inc" are the same vendor. "INV-00042" and "inv 42" are probably the same invoice. So every comparison goes through small key functions:
const norm = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
// drop legal suffixes so "Acme Inc" and "ACME, Inc." match
const nameKey = (s) => norm(String(s || '')
.replace(/\b(incorporated|inc|llc|ltd|limited|gmbh|corp|corporation|co|plc|pty|bv|sarl|srl)\b\.?/gi, ''));
// invoice numbers, tax IDs, PO numbers: uppercase, alphanumeric, no leading zeros
const idKey = (s) => String(s ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/^0+/, '');
Vendors are matched on tax ID first and name second, because names drift and tax IDs usually do not.
Step 3: two lists, holds and notes
The routing node keeps two arrays:
- holds: anything that stops the invoice until a person looks.
- notes: anything that sends it to a human approver instead of auto-approving.
let route = 'hold';
if (!holds.length) {
if (poMatched && total <= autoApproveLimit && !notes.length) route = 'auto_approve';
else route = 'approve';
}
Each check pushes a plain-English sentence, not a code. Those sentences go straight into the Slack message and the log, so the approver reads why it came to them rather than ERR_PO_MISMATCH.
The checks that actually catch things
Vendor and sender. Is the vendor on the list and active? Did the email come from the domain on the vendor record? An invoice from acme-billing.co when the record says acme.com is held.
Bank details. If the invoice includes bank details and they differ from the vendor record, it is held and flagged as possible payment fraud. We also run a regex over the email body and the model's notes for phrases like "updated bank details" or "remit to our new account". Business email compromise usually announces itself in exactly those words.
When that hold fires, the workflow alerts finance in Slack and never replies to the sender. Replying to a compromised thread just confirms to the attacker that someone is reading.
Arithmetic. Subtotal plus tax should equal the total, and the line items should add up to the subtotal (with a small tolerance for rounding). Models sometimes misread one digit; this catches it.
Duplicates, three ways.
- Same vendor and same invoice number already in the log.
- Same vendor, same amount, invoice dates within N days (the classic "resent with a new number").
- The bill already exists in QuickBooks, which catches bills someone entered by hand before the automation existed.
Purchase orders. Is the PO on the list, still open, raised for this vendor and in the same currency? Then we subtract everything already approved against that PO, so three invoices cannot each "fit" a PO that only has room for one.
const invoiced = log
.filter((r) => idKey(r.po_number) === poKey && /^approved/i.test(r.status))
.reduce((a, r) => a + num(r.total), 0);
const remaining = num(po.amount) - invoiced;
if (total <= remaining * (1 + tolerancePct / 100) + 0.01) poMatched = true;
Approvals that respect separation of duties
Small, PO-matched invoices with no notes approve themselves. Everything else goes to a Slack approver using the Slack node's send-and-wait operation, so the workflow pauses until someone clicks Approve or Decline.
Two rules sit on top:
- Above a second threshold, a second approver must also say yes.
- If the usual approver raised the PO, the request goes to a backup approver instead. The person who ordered something should not be the person who signs off paying for it.
Not every hold is the same
A held invoice gets one of three follow-ups:
- Fraud risk (bank or domain mismatch): Slack alert to finance, no reply.
- Fixable by the vendor (unknown PO number, totals that do not add up): a polite email asking for a corrected invoice.
- Everything else: labelled in Gmail and logged for a person to review.
Gmail labels mirror the outcome (approved, held, rejected), so the AP inbox doubles as a status board.
What we would tell anyone building this
- Keep the model on extraction. Every decision you hand to it is one you cannot explain to an auditor.
- Write holds as sentences. The approver, the log and the vendor email all reuse them.
- Test duplicates by running the same invoice twice. Our pinned sample does exactly that.
- Make the workflow its own error workflow, so a failure posts to the AP channel instead of failing silently.
The full workflow is free on the n8n template library: Approve vendor invoices from Gmail with Claude, PO matching and Slack, then post bills to QuickBooks. We also wrote up the setup and the Google Sheet structure in more detail on our blog.
If you have built AP automation in n8n, I would like to hear which checks caught real problems for you, and which ones turned out to be noise.
Top comments (0)