Docs
One endpoint turns a PDF or image into markdown, one page per model call, every page at once. Get an API key from the dashboard, then send a document.
Quickstart
curl https://www.opendocrouter.ai/v1/parse \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "google/gemini-3.8-flash-low",
"document": { "url": "https://arxiv.org/pdf/1706.03762" }
}'Check the response's status: a partial result is missing pages. The Python and TypeScript examples retry failed pages once and stop if any still fail, rather than returning an incomplete document.
Request
POST /v1/parse with the key in Authorization: Bearer <key>.
| model | Required. A model ID from the models page. |
| document | { "url": "https://..." }, { "data": "<base64>", "mime_type": "application/pdf" | "image/png" | "image/jpeg" }, or { "upload_id": "..." } from POST /v1/uploads. |
| pages | Optional, 1-based: "1-3,7". All pages when left out. |
| cache | Optional, default false. When true, pages that work are kept for 24 hours, encrypted, and the same page of the same document with the same model comes back free, marked cached. When false, nothing is kept. |
Response
Up to 50 pages, always 200 once pages ran, with a status per page. completed means every page worked, partial means some did, and failed means none did. A partial response is an incomplete document: check each page's status before using it. There's no joined markdown; join pages[].markdown yourself.
{
"id": "0f433d25-c2d5-40df-960f-1cb4c5ddc415",
"status": "partial",
"model": "google/gemini-3.8-flash-low",
"model_version": "2026-09-25",
"price_version": "2026-09-25",
"pages": [
{ "page": 1, "status": "ok", "markdown": "# Attention Is All You Need ...", "cached": false,
"usage": { "input_tokens": 757, "output_tokens": 811 }, "charge_usd": 0.00397 },
{ "page": 2, "status": "error",
"error": { "code": "timeout", "message": "The page didn't finish before the request deadline" },
"charge_usd": 0 }
],
"usage": { "input_tokens": 757, "output_tokens": 811 },
"charge_usd": 0.00397
}Every response carries an X-Request-Id header, which also appears in your usage log. GET /v1/requests/<id> returns what happened to a request without its markdown: its status, the hold and whether it was released, the charge, and each page's status, error, tokens and charge.
Large documents
A request over 50 pages (up to 500) runs as a job. It returns 202 with the usual response shape, status: "processing", no pages yet and a poll_url. GET /v1/parse/<id> returns the same shape with pages_done while it runs, then the final status and results, up to 100 pages at a time. When has_more is true, fetch the next pages with ?cursor=<next_cursor>.
Files over about 3 MB can go by URL, or by upload: POST /v1/uploads returns a URL to PUT the file to, valid for an hour, and each upload is used once and then deleted.
# 1. Get a one-time upload URL, and PUT the file to it (up to 50 MB)
curl -X POST https://www.opendocrouter.ai/v1/uploads -H "Authorization: Bearer $API_KEY"
# { "upload_id": "9b2c...", "upload_url": "https://...", "max_bytes": 52428800, ... }
curl -T report.pdf "<upload_url>"
# 2. Parse it. Over 50 pages, the answer is 202 with status "processing"
curl https://www.opendocrouter.ai/v1/parse \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "model": "google/gemini-3.8-flash-low", "document": { "upload_id": "9b2c..." } }'
# 3. Poll until status isn't "processing", then page through the results
curl "https://www.opendocrouter.ai/v1/parse/<id>" -H "Authorization: Bearer $API_KEY"
curl "https://www.opendocrouter.ai/v1/parse/<id>?cursor=<next_cursor>" -H "Authorization: Bearer $API_KEY"Job results are kept encrypted for 24 hours after the job ends, then deleted. DELETE /v1/parse/<id> deletes them now; on a running job it also stops the pages that haven't started, and you're charged only for pages that ran.
Retrying pages
Each page is retried once for you on timeouts, rate limits, provider errors and capacity, and on answers that loop or aren't a transcription. A page error has a code, a message, and a reason when the provider gave one, such as RECITATION, SAFETY, refusal, max_tokens or repetition. If a page still fails, send the request again with just those pages, as the quickstart does:
failed = [n for n, page in pages.items() if page["status"] == "error"]
if failed:
retry = parse({**body, "pages": ",".join(map(str, failed))})
pages.update({page["page"]: page for page in retry["pages"]})| Page error | Meaning | Worth retrying |
|---|---|---|
| timeout | Still running at the deadline, or the provider timed out | Yes |
| rate_limited | The provider rate-limited us | Yes |
| provider_error | The provider returned an error; its message is included | Yes |
| at_capacity | No capacity came free within 10 seconds | Yes |
| output_truncated | The page hit the model's output limit | Rarely helps |
| content_filtered | The provider blocked the output, or the model declined (reason says which) | Rarely helps |
| repetitive_output | The model got stuck repeating text, twice | Sometimes |
| invalid_output | The model answered without transcribing the page, twice | Sometimes |
| empty_output | The model returned no text | Sometimes |
| response_too_large | The page didn't fit in the 4.5 MB response | Yes, on its own |
| unreadable_page | The PDF opened, but this page couldn't be read | No |
| not_processed | The job ended (it failed or was deleted) before this page ran | Yes |
Results of requests up to 50 pages aren't stored. If your connection drops after one finished, it was still charged, and a retry is a new, charged request.
Errors
A request that can't run returns { "error": { "code", "message" } } and is never charged.
| Status | Code | When |
|---|---|---|
| 400 | invalid_request | Bad JSON, unknown model, or a bad pages value |
| 400 | url_not_allowed | The URL isn't public HTTPS on the default port, or redirects somewhere that isn't |
| 401 | unauthorized | Missing, unknown or revoked API key |
| 402 | insufficient_credits | Available credit is below the most this request could cost. Includes required_usd and available_usd |
| 403 | account_paused | The account is paused; contact us |
| 404 | not_found | No such request, job or upload on this account, or the upload was used |
| 410 | gone | A job's results were deleted, by you or 24 hours after it finished |
| 413 | too_large | Over 500 pages, a request body over 4 MB, or a file over 50 MB |
| 415 | unsupported_type | Not a PDF, PNG or JPEG |
| 422 | unreadable_document | An encrypted or corrupt PDF, or a URL that couldn't be fetched |
| 429 | rate_limited | More than 10 requests, or 5 jobs over 50 pages, running at once on the account; or the provider rate-limited every page (never charged). Includes Retry-After |
| 503 | at_capacity | The model is at capacity or paused. Includes Retry-After |
| 503 | model_starting | A model we host couldn't start in time. Retry after Retry-After (a few minutes) |
Billing
Credit is prepaid: top up from $10 in the dashboard. Each page is charged for the tokens it used at the model's price, which includes a small premium over the provider's price. Failed pages, blank pages and cached pages are free; a blank page comes back as ok with empty markdown.
To start, a request needs the most it could possibly cost available (the model's most-per-page price times its pages). That amount is held while it runs; when it finishes, you're charged what it used and the rest is released.
Limits
| Pages | Up to 50 per request answered directly; 51 to 500 run as a job. |
| Inline files | Request body up to 4 MB (a file of about 3 MB). |
| Files by URL | Up to 50 MB, public HTTPS only. |
| Uploads | Up to 50 MB, used within an hour. |
| Concurrency | 10 requests and 5 jobs running at once per account. An account's pages use at most a quarter of a model's capacity at once; pages past that wait for the account's earlier pages. |
| Time | Pages still running after 270 seconds come back as timeout. A job waits up to 30 minutes for provider capacity. |