# OCR API: instructions for AI agents

> This is the API of the hosted service at https://api.ocr-software.com, and of the same service a human downloads and runs on their own hardware. The exact source for every path, field and error code is `https://ocr-software.com/openapi.json`; every service also serves it at `/openapi.json` without a key. Price, budget and balance fields (`price_*`, `job_budget`, `balance_*`, `charged_*`) are operator fields, not used in the download edition; they mean nothing unless the operator of the installation set prices. Do not ask the human for a budget in that case.

Base URL: the hosted service is https://api.ocr-software.com, and the examples below use it. A human who runs the service on their own server gives you that address instead; a fresh installation listens on `http://127.0.0.1:8080`.
This file is the plain-text twin of https://ocr-software.com/docs/agents.md . If this file and `openapi.json` disagree, `openapi.json` wins.

## Rules for the agent (read first)

1. Never invent, guess or look up an API key. Keys start with `ocr_`. Ask the human for it. Read it from the environment variable `OCR_KEY`. Never print it, log it or write it into a file.
2. Ask the human which pipeline to use and which formats to deliver. Do not choose a pipeline yourself, and do not switch pipelines on your own after an error. The system never chooses for the user either. List the options with `GET /v1/pipelines` and show them to the human (name@version, description, capabilities).
3. Before every real job you may run a dry run (`dry_run=1`): it returns the page count, so the human knows what is about to run. Only if the operator set prices does the answer carry a price; then show it to the human and set `job_budget` (millicents).
4. If the operator set prices, money is in millicents (1/1000 cent; 1 EUR = 100,000 millicents). On the hosted service nothing is billed today, so ignore price fields and `job_budget`. Without prices there is nothing to pay or to budget; the EULA next to the download applies to your use. Support and operation for third parties need a contract: https://ocr-software.com/contact/.
5. The service deletes the customer document when the job ends. The result stays available for a limited time (default 24 h, then `404 not_found`). Download results right away.
6. Do not send documents the human has not agreed to send.
7. Do not make a key yourself. The human gets one for the hosted service on request at https://ocr-software.com/contact/#api-key (free, it only counts pages), or creates it on their own server with `ocrctl key new` (see "Create a key on your server"). Ask the human.

## Endpoints

| method and path | use |
|---|---|
| `GET /v1/pipelines`, `GET /v1/pipelines/{name@version}` | list pipelines: the same fields as `/v1/catalog`, plus `unavailable_reason` |
| `GET /v1/usage` | pages used; balance and pages left only if the operator set prices |
| `POST /v1/jobs` | submit one file (or dry run) |
| `GET /v1/jobs` | your jobs, newest first; filters `client_ref`, `status`, paging with `limit` and `cursor` |
| `GET /v1/jobs/{id}` | job state and counts |
| `DELETE /v1/jobs/{id}` | delete the stored result now; cancel a running job |
| `GET /v1/jobs/{id}/result` | result in `json`, `text` or `md` |
| `GET /v1/jobs/{id}/report` | job report: which pipeline file and values read the pages, and the seconds per stage |
| `POST /v1/jobs/{id}/result?format=pdf` | searchable PDF: you send the original again |
| `POST /v1/bucket-jobs`, `GET /v1/bucket-jobs/{id}` | job over an S3 bucket |
| `GET /healthz`, `GET /openapi.json` | liveness, this spec (no key) |
| `GET /v1/status` | how busy the service is and how long a new page waits, per class (no key, limited per address) |
| `GET /v1/catalog` | what the service offers: pipelines, formats, features (no key, cached, limited per address) |

## Auth

`Authorization: Bearer ocr_...` on every `/v1/...` request. `/healthz` and `/openapi.json` need no key.
How to get a key: the human asks for it for the hosted service at https://ocr-software.com/contact/#api-key (free, it only counts pages; until then `/healthz`, `/openapi.json`, `/v1/status` and `/v1/catalog` answer without a key), or creates it on their own server with `ocrctl key new` (see "Create a key on your server" below, or https://ocr-software.com/docs/api-guide/#key). No card, no password. A key is shown once, when it is created.

## Errors

