Loading...
Loading...
Complete reference for POST /v1/ocr — parameters, examples, error codes, and authentication.
All API requests require a Bearer API key in the Authorization header. Generate keys from your dashboard.
# All authenticated requests require this header:
Authorization: Bearer sk-ocr-your-api-key
# Example:
curl -X POST https://api.open-ocr.com/v1/ocr \
-H "Authorization: Bearer sk-ocr-your-api-key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "engine": "mistral-mistral-ocr-4-1", "input": { "type": "url", "url": "https://example.com/doc.png" } }'| Parameter | Type | Required | Description |
|---|---|---|---|
| Idempotency-Key (header) | string | Yes | Send a new unique value (for example a UUID) for each logical request, and reuse the SAME value when you retry that request so it is processed and billed once. Use a NEW value for a different payload: Reusing a key with a DIFFERENT request body returns 409 idempotency_key_reused; the same key with the same request (even with different base64 line breaks or omitted default fields) replays the original result. The comparison is on the effective request (decoded base64 bytes, resolved engine, defaults applied such as page_count 1, output_format json, mode sync and language en), not its spelling. A request first stored before 2026-09-29 has no fingerprint and replays even a changed request. The text is replayed only while OCR history is retained: with history off a replayed success has a null extracted_text and a warning that the text was not retained, so clients with history off must keep the first response. Use a fresh key for every distinct document. Without this header the call fails with 400 (Idempotency-Key header is required). |
| engine | string | Yes | Engine ID, e.g. "mistral-mistral-ocr-4-1". See GET /v1/engines. |
| input | object | Yes | The document to process. |
| input.type | string | Yes | Either "url" or "base64". Other values, including "file", are rejected. |
| input.url | string | If type=url | Public URL of the document (image or PDF). |
| input.data_base64 | string | If type=base64 | Raw base64-encoded document data. Do not include a data: URI prefix (it is rejected as invalid base64). |
| input.mime_type | string | No | MIME type hint for base64 input, e.g. "image/png". |
| options.language_hints | string[] | No | Language hints, e.g. ["eng", "fra"], honored by OpenOCR-hosted engines. Prefer settings.language. |
| options.detect_tables | boolean | No | Enable table detection where the engine supports it. |
| fallback_engine | string | No | Engine ID used for scanned pages in a PDF when scanned_handling is fallback. |
| scanned_handling | string | No | "skip", "fallback" or "error" for scanned (image-only) PDF pages. Default: skip, or fallback when fallback_engine is set. |
| fallback | string | boolean | No | Opt-in automatic retry. Set "similar" (or true) and, if your engine refuses the page, returns no text, or hits a temporary error or timeout, OpenOCR retries once on an engine from a different vendor that scores within 5 points on our hard benchmark and costs the same or less per page. You are never charged more than the engine you asked for; engine, warning and metadata.fallback name the engine that produced the text. Default "off". Not the same as fallback_engine, which only covers scanned PDF pages. |
| settings.dpi | integer | No | Resolution hint, 72 to 1200. |
| settings.page_range | string | No | Page selection such as "1-3,5" for engines that support it (OCRmyPDF). |
| settings.detect_tables | boolean | No | Same as options.detect_tables. |
| mode | string | No | "sync" (default) or "async". An async request returns 202 with status "processing", a request_id and a poll_url; poll GET /v1/ocr/{request_id} or set webhook_url. |
| webhook_url | string | No | Webhook URL for async results. Requires mode="async". |
| page_count | integer | No | Number of pages to process for multi-page PDFs (up to the engine's max_pages limit; see max_pages_per_request in GET /v1/engines). Default: 1, so only the first page is processed unless you set this. Set page_count to the number of pages you want. Compare pages with pages_total in the response to see whether the whole document was processed. |
| output_format | string | No | "json" (default), "text" (plain text, markdown stripped), or "markdown" (a fixed header, then the engine output untouched; see Text and markdown output below). For text and markdown the response body is the text itself; see Text and markdown output below. With json, extracted_text is unchanged and extracted_text_plain is added when the plain version differs. |
| settings | object | No | Unified engine settings — auto-mapped to each engine's native format. |
| settings.language | string | No | ISO language code, e.g. "en", "ja", "zh". Default: "en". |
| native_settings | object | No | Engine-specific passthrough params. Overrides unified settings on conflict. |
| Parameter | Type | Required | Description |
|---|---|---|---|
| request_id | string | Always | Unique request identifier. |
| status | string | Always | "queued" or "processing" while running. Terminal: "succeeded", "partial" (some text recovered; only the preview is stored), "failed" or "rejected". A POST response can also say "refunded" or "timeout"; GET reports those as "failed". |
| engine | string | Always | Engine ID that processed the request. |
| extracted_text | string | On success | Full extracted text: the raw engine output. |
| extracted_text_preview | string | On success | Truncated preview of extracted text. |
| pages | integer | On success | Number of pages processed and billed. Not clamped to the document length. |
| pages_total | integer | When known | Number of pages in the source document. Omitted when the router cannot tell (for example a single image URL). If it is larger than pages, only part of the document was processed; set page_count to process more. |
| warnings | string[] | When applicable | Non-fatal notes, for example "document has 3 pages; processed 1. Set page_count to process more." Check it when you send a multi-page PDF. |
| extracted_text_plain | string | When it differs | Plain-text conversion of extracted_text (see Plain-text conversion rules). Present only for output_format json when it differs from extracted_text. |
| metadata.warnings | string[] | When applicable | LLM engines only: notes about the output, for example: possible duplicated trailing block: 12 lines. It is not copied to the single warning field or the X-OCR-Warning header. |
| metadata.possible_duplicate | object | When applicable | LLM engines only: { lines, text } describing an exact trailing repeat. Text is never removed: the transcript stays intact and this is only a flag for you to review. |
| http_status | integer | When applicable | Only when a slow response was committed early: the HTTP status the response would have had, for example 200 on a completed success. |
| poll_url | string | Async only | Path to poll for the result, /v1/ocr/{request_id}. Returned with status "processing". For an image-only PDF over 20 pages on an engine without native PDF it is /v1/ocr/jobs/{job_id}/status instead; fetch the text from /v1/ocr/jobs/{job_id}/result (see the PDF routing page). |
| cost_debited | number | Always | Credits charged (USD). Non-zero on a failed request when the engine ran and the provider billed us for the attempt — see Billing on Failed Requests. 0.0 when nothing is owed. |
| provider_latency_ms | integer | On success | Engine processing time in milliseconds. |
| confidence | number | On success (engine-dependent) | OCR confidence score (0–1). Absent for engines that don't report confidence. |
| applied_settings | object | On success | Actual settings applied, including language mapping and native overrides. |
curl -X POST https://api.open-ocr.com/v1/ocr \
-H "Authorization: Bearer sk-ocr-your-api-key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: req-$(uuidgen)" \
-d '{
"engine": "mistral-mistral-ocr-4-1",
"input": {
"type": "url",
"url": "https://example.com/invoice.png"
}
}'
# 200 OK (status: "succeeded"; still read the status field, not only the HTTP code)
{
"request_id": "req_abc123",
"status": "succeeded",
"engine": "mistral-mistral-ocr-4-1",
"extracted_text": "Invoice #1042\nDate: 2026-02-28\nTotal: $42.00",
"extracted_text_preview": "Invoice #1042...",
"pages": 1,
"cost_debited": 0.005,
"provider_latency_ms": 148,
"confidence": 0.94
}curl -X POST https://api.open-ocr.com/v1/ocr \
-H "Authorization: Bearer sk-ocr-your-api-key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: req-$(uuidgen)" \
-d '{
"engine": "mistral-mistral-ocr-4-1",
"page_count": 3,
"input": {
"type": "url",
"url": "https://example.com/report.pdf"
}
}'
# 200 OK (status: "succeeded"; still read the status field, not only the HTTP code)
{
"request_id": "req_pdf456",
"status": "succeeded",
"engine": "mistral-mistral-ocr-4-1",
"extracted_text": "Page 1 content...\n--- Page 2 ---\nPage 2 content...\n--- Page 3 ---\nPage 3 content...",
"pages": 3,
"cost_debited": 0.015,
"provider_latency_ms": 820
}# Omit page_count and only page 1 is processed. pages_total and warnings tell you.
# 200 OK (status: "succeeded"; still read the status field, not only the HTTP code)
{
"request_id": "req_pdf457",
"status": "succeeded",
"engine": "mistral-mistral-ocr-4-1",
"extracted_text": "Page 1 content...",
"pages": 1,
"pages_total": 3,
"warnings": ["document has 3 pages; processed 1. Set page_count to process more."],
"cost_debited": 0.005
}Language hints are honored by OpenOCR-hosted engines such as local-tesseract. Some engines, including Mistral OCR, ignore the hint and detect the language automatically.
curl -X POST https://api.open-ocr.com/v1/ocr \
-H "Authorization: Bearer sk-ocr-your-api-key" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"engine": "local-tesseract",
"input": {
"type": "url",
"url": "https://example.com/japanese-receipt.png"
},
"settings": {
"language": "ja"
}
}'
# 200 OK (status: "succeeded"; still read the status field, not only the HTTP code)
{
"request_id": "req_ja789",
"status": "succeeded",
"engine": "local-tesseract",
"extracted_text": "領収書\n合計: ¥4,200",
"pages": 1,
"cost_debited": 0.002,
"provider_latency_ms": 1420,
"applied_settings": { "language": "ja" }
}# Encode image locally
IMAGE_B64=$(base64 -w 0 /path/to/receipt.png)
curl -X POST https://api.open-ocr.com/v1/ocr \
-H "Authorization: Bearer sk-ocr-your-api-key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: req-$(uuidgen)" \
-d "{
\"engine\": \"mistral-mistral-ocr-4-1\",
\"input\": {
\"type\": \"base64\",
\"data_base64\": \"$IMAGE_B64\",
\"mime_type\": \"image/png\"
}
}"
# 200 OK (status: "succeeded") — same response shape as URL inputWith output_format set to text or markdown the body is text rather than JSON (for markdown, a fixed wrapper followed by the unchanged engine output, shown below), so page information travels in response headers (all exposed to browsers via CORS):
| Parameter | Type | Required | Description |
|---|---|---|---|
| X-OCR-Pages | integer | Always | Pages processed and billed. |
| X-OCR-Pages-Total | integer | When known | Pages in the source document. |
| X-OCR-Warnings | string | When applicable | Warnings joined with " | ". The header is absent when nothing was skipped. |
# OCR Result
**Engine:** mistral-mistral-ocr-4-1
**Status:** succeeded
---
<engine output, exactly as the engine returned it>Large PDF jobs may add a pages_failed array to the finished result, together with a warning such as "page 4 failed OCR". On an async 202, pages_total is known for base64 inputs only; for URL inputs it appears when you poll or receive the webhook. Text conversion rules are listed below. Batch items carry pages, pages_total and warnings. GET /v1/ocr/{request_id} returns them once the request has finished, including on replays. Text output text is plain text with markdown stripped; markdown returns a fixed header followed by the engine output, which is not modified:
status: "succeeded" in the body. Still check the status field, not only the HTTP code: a response can also carry partial, refunded or another non-success status. When a slow response was committed early (headers flushed before the result), a completed success is HTTP 202 with status: "succeeded" and http_status: 200 in the body, so do not treat every 202 as still running.output_format text or markdown, a completed request returns 200.status: "processing" and a poll_url.status field) with that outcome's normal HTTP code, which is not always the code first seen: a slow first response may have arrived as an early-flushed 202 carrying http_status, and its replay is then 200 for a success. The normal codes are: a success 200, a refunded request 202 with status: "refunded", and a timeout 503 with status: "timeout", a Retry-After header and retry_after_seconds in the body. Replaying an in-flight async request returns 202 JSON with the poll_url. This is about POST replays. GET /v1/ocr/{request_id} is separate: it returns 200 for a finished request only while OCR history is retained, and 410 result_not_retained otherwise; refunded and timeout outcomes read as failed there.output_format: "text" converts the engine output like this. With the default JSON format, extracted_text stays the raw engine output and extracted_text_plain carries this conversion when it differs.
<br> inside a cell becomes a space. Pipe lines without a separator row are left as they are.# C# becomes C#).--- is kept, because PDF pages are joined with a blank line, ---, and a blank line, so it marks page breaks (use pages and pages_total for the authoritative page count). ***, ___, - - -, * * * and _ _ _ are dropped.5 * 3 and 2*3*4 survive. Underscores are not treated as emphasis. is removed entirely.\*, \_, \|, \# and \` become the literal character.[text](url), underscores and bare URLs stay as they are. Text that is already plain is returned byte-identical.An async request (mode: "async") returns status: "processing" and a request_id. Poll this endpoint with that ID until status is no longer queued or processing. GET returns these statuses: queued and processing (still running), then succeeded, partial (some text recovered), failed or rejected. GET never returns refunded or timeout: the router stores those outcomes as failed (read provider_error). Those two words appear only in the original POST response. For a partial result the full text is not stored: read extracted_text_preview (up to 2,000 characters) and warning. Or set webhook_url to be called instead. The same endpoint recovers the saved result of a synchronous request whose connection dropped. Polling only reads the saved request: it does not run OCR again and is not charged. It takes no Idempotency-Key.
curl https://api.open-ocr.com/v1/ocr/req_abc123 \
-H "Authorization: Bearer sk-ocr-your-api-key"
# Still running: read the "status" field, not the HTTP code
# { "request_id": "req_abc123", "status": "processing" }
# Partial (some text recovered): extracted_text is null; only the preview is stored
# { "request_id": "req_abc123", "status": "partial", "extracted_text": null,
# "extracted_text_preview": "First 2,000 characters...", "warning": "..." }
# Completed: 200 OK (a replay of a finished request)
# {
# "request_id": "req_abc123",
# "status": "succeeded",
# "engine": "mistral-mistral-ocr-4-1",
# "extracted_text": "Invoice #1042...",
# "pages": 1,
# "cost_debited": 0.005
# }Poll every few seconds and stop on a terminal status. If OCR history is turned off for your account the saved text is not retained; see recovering results. The charge for a request is the cost_debited value in the original POST response (and cost_usd in the webhook). This GET repeats it only while OCR history is enabled: with history off it returns 410 result_not_retained.
All errors return a consistent JSON envelope:
{
"error": {
"code": "invalid_request",
"message": "Idempotency-Key header is required"
}
}The machine-readable field is error.code(not type). Errors do not always include a request_id.
invalid_requestMissing or malformed request body, missing Idempotency-Key, or unsupported input type.
{ "error": { "code": "invalid_request", "message": "Idempotency-Key header is required" } }unknown_engineThe engine ID is not in the catalog. The suggestions array lists close matches; see GET /v1/engines for every valid ID. An engine that exists but is disabled returns an engine-is-not-available error instead.
{ "error": { "code": "unknown_engine", "message": "'mistral-ocr-4-1' is not a known engine id. Did you mean: mistral-mistral-ocr-4-1?", "suggestions": ["mistral-mistral-ocr-4-1"] } }authentication_errorMissing or invalid API key. Check your Authorization header.
{ "error": { "code": "authentication_error", "message": "Invalid API key" } }insufficient_balanceAccount balance too low. Add credits at open-ocr.com/pricing.
{ "error": { "code": "insufficient_balance", "message": "Insufficient balance to process any pages. Need $0.005, have $0.0001" } }insufficient_scopeYour API key lacks permission for this operation.
{ "error": { "code": "insufficient_scope", "message": "This API key does not have access to this resource" } }payload_too_largeRequest body exceeds the 10 MB limit. Reduce image resolution or split into multiple requests.
{ "error": { "code": "payload_too_large", "message": "Request body exceeds 10 MB limit" } }rate_limitedToo many requests. Respect Retry-After and X-RateLimit-* headers.
{ "error": { "code": "rate_limited", "message": "Rate limit exceeded" }, "retry_after_seconds": 30 }internal_errorServer-side error. A failure on our side is never charged — retry safely with the same Idempotency-Key.
{ "error": { "code": "internal_error", "message": "An unexpected error occurred" } }provider_errorThe OCR engine returned an error. Not charged if the engine never ran; charged the provider's cost for the attempt, at cost, if it ran and then failed. See Billing on Failed Requests below.
{ "error": { "code": "provider_error", "message": "Engine returned a non-200 status" } }engine_unavailableSelected engine is temporarily offline. The request never reached an engine, so it is not charged. Try another engine or retry later.
{ "error": { "code": "engine_unavailable", "message": "Engine local-easyocr is currently unavailable" } }For retry strategies per error code, see the Error Codes page.
A failure is not automatically free. What you pay depends on whether the engine actually ran.
cost_debited value in the original POST response (and cost_usd in the webhook); GET /v1/ocr/{request_id} repeats it only when OCR history is enabled.On the wire this means cost_debited may be non-zero on a response whose status is "failed". A charged failure does not get a status or error code of its own — cost_debited is where you read the charge, and provider_error tells you what went wrong. Successful requests are unaffected and bill at the published price.
{
"request_id": "req_abc123",
"status": "failed",
"engine": "google-gemini-3.1-flash-lite",
"extracted_text": null,
"pages": 0,
"cost_debited": 0.0004,
"provider_error": "Engine timed out after generating a partial response"
}Here the engine was quoted at $0.0006 for the page and the provider billed us $0.0004 for the tokens their model produced before it failed, so $0.0004 is what you pay.
Full policy, including a worked example, on the Error Codes page.
All authenticated endpoints are limited to 60 requests per minute per API key. Every response includes rate-limit headers.
# Headers on every response:
X-RateLimit-Limit: 60 # requests allowed per window
X-RateLimit-Remaining: 42 # requests left
X-RateLimit-Reset: 1709251200 # Unix timestamp when window resets
# On 429:
Retry-After: 18 # seconds to waitimport time, uuid, requests
def ocr_with_retry(payload, api_key, max_retries=3):
idempotency_key = str(uuid.uuid4()) # one key per logical request, reused on every retry
for attempt in range(max_retries + 1):
resp = requests.post(
"https://api.open-ocr.com/v1/ocr",
json=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Idempotency-Key": idempotency_key,
},
timeout=(5, 240), # connect 5s, read 240s: above the router's 215s max wall
)
if resp.status_code != 429:
return resp
wait = float(resp.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait)
return resp
payload = {"engine": "mistral-mistral-ocr-4-1", "input": {"type": "url", "url": "https://example.com/doc.png"}}
api_key = "sk-ocr-your-api-key"
resp = ocr_with_retry(payload, api_key)
# Use it: HTTP 2xx only means the call completed. Check the body status.
body = resp.json()
if body.get("status") == "timeout":
# A timed-out request replays as the same 503 for this key, forever. Report it, or start a
# NEW attempt with a NEW Idempotency-Key. Never loop on the same key.
raise RuntimeError("OCR timed out; retry with a NEW Idempotency-Key")
if not resp.ok:
# Error envelope: {"error": {"code", "message"}} with no "status" field, for example
# 409 idempotency_key_reused (the key was used with a different request).
err = body.get("error", {})
raise RuntimeError(f"HTTP {resp.status_code} {err.get('code')}: {err.get('message')}")
if body["status"] != "succeeded":
raise RuntimeError(f"OCR ended as {body['status']}: {body.get('provider_error')}")
print(body["extracted_text"])Creating a key: Navigate to Dashboard → API Keys and click New Key. Give it a descriptive label so you can identify it later.
Storing your key: Copy the key immediately — it's only shown once. Store it in a secret manager (AWS Secrets Manager, HashiCorp Vault, etc.) or as an environment variable. Never commit it to source control.
Rotating a key: Create a new key, update your services to use it, then revoke the old key from the dashboard. Zero-downtime rotation: both keys are valid until the old one is revoked.
Key scopes: Keys are currently full-access. Scoped read-only keys are on the roadmap.
# Store your API key as an environment variable
export OPENOCR_API_KEY="sk-ocr-your-api-key"
# Reference it in curl
curl -X POST https://api.open-ocr.com/v1/ocr \
-H "Authorization: Bearer $OPENOCR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"engine":"mistral-mistral-ocr-4-1","input":{"type":"url","url":"https://example.com/doc.png"}}'import os
import uuid
import requests
response = requests.post(
"https://api.open-ocr.com/v1/ocr",
headers={
"Authorization": f"Bearer {os.environ['OPENOCR_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"engine": "mistral-mistral-ocr-4-1",
"input": {"type": "url", "url": "https://example.com/doc.png"},
},
)
response.raise_for_status()
body = response.json()
assert body["status"] == "succeeded", body
print(body["extracted_text"])// Node.js 18+: load the key from the environment
const response = await fetch("https://api.open-ocr.com/v1/ocr", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENOCR_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
engine: "mistral-mistral-ocr-4-1",
input: { type: "url", url: "https://example.com/doc.png" },
}),
});
const result = await response.json();
if (result.status !== "succeeded") throw new Error("OCR ended as " + (result.status ?? result.error?.code)); // error envelopes have no status
if (!response.ok) throw new Error(result.error?.message ?? `HTTP ${response.status}`);
console.log(result.extracted_text);Official OpenOCR SDKs are coming soon; these examples call the REST API directly.
List all available OCR engines. No request body required.
curl https://api.open-ocr.com/v1/engines \
-H "Authorization: Bearer $OPENOCR_API_KEY"
# 200 OK — array of engine objects
# [
# {
# "id": "mistral-mistral-ocr-4-1",
# "name": "Mistral OCR 4.1",
# "provider": "mistral",
# "capabilities": ["printed", "handwritten", "pdf"],
# "price_per_page": 0.005
# },
# ...
# ]| Constraint | Limit | Notes |
|---|---|---|
| Max request body | 10 MB | Applies to base64 payloads. Use URL input for larger files. |
| Supported image formats | PNG, JPEG, WEBP, GIF, TIFF, BMP | TIFF/BMP require single-page engines. |
| PDF support | Yes (multi-page) | Requires an engine with pdf capability. See /v1/engines. |
| Max pages (PDF) | Engine-dependent | Tesseract: 200 pages. Vision LLMs: 5–20 pages. |
| URL input | Publicly accessible URL | Supports http:// and https://. No auth-gated URLs. |
| Base64 input | Raw base64 string, no data: prefix | Strip data:image/png;base64, before sending. |
# Upload a local file via base64
IMG_B64=$(base64 -w 0 /path/to/invoice.png)
curl -X POST https://api.open-ocr.com/v1/ocr \
-H "Authorization: Bearer $OPENOCR_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d "{
\"engine\": \"mistral-mistral-ocr-4-1\",
\"input\": {
\"type\": \"base64\",
\"data_base64\": \"$IMG_B64\",
\"mime_type\": \"image/png\"
}
}"import base64, requests, os, uuid
with open("invoice.png", "rb") as f:
b64 = base64.b64encode(f.read()).decode()
resp = requests.post(
"https://api.open-ocr.com/v1/ocr",
headers={
"Authorization": f"Bearer {os.environ['OPENOCR_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"engine": "mistral-mistral-ocr-4-1",
"input": {"type": "base64", "data_base64": b64, "mime_type": "image/png"},
},
timeout=60,
)
resp.raise_for_status() # an error envelope has no "status" field
body = resp.json()
assert body["status"] == "succeeded", body
print(body["extracted_text"])