space ocr
GuidesArticlesPricingDocs
developer

Parse Invoices in Python: A REST API Guide

Extract structured data from invoices using Python. A guide to calling a simple REST API to get JSON fields, line items, and verifiable coordinates for any invoice image.

7 min read· 2026-08-31

Getting structured data from invoices is a common but frustrating task. You might have a folder of scanned receipts or vendor invoices as JPEGs or PNGs, and you need to pull out the invoice number, total amount, and each line item for accounting or analysis. Most OCR tools dump a wall of text, leaving you to piece the structure back together with fragile regular expressions.

There is a more direct way: a REST API call that returns the structured JSON you need, with every value traced back to its exact location on the page. The interactive demo below shows the final result. Click any field on the left to see its position highlighted on the invoice.

Invoice with extracted-field bounding boxes
Verified fields
Invoice

Each value with a box carries a verified on-page location — in data.cells[path], that is box + 4-point quad + evidence.match_ratio — on a 0–1000 normalized grid (0,0 top-left → 1000,1000 bottom-right), the same shape the live API returns. Hover a field to trace it back to the pixels it came from.

An example invoice for OCR processing
An invoice image — the input to /ocr/fields.

space-ocr is not a library you install, but a plain HTTP service. The process is simple: take an invoice image, send it to the POST /ocr/fields endpoint, and define the schema you want back. You can specify fields like invoice_number, total_due, and a table of line_items with columns for description and price. The API returns that schema as data.values, and keeps the coordinates and the review signals in separate keys beside it.

Every extracted value is linked to its bounding box on the original image, providing a verifiable audit trail.

The response keeps the data and the checks in separate places. data.values is your schema and nothing else — an invoice_number of "20250430-001" sits exactly where you declared it. data.cells is a flat map keyed by path, so cells["total_due"] holds that value's box and quad on the page along with a verified verdict. data.review.flagged is the short list of paths worth opening again, each with its reasons. Because the schema below declares a date and several numbers, data.normalized comes back with the parsed forms as well, while values keeps the text as it was read. The Python script below walks each of them in turn.

parse_invoice.py
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
import os
import base64
import json
import requests

# Get your API key from an environment variable
API_KEY = os.environ.get("SPACE_OCR_API_KEY")
IMAGE_PATH = "./path/to/your/invoice.jpg" # Path to your invoice image
API_URL = "https://api.space-ocr.com/ocr/fields"

if not API_KEY:
    raise ValueError("API key not found. Set the SPACE_OCR_API_KEY environment variable.")

# Declare the schema you want back.
# 'type' adds a deterministic parse under data.normalized; 'required' and 'pattern'
# are checked after extraction and show up in the review list when they fail.
# Declarations never reach the model: they do not change the value, only the signals.
fields_schema = [
    {"name": "supplier", "type": "string", "description": "The name of the company that issued the invoice."},
    {"name": "invoice_number", "type": "string", "required": True, "pattern": "^[0-9]{8}-[0-9]{3}$"},
    {"name": "issue_date", "type": "date", "required": True},
    {"name": "total_due", "type": "number", "required": True},
    {
        "name": "line_items",
        "type": "array",
        "description": "All items listed in the invoice table.",
        "children": [
            {"name": "item_description", "type": "string"},
            {"name": "unit_price", "type": "number"},
            {"name": "quantity", "type": "integer"},
            {"name": "line_total", "type": "number"}
        ]
    }
]

# Read the image file and encode it in base64
with open(IMAGE_PATH, "rb") as image_file:
    base64_image = base64.b64encode(image_file.read()).decode("utf-8")

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json"
}

payload = {
    "image": base64_image,
    "imageType": "base64",
    "fields": fields_schema
}

print(f"Sending request for {IMAGE_PATH}...")
response = requests.post(API_URL, headers=headers, json=payload)

if response.status_code != 200:
    # Errors use one envelope: {"error": {"code", "message", "requestId"}, "details": {...}}
    try:
        err = response.json()["error"]
        print(f"HTTP {response.status_code} {err['code']}: {err['message']} (requestId {err['requestId']})")
    except (ValueError, KeyError):
        # A body over 32MiB is cut off before the app sees it and comes back as HTML
        print(f"HTTP {response.status_code}: {response.text[:200]}")
    if response.status_code == 429:
        print(f"Rate limited - Retry-After: {response.headers.get('Retry-After')} seconds")
    # 400 invalid_image is not charged and a retry will not help; 502 is refunded and worth
    # retrying; 504 means the 180s sync ceiling was hit, so send that page on its own.
    raise SystemExit(1)

data = response.json()["data"]
values = data["values"]                  # your schema, and nothing else
cells = data["cells"]                    # flat map: path -> box / quad / verified / review / evidence
review = data["review"]                  # what is worth a second look
normalized = data.get("normalized", {})  # deterministic parse of the types you declared
image = data["image"]                    # {"width", "height"} - the frame the coordinates use

print("\n--- Extracted Invoice Data ---")
print(json.dumps(values, ensure_ascii=False, indent=2))

# 'values' keeps the text as it was read; the parsed form lives here.
# A null leaf means the parse failed - the reason sits in cells[path]["normalized"]["error"].
print("\nissue_date parsed:", normalized.get("issue_date"))
print("total_due parsed :", normalized.get("total_due"))

