Inxai OCR API
Overview
Inxai OCR reads PDFs and images and returns their text, layout, tables and formulas. It runs on CPU inside your own network. There are no users or accounts: one API token, which only your services hold, opens the API.
Services on the same Docker network reach it at http://inxai-ocr:8082. This page is served from http://inxai-ocr:8082. Every route under /v1 needs the token; this page and /openapi.yaml do not.
Two ways to submit a document:
POST /v1/ocruploads and waits for the result, up to a deadline. Use it for images and short PDFs.POST /v1/jobsqueues the document and returns a job id at once. Poll the job, then fetch the result. Use it for long PDFs and batches.
Both run the same pipeline and produce identical results.
API token
One secret token opens the API. Every service sends it on every /v1 request:
Authorization: Bearer <token>
X-API-Key: <token> works too. The token never goes in the URL.
Create it
./scripts/new-api-token.sh
It prints API_TOKEN=… (64 random hex characters). Put that line in the OCR server's .env, or in Coolify's environment variables, and give the same value to each service that calls the API, for example as its API_TOKEN secret. Any token of at least 32 characters works; the app refuses to start with a missing, short or placeholder token.
The server keeps only the token's SHA-256 in memory and compares digests in constant time. The token is never logged; the startup line api access shows only the first 8 characters of its SHA-256, so you can tell which token a server has.
Change it
Set a new API_TOKEN, restart the app, and update the services. The old token stops working at the restart.
Connection security
Each request passes these checks in order, as soon as its headers arrive and before any of the upload is accepted. A refused caller never gets a byte written to disk.
- Network. The source address must be in
OCR_ALLOWED_NETWORKS(config/app.env). By default that is loopback and private networks, so a direct connection from the public internet is refused with403 network_not_allowed. - Client certificate, when
TLS_MODE=mutual. A request without a certificate from your CA gets403 client_certificate_required. - API token. Missing or wrong:
401 unauthenticated. - Rate. More than
OCR_RATE_LIMIT_PER_MINUTErequests a minute, all callers together:429 rate_limitedwithRetry-After.
PostgreSQL sits on an internal network that nothing outside the stack can reach, and the app connects to it as an unprivileged role.
Traffic between hosts: TLS and mutual TLS
Inside one Docker host, traffic stays on the host's private network. The app's own TLS is set by TLS_MODE:
off(default): plain HTTP. Use it on Coolify: its proxy terminates HTTPS for the domain and forwards plain HTTP, and client certificates would reach the proxy, not the app.tls: the app serves HTTPS itself, for services connecting directly from other servers.mutual: HTTPS, and every request also needs a client certificate from your CA.
On a dedicated server, create the certificates, set TLS_MODE=mutual, and start with compose.tls.yaml. Setting TLS_MODE=off later turns it off without removing the certificate settings.
./scripts/mtls.sh ca
./scripts/mtls.sh server inxai-ocr ocr.internal.example
./scripts/mtls.sh client billing-service
docker compose -f compose.yaml -f compose.tls.yaml up -d --wait
A service then calls with both its certificate and the token:
curl --cacert ca.crt --cert billing-service.crt --key billing-service.key \
-H "Authorization: Bearer $API_TOKEN" https://inxai-ocr:8082/v1
Quickstart
Check the token
curl http://inxai-ocr:8082/v1 -H "Authorization: Bearer $API_TOKEN"
A 200 reply means the token works; it lists the endpoints, presets and formats.
One request, one result
curl 'http://inxai-ocr:8082/v1/ocr?preset=smart_capture&languages=ara,eng&format=text' \
-H "Authorization: Bearer $API_TOKEN" \
-H 'Content-Type: application/pdf' \
--data-binary @invoice.pdf
The body is the raw file, not a form. The response body is the result in format. If the document takes longer than deadline_seconds (30 by default), the reply is 202 with the job id in X-Job-Id. The job keeps running; fetch its result as below.
Queue, wait, fetch
JOB=$(curl -s 'http://inxai-ocr:8082/v1/jobs?preset=document_reconstruction&languages=eng' \
-H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/pdf' \
-H 'Idempotency-Key: report-2026-11-14' \
--data-binary @report.pdf | jq -r .job_id)
curl -s http://inxai-ocr:8082/v1/jobs/$JOB -H "Authorization: Bearer $API_TOKEN"
# wait until "status" is COMPLETE, then:
curl -s "http://inxai-ocr:8082/v1/jobs/$JOB/result?format=markdown" \
-H "Authorization: Bearer $API_TOKEN" -o report.md
Python
import os, time, requests
OCR = "http://inxai-ocr:8082"
AUTH = {"Authorization": f"Bearer {os.environ['API_TOKEN']}"}
with open("scan.png", "rb") as f:
job = requests.post(f"{OCR}/v1/jobs", headers={**AUTH, "Content-Type": "image/png"},
params={"preset": "text_capture", "languages": "ara,eng"},
data=f, timeout=120).json()
while True:
state = requests.get(f"{OCR}/v1/jobs/{job['job_id']}", headers=AUTH, timeout=30).json()
if state["status"] in ("COMPLETE", "FAILED"):
break
time.sleep(1)
if state["status"] == "FAILED":
raise RuntimeError(state.get("failure_message"))
text = requests.get(f"{OCR}/v1/jobs/{job['job_id']}/result",
params={"format": "text"}, headers=AUTH, timeout=60).text
Node.js
import { readFile } from "node:fs/promises";
const OCR = "http://inxai-ocr:8082";
const headers = { Authorization: `Bearer ${process.env.API_TOKEN}` };
const res = await fetch(`${OCR}/v1/ocr?preset=text_capture&languages=eng&format=json`, {
method: "POST",
headers: { ...headers, "Content-Type": "image/jpeg" },
body: await readFile("receipt.jpg"),
});
if (res.status === 202) {
// Still running: poll /v1/jobs/{id} with this id.
console.log("job", res.headers.get("x-job-id"));
} else if (!res.ok) {
throw new Error((await res.json()).error.message);
} else {
const graph = await res.json();
console.log(graph.pages.length, "pages");
}
Endpoints
| Method and path | What it does |
|---|---|
POST /v1/ocr | Uploads a document and waits for one format of its result. format (default json) picks it; deadline_seconds (1–120) bounds the wait. |
POST /v1/jobs | Queues a document. Returns 202 with job_id, the resolved settings and a cost estimate. |
GET /v1/jobs/{job_id} | Status, page progress, settings, and failure_code / failure_message when it failed. |
GET /v1/jobs/{job_id}/result?format= | The finished result in one of the job's formats. 409 not_ready until the job is COMPLETE. |
GET /v1/jobs/{job_id}/events | Server-sent events: status on every change and page_done per finished page. Ends at a final state or after 5 minutes. |
GET /v1/jobs/{job_id}/pages/{index}/result?format= | One page's result, available as soon as that page is done. Indexes start at 0. Required for delivery=pages jobs. |
GET /v1/jobs/{job_id}/pages/{index}/image | The page image OCR ran on; every polygon in the result is normalized to it. Optional format (png, jpeg, webp), quality, max_side, crop=x0,y0,x1,y1. |
DELETE /v1/jobs/{job_id} | Deletes the job's source, page images and results. The job id remains as DELETED. Repeating it succeeds. |
GET /v1/capabilities | What this deployment can run: options per stage, loaded models, limits, presets and formats. |
GET /v1 | The endpoint inventory; a quick check that the token works. |
Every response carries X-Request-Id, the id it was logged under. Send your own X-Request-Id to correlate logs across services. Errors are JSON:
{"error": {"code": "unsupported_format", "message": "unknown output format: docx",
"hint": "known formats: json,protobuf,markdown,…"}}
The full machine-readable contract is /openapi.yaml.
Parameters
For POST /v1/jobs and POST /v1/ocr, all in the query string. The body is the raw document. An unknown parameter is refused with 400 invalid_parameter, so a typo is never silently ignored.
| Parameter | Values | Default |
|---|---|---|
preset | A preset id. Fills in the options below; any you also send win. | none |
languages | Comma-separated, e.g. ara,eng, fra, chi_sim | ara,eng |
formats | Comma-separated formats to produce | json |
quality | fast (200 DPI), balanced (260), quality (320) | balanced |
engines | Comma-separated: gocr (the inxai-ocr model), paddle, oneocr (the inxai-ocr-en model: English only, with quality=quality) | gocr |
detector | Text detector, e.g. gocr (inxai-ocr), ppocrv6_tiny, ppocrv6_small, ppocrv6_medium, or none | server setting |
tier | Paddle recognizer size: tiny, small, medium | server setting |
layout | auto, geometry, gocr (inxai-ocr), doclayoutv3, none | auto |
tables | rules (printed borders), slanet (predicted), auto, none | rules |
formulas | ppformulanet_s (LaTeX), none | none |
text_source | For PDFs: auto, native (text layer), ocr, verified | auto |
autorotate | 1 straightens rotated pages first | 0 |
line_orientation | vertical, all, none: which lines are checked for being upside down | server setting |
md_images | Figures in Markdown: embed, link, none | embed |
delivery | document, or pages for very long PDFs (results per page only) | document |
filename | The original name, checked against the bytes | none |
Headers: Content-Type must agree with the file's real type (it is detected from the bytes). Idempotency-Key (up to 200 characters) makes a retried upload return the original job instead of creating another; reusing it with different bytes or options is 409.
A stage whose model this deployment does not carry is refused with 400 stage_not_loaded, never silently skipped. GET /v1/capabilities lists what is loaded.
Presets
A preset names a complete recipe, so a service can ask for preset=spreadsheet_capture rather than five options. Combine any preset with quality and engines to trade speed for detail.
| Preset | Use it for | Sets |
|---|
Speed and detail
| Choice | Send |
|---|---|
| Fast | quality=fast&engines=gocr |
| Balanced | quality=balanced&engines=gocr,paddle&detector=ppocrv6_tiny&tier=tiny&autorotate=1 |
| High detail | quality=quality&engines=gocr,paddle&detector=ppocrv6_medium&tier=medium&autorotate=1&line_orientation=all |
Output formats
| Format | Contents |
|---|---|
json | The full document graph: pages, lines with polygons and confidence, regions, tables with cells, formulas, reading order and provenance. |
text | Plain text in reading order. |
markdown | Headings, paragraphs, tables and LaTeX formulas. |
html | Semantic HTML with tables. |
rag_jsonl | One chunk per line with page and polygon citations, for retrieval. |
compact | A smaller JSON with text and boxes only. |
alto, page | ALTO and PAGE XML for archives and digitization tools. |
protobuf | The document graph as Protocol Buffers (contracts/protobuf/document_graph.proto). |
Polygons are normalized to the page image: [x, y] from 0 to 1, origin top-left. Multiply by the image's width and height to draw them.
Job states
RECEIVED → VALIDATED → INGESTED → PAGE_ANALYSIS → FUSED → EXPORTED → COMPLETE.
Final states are COMPLETE, FAILED (with failure_code and failure_message), and DELETED. A worker that crashes mid-job releases it after QUEUE_LEASE_SECONDS. Another worker resumes from the last finished page, retrying up to QUEUE_MAX_ATTEMPTS times.
Errors
| Status | Codes | What to do |
|---|---|---|
| 400 | invalid_parameter, unsupported_*, stage_not_loaded, content_type_mismatch, empty_body | Fix the request; the hint names the accepted values. |
| 401 | unauthenticated | Send the API token in Authorization: Bearer. |
| 403 | network_not_allowed, client_certificate_required | Call from an allowed network, with a client certificate when mutual TLS is on. |
| 404 | not_found, format_not_available, page_not_available | No such job, or the format was not requested. |
| 409 | not_ready, idempotency_key_reuse, page_delivery | Wait for Retry-After, or use a new idempotency key. |
| 410 | gone | The job was deleted. |
| 413 | payload_too_large | The file exceeds LIMIT_MAX_UPLOAD_BYTES. |
| 415 | unsupported_media_type | Send a PDF or a raster image. |
| 422 | processing_failed | From /v1/ocr: the document could not be read; see the message. |
| 429 | rate_limited, too_many_active_jobs | Retry after Retry-After seconds. |
| 503 | unavailable | Temporary; retry after Retry-After. |
Limits
Set in config/app.env. Connect with the token to see this deployment's values.
| Limit | Setting | Value |
|---|---|---|
| Upload size | LIMIT_MAX_UPLOAD_BYTES | 2 GiB |
| Pages per document | LIMIT_MAX_PAGES | 10000 |
Pages for delivery=document | LIMIT_FULL_DOCUMENT_PAGES | 200 |
| Megapixels per page | MAX_PDF_PAGE_MEGAPIXELS | 80 |
| Processing time per job | LIMIT_MAX_WALL_SECONDS | 86400 s |
| Requests per minute, all callers | OCR_RATE_LIMIT_PER_MINUTE | 600 |
| Queued or running jobs | OCR_MAX_ACTIVE_JOBS | 100 |
Health and monitoring
GET /health/live and GET /health/ready on port 8082 need no token and reveal nothing. Prometheus metrics, per-stage timings and build information are on port 9200 (/metrics, /internal/profile, /internal/build-info), which is not published outside the container.
Refused requests are counted by reason in docplatform_auth_failures_total, and logged with the source address and path, never the token.