Loading...
Loading...
A connection timeout does not necessarily mean OCR failed. Before submitting again, check the original request and recover its saved result. Use its request ID, or replay the identical submission with the same Idempotency-Key if you never received an ID.
All API errors return a consistent JSON envelope with an HTTP status code, error code, and human-readable message.
Every error response follows this structure:
{
"error": {
"code": "invalid_request",
"message": "Idempotency-Key header is required"
}
}Branch on error.code, not on the message text. The envelope has no type field, and errors do not always carry a request_id. Rate-limit (429) bodies add a top-level retry_after_seconds.
| Status | Code | Description |
|---|---|---|
| 400 | invalid_request | Missing or malformed request body, invalid engine ID, or unsupported input type. |
| 400 | unknown_engine | The engine ID is not in the catalog. The response carries a suggestions array of close matches. See GET /v1/engines for valid IDs. |
| 401 | authentication_error | Missing or invalid API key. Check your Authorization header. |
| 402 | insufficient_balance | Your account balance is too low for this request. Add credits to continue. |
| 404 | not_found | The requested resource (engine, request ID) does not exist. |
| 422 | unprocessable_entity | The input was received but could not be processed (e.g., corrupt image, unsupported format). |
| 429 | rate_limited | Too many requests. Check X-RateLimit-* headers for limits and reset time. |
| 500 | internal_error | Something went wrong on our end. A failure on our side is never charged. Retry with the same Idempotency-Key. |
| 502 | provider_error | The OCR engine returned an error. If the engine never ran, you are not charged. If it ran and then failed, you are charged the provider's cost for that attempt at cost, including costs above the initial estimate. See Billing on Errors. |
| 409 | idempotency_key_reused | This Idempotency-Key was already used with a different request. Use a new key for a new document. The comparison is on the effective request (decoded base64 bytes, resolved engine, defaults applied), so different base64 line breaks or omitted default fields still replay the original result. A request first stored before 2026-09-29 has no fingerprint and replays even a changed request. Replayed text is only available while OCR history is retained; with history off keep the first response. |
| 413 | payload_too_large | Request body exceeds the 10 MB limit. Reduce image resolution or split into multiple requests. |
| 503 | engine_unavailable | The selected engine is temporarily offline. The request never reached an engine, so it is not charged. Try a different engine or retry later. |
Some engines decline real, readable pages. Google Gemini's RECITATION filter, for example, blocks output that matches published or template text (a printed declaration, standard report wording). The response has status: "refunded" and provider_error gives the engine's reason. Sending the page to the same engine again gives the same result.
To have OpenOCR retry automatically, add "fallback": "similar". If the engine refuses, returns no text, or hits a temporary error or timeout, the request is retried 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. It is off by default, and a refusal without it names the engine it would use.
{
"engine": "google-gemini-3.1-flash-lite",
"input": { "type": "url", "url": "https://example.com/page.jpg" },
"fallback": "similar"
}When the retry answers, engine is the engine that produced the text, warning says why the first engine was skipped, and metadata.fallback records both. If no comparable engine qualifies, provider_error says so and you are refunded as usual. A file no engine can read (corrupt or unsupported) is not retried.
The dashboard usage endpoint GET /api/usagenow prefers Authorization: Bearer <api_key>. Query-string auth via api_key=... is a deprecated fallback for older clients and should be removed from integrations.
# Preferred
curl "https://open-ocr.com/api/usage?user_id=YOUR_USER_ID" \
-H "Authorization: Bearer sk-ocr-your-api-key"
# Deprecated fallback (migration only)
# curl "https://open-ocr.com/api/usage?user_id=YOUR_USER_ID&api_key=sk-ocr-your-api-key"Why migrate: query parameters can leak into proxy logs, browser history, analytics tools, and referrer headers.
Every API response includes rate limit information:
| Header | Description |
|---|---|
| X-RateLimit-Limit | Maximum requests allowed per window |
| X-RateLimit-Remaining | Requests remaining in current window |
| X-RateLimit-Reset | Unix timestamp when the window resets |
| Retry-After | Seconds to wait before retrying (only present on 429 responses) |
# Example rate limit headers:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 997
X-RateLimit-Reset: 1771920000Retry only transient failures: 429,500,502, and503. Do not retry 400/401/402/404/409/413/422 until you fix the request.
Retry-After if present.import random
import time
import uuid
import requests
TRANSIENT = {429, 500, 502, 503}
def ocr_with_retry(payload, api_key, idempotency_key, max_retries=3):
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 not in TRANSIENT:
return resp
if resp.status_code == 503 and resp.json().get("status") == "timeout":
return resp # stored timeout: the same key returns the same 503, so do not retry it
if attempt == max_retries:
return resp
if resp.status_code == 429 and resp.headers.get("Retry-After"):
delay = float(resp.headers["Retry-After"])
else:
delay = min(2 ** attempt, 10) + random.uniform(0, 0.25)
time.sleep(delay)
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, str(uuid.uuid4()))
# 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"])4xx errors mean the request must be corrected before retrying.
# 400 invalid_request: verify required fields + valid engine ID
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": "mistral-mistral-ocr-4-1",
"input": {"type": "url", "url": "https://example.com/doc.png"}
}'
# 401 authentication_error: use Authorization header, not query params
curl https://api.open-ocr.com/v1/usage -H "Authorization: Bearer sk-ocr-your-api-key"
# 402 insufficient_balance: check balance before running more OCR
curl https://api.open-ocr.com/v1/usage -H "Authorization: Bearer sk-ocr-your-api-key"
# 409 idempotency_key_reused: {"error":{"code":"idempotency_key_reused","message":"This Idempotency-Key was already used with a different request. Use a new key for a new document."}}
# Fix: generate a NEW key for every distinct document. Same key + same request replays the original result.# 413 payload_too_large: compress or resize before upload
convert input.png -resize 2000x2000\> -quality 85 output.jpg
# 422 unprocessable_entity: input is readable JSON but invalid/corrupt file
# Fix checklist:
# 1) Confirm MIME type matches actual file
# 2) Confirm base64 has no data: prefix
# 3) Try the same file via public URL to isolate encoding issues5xx errors are usually transient. Retry with the same idempotency key, then fall back to another engine.
API_KEY="sk-ocr-your-api-key"
IDEMPOTENCY_KEY="$(uuidgen)"
# First attempt on preferred engine
curl -X POST https://api.open-ocr.com/v1/ocr -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: $IDEMPOTENCY_KEY" -d '{"engine":"mistral-mistral-ocr-4-1","input":{"type":"url","url":"https://example.com/doc.png"}}'
# If 502/503 persists after retries, switch to a fallback engine with a NEW key
curl -X POST https://api.open-ocr.com/v1/ocr -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" -d '{"engine":"local-easyocr","input":{"type":"url","url":"https://example.com/doc.png"}}'One rule decides every case: calling a model through OpenOCR never costs you more than calling that provider directly would have. Everything below follows from it.
Invalid input, a bad API key, a rate limit, an engine that is unavailable or misconfigured, or any failure on our side — you pay nothing, and any provisional debit is refunded automatically. No model did any work, so there is nothing to bill.
This covers an engine that errored partway, timed out mid-generation, or returned output you cannot use. The provider bills us for the work their model did, and we pass that through at cost, with no markup. The initial estimate is not a billing cap. Every attempt the provider bills is included, even when a call fails or returns no text.
If the provider has not reported a cost, billing stays pending. Any later verified provider charge is debited once. We never guess a charge. The charge is the cost_debited field of the original POST response (and cost_usd in the webhook). GET /v1/ocr/{request_id} repeats it only while OCR history is enabled; with history off it returns 410 result_not_retained. If credit is insufficient, the amount stays owed and is collected after a top-up; further token-priced calls are blocked until it is covered.
Token-priced requests use the engine's published token rates and provider fees, with usage from all attempts included. The initial debit is an estimate. Flat-priced engines continue to charge per page.
You send one page to an engine quoted at $0.002 per page. The model generates most of a response, then the request times out before it returns usable text. The provider bills us $0.0004 for the tokens it produced. You are charged $0.0004 — not the $0.002 quote, and not zero. Had the same engine failed before the model started, or had we no provider charge on record for it, you would be charged $0.00.
On a failed request, cost_debited is the amount actually passed through — 0.0 when nothing is owed, and non-zero when the engine ran. You never have to infer the charge from your balance, and you do not need to watch for a new status: a charged failure reports the same status and error code it would have reported if it had cost you nothing, with provider_error describing what went wrong.