Reference

Customer API

Submit documents and retrieve extracted text or structured data. Every response is JSON, every timestamp is ISO 8601 UTC, and every request is authenticated with an API key.

Overview

The customer API is the integration point for submitting documents and retrieving extracted text or structured data. It runs on a separate domain from the dashboard and uses API key authentication.

All responses are JSON. All timestamps are ISO 8601 in UTC with a Z suffix.

Base URL

base-url
text
https://api.ocrwell.com/v1

HTTPS only. Plaintext HTTP requests are rejected.

Authentication

Every request must include an API key in the X-API-Key header. Keys are created in the dashboard and displayed once at creation time.

header
text
X-API-Key: <your-api-key>

The server validates the key by computing its SHA-256 hash and looking it up in DynamoDB. An invalid or missing key returns 401.

Coding agents

If you work in Claude Code or OpenAI Codex CLI, install the OCRWell skill once and the agent will write the integration for you — two-step uploads, polling with backoff, structured extraction schemas, rate-limit handling, and webhook signature verification — in whatever language you're working in. Both agents read the same SKILL.md format, so one install covers both.

The skill is open source at github.com/ocrwell/skill and published to npm as @ocrwell/skill.

Claude Code — plugin marketplace

The recommended path for Claude Code. Uses the built-in plugin system, so you get versioning, namespacing, and /plugin update out of the box.

claude-code
text
/plugin marketplace add ocrwell/skill
/plugin install ocrwell@ocrwell

Update later with /plugin update ocrwell@ocrwell, remove with /plugin uninstall ocrwell@ocrwell.

Claude Code or Codex CLI — npx installer

The installer writes the skill directory to the location your agent reads from: ~/.claude/skills/ocrwell for Claude Code, or ~/.agents/skills/ocrwell (user-wide) / ./.agents/skills/ocrwell (per-repo) for Codex.

install.sh
bash
$ npx @ocrwell/skill install                   # prompts for a target
$ npx @ocrwell/skill install --claude           # Claude Code, user-wide
$ npx @ocrwell/skill install --codex            # Codex CLI, user-wide
$ npx @ocrwell/skill install --both             # both
$ npx @ocrwell/skill install --codex --project  # per-repo Codex install

Update by re-running with --force (e.g. npx @ocrwell/skill@latest install --claude --force). Remove with npx @ocrwell/skill uninstall --claude or --codex (add --project for the per-repo copy).

Expose your API key

The skill expects the key in an environment variable so generated code never hard-codes secrets:

shell
bash
$ export OCRWELL_API_KEY=...

Verify it's loaded

Start a fresh session and ask the agent something OCRWell-shaped, for example:

Write a Python script that uploads invoice.pdf to OCRWell and prints the extracted text.

With the skill loaded, the agent will send X-API-Key from the env variable, follow the two-step upload, poll GET /v1/jobs/{jobId} with backoff, and correctly treat 200 with job.status: "failed" as a job failure rather than an HTTP error. If it instead invents the API or asks for documentation, the skill wasn't picked up — in Claude Code, confirm ocrwell appears under /plugin or that ~/.claude/skills/ocrwell/SKILL.md exists; in Codex, run /skills or check ~/.agents/skills/ocrwell/SKILL.md. Then restart the session.

Quickstart: OCR

Upload a document and get its text back. This walkthrough assumes you already have an API key from the dashboard and the key is available in the shell variable $KEY.

1. Get an upload URL

Ask the API for a pre-signed URL to upload your file to.

1-get-upload-url.sh
bash
$ curl -X POST https://api.ocrwell.com/v1/uploads \
    -H "X-API-Key: $KEY" \
    -H "Content-Type: application/json" \
    -d '{"filename":"contract.pdf"}'

The response contains an upload_id (keep this for step 3) and an upload_url (used in step 2).

2. Upload the file

PUT the file contents to the pre-signed URL. The URL is valid for 15 minutes.

2-upload.sh
bash
$ curl -X PUT "<upload_url>" \
    -H "Content-Type: application/pdf" \
    --data-binary @contract.pdf