# The review queue: every entry carries a path and a rank-ordered reasons array.
print(f"\n{len(review['flagged'])} of {review['declared']} declared fields were flagged:")
for item in review["flagged"]:
    path = item["path"]
    reasons = item["reasons"]      # reasons[0] is the primary one
    cell = cells.get(path)         # 'missing' and 'nobox' have no cell to point at
    box = cell["box"] if cell else None
    print(f"  {path}: {reasons[0]} ({', '.join(reasons)}) box={box}")

# Where a single value came from
total = cells.get("total_due")
if total:
    print("\ntotal_due")
    print("  box     :", total["box"])       # {"xmin", "ymin", "xmax", "ymax"}, 0-1000
    print("  quad    :", total["quad"])      # four {"x", "y"} points, following the page tilt
    print("  verified:", total["verified"])  # False if flagged, True if a check ran clean, None if there was nothing to check
    print("  evidence:", total["evidence"])  # text_match, source, match_ratio, printed_text ...
    left = total["box"]["xmin"] / 1000 * image["width"]
    top = total["box"]["ymin"] / 1000 * image["height"]
    print(f"  pixels  : ({left:.0f}, {top:.0f}) on a {image['width']}x{image['height']} page")

# Line items: values holds the rows, cells is keyed by the indexed path
print("\nline_items")
for i, row in enumerate(values.get("line_items") or []):
    cell = cells.get(f"line_items[{i}].line_total")
    verified = cell["verified"] if cell else None
    print(f"  row {i}: {row.get('item_description')} x{row.get('quantity')} "
          f"= {row.get('line_total')} (verified={verified})")
✓ Verified

To ensure accuracy, space-ocr doesn't just trust the language model's output. The model returns the text value plus hints about which words it used on the page. The engine then performs a character-by-character match of the extracted value against the page's actual OCR symbols. That match produces the match_ratio confidence score (where ≥ 0.85 is a high-confidence match) and the coordinates, and both land in data.cells[path]: box as the integer keys xmin, ymin, xmax, ymax, and quad as four points that follow the tilt of the page. All coordinates are normalized to a 0-1000 scale, making them independent of the original image resolution, and data.image reports the page size to convert them to pixels. The comparison itself is reported as evidence.text_match, while verified is the cell's overall verdict — it turns false whenever anything was flagged, including a rule you declared yourself.

The API is priced per call, not per field or page complexity. It costs $0.05 per image, and requests that fail to produce a result are not charged. New accounts get 100 free scans each month to start.

  1. Get Your API Key
    Sign up for a free space-ocr account and find your API key in the dashboard settings. The key will start with 'spocr_'.
  2. Prepare Your Invoice Image
    Save your invoice as a common image file, such as a JPEG or PNG. Note the file path for your script.
  3. Define Your Data Schema
    Create a JSON array in your Python script that defines the names and types of the fields you want to extract, including any line-item tables.
  4. Write the Python Script
    Using the requests library, write a script to read the image, convert it to base64, and POST it to https://api.space-ocr.com/ocr/fields with your API key in the Authorization header.
  5. Run and Process the JSON
    Execute your script. Load `data.values` into your database, accounting software or analytics tool, and open the handful of values listed in `data.review.flagged` first.
Do I need to install an SDK or library?
No, space-ocr is a standard HTTP REST service. You can call it from any language or tool that can make HTTP requests, like cURL or Python's requests library.
Can I process PDF invoices?
The API endpoint accepts raster image formats like JPEG, PNG, or WebP. To process a PDF file, you first need to render each page into an image in your own code before sending it to the API. The space-ocr web application handles this conversion for you automatically.
What are the coordinates in the response?
The API returns 0-1000 normalized coordinates, where (0,0) is the top-left corner and (1000,1000) is the bottom-right. Each entry in `data.cells` carries a `box` with the integer keys `xmin`, `ymin`, `xmax` and `ymax`, and next to it a `quad` of four points that follows the tilt of the page. To convert either one to pixels, use `data.image`, which gives the width and height of the page as it was read.
How do I handle line items or tables?
In your `fields` schema, define a field with `type: 'array'`. Then, specify the columns you want to extract for each row inside the nested `children` property. The API will return an array of objects for that field.
What if I don't know the invoice layout beforehand?
If you can't settle the schema in advance, set `autoFields: true` in the request body instead of supplying a `fields` array. The service detects the document's structure and returns the fields it finds. Once you see which names come back, you can declare them explicitly in `fields` for every run after that.
How do I know which values to check by hand?
Read `data.review.flagged`. Each entry gives a `path` and a rank-ordered `reasons` array whose first element is the primary one — codes such as `missing`, `text_mismatch` or `pattern_mismatch` say why the value was raised. The count is the length of `flagged` itself; there is no separate counter. Per value, `cells[path].verified` returns the same verdict as a boolean, and the character-level detail sits in `cells[path].evidence`, including `match_ratio` (0.0 to 1.0, where 0.85 and above is treated as a high-confidence match). Values that both engines happen to misread the same way can still pass, so the list narrows the manual pass rather than removing it.

Start parsing invoices in minutes.

Get your API key and 100 free scans a month. No credit card required.

Related