FastOCR API
Upload a PDF or an image. Poll for completion. Download the extracted text or a searchable PDF.
20 free pages a month, then Page Packs from $4.99 for 500 pages.
What is the FastOCR REST API?
The FastOCR REST API extracts text from scanned and digital PDFs and from images in more than 100 languages. It runs an asynchronous five-step flow. Create a document. Upload the file to a presigned S3 URL. Start processing. Poll the document status. Download the output as plain text, or as a searchable PDF with an embedded text layer. It accepts PDF files up to 1 GiB, and PNG, JPG, JPEG, GIF, WebP and BMP images up to 20 MiB. An image counts as one page. Any signed-in FastOCR account can create an API key and start right away. The Free plan includes 20 free pages per month, shared with PDFs on the website. Paid plans share their Document OCR allowance with the API too. You buy more pages as Page Packs on the pricing page. Page Packs work on the website too.
Docs menu
On this page
Quickstart
Create an API key
Sign in and create a key in the developer console. You accept the API terms with your first key. The Free plan includes 20 free pages per month, shared with PDFs on the website. Paid plans share their Document OCR allowance with the API too. Every example reads the key from
FASTOCR_API_KEY.Terminalbashpip install fastocr-sdk export FASTOCR_API_KEY="your_api_key"The first line installs the Python SDK,
fastocr-sdk. It is not related to thefastocrpackage on PyPI. Node.js 18+ and cURL need nothing extra.Run the snippet
Each tab extracts the text of
scan.pdfand saves or prints it.Pythonfrom fastocr_sdk import FastOCR # Reads the key from the FASTOCR_API_KEY environment variable. with FastOCR() as client: text = client.extract_text("scan.pdf") print(text)// Node.js 18+, no dependencies. Save as quickstart.mjs and run: node quickstart.mjs import { readFile, writeFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; const BASE = 'https://api.fastocr.org'; const auth = { Authorization: `Bearer ${process.env.FASTOCR_API_KEY}` }; async function api(path, init = {}) { const response = await fetch(BASE + path, { ...init, headers: { ...auth, ...init.headers } }); const body = await response.json(); if (!response.ok) throw new Error(`${response.status} ${body.error?.code ?? body.message}`); return body; } const pdf = await readFile('scan.pdf'); // 1. Create a document const created = await api('/v1/documents', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID() }, body: JSON.stringify({ filename: 'scan.pdf', size_bytes: pdf.length }), }); // 2. Upload the file to the presigned URL const upload = await fetch(created.upload_url, { method: 'PUT', headers: { 'Content-Type': 'application/pdf' }, body: pdf, }); if (!upload.ok) throw new Error(`Upload failed: ${upload.status}`); // 3. Start processing await api(`/v1/documents/${created.id}/start`, { method: 'POST' }); // 4. Poll until the status is completed, partial, or failed let document; do { await new Promise((resolve) => setTimeout(resolve, 2000)); document = await api(`/v1/documents/${created.id}`); } while (!['completed', 'partial', 'failed'].includes(document.status)); if (document.status === 'failed') throw new Error(document.error.code); // 5. Get the download link, then download the text const output = await api(`/v1/documents/${created.id}/output?format=text`); await writeFile('result.txt', await (await fetch(output.url)).text()); console.log(document.status, document.pages_billed);export FASTOCR_API_KEY="your_api_key" # 1. Create a document RESPONSE=$(curl -sS --fail-with-body -X POST https://api.fastocr.org/v1/documents \ -H "Authorization: Bearer $FASTOCR_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"filename":"scan.pdf","size_bytes":'$(wc -c < scan.pdf | tr -d ' ')'}') DOCUMENT_ID=$(echo "$RESPONSE" | jq -r .id) UPLOAD_URL=$(echo "$RESPONSE" | jq -r .upload_url) # 2. Upload the file to the presigned URL curl -sS --fail-with-body -X PUT "$UPLOAD_URL" \ -H "Content-Type: application/pdf" \ --data-binary @scan.pdf # 3. Start processing curl -sS --fail-with-body -X POST https://api.fastocr.org/v1/documents/$DOCUMENT_ID/start \ -H "Authorization: Bearer $FASTOCR_API_KEY" # 4. Poll until the status is completed, partial, or failed while true; do BODY=$(curl -sS --fail-with-body https://api.fastocr.org/v1/documents/$DOCUMENT_ID \ -H "Authorization: Bearer $FASTOCR_API_KEY") || exit 1 STATUS=$(echo "$BODY" | jq -r .status) case "$STATUS" in completed|partial|failed) break;; esac sleep 2 done echo "Final status: $STATUS" # 5. Get the download link, then download the text (a failed document has no output) RESULT_URL=$(curl -sS --fail-with-body "https://api.fastocr.org/v1/documents/$DOCUMENT_ID/output?format=text" \ -H "Authorization: Bearer $FASTOCR_API_KEY" | jq -r .url) curl -sS --fail-with-body "$RESULT_URL" -o result.txtRead the result
The Node.js and HTTP tabs poll
GET /v1/documents/{id}until the document finishes. A completed document looks like this:200 OKJSON{ "id": "3f9c2b1e-7d54-4a8e-9b0a-5c1e8d2f6a47", "status": "completed", "external_id": "INV-1001", "pages_billed": 10, "pages_total": 10, "pages_processed": 10, "is_truncated": false, "outputs": { "text": { "available": true }, "pdf": { "available": true } } }Then
GET /v1/documents/{id}/output?format=text(orformat=pdf) returns a download link that works for one hour. The Document OCR reference describes each request.
Packages
Prefer a package to raw HTTP? These call the same API with the same key.
Getting access
Any signed-in FastOCR account can create an API key and start right away. The Free plan includes 20 free pages per month, shared with PDFs on the website. Paid plans share their Document OCR allowance with the API too.
- Sign in and create an API key in the developer console. You accept the API terms with your first key.
- Run the quickstart with your key in FASTOCR_API_KEY.
- When you need more document pages, buy a Page Pack on the pricing page.
Pack sizes and rates are on the pricing page. A document that runs out of pages fails with buy_pages. After you buy pages, call /start again on the same document.
API
The Document OCR API has five steps. Create a document, upload the PDF or image, start processing, poll for completion, and download the result.
Document OCR
Upload a PDF or an image, run OCR, and get extracted text or a searchable PDF. The API supports 100+ languages.
View referenceAuthentication
Every request sends your API key in the Authorization header as Bearer <key>. Create API keys in the developer console. Each API key starts with fok_live_.
from fastocr_sdk import FastOCR
client = FastOCR(api_key="fok_live_abc123...")const response = await fetch('https://api.fastocr.org/v1/documents', {
headers: { Authorization: 'Bearer fok_live_abc123...' },
});curl https://api.fastocr.org/v1/documents \
-H "Authorization: Bearer fok_live_abc123..."The API gateway returns authentication failures before the request reaches the API.
- The body always has a
message. On newer deployments it also has anerrorobject withtype: "gateway_error". - A request with no
Authorizationheader returns401(codeunauthorized). - A header with an unknown, malformed, or revoked API key returns 403, not 401 (code
access_denied). - If
erroris absent, use the HTTP status andmessage. - The gateway caches API key checks for up to 60 seconds. A revoked API key can keep working for that time.
Pagination
List endpoints return one page of results and an opaque cursor. Send the cursor back to get the next page.
| Parameter | Default | Description |
|---|---|---|
| Parameterlimit | Default20 | DescriptionAn integer from 1 to 100. It sets the number of items on each page. |
| Parametercursor | Default— | DescriptionAn opaque string. Use next_cursor from the previous response. |
| Parameterstatus | Default— | DescriptionDocument list only. One of awaiting_upload, processing, completed, partial, or failed. |
| Parameterupdated_since | Default— | DescriptionReturns documents whose status changed at or after this time. Send an ISO 8601 date or datetime, for example 2026-10-01T00:00:00Z. A value with no offset is UTC. |
from fastocr_sdk import FastOCR
with FastOCR() as client:
cursor = None
while True:
page = client.documents.list(limit=100, cursor=cursor)
for document in page.documents:
print(document.id, document.status)
cursor = page.next_cursor
if cursor is None:
breakconst headers = { Authorization: `Bearer ${process.env.FASTOCR_API_KEY}` };
let cursor = null;
do {
const params = new URLSearchParams({ limit: '100' });
if (cursor) params.set('cursor', cursor);
const response = await fetch(`https://api.fastocr.org/v1/documents?${params}`, { headers });
const page = await response.json();
for (const document of page.documents) console.log(document.id, document.status);
cursor = page.next_cursor;
} while (cursor);curl -s -G "https://api.fastocr.org/v1/documents" \
-H "Authorization: Bearer $FASTOCR_API_KEY" \
--data-urlencode "limit=100" \
--data-urlencode "cursor=<next_cursor>"The API lists documents newest first in the documents array. When next_cursor is null, there are no more pages.
Each page holds up to limit documents that match your filters. On a very long list, a page can be shorter while next_cursor is still set. Follow the cursor until it is null. A cursor expires 24 hours after the API issues it.
The updated_since filter
updated_since accepts an ISO 8601 date or datetime. The value can end in Z, have any offset, or have no offset. The API reads a value with no offset as UTC.
A document matches when its last status change is at or after the value. If the status never changed, the creation time applies. A date alone, such as 2026-10-01, works.
The API does not reject a value that is not a date. It compares that value as text.
Idempotency
Create endpoints need an Idempotency-Key header. The API matches the header name without regard to case.
A client can retry a request with the same key and body within about 24 hours. The API then returns the original 201. If the original upload_url expired while the document is still awaiting_upload, the response carries a fresh upload_url and expires_at.
The API compares the body on filename, size_bytes, sha256, and external_id. If the body is different, the API returns 409 idempotency_key_conflict.
from fastocr_sdk import FastOCR
with FastOCR() as client:
created = client.documents.create(
"scan.pdf", 1048576, idempotency_key="invoice-2026-10-001"
)const response = await fetch('https://api.fastocr.org/v1/documents', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FASTOCR_API_KEY}`,
'Idempotency-Key': 'invoice-2026-10-001',
'Content-Type': 'application/json',
},
body: JSON.stringify({ filename: 'scan.pdf', size_bytes: 1048576 }),
});curl -X POST https://api.fastocr.org/v1/documents \
-H "Authorization: Bearer $FASTOCR_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"filename":"scan.pdf","size_bytes":1048576}'Limits
- Files: a PDF up to 1 GiB (
1,073,741,824bytes), or a PNG, JPG, JPEG, GIF, WebP or BMP image up to 20 MiB (20,971,520bytes). An image counts as one page. - Concurrency: an account can process 2 documents at the same time. An account with an API contract, or with 10,000 or more pages bought, can process 10. Very large documents may take longer to start.
A/startrequest over the limit returns429 concurrency_limitwith aRetry-Afterheader. The document staysawaiting_upload. Wait theRetry-Afterseconds, then callPOST /startagain. - Request rate: each API key and each source address has its own request rate limit on
/v1, in addition to the shared throttle of the API gateway. A request above a limit returns429with the same body shape as the authentication errors (codethrottledwhen the gateway produced it; a per-key or per-address429hasmessageonly and aRetry-Afterheader). Wait theRetry-Afterseconds, or back off, then retry. - Pages: The Free plan includes 20 free pages per month, shared with PDFs on the website. Paid plans share their Document OCR allowance with the API too. Page Packs add pages to the same balance, and they work on the website too. Check what is left with
GET /v1/balance.
The API reserves pages when it knows the real page count. This is after/startreturns. If fewer pages remain than the PDF has, the document finishes aspartial. The API then processes the first pages only. If no pages remain, the document fails withbuy_pages.
Errors
A document that fails during processing returns HTTP 200 with the status failed and an error object. The HTTP status describes the request. It does not describe the work.
- Match on
code. Do not match onmessage. FastOCR can change messages without notice. - FastOCR will add new codes. Treat an unknown code as
processing_failed. - Only
buy_pageslets you start a failed document again. A document that runs out of pages fails with buy_pages. After you buy pages, call /start again on the same document.
Document failure codes
{
"id": "3f9c2b1e-7d54-4a8e-9b0a-5c1e8d2f6a47",
"status": "failed",
"external_id": null,
"error": {
"type": "processing_error",
"code": "timeout",
"message": "This file took too long to process. Please try again.",
"retryable": true
}
}| Code | Type | When | Retryable |
|---|---|---|---|
| Codebuy_pages | Typeinsufficient_credits | WhenThe account has no pages left. | RetryableNo. Buy a Page Pack. Then call POST /start again on the same document. |
| Codepassword_protected | Typeinvalid_request | WhenThe file has a password. | RetryableNo |
| Codeunreadable_document | Typeinvalid_request | WhenThe API could not read the PDF. | RetryableNo |
| Codeno_text_detected | Typeinvalid_request | WhenThe API found no readable text in the file. | RetryableNo |
| Codecorrupted_file | Typeinvalid_request | WhenThe file has damage, or the API does not support it. | RetryableNo |
| Codeimage_too_large | Typeinvalid_request | WhenThe image is too large or has too many pixels to read. Upload a smaller copy. | RetryableNo |
| Codetoo_many_pages | Typeinvalid_request | WhenThe file has more pages than one document may hold. | RetryableNo |
| Codeempty_file | Typeinvalid_request | WhenThe file has no pages. | RetryableNo |
| Codetimeout | Typeprocessing_error | WhenProcessing took too long. | RetryableYes |
| Codeout_of_memory | Typeprocessing_error | WhenProcessing used all available memory. | RetryableYes |
| Codeprocessing_failed | Typeprocessing_error | WhenAny other failure. | RetryableYes |
Request errors
A rejected request returns a 4xx or 5xx status. The body has an error object with type, code, message, and request_id. Include the request_id when you contact support.
The type is invalid_request, not_found, conflict, rate_limit, or error.
{
"error": {
"type": "invalid_request",
"code": "unsupported_file_type",
"message": "Only PDF, PNG, JPG, JPEG, GIF, WebP and BMP files are supported",
"request_id": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef"
}
}| HTTP | Code | Type | When |
|---|---|---|---|
| HTTP400 | Codeinvalid_json | Typeinvalid_request | WhenThe request body is not valid JSON, or it is not a JSON object. |
| HTTP400 | Codemissing_idempotency_key | Typeinvalid_request | WhenA create request has no Idempotency-Key header. |
| HTTP400 | Codeinvalid_idempotency_key | Typeinvalid_request | WhenThe Idempotency-Key header is longer than 255 characters. |
| HTTP400 | Codeinvalid_filename | Typeinvalid_request | Whenfilename is missing, is not a string, or is longer than 255 characters. |
| HTTP400 | Codeunsupported_file_type | Typeinvalid_request | Whenfilename does not end in .pdf, .png, .jpg, .jpeg, .gif, .webp or .bmp. |
| HTTP400 | Codeinvalid_size_bytes | Typeinvalid_request | Whensize_bytes is not a positive integer. |
| HTTP400 | Codefile_too_large | Typeinvalid_request | Whensize_bytes is more than 1,073,741,824 (1 GiB) for a PDF, or more than 20,971,520 (20 MiB) for an image. |
| HTTP400 | Codeinvalid_sha256 | Typeinvalid_request | Whensha256 is not the base64 encoding of the 32-byte digest. |
| HTTP400 | Codeinvalid_external_id | Typeinvalid_request | Whenexternal_id is not a string of at most 256 characters. |
| HTTP400 | Codeupload_not_found | Typeinvalid_request | WhenA start request came before the file upload. |
| HTTP400 | Codesize_mismatch | Typeinvalid_request | WhenThe size of the uploaded file is different from size_bytes. |
| HTTP400 | Codeinvalid_format | Typeinvalid_request | Whenformat is not text or pdf. |
| HTTP400 | Codeinvalid_limit | Typeinvalid_request | Whenlimit is not an integer from 1 to 100. |
| HTTP400 | Codeinvalid_status | Typeinvalid_request | Whenstatus is not a public document status. |
| HTTP400 | Codeinvalid_cursor | Typeinvalid_request | Whencursor has the wrong format, or it is more than 24 hours old. |
| HTTP404 | Codedocument_not_found | Typenot_found | WhenThis account has no such document. |
| HTTP404 | Codeoutput_unavailable | Typenot_found | WhenA client requested format=pdf for a document whose searchable PDF could not be generated. The extracted text is still available with format=text. |
| HTTP409 | Codeidempotency_key_conflict | Typeconflict | WhenA request used the key before with a different body. |
| HTTP409 | Codedocument_not_startable | Typeconflict | WhenThe document is completed, partial, or failed with a code other than buy_pages. |
| HTTP409 | Codedocument_not_ready | Typeconflict | WhenA client requested output before the document completed, or the document failed. |
| HTTP409 | Codedocument_processing | Typeconflict | WhenA client sent DELETE for a document that is still in flight. |
| HTTP429 | Codeconcurrency_limit | Typerate_limit | WhenToo many documents are in flight. Wait the Retry-After seconds (30), then call POST /start again. |
| HTTP500 | Codenot stable | Typeerror | WhenUnexpected server error. Retry with backoff. |
Gateway errors
The API gateway returns 401, 403, and 429 errors before the request reaches the API. The body has a message and, on newer deployments, an error object with type: "gateway_error". See Authentication. Treat an unknown code as a new condition for its HTTP status.
| HTTP | Code | Type | When |
|---|---|---|---|
| HTTP401 | Codeunauthorized | Typegateway_error | WhenThe request has no Authorization header. |
| HTTP403 | Codeaccess_denied | Typegateway_error | WhenThe API key is unknown, malformed, or revoked. |
| HTTP429 | Codethrottled | Typegateway_error | WhenThe request is above the per-key or per-address rate limit on /v1, or the shared throttle of the API gateway. A per-key or per-address 429 has message only and a Retry-After header. Wait, then retry. |
| HTTP403 | Codemissing_authentication_token | Typegateway_error | WhenThe route does not exist and the request has no Authorization header. With a Bearer API key, the same request returns 403 gateway_error. |
| HTTPOther | Codegateway_error | Typegateway_error | WhenAny other gateway condition. Use the HTTP status and message. |
Handle errors in Python
The SDK raises DocumentFailedError for a failed document and RateLimitError (with retry_after) for a 429. All SDK errors extend FastOCRError.
extract_text raises PartialResultError when the account runs out of pages partway through, so it never returns truncated text without telling you. The request itself succeeded, so PartialResultError has no status_code, and its code is partial_result. Pass allow_partial=True to receive the text of the processed pages instead.
from fastocr_sdk import (
DocumentFailedError,
FastOCR,
FastOCRError,
PartialResultError,
RateLimitError,
)
with FastOCR() as client:
try:
text = client.extract_text("scan.pdf")
except PartialResultError as error:
print(error.pages_processed, error.pages_total)
text = client.documents.get_text(error.document_id)
except DocumentFailedError as error:
print(error.code, error.retryable, error.document_id)
except RateLimitError as error:
print(error.retry_after)
except FastOCRError as error:
print(error.status_code, error.code, error.request_id)Versioning and compatibility
/v1 is additive-only. FastOCR adds fields. It never renames, retypes, or deletes a field. Status codes and existing error codes do not change.
New fields, new statuses, and new error codes may appear. Clients must ignore unknown fields. Clients must treat an unknown error code as processing_failed (document errors) or by its HTTP status (request errors).
A breaking change would ship as /v2. /v1 would stay available for at least 6 months after that.
Ready to start?
Create an API key and run the quickstart. 20 free pages a month, then Page Packs from $4.99 for 500 pages.