space ocr
GuidesArticlesPricingDocs
receipts

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.

7 min read· 2026-08-31

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[] — line items as an array
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
  "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.

extract line items from one invoice
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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:

data — the 商品[0] row and one child (abridged)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
  "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.

✓ Verified

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.

Click any line-item cell → the matching region lights up on the original invoice.

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.

Export the sheet — array line items expand into one row per item with colName.childName columns.

A trimmed export for this receipt looks like this:

#店舗名日付商品.商品名商品.単価
1KINSHO2019年08月17日ポッカレモン100359
2KINSHO2019年08月17日エキストラBオリー698
3KINSHO2019年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

  1. Define an array field for the line items
    In 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.
  2. Send the invoice to /ocr/fields
    POST 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.
  3. Verify each line
    Work 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.
  4. Export to CSV
    Export 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.
How do I extract line items from invoices automatically?
Declare the line-item table as a field with type "array" and a children list (for example description, qty, unit_price), then POST the image to /ocr/fields. The engine returns the array as a list of rows — one object per line item — without you writing any table-parsing code. Each row and each of its children also gets an entry in data.cells, keyed by paths like items[0] and items[0].qty, carrying box and quad coordinates plus the verified verdict and any review reasons.
Can OCR handle a variable number of line items per invoice?
Yes. An array field doesn't assume a fixed row count. The receipt in the example above yields 10 items; another invoice might yield 30. The model reads the repeating rows and returns their values; the engine then character-matches each value against the OCR symbols detected on the page and anchors its coordinates within its own row. You get as many line-item objects as the page carries, each addressable at its own path in data.cells.
How do line items appear in the CSV export?
Array fields expand on export. The header is '#' plus the scalar columns plus one column per array child, named colName.childName (e.g. items.description, items.qty, items.unit_price). Each line item becomes its own sub-row, repeating the document-level fields like vendor and date, which is the flat format ledger and spreadsheet importers expect. The file is UTF-8 with a BOM for clean CJK in Excel.
How do I know a line item was read correctly?
Start with data.review.flagged. It lists one entry per path that did not check out, each with a ranked reasons array (text_mismatch, missing and the other documented codes), and flagged.length is the count. Use the path to look up data.cells[path], where evidence.match_ratio reports the fraction of the value's characters located on the page — 1.0 means every character was found, and 0.85 and above is treated as a confident match. That is supporting evidence rather than a gate. In the app you can also click a cell to highlight the exact region the value came from.
Does it work on non-English invoices?
Yes. Language detection is automatic — Japanese, Korean, Chinese, and English run through one engine, including full-width characters. The example above extracts a Japanese receipt's 商品 (items) array with 商品名, 数量, and 単価 children. There's no language flag to set.
Related