Skip to content

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.

  1. A user uploads a PDF or photo in Sancho. The file goes to S3 and a row is created with status pending.

  2. Sancho posts the file to your submit endpoint. You respond immediately with a job id — you do not wait for OCR to finish.

  3. Your worker processes the document in the background.

  4. Sancho polls the status endpoint every OCR_POLL_INTERVAL_MS (3 s by default). As long as you return pending/processing/queued, polling continues.

  5. When you return ready with data, Sancho validates the result against the schema, stores it, and runs the downstream steps (attaching the document, prefilling the form).

  6. 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.

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.

A minimal FastAPI service. It keeps jobs in memory and has no authentication — it shows the shape of the contract, not production code.

import uuid
from 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.

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.

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.

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.

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.

  1. 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.

  2. Poll for the result:

    Terminal window
    curl -sS https://ocr.company.local/ocr/documents/<id>

    Expected: {"status": "processing"} first, then {"status": "ready", "data": {...}}.

  3. Validate the result against the schema:

    Terminal window
    curl -sS https://ocr.company.local/ocr/documents/<id> | jq .data > result.json
    curl -sS https://sancho-wms.pl/schemas/purchase-order.schema.json > schema.json
    check-jsonschema --schemafile schema.json result.json
  4. Check an unknown job — GET /ocr/documents/does-not-exist must return 404, not 200 with status pending.

  5. Check an unreadable document — your service must return {"status": "error", "message": "..."} rather than sitting in processing forever.

  • data as 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 no data. Sancho treats it as an empty result and fails validation.
  • Blocking the POST response until OCR finishes. The limit is 60 seconds, and it wastes the poll budget regardless.
  • 404 for 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 values0, "none", "-" in fields you did not read. Omit those fields instead.