Every error is JSON: `{"error":{"code":"...","message":"...","details":[...],"missing":[...],"pipelines":[...]}}`. `code` and `message` are always present; the other three only for the cases noted below. Branch on `code`, not on `message`.
`details` (a list of every problem) comes with `400 invalid_request` from the query parameters of `POST /v1/jobs`, with `400 invalid_dpi` (one entry), and with `400 invalid_request` for a bucket job whose JSON body parses but has bad fields (bucket `job_id`, `pipeline`, `formats`, `destination`). Every other `invalid_request` (for example a bad `format` on the result, or a bucket body that is not one JSON object) has only `message`. `missing` and `pipelines` come only with `422 capability_missing`. Refusals at a numeric limit (`429 too_many_uploads`, `503 busy`, `429 rate_limited`) may also carry `limit` (the name of the limit), `limit_value` and `current`.

| HTTP | code | meaning / what to do |
|---|---|---|
| 400 | `invalid_request` | bad or unknown query parameter, duplicate single value, unknown pipeline or name, or a `job_budget` that is not a whole number (text); on `POST /v1/jobs` `details` lists every problem. Fix the request, do not retry as is. |
| 400 | `invalid_dpi` | `dpi` is not 150, 200 or 300 (and nothing else is wrong with the request; otherwise `invalid_request`). Send one of the three values or leave `dpi` out. |
| 400 | `empty_document` | body is empty. |
| 401 | `unauthorized` | no `Authorization` header (`Missing API key`) or a wrong key. Ask the human. Do not retry and do not try other keys: 10 wrong keys within 10 minutes lock the client address for 15 minutes (service defaults), see the lockout note below. |
| 402 | `payment_required` | the free pages of the key are used up (a key made without `-paid` stops there), or the balance does not cover one page. Stop and tell the human; the operator makes a key with `-paid`. Do not retry. |
| 403 | `forbidden` | the key or the client address is blocked. Stop and tell the human. |
| 404 | `not_found` | unknown job or pipeline, or the result expired (`result_ttl`). |
| 405 | `method_not_allowed` | wrong HTTP method. |
| 409 | `not_ready` | `GET .../result` while the job still runs. Keep polling the job. |
| 409 | `conflict` | a bucket job with this `job_id` is already running. |
| 409 | `idempotency_conflict` | `POST /v1/jobs`: this `Idempotency-Key` was already used within 24 hours for another document or other parameters. Use a new key for a new job. |
| 413 | `too_large` | file over the size limit. |
| 415 | `unsupported_type` | not PDF, PNG, JPEG or TIFF. |
| 422 | `invalid_document` | document is broken. |
| 422 | `password_required` | encrypted PDF. Ask the human for an unlocked file. |
| 422 | `too_many_pages` | over the page limit. |
| 422 | `page_too_large` | a page is too large to render. |
| 422 | `render_failed` | page could not be rendered. |
| 422 | `timeout` | processing took too long. |
| 422 | `resource_limit` | a resource limit was hit. |
| 422 | `capability_missing` | the pipeline cannot deliver what you asked (formats or features). `missing` says what, `pipelines` lists pipelines that deliver everything asked. Show the list to the human; do not switch by yourself. |
| 422 | `budget_too_low` | `job_budget` is below the price of one page (only if the operator set prices; `0` and negative values too). The message names the minimum. Ask the human for a higher budget. |
| 422 | `source_required` | `GET .../result?format=pdf`: a PDF cannot be built from the stored result. Use `POST` with the original document. |
| 422 | `source_mismatch` | `POST .../result` for a PDF: the page count or the page sizes differ from the job. It is no checksum: another document with the same page count and sizes is not detected. |
| 422 | `pdf_unsupported` | the document cannot get a text layer (searchable PDF). |
| 422 | `access_denied` | bucket job: the bucket refused the credentials or the anonymous read. Nothing starts. |
| 422 | `no_such_bucket` | bucket job: the bucket does not exist. |
| 422 | `wrong_region` | bucket job: the bucket lives in another region than the one given. |
| 422 | `source_unreachable` | bucket job: network, timeout or service error on the source side. |
| 408 | `upload_too_slow` | the upload arrived too slowly. Retry with a faster connection or a smaller file. |
| 429 | `rate_limited` | too many requests or too many parallel jobs. `Retry-After` is short (seconds); wait, then retry. |
| 429 | `locked_out` | lockout after repeated wrong keys: `Retry-After` is 900 (15 minutes). Do not wait and retry; stop and tell the human. |
| 429 | `too_many_uploads` | too many uploads of this key in progress at once. Retry later with backoff. |
| 500 | `internal_error` | server error. |
| 501 | `not_implemented` | the service has no worker configured for jobs. |
| 503 | `busy` | too many uploads or PDF builds in progress on the whole service. Retry later with backoff. |
| 503 | `unavailable` | no worker is available for the chosen pipeline right now. Retry after `Retry-After`. |
| 503 | `pipeline_unavailable` | no worker serves the chosen pipeline right now (also for `dry_run`). Nothing was booked. Wait `Retry-After` (30 s) or choose another pipeline from `GET /v1/catalog`. |

