DEV Community

OCR-AI
OCR-AI

Posted on

Pulling fields out of Hebrew invoices with a REST API

By the OCR-AI team

If you've ever run a scanned Israeli tax invoice through a general-purpose OCR library, you know the output. The words come back in the wrong order, the phone number is split across two lines, and the total ends up somewhere next to the vendor's address. You can fix some of it with post-processing. Most people give up and retype.

This post walks through reading Hebrew invoices and receipts with our REST API, and then spends some time on why this is harder than it looks, because that part applies no matter which tool you use.

The one endpoint you need

Reading a document is a single call: POST https://ocr-ai.com/api/v1/read. It accepts PDF, JPEG, PNG and WebP, and you authenticate with an API key as a Bearer token (Authorization: Bearer ocrai_sk_...; an X-API-Key header also works).

You can send the file three ways:

  • multipart/form-data, with the file in a field called file
  • JSON, with file_base64 or file_url (a public https link)
  • the raw bytes, with the file's own Content-Type, and the options as query parameters

The options are the same in each case: pages (like "1-3" or "1-5,8"), hint (auto, invoice, receipt, card, table or text) and language for labels and the summary (en or he).

Upload from disk:

curl https://ocr-ai.com/api/v1/read \
  -H "Authorization: Bearer $OCRAI_KEY" \
  -F "file=@invoice.pdf" \
  -F "pages=1-3"
Enter fullscreen mode Exit fullscreen mode

Or point it at a URL:

curl https://ocr-ai.com/api/v1/read \
  -H "Authorization: Bearer $OCRAI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"file_url": "https://example.com/invoice.pdf", "hint": "invoice"}'
Enter fullscreen mode Exit fullscreen mode

One thing worth knowing: the server decides the file type from the bytes, not from the name or the declared type. And unknown fields are rejected with a 400 bad_request that names the offending field, which is nice when you typo hnit.

What comes back

The response is a document object. At the top level you get page accounting (total_pages, pages_read, pages_charged, balance, next_pages), and then pages[], one entry per page. Each page that was read has a document_type, a language, a short summary, plus fields, tables and the full text in reading order.

Fields are the interesting part. Each one has a key, a human label, the value as printed, a type, and where it applies a normalized value and a box:

{
  "key": "issue_date",
  "label": "תאריך הפקה",
  "value": "21/10/2026",
  "normalized": "2026-10-21",
  "type": "date",
  "box": [144, 706, 163, 798]
}
Enter fullscreen mode Exit fullscreen mode

normalized is what you actually want to store: dates as YYYY-MM-DD, money as a plain decimal ("6,018.00 ₪" becomes "6018"). The box is [ymin, xmin, ymax, xmax] on a 0-1000 scale relative to the page, so you can draw a highlight over the scan in a review UI. If a value was hard to read, the field carries "uncertain": true.

The keys are fixed. For invoices you'll see things like vendor_name, vendor_tax_id, document_number, allocation_number, issue_date, due_date, subtotal, vat_rate, vat_amount, total and currency. Line items arrive as a table with columns and rows.

A small Python script

Here's a minimal script that reads an invoice and prints the header fields plus the line items. It uses only the documented endpoint and response shape.

import os
import requests

KEY = os.environ["OCRAI_KEY"]

def read_invoice(path):
    with open(path, "rb") as f:
        r = requests.post(
            "https://ocr-ai.com/api/v1/read",
            headers={"Authorization": f"Bearer {KEY}"},
            files={"file": f},
            data={"hint": "invoice", "language": "en"},
            timeout=180,  # multi-page calls can take over a minute
        )
    if r.status_code == 402:
        body = r.json()
        raise SystemExit(f"Out of pages: {body['message']} -> {body['buy_url']}")
    r.raise_for_status()
    return r.json()

doc = read_invoice("invoice.pdf")

for page in doc["pages"]:
    if page["status"] != "done":
        print(f"page {page['page']}: {page['status']} {page.get('error', '')}")
        continue
    fields = {f["key"]: f for f in page["fields"]}
    for key in ("vendor_name", "vendor_tax_id", "document_number",
                "allocation_number", "issue_date", "vat_amount", "total"):
        f = fields.get(key)
        if f:
            flag = "  <- check" if f.get("uncertain") else ""
            print(f"{key:18} {f.get('normalized', f['value'])}{flag}")
    for table in page["tables"]:
        print(table["columns"])
        for row in table["rows"]:
            print(row)
Enter fullscreen mode Exit fullscreen mode

A few details in there are deliberate. The long timeout is because a call can read up to 20 pages and that can take more than a minute. The per-page status check matters because a call can succeed while one page fails; a failed page is given back (refunded: true) and has an error such as engine_busy, which you can simply retry. And the 402 branch exists because when the account has fewer pages than the call needs, nothing is read or charged. You get a buy_url instead.

Limits to plan around: 20 pages per call, 20 MB per file, 28 MB per request body, roughly 30 calls a minute per key. If a PDF has more pages than one call reads, next_pages tells you the range to ask for next.

Why Hebrew OCR is genuinely hard

None of this is specific to us. If you're evaluating any tool, these are the places to test.

Bidirectional text. Hebrew runs right to left, but numbers, dates, email addresses and English product names inside it run left to right. A PDF stores characters in some order, and a viewer applies the Unicode bidi algorithm to display them. Copy text out of a lot of Hebrew PDFs and you get the stored order, which is why "reversed Hebrew" is such a common complaint. Reversing the string fixes the Hebrew and breaks every number.

Mixed numbers and currency. An invoice line like 2 × 680.00 = 1,360.00 ₪ has to survive with the digits in order and the shekel sign attached to the right number. Thousands separators and decimal points look like noise to a naive tokenizer. Getting this wrong is worse than getting a word wrong, because nobody notices until the books don't balance.

Layout. Israeli invoices tend to put the vendor block top right, the customer block top left, and a table whose first column is on the right. Reading order for a page like that isn't "top to bottom, left to right" or even its mirror image.

Nikud and look-alike letters. Most business documents don't use vowel points, but some printed material does, and stray dots from a bad scan can be mistaken for them. Several letters differ by a tiny stroke (ד and ר, ה and ח, ו and ן), which is exactly the detail a faded thermal receipt loses.

Fonts. Hebrew typography is a smaller world, and a lot of invoices are produced by older accounting software with idiosyncratic fonts, or printed on thermal paper that fades within months.

So treat the output as something to check, not something to trust blindly. That's why every field has a box and an uncertainty flag: in a pipeline, route anything marked uncertain to a person, and let the rest through.

Trying it without code

If you just want to see what a given invoice produces, the browser version at ocr-ai.com reads 5 pages a day free, with no signup. For the API, an account's first key starts with 100 free pages. The full reference, including every error code, is at /docs, and there's a Markdown copy at /docs.md if you want to paste it into a model's context.

Top comments (0)