One endpoint.
One header.
One file.
Everything below is verified against the running API. Base URL https://api.optii.eu
Authentication
Send your key in the X-API-Key header. Keys start with optii_sk_ and are shown once, at creation. Keys are stored hashed; a lost key is rotated, not recovered.
Every response carries an X-Request-ID header. Quote it when you write to us.
curl https://api.optii.eu/ocr/extract \ -H "X-API-Key: optii_sk_…" \ -F "file=@document.pdf"
POST/ocr/extract
Multipart upload of one file. Returns every line with its bounding box and confidence, in reading order, page after page.
Query parameters
- structured
- false (default) for lines, true for Belgian document fields (next section).
- format
- v2 (default). legacy returns the flat invoice shape of 2025 clients; do not use it for new code.
Response 200
{
"success": true,
"text": "DUMONT & FILS SPRL\nRue de la Loi 42 · 1000 Bruxelles\nTVA BE0202239951\n…",
"lines": [
{ "text": "DUMONT & FILS SPRL", "confidence": 0.996, "bbox": [[50.0, 50.0], [304.0, 50.0], [304.0, 84.0], [50.0, 84.0]] },
{ "text": "Rue de la Loi 42 · 1000 Bruxelles", "confidence": 0.991, "bbox": [[56.0, 82.0], [286.0, 82.0], [286.0, 100.0], [56.0, 100.0]] },
{ "text": "TVA BE0202239951", "confidence": 0.993, "bbox": [[56.0, 106.0], [198.0, 106.0], [198.0, 124.0], [56.0, 124.0]] }
],
"confidence": 0.991,
"source": "ocr",
"quality": { "blur": 812.4, "long_side_px": 2339, "low_confidence_ratio": 0.0, "warnings": [] },
"file_hash": "a3b2c1d4e5f6",
"processing_time_ms": 342
} Fields
- text
- the full text, lines joined with newlines
- lines[]
- one object per line: text, confidence (0 to 1), bbox as four corner points [[x, y], …] in page pixels, clockwise from the top left
- confidence
- mean line confidence
- source
- text, ocr or hybrid, see Source and quality
- quality
- blur, long_side_px, low_confidence_ratio, warnings[]
- file_hash
- SHA-256 of the file, first characters; the same file always gives the same value
- processing_time_ms
- server time in milliseconds, upload excluded
POST/ocr/extract?structured=true
The same call with ?structured=true. The engine detects the document kind from its text (invoice, credit_note, debit_note, receipt, else unknown), extracts the Belgian fields and runs the checks. When the classifier is confident, document_type carries its label instead.
A field that is not on the page is null. Amounts are numbers with a dot, dates are kept as printed (dd/mm/yyyy on most Belgian paper).
{
"document_type": "invoice",
"source": "ocr",
"quality": { "blur": 812.4, "long_side_px": 2339, "low_confidence_ratio": 0.0, "warnings": [] },
"supplier_name": "DUMONT & FILS SPRL",
"supplier_vat": "BE0202239951",
"supplier_iban": "BE68539007547034",
"document_number": "2024-0847",
"document_date": "15/03/2024",
"due_date": "14/04/2024",
"structured_communication": "+++123/4567/89002+++",
"currency": "EUR",
"line_items": [
{ "description": "Conseil juridique — mars 2024", "quantity": 10, "unit_price": 125.0, "line_total": 1250.0, "vat_rate": 21.0 }
],
"vat_subtotals": [ { "rate": 21.0, "base_amount": 1250.0, "vat_amount": 262.5 } ],
"total_excl_vat": 1250.0,
"total_vat": 262.5,
"total_incl_vat": 1512.5,
"validations": [
{ "check": "vat_checkdigit", "passed": true, "message": "" },
{ "check": "iban_mod97", "passed": true, "message": "" },
{ "check": "structured_comm_mod97", "passed": true, "message": "" },
{ "check": "totals_sum", "passed": true, "message": "" }
],
"confidence": 0.91,
"processing_time_ms": 342
} Checks
- vat_checkdigit
- the Belgian VAT number’s check digits (97 minus the first eight digits mod 97)
- iban_mod97
- the IBAN rearranged mod 97 equals 1
- structured_comm_mod97
- the structured communication +++xxx/xxxx/xxxxx+++ check digits
- totals_sum · vat_subtotals_sum · vat_bases_sum
- total_excl_vat + total_vat = total_incl_vat within one cent
- line_items_sum
- the line items sum to the total excl. VAT when present
POST/ocr/classify
Multipart upload of one file. The text is read (text layer or OCR) and classified by an XLM-RoBERTa model into one of 32 Belgian document types.
Query parameters
- top_k
- number of predictions returned, 1 to 20, default 5.
Response 200
{
"success": true,
"document_type": "acc_purchase_invoice",
"confidence": 0.9361,
"confident": true,
"predictions": [
{ "label": "acc_purchase_invoice", "confidence": 0.9361, "rank": 1 },
{ "label": "acc_sales_invoice", "confidence": 0.0298, "rank": 2 },
{ "label": "acc_credit_note", "confidence": 0.0112, "rank": 3 }
],
"processing_time_ms": 388
} - document_type
- the label when confident is true, otherwise unknown
- confident
- true when the top probability is at or above the threshold and the label is not a fallback
- predictions[]
- label, confidence, rank, best first
Labels are snake_case with a family prefix, for example acc_purchase_invoice or acc_sales_invoice. The full list is on the dashboard.
Source and quality
Every extract response says where the text came from.
- text
- the PDF had a usable text layer; characters are exact, every line has confidence 1
- ocr
- every page went through the engine
- hybrid
- some pages had a layer, some did not
quality quality is computed on the pages that went through OCR:
- blur
- variance of the Laplacian on a grayscale downscaled to 1500 px; lower is blurrier
- long_side_px
- the smallest page long side in pixels
- low_confidence_ratio
- share of lines under 0.80 confidence
- warnings[]
- any of low_resolution (under 1500 px), blurry (under 400), low_confidence (ratio over 0.15)
When warnings is not empty, show the user the page and ask for a rescan. The numbers may still be right; you cannot know.
Idempotency
Send an Idempotency-Key header (any string up to 255 characters, a UUID is fine). For 24 hours the same key with the same file and parameters returns the stored response without a second read and without a second count. The same key with a different file answers 409 idempotency_conflict.
The fingerprint is the SHA-256 of the file plus the parameters, so a retried multipart upload matches even when the boundary differs.
Rate limits and quotas
Limits are per plan and per key. The table is the live configuration.
| Plan | Requests / min | Max file | Keys | Monthly quota |
|---|---|---|---|---|
| Free | 10 | 5 MB | 1 | 500 |
| Pro | 60 | 20 MB | 5 | 10.000 |
| Enterprise | 300 | 20 MB | 100 | unlimited |
Over the rate limit: 429 rate_limited with Retry-After. Over the quota: 429 with a message that names the quota. The public demo endpoint allows 10 requests per minute per IP and 5 MB.
Errors
Every error is JSON with two fields: error, a snake_case code, and message, a sentence for humans. Never parse the message.
HTTP/1.1 413 Payload Too Large
X-Request-ID: 6f1c0a2b9d3e
Content-Type: application/json
{ "error": "file_too_large", "message": "File too large (max 5MB)" } | Status | error | When | What to do |
|---|---|---|---|
| 400 | unsupported_file_type | extension not in pdf png jpg jpeg tiff tif bmp webp | convert the file |
| 401 | unauthorized | missing or unknown key | check the key in the dashboard |
| 403 | forbidden | the plan does not allow it (key limit, size) | see pricing |
| 409 | idempotency_conflict | same Idempotency-Key, different file | use a new key |
| 413 | file_too_large | over the plan’s max file | compress or split |
| 422 | validation_failed | no file field, bad query parameter | read the request section |
| 429 | rate_limited | rate limit or monthly quota | wait for Retry-After, or upgrade |
| 500 | processing_failed | the engine could not read the file | retry once, then write to us with the request id |
| 503 | model_unavailable | the engine is loading after a restart | retry in a few seconds |
| 503 | db_unavailable | usage database down; reads still work | retry |
Formats and limits
Input: PDF (text or scanned, multi-page), PNG, JPG, TIFF, BMP, WebP. Max 20 MB with a key, 5 MB on the demo. Languages read: French, Dutch, German, English; the engine handles 50 scripts but the field extraction and classifier are Belgian.
Output: JSON, UTF-8. Line order is reading order per page. Bounding boxes are polygons in the pixel space of the rendered page.
Snippets
No SDK to install. Three snippets that work as they are.
curl -X POST "https://api.optii.eu/ocr/extract?structured=true" \ -H "X-API-Key: optii_sk_…" \ -H "Idempotency-Key: $(uuidgen)" \ -F "file=@facture-2024-0847.pdf"
import requests, uuid
with open("facture-2024-0847.pdf", "rb") as f:
r = requests.post(
"https://api.optii.eu/ocr/extract",
params={"structured": "true"},
headers={"X-API-Key": "optii_sk_…", "Idempotency-Key": str(uuid.uuid4())},
files={"file": f},
timeout=90,
)
r.raise_for_status()
doc = r.json()
print(doc["supplier_vat"], doc["total_incl_vat"], doc["validations"])import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("file", new Blob([await readFile("facture-2024-0847.pdf")]), "facture-2024-0847.pdf");
const res = await fetch("https://api.optii.eu/ocr/extract?structured=true", {
method: "POST",
headers: { "X-API-Key": "optii_sk_…", "Idempotency-Key": crypto.randomUUID() },
body: form,
});
if (!res.ok) throw new Error(`${res.status} ${(await res.json()).error}`);
const doc = await res.json();
console.log(doc.supplier_vat, doc.total_incl_vat, doc.validations); OpenAPI
The machine-readable contract is at https://api.optii.eu/openapi.json. It is the curated tool manifest used by the chut.app platform; the dashboard routes are not in it.