Typing receipts and invoices into a spreadsheet is the kind of job everyone agrees should be automated, and then it isn't, because "automated" has to include knowing which pages came out wrong.
This tutorial builds a small tool around the API4AI OCR API. It reads a whole folder of scans and photos (JPEG, PNG and multi-page PDF), saves the text with the original line structure, keeps every word's position in a CSV, and writes a report that tells a person which pages to open. It's about 150 lines of Python, and the only dependency is requests.
One request
curl -X POST "https://api4ai.cloud/ocr/v1/results?algo=simple-words" \
-H "X-API-KEY: $API4AI_KEY" \
-F "image=@receipt.jpg"
The file goes in as multipart form data: in image, or a public link in url. JPEG, PNG or PDF, under 16 MB, at most 4096×4096.
algo picks the mode. The default, simple-text, returns the whole page as one block of text. simple-words returns every word with its own box, and that's the one this script uses. The reason is in the next section.
What comes back
Here's the docs example, a handwritten envelope, in simple-words mode (trimmed to three of its nine words):
{
"results": [
{
"status": { "code": "ok", "message": "Success" },
"name": "text.jpg",
"width": 1500,
"height": 1000,
"entities": [
{
"kind": "objects",
"name": "words",
"objects": [
{ "box": [0.2073, 0.301, 0.1187, 0.077],
"entities": [{ "kind": "text", "name": "text", "text": "Jane" }] },
{ "box": [0.22, 0.453, 0.094, 0.044],
"entities": [{ "kind": "text", "name": "text", "text": "1095" }] },
{ "box": [0.3793, 0.43, 0.0947, 0.061],
"entities": [{ "kind": "text", "name": "text", "text": "Main" }] }
]
}
]
}
]
}
Each box is [x, y, width, height], as fractions of the image size. For a PDF, results has one entry per page, each with a page number.
Three things the docs tell you that change the code
1. Failures come back as HTTP 200. A file the service can't read (corrupted, not really an image, too large) doesn't raise an HTTP error. It returns 200, with "failure" in status.code and the reason in status.message:
{ "status": { "code": "failure", "message": "Can not load image." },
"name": "file.txt", "entities": [] }
If you only check the HTTP code, those files silently drop out of your results. The script checks status.code for every page.
2. There's no confidence score. You can't filter on "below 80%". So the script checks the text itself: is the page empty, does it have fewer words than a real document would, and does it contain the things this kind of document always has. For receipts and invoices that's an amount and a date.
3. A PDF is billed per page. Each page is converted to an image and read separately. A 12-page contract is 12 requests. The script counts pages, so the summary line tells you what the run cost.
Rebuilding the lines
We photographed a test receipt (a made-up café, lying slightly crooked on a table) and sent it to the API in both modes. All 51 words came back right. Here's the end of the simple-text block:
1 x Banana bread
3.50
Subtotal
VAT 20% incl.
TOTAL
Card
16.50
2.75
16.50
16.50
Thank you, see you soon!
The labels and the amounts at the bottom came back as two separate runs. Which 16.50 is the total? The text alone doesn't say.
With simple-words you get every word's box, so you can rebuild the lines yourself. The obvious rule is to sort words by their vertical centre and start a new line when the centre jumps by more than half a word's height. On this receipt that rule fails too: the photo is tilted by a couple of degrees, so each amount on the right sits slightly higher than its label on the left, and "TOTAL" and "16.50" land on different lines.
The fix is to measure the tilt first. Neighbouring words on the same line give you a slope; take the median, subtract it, and then group:
def to_lines(words):
"""Rebuild reading order from word boxes, allowing for a slightly tilted photo."""
mid = lambda w: w[2] + w[4] / 2 # vertical centre
# Tilt: how far the centre moves per unit of x between neighbouring words.
slopes = [(mid(b) - mid(a)) / (b[1] - a[1]) for a in words for b in words
if 0 < b[1] - (a[1] + a[3]) < 2 * a[4] and abs(mid(b) - mid(a)) < 0.5 * a[4]]
tilt = statistics.median(slopes) if slopes else 0.0
level = lambda w: mid(w) - tilt * w[1]
...
On the receipt, that gives:
Date 16/10/2026 08:42
Receipt No. 004471
2 x Flat white 7.00
1 x Croissant 2.80
1 x Orange juice 3.20
1 x Banana bread 3.50
Subtotal 16.50
VAT 20% incl. 2.75
TOTAL 16.50
Card 16.50
It's still a simple rule. A label photographed strongly from the side, where the tilt changes across the page, breaks it: on our angled jar label it split "Wildflower Honey" over two lines. That's one more reason the report exists.
The checks
These live at the top of the script, so you can change them per document type:
MIN_WORDS = 5
EXPECT = {
"amount": r"\d[\d,]*[.,]\d{2}\b", # 12.50, 1,204.00
"date": r"\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b|\b\d{4}-\d{2}-\d{2}\b",
}
A page gets a flag when it has no text, fewer than MIN_WORDS words, or no match for one of the EXPECT patterns. For delivery notes you might expect an order number instead; for shipping labels, a postcode. Thresholds and patterns here are a starting point, not a tuned set.
The whole script
"""OCR a folder of scans and photos, and flag the pages a person should check.
pip install requests
export API4AI_KEY=... # from portal.api4.ai
python3 ocr_folder.py scans/ text/
For every file you get text/<same path>.txt (pages separated by a form feed),
plus text/words.csv (every word with its box) and text/report.csv (one row per
page: status, word count, and the reasons to look at it).
"""
import csv
import os
import pathlib
import re
import statistics
import sys
import time
from concurrent.futures import ThreadPoolExecutor
import requests
URL = "https://api4ai.cloud/ocr/v1/results?algo=simple-words"
KEY = os.environ["API4AI_KEY"]
FILES = {".jpg", ".jpeg", ".png", ".pdf"}
# What a normal page should contain. Edit these for your own documents.
MIN_WORDS = 5
EXPECT = {
"amount": r"\d[\d,]*[.,]\d{2}\b", # 12.50, 1,204.00
"date": r"\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b|\b\d{4}-\d{2}-\d{2}\b",
}
src, dst = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
def call_api(path, tries=4):
for attempt in range(tries):
with path.open("rb") as f:
r = requests.post(URL, headers={"X-API-KEY": KEY},
files={"image": f}, timeout=300)
if r.status_code in (429, 500, 502, 503, 504) and attempt < tries - 1:
time.sleep(2 ** attempt) # 1, 2, 4 s
continue
r.raise_for_status()
return r.json()["results"] # one result per page for a PDF
def words_of(page):
"""[(text, x, y, w, h), ...] from one page of the response."""
out = []
for entity in page.get("entities", []):
for obj in entity.get("objects", []):
text = " ".join(e["text"] for e in obj["entities"] if e["kind"] == "text")
out.append((text, *obj["box"]))
return out
def to_lines(words):
"""Rebuild reading order from word boxes, allowing for a slightly tilted photo."""
mid = lambda w: w[2] + w[4] / 2 # vertical centre
# Tilt: how far the centre moves per unit of x between neighbouring words.
slopes = [(mid(b) - mid(a)) / (b[1] - a[1]) for a in words for b in words
if 0 < b[1] - (a[1] + a[3]) < 2 * a[4] and abs(mid(b) - mid(a)) < 0.5 * a[4]]
tilt = statistics.median(slopes) if slopes else 0.0
level = lambda w: mid(w) - tilt * w[1]
lines = []
for w in sorted(words, key=level): # same level = same line
if lines and abs(level(w) - lines[-1][0]) < 0.5 * w[4]:
lines[-1][1].append(w)
else:
lines.append([level(w), [w]])
text = []
for _, ws in lines:
line = ""
for t, *_ in sorted(ws, key=lambda w: w[1]): # left to right
glue = t in ",.;:!?%)" or (line[-1:] == ":" and line[-2:-1].isdigit())
line += t if not line or glue else " " + t
text.append(line)
return "\n".join(text)
def checks(words, text):
flags = []
if not words:
return ["no text"]
if len(words) < MIN_WORDS:
flags.append("few words")
for name, pattern in EXPECT.items():
if not re.search(pattern, text):
flags.append("no " + name)
return flags
def ocr(path):
rel = path.relative_to(src)
out = dst / rel.with_suffix(rel.suffix + ".txt")
if out.exists():
return rel, None
rows, texts = [], []
for n, page in enumerate(call_api(path), start=1):
number = page.get("page", n)
# A file the service can't read still comes back as HTTP 200.
if page["status"]["code"] != "ok":
rows.append((rel, number, "failed: " + page["status"]["message"], [], []))
continue
words = words_of(page)
text = to_lines(words)
texts.append(text)
rows.append((rel, number, "ok", words, checks(words, text)))
if texts: # nothing written means retry next run
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\f".join(texts), encoding="utf-8")
return rel, rows
files = sorted(p for p in src.rglob("*") if p.suffix.lower() in FILES)
dst.mkdir(parents=True, exist_ok=True)
pages = failed = flagged = skipped = 0
def open_csv(name, header):
f = open(dst / name, "a", newline="", encoding="utf-8")
w = csv.writer(f)
if f.tell() == 0:
w.writerow(header)
return f, w
fr, report = open_csv("report.csv", ["file", "page", "status", "words", "flags"])
fw, wordlist = open_csv("words.csv", ["file", "page", "word", "x", "y", "w", "h"])
with ThreadPoolExecutor(max_workers=4) as pool, fr, fw:
for rel, rows in pool.map(ocr, files):
if rows is None:
skipped += 1
continue
for rel, number, status, words, flags in rows:
pages += 1
failed += status != "ok"
flagged += bool(flags)
report.writerow([rel, number, status, len(words), "; ".join(flags)])
wordlist.writerows([rel, number, t, *(round(v, 4) for v in box)]
for t, *box in words)
mark = "CHECK " if flags or status != "ok" else " "
print(f"{mark}{rel} p{number}: {status}, {len(words)} words {'; '.join(flags)}")
print(f"\n{len(files)} files: {skipped} already done. {pages} pages read "
f"(each page is billed), {failed} failed, {flagged} flagged for a person")
Run it:
pip install requests
export API4AI_KEY=...
python3 ocr_folder.py scans/ text/
What you get in text/:
-
test-receipt.jpg.txtfor every file: the rebuilt text, pages separated by a form feed. The original extension stays in the name, soinvoice.pdfandinvoice.jpgcan't overwrite each other. -
words.csv: one row per word with its page and box, for anything that needs positions later. -
report.csv: one row per page.
file,page,status,words,flags
test-receipt.jpg,1,ok,51,
invoice-77.pdf,1,ok,214,
invoice-77.pdf,2,ok,4,few words; no amount; no date
photo-blurry.jpg,1,ok,0,no text
broken.png,1,failed: Can not load image.,0,
(The first row is the test receipt above; the others are examples.) Only the last three need a person.
What else it takes care of:
- Originals are never touched. Output goes to a separate folder with the same structure.
-
Resume. A file with a
.txtalready in place is skipped, so after a crash or Ctrl-C you run the same command again. Files that failed have no.txt, so they're retried. - Rate limits. A 429 or a 5xx waits 1, 2, then 4 seconds before giving up.
- Count pages. The last line says how many pages were read, failed and flagged. Pages, not files, are what you pay for.
Four workers is a starting point. PDFs take longer than single images, so if you see timeouts, lower it before raising the timeout.
What it costs
On the API4AI developer portal the OCR API is pay-as-you-go at $5 per 1,000 requests, with no subscription. One image is one request; a PDF counts per page. New accounts start with $5 of credit, about 1,000 pages. For large volumes, ask for a quote at hello@api4.ai.
Before you write code
Put your worst pages through the demo on the OCR API page first: the faded thermal receipt, the handwritten form, the photo taken at an angle. It takes any JPEG or PNG and shows every word it found. The full reference is in the docs. There's also a separate VIN endpoint that applies VIN rules and returns suggestions, if that's your use case. The less technical version of this post, covering receipts, forms, labels and VINs, is on our blog.
What does your pipeline do with a page it can't read: retry, skip, or send it to a person?
Top comments (0)