Extract invoice data as typed JSON with a schema
A working guide to invoice extraction with a JSON Schema: which fields to mark required, how to send a PDF, reading the result, and the four things that actually go wrong.
7 min readMost invoice extraction guides stop at "send the PDF to a model and ask for JSON". That works until the model returns a total as "1.234,56 EUR" on Tuesday and 1234.56 on Wednesday, invents a VAT number that is not on the page, or renames your fields. This walks through doing it properly with a schema, end to end, with code you can run.
Start with the schema, not the prompt
The schema is the contract. It decides the field names, the types and what is allowed to be missing, and it does that for every document, not just the one you tested on.
Three decisions in there are worth copying.
Only truly mandatory fields are required. supplier_name, total and currency appear on every invoice worth processing. A purchase order number does not, and marking it required pressures the extraction to produce something rather than admit the field is absent. A null you can handle beats a plausible invention you cannot detect.
Numbers are typed as numbers. Not strings. That is what stops "1.234,56 EUR" reaching your database, because the value has to survive being a number before it is returned.
Ask only for what you use. Output tokens dominate the cost of extraction. A schema of nine fields plus line items is meaningfully cheaper than asking for every field an invoice could theoretically carry, and it is also more accurate, because there is less to get wrong.
Send the document
PDFs go in as a data URL and are read visually, so layout, tables and stamps survive. Images work the same way, and DOCX, XLSX and CSV are extracted to text.
You get 201 with a job id straight away. Extraction runs asynchronously, which is deliberate: a long document outlives an HTTP request, and we wrote about why that matters.
In TypeScript, with the schema above:
Give a webhook_url and the finished job is posted to you. Leave it out and poll GET /jobs/:jobId/get until status is no longer pending.
Read the result
output is a string. For a json target, parse it:
Numbers come back as numbers whatever separator convention the document used, so 1.234,56 on a German invoice arrives as 1234.56.
Dates come back ISO formatted too, and for a field that reaches a database it is worth making that a guarantee rather than a judgement: add "format": "date" and the runtime builds the ISO string from the parts the model read, instead of the model formatting it. Same for country, currency, iban and phone. The full list is in Schemas and formats.
Every job also reports what it cost:
The four things that actually go wrong
A field is genuinely not on the document. You want null, and you want it consistently. That is why the required list is short. Handle null in your application rather than forcing the extraction to guess.
The supplier writes numbers differently. Covered by typing them as numbers, and by normalization on the way out. Test with at least one invoice from another country before you trust it.
Line items with a merged or split row. Tables are the hardest part of any document, and complex ones are where every extraction system is weakest, ours included. If line item totals matter to you financially, check that the items sum to the subtotal in your own code, and flag the invoice for review when they do not. Take that as a warning about the state of the art, not just about us.
A document that comes back refused. Some documents are declined by the model rather than extracted, most commonly published material such as book or magazine pages. That arrives as a specific error code, not a mysterious failure, so you can route it rather than retry it forever. Failed jobs refund their hold in full.
Where to go next
- Quickstart for keys and your first job.
- API reference for the full request and response shape.
- Jobs and queue for webhooks, statuses and retry behaviour.
- Pricing for how tokens turn into a bill, and what a page really costs.