3. Submit for OCR

Submit the uploaded document for processing in text mode.

3-submit.sh
bash
$ curl -X POST https://api.ocrwell.com/v1/documents \
    -H "X-API-Key: $KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "upload_id": "019539a6-6c3d-7e5f",
      "filename": "contract.pdf",
      "mode": "text"
    }'

The response is 202 Accepted and contains a job.id you use to retrieve the result in step 4.

4. Poll for the result

The job moves through pending, processing, and then completed. Poll every second or two until the status is completed.

4-poll.sh
bash
$ curl https://api.ocrwell.com/v1/jobs/019539a6-6c3d-7e5f \
    -H "X-API-Key: $KEY"

A completed text-mode job returns one entry in result.pages per page of the document.

completed.json
json
{
  "job": {
    "id": "019539a6-6c3d-7e5f",
    "status": "completed",
    "mode": "text",
    "page_count": 2
  },
  "result": {
    "pages": [
      { "page": 1, "text": "AGREEMENT..." },
      { "page": 2, "text": "Signed on..." }
    ]
  }
}

Next steps: configure a webhook callback if you would rather be notified than poll, review the error codes your client should handle, and check the rate limiting rules before running bulk jobs.

Quickstart: extraction

Upload a document and get structured data back, shaped exactly like the schema you provide. As with the OCR quickstart, this assumes your API key is in $KEY.

1. Get an upload URL

1-get-upload-url.sh
bash
$ curl -X POST https://api.ocrwell.com/v1/uploads \
    -H "X-API-Key: $KEY" \
    -H "Content-Type: application/json" \
    -d '{"filename":"receipt.jpg"}'

2. Upload the file

2-upload.sh
bash
$ curl -X PUT "<upload_url>" \
    -H "Content-Type: image/jpeg" \
    --data-binary @receipt.jpg

3. Describe what you want back

The schema describes the shape of the output and also tells the extractor which fields to read from the document. For a receipt you might ask for the merchant, the date, and the total.

schema.json
json
{
  "merchant": "",
  "date": "",
  "total": 0
}

See Extraction schema for field types, nesting, arrays of repeating items, and worked examples.

4. Submit for extraction

Submit the uploaded document in structured mode with the schema inline.

4-submit.sh
bash
$ curl -X POST https://api.ocrwell.com/v1/documents \
    -H "X-API-Key: $KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "upload_id": "019539a7-8e4f-7a6b",
      "filename": "receipt.jpg",
      "mode": "structured",
      "schema": {
        "merchant": "",
        "date": "",
        "total": 0
      }
    }'

The response is 202 Accepted with a job.id.

5. Poll for the result

5-poll.sh
bash
$ curl https://api.ocrwell.com/v1/jobs/019539a7-8e4f-7a6b \
    -H "X-API-Key: $KEY"

A completed structured-mode job returns a result.data object that matches the shape of the schema you submitted.

completed.json
json
{
  "job": {
    "id": "019539a7-8e4f-7a6b",
    "status": "completed",
    "mode": "structured",
    "page_count": 1
  },
  "result": {
    "data": {
      "merchant": "Prentiss & Co.",
      "date": "18/03/2026",
      "total": 42.6
    }
  }
}

Next steps: read Extraction schema for nested objects, arrays and JSON Schema support, set up a webhook callback instead of polling, and review the error codes your client should handle.

Rate limiting

Limits are applied per organisation (resolved from the API key). Every response includes rate limit headers:

response-headers
text
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1711180800

X-RateLimit-Reset is a Unix timestamp (UTC) indicating when the current window resets. When the limit is exceeded, the API returns 429 Too Many Requests with a Retry-After header (seconds until the client should retry).

For bulk processing, honour Retry-After instead of retrying immediately, and check X-RateLimit-Remaining before issuing a burst so the client can pace itself and avoid hitting the limit in the first place.

Errors

All error responses share a consistent shape:

