Pull invoice data from Gmail PDFs into Google Sheets with n8n
Read PDF invoices from a Gmail inbox, extract vendor, dates and totals with n8n's Information Extractor, and log them to Google Sheets with a sanity check.
· ranx.cloud

This is for the person who does the books at a small company and currently copies vendor, invoice number, dates and amounts out of PDF attachments by hand. The workflow below watches an invoices@ address (or a Gmail label), pulls the text out of every PDF attachment, has an LLM fill a fixed set of fields, sanity-checks the totals, and writes one row per invoice to a Google Sheet. Invoices it can't read are labelled for a human instead of being guessed at.
Gmail Trigger → Code (one item per PDF) → Extract from File (PDF)
→ If (has text?) ─yes→ Information Extractor (+ chat model) → If (totals add up?) → Google Sheets → Gmail: Add Label
└no→ Gmail: Add Label "invoices/needs-review"
LLM cost on a small model is around $0.30 per 500 invoices. Details at the end.
Before you start
- A Gmail OAuth2 API credential and a Google Sheets OAuth2 API credential in n8n. Both use the same Google Cloud OAuth client; enable the Gmail API, Google Sheets API and Google Drive API (n8n uses Drive to list your spreadsheets) in that project. The redirect URL to register in Google Cloud is shown in the n8n credential as OAuth Redirect URL. Our support-email triage post walks through the Google side, including the seven-day token expiry trap for apps left in Testing mode.
- Gmail labels
invoices/processedandinvoices/needs-review. - An Anthropic or OpenAI API key, added in n8n as a credential.
- A sheet with this header row:
invoice_key | received_at | vendor | invoice_number | invoice_date | due_date | currency | subtotal | tax | total | file_name | email_link
1. Gmail Trigger
Event: Message Received
Simplify: off
Options → Download Attachments: on
Filters → Search: has:attachment filename:pdf -label:invoices/processed -label:invoices/needs-review
Add to:invoices@yourcompany.com or label:invoices to the search if the mailbox gets other mail. Download Attachments is only available with Simplify off. Each attachment lands as a binary property named attachment_0, attachment_1, and so on (the prefix is the Attachment Prefix option).
2. Code: one item per PDF
The attachment order isn't reliable. Email signatures and logos are attachments too, so attachment_0 is often a PNG, and some suppliers send the invoice and a delivery note in one email. A short Code node (JavaScript) fixes both by emitting one item per PDF:
const out = [];
$input.all().forEach((item, i) => {
for (const [key, file] of Object.entries(item.binary ?? {})) {
if (file.mimeType === 'application/pdf') {
out.push({
json: {
messageId: item.json.id,
threadId: item.json.threadId,
subject: item.json.subject,
from: item.json.from?.text,
receivedAt: item.json.date,
fileName: file.fileName,
},
binary: { data: file },
pairedItem: { item: i },
});
}
}
});
return out;
Name the node Split PDFs; later expressions refer to it. JavaScript runs in n8n's built-in task runner. You can't require npm packages from the Code node unless the instance was started with them allowed, and you don't need any here.
3. Extract from File
Operation: Extract From PDF
Input Binary Field: data
Options → Max Pages: 5
The output has the text in $json.text. Max Pages keeps a 60-page statement from blowing up your token bill; invoices rarely need more than the first few pages.
4. If: is there any text?
Scanned invoices are images inside a PDF, and the extractor returns an empty or near-empty string for them. Add an If node:
{{ $json.text.trim().length }} is greater than 50
The false branch goes to a Gmail node (Message → Add Label, Message ID {{ $('Split PDFs').item.json.messageId }}, label invoices/needs-review). Those are the ones a person types in.
5. Information Extractor
Add Information Extractor on the true branch and attach a chat model (for example Anthropic Chat Model with a Haiku model, or OpenAI Chat Model).
Text:
File: {{ $('Split PDFs').item.json.fileName }}
Email subject: {{ $('Split PDFs').item.json.subject }}
{{ $json.text }}
Schema Type: From Attribute Descriptions
Attributes:
| Name | Type | Required | Description |
|---|---|---|---|
| vendor | string | yes | Legal name of the company issuing the invoice (the seller, not the customer). |
| invoice_number | string | yes | Invoice number exactly as printed, including prefixes and dashes. |
| invoice_date | date | yes | Issue date of the invoice. |
| due_date | date | no | Payment due date. Leave empty if not printed. Do not calculate it from payment terms. |
| currency | string | yes | ISO 4217 code, e.g. USD, EUR, GBP. |
| subtotal | number | no | Amount before tax. Leave empty if not printed. |
| tax | number | no | Total tax amount. 0 if the invoice states no tax; empty if not printed. |
| total | number | yes | Final amount due, including tax. Number only, no currency symbol or thousands separator. |
Two things in those descriptions matter more than they look:
- Required tells the model it must produce a value. Mark a field required only if every invoice really has it. A required
due_dateon an invoice without one gets you a confidently invented date. - "Do not calculate" and "leave empty if not printed" stop the model from being helpful in ways your accountant won't appreciate.
The node returns the fields under $json.output, with dates as YYYY-MM-DD.
6. If: do the totals add up?
A cheap check catches most extraction mistakes (a misread digit, subtotal and total swapped):
{{ $json.output.subtotal == null || $json.output.tax == null || Math.abs($json.output.subtotal + $json.output.tax - $json.output.total) < 0.02 }}
is true
Use a Boolean condition, is true. The false branch gets the needs-review label like the scanned ones.
7. Google Sheets
Resource: Sheet Within Document
Operation: Append or Update Row
Document: (pick your sheet)
Sheet: Sheet1
Mapping Column Mode: Map Each Column Manually
Column to match on: invoice_key
Values:
invoice_key: {{ ($json.output.vendor + '|' + $json.output.invoice_number).toLowerCase() }}
received_at: {{ $('Split PDFs').item.json.receivedAt }}
vendor: {{ $json.output.vendor }}
invoice_number: {{ $json.output.invoice_number }}
invoice_date: {{ $json.output.invoice_date }}
due_date: {{ $json.output.due_date }}
currency: {{ $json.output.currency }}
subtotal: {{ $json.output.subtotal }}
tax: {{ $json.output.tax }}
total: {{ $json.output.total }}
file_name: {{ $('Split PDFs').item.json.fileName }}
email_link: https://mail.google.com/mail/u/0/#all/{{ $('Split PDFs').item.json.threadId }}
Append or Update Row keyed on vendor plus invoice number means a supplier who sends the same invoice twice (reminder emails often re-attach it) updates one row instead of adding a duplicate.
Finish with a Gmail Add Label node putting invoices/processed on {{ $('Split PDFs').item.json.messageId }}. Because the trigger's search excludes that label, nothing is processed twice.
8. Test and publish
Forward yourself five real invoices from different suppliers, including one scan and one with several pages. Run Execute workflow, check the sheet against the PDFs field by field, adjust descriptions, and Publish when a batch comes out clean.
Costs and limits
Prices as of November 2026 from Anthropic's pricing page: Claude Haiku 5.5 is $0.10 per million input tokens and $0.50 per million output tokens (prompts up to 100,000 tokens); Claude Sonnet 5.5 is $2 / $10.
A one or two page invoice is about 1,000 to 3,000 tokens of text. With the attribute schema and instructions, assume 3,500 input tokens. Output is a small JSON object; assume 400 tokens to leave room for any thinking tokens, which bill as output.
Haiku 5.5: 3,500 × $0.10/1M + 400 × $0.50/1M = $0.00035 + $0.00020 = $0.00055 per invoice
500 invoices / month ≈ $0.28
Sonnet 5.5: 3,500 × $2/1M + 400 × $10/1M = $0.0070 + $0.0040 = $0.0110 per invoice
500 invoices / month ≈ $5.50
Start with the small model. Move to the larger one only if the review queue shows the small one misreading layouts, and only for those suppliers if you can route them separately.
Google Sheets API write quotas are 60 requests per minute per user and 300 per minute per project (Sheets API limits). The trigger's Max Emails per Poll (default 10) keeps you far below that, even on the day you forward a backlog.
What goes wrong
The extractor returns an empty text. The PDF is a scan. That's what the first If node is for. If most of your suppliers send scans, this workflow is the wrong tool and you want an OCR step first.
attachment_0 is a logo. That's why the Code node filters by MIME type instead of trusting the order. If some suppliers send PDFs with a generic application/octet-stream type, also accept file.fileName?.toLowerCase().endsWith('.pdf').
Paired item errors ("Paired item data unavailable"). Happen when an expression like $('Split PDFs').item can't trace the current item back to its source. The pairedItem line in the Code node is what keeps that link; don't drop it if you edit the code.
Numbers come out as strings with commas, or European formats (1.234,56) are read wrong. Say so in the total description: "Invoices may use a comma as decimal separator; return a plain number with a dot." Then check with the totals If node.
A credential that worked last week fails with invalid_grant. Your Google OAuth app is in Testing mode, and Google expires its refresh tokens after seven days. Publish the app (or use an Internal app on Workspace) and reconnect.
An invoice never shows up in the sheet and the email has no label. The run failed somewhere after the trigger, so the email was never labelled. The trigger won't fetch it again on its own, because it only asks Gmail for mail newer than its last poll. Fix the error, then open the failed run under Executions and retry it.
Variations
- Upload a copy to Drive. Add a Google Drive node (Upload) on the success branch with the binary
dataand a name like{{ $json.output.vendor }}_{{ $json.output.invoice_number }}.pdf. - Collect invoices from a form instead of email. Replace the Gmail Trigger with an n8n Form Trigger with a file field, so staff can upload receipts from their phones; the rest of the workflow stays the same.


