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
https://api.ocrwell.com/v1HTTPS 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.
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.
/plugin marketplace add ocrwell/skill
/plugin install ocrwell@ocrwellUpdate 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.
$ 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 installUpdate 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:
$ export OCRWELL_API_KEY=...Verify it's loaded
Start a fresh session and ask the agent something OCRWell-shaped, for example:
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.
$ 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.
$ curl -X PUT "<upload_url>" \
-H "Content-Type: application/pdf" \
--data-binary @contract.pdf3. Submit for OCR
Submit the uploaded document for processing in text mode.
$ 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.
$ 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.
{
"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
$ 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
$ curl -X PUT "<upload_url>" \
-H "Content-Type: image/jpeg" \
--data-binary @receipt.jpg3. 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.
{
"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.
$ 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
$ 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.
{
"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:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1711180800X-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).
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": {
"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 errorsrequest_id— unique identifier for this request, useful for support enquiries
Error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request | Malformed request body or parameters |
| 400 | validation_error | One or more fields failed validation |
| 400 | unsupported_format | Document format not supported |
| 401 | unauthorized | Missing or invalid API key |
| 404 | not_found | Resource does not exist or belongs to a different org |
| 410 | result_expired | Job result has been deleted after the retention window |
| 409 | duplicate_request | Idempotent request already processed with a different body |
| 413 | file_too_large | Document exceeds the maximum file size |
| 422 | invalid_schema | Provided JSON schema for structured extraction is invalid |
| 429 | rate_limited | Rate limit exceeded |
| 500 | internal_error | Unexpected server error |
| 503 | service_unavailable | Temporarily 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.
Idempotency-Key: upload_inv_2026_03_01_001If 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.
Upload
POST /v1/uploadsGenerates 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
{
"filename": "invoice.pdf"
}Response — 200 OK
{
"upload_id": "019539a6-6c3d-7e5f",
"upload_url": "https://api.ocrwell.com/uploads/...",
"expires_in": 900,
"request_id": "019539b1-2a3b-7c4d"
}Uploading the file
$ curl -X PUT "<upload_url>" \
-H "Content-Type: application/pdf" \
--data-binary @invoice.pdfThe Content-Type header must match the file format declared in the filename.
| Extension | Content-Type |
|---|---|
| application/pdf | |
| .jpg, .jpeg | image/jpeg |
| .png | image/png |
| .tiff, .tif | image/tiff |
| .webp | image/webp |
| .bmp | image/bmp |
Submit document
POST /v1/documentsAsynchronous 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
{
"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 isstructured. JSON object describing the desired output. Accepts either a template or a JSON Schema.
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
{
"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.
{
"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.
{
"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.
{
"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:
{
"merchant": "",
"date": "",
"items": [
{"name": "", "price": 0}
],
"total": 0
}A completed job for this schema might return:
{
"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_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:
{
"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.
{
"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": "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. typeas an array (e.g.["string", "null"]) — the first non-null entry is used.- Nested
propertiesand single-schemaitems. - Local
$refresolved against#/$defs/...or#/definitions/.... constandenum— 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
GET /v1/jobs/:jobIdRetrieves 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 workerprocessing— an OCR worker is processing the documentcompleted— processing finished successfullyfailed— processing failed
Completed — text mode
{
"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
{
"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
{
"job": {
"id": "019539a6-6c3d-7e5f",
"status": "processing",
"mode": "text",
"created_at": "2026-03-23T02:15:00Z"
}
}Failed
{
"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.
{
"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.
Set webhook
PUT /v1/webhooksCreates 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.
{
"url": "https://example.com/ocr-callback"
}{
"webhook": {
"url": "https://example.com/ocr-callback",
"secret": "whsec_a1b2c3d4e5f6...",
"created_at": "2026-03-23T03:00:00Z"
}
}Get webhook
GET /v1/webhooksReturns 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
DELETE /v1/webhooksRemoves 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.
X-Ocrwell-Signature: sha256=a1b2c3d4e5f6...
X-Ocrwell-Timestamp: 1711159200
X-Ocrwell-Event: job.completedVerification steps
- Read the
X-Ocrwell-Timestampheader. - Concatenate
{timestamp}.{raw_body}. - Compute HMAC-SHA256 of the concatenated string using the webhook secret.
- Compare the result with the
X-Ocrwell-Signaturevalue using constant-time comparison. - Reject if the timestamp is more than 5 minutes old.
Event: job.completed
{
"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
{
"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
| Constraint | Limit |
|---|---|
| Maximum file size | 20 MB |
| Maximum pages per document | 1000 |
| Supported formats | PDF, JPEG, PNG, TIFF, WebP, BMP |
| Maximum schema size (structured mode) | 64 KB (applies to both submitted bytes and the converted template) |
| Maximum Idempotency-Key length | 64 characters |
| Upload URL validity | 15 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.
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.