Most invoice automations stop at extraction: an LLM pulls the vendor, total and bank details out of a PDF, and the numbers go straight into a spreadsheet or ledger. The risky part is that an LLM can read a perfectly formatted IBAN with one wrong digit, and nothing downstream notices.
This post walks through a free n8n template that handles the extraction, and a small API I built that handles the checksum part. They're separate pieces: the template doesn't call the API out of the box. I'll show how to add it.
Part 1: the free n8n template
Nodes, in order:
- Gmail Trigger: polls for unread mail labelled
invoiceswith a PDF attached. - Split PDF Attachments (Code node): one item per PDF, so an email with three invoices produces three rows.
- Extract From File: reads the PDF's text layer. Scanned image-only PDFs need OCR first.
- Gemini + Structured Output Parser: returns vendor, invoice number, PO number, dates, currency, subtotal, tax, total, line items and a confidence score as strict JSON. Temperature 0, retries on rate limits.
- Normalize Invoice (Code node): cleans numbers and dates, checks that subtotal + tax = total, and marks low-confidence rows
needs_review. - Google Sheets: appends the row. Then Gmail marks the email read so it's never processed twice.
Setup takes about 10 minutes: a Gmail label and filter, a sheet with an Invoices tab, and three credentials (Gmail, Sheets, a free Gemini key from Google AI Studio).
Two things that made it more reliable:
- Strict schema over free text. The Structured Output Parser rejects anything that isn't the expected JSON, so a malformed response fails loudly instead of writing garbage.
- Check the math in code, not in the prompt. Asking Gemini "does this add up?" is slower and less reliable than one line of arithmetic in the Normalize node.
Part 2: validating payment IDs with an API
Extraction confidence doesn't tell you whether an IBAN is real. The ISO 13616 mod-97 checksum does. I put that, plus a few related checks, in a Cloudflare Worker:
- IBAN: country, length, mod-97 checksum, SEPA membership
- BIC: format and country
- EU VAT: format, plus an optional live lookup against the EU's VIES service
- UK VAT: format and checksum
- US EIN: format and IRS prefix (EINs have no check digit, so that's as far as honest validation goes)
Up to 50 identifiers per batch request. Here's a real response from the keyless demo endpoint:
curl "https://invoice-validator.mohamed-bahaa53000.workers.dev/demo/v1/iban?value=DE89370400440532013000"
{
"iban": "DE89370400440532013000",
"country": "DE",
"check_digits": "89",
"bban": "370400440532013000",
"valid": true,
"formatted": "DE89 3704 0044 0532 0130 00",
"sepa": true,
"errors": []
}
The demo allows 10 calls per IP per day without a key.
Why a Worker rather than an n8n Code node? Mostly reuse: the same check is useful from Make, Zapier or a backend, and it's stateless, so it runs at the edge in milliseconds. It also isolates the slow part. VIES throttles by member state, so when it's unavailable the API returns checked: false with a reason instead of a false "invalid".
Part 3: wiring them together yourself
After Normalize Invoice, add an HTTP Request node:
- Method: POST, to the
/v1/batchendpoint from the RapidAPI listing - Headers:
X-RapidAPI-KeyandX-RapidAPI-Host - Body:
{
"items": [
{ "id": "INV-1042-iban", "type": "iban", "value": "DE89370400440532013000" },
{ "id": "INV-1042-vat", "type": "eu_vat", "value": "DE123456789", "live": true }
]
}
In n8n, map each value with an expression from the previous node, for example $json.invoice_number, $json.vendor_iban and $json.vendor_vat.
You'll need to add vendor_iban and vendor_vat to the Gemini schema first. Then treat valid: false as "needs review", and checked: false from VIES as "verify manually", not as a rejection. A person sees exactly why a row was flagged.
What I took away
- Fail open to a human, never closed to the ledger. Anything that can time out should route to review.
- Batch external checks. One call per identifier multiplies latency and quota.
- Keep each piece honest about what it does. The template extracts; the API validates; you decide how strict to be.
Links
- Free n8n template: https://mohabaha0.gumroad.com/l/ap-invoice-triage-free
- Invoice & Payment ID Validator on RapidAPI: https://rapidapi.com/MohamedBahaa/api/invoice-payment-id-validator
Feedback on either is welcome, especially edge cases where the extraction or the validation gets it wrong.
Top comments (13)
The mod-97 step catches the extraction slip, but the costlier invoice failure is a perfectly valid IBAN that is not the vendor's. When a supplier's mailbox is compromised, the fake invoice carries a real account that passes every checksum. Since the sheet already holds past rows per vendor, I would store the last known IBAN for each vendor (normalized: spaces stripped, uppercased) and treat any change as needs_review on its own, confirmed through a phone number you already had, not one printed on the new invoice. That is one lookup, and it covers the case the checksum cannot see.
A smaller one in Normalize Invoice: if subtotal + tax = total is compared as JavaScript floats, sums like 0.1 + 0.2 do not equal 0.3, and invoices that round tax per line can be a cent off. Comparing in integer cents with a small tolerance tied to the number of line items avoids flagging correct invoices.
This is the best point in the thread. A real account that passes every checksum is exactly the failure a stateless check can't see, and the sheet already has the history, so "IBAN changed for this vendor -> needs_review, confirm on the old number" costs one lookup. That's going on the list for the template. On floats: Normalize Invoice rounds every amount to cents and allows 0.02 of slack, so 0.1 + 0.2 won't trip it. But scaling the tolerance by line count is smarter for invoices that round tax per line, and a fixed 2 cents can be too tight there. Thanks.
You are right on the floats, rounding to cents with 0.02 of slack already covers the 0.1 + 0.2 case, so that part of my comment did not apply to your node. For the per-line case, each line can drift by at most half a cent when tax is rounded per line, so a tolerance of about 0.005 times the line count gives a bound that grows with the invoice instead of a fixed guess. Glad the IBAN history check fits the template.
"Fail open to a human, never closed to the ledger" is the line most automation tutorials get backwards, and Hayrullah's point about a valid IBAN that isn't the vendor's has a twin in lead data. We route inbound leads in n8n by the email domain: the domain is a valid identifier, it resolves to a company record, and it still sends the lead to the wrong queue when the domain is gmail.com or a hosting reseller, because the identifier is real and says nothing about the sender. A per-row "why this was flagged" field, like your checked: false with a reason, saved more review time than any accuracy gain in the extractor. On the batch endpoint with 50 items: if one VIES lookup times out, do the other 49 come back, or does the whole batch wait on the slow one?
Good question, I checked the code rather than guess. The batch runs every item in parallel and returns one response, so the other 49 always come back in the same reply; the response just waits for the slowest item. Each live VIES lookup has its own 6-second timeout. If it times out, that item still returns its offline result (format and checksum) with vies.checked = false and error "vies_timeout", never a false "invalid". Timeouts aren't retried; only VIES's throttling errors (like ms_max_concurrent_req) get one retry after 600 ms. Live lookups are also capped at 10 per batch, and items past that get a "live_lookup_limit_reached" warning and offline checks only. So worst case is a few seconds of waiting, not a hung batch.
That is the right shape, and a named vies_timeout with checked: false is the detail that makes it usable downstream, because the review queue can filter on the reason instead of re-reading the row. I wrote the lookup-side equivalent today: five kinds of empty from 30 days of logs, which one may be retried and which one goes to the review pile, if the comparison is useful: dev.to/kevinhall/four-kinds-of-emp...
Checking the arithmetic in code instead of asking the model is the right call, and the needs_review flag is the part that decides whether this saves time. The failure I would watch is the flagged rows piling up in the sheet with no owner. A named person and a daily glance at that one filter is what keeps the automation from becoming a second inbox.
Separating extraction from verification is exactly the right architecture — the model is good at reading the document and structurally incapable of knowing whether what it read is valid, so the checksum step has to live outside it. The subtotal-plus-tax-equals-total check in your normalization node is the same idea in miniature: an extracted invoice, like an extracted IBAN, should have to pass an arithmetic test before it is allowed to become a payment.
One step worth stress-testing here is duplicate handling. Marking the Gmail message as read stops the same email from being processed twice, but it does not stop the same invoice arriving again as a forward, a resend, or a second copy in another thread. Before a row becomes payable, would a uniqueness check on vendor + invoice number + total, or a hash of the PDF, belong in the Normalize node or in the validation batch? Extraction can be completely correct in that case and the payment still goes wrong, simply because it goes through twice.
Fair catch. In the free version, marking the email as read is the only dedupe, so a forward or a resend would get through. The Pro version has a "Duplicate & PO Checks" step: the same vendor and invoice number already in the log (or twice in one batch) is blocked with a Slack alert, and the same vendor with the same total within 7 days goes to approval as a possible duplicate. A hash of the PDF would add a check for exact resends where the invoice number came out differently, which is a nice cheap addition. Thanks.
Great point on IBAN checksums. For the scanned-PDF gap you mention, I built an OCR API that returns text plus structured invoice fields, so it can sit before the Gemini node in n8n as an HTTP Request: rapidapi.com/riasadrion/api/ocr-im.... Free tier is 10 pages. It's still worth keeping your checksum step after it, since OCR digits can be wrong too.
Nice write-up — the 'fail open to a human' framing is the part most automation tutorials skip. One thing I'd add on the dedupe front: mark-as-read works until someone forwards you the same invoice again. Hashing the extracted fields (or even just the PDF bytes) and storing it alongside the row gives you a cheap second guard for resubmitted or forwarded duplicates. Curious how the review queue holds up at volume — do you prioritize by amount or vendor history, or does everything just land in one pile?
Honest answer: in the free version it's one pile. Anything that fails a check (low extraction confidence, totals that don't add up, an invalid IBAN/VAT) gets status needs_review in the sheet, and you filter on that column. No ranking.
The Pro version thins the pile before anyone looks at it: invoices under an approval threshold (default 5,000) that match an open PO within tolerance auto-approve, and you can list trusted vendors to skip review. Everything else becomes its own Slack approval message with the amount, the reasons, and a PAY/HOLD payee check, and a HOLD (changed bank details, new payee, bad IBAN) goes at the top of the message. It still doesn't sort the queue by amount or vendor history though.
If I were adding one thing for volume, it'd be ordering the review list by risk first (HOLDs), then by amount, so the person reviewing works the dangerous and expensive ones before the small stuff.