error.json
json
{
  "error": {
    "code": "validation_error",
    "message": "The 'mode' field is required.",
    "details": [{
      "field": "mode",
      "reason": "required"
    }]
  },
  "request_id": "019539b1-2a3b-7c4d"
}
  • error.code — machine-readable error code (always present)
  • error.message — human-readable explanation (always present)
  • error.details — optional array with field-level detail for validation errors
  • request_id — unique identifier for this request, useful for support enquiries

Error codes

StatusCodeMeaning
400bad_requestMalformed request body or parameters
400validation_errorOne or more fields failed validation
400unsupported_formatDocument format not supported
401unauthorizedMissing or invalid API key
404not_foundResource does not exist or belongs to a different org
410result_expiredJob result has been deleted after the retention window
409duplicate_requestIdempotent request already processed with a different body
413file_too_largeDocument exceeds the maximum file size
422invalid_schemaProvided JSON schema for structured extraction is invalid
429rate_limitedRate limit exceeded
500internal_errorUnexpected server error
503service_unavailableTemporarily unable to process requests

Idempotency

POST /v1/documents accepts an optional Idempotency-Key header to prevent duplicate processing. The key must be a unique string (up to 64 characters). The server stores the key alongside the job record and returns the same response for repeated requests with the same key within a 24-hour window.

header
text
Idempotency-Key: upload_inv_2026_03_01_001

If the same key is sent with a different request body, the server returns 409 Conflict with error.code of duplicate_request. Idempotency keys are scoped to the organisation — two different orgs can use the same key independently.

A typical case: a batch job submits invoices and a network timeout forces the client to retry. Without an idempotency key, the same invoice risks being submitted (and billed) twice. Setting the key to something stable like the invoice ID or a hash of the file contents means the retry returns the original job rather than creating a new one. If your client never retries submissions you can leave the header out.

Upload

endpoint
text
POST /v1/uploads

Generates a pre-signed URL for uploading a document directly to storage. The URL is valid for 15 minutes. This is the first step in a two-step upload process — after uploading the file, use POST /v1/documents to submit it for processing.

Request

request.json
json
{
  "filename": "invoice.pdf"
}

Response — 200 OK

response.json
json
{
  "upload_id": "019539a6-6c3d-7e5f",
  "upload_url": "https://api.ocrwell.com/uploads/...",
  "expires_in": 900,
  "request_id": "019539b1-2a3b-7c4d"
}

Uploading the file

curl
bash
$ curl -X PUT "<upload_url>" \
    -H "Content-Type: application/pdf" \
    --data-binary @invoice.pdf

The Content-Type header must match the file format declared in the filename.

ExtensionContent-Type
.pdfapplication/pdf
.jpg, .jpegimage/jpeg
.pngimage/png
.tiff, .tifimage/tiff
.webpimage/webp
.bmpimage/bmp

Submit document

endpoint
text
POST /v1/documents

Asynchronous endpoint for documents of any page count. The server queues the job and returns 202 Accepted immediately. Use GET /v1/jobs/:id to poll for the result, or configure a webhook to receive a callback on completion. Documents must be uploaded first via POST /v1/uploads.

Request

request.json
json
{
  "upload_id": "019539a6-6c3d-7e5f",
  "filename": "invoice.pdf",
  "mode": "text"
}
  • upload_id — required. The upload ID returned by POST /v1/uploads.
  • filename — required. Must match what was used in the upload request.
  • mode — required. Either "text" or "structured".
  • schema — required when mode is structured. JSON object describing the desired output. Accepts either a template or a JSON Schema.
Use text mode when you want the raw contents of a document: archival search, feeding pages to an LLM, or pulling quotations for human review. Use structured mode when you already know the fields you want back — invoice line items, driver's licence details, or form values that will land in a database row. Structured mode removes the parsing step on your side by returning data that already matches the schema you provided, so you don't have to write regexes to pick fields out of free-form text.

Response — 202 Accepted

