PDF extraction

Extract invoice data from Gmail PDFs to Google Sheets with n8n

Build an n8n workflow that turns Gmail invoice PDFs into structured Google Sheets rows with okraPDF: upload, parse with a JSON schema, poll, append.

June 15, 2026 Updated August 31, 2026 7 min read okraPDF

You can turn Gmail invoice PDFs into structured Google Sheets rows with a single n8n workflow and the okraPDF API — no manual data entry and no community node to install. Invoices and bank statements arrive as email attachments; instead of retyping vendor, invoice number, dates, and totals, this workflow sends each attachment to okraPDF, gets the fields back already typed, and appends a row to your sheet.

It uses built-in n8n nodes only — Gmail Trigger, IF, HTTP Request, Wait, and Google Sheets — so it imports on n8n Cloud or self-hosted n8n with no community node to install. The same shape moves any PDF into a sheet; here we focus on the invoice-extraction case.

What you’ll build

Gmail Trigger (PDF attachment)
  → IF attachment is a PDF
  → POST /v1/files            (store the binary, get a file id)
  → POST /v1/parse            (parse it with a JSON schema)
  → Wait → GET /v1/jobs/{id}  (poll until the job is terminal)
  → Google Sheets: append row (vendor, invoice #, dates, totals)

The key idea: instead of OCR-then-regex, you hand okraPDF a JSON schema describing the fields you want, and it returns them already typed and labeled. That’s the difference between parsing and pattern-matching — a new invoice layout doesn’t break a schema the way it breaks a regex.

Prerequisites

  • An okraPDF API key from okrapdf.com/settings/keys. Structured extraction must be enabled on the account.
  • Gmail and Google Sheets connected in n8n.
  • n8n Cloud or a self-hosted instance.

Create one HTTP Header Auth credential in n8n for okraPDF:

  • Name: okraPDF API Key
  • Header Name: Authorization
  • Header Value: Bearer okra_YOUR_KEY_HERE

Every okraPDF HTTP Request node below uses Authentication: Generic Credential TypeGeneric Auth Type: HTTP Header Auth. Attaching the credential is not enough on its own — that pairing is what makes n8n actually send the header.

Step 1 — Gmail Trigger

Add a Gmail Trigger and configure it to hand the attachment to the next node as binary:

  • Simplify: off — you want the full message and its attachments.
  • Download Attachments: on.
  • Attachment Prefix: attachment_.

The first attachment is now available as the binary field attachment_0.

Step 2 — Keep only PDFs

Add an IF node so non-PDF attachments are dropped:

{{ ($binary.attachment_0?.mimeType || '').includes('pdf')
   || ($binary.attachment_0?.fileName || '').toLowerCase().endsWith('.pdf') }}

Wire the true branch onward; leave the false branch empty to ignore non-PDFs.

Step 3 — Upload the PDF to okraPDF

Add an HTTP Request node, Upload to okraPDF:

  1. Method: POST
  2. URL: https://api.okrapdf.com/v1/files
  3. Authentication: HTTP Header Auth → your okraPDF API Key credential
  4. Body: Form-Data (multipart). Add one parameter:
    • Type: n8n Binary File
    • Name: file
    • Input Data Field Name: attachment_0

The response has a top-level id. That’s the reusable file id you parse next.

Step 4 — Parse with a JSON schema

Add an HTTP Request node, Parse invoice fields:

  1. Method: POST
  2. URL: https://api.okrapdf.com/v1/parse
  3. Authentication: the same okraPDF credential
  4. Body: JSON. Toggle the body field to expression mode (in n8n it then starts with =) so {{ $json.id }} resolves to the uploaded file’s id — pasted as plain JSON the placeholder is sent literally and /v1/parse 404s on the bogus id:
{
  "parser": "gemini-vision",
  "file": { "id": "{{ $json.id }}" },
  "pages": "1-3",
  "schema": {
    "type": "object",
    "properties": {
      "vendor":         { "type": "string", "description": "Supplier / biller name" },
      "invoice_number": { "type": "string" },
      "invoice_date":   { "type": "string", "description": "ISO date if possible" },
      "due_date":       { "type": "string" },
      "currency":       { "type": "string" },
      "subtotal":       { "type": "string" },
      "tax":            { "type": "string" },
      "total":          { "type": "string" }
    },
    "required": ["vendor", "invoice_number", "total"]
  }
}

Edit the properties to match your own fields. Sending a schema is what switches okraPDF from plain parsing to structured extraction.

/v1/parse is async: it returns 202 with a status_url pointing at /v1/jobs/{id}. You poll that next.

Step 5 — Poll the job

Add a Wait node, then an HTTP Request node, Get job result:

  • Method: GET
  • URL:
{{ ($json.status_url && $json.status_url.startsWith('http'))
     ? $json.status_url
     : 'https://api.okrapdf.com/v1/jobs/' + ($json.id || $json.job_id) }}

That guard matters. On the first poll the 202 body carries an absolute status_url, but on each subsequent GET /v1/jobs/{id} the response returns a relative status_url (/v1/jobs/{id}). Feeding a relative URL straight into the HTTP Request node breaks the second poll — so only reuse status_url when it starts with http, otherwise rebuild it from the job id.

Then an IF node to decide whether the job is done:

{{ ['succeeded','completed','completed_with_errors','failed','canceled','cancelled']
     .includes(($json.status || $json.internal_status || '').toLowerCase()) }}

Include completed_with_errors — it is a terminal status. Leave it out and a parse that finishes with partial page errors loops forever. Route the “not done yet” branch back to the Wait node; pace it with:

{{ Math.max(1, Math.ceil(($json.next_poll_after_ms ?? 15000) / 1000)) }}

Step 6 — Append the row to Google Sheets

Add a Google Sheets node, Append row, pointed at your invoice log. The extracted fields live under result.extracted.data:

ColumnValue
Vendor={{ $json.result?.extracted?.data?.vendor }}
Invoice #={{ $json.result?.extracted?.data?.invoice_number }}
Invoice date={{ $json.result?.extracted?.data?.invoice_date }}
Due date={{ $json.result?.extracted?.data?.due_date }}
Currency={{ $json.result?.extracted?.data?.currency }}
Total={{ $json.result?.extracted?.data?.total }}
Schema valid={{ $json.result?.extracted?.schema_valid }}
Pages={{ $json.result?.usage?.pages }}
Job ID`={{ $json.id

schema_valid tells you whether the model returned every field your schema required — a cheap quality gate before you trust a row. The parsed text itself, if you also want it, is at result.formats.text.content and result.formats.markdown.content.

Production notes

  • Use n8n environment credentials, not a hardcoded key. Scope the API key to this workflow rather than a shared admin key.
  • Add idempotency. n8n retries and Gmail can redeliver. Key rows on the file id or invoice number and upsert instead of blind-appending.
  • Extraction has to be enabled on the okraPDF account, or /v1/parse parses but returns no result.extracted.
  • Watch schema_valid. Route false rows to a review tab instead of your clean ledger.

That’s the whole loop: Gmail in, structured fields out, one row per invoice. The same workflow handles bank statement PDFs — swap the schema for the fields you need. To read from a shared mailbox instead of your own, point the Gmail Trigger at that mailbox and leave the rest of the workflow unchanged.

FAQ

Can n8n extract data from PDF invoices?

Yes. n8n has no native PDF-extraction node, but its built-in HTTP Request node can call the okraPDF API: POST /v1/parse with a JSON schema returns invoice fields already typed and labeled. No community node is required, so the workflow runs on n8n Cloud or self-hosted n8n.

How do I get invoice fields from a PDF into Google Sheets?

Upload the PDF to POST /v1/files, parse it with POST /v1/parse and a JSON schema, poll GET /v1/jobs/{id} until the job is terminal, then map result.extracted.data.* to columns in a Google Sheets Append row node.

Do I need OCR for scanned invoices?

No separate step. okraPDF’s gemini-vision parser reads scanned and image-only PDFs, so the same workflow handles both digital and scanned invoices.

Why use a JSON schema instead of regex?

A schema describes the fields you want and survives layout changes; a regex breaks the moment a vendor moves a total or relabels a field. okraPDF also returns schema_valid, so you can route incomplete extractions to a review tab instead of trusting them blindly.

Ready to wire it up? Grab a key at okrapdf.com and start with a single test invoice.