Guides & Tutorials10 min read

Invoice Data Extraction with a Vision LLM API: 60 Invoices, Every Field Scored, $0.78 per Thousand

We ran 60 invoices, half of them degraded like phone scans, through Qwen 3.8 27B's vision input over an OpenAI-compatible API with a JSON schema on the output. Vendor, customer, invoice number, currency, every amount and every line item came back right on all 60. The one thing that failed, half the time, was a date written with slashes, and the fix is two lines of code, not a bigger model.

The Paralon capybara holding a magnifying glass over a floating invoice while glowing rows of extracted fields lift off the page

Pulling fields out of invoices used to mean an OCR engine, a pile of regular expressions per vendor layout, and a person fixing the misses. A vision language model collapses that into one request: the image goes in, JSON comes out, and the schema you attach decides what "JSON" means. The question is how good it actually is, on documents that are not the clean PDF in the vendor's demo, and what a thousand of them cost.

This guide measures it. Sixty invoices, three layouts, four fonts, dates in three formats, half of them rotated, blurred, speckled and JPEG-crushed to look like a phone photo, sent one by one to qwen3.8-27b on the ParalonCloud inference API with a JSON schema on the response. Every field is scored against known truth. The script is at the end and runs against any OpenAI-compatible endpoint that serves a vision model.

Where the invoices come from, and why that matters

The sixty invoices are generated: a script draws them with known values, which is the only way to score twelve fields on sixty documents without labelling by hand, and the only way to publish the set without anyone's real vendor or customer on it. The generator is a single Python file, linked at the end; change the lists and it draws yours.

That cuts both ways. Synthetic invoices, even degraded, are cleaner than a crumpled receipt with a coffee stain and a handwritten "paid". Treat the numbers here as the ceiling for this model on this task, then run the same script on fifty of your own documents before you trust it with the accounts payable queue.

The request

The whole method is one chat completion with two things attached: the image, and a schema.

from openai import OpenAI
import base64, json

client = OpenAI(base_url="https://paraloncloud.com/v1", api_key=KEY)

SCHEMA = {
  "type": "object",
  "properties": {
    "vendor_name": {"type": "string"},
    "customer_name": {"type": "string"},
    "invoice_number": {"type": "string"},
    "invoice_date_as_printed": {"type": "string"},
    "due_date_as_printed": {"type": "string"},
    "currency": {"type": "string", "enum": ["EUR", "USD", "GBP", "RON"]},
    "subtotal": {"type": "number"},
    "tax_rate_percent": {"type": "number"},
    "tax_amount": {"type": "number"},
    "total": {"type": "number"},
    "line_items": {"type": "array", "items": {"type": "object", "properties": {
      "description": {"type": "string"}, "quantity": {"type": "number"},
      "unit_price": {"type": "number"}, "amount": {"type": "number"}},
      "required": ["description", "quantity", "unit_price", "amount"]}}},
  "required": ["vendor_name", "customer_name", "invoice_number", "invoice_date_as_printed",
               "due_date_as_printed", "currency", "subtotal", "tax_rate_percent", "tax_amount",
               "total", "line_items"]}

image_b64 = base64.b64encode(open("invoice.jpg", "rb").read()).decode()
r = client.chat.completions.create(
    model="qwen3.8-27b",
    messages=[{"role": "user", "content": [
        {"type": "text", "text": "Extract the fields of this invoice. Copy each date exactly as printed. "
                                 "Numbers without thousands separators. Copy names and the invoice number exactly. List every line item."},
        {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}}]}],
    response_format={"type": "json_schema", "json_schema": {"name": "invoice", "schema": SCHEMA}},
    extra_body={"chat_template_kwargs": {"enable_thinking": False}},
    temperature=0, max_tokens=900)

fields = json.loads(r.choices[0].message.content)

Three choices in there carry the result:

  • response_format with a JSON schema. The server constrains decoding to the schema, so the output is not "usually JSON", it is JSON, with currency limited to the four codes you listed and total a number, not "13,823.56 EUR". Sixty of sixty responses parsed.
  • Thinking off. Qwen 3 reasons before answering by default; on an extraction task that is a few hundred tokens of the model narrating the invoice to itself before it writes the JSON. enable_thinking: false in chat_template_kwargs turns it off at the template level, which is more reliable than the /no_think prompt switch.
  • Dates copied as printed, not converted. That is the finding of this guide, so it gets its own section.

What we measured

60 invoices, 4 requests in flight, 13 September 2026:

fieldcorrect
vendor name60 / 60
customer name60 / 60
invoice number60 / 60
currency60 / 60
subtotal, tax rate, tax amount, total60 / 60 each
line items: count60 / 60
line items: every description, quantity and amount60 / 60 (232 of 232 lines)
invoice date, when the model converted it51 / 60
due date, when the model converted it50 / 60
all fields correct, model converting dates50 / 60
all fields correct, dates parsed in code60 / 60
requests failed or malformed0
input tokens per document1,417 (the image is about 1,300 of them)
output tokens per document357
latency, median / p903.7 s / 5.4 s
throughput at 4 in flight3,629 documents per hour
cost at list price$0.00078 per document, $0.78 per 1,000

