API Reference

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

HeaderRequiredDescription
X-API-KeyYesYour API key
Content-TypeYesmultipart/form-data

Request Body (Multipart)

ParameterTypeRequiredDescription
filefileYesDocument file to upload. Which types your plan accepts: File types below
pipelinestringNoEngine: 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.
speedstringNofast 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_pdfstringNoSet true to also produce a searchable PDF. deep-extract only, Enterprise plans. Default: false
incognitostringNoSet 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_daysstringNoRetention 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
versionstringNoPipeline version for reproducibility (e.g., 1.0.0). If not specified, uses current default
schemastringNoJSON 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_idstringNoID 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_urlstringNoURL 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_emailstringNoHave 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_hoursstringNoWith 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_daysstringNoWith 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_keystringNoUp 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
previewbooleanNoGenerate 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_pagebooleanNoInclude a per-page breakdown in results (default: false, may be auto-enabled when a preview is generated)
include_imagesbooleanNoDeprecated — alias for preview. Still honored for back-compat; new integrations should use preview.
include_markersbooleanNoSet 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_pagesbooleanNoDeprecated — 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

PlanAccepted uploads
FreePDF, PNG, JPEG
StandardPDF, PNG, JPEG, TIFF (multi-page), WebP, BMP, GIF, DOCX, TXT
EnterpriseThe 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 Example
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

FieldTypeDescription
idstringUnique job identifier (UUID)
statusstringInitial job status (queued)
retention_expires_atstringOnly 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

CodeDescription
200Job created; with an idempotency_key seen before, the job that request created
400Invalid request body, schema, or parameters; a file that could not be converted to PDF; a document over the plan's pages per document
401Invalid or missing API key
402The 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)
409The idempotency_key was used before with a different file or options
413Document over the hard maximum (2,000 pages or 500 MB)
415The plan does not accept this file type; the body names the plan that does
429Submissions per minute, pages in flight, or the monthly page allowance exceeded; Retry-After says when to try again. See Rate Limits
500Internal server error
503The 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

HeaderRequiredDescription
X-API-KeyYesYour API key

Path Parameters

ParameterTypeDescription
job_idUUIDThe job ID returned from POST /v1/process

Example Request

cURL Example
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

