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 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 works for the general PDF-into-Sheets pipeline; 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. For the webhook-driven variant — where okraPDF calls n8n when a hosted PDF is ready instead of n8n polling — see automating PDF workflows with n8n webhooks. To send invoices in from a shared mailbox rather than your own, see the Gmail PDF email intake pattern.

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.