`408 upload_too_slow`, `429 too_many_uploads` and `503 busy` come from the document intake and are in `openapi.json`. Treat `429` and `503` as retryable with backoff, except `locked_out` (see the table).

Key lockout: after 10 wrong keys within 10 minutes the service answers every request from that client address with `429 locked_out` and `Retry-After: 900`, for 15 minutes (service defaults; your installation may set other values). The lock is by client address and only counts and refuses wrong keys: a request with no `Authorization` header at all is answered `401` (`Missing API key`) but never counts, so a client that forgot the header cannot lock itself out; a wrong token counts. a correct key always gets through, also from an address that is locked (several customers behind one proxy do not lock each other out). A guessing or looping agent therefore locks itself out. If you get 401, stop and ask the human for the key; never try variants.

Retry rules: retry only `429`, `503`, `500` and network errors, at most 5 times, waiting 1, 2, 4, 8, 16 s (or `Retry-After` if longer). Exception: if `Retry-After` is more than 60 seconds, do not wait and retry; stop and tell the human (that is `locked_out`, and 5 waits of 900 s would block you for over an hour). Never retry another `4xx` without changing something. A retried `POST /v1/jobs` can create a second job; keep the `job_id` of every accepted job in your own notes.

## 1. List pipelines (ask the human to pick)

```
curl -s https://api.ocr-software.com/v1/pipelines -H "Authorization: Bearer $OCR_KEY"
```
Returns `{"pipelines":[...]}`; each pipeline has `pipeline`, `description`, `factor` and `price_per_page_millicents` (operator fields, not used in the download edition), `autoregressive`, `capabilities`, `formats` and `features` (what the capabilities deliver), `orderable` (`false` for a preview pipeline, with the fixed `preview_note`), `available` and (only when `available` is `false`) `unavailable_note` (one fixed sentence) and `unavailable_reason` (which capability is missing). `GET /v1/catalog` has the same pipeline objects, without `unavailable_reason`.
`available: false` means the worker behind the service lacks a capability this pipeline needs; a job on it fails at once with every page `failed` and costs nothing. Show the human only pipelines with `available: true`.
`pipeline` is `name@version`, for example one from `GET /v1/pipelines`. A version is pinned: a change ships as a new version. `autoregressive: true` means a step can generate text (and can invent it); `false` means it cannot. `capabilities` is a map of booleans, for example `json`, `text`, `markdown`, `hocr`, `pdf`, `word_boxes`, `table_cells`, `layout_classes`, `header_footer`.
One pipeline: `GET /v1/pipelines/{name@version}` (404 `not_found` if unknown).
You choose; the service never switches, and which pipelines are `available` depends on the worker. Always read the live list; do not rely on a fixed list.

## 2. Count first (dry run)

```
curl -s -X POST "https://api.ocr-software.com/v1/jobs?pipeline=name@version&dry_run=1" \
  -H "Authorization: Bearer $OCR_KEY" --data-binary @report.pdf
```
Nothing runs, nothing is billed. 200 with:
`dry_run` (true), `pipeline`, `pages`, `factor`, `price_per_page_millicents`, `price_millicents` (operator field), `pages_covered` (pages that run before a budget or limit stops the job, if any is set), `pages_not_run`, `max_charge_millicents` (operator field), `dpi` (PDF only: the resolution the pages would be rendered at), `balance_millicents` (operator field), `warnings` (absent when there is nothing to say; a list of `code` and `message`, with the codes `job_budget`, `dpi_ignored`, `balance_too_low`, `balance`: what a real job would meet, for example fewer pages covered than the document has).
Show the page count to the human and get a yes. If prices are set, show `price_millicents` too (EUR = millicents / 100000).

## 3. Submit a job

