What it does
The system is an n8n workflow that polls a Gmail inbox on a schedule, identifies invoice-related emails (whether the invoice lives in a PDF attachment or directly in the email body), uses Claude to classify and extract structured invoice data, validates that data, checks it against invoices already logged, and writes the result to one of two Google Sheets tabs: Valid Invoice or Invalid / Unprocessable Invoices.
How it works
1. Trigger & fetch
A Schedule Trigger runs on an interval and pulls up to 11 unread emails via Gmail (downloadAttachments: true).
2. Normalize
Each email's HTML/plain body is cleaned (HTML tags stripped, entities decoded, signatures cut off using common sign-off patterns like "Kind regards," "Sincerely,") and attachment presence is detected from both binary data and the multipart/mixed header as a fallback.
3. Split into candidates Every email produces one email-body candidate (always) plus one candidate per PDF attachment. Non-PDF attachments are noted but ignored. This means a single email can generate multiple parallel "is this an invoice?" checks — one for the body text, one for each PDF.
4. Get text
- -Email-body candidates already have text.
- -PDF candidates go through n8n's
Extract from File(PDF) node. If extraction throws (corrupt file), the error is caught (continueErrorOutput) and routed to a metadata-preserving error handler instead of crashing the run. If extraction succeeds but returns under 20 characters of text, the candidate is flaggedneeds_vision_fallback(this covers scanned/image-only PDFs. The workflow does not do OCR, so these are deliberately routed to "unprocessable" rather than passed to Claude with empty text).
5. Detect (Claude, call #1)
Candidates with usable text are sent to claude-haiku-4-5 with a classifier prompt that returns {is_invoice, confidence, reasoning}. This step exists specifically to catch text that mentions an invoice without being one (e.g. "please find attached invoice X" in a forwarding email), a keyword filter on the subject line alone wouldn't catch this.
6. Extract (Claude, call #2)
Only candidates classified as is_invoice: true go to a second Claude call with an extraction prompt returning vendor_name, sender_email, invoice_number, invoice_date, due_date, total_amount, currency, description. The prompt explicitly instructs Claude to return null for anything not present in the source text rather than infer it.
7. Validate
A Code node checks for required fields (vendor_name, invoice_number, numeric total_amount, currency, and at least one of invoice_date/due_date). Anything missing is collected into a validation_errors list and the record is marked invalid_schema rather than dropped silently.
8. Deduplicate
Valid candidates are compared against every row already in the "Valid Invoice" sheet, plus every other candidate in the same run, using a composite key of invoice_number + vendor_name (case-insensitive). This double comparison matters: without checking against sibling candidates in the same batch, two duplicate invoices arriving in the same polling cycle would both slip through, since neither would exist in the sheet yet when checked individually.
9. Log & mark as read
- -Valid, non-duplicate --> appended to Valid Invoice sheet.
- -Duplicate --> appended to Invalid / Unprocessable Invoices with a note identifying which existing row it matches.
- -Failed validation, not-an-invoice, or unprocessable (no text / corrupt / scanned) --> also appended to Invalid / Unprocessable Invoices with an
error_noteexplaining why. - -In every case, the source email is marked as read afterward, so re-polling never reprocesses it.
Data model
| Sheet | Purpose | Key fields |
|---|---|---|
| Valid Invoice | Successfully extracted, validated, non-duplicate invoices | all extracted fields + is_valid, processing_status |
| Invalid / Unprocessable Invoices | Everything that didn't make it to the log | source_type, attachment_filename, processing_status, error_note |
processing_status values used across the sheets: valid, invalid_schema, not_an_invoice, unprocessable, duplicate_skipped.
How to use it
- -Connect Gmail and Google Sheets credentials in n8n.
- -Point the Google Sheets nodes at your own spreadsheet (two tabs: "Valid Invoice", "Invalid / Unprocessable Invoices").
- -Activate the workflow — it polls automatically on the Schedule Trigger's interval.
- -Check "Valid Invoice" for logged invoices; check "Invalid / Unprocessable Invoices" and its
error_notecolumn to debug anything that didn't get logged as expected.
Known limitations
- -No OCR. Scanned/image-only PDFs are correctly detected and routed to unprocessable, but there is no fallback path to actually read them.
- -Dedup key is
invoice_number + vendor_name, not Gmail message ID so a resend with a different invoice number for the same underlying charge would not be caught. - -Non-PDF attachments are ignored (only noted), so an invoice sent as e.g. a
.docxor image file would fall back to whatever text is in the email body.