Building an OCR backend
This guide is for the team writing the OCR service. The HTTP contract itself is on the API contract page — this is about how to satisfy it.
You are building a job service: accept a file, return an id immediately, process in the background, and serve the result on poll. Sancho knows nothing about your stack — only the two endpoints and the JSON schema matter.
Processing flow
Section titled “Processing flow”-
A user uploads a PDF or photo in Sancho. The file goes to S3 and a row is created with status
pending. -
Sancho posts the file to your submit endpoint. You respond immediately with a job id — you do not wait for OCR to finish.
-
Your worker processes the document in the background.
-
Sancho polls the status endpoint every
OCR_POLL_INTERVAL_MS(3 s by default). As long as you returnpending/processing/queued, polling continues. -
When you return
readywithdata, Sancho validates the result against the schema, stores it, and runs the downstream steps (attaching the document, prefilling the form). -
error, exhausting the poll budget, or failing schema validation all mark the document as errored. The user can upload again — Sancho does not retry the job on its own.
Timing budget
Section titled “Timing budget”| Stage | Limit |
|---|---|
POST response (submit) |
60 s, but aim for a few seconds |
GET response (status) |
15 s |
| Whole recognition | OCR_POLL_INTERVAL_MS × OCR_POLL_MAX_ATTEMPTS, 3 minutes by default |
The default 3000 ms × 60 is exactly those 3 minutes. You can redistribute the budget (e.g. 5000 ms × 36), but you cannot extend it. Documents needing longer processing must be pre-processed before they reach Sancho.
A single failed poll does not abort processing — Sancho retries it within the remaining budget, so a brief network outage will not ruin the whole job.
Reference implementation
Section titled “Reference implementation”A minimal FastAPI service. It keeps jobs in memory and has no authentication — it shows the shape of the contract, not production code.
import uuidfrom fastapi import BackgroundTasks, FastAPI, Form, HTTPException, UploadFile
app = FastAPI()jobs: dict[str, dict] = {}
def recognize(job_id: str, content: bytes, document_type: str) -> None: try: # Docling / your pipeline goes here. The result must match the schema. jobs[job_id] = {"status": "ready", "data": parse(content, document_type)} except Exception as error: jobs[job_id] = {"status": "error", "message": str(error)}
@app.post("/ocr/documents")async def submit(background: BackgroundTasks, file: UploadFile, documentType: str = Form(...)) -> dict: if documentType not in {"purchase_order", "goods_received_source"}: raise HTTPException(status_code=400, detail="unsupported documentType")
job_id = str(uuid.uuid4()) jobs[job_id] = {"status": "pending"} background.add_task(recognize, job_id, await file.read(), documentType)
return {"id": job_id, "status": "pending"}
@app.get("/ocr/documents/{job_id}")async def status(job_id: str) -> dict: job = jobs.get(job_id) if job is None: raise HTTPException(status_code=404, detail="unknown job")
return {"id": job_id, **job}For production, add durable job storage (a restart must not lose results), a real queue instead of BackgroundTasks, and Authorization header verification if you set OCR_CUSTOM_API_KEY.
Filling in the schema
Section titled “Filling in the schema”Full definitions: purchase-order.schema.json and goods-received-source.schema.json. Below are the parts that trip people up.
Three different numbers on a source document
Section titled “Three different numbers on a source document”| Field | What it is | Example |
|---|---|---|
documentNumber |
The number of the document itself (WZ note, invoice, delivery note) | WZ/0612/24/10/MG |
order |
The issuer’s internal order number (ZS, ZO, ZAD, “our document no.”) | ZS/1420/2026 |
externalOrder |
The buyer’s order number (customer PO) | ZAM_W/2025/508 |
Mixing these up means Sancho cannot match the delivery to the right order. When unsure which is which, leave the field empty — no value beats a value in the wrong field.
Numbers
Section titled “Numbers”Values must be JSON numbers, not strings. Polish formatting has to be normalized:
| On the document | In JSON |
|---|---|
1 234,56 |
1234.56 |
12,000 (quantity) |
12 |
1.234,56 |
1234.56 |
Watch the thousands separator: a plain space, a non-breaking space and a dot are all used interchangeably.
Format YYYY-MM-DD. 12.06.2026 becomes 2026-06-12. If you cannot read a date unambiguously, return null.
Tax ids
Section titled “Tax ids”Digits only is best (5261040828). Sancho strips spaces and dashes anyway, but it does not split off a country prefix — PL5261040828 is stored as-is.
Line items
Section titled “Line items”items holds one entry per table row. Simply omit fields you did not read — an omitted value is treated as null, an omitted array as []. Do not send empty strings or placeholder zeros: 0 in quantity means “zero units”, not “unknown”.
In the goods-received-source schema, transport / shipping service lines are not goods lines — leave them out of items and return their net value in the document-level deliveryCostNet field.
Pre-rollout checks
Section titled “Pre-rollout checks”-
Submit a document and note the id:
Terminal window curl -sS -X POST https://ocr.company.local/ocr/documents \-F "file=@order.pdf" \-F "documentType=purchase_order"Expected: a 2xx and
{"id": "...", "status": "pending"}within a few seconds. -
Poll for the result:
Terminal window curl -sS https://ocr.company.local/ocr/documents/<id>Expected:
{"status": "processing"}first, then{"status": "ready", "data": {...}}. -
Validate the result against the schema:
Terminal window curl -sS https://ocr.company.local/ocr/documents/<id> | jq .data > result.jsoncurl -sS https://sancho-wms.pl/schemas/purchase-order.schema.json > schema.jsoncheck-jsonschema --schemafile schema.json result.json -
Check an unknown job —
GET /ocr/documents/does-not-existmust return404, not200with statuspending. -
Check an unreadable document — your service must return
{"status": "error", "message": "..."}rather than sitting inprocessingforever.
Common mistakes
Section titled “Common mistakes”dataas a JSON string instead of an object. Sancho does not parse the string a second time.- Numbers as strings (
"1 234,56"). Schema validation rejects the result and the document is marked errored. status: "ready"with nodata. Sancho treats it as an empty result and fails validation.- Blocking the
POSTresponse until OCR finishes. The limit is 60 seconds, and it wastes the poll budget regardless. 404for a job that is still processing. Return a status, not an error — otherwise you burn the budget on retries.- Losing results across a restart. An in-flight job whose state disappeared ends in an error on Sancho’s side.
- Placeholder values —
0,"none","-"in fields you did not read. Omit those fields instead.