```
curl -s -i -X POST "https://api.ocr-software.com/v1/jobs?pipeline=name@version&formats=json,text&job_budget=5000" \
  -H "Authorization: Bearer $OCR_KEY" --data-binary @report.pdf
```
The request body is the file itself (PDF, PNG, JPEG or TIFF; each TIFF directory is one page). Do not use multipart. The file type is detected from the content. One file per job.
Query parameters (any other parameter, a repeated single value or an invalid value gives `400 invalid_request`; a parameter with an empty value, for example `?wait=`, counts as not given on every route):

| parameter | meaning |
|---|---|
| `pipeline` | `name@version` from `/v1/pipelines`. The API accepts a missing `pipeline` when exactly one pipeline exists, but you still tell the human which one it is and get a yes; never use the omission to skip asking. |
| `formats` | comma separated: `json,text,md,hocr,pdf`. `hocr` needs a pipeline with the `hocr` capability, `pdf` is built by `POST .../result` (below); otherwise `422 capability_missing`. It is a check at submit, not a filter: it does not limit what you can fetch later. Every format the pipeline supports can be fetched with `result?format=...` whatever you put here (observed: submitted with `formats=json`, then `format=md` worked). |
| `features` | comma separated: `word_boxes,table_cells,layout_classes,header_footer`. Also only a check: asking for a feature the pipeline lacks gives `422 capability_missing`. It does not switch anything on: word boxes are in the JSON whenever the pipeline has `word_boxes`, even if you did not ask. |
| `dry_run` | `1`, `true`, `0`, `false`. |
| `dpi` | optional, `150`, `200` or `300`: the resolution at which the pages of a PDF are rendered (default 150). Images and TIFF ignore it (the dry run warns with `dpi_ignored`). The page size limit applies at this dpi: a page too big for it gives `422 page_too_large`, never a silently smaller render. Part of the idempotency check, returned as `dpi` on every page of the result, the price per page does not change. Any other value gives `400 invalid_dpi`. |
| `client_ref` | your own reference for the job (up to 200 printable characters, for example an invoice number). It is stored with the job, returned in the answers and filters `GET /v1/jobs?client_ref=...`. |
| `job_budget` | integer millicents, only meaningful if the operator set prices. The job stops before a page that would exceed it; open pages are `not_run`. A whole number; non-numeric gives `400 invalid_request`. A number below one page price (`0` and negative included) gives `422 budget_too_low`. |

For "Markdown and JSON with word boxes": the pipeline must list `markdown`, `json` and `word_boxes` in `capabilities`. Send `formats=json,md&features=word_boxes` (recommended: a missing capability then fails at submit with `422 capability_missing`, not later), and fetch `format=json` and `format=md` from the same job.

Order of checks: parameters, capability, budget, then the document is read.
Answer `202` with header `Location: /v1/jobs/{id}` and body: `job_id`, `state`, `pipeline`, `pages`, `price_millicents`, `price_per_page_millicents`, and `client_ref` if you sent one.

Retry safely with `Idempotency-Key`: send a header `Idempotency-Key: <1 to 255 printable characters, for example a UUID>` on `POST /v1/jobs`. If the call times out or you do not know whether it arrived, send the same call again with the same key. Within 24 hours the same key, from the same API key, with the same document and parameters returns the existing job as `200` (header `Idempotent-Replayed: true`, same fields plus `charged_millicents`) and never creates a second job or a second charge; this check comes before the limits, so it also works when the key is at its job cap. The same key with another document or other parameters gives `409 idempotency_conflict`. Without the header every call is a new job. `dry_run` ignores the key. Charges: pages of one job are booked once, also across a service restart. A new job, with or without a new key, is charged for all its pages again, so after `stop` or failed pages do not resubmit the whole document to "finish" it; a repeat with the same key only returns the old job.

## 4. Poll the job