accepted.json
json
{
  "job": {
    "id": "019539a6-6c3d-7e5f",
    "status": "pending",
    "mode": "text",
    "created_at": "2026-03-23T02:15:00Z"
  },
  "request_id": "019539b1-2a3b-7c4d"
}

Extraction schema

In structured mode, the schema field on a submission describes the output you want back. It is also the instruction to the extractor about which fields to read from the document: if a field is not in the schema, it is not returned, and every field in the schema is treated as requested. You can express the schema as a filled-out template or as a JSON Schema. Both are covered below.

Field types

Leaf fields are one of four types: string, number, integer, or boolean. In template form the type is inferred from the sentinel value you use: an empty string for text, a zero for numeric fields, and true or false for booleans. The output preserves the same types.

types-template.json
json
{
  "patient_name": "",
  "visit_fee": 0,
  "visit_count": 0,
  "insured": false
}

To distinguish integer from number in template form, use a JSON Schema (see below) and set the type explicitly. With a template, the extractor returns whole numbers where the document contains whole numbers and decimals where it contains decimals.

Nested objects

Objects can nest to any depth. The output mirrors the nesting you describe.

nested-template.json
json
{
  "customer_name": "",
  "address": {
    "line1": "",
    "city": "",
    "postcode": ""
  }
}

Arrays of repeating items

When a document contains a list of similarly-shaped items (line items on an invoice, entries on a bank statement, rows on a form), describe the list as an array in the schema. The array must contain exactly one element, and that element describes the shape of every item in the output.

array-template.json
json
{
  "line_items": [
    {
      "description": "",
      "quantity": 0,
      "amount": 0
    }
  ]
}

The response contains as many line-item objects as the extractor finds in the document.

Optional and missing fields

Every field in the schema is treated as requested. If the document does not contain a value for a field, the result uses the sentinel for its type: an empty string, a zero, or false. In JSON Schema form the required keyword is accepted but informational.

The practical consequence is that you largely cannot distinguish "the field was missing from the document" from "the field was present but empty". Code that consumes extraction results should treat sentinel values as absent rather than as real data.

Worked example: receipt

A retail receipt with a list of items. The schema:

receipt-schema.json
json
{
  "merchant": "",
  "date": "",
  "items": [
    {"name": "", "price": 0}
  ],
  "total": 0
}

A completed job for this schema might return:

receipt-result.json
json
{
  "data": {
    "merchant": "Prentiss & Co.",
    "date": "18/03/2026",
    "items": [
      {
        "name": "Flat white",
        "price": 4.8
      },
      {
        "name": "Almond croissant",
        "price": 5.2
      }
    ],
    "total": 10
  }
}

Worked example: invoice with line items

An invoice with header fields and a repeating line-item block. The schema:

invoice-schema.json
json
{
  "invoice_number": "",
  "issue_date": "",
  "due_date": "",
  "supplier": {
    "name": "",
    "vat_number": ""
  },
  "line_items": [
    {
      "description": "",
      "quantity": 0,
      "unit_price": 0,
      "amount": 0
    }
  ],
  "subtotal": 0,
  "vat": 0,
  "total": 0
}

A completed job for this schema might return:

invoice-result.json
json
{
  "data": {
    "invoice_number": "INV-4821",
    "issue_date": "01/03/2026",
    "due_date": "31/03/2026",
    "supplier": {
      "name": "Harwood Analytics Ltd",
      "vat_number": "GB123456789"
    },
    "line_items": [
      {"description": "Consulting, March",   "quantity": 12, "unit_price": 180, "amount": 2160},
      {"description": "Report preparation",  "quantity":  1, "unit_price": 450, "amount":  450}
    ],
    "subtotal": 2610,
    "vat": 522,
    "total": 3132
  }
}

Template format

Easier to hand-write and read at a glance. A partially-filled example of the desired output. Leaves use sentinel values: "" for text, 0 for numbers, true/false for booleans. Nested objects are fine, and an array must contain exactly one element describing the shape of every item.

template.json
json
{
  "invoice_number": "",
  "total": 0,
  "paid": false,
  "line_items": [
    {"description": "", "quantity": 0, "amount": 0}
  ]
}