FieldTypeDescription
idstringJob identifier
statusstringqueued, processing, completed, or failed
schema_versionstringAlways "dp02"
pipelinestringEngine, as you named it: extract or deep-extract (or fast/standard if you still send those)
productstringWhat the job was sold as: extract, deep-extract or deep-extract-fast (parse on jobs from before October 2026)
searchable_pdfbooleantrue when a searchable PDF was produced
incognitobooleantrue when the job ran in incognito mode (the submitted document has been crypto-shredded)
retention_expires_atstringPresent 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_atstringSet 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_atstringISO 8601 timestamp
completed_atstringISO 8601 timestamp (when completed or failed)
documentobjectDocument-level OCR output (when completed)
document.page_countnumberNumber of pages processed
document.content.formatstringText format, markdown
document.content.textstringFull OCR text as markdown (omitted when the text is offloaded, see text_url)
document.content.text_urlstringURL to fetch full text (when text >1MB)
document.content.text_previewstringFirst characters (when text is offloaded)
document.layoutobjectTyped dp.lite.v1 structured layout (blocks + page-mappings), on either engine. (Replaces the old stringified json_string.)
extraction.fieldsarrayStructured extraction (if a schema/blueprint was provided). A list of field objects; the same shape is used in pages[].fields
extraction.fields[].keystringStable field identifier
extraction.fields[].field_idstringStable id for repeated rows (e.g. line_items[0].amount), when the field is one of many
extraction.fields[].valueanyExtracted value (scalar, object, or array for nested/tabular data)
extraction.fields[].textstringThe value as it appears on the page, before normalization
extraction.fields[].needs_reviewbooleanWhether field requires human review
extraction.fields[].review_reasonstringWhy the field was flagged (when needs_review is true)
extraction.fields[].validationobject{ passed, rule, expected, actual, message }, when a schema validation rule applies to the field
extraction.fields[].location.pagenumberPage number where field was found
extraction.fields[].location.bounding_boxobject{ 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
pagesarrayPer-page breakdown (with per_page=true, and auto-enabled when a preview is generated)
pages[].page_numbernumber1-based page number
pages[].content.textstringPage text as markdown
pages[].fieldsarrayFields found on that page, same shape as extraction.fields
pages[].needs_reviewbooleanWhether the page needs verification
pages[].review_reasonstringWhy the page was flagged
groundingarrayBlock-level bounding boxes on either engine, normalized 0–1. Present when you sent include_markers=true, on every plan, Free included
reviewobjectReview summary for the whole job
review.needs_reviewbooleanWhether any field or page needs review
review.quality_scorenumberOverall quality score (0–1); null when no pipeline computed one
review.fields_totalnumberFields extracted
review.fields_needing_reviewnumberFields flagged for review
review.review_ratenumberfields_needing_review / fields_total (0–1)
review.flagsarrayStructured flags: { scope, target, severity, reason }
review.statusstringJobs sent with reviewer_email: pending, completed, cancelled or expired. See Human review
review.reviewersarrayThe reviewers' emails
review.urlstringThe review screen
review.due_atstringISO 8601, when review_due_hours was set
review.expires_atstringISO 8601, when an unfinished review closes
review.completed_atstringISO 8601, once a reviewer completed it
review.completed_bystringWhich reviewer completed it
review.versionnumber0 until completed, 1 when a reviewer completes it, one more for every later correction
review.changesarrayOne per corrected value: { key, previous_value, value, changed_at, changed_by, version }, keyed like line_items[0].amount
artifacts.preview_urlstringPublic preview URL (when preview=true; every plan)
artifacts.searchable_pdf_urlstringDownloadable searchable PDF (when searchable_pdf=true)
webhookobjectWebhook delivery info: { url, delivered, delivered_at, error }
meta.engine_versionstringEngine version that ran the job (informational)
meta.cost_breakdownobject{ total_usd, by_step }. Gated to allowlisted accounts.
errorstringError 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:

typedetailsWhat it is
headingnoneA title or section heading
textnoneA 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 checklistA 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 otherA picture, stamp, signature or code on the page

What each engine returns for the non-text parts of a page:

On the pagedeep-extractextract
CheckboxesA checklist list; each item starts with [x] or [X] (marked), or [ ] (empty)Read into the text as ☒ and ☐
ChartsA chart block with the labels and values read from the chartAn image block with role other; values are not read
StampsAn image block with role stamp; the stamp's text is in captionAn image block with role other
SignaturesAn image block with role signature. It says a signature is present; it does not verify who signedAn image block with role other
Barcodes, QR codesAn image block with role barcode. A number printed beside the code is read; the code itself is not decodedAn 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

StatusDescription
queuedJob created, waiting to start processing
processingPipeline is actively running
completedProcessing finished successfully, results available
failedProcessing encountered an unrecoverable error

Status Codes

CodeDescription
200Job found
401Invalid or missing API key
404Job not found
429Polling limit for the plan (Free 20 a minute, Standard 60, Enterprise 150); Retry-After says when to try again
500Internal 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

FieldTypeDescription
pipelines[].namestringValue to pass as pipeline on POST /v1/process
pipelines[].descriptionstringWhat the engine does
pipelines[].statusstringga (generally available) or beta
pipelines[].aliasesarrayOlder names for this engine that are still accepted
products[].namestringextract, deep-extract or deep-extract-fast
products[].price_per_1k_pages_usdnumberList price on the Standard plan
products[].howstringThe request that produces this product

Engines marked beta may change without notice — pin to a ga engine for production workloads. searchable is not listed: it is deep-extract with the searchable_pdf=true add-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; incognito governs 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 return 400.
  • 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 carries retention_expires_at so 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 no document, extraction, pages or artifacts, and the preview link answers 410 Gone — this does not wait for the deletion to finish.
  • Deletion is verified, not assumed: data_deleted_at is set only once every stored object is confirmed gone; anything that fails to delete is retried automatically, and until then retention_expires_at stays 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:

  1. The job completes as usual (job.completed). Its review.status is pending and review.url is the review screen.
  2. Each reviewer gets an email with a link that signs them in with a code. No account or password is needed. Their /reviews page lists everything waiting for them.
  3. The reviewer corrects values on the review screen and presses Complete. The first of a team to complete it completes it.
  4. Your webhook_url receives review.completed: the same body as GET /v1/jobs/{job_id}, with the corrected values in extraction.fields, review.completed_by and review.changes[] saying who changed what. A correction made after completion arrives as review.updated with review.version one higher.

Review events, each also sent as the X-DeepRead-Event header and in the body's event field:

EventWhen
review.completedA reviewer completed it
review.updatedA value was corrected after completion
review.overduereview_due_hours passed with the review still open; it stays open
review.expiredreview_expiry_days passed with the review still open; sent with the unreviewed data
review.cancelledYou 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 text
  • document.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 completed or failed
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.

LimitFreeStandardEnterprise
Submissions per minute (POST /v1/process)10100500
Pages in flight (queued + processing, added up)16200500
Polling per minute (GET /v1/jobs/{job_id})2060150
Pages per document501,0002,000
Hard maximum per document2,000 pages / 500 MB2,000 pages / 500 MB2,000 pages / 500 MB
  • Submissions per minute are counted per endpoint across every API server (POST /v1/form-fill, POST /v1/pii/redact and POST /v1/optimize each have their own count). Past the limit the request is refused with 429, a Retry-After header (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 giving pages_in_flight, max_pages_in_flight and pages_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 with 429 and a Retry-After header. Webhooks are the way to avoid polling altogether.
  • Pages per document past the plan's limit are refused with 400 and page_count_exceeded, with page_count, max_pages and plan in the body. On Free and Standard a PDF that cannot be read (corrupted or password-protected) is refused with 400 pdf_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 413 with 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. The X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used and X-RateLimit-Reset headers on every response show the standing; X-RateLimit-Reset is 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.