```
curl -s https://api.ocr-software.com/v1/jobs/$JOB_ID -H "Authorization: Bearer $OCR_KEY"
```
Fields: `job_id`, `pipeline`, `state` (`running` or `done`), `pages`, `pages_done`, `pages_warned` (done pages that carry a warning such as `model_output_incomplete`; a subset of `pages_done`), `pages_failed`, `pages_not_run`, `pages_pending` (pages not processed yet; falls while running, 0 at `done`), `charged_millicents` (rises while the job runs, one page price per finished page; final at `done`), `stop` only if the job ended early (`job_budget`, `balance`, `cancelled` after a cancel, `worker_unavailable` when the OCR machine stayed unreachable, `worker_unsupported` or `pages_failed`), and `queue` while the job waits.
`pages_failed` counts pages that could not be read: the image was bad, the pipeline is not available on the worker, or the engine failed on that page on every try. The job goes on with the other pages.
`queue` says where the job stands among all waiting jobs: `class` (`paid` or `free`; free jobs wait behind paid ones only if the operator set prices), `position` (1 = next), `pages_ahead`, `estimated_wait_seconds` (a guide, absent until the service has measured a page), and for free jobs `paid_pages_ahead`, `waited_seconds`, `max_wait_seconds`, `message`. The job keeps running while it waits; do not resubmit.
Poll with `?wait=N` (see "Status and volume" below); without it, poll no faster than every 2 to 5 seconds and back off up to 30 s for long jobs. There is no push or webhook. Stop polling when `state` is `done`. `done` does not mean every page worked: check `pages_failed`, `pages_not_run` and `stop`, and tell the human about them. Do this also when you fetch `text` or `md`: they carry no page status.

## 5. Fetch the result

```
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, `application/json`), `text` (`text/plain`) or `md` (`text/markdown`). `hocr` needs the `hocr` capability (else `422 capability_missing`). `pdf` on `GET` gives `422 source_required`; see "Searchable PDF". While the job runs: `409 not_ready`. After `result_ttl` (default 24 h): `404 not_found`.

- `json`: `{"schema_version":1,"pipeline":"name@v","not_delivered":[...],"pages":[...]}`.
  Page: `page` (1-based), `status` (`done`, `failed`, `not_run`), `safe_ok` (boolean, always present, never true for a page that is not `done`; its calibration is not published yet, so do not treat it as a guarantee), optional `width`, `height` (pixels; left out when the pipeline did not report them), optional `lines` (may be missing for a `failed` page; empty for `not_run`; read it as `page.get("lines", [])`).
  A page may carry `warnings[]`, each `{code, message}` (optional `table`): what the service knows went wrong without failing the page. `table_not_found` (the layout has a table region but no table was built), `table_words_unplaced` (words of a table are in no cell), `model_output_incomplete` (the model output was cut or unreadable in part; text only the CTC reader found is added with the flag `not_in_model`). Treat the page as readable but check it.
  `done` with `safe_ok: false` is normal and common, because `safe_ok` is not calibrated. Tell the human the page finished, that `safe_ok` is not a published guarantee in either direction, and judge by `confidence` and `verified`.
  Line: `{"words":[...]}` in reading order. Word: `text`, `confidence` (0 to 1), `source` (`textlayer`, `ctc` or `vlm`), `verified` (boolean), and `box` = `[x0,y0,x1,y1]` in page pixels, only if the pipeline has the `word_boxes` capability.
  `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".
  Pipelines with `layout_classes` and `table_cells` (`GET /v1/pipelines` shows which) add `layout[]` (regions with `class`, `box`, `box_source`) and `tables[]` (cells with `row`, `col`, `rowspan`, `colspan`, `words`). A region's `confidence` is `null` when the model gives no value: `null` means unknown, never 0. Some classes are weak there (title, footnote, caption); check them before you rely on them.
  A low `confidence` or `verified: false` means look at this word; it is a hint, not an error.
- `text`: one line per line, pages separated by a form feed (`\f`). A `failed` or `not_run` page is an empty page there; the text does not say which.
- `md`: `## Page N` per page, lines with hard breaks (two spaces at the line end), no tables, markup and links are escaped. A `failed` or `not_run` page is an empty section; the text does not say why. Get the page status from the job (`pages_failed`, `pages_not_run`, `stop`) or from the `json` result.

### Searchable PDF, delete, cancel, list