Degradation did not move a single non-date field: the thirty rotated, blurred, noisy JPEGs at quality 35 to 60 scored the same as the thirty clean PNGs on names, numbers, amounts and line items. The 232 line items came back with the right description, quantity and amount every time, including the invoice with two identical descriptions at different prices. A 27B model reading a 1000×1400 image at 1,300 tokens is, for this kind of document, an OCR engine that also understands what a subtotal is.

The one thing that broke: dates with slashes

Every wrong field in the run was a date, and every wrong date was on an invoice that printed dates the American way, 02/12/2026. ISO dates were 21 of 21, European 12.02.2026 dates were 19 of 19, American dates were 10 of 20, and the ten that failed are exactly the ten where the day was 12 or less. 02/12/2026 is 12 February in the vendor's world and 2 December in the model's guess, and nothing on the page says which. When the day was 19, the model got it right every time, because 19 cannot be a month.

This is not a model weakness; it is an underdetermined input. Two things we tried, in order:

  1. Tell it the locale in the prompt ("this vendor prints dates as MM/DD/YYYY"): 15 of 20. Better, still guessing.
  2. Ask for the date as printed and parse it in code, with the locale you know from the vendor record: 20 of 20, and it will be 20 of 20 tomorrow, because datetime.strptime does not have opinions.
from datetime import datetime

def parse_printed(text, vendor_locale):
    fmts = ["%m/%d/%Y", "%m/%d/%y"] if vendor_locale == "us" else ["%d.%m.%Y", "%d/%m/%Y", "%Y-%m-%d"]
    for f in fmts:
        try:
            return datetime.strptime(text.strip(), f).strftime("%Y-%m-%d")
        except ValueError:
            pass
    return None  # send it to a human

fields["invoice_date"] = parse_printed(fields["invoice_date_as_printed"], vendor["locale"])

The general rule hiding in there: let the model read, and let code decide. Anything the page states and your system already knows how to interpret, the vendor's country, its VAT rate, its currency, should be copied verbatim and resolved deterministically. The model's judgement is for the part no rule covers, which on an invoice is reading blurry text in the right cell.

Cost, and what changes it

$0.78 per thousand documents at list price, of which the image tokens are most: 1,300 input tokens at $0.12 per million is $0.16 per thousand, and the 357 output tokens at $1.70 per million is $0.61. Two levers:

  • Output size. The line items are the bulk of the output. If you only need totals, drop line_items from the schema and the cost roughly halves. Asking for dates as printed added 47 output tokens per document, about $0.08 per thousand, for the 100%.
  • Image size. A 1000×1400 image costs about 1,300 tokens. Downscaling a phone photo to that before sending it is both cheaper and, past a point, no worse; below about 800 px wide the small print on line items starts to go. Measure on your own documents, the script reports tokens per page.

At 3,629 documents an hour on four in-flight requests, a backlog of 100,000 invoices is a little over a day on one key and about $78. The premium key allows eight in flight, which roughly doubles that.

What to check before you trust it

  1. Run the script on your own documents, at least fifty, with truth labels for the totals at minimum. The synthetic set is the ceiling.
  2. Reconcile in code. subtotal + tax_amount == total and the sum of line amounts equals the subtotal; when either fails, route to review. On the 60 here both held every time, which means the model is not inventing numbers, but the check is free and catches the day it does.
  3. Handwriting, stamps, multi-page. None of that is in this set. Expect the line-item accuracy to fall first.
  4. Keep temperature at 0. Extraction is not the place for sampling.

The script

The generator (60 invoices, three layouts, degradation) and the scorer (concurrency, schema, per-field accuracy, tokens and cost) are two short Python files; both use only requests and Pillow. Swap the API key, point GT at your own ground-truth file, and the same report prints for your documents.

  • make_invoices.py: draws invoices from lists of vendors, customers, items and currencies, writes ground_truth.json, degrades every other one.
  • extract_invoices.py: sends each image with the schema above, four in flight, parses the as-printed dates with the vendor's locale, scores each field by normalized string match or a one-cent tolerance, and prints the tables you saw.
pip install requests pillow
OUT=img N=60 python3 make_invoices.py
PRL_KEY=prlc_... GT=img/ground_truth.json python3 extract_invoices.py

The text-only counterpart, a synthetic-data runner on the same endpoint, is in the previous guide. A free key from the Console carries a 250,000-token trial, enough for about 140 invoices of this size; after that it is the per-token price above on a premium key, billed from prepaid credits (how billing works).

Frequently asked questions

Does it work on PDFs? Render each page to an image first (pdf2image, PyMuPDF) and send the pages; the model reads pixels, not PDF text layers. For a text PDF you may not need vision at all: extract the text and send it to the same endpoint without the image, at a fraction of the input tokens.

Which vision models does the API serve? The vision launch post covers qwen3.8-27b, the model used here; /v1/models lists what is live. The request format is the OpenAI one, image_url with a data URL or an https URL, documented under chat completions.

Why not a dedicated OCR model? For a fixed layout at very high volume, a classic OCR pipeline is cheaper per page. For the long tail of layouts, where every vendor is different, the schema-plus-vision approach needs no per-vendor work, which is what usually costs the most.

Is the output good enough to post to accounting without review? Totals and line items were right 60 of 60 here, with the reconciliation checks holding. On real documents, keep the reconciliation and route failures to a person; that turns "review everything" into "review the few that do not add up".

Keep reading

Related Articles