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_formatwith a JSON schema. The server constrains decoding to the schema, so the output is not "usually JSON", it is JSON, withcurrencylimited to the four codes you listed andtotala 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: falseinchat_template_kwargsturns it off at the template level, which is more reliable than the/no_thinkprompt 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:
| field | correct |
|---|---|
| vendor name | 60 / 60 |
| customer name | 60 / 60 |
| invoice number | 60 / 60 |
| currency | 60 / 60 |
| subtotal, tax rate, tax amount, total | 60 / 60 each |
| line items: count | 60 / 60 |
| line items: every description, quantity and amount | 60 / 60 (232 of 232 lines) |
| invoice date, when the model converted it | 51 / 60 |
| due date, when the model converted it | 50 / 60 |
| all fields correct, model converting dates | 50 / 60 |
| all fields correct, dates parsed in code | 60 / 60 |
| requests failed or malformed | 0 |
| input tokens per document | 1,417 (the image is about 1,300 of them) |
| output tokens per document | 357 |
| latency, median / p90 | 3.7 s / 5.4 s |
| throughput at 4 in flight | 3,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:
- Tell it the locale in the prompt ("this vendor prints dates as MM/DD/YYYY"): 15 of 20. Better, still guessing.
- 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.strptimedoes 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_itemsfrom 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
- Run the script on your own documents, at least fifty, with truth labels for the totals at minimum. The synthetic set is the ceiling.
- Reconcile in code.
subtotal + tax_amount == totaland 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. - Handwriting, stamps, multi-page. None of that is in this set. Expect the line-item accuracy to fall first.
- 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, writesground_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".