- `POST /v1/jobs/{id}/result?format=pdf` with the original document as the body returns the PDF with an invisible text layer. The service deletes your document when the job ends, so you send it again; it must match the job (`422 source_mismatch` if page count or sizes differ). Pages that were not read carry a visible strip `OCR FAILED` or `OCR NOT RUN`; their numbers are in the header `X-Pages-Unread`. The PDF goes straight back to you. Other refusals as for an upload (`422 pdf_unsupported`, `capability_missing`, `409 not_ready`).
- `DELETE /v1/jobs/{id}` deletes the stored result now (`204`; the state and the counts stay). On a running job it cancels instead (`202`, state `cancelling`): open pages end `not_run`, finished pages stay, the job ends with `stop: cancelled`. Poll until `done`.
- `GET /v1/jobs/{id}/report` tells how the pages of a job were read. It works while the job runs (the pages read so far) and after it, and goes away with the result (`404 not_found` after `result_ttl`). It holds numbers, codes and hashes only: no text, no file names. Fields: `schema_version`, `job_id`, `state`, `pipeline`, `pipeline_hash` (sha256 of the pipeline file the job was accepted with; `unknown` for a job stored before the hash was kept), `pipeline_changed` (only present, `true`, when the file has changed since), `params` (every value of that file that changes the result, as the worker received it, with the render dpi the job chose), `model_rev` (the model revisions the worker reported, one entry per distinct value), `pages` (`total`, `done`, `failed`, `not_run`, `pending`), `seconds` (sums over the pages read: `scheduler_wait`, `render`, `queue`, `read`, `worker`), `page_stages` (the same seconds per page, with that page's `model_rev`) and, only if it happened, `pages_read_with_other_pipeline_hash` (pages read with another pipeline file because the service restarted with a changed one). Keep `pipeline_hash`, `params` and `model_rev` with the result if you must show later how a page was read.
- `GET /v1/jobs` lists the jobs of your key, newest first, same fields as the status. Parameters (all optional, each once): `client_ref` (jobs submitted with that reference), `status` (`running` or `done`; a cancelled job is `done` with `stop: cancelled`, so filter those on the client by `stop`), `limit` (1 to 1000, default 100) and `cursor`. While more jobs follow, the answer has `next_cursor`; pass it unchanged as `cursor` to get the next page. New jobs never shift a cursor.

## 6. Usage

```
curl -s https://api.ocr-software.com/v1/usage -H "Authorization: Bearer $OCR_KEY"
```
`pages_used`, `pages_left` (pages until the page limit of the key stops it; `null` for a key without that limit). `balance_millicents`, `spent_millicents` and `paid` are operator fields, not used in the download edition.

## 7. Bucket jobs (many files)

For thousands of PDFs, do not upload one by one. Give an S3 or S3-compatible bucket. Every PDF (`.pdf`, any case) under `source.prefix` is read; results and `manifest.json` are written under `destination.prefix`. PDF only (no images). Use temporary keys: read-only on the source prefix, write-only on the destination prefix. The credentials are held in RAM only and are never returned or logged. Ask the human for them; never invent them.

```
curl -s -X POST https://api.ocr-software.com/v1/bucket-jobs \
  -H "Authorization: Bearer $OCR_KEY" -H "Content-Type: application/json" -d '{
  "job_id": "archive-2026-q1",
  "pipeline": "name@version",
  "formats": ["json","text"],
  "job_budget": 5000000,
  "max_objects": 100000,
  "source":      {"bucket":"customer-docs","region":"eu-central-1","prefix":"in/","access_key":"...","secret_key":"...","session_token":"..."},
  "destination": {"bucket":"customer-docs","region":"eu-central-1","prefix":"out/","access_key":"...","secret_key":"...","session_token":"..."}
}'
```
Required: `job_id`, `source`, `destination`. Optional: `pipeline` (as for document jobs, it may be left out only when exactly one pipeline exists; otherwise `400 invalid_request`, "Unknown or missing pipeline"), `formats` (`json`,`text`,`md`,`hocr`,`pdf`; here `formats` chooses the files written, unlike the document job; `pdf` puts the text layer on the original PDF, a document that cannot get one ends `partial` with `pdf_unsupported` and keeps its other files), `job_budget` (millicents), `max_objects` (default 100,000 per run). Endpoint object fields: `endpoint` (empty = AWS; otherwise `https` host only, public address only), `region`, `bucket`, `prefix`, `path_style` (boolean), `access_key`, `secret_key`, `session_token`. Source and destination prefixes in the same bucket must not contain each other. Choose your own `job_id`; ids starting with `job_` are rejected.
Answer `202` with `Location: /v1/bucket-jobs/{id}` and `job_id`, `state`, `pipeline`. The source prefix is listed before the `202`, so wrong credentials or an unreachable store fail at once and nothing starts: `422 access_denied`, `no_such_bucket`, `wrong_region` or `source_unreachable`. The destination is only reached later; a failure there shows as `state: failed`. Not tested end to end against a real S3 bucket. Errors: `400 invalid_request`, `401 unauthorized`, `409 conflict` (same `job_id` running), `413 too_large`, `422 capability_missing`, `budget_too_low` or the bucket codes above.
Output per file: `<destination prefix><path under source prefix><ext>` with ext `.json`, `.txt`, `.md`; for example `a/b.pdf` gives `a/b.pdf.json`.
Resume: send the same POST with the same `job_id` again. Finished objects (same ETag) are skipped and no page is billed twice. This is also how you continue after `interrupted` (service restart) or `incomplete` (a stop). Credentials are not kept across restarts, so you send them again.

Poll: `GET /v1/bucket-jobs/{id}`.
Fields: `job_id`, `pipeline`, `state` (`running`, `complete`, `incomplete`, `failed`, `interrupted`), `stop` (reason when the job stopped early: `job_budget`, `balance`, `max_objects`), `error` (a fixed short word, only with state `failed`: `access_denied`, `no_such_bucket`, `wrong_region`, `source_unreachable`, `destination_unreachable`, `output_prefix_conflict` when the destination prefix clashes with an earlier run or manifest or overlaps the source, or `run_failed` for anything else), `error_side` (`source` or `destination`, which bucket caused it), `queue` (as for jobs), `objects`, `pages_done`, `pages_failed`, `pages_not_run`, `started`, `updated` (date-time).
Progress (the bucket is not pre-counted; the listing streams while objects run):
- `pages_per_min`: pages per minute over the last 5 minutes. `pages_per_min_overall`: since start.
- `objects_listed`: PDF objects listed so far; a lower bound until `listing_complete` is `true`.
- `listing_complete` (boolean). Only once it is true: `objects_total` (final count) and `percent_objects` (0 to 100, handled or skipped as already done).
- `current_objects` (array of object keys being processed now, your own names) and `current_count`. Shown only here and in the manifest, never logged.
Do not show a percentage before `listing_complete` is true; show pages done and `pages_per_min`.
`manifest.json` in the destination prefix holds job, pipeline, formats, status (`complete` or `incomplete`), stop reason and per object: key, ETag, size, status (`done`, `partial`, `failed`), page counts, error code, output files; plus the same progress. Names and numbers only, never content.
Done = `state` is `complete`. `incomplete` means a stop: tell the human, then resume with the same `job_id`.

## 8. Create a key on your server

Only with the human. The human makes the first key on the server that runs the installation:

```
sudo /opt/ocr/bin/ocrctl key new -id mykey -paid -keys /etc/ocr/keys.txt
```

- `-paid` means: no page limit for this key. Nothing is billed in the download edition. 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 key is printed once on standard output. Only its hash goes into `keys.txt`.
- The running service accepts it within a few seconds. `GET /v1/usage` answers `401` without the key and `200` with it.
- The steps from download to first call: https://ocr-software.com/docs/install/#steps

## Status and volume

- `GET /v1/jobs/{id}` is the status of ONE document (one job per document).
- `GET /v1/bucket-jobs/{id}` is ONE status for the whole bucket job. It has counts only, no file names (apart from `current_objects`, your own keys being processed now): `pages_done`, `objects_listed`, `listing_complete`, `objects_total` and `percent_objects` (both only once `listing_complete` is `true`), `pages_per_min`. Fields as in `openapi.json`.
- Rule of thumb: a few documents, use HTTPS jobs (one `POST /v1/jobs` each). Many documents (thousands and more): one bucket job, and poll its one status, not one status per document.
- Long poll: add `?wait=N` to either status call (`N` whole seconds, 0 to 60, default 0 = answer at once). The request is held until the job changes or `N` seconds pass, then answers `200` with the normal body, so look at the body, not at how long it took. A finished job answers at once, an unknown job gives `404` at once. `wait` out of range, not a whole number or repeated gives `400 invalid_request`. On a bucket job a change of `state` ends the wait at once; counted objects and pages end it after at least 3 s. The server may cut `N` short (service default: 55 s at most) and, when many clients are waiting (defaults: 32 in all, 4 per key), answers at once without an error; then simply call again, with a pause of a few seconds so you do not spin. Loop: call with `?wait=30`, read the body, repeat until the state is final.
- Without `wait`: no faster than once every few seconds (2 to 5 s, up to 30 s for long jobs).
- Limits: `429 rate_limited` carries `Retry-After`; honour it (see Errors). A waiting request counts once against the per-key rate limit. Service defaults: 20 requests per second (burst 40) per key and 8 running jobs per key (`max_jobs_per_key`); your installation may set other values.
- No webhooks. There is no push; you poll.

## Limits

- Over the file size or page limit: `413 too_large`, `422 too_many_pages`, `422 page_too_large`. The numbers come from the configuration of the installation. Do not assume a number.
- A rate limit per key and a cap of concurrent jobs per key exist: `429 rate_limited` with a short `Retry-After`. Separate from that, the wrong-key lockout (see Errors) answers `429 locked_out` with `Retry-After: 900`. Service defaults are 20 requests per second (burst 40) and 8 jobs per key; your installation may set other values.
- Result retention: `result_ttl`, default 24 h.
- The package is free for private use and tests of businesses, under the <a href="/legal/eula/">licence terms (EULA)</a>, version 2026-11-08; the German text is binding. Productive use in a company needs a contract. The hosted service has its own <a href="/legal/saas-terms/">SaaS terms</a>. Prices are net.

## Complete example in Python (standard library only)

```python
import json, os, sys, time, urllib.request, urllib.error, urllib.parse

BASE = "https://api.ocr-software.com"   # your own server: http://127.0.0.1:8080
KEY = os.environ["OCR_KEY"]          # never hard-code, never print


def call(method, path, body=None, ctype=None):
    for attempt in range(5):
        req = urllib.request.Request(BASE + path, data=body, method=method)
        req.add_header("Authorization", "Bearer " + KEY)
        if ctype:
            req.add_header("Content-Type", ctype)
        try:
            with urllib.request.urlopen(req, timeout=300) as r:
                return r.status, json.loads(r.read())
        except urllib.error.HTTPError as e:
            payload = e.read().decode("utf-8", "replace")
            wait = int(e.headers.get("Retry-After", 0) or 0)
            if wait > 60:            # key lockout (900 s): do not wait, go back to the human
                sys.exit("%s %s (locked for %d s, ask the human)" % (e.code, payload, wait))
            if e.code in (429, 500, 503) and attempt < 4:
                time.sleep(max(wait, 2 ** attempt))
                continue
            sys.exit("%s %s" % (e.code, payload))
        except urllib.error.URLError:
            if attempt == 4:
                raise
            time.sleep(2 ** attempt)


pipeline = sys.argv[1]               # chosen by the human, e.g. name@version from /v1/pipelines
doc = open(sys.argv[2], "rb").read()
q = urllib.parse.urlencode({"pipeline": pipeline, "formats": "json"})

_, quote = call("POST", "/v1/jobs?" + q + "&dry_run=1", doc)
print("pages", quote["pages"])
# ask the human to confirm here before the next line

_, job = call("POST", "/v1/jobs?" + q, doc)
job_id = job["job_id"]
delay = 2
while True:
    _, st = call("GET", "/v1/jobs/" + job_id)
    if st["state"] == "done":
        break
    time.sleep(delay)
    delay = min(delay * 1.5, 30)
print({k: st.get(k) for k in ("pages", "pages_done", "pages_failed", "pages_not_run", "stop")})

_, result = call("GET", "/v1/jobs/%s/result?format=json" % job_id)
for page in result["pages"]:
    for line in page.get("lines", []):   # lines can be missing for a failed page
        print(page["page"], " ".join(w["text"] for w in line["words"]))
```

(The file is sent as the raw body. `urllib` sends `application/x-www-form-urlencoded` for a body by default; the service ignores the header and detects the type from the content.)

## For the human (what the agent should ask)

1. "Which pipeline?" (show the live list from `/v1/pipelines`).
2. "Which formats do you need?" (`json`, `text`, `md`).
3. "Is it fine to run this file?" The service runs on your own hardware. The uploaded document is deleted when the job ends; results are kept 24 h by default.

## Links

- Limits: https://ocr-software.com/docs/api-guide/#prod-limits
- Pipelines: https://ocr-software.com/docs/api-guide/#int-pipeline
- OpenAPI: https://ocr-software.com/openapi.json
- Guide for developers: https://ocr-software.com/docs/
- Index for agents: https://ocr-software.com/llms.txt
