Extract line items from invoices automatically
Pull invoice and receipt line items into structured rows automatically. Declare an array field, get a row per item with source coordinates and a review list of what to check, then export to CSV.
Invoices and receipts are the documents people most want to digitize, and the hardest part is never the header. Vendor name, date, invoice number — those are single values an OCR model can grab in one shot. The pain is the table in the middle: a variable number of line items, each with a description, a quantity, and a price, that has to come out as clean rows you can total, reconcile, and load into a ledger.
This guide shows how to extract line items from invoices automatically with space-ocr — not as flattened text, but as a structured array where every line is its own row and every cell still points back to the exact spot on the page it was read from. If you are extracting whole documents rather than just the table, start with the broader invoice and receipt OCR walkthrough.
The trick: declare line items as an array field
Most OCR APIs make you extract the table as a single string and parse it yourself. space-ocr lets you describe the line-item table as part of the schema. A FieldSpec with type: "array" and a children list tells the engine: this region repeats, and each repetition has these sub-fields.
Here is an example schema for a receipt. The 商品 ("items") field is an array whose children are 商品名 (name), 数量 (quantity), and 単価 (unit price):
{
"fields": [
{ "name": "店舗名", "type": "string", "description": "store name" },
{ "name": "日付", "type": "string", "description": "date" },
{ "name": "合計", "type": "string", "description": "total" },
{
"name": "商品",
"type": "array",
"description": "one row per line item",
"children": [
{ "name": "商品名", "type": "string", "description": "item name" },
{ "name": "数量", "type": "string", "description": "quantity" },
{ "name": "単価", "type": "string", "description": "unit price" }
]
}
]
}Post that to POST /ocr/fields with the image, and the array field comes back as a list. This receipt yields 10 line items — ポッカレモン100 at 359, シール割引 at -34 (a discount line, sign preserved), エキストラBオリー at 698, and so on. You didn't write a row parser, a column splitter, or a regex. You declared the shape once.
curl -s https://api.space-ocr.com/ocr/fields \
-H "Authorization: Bearer $SPACE_OCR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/receipt.jpg",
"imageType": "url",
"fields": [
{ "name": "total", "type": "string" },
{ "name": "items", "type": "array",
"children": [
{ "name": "description", "type": "string" },
{ "name": "qty", "type": "string" },
{ "name": "unit_price", "type": "string" }
] }
]
}'Each line item is independently verifiable
This is where line-item extraction usually goes wrong: the model returns a tidy-looking table that's subtly misaligned — a price shifted up a row, a description merged with the one below.
The response keeps the two concerns apart. data.values holds the business data in exactly the shape you declared, and nothing else. Where a value came from and whether it held up lives in data.cells, a flat map keyed by path: 商品[0] is the whole first row (its union box), 商品[0].単価 is that row's unit price. Every entry carries box, quad, verified, review and evidence, and data.review.flagged lists the paths worth a second look. One line of the receipt looks like this:
{
"values": {
"商品": [
{ "商品名": "ポッカレモン100", "数量": "1", "単価": "359" }
]
},
"cells": {
"商品[0]": {
"box": { "xmin": 96, "ymin": 354, "xmax": 486, "ymax": 380 },
"quad": [
{ "x": 96, "y": 358 }, { "x": 486, "y": 354 },
{ "x": 486, "y": 376 }, { "x": 96, "y": 380 }
],
"verified": null,
"review": null,
"evidence": { "source": "vision_symbol_match", "match_ratio": 1.0 }
},
"商品[0].単価": {
"box": { "xmin": 450, "ymin": 356, "xmax": 484, "ymax": 378 },
"quad": [
{ "x": 450, "y": 360 }, { "x": 483, "y": 356 },
{ "x": 485, "y": 374 }, { "x": 452, "y": 378 }
],
"verified": true,
"review": null,
"evidence": { "text_match": true, "source": "vision_symbol_match", "match_ratio": 1.0 }
}
},
"review": {
"unit": "field",
"flagged": [
{ "path": "商品[3].単価", "reasons": ["text_mismatch"] }
],
"by_reason": { "text_mismatch": 1 }
},
"image": { "width": 1654, "height": 2339 }
}So a price isn't just 359 — it's 359 inside a box on a 0–1000 normalized grid (xmin/ymin/xmax/ymax, top-left origin), with a quad of four points that follows the document's tilt. data.image reports the width and height of the page as it was read, which is what converts those numbers back to pixels.
evidence.match_ratio says how much of the value's text was actually located on the page: 1.0 means every character was found, and the engine treats ≥ 0.85 as a confident match. Read it as supporting evidence, not as the gate. The work list is data.review.flagged — one entry per path, each with a ranked reasons array whose first element is the primary one, and flagged.length is the number of things to look at. Sorting the remaining rows by match ratio is still a useful second pass when you want the weakest lines first. For the full mechanics, see validating OCR with bounding boxes.
The model doesn't invent those coordinates. The language model returns each line item's text — plus a hint of which word tokens it used — but never the boxes. The engine then character-matches that text against the symbols the vision OCR actually detected on the page, reporting a match_ratio for how much of each value it found. The model's token hints can be noisy across repeated rows, so column- and row-consistency checks validate them rather than trusting them blindly — which matters most on a 30-row table where lines look alike. That's what makes the table checkable instead of merely plausible: every line carries a score for how well it matched the page.
Click a line, land on the pixels
Because every line item knows where it lives, spot-checking a table becomes a click. In the app you click any cell — a description, a quantity, a unit price — and the source image highlights the exact region that value came from, with a zoomed crop. For an invoice with thirty lines, your eye goes straight to the one that looks off instead of scanning the whole page.
From line items to a CSV your accounting tool can read
Once line items are stored in a sheet, exporting is where the array shape pays off again. space-ocr expands array fields on export: the header becomes # plus the scalar columns, plus one column per array child named colName.childName (so 商品.商品名, 商品.数量, 商品.単価). Each line item becomes its own sub-row — a receipt with 10 items produces 10 rows, all carrying the same store name and date. That's exactly the long, flat format spreadsheets and ledger importers expect.
A trimmed export for this receipt looks like this:
| # | 店舗名 | 日付 | 商品.商品名 | 商品.単価 |
|---|---|---|---|---|
| 1 | KINSHO | 2019年08月17日 | ポッカレモン100 | 359 |
| 2 | KINSHO | 2019年08月17日 | エキストラBオリー | 698 |
| 3 | KINSHO | 2019年08月17日 | シール割引 | -34 |
The file is UTF-8 with a BOM, so Japanese, Korean, and Chinese item names open cleanly in Excel. Any manual correction you made overrides the OCR value in the export, while the original stays on record.
If you are going to compute with these numbers, declare 数量 and 単価 as number (or integer). The declared type never reaches the model, so the extracted text is unchanged; what it adds is data.normalized, a sparse tree shaped exactly like data.values whose leaves are parsed deterministically — "359" arrives as 359. A leaf that will not parse comes back null, with the reason on that path's cell and type_mismatch among its review reasons. Source verification and business rules answer different questions, so it is worth running both: recompute quantity × unit price against the line amount, and work through data.review.flagged for the values whose source did not check out.
For the end-to-end image-folder-to-spreadsheet flow, see scanned documents to CSV.
Do it in a few steps
- Define an array field for the line itemsIn your fields[] schema, add a field with type "array" and a children list — e.g. description, qty, unit_price. This tells the engine the line-item region repeats with those sub-fields.
- Send the invoice to /ocr/fieldsPOST the image (as a URL or base64) with imageType and your fields[] to https://api.space-ocr.com/ocr/fields. The array field comes back as a list, one object per line item.
- Verify each lineWork through data.review.flagged first: each entry gives a path and its reasons. Look that path up in data.cells to get box, quad, verified and evidence, or click the cell in the app to jump to the exact region on the image and confirm the value.
- Export to CSVExport the sheet — array children expand into colName.childName columns and each line item becomes its own row, with document-level fields repeated, ready for your accounting tool.