JSON Schema

Use this when you already have a JSON Schema for your data, or when you need explicit typing (for example, forcing integer rather than number) and required markers. It is a standard draft-07 / 2020-12 JSON Schema. The same output as above can be requested with:

schema.json
json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["invoice_number", "total", "line_items"],
  "properties": {
    "invoice_number": {"type": "string"},
    "total": {"type": "number"},
    "paid": {"type": "boolean"},
    "line_items": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "description": {"type": "string"},
          "quantity": {"type": "integer"},
          "amount": {"type": "number"}
        }
      }
    }
  }
}

Detection

The submission is treated as a JSON Schema when the root object contains any of $schema, $defs, definitions, $ref, or both type: "object" and a properties object. Otherwise it is treated as a template. Field names starting with $ and the literal key definitions are reserved at the root.

Supported JSON Schema subset

  • Types object, array, string, number, integer, boolean.
  • type as an array (e.g. ["string", "null"]) — the first non-null entry is used.
  • Nested properties and single-schema items.
  • Local $ref resolved against #/$defs/... or #/definitions/....
  • const and enum — the sentinel is derived from the JSON type of the (first) value.
  • anyOf, oneOf, allOf — the first branch that successfully converts is used; other branches are ignored.
  • Informational keywords (description, required, format, title, etc.) are accepted and ignored.

Not supported

The server returns 422 invalid_schema for: tuple items (arrays of schemas), external $ref, circular $ref chains, empty composition arrays, and any type other than the six listed above. Both the raw submitted bytes and the converted template must be 64 KB or less.

Get job

endpoint
text
GET /v1/jobs/:jobId

Retrieves a job's status and, if completed, its result. The HTTP status is always 200 if the job exists — the job's status field communicates the processing outcome, not the HTTP status code.

Job statuses

  • pending — queued, waiting for an OCR worker
  • processing — an OCR worker is processing the document
  • completed — processing finished successfully
  • failed — processing failed

Completed — text mode

completed-text.json
json
{
  "job": {
    "id": "019539a6-6c3d-7e5f",
    "status": "completed",
    "mode": "text",
    "page_count": 3,
    "created_at": "2026-03-23T02:15:00Z",
    "completed_at": "2026-03-23T02:15:04Z"
  },
  "result": {
    "pages": [
      { "page": 1, "text": "INVOICE..." },
      { "page": 2, "text": "Description..." },
      { "page": 3, "text": "Subtotal..." }
    ]
  }
}

Completed — structured mode

completed-structured.json
json
{
  "job": {
    "id": "019539a7-8e4f-7a6b",
    "status": "completed",
    "mode": "structured",
    "page_count": 1
  },
  "result": {
    "data": {
      "id_number": "D1234567",
      "last_name": "Smith",
      "first_name": "Jane",
      "date_of_birth": "1990-05-14",
      "sex": "F"
    }
  }
}

Still processing

processing.json
json
{
  "job": {
    "id": "019539a6-6c3d-7e5f",
    "status": "processing",
    "mode": "text",
    "created_at": "2026-03-23T02:15:00Z"
  }
}

Failed

failed.json
json
{
  "job": {
    "id": "019539a6-6c3d-7e5f",
    "status": "failed",
    "failed_at": "2026-03-23T02:15:06Z",
    "failure_reason": "Document could not be read."
  }
}

Result expired

Returned when a completed job's result has been deleted after the retention window.

expired.json
json
{
  "error": {
    "code": "result_expired",
    "message": "The result for this job has expired."
  }
}

Webhooks

Each organisation has at most one webhook URL. Callbacks are sent whenever a job completes or fails.

Webhooks suit background work — queue consumers, nightly batch runs, async pipelines — because the client avoids spending requests and latency polling while the OCR worker is still busy. If a user is waiting on a page for the result, polling GET /v1/jobs/:id every second or two is simpler, and usually faster end-to-end than the extra round trip through your webhook handler.

Set webhook

