Back to guides
Architecture·July 17, 2026·9 min read

Invoice Data Extraction with LLMs — the API pattern that replaced deep learning (2026)

The OCR-plus-LayoutLM pipeline for invoice extraction has collapsed into a single vision-LLM API call: image in, schema-validated JSON out. Here is the complete working pattern — code, per-invoice economics, and the guardrails that make it production-grade.

Five years ago, extracting structured data from invoices meant a deep learning pipeline: an OCR stage, a layout-aware model (LayoutLM, Donut) fine-tuned on thousands of hand-labeled documents, and a maintenance burden every time a vendor changed their template. In 2026 the entire pipeline collapses into one vision-LLM API call: image in, schema-validated JSON out — zero training data, zero hosted models, and new layouts handled zero-shot. This guide shows the full working pattern, what it costs per invoice, and how to keep accuracy production-grade.

Why LLMs replaced the invoice deep-learning stack

Fine-tuned layout model (2021)Vision LLM (2026)
Training data3,000–50,000 labeled invoicesNone (zero-shot) — a few examples help edge cases
New vendor layoutOften needs re-labeling + retrainingHandled zero-shot
InfrastructureGPU serving + OCR stageOne HTTPS call
Output formatToken tags → custom decodingSchema-constrained JSON
Cost per invoiceEngineering time dominates$0.00042–$0.00426

A fine-tuned extractor can still win on a single frozen high-volume layout, but for the long tail of real-world invoices — different vendors, languages, scan quality — the zero-shot vision LLM is more accurate in practice and radically cheaper to own. More patterns for this class of workload live in the data-extraction use case.

The complete pattern: image → schema-validated JSON

Everything below runs against Kunavo's OpenAI-compatible endpoint — point base_url at https://api.kunavo.com/v1 and the same code can hit Claude, Gemini or GPT by changing one string:

extract_invoice.py
from openai import OpenAI
import base64, json

client = OpenAI(
    base_url="https://api.kunavo.com/v1",
    api_key="sk-kunavo-...",
)

SCHEMA = {
    "name": "invoice",
    "schema": {
        "type": "object",
        "properties": {
            "vendor_name":    {"type": "string"},
            "vendor_tax_id":  {"type": ["string", "null"]},
            "invoice_number": {"type": "string"},
            "invoice_date":   {"type": "string", "description": "ISO 8601"},
            "due_date":       {"type": ["string", "null"]},
            "currency":       {"type": "string", "description": "ISO 4217"},
            "line_items": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "description": {"type": "string"},
                        "quantity":    {"type": "number"},
                        "unit_price":  {"type": "number"},
                        "amount":      {"type": "number"},
                    },
                    "required": ["description", "amount"],
                },
            },
            "subtotal":  {"type": ["number", "null"]},
            "tax":       {"type": ["number", "null"]},
            "total":     {"type": "number"},
        },
        "required": ["vendor_name", "invoice_number", "invoice_date",
                     "currency", "line_items", "total"],
    },
}

def extract(path: str) -> dict:
    image_b64 = base64.b64encode(open(path, "rb").read()).decode()
    resp = client.chat.completions.create(
        model="claude-haiku-4-5",   # or gemini-2-5-flash for the cheapest run
        response_format={"type": "json_schema", "json_schema": SCHEMA},
        messages=[
            {"role": "system", "content":
                "Extract the invoice into the schema. Copy values exactly as "
                "printed; use null when a field is absent. Never invent data."},
            {"role": "user", "content": [
                {"type": "image_url",
                 "image_url": {"url": f"data:image/png;base64,{image_b64}"}},
            ]},
        ],
    )
    return json.loads(resp.choices[0].message.content)

inv = extract("invoice_0231.png")

# Cheap arithmetic guardrail: reject when the line items don't add up.
delta = abs(sum(li["amount"] for li in inv["line_items"])
            + (inv.get("tax") or 0) - inv["total"])
if delta > 0.02:
    raise ValueError(f"line items disagree with total by {delta:.2f}")

print(inv["vendor_name"], inv["total"], inv["currency"])

Three details do most of the work: the JSON schema in response_format (the model cannot emit anything else), the system-prompt rule “copy values exactly; null when absent; never invent” (kills hallucinated tax IDs), and the arithmetic guardrail — line items + tax must equal the total, or the document goes to a stronger model or a human. See the chat completions reference for the structured-output options.

Cost per invoice, by model

A one-page invoice ≈ 1,800 input tokens (sent as an image) + ~350 output tokens of JSON:

ModelPer invoicePer 10,000 invoicesUse for
gemini-2-5-flash$0.00042$4.20Cheapest bulk runs
claude-haiku-4-5$0.00142$14.20Default: clean printed invoices
claude-sonnet-4-6$0.00426$42.60Escalation tier: scans, handwriting, failures

Rates are live from the pricing page (about 60% under the providers' list prices), and failed requests are not billed. With two-tier routing — everything through Haiku, arithmetic-check failures escalated to Sonnet — 10,000 invoices/month typically lands under $17.04.

Accuracy: the checklist that gets you to production

  1. Schema first. Mark truly-required fields as required, allow null everywhere else — forcing a value forces a hallucination.
  2. Send the image, not OCR text. Layout carries meaning (columns, totals boxes); vision models read it directly. Only fall back to text for born-digital PDFs where you can extract a clean text layer.
  3. Validate arithmetic in code. Line items + tax = total catches most extraction errors for free.
  4. Escalate, don't retry blindly. Failures go to Sonnet with the validation error included in the prompt; persistent failures go to a human queue.
  5. Few-shot the true edge cases. Two or three examples of your worst layouts (credit notes, multi-currency) in the system prompt beat any amount of instruction tuning.

Beyond invoices

The same schema-constrained pattern covers receipts, purchase orders, delivery notes, customs forms and bank statements — swap the schema, keep the guardrails. Browse the broader structured data extraction use case for RAG-adjacent patterns, or the cost optimization guide for routing strategies once volume grows. One Kunavo key covers every model tier used here — sign up free and create a key to run the snippet as-is.

FAQ

Can I extract data from PDF invoices, not just images?

Yes — render each PDF page to PNG (e.g. with pdf2image) and send it as above. For born-digital PDFs you can also extract the text layer and send it as plain text, which is cheaper; keep the image path for scans.

How accurate is LLM invoice extraction?

On clean printed invoices, schema-constrained vision extraction with the arithmetic guardrail routinely clears 98%+ field-level accuracy — and unlike a fine-tuned model, accuracy holds on layouts it has never seen. Measure on your own documents: 100 labeled invoices make a solid eval set.

Is my invoice data used for training?

No — requests through Kunavo are passed to the model providers under API-usage terms (not consumer-app terms) and are not used to train models. See the billing & data docs for retention details.