On this page

After this page you can read a page with three curl calls, pin the pipeline so the result stays fixed, and run a client that survives a bad day. Every page comes back with the text, a box and a confidence for every word.

This is the API of the hosted service at https://api.ocr-software.com, and of the same service when you download it and run it on your own server (install). Examples use the hosted address. On your own server use http://127.0.0.1:8080 instead, the address it listens on by default. Every call, field and error is in the reference, generated from the OpenAPI file.

First call in five minutes

With a key from Get an API key in OCR_KEY (on your own server: the service running, and http://127.0.0.1:8080 in place of the hosted address):

curl -s https://api.ocr-software.com/healthz          # no key needed: is the service up?
curl -s -X POST "https://api.ocr-software.com/v1/jobs?pipeline=scan-ctc@2&formats=json"   -H "Authorization: Bearer $OCR_KEY" --data-binary @page.pdf   # answer: {"job_id": "...", "state": "running", ...}
curl -s "https://api.ocr-software.com/v1/jobs/$JOB_ID/result?format=json" -H "Authorization: Bearer $OCR_KEY" -o result.json

Nothing answers on port 8080, or /healthz is not ok? That is a matter for the server: see the install troubleshooting.

Wait with GET /v1/jobs/$JOB_ID?wait=30 until state is done before the third call (Poll the job status). In result.json every word has text, confidence (0 to 1) and box = [x0, y0, x1, y1] in page pixels. A file of up to 1,000 pages is one call.

You want Where
The same result every time Pin pipeline=name@version; the pinned version is the one that reads (Choose a pinned pipeline)
To know which words to double-check Filter on confidence (Use the confidence score)
To know what went wrong Branch on error.code (OCR API error codes); limits in one table
To know a page was not read Header X-Pages-Failed on the result; such a page costs nothing
To run it on your own server Install

The service also serves the OpenAPI file at /openapi.json, without a key.

Get an API key for the OCR API

A key for the hosted service comes on request: ask for it on the contact page. It is free and only counts your pages. The key is the first step; until you have one, /healthz and the free test answer without it. On your own server the tool that comes with the installation makes the key, with one command:

sudo /opt/ocr/bin/ocrctl key new -id mykey -paid -keys /etc/ocr/keys.txt
  • The key is printed once on standard output. Save it then. Only its hash goes into keys.txt.
  • The running service accepts the key within a few seconds. Check it: /v1/usage answers 401 without the key and 200 with it.
  • -paid means: no page limit for this key. Nothing is billed in the download edition. (A key for the hosted service is made by us, not with this tool.) Without it the key stops after a quota of pages in total (free_pages in the configuration, 100000 pages by default). This quota counts all pages the key reads over time. It is not the limit for one document, which is 1,000 pages.

The steps around it, from download to first call, are on the install page.

OCR API integration: choose a pipeline, send a job, fetch the result

Every call below needs Authorization: Bearer ocr_.... Put the key in the environment variable OCR_KEY.

Choose a pinned OCR pipeline version

curl -s https://api.ocr-software.com/v1/pipelines -H "Authorization: Bearer $OCR_KEY"

Each pipeline is named and versioned, in the form name@version. You choose it, and it stays pinned until you choose another. A version stays as released: a changed pipeline is a new @version. Which pipeline fits which page, what it returns and what was measured: Pipelines. Per pipeline you get description, capabilities (for example word_boxes, hocr) and available. If available is false, a job on it fails at once. The modes are described on the start page.

Count pages before reading: the OCR dry run

# dry run: nothing is read, the page count comes back
curl -s -X POST "https://api.ocr-software.com/v1/jobs?pipeline=scan-ctc@2&dry_run=1"   -H "Authorization: Bearer $OCR_KEY" -H "Content-Type: application/pdf" --data-binary @file.pdf

A dry run is a POST /v1/jobs with dry_run=1 and the document as the body. It is a normal upload, so a large file is uploaded once for the count and once for the job. The answer is 200; it can also carry warnings (code and message). Example with made-up numbers:

{"dry_run": true, "pipeline": "scan-ctc@2", "pages": 12, "pages_covered": 12, "pages_not_run": 0}

The answer also has price fields. They matter only if the operator of an installation has set prices (an operator setting, off by default); the OpenAPI file lists them.

Send the OCR job

curl -s -i -X POST "https://api.ocr-software.com/v1/jobs?pipeline=scan-ctc@2&formats=json,text&job_budget=5000" \
  -H "Authorization: Bearer $OCR_KEY" -H "Content-Type: application/pdf" --data-binary @file.pdf

The body is the file itself (PDF, PNG, JPEG or TIFF; each TIFF directory is one page), not a multipart form. The service detects the type from the content. The answer is 202 with the header Location: /v1/jobs/{id} and a body like this (made-up numbers; client_ref comes back if you sent one, see Sending twice):

{"job_id": "...", "state": "running", "pipeline": "scan-ctc@2", "pages": 12}

job_budget (optional) only matters when the operator has set prices: the job stops before a page that would exceed it, and the open pages end as not_run. formats and features are a check at submit only: if the pipeline cannot deliver one, the call is refused with 422 capability_missing. They do not choose what is stored.

dpi (optional) is the resolution at which the pages of a PDF are rendered: 150, 200 or 300; without it the service uses 150. Images and TIFF are read at their own resolution and ignore it (the dry run says so with the warning dpi_ignored). The page size limit applies at the dpi you choose: a page that is too big at that resolution is refused with 422 page_too_large, it is never rendered smaller on its own. Any other value gives 400 invalid_dpi. The result carries the dpi on every page, and the dry run on a PDF returns the dpi it would use.

Poll the OCR job status

curl -s "https://api.ocr-software.com/v1/jobs/$JOB_ID?wait=30" -H "Authorization: Bearer $OCR_KEY"

?wait=N (whole seconds, 0 to 60) holds the request until the job changes or N seconds pass, then answers 200 with the normal body. The server may cut N short (55 seconds by default) and answers at once when many clients wait. So read the body and call again until state is done; pause a few seconds between calls. Without wait, poll every 2 to 5 seconds.

{"job_id": "...", "pipeline": "scan-ctc@2", "state": "running", "pages": 12, "pages_done": 5,
 "pages_failed": 0, "pages_not_run": 0, "pages_pending": 7}

done does not mean every page was read. After done look at pages_failed, pages_not_run and stop. stop is set only if the job ended early: job_budget, balance, cancelled, worker_unavailable, worker_unsupported or pages_failed. A page in pages_failed could not be read (the image was bad, the pipeline is not available on the worker, the worker was busy, the model gave no output, or the engine failed on it on every try); the job goes on with the other pages. While a job waits in the queue, the status carries queue with its position; do not send it again.

Fetch the OCR result as JSON, text, Markdown, hOCR or searchable PDF

curl -s "https://api.ocr-software.com/v1/jobs/$JOB_ID/result?format=json" -H "Authorization: Bearer $OCR_KEY" -o result.json

format is json (default), text, md or hocr where the pipeline has the capability. While the job runs the answer is 409 not_ready; keep waiting on the job. After the retention time (24 hours by default) it is 404 not_found. Whatever the pipeline can deliver can be fetched, whatever you put in formats at submit.

  • json: schema_version, pipeline, not_delivered and pages. A page has page (from 1), status (done, failed, not_run), safe_ok, optional width and height in pixels, and lines, which may be missing for a failed page: read it as page.get("lines", []). A line holds words in reading order. A word has text, confidence (0 to 1), source (textlayer, ctc or vlm), verified and, with the capability word_boxes, box = [x0, y0, x1, y1] in page pixels. Where the pipeline delivers them, a page also has dpi, rotation (0, 90, 180 or 270) with rotation_checked, layout (regions with class and box), tables (with rows, cols, cells and words_unplaced), warnings (a list of code and message; the codes are table_not_found, table_words_unplaced and model_output_incomplete) and, on a failed page, error with code and message. Only page, status, safe_ok and lines are always in the schema. A word of a merged reading (the language-model pipeline) also has flag: ok, differs, punct_differs, low_conf, sup, no_ctc, markup or not_in_model.
  • safe_ok is not calibrated and is no promise in either direction. A page that is done with safe_ok: false is normal. Judge by confidence; a low value or verified: false is a hint to look at the word, not an error.
  • not_delivered lists what the pipeline does not deliver (table_cells, layout_classes, header_footer, word_boxes). A missing table then means “not delivered”, not “no table”.
  • text and md carry no page status. A failed or not-run page is an empty page or section there. Read the status from the job or the json result.
  • X-Pages-Failed and X-Pages-Not-Run are headers on every answer of GET /v1/jobs/{id}/result, in all formats. They hold the number of pages with status failed and not_run, and are present only when that number is above 0. They tell you that the empty page in a text result is a page that was not read, not a blank one. The searchable PDF names such pages in X-Pages-Unread instead.
  • Searchable PDF: POST /v1/jobs/{id}/result?format=pdf with the original document as the body (it was deleted when the job ended). It must match the job, or the answer is 422 source_mismatch.
  • DELETE /v1/jobs/{id} removes the result now. The state and counts stay readable and the bookings stay. On a running job it cancels instead (202, state is cancelling in that answer only; GET shows running and then done): open pages end not_run, finished pages stay billed.

Use the confidence score per word

Every word carries confidence from 0 to 1. Choose a threshold that suits your documents: take the words below it and send those pages or words to a person.

low = [w for p in result["pages"] for l in p.get("lines", []) for w in l["words"] if w["confidence"] < 0.6]

safe_ok is not a calibrated promise; judge by confidence and verified.

OCR API Python example: upload, wait, fetch

Python 3, standard library only, kept short on purpose; Production adds the error handling.

import json, os, time, urllib.request

API = "https://api.ocr-software.com"
HEAD = {"Authorization": "Bearer " + os.environ["OCR_KEY"]}

def call(method, path, body=None, ctype=None):
    h = dict(HEAD, **({"Content-Type": ctype} if ctype else {}))
    with urllib.request.urlopen(urllib.request.Request(API + path, body, h, method=method)) as r:
        return json.load(r)

doc = open("file.pdf", "rb").read()
query = "pipeline=scan-ctc@2"
count = call("POST", f"/v1/jobs?{query}&dry_run=1", doc, "application/pdf")
print("pages:", count["pages"])
job = call("POST", f"/v1/jobs?{query}", doc, "application/pdf")
while True:
    st = call("GET", f"/v1/jobs/{job['job_id']}?wait=30")
    if st["state"] == "done":
        break
    time.sleep(2)   # the server may answer at once; do not poll faster
if st["pages_failed"] or st["pages_not_run"] or st.get("stop"):
    print("partial result:", st)
result = call("GET", f"/v1/jobs/{job['job_id']}/result?format=json")
for page in result["pages"]:
    if page["status"] == "done":
        print(page["page"], " ".join(w["text"] for l in page.get("lines", []) for w in l["words"]))

Large documents and many files

One job reads one file. The limits are in the table: up to 1,000 pages per document by default. For a larger document, split it with any PDF tool and send the parts as separate jobs. The pages of a result count from 1 within that part, so add the offset of the part yourself.

For thousands of documents use one job over an S3 bucket: POST /v1/bucket-jobs reads every PDF under a prefix and writes the results and a manifest.json to a destination prefix. Status with GET /v1/bucket-jobs/{id} (counts only, no file names, also with ?wait=N). Credentials are held in memory only. The same job_id sent again resumes the job. See the reference.

OCR API in production: retries, limits, idempotency, keys

Retry the OCR API correctly

Retry only 429, 503, 500 and network errors, at most 5 times, after 1, 2, 4, 8 and 16 seconds, or the Retry-After value if that is longer (the service sends whole seconds; a 503 comes with it). If Retry-After is more than 60 seconds, stop and tell a person. That is locked_out (900 seconds after repeated wrong keys, the lock is by client address and only counts and refuses wrong keys; a valid key gets through). After any other 4xx change the request before you send it again; the same request fails again.

import json, os, time, urllib.error, urllib.request

API = "https://api.ocr-software.com"
KEY = os.environ["OCR_KEY"]          # never in code, never in a log

def call(method, path, body=None, ctype=None, raw=False, extra=None):
    headers = {"Authorization": "Bearer " + KEY, **(extra or {})}
    if ctype:
        headers["Content-Type"] = ctype
    for wait in (1, 2, 4, 8, 16, None):
        req = urllib.request.Request(API + path, body, headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=120) as r:
                data = r.read()
                return data if raw else json.loads(data)
        except urllib.error.HTTPError as e:
            err = json.loads(e.read() or b"{}").get("error", {})
            retry_after = int(e.headers.get("Retry-After") or 0)
            if (e.code not in (429, 500, 503) or err.get("code") in ("locked_out",)
                    or retry_after > 60 or wait is None):
                raise RuntimeError(f"{e.code} {err.get('code')}: {err.get('message')}") from None
            time.sleep(max(wait, retry_after))
        except (urllib.error.URLError, TimeoutError):
            if wait is None:
                raise
            time.sleep(wait)

Idempotency-Key: send an OCR job twice and get one job

Send an Idempotency-Key with every POST /v1/jobs. A call whose answer you never received may have created a job; with the key, you send the same call again and cannot create a second job.

  • The key: a header Idempotency-Key with 1 to 255 printable characters of your choice (a UUID is fine). Within 24 hours, the same key from the same API key with the same document and the same parameters answers 200 with the existing job (header Idempotent-Replayed: true) and starts nothing again. This check comes before the limits. After 24 hours the key is free again, and a send makes a new job. dry_run ignores the key.
  • A conflict: the same key with another document or other parameters (pipeline, formats, features, job_budget, client_ref, dpi) answers 409 idempotency_conflict. A new job needs a new key; repeating the request does not help.
  • Your reference: client_ref (a query parameter, printable characters, at most 200) is stored with the job and comes back in every job answer. Use your own invoice or document number.
  • Write the key and the job_id into your own store, the key before the first send, the job_id of every answer before you poll.
  • If you sent no key and lost the answer, look first: GET /v1/jobs?client_ref=invoice-2026-0815 lists only the jobs with that reference. More filters: status (running or done). The list is newest first, limit is 1 to 1000 (default 100), and while more jobs follow the answer has next_cursor; pass it unchanged as cursor. A bad value or an unknown parameter gives 400 invalid_request.
  • Polling and fetching are safe to repeat. DELETE may be repeated too.
  • A bucket job is the exception: sending POST /v1/bucket-jobs again with the same job_id resumes the job; while it runs the answer is 409 conflict.
# first send: your own key and reference
curl -s -i -X POST "https://api.ocr-software.com/v1/jobs?pipeline=scan-ctc@2&job_budget=5000&client_ref=invoice-2026-0815" \
  -H "Authorization: Bearer $OCR_KEY" -H "Idempotency-Key: 6b0d5c9e-3f1a-4c7e-9a52-0d8e1f2a7b34" \
  -H "Content-Type: application/pdf" --data-binary @file.pdf
# 202 Accepted, body with "job_id"

# the same command again (network dropped, no answer seen): 200, header "Idempotent-Replayed: true",
# the same job_id, no second job
# uses call() from the retry example above (its argument extra adds headers)
import urllib.parse, uuid

ref = "invoice-2026-0815"
idem = str(uuid.uuid4())   # store it with the document BEFORE the first send; reuse it on every repeat
path = "/v1/jobs?pipeline=scan-ctc@2&job_budget=5000&client_ref=" + urllib.parse.quote(ref)
try:
    # call() repeats network errors with the same headers, so the same key goes out again: safe
    job = call("POST", path, doc, "application/pdf", extra={"Idempotency-Key": idem})
except RuntimeError as err:
    if str(err).startswith("409 idempotency_conflict"):
        # this key already stands for another document or other parameters: a bug in your key handling
        raise SystemExit("key reused for a different job: " + str(err))
    raise
print(job["job_id"])   # 202 the first time, 200 on a repeat; the body carries job_id either way

Pages in a new job. A new job, without a key or with a new key, reads all its pages again, including those the old job had finished. There is no page range and no way to continue another job. To leave pages out, send only the pages that are still open.

Limits and sizes

These are the default values of the service itself. Your installation may set other values (the operator changes them in /etc/ocr/config.yaml, see the install guide), so do not write a number into your code: when a limit is hit, the error names it (limit, limit_value, current) or the message does.

Limit Service default When exceeded
Size of one document (the request body) 100 MiB 413 too_large
Pages of one document 1,000 (also the most a result holds) 422 too_many_pages
Size of one page (image file, TIFF page, PDF size divided by pages) 10 MiB 422 page_too_large
Pixels of one page 40,000,000 422 page_too_large
Requests per key 20 per second, burst 40 429 rate_limited with Retry-After
Jobs running at once per key 8 429 rate_limited with Retry-After
Uploads in progress per key and in the whole service 2 per key, 8 in the whole service 429 too_many_uploads, 503 busy with Retry-After
Time for the upload to arrive at least 128 KiB per second after a grace time of 15 s 408 upload_too_slow
?wait=N 0 to 60 s, the server cuts it at 55 s 400 invalid_request if out of range
How long a result is kept 24 hours 404 not_found

A job for a large document runs long. Use ?wait and do not time out your own client early.

Usage and cost

  • The hosted service does not bill per page today: ignore the price fields and job_budget. Productive use needs a contract (pricing).
  • For private use and tests the downloaded software costs nothing per page; productive use needs a contract, and the EULA next to the download applies. On your own server price fields and job_budget are operator fields, not used in the download edition.
  • GET /v1/usage gives the pages used with your key.

Keep the API key safe

  • Read the key from the environment or from your secret store. Do not put it in source code, in a URL, in a log or in an error report. Keys start with ocr_.
  • The key is shown once, and the service keeps no copy in clear. If it is lost, make a new one the same way, under a new id. A key stays valid until you remove it.
  • Ten wrong keys within ten minutes lock the client address for 15 minutes (service defaults): further wrong keys from it get 429 locked_out. A valid key still gets through, also from a locked address, so one system with an old key no longer blocks the others on a shared outgoing address. Fix a 401 at once; do not loop on it.

Data retention: what the OCR service keeps

  • Your document is deleted when the job ends; for a searchable PDF you send it again.
  • The result (the text that was read) is kept for 24 hours by default, so you can fetch it. DELETE /v1/jobs/{id} removes it at once. The state and counts of the job stay, without content.
  • The hosted service runs on a server we operate. On your own server the pages are processed where you run it. The licence is the EULA next to the download.

Before you go live

  1. Branch on error.code, never on the message. All codes are in OCR API error codes.
  2. After done, check pages_failed, pages_not_run and stop, and decide what a partial result means for you. To read a failed page again, send the document in a new job.
  3. Send an Idempotency-Key and a client_ref with every job.
  4. Store every job_id before polling, and fetch the result soon after done.
  5. Do not hard-code limits. Read them from the errors.

Lean by design: you create a job, poll it and fetch the result; the interface and the schedule around it are yours. In a ticket give the time and the job_id. To change a key, make the new one with ocrctl key new, switch your clients, then delete the old line in keys.txt. The service reloads the file within seconds, so both keys work in between.

OCR API error codes and how to fix them

Every error is JSON {"error":{"code","message"}}; some carry more fields (details, missing, pipelines, limit, limit_value, current). Branch on code. The API reference lists the cause and the remedy for every code.

Resources