endpoint
text
PUT /v1/webhooks

Creates or replaces the webhook configuration. The server generates a signing secret on creation and returns it in the response. The secret is never returned again after creation — if it is lost, send another PUT to rotate it.

request.json
json
{
  "url": "https://example.com/ocr-callback"
}
response.json
json
{
  "webhook": {
    "url": "https://example.com/ocr-callback",
    "secret": "whsec_a1b2c3d4e5f6...",
    "created_at": "2026-03-23T03:00:00Z"
  }
}

Get webhook

endpoint
text
GET /v1/webhooks

Returns the current webhook configuration. The secret field is only present in the response to PUT. Responds with webhook: null if none is configured.

Delete webhook

endpoint
text
DELETE /v1/webhooks

Removes the webhook configuration. Responds with 204 No Content. After deletion, no callbacks are sent for completed jobs.

Webhook callbacks

When a job completes or fails and the org has a webhook configured, the server sends a POST to the webhook URL.

Signing

Each callback is signed with HMAC-SHA256 using the webhook secret. The signature covers the timestamp and the raw request body to prevent replay attacks.

callback-headers
text
X-Ocrwell-Signature: sha256=a1b2c3d4e5f6...
X-Ocrwell-Timestamp: 1711159200
X-Ocrwell-Event: job.completed

Verification steps

  1. Read the X-Ocrwell-Timestamp header.
  2. Concatenate {timestamp}.{raw_body}.
  3. Compute HMAC-SHA256 of the concatenated string using the webhook secret.
  4. Compare the result with the X-Ocrwell-Signature value using constant-time comparison.
  5. Reject if the timestamp is more than 5 minutes old.

Event: job.completed

job.completed.json
json
{
  "event": "job.completed",
  "job": {
    "id": "019539a6-6c3d-7e5f",
    "status": "completed",
    "mode": "text",
    "page_count": 3,
    "completed_at": "2026-03-23T02:15:04Z"
  }
}

Event: job.failed

job.failed.json
json
{
  "event": "job.failed",
  "job": {
    "id": "019539a6-6c3d-7e5f",
    "status": "failed",
    "failure_reason": "Document could not be read."
  }
}

The callback contains job metadata but not the OCR result. The receiver should call GET /v1/jobs/:id to fetch the full result. Results are available for 5 minutes after first retrieval.

Retry policy

If the webhook endpoint returns a non-2xx status or fails to respond within 10 seconds, the server retries up to 3 times with exponential backoff (30s, 120s, 480s). After all retries are exhausted, the delivery is abandoned. The job result remains available via the API.

Limits

ConstraintLimit
Maximum file size20 MB
Maximum pages per document1000
Supported formatsPDF, JPEG, PNG, TIFF, WebP, BMP
Maximum schema size (structured mode)64 KB (applies to both submitted bytes and the converted template)
Maximum Idempotency-Key length64 characters
Upload URL validity15 minutes

Files exceeding the size limit receive a 413 response. Unused upload URLs expire after 15 minutes. Uploaded files that are not submitted for processing are automatically deleted after 24 hours.

Data retention

Uploaded documents are deleted from storage immediately after OCR processing completes.

Results are available for 5 minutes after the first retrieval via GET /v1/jobs/:id. After 5 minutes, the result data is permanently deleted. Subsequent requests for the same job return 410 Gone with error code result_expired.

Unread results and any documents not cleaned up during processing are automatically deleted after 24 hours via a storage lifecycle policy.

The API is not intended as long-term storage for extracted data. Persist whatever you need into your own database as soon as GET /v1/jobs/:id returns a completed result — assume the copy held by ocrwell has disappeared by the time you'd want to read it again.

Your documents are never used to train AI models. See the privacy policy for the full set of data-handling commitments, and the security page for the controls that protect them.

OCRWell

Precision-engineered document intelligence for the developers shaping the modern web.

Platform
Resources
Legal
© 2026 Corrected Cloud Pty Ltd. OCRWell is a product of Corrected Cloud Pty Ltd.
All systems operational