OCR
API reference for OCR document processing endpoints
OCR
Process documents with AI-powered OCR and structured data extraction.
POST /v1/process
Upload a document for asynchronous processing. Returns a job ID immediately.
Endpoint
POST https://api.deepread.tech/v1/process
Authentication
Include your API key in the X-API-Key header:
X-API-Key: YOUR_API_KEY
Request
Headers
| Header | Required | Description |
|---|---|---|
X-API-Key | Yes | Your API key |
Content-Type | Yes | multipart/form-data |
Request Body (Multipart)
| Parameter | Type | Required | Description |
|---|---|---|---|
file | file | Yes | Document file to upload. Which types your plan accepts: File types below |
pipeline | string | No | Engine: extract (one OCR pass; sold as Extract) or deep-extract (two passes reconciled by an LLM judge, rotation correction, a verification read; sold as Deep Extract). If omitted, Free and Standard accounts run extract and other plans deep-extract. The older names are still accepted and echoed back: fast is extract, standard is deep-extract, searchable is deep-extract + searchable_pdf=true. |
speed | string | No | fast or standard (default). fast with deep-extract is about 2x faster at the same accuracy and is sold as Deep Extract Fast ($90 per 1,000 pages; product is deep-extract-fast). fast with extract costs nothing extra (Extract is already fast; the Extract price, $15 per 1,000 pages): a short document takes the same time, long documents with big tables (40+ pages) are about 2x faster. Standard and Enterprise plans only (Free gets 402). Any other value returns 400. Not the same as the legacy pipeline=fast |
searchable_pdf | string | No | Set true to also produce a searchable PDF. deep-extract only, Enterprise plans. Default: false |
incognito | string | No | Set true for incognito processing: your document is encrypted with a single-use key before it reaches storage and crypto-shredded (file deleted and key destroyed) the moment the job completes or fails. Disables the preview link and cannot be combined with searchable_pdf (both require stored files). Extracted results are unaffected. Enterprise plans; other plans get 402. Default: false |
retention_days | string | No | Retention add-on: delete everything about this job — the document, preview artifacts and the extracted results — this many days after submission (integer, 1–365). The job is fully usable until then; afterwards GET /v1/jobs/{id} reports data_deleted_at with no content and the preview link answers 410. Combines with incognito. Enterprise plans; other plans get 402. Default: keep indefinitely |
version | string | No | Pipeline version for reproducibility (e.g., 1.0.0). If not specified, uses current default |
schema | string | No | JSON Schema for the fields to extract. Without it (or blueprint_id) the job returns the document as Markdown with layout and bounding boxes; with it, the fields too. The product does not change. Never together with blueprint_id |
blueprint_id | string | No | ID of a blueprint the optimizer tuned against your own labelled documents; use it instead of schema, never both. Standard and Enterprise plans; on Free it is refused with 402 |
webhook_url | string | No | URL to receive job completion notifications. Must be a public https:// or http:// URL: no localhost, private or internal addresses. Standard and Enterprise plans; sending it on Free returns 402 naming the plan. See Webhooks |
reviewer_email | string | No | Have a person check the values in DeepRead's review screen before you use them. One email, or a team as several emails separated by commas (up to 10): any of them can review it, the first to complete it does. Each signs in with a code emailed to them. Turns preview on; cannot be combined with incognito. See Human review |
review_due_hours | string | No | With reviewer_email: the review is due this many hours after submission (whole number, 1–8760). When it passes, review.overdue is sent and the review stays open. Default: no deadline |
review_expiry_days | string | No | With reviewer_email: an unfinished review closes this many days after submission (whole number, 1–365, never later than retention_days). review.expired is sent with the unreviewed data. Default: 30 |
idempotency_key | string | No | Up to 255 characters, unique per account (case and surrounding whitespace ignored). A retry with the same key returns the job the first request created, with 200; the same key with a different file or options is refused with 409. See Retrying safely |
preview | boolean | No | Generate a shareable visual preview: page images plus a public preview link (default: false). Anyone with the link can open it, so it is created only when you ask for it. Works on every plan. For click-to-highlight in the preview, send include_markers=true as well. Images are always generated during processing (PDF to PNG conversion); this flag controls whether they're stored and exposed via public URLs. |
per_page | boolean | No | Include a per-page breakdown in results (default: false, may be auto-enabled when a preview is generated) |
include_images | boolean | No | Deprecated — alias for preview. Still honored for back-compat; new integrations should use preview. |
include_markers | boolean | No | Set true to return bounding boxes: layout blocks under grounding, and location.bounding_box on each extracted field (default: false). Works on both engines and on every plan, Free included. Do not rely on preview=true alone to return boxes. |
include_pages | boolean | No | Deprecated — alias for per_page. Still honored for back-compat; new integrations should use per_page. |
A schema is optional. Without one the job returns the document as Markdown with layout and bounding boxes; with a schema or a blueprint_id it returns your fields too. The product follows the engine: extract is Extract, deep-extract is Deep Extract, and deep-extract with speed=fast is Deep Extract Fast. Blueprints are schemas the optimizer has tuned against your own labelled documents. How much they help depends on your data — the optimizer measures the lift on documents it held back and reports it per run.
File types
| Plan | Accepted uploads |
|---|---|
| Free | PDF, PNG, JPEG |
| Standard | PDF, PNG, JPEG, TIFF (multi-page), WebP, BMP, GIF, DOCX, TXT |
| Enterprise | The Standard list, plus APNG, PSD, PCX, PPM, CUR, DCX, FTEX, PIXAR, DOC, DOTX, ODT, RTF, WPD, PPT, PPTX, ODP, HTML, CSV, XLSX, XLSM, XLS, XLTX, XLTM, ODS |
A type the plan does not accept is refused with 415 and a body naming the plan that has it. Everything that is not a PDF, PNG or JPEG is converted to PDF before the page count, and the converted page count is what is charged; spreadsheets paginate sheet by sheet, so wide sheets produce many pages. A file that cannot be converted is 400 with the reason. The job then runs on the PDF and reports it as such.
Example Request
curl -X POST https://api.deepread.tech/v1/process \ -H "X-API-Key: $DEEPREAD_API_KEY" \ -F "file=@invoice.pdf" \ -F 'schema={"type":"object","properties":{"invoice_number":{"type":"string","description":"The invoice number"},"total_amount":{"type":"number","description":"The total amount due"}}}' \ -F "webhook_url=https://your-app.com/webhooks/deepread"Fast mode
Deep Extract Fast: pipeline=deep-extract with speed=fast. About 2x faster, same accuracy, $90 per 1,000 pages against $35 for Deep Extract. Standard and Enterprise plans; a Free account gets 402. A 1–2 page document comes back in about 40 seconds instead of about 1.5 minutes; every timing is under How long it takes on the benchmarks page.
curl -X POST https://api.deepread.tech/v1/process \ -H "X-API-Key: YOUR_API_KEY" \ -F "file=@invoice.pdf" \ -F "pipeline=deep-extract" \ -F "speed=fast"
Response
Success Response (200 OK)
{ "id": "550e8400-e29b-41d4-a716-446655440000", "status": "queued" }
Response Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique job identifier (UUID) |
status | string | Initial job status (queued) |
retention_expires_at | string | Only when the request set retention_days: when the document, preview artifacts and results get deleted (ISO 8601) |
Job Completion
Job results are delivered via webhook (if webhook_url is provided) or can be retrieved using GET /v1/jobs/{job_id}. See Jobs & Webhooks for webhook payload structure.
If you don't use webhooks, or need to check status manually, use GET /v1/jobs/{job_id} to retrieve job status and results.
Status Codes
| Code | Description |
|---|---|
200 | Job created; with an idempotency_key seen before, the job that request created |
400 | Invalid request body, schema, or parameters; a file that could not be converted to PDF; a document over the plan's pages per document |
401 | Invalid or missing API key |
402 | The plan does not include a feature you sent, the credits do not cover the job, or the API key is at its monthly spend limit (key_limit_reached); the body names what is needed (see below) |
409 | The idempotency_key was used before with a different file or options |
413 | Document over the hard maximum (2,000 pages or 500 MB) |
415 | The plan does not accept this file type; the body names the plan that does |
429 | Submissions per minute, pages in flight, or the monthly page allowance exceeded; Retry-After says when to try again. See Rate Limits |
500 | Internal server error |
503 | The API key could not be checked because our database did not answer; retry after Retry-After (5 s). A bad key is 401 |
Every plan-gated feature refuses with the same 402 body, naming the plan that has it: webhook_url, blueprints and the optimizer from Standard; searchable_pdf, incognito, retention_days, PII redaction and form fill on Enterprise. required_plans lists every plan slug that has the feature, legacy ones included.
{ "detail": { "error": "Webhooks is not on the Free plan", "message": "Webhooks is available from the Standard plan. Visit /dashboard/billing to upgrade.", "feature": "webhooks", "current_plan": "free", "required_plans": ["standard", "enterprise"] } }
Retrying safely
Send idempotency_key with any submission you might retry after a timeout, a 429 or a dropped connection. The key is up to 255 characters and unique per account; case and surrounding whitespace are ignored. A retry with the same key returns the job the first request created, with 200 and its current status, instead of creating and charging a second job. The key belongs to one request: the same key with a different file or different options is refused with 409, never replayed.
curl -X POST https://api.deepread.tech/v1/process \ -H "X-API-Key: YOUR_API_KEY" \ -F "file=@invoice.pdf" \ -F "idempotency_key=invoice-0042-2026-09-17"
GET /v1/jobs/{job_id}
Retrieve the status and results of a processing job.
Endpoint
GET https://api.deepread.tech/v1/jobs/{job_id}
Authentication
X-API-Key: YOUR_API_KEY
Headers
| Header | Required | Description |
|---|---|---|
X-API-Key | Yes | Your API key |
Path Parameters
| Parameter | Type | Description |
|---|---|---|
job_id | UUID | The job ID returned from POST /v1/process |
Example Request
curl -X GET https://api.deepread.tech/v1/jobs/550e8400-e29b-41d4-a716-446655440000 \ -H "X-API-Key: $DEEPREAD_API_KEY"Response
Completed Job (200 OK)
Every response carries "schema_version": "dp02".
{ "id": "550e8400-e29b-41d4-a716-446655440000", "status": "completed", "schema_version": "dp02", "pipeline": "extract", "product": "extract", "created_at": "2025-01-01T12:00:00Z", "completed_at": "2025-01-01T12:15:00Z", "document": { "page_count": 1, "content": { "format": "markdown", "text": "# INVOICE #INV-12345\n\n**Vendor:** Acme Corp\n**Total:** $1,250.00\n..." }, "layout": { "version": "dp.lite.v1", "layout": { "blocks": [] } } }, "extraction": { "fields": [ { "key": "invoice_number", "value": "INV-12345", "needs_review": false, "location": { "page": 1 } }, { "key": "total_amount", "value": 1250.0, "needs_review": false, "location": { "page": 1 } } ] }, "review": { "needs_review": false, "quality_score": null, "fields_total": 2, "fields_needing_review": 0, "review_rate": 0.0, "flags": [] }, "artifacts": { "preview_url": "https://preview.deepread.tech/abc123token" } }
Failed Job (200 OK)
{ "id": "550e8400-e29b-41d4-a716-446655440000", "status": "failed", "created_at": "2025-01-01T12:00:00Z", "completed_at": "2025-01-01T12:15:00Z", "error": "Unable to extract required fields from document" }
Processing/Queued Job (200 OK)
{ "id": "550e8400-e29b-41d4-a716-446655440000", "status": "processing", "created_at": "2025-01-01T12:00:00Z" }
Response Fields
| Field | Type | Description |
|---|---|---|
id | string | Job identifier |
status | string | queued, processing, completed, or failed |
schema_version | string | Always "dp02" |
pipeline | string | Engine, as you named it: extract or deep-extract (or fast/standard if you still send those) |
product | string | What the job was sold as: extract, deep-extract or deep-extract-fast (parse on jobs from before October 2026) |
searchable_pdf | boolean | true when a searchable PDF was produced |
incognito | boolean | true when the job ran in incognito mode (the submitted document has been crypto-shredded) |
retention_expires_at | string | Present when retention_days was set: ISO 8601 moment the content stops being served and gets deleted. Stays visible (in the past) while stored objects are still being deleted |
data_deleted_at | string | Set once every stored object is confirmed deleted. From the deadline on document, extraction, pages and artifacts are absent and the preview link answers 410 |
created_at | string | ISO 8601 timestamp |
completed_at | string | ISO 8601 timestamp (when completed or failed) |
document | object | Document-level OCR output (when completed) |
document.page_count | number | Number of pages processed |
document.content.format | string | Text format, markdown |
document.content.text | string | Full OCR text as markdown (omitted when the text is offloaded, see text_url) |
document.content.text_url | string | URL to fetch full text (when text >1MB) |
document.content.text_preview | string | First characters (when text is offloaded) |
document.layout | object | Typed dp.lite.v1 structured layout (blocks + page-mappings), on either engine. (Replaces the old stringified json_string.) |
extraction.fields | array | Structured extraction (if a schema/blueprint was provided). A list of field objects; the same shape is used in pages[].fields |
extraction.fields[].key | string | Stable field identifier |
extraction.fields[].field_id | string | Stable id for repeated rows (e.g. line_items[0].amount), when the field is one of many |
extraction.fields[].value | any | Extracted value (scalar, object, or array for nested/tabular data) |
extraction.fields[].text | string | The value as it appears on the page, before normalization |
extraction.fields[].needs_review | boolean | Whether field requires human review |
extraction.fields[].review_reason | string | Why the field was flagged (when needs_review is true) |
extraction.fields[].validation | object | { passed, rule, expected, actual, message }, when a schema validation rule applies to the field |
extraction.fields[].location.page | number | Page number where field was found |
extraction.fields[].location.bounding_box | object | { x, y, width, height } as fractions of page width and height (0–1, origin top-left). Present when you sent include_markers=true and the field was located |
pages | array | Per-page breakdown (with per_page=true, and auto-enabled when a preview is generated) |
pages[].page_number | number | 1-based page number |
pages[].content.text | string | Page text as markdown |
pages[].fields | array | Fields found on that page, same shape as extraction.fields |
pages[].needs_review | boolean | Whether the page needs verification |
pages[].review_reason | string | Why the page was flagged |
grounding | array | Block-level bounding boxes on either engine, normalized 0–1. Present when you sent include_markers=true, on every plan, Free included |
review | object | Review summary for the whole job |
review.needs_review | boolean | Whether any field or page needs review |
review.quality_score | number | Overall quality score (0–1); null when no pipeline computed one |
review.fields_total | number | Fields extracted |
review.fields_needing_review | number | Fields flagged for review |
review.review_rate | number | fields_needing_review / fields_total (0–1) |
review.flags | array | Structured flags: { scope, target, severity, reason } |
review.status | string | Jobs sent with reviewer_email: pending, completed, cancelled or expired. See Human review |
review.reviewers | array | The reviewers' emails |
review.url | string | The review screen |
review.due_at | string | ISO 8601, when review_due_hours was set |
review.expires_at | string | ISO 8601, when an unfinished review closes |
review.completed_at | string | ISO 8601, once a reviewer completed it |
review.completed_by | string | Which reviewer completed it |
review.version | number | 0 until completed, 1 when a reviewer completes it, one more for every later correction |
review.changes | array | One per corrected value: { key, previous_value, value, changed_at, changed_by, version }, keyed like line_items[0].amount |
artifacts.preview_url | string | Public preview URL (when preview=true; every plan) |
artifacts.searchable_pdf_url | string | Downloadable searchable PDF (when searchable_pdf=true) |
webhook | object | Webhook delivery info: { url, delivered, delivered_at, error } |
meta.engine_version | string | Engine version that ran the job (informational) |
meta.cost_breakdown | object | { total_usd, by_step }. Gated to allowlisted accounts. |
error | string | Error message (when failed) |
Layout blocks
document.layout holds a blocks list. Every block has an id, a type, an order (top to bottom on the page), its content as text, and details that depend on the type:
type | details | What it is |
|---|---|---|
heading | none | A title or section heading |
text | none | A paragraph |
kv | { pairs: [{ key, value }] } | Label and value pairs, such as Date: 2026-09-18 |
table | { title, columns, rows, footers } | A table, with totals rows under footers |
list | { style, items } where style is bulleted, numbered or checklist | A list, or a group of checkboxes |
chart | { chart_type, title, x_labels, y_label, series: [{ name, values }] } | A chart, with the numbers it shows |
image | { role, image_id, caption } where role is logo, stamp, signature, barcode, photo or other | A picture, stamp, signature or code on the page |
What each engine returns for the non-text parts of a page:
| On the page | deep-extract | extract |
|---|---|---|
| Checkboxes | A checklist list; each item starts with [x] or [X] (marked), or [ ] (empty) | Read into the text as ☒ and ☐ |
| Charts | A chart block with the labels and values read from the chart | An image block with role other; values are not read |
| Stamps | An image block with role stamp; the stamp's text is in caption | An image block with role other |
| Signatures | An image block with role signature. It says a signature is present; it does not verify who signed | An image block with role other |
| Barcodes, QR codes | An image block with role barcode. A number printed beside the code is read; the code itself is not decoded | An image block with role other |
A chart from a Deep Extract job:
{ "id": "b3", "type": "chart", "order": 3, "content": "Pallets delivered per quarter (2026)", "details": { "chart_type": "bar", "title": "Pallets delivered per quarter (2026)", "x_labels": ["Q1", "Q2", "Q3", "Q4"], "y_label": "", "series": [{ "name": "Pallets delivered", "values": [120, 95, 143, 168] }] } }
The chart's numbers are in the layout block only. In document.content.text the chart appears as a single line with its type and title. The cropped image itself is not returned.
Job Status Values
| Status | Description |
|---|---|
queued | Job created, waiting to start processing |
processing | Pipeline is actively running |
completed | Processing finished successfully, results available |
failed | Processing encountered an unrecoverable error |
Status Codes
| Code | Description |
|---|---|
200 | Job found |
401 | Invalid or missing API key |
404 | Job not found |
429 | Polling limit for the plan (Free 20 a minute, Standard 60, Enterprise 150); Retry-After says when to try again |
500 | Internal server error |
GET /v1/pipelines
List the accuracy tiers DeepRead offers, with their lifecycle status. Useful for discovering tiers without hardcoding names. The list is the same for everyone and the endpoint needs no API key.
Endpoint
GET https://api.deepread.tech/v1/pipelines
Response (200 OK)
{ "pipelines": [ { "name": "extract", "description": "One OCR pass, sold as Extract with or without a schema. The default for Free and Standard accounts.", "status": "ga", "aliases": ["fast"] }, { "name": "deep-extract", "description": "Two OCR passes reconciled by an LLM judge, rotation correction, a verification read of every extracted value. Sold as Deep Extract. The default for Enterprise and legacy Pro accounts.", "status": "ga", "aliases": ["standard", "searchable"] } ], "products": [ { "name": "extract", "description": "One OCR pass: the document as Markdown with tables, layout blocks and bounding boxes, plus the fields from your schema or blueprint when you send one, each with a review flag and its location on the page.", "price_per_1k_pages_usd": 15.0, "how": "pipeline=extract, with or without a schema or blueprint_id" }, { "name": "deep-extract", "description": "Two independent OCR passes reconciled by an LLM judge, rotation correction, field locations and a verification read of every value. Supports searchable_pdf=true.", "price_per_1k_pages_usd": 35.0, "how": "pipeline=deep-extract, with or without a schema" }, { "name": "deep-extract-fast", "description": "Deep Extract run about twice as fast (speed=fast): the same two OCR passes, judge and field checks, with more of the work done at once on faster model tiers.", "price_per_1k_pages_usd": 90.0, "how": "pipeline=deep-extract with speed=fast" } ] }
Always read status rather than assuming the list is fixed.
Response Fields
| Field | Type | Description |
|---|---|---|
pipelines[].name | string | Value to pass as pipeline on POST /v1/process |
pipelines[].description | string | What the engine does |
pipelines[].status | string | ga (generally available) or beta |
pipelines[].aliases | array | Older names for this engine that are still accepted |
products[].name | string | extract, deep-extract or deep-extract-fast |
products[].price_per_1k_pages_usd | number | List price on the Standard plan |
products[].how | string | The request that produces this product |
Engines marked
betamay change without notice — pin to agaengine for production workloads.searchableis not listed: it isdeep-extractwith thesearchable_pdf=trueadd-on, and still accepted as an alias.
Incognito Processing
For sensitive documents, add incognito=true to any POST /v1/process request:
curl -X POST https://api.deepread.tech/v1/process \ -H "X-API-Key: YOUR_API_KEY" \ -F "file=@confidential.pdf" \ -F "pipeline=deep-extract" \ -F "incognito=true"
How it works:
- Your document is encrypted with a single-use key before it reaches storage — our storage only ever holds ciphertext.
- The moment the job reaches a terminal state (completed or failed), the file is deleted and the key is destroyed. This is crypto-shredding: even a backup copy taken mid-job is permanently unreadable afterwards.
- You still get your full results (text, layout, extracted fields) via the API and webhooks — extracted results follow your account's normal retention;
incognitogoverns the submitted document.
Trade-offs, by design:
- No preview link (
artifacts.preview_url) — there is no stored document to show. - Cannot be combined with
searchable_pdf— a searchable PDF is a stored deliverable. Requests combining them return400. - The document cannot be reprocessed later — resubmit it if you need another run.
Retention — delete everything after N days
incognito protects the document the moment the job finishes. If you also need the results gone after a while, add retention_days (1–365) to the request:
curl -X POST https://api.deepread.tech/v1/process \ -H "X-API-Key: YOUR_API_KEY" \ -F "file=@contract.pdf" \ -F "pipeline=deep-extract" \ -F "retention_days=7"
How it works:
- Until the deadline the job is exactly like any other — full results, webhook, preview link,
GET /v1/jobs/{id}. The response carriesretention_expires_atso you know when it goes. - Once the deadline passes, we delete the uploaded document, every preview page image and the searchable PDF, and scrub the extracted text, structured data and per-page results from our database. Only the job's accounting record (status, timestamps, page count, cost) remains.
- From the deadline on,
GET /v1/jobs/{id}returns nodocument,extraction,pagesorartifacts, and the preview link answers410 Gone— this does not wait for the deletion to finish. - Deletion is verified, not assumed:
data_deleted_atis set only once every stored object is confirmed gone; anything that fails to delete is retried automatically, and until thenretention_expires_atstays visible (in the past) so you can tell "deleting" from "kept".
Combine both for the strongest setting — incognito=true + retention_days=7 means the document is never stored in the clear and the results are forgotten after a week. Deletion is permanent; fetch and store what you need before the deadline.
Using Blueprints
Instead of providing a raw schema, you can use an optimized schema blueprint:
curl -X POST https://api.deepread.tech/v1/process \ -H "X-API-Key: YOUR_API_KEY" \ -F "file=@invoice.pdf" \ -F "blueprint_id=660e8400-e29b-41d4-a716-446655440001"
Blueprints provide:
- Measured accuracy: tuned against your own labelled documents, and scored on ones held back, so the reported lift is the lift on unseen data
- Reusable: Create once, use for all similar documents
- Auto-optimized: System learns from your evaluation data
See The Optimizer for more information.
Human review
Name who checks the document and DeepRead runs the review: the review screen, the reviewer's queue and the audit trail are all built in.
curl -X POST https://api.deepread.tech/v1/process \ -H "X-API-Key: YOUR_API_KEY" \ -F "file=@invoice.pdf" \ -F 'schema={"type":"object","properties":{"total":{"type":"number"}}}' \ -F "webhook_url=https://you.example.com/deepread" \ -F "reviewer_email=maria@bpo.example.com, sam@bpo.example.com" \ -F "review_due_hours=4"
What happens:
- The job completes as usual (
job.completed). Itsreview.statusispendingandreview.urlis the review screen. - Each reviewer gets an email with a link that signs them in with a code. No account or password is needed. Their
/reviewspage lists everything waiting for them. - The reviewer corrects values on the review screen and presses Complete. The first of a team to complete it completes it.
- Your
webhook_urlreceivesreview.completed: the same body asGET /v1/jobs/{job_id}, with the corrected values inextraction.fields,review.completed_byandreview.changes[]saying who changed what. A correction made after completion arrives asreview.updatedwithreview.versionone higher.
Review events, each also sent as the X-DeepRead-Event header and in the body's event field:
| Event | When |
|---|---|
review.completed | A reviewer completed it |
review.updated | A value was corrected after completion |
review.overdue | review_due_hours passed with the review still open; it stays open |
review.expired | review_expiry_days passed with the review still open; sent with the unreviewed data |
review.cancelled | You cancelled it |
PATCH /v1/jobs/{job_id}/review
Changes an open review. Authenticates with your API key like the other job endpoints.
# Reassign it (the new reviewers are emailed the link) curl -X PATCH https://api.deepread.tech/v1/jobs/{job_id}/review \ -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{"reviewer_email": "lee@bpo.example.com"}' # Cancel it curl -X PATCH https://api.deepread.tech/v1/jobs/{job_id}/review \ -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{"status": "cancelled"}'
GET /v1/reviews is the reviewer's queue: the reviews waiting for the signed-in reviewer, the list their /reviews page shows.
The review screen is the job's preview, so anyone with the link can see the document and the values. Only the named reviewers can change or complete them, and only they and you see the reviewers' names and the change history.
Webhooks
If you provide a webhook_url, DeepRead sends a POST request when the job completes or fails, and for jobs sent with reviewer_email when the review changes. The webhook payload contains the complete job result. Every body carries event (job.completed, job.failed, or one of the review events), also sent as the X-DeepRead-Event header.
Webhooks are available from the Standard plan; sending webhook_url on Free returns 402 naming the plan.
Webhook security: Every delivery carries X-DeepRead-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 over "<t>.<raw request body>" keyed by your account's signing secret. Read the secret at GET /dashboard/v1/webhooks/secret (it is created on first read) and rotate it at POST /dashboard/v1/webhooks/secret/rotate; deliveries sign with the new secret from the next one on. Verify over the exact bytes you received, compare in constant time, and reject a t older than five minutes:
import hmac, hashlib, time def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts["t"]) if abs(time.time() - t) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts["v1"])
Even with a verified signature, treat the delivery as a notification and fetch the canonical result from GET /v1/jobs/{job_id} from your backend before acting on it.
See Jobs & Webhooks for webhook payload structure.
Large Text Responses
For documents with large OCR output (>1MB), the full text is stored externally:
document.content.text_url: URL to fetch the full textdocument.content.text_preview: First characters for quick inspection
content = job.get('document', {}).get('content', {}) if content.get('text_url'): text_response = requests.get(content['text_url']) full_text = text_response.text else: full_text = content.get('text', '')
Manual Status Checking
If checking job status manually instead of using webhooks:
- Check status after a short delay (e.g., 5 seconds)
- Use exponential backoff for subsequent checks (e.g., 5s, 10s, 20s, 30s intervals)
- Set a maximum wait duration (e.g., 5 minutes)
- Stop checking once status is
completedorfailed
import time import requests def check_job_status(job_id, max_wait_seconds=300): start_time = time.time() check_interval = 5 while time.time() - start_time < max_wait_seconds: response = requests.get( f"https://api.deepread.tech/v1/jobs/{job_id}", headers={"X-API-Key": "YOUR_API_KEY"} ) job = response.json() status = job['status'] if status == 'completed': return job elif status == 'failed': raise Exception(f"Job failed: {job.get('error')}") elif status in ['queued', 'processing']: time.sleep(check_interval) check_interval = min(check_interval * 2, 30) # Exponential backoff, cap at 30s continue raise Exception(f"Job did not complete within {max_wait_seconds} seconds")
For production use, webhooks are recommended as they provide immediate notifications without polling.
Rate Limits
Every limit is per plan. A refused request answers 429 with a Retry-After header saying when to try again, except the hard maximum, which is 413.
| Limit | Free | Standard | Enterprise |
|---|---|---|---|
Submissions per minute (POST /v1/process) | 10 | 100 | 500 |
| Pages in flight (queued + processing, added up) | 16 | 200 | 500 |
Polling per minute (GET /v1/jobs/{job_id}) | 20 | 60 | 150 |
| Pages per document | 50 | 1,000 | 2,000 |
| Hard maximum per document | 2,000 pages / 500 MB | 2,000 pages / 500 MB | 2,000 pages / 500 MB |
- Submissions per minute are counted per endpoint across every API server (
POST /v1/form-fill,POST /v1/pii/redactandPOST /v1/optimizeeach have their own count). Past the limit the request is refused with429, aRetry-Afterheader (seconds until the minute turns) and a body naming the limit. A refused request counts too. - Pages in flight is an admission limit, not reserved capacity: it bounds what one account can have waiting, it does not guarantee how fast the queue moves. A submission is admitted when you have nothing in flight (one large document is never refused for being large) or when its pages fit under the cap. Otherwise it is refused with
429,Retry-After: 30, and a body givingpages_in_flight,max_pages_in_flightandpages_requested. Nothing is queued: resubmit when a job finishes. OCR, PII redaction and form-fill jobs share the one budget, each counted at the pages it was admitted for. - Polling
GET /v1/jobs/{job_id}past the plan's rate is refused with429and aRetry-Afterheader. Webhooks are the way to avoid polling altogether. - Pages per document past the plan's limit are refused with
400andpage_count_exceeded, withpage_count,max_pagesandplanin the body. On Free and Standard a PDF that cannot be read (corrupted or password-protected) is refused with400pdf_parse_error, since its pages cannot be counted against the limit. - The hard maximum applies on every plan: no document over 2,000 pages or 500 MB is accepted, and the answer is
413with the limit in the body. Contact us for larger documents. - Monthly pages on Free: 2,000 a month, reset every month on the day the account signed up; unused pages do not carry over. A document that would take the account past 2,000 is refused whole with
429(no job is created) until the cycle resets. TheX-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-UsedandX-RateLimit-Resetheaders on every response show the standing;X-RateLimit-Resetis your own next reset, not the first of the month.
Send an idempotency_key with any submission you retry after a 429, so the retry can never create a second job.