Skip to navigation

End-User Tutorial

This document is intended to serve as a tutorial to end users of an OCR server directly consuming its API endpoints.

The API reference in this documentation describes each endpoint and its requests and responses. It is generated from the OpenAPI specification of the API. A running OCR server also shows its OpenAPI specification at its /docs endpoint.

Requirements

This tutorial uses the curl CLI and Python to demonstrate HTTP API interaction.

These programs are not hard requirements. Any program that can submit HTTP GET and POST requests is sufficient to replace curl, and likewise for any program able to manipulate JSON data to replace Python.

Authentication

The API requires an emtelligent platform API key, sent as a bearer token in the Authorization header of every request. Keys are created in the platform console for your organization and need the ocr:process scope. A request with a missing, unknown, or revoked key is rejected with 401. A key without the ocr:process scope is also rejected with 401. A valid key whose organization is suspended or out of credit is rejected with 402. A 503 means the server cannot verify the key at that time, and you should retry the request later.

Every key in an organization sees the same jobs. GET /me returns your organization id, owner id (your key id), your key’s scopes, credit remaining (in cents, negative when owed), and the pages billed to your organization over the recent window, which starts at usage_since. soft_limit is true when your organization is over its spending threshold but is still served, and stale is true when the server answered from a cache during a platform outage.

Each job reserves its page count against your organization’s credit before rendering. When the credit does not cover the job, the job ends in state failed with an error that begins with InsufficientCredit: and details requested vs. available credit. The upload itself still returns 201 instead of 402 or similar due to limitations surrounding credit check infrastructure. You should not assume that a 201 response on upload means your job has been fully accepted.

The examples below read the key from an API_KEY shell variable and pass it as -H "Authorization: Bearer $API_KEY". Set it once for your session:

export API_KEY='your-api-key'

Usage

To illustrate and describe usage of OCR, this section will walk you through a full job lifecycle, from submitting the input document to pulling its results.

All examples below assume that OCR is deployed to and available at https://ocr.emtelligent.com. Replace the URLs in snippets with wherever your instance is deployed to.

Document submission

First, submit your document as the raw request body. Accepted formats are PDF, PNG, JPEG, and TIFF (including multi-page TIFF).

The X-Filename header is optional and sets the job’s display name. It does not affect document processing. Filenames must be URL percent encoded (e.g. é -> %C3%A9).

curl -s -X POST --data-binary @doc.pdf \
-H "Authorization: Bearer $API_KEY" \
-H 'Content-Type: application/pdf' \
-H 'X-Filename: doc.pdf' \
'https://ocr.emtelligent.com/jobs'

The server responds with 201 and a JSON body describing the accepted job:

{
"job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"filename": "doc.pdf",
"mime_type": "application/pdf",
"state": "queued",
"bytes_received": 184320,
"status_url": "/jobs/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"result_url": "/jobs/3fa85f64-5717-4562-b3fc-2c963f66afa6/result"
}

The request body only accepts raw file bytes, and does not support multipart/form-data. The server ignores any Content-Type header. It rejects anything that it does not consider a PDF, PNG, JPEG, or TIFF with 415. Empty bodies are rejected with 400, and uploads over the configured size limit with 413. A 409 means that a concurrent submission with the same Idempotency-Key (see below) failed during its upload, and you should retry the request, or that the job was deleted during the upload.

The detected format is reported back as the mime_type field (one of application/pdf, image/png, image/jpeg, or image/tiff) so you can confirm what your document is being processed as. The same field appears on job summaries and job statuses. Note that in those contexts, it can be null for a job whose upload was cut off before the format check.

Every later step needs the returned job_id, so we recommend capturing it systematically.

Below is an example snippet submitting a document and capturing the returned job_id as a shell variable:

JOB_ID=$(curl -s -X POST --data-binary @doc.pdf \
-H "Authorization: Bearer $API_KEY" \
-H 'Content-Type: application/pdf' \
-H 'X-Filename: doc.pdf' \
'https://ocr.emtelligent.com/jobs' \
| python -c 'import json, sys; print(json.load(sys.stdin)["job_id"])')

Idempotent submission (de-duplication)

The optional Idempotency-Key header is intended to close the gap where your client may fault between submission and recording the returned job_id, resubmitting work that is already being processed by the server.

The key is an opaque string of 1 to 255 printable ASCII characters that you choose, for example a document hash or a stable order number. This key must be unique in your organization, as the server scopes each key to your organization i.e. two users in the same organization who send the same key get the same job. A key that is empty, longer than 255 characters, or contains anything other than printable ASCII is rejected with 422.

curl -s -X POST --data-binary @doc.pdf \
-H "Authorization: Bearer $API_KEY" \
-H 'Content-Type: application/pdf' \
-H 'X-Filename: doc.pdf' \
-H 'Idempotency-Key: invoice-2026-0042' \
'https://ocr.emtelligent.com/jobs'

The first submission with a key creates the job and sees a 201 response, as shown above. A repeat submission with the same key answers 200 with the job’s current status (the same body as the status endpoint below) — this is considered a replay and does NOT reprocess the document. You should differentiate the replay vs. fresh upload response based off the HTTP status code.

Three properties of the key to plan around:

  1. The server does not compare document bytes. An idempotency key sent again with a different document returns the original job.
  2. A deleted or expired job releases its key, so you can process the same document again after its results are purged.
  3. A failed job keeps its key until it is deleted or expires. Resubmitting with the same key returns the failed job, so delete it before you retry.

Listing all available jobs

You can list all jobs, processing or complete, via the /jobs endpoint.

curl -s -H "Authorization: Bearer $API_KEY" https://ocr.emtelligent.com/jobs

This command returns a JSON array of job summaries. You can optionally pipe it through something like python -m json.tool or jq to pretty-print.

[
{
"job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"filename": "doc.pdf",
"mime_type": "application/pdf",
"state": "ocr",
"created_at": "2026-06-22T17:24:01+00:00"
},
{
"job_id": "...",
[...]
},
[...]
]

Listing a specific job’s details

To pull a specific job’s detailed status, you can hit the /jobs/[job_id] endpoint. You should poll this endpoint to track progress for a specific job, rather than searching the results of the general /jobs endpoint. A job_id that does not exist in your organization returns 404, here and on the result and delete endpoints.

curl -s -H "Authorization: Bearer $API_KEY" "https://ocr.emtelligent.com/jobs/$JOB_ID"

Which will return something like:

{
"job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"filename": "doc.pdf",
"mime_type": "application/pdf",
"state": "ocr",
"created_at": "2026-06-22T17:24:01+00:00",
"updated_at": "2026-06-22T17:24:09+00:00",
"finished_at": null,
"results_expire_at": null,
"results_purged_at": null,
"purge_reason": null,
"purged_by": null,
"error": null,
"render": {
"total_pages": 12,
"rendered": 12,
"failed": 0,
"loaded_from_log": 0,
"in_flight_pages": 0
},
"ocr": {
"submitted": 12,
"completed": 7,
"failed": 0,
"in_flight": 5
},
"page_timings": null,
"ocr_service": { "in_flight": 5, "queued": 0, "completed_total": 7 },
"page_budget": { "limit": 500, "available": 495, "waiting": 0 },
"render_pool": { "limit": 3, "available": 3, "waiting": 0 },
"result_available": false
}

You can see the response schema in more detail in the OpenAPI spec.

The endpoint’s response reports the job state, the detected input mime_type, per-stage counters under render and ocr, and live service gauges.

A job moves sequentially through queued, rendering, ocr, and terminates at done or failed. A job can move to failed from any other state, including queued (for example when your organization’s credit does not cover it), and a running job returns to queued if the server restarts. finished_at is set once the job reaches a terminal state, and results_expire_at (populated only when the deployment configures a retention period) is computed from it. Both are null while the job is still running.

The result_available field is true while the result file can actually be downloaded i.e. the following conditions are true

  • The job’s state is done
  • Its results have not been purged or expired.

Consequently, the result_available field may flip back to false after a purge or expiry even though state remains done.

The top-level render and ocr objects contain stage-specific page data for this particular job, while ocr_service, page_budget, and render_pool are global gauges displaying service availability across all jobs.

You can add the ?timings=true query parameter to fold in per-page render and parsing timing distributions under page_timings.

curl -s -H "Authorization: Bearer $API_KEY" "https://ocr.emtelligent.com/jobs/$JOB_ID?timings=true"

The augmented response carries a top-level page_timings object alongside the fields shown above:

{
[...],
"page_timings": {
"render": {
"count": 12,
"total_ms": 4213.2,
"min_ms": 288.4,
"max_ms": 612.7,
"mean_ms": 351.1,
"p50_ms": 333.0,
"p95_ms": 600.2,
"slowest_page": 9
},
"ocr": {
"count": 7,
"total_ms": 17343.0,
"min_ms": 1904.1,
"max_ms": 3201.8,
"mean_ms": 2477.6,
"p50_ms": 2402.5,
"p95_ms": 3150.0,
"slowest_page": 4
},
"span_ms": 6321.4,
"pages": [
{
"page": 1,
"render_start_ms": 0.0,
"render_end_ms": 351.1,
"render_ms": 351.1,
"ocr_start_ms": 353.4,
"ocr_end_ms": 2831.0,
"ocr_ms": 2477.6
},
{
"page": 9,
"render_start_ms": 1583.0,
"render_end_ms": 2195.7,
"render_ms": 612.7,
"ocr_start_ms": null,
"ocr_end_ms": null,
"ocr_ms": null
}
]
}
}

Only finished work is reflected here. A page appears under pages once it has at least one finished stage, and a stage’s fields remain null until that stage completes for the page. Pages still in flight are tracked by the live render and ocr counters described above, not in page_timings.

The render and ocr objects share one shape. Each summarizes the per-page wall-clock duration distribution (in milliseconds) for pages that have finished that stage:

FieldDescription
countNumber of pages that have finished this stage.
total_msSum of those pages’ durations.
min_ms, max_ms, mean_msSmallest, largest, and average per-page duration. null until a page finishes the stage.
p50_ms, p95_ms50th and 95th percentile of the per-page durations. null until a page finishes the stage.
slowest_page1-based page number with the largest duration in this stage.

The two remaining fields describe the timeline as a whole:

FieldDescription
span_msWall-clock span of all per-page work so far, measured from t0 (the start of the earliest page span) to the latest span end. null until the first span lands.
pagesPer-page timeline rows, one per page that has finished at least one stage, sorted by page number.

Each pages row places one page’s render and OCR bars on a shared timeline. Every *_start_ms and *_end_ms value is an offset in milliseconds from t0. You can use this information to draw a Gantt chart.

FieldDescription
page1-based page number.
render_start_ms, render_end_msStart and end of the render bar, as offsets from t0.
render_msRender duration.
ocr_start_ms, ocr_end_msStart and end of the OCR bar, as offsets from t0.
ocr_msOCR duration.

Downloading results

curl -s -H "Authorization: Bearer $API_KEY" "https://ocr.emtelligent.com/jobs/$JOB_ID/result" -o result.json

The result endpoint returns 409 while the job is still running, 200 once its state is done, and 410 when the job failed or its results were deleted or expired. A failed job never reaches done and never has results. Poll the status endpoint until state is done to know when results are ready, or failed to stop waiting on a job that will not produce one.

The endpoint returns 410 once the job’s results no longer exist: either their retention period elapsed or the job was deleted. A 410 is permanent, so download results promptly once the job reaches done. The status endpoint keeps working after a 410. results_purged_at and purge_reason report when and why the results were removed, and results_expire_at shows the deadline ahead of time.

Append ?timings=true to this endpoint to embed per-page timings into the downloaded file under a top-level page_timings key.

This is an example loop that waits for the job to finish and then downloads the result:

while :; do
state=$(curl -s -H "Authorization: Bearer $API_KEY" "https://ocr.emtelligent.com/jobs/$JOB_ID" \
| python -c 'import json, sys; print(json.load(sys.stdin)["state"])')
case "$state" in
done) curl -s -H "Authorization: Bearer $API_KEY" "https://ocr.emtelligent.com/jobs/$JOB_ID/result" -o result.json; break ;;
failed) echo "job failed"; break ;;
*) sleep 2 ;;
esac
done

Deleting a job’s data

curl -s -X DELETE -H "Authorization: Bearer $API_KEY" "https://ocr.emtelligent.com/jobs/$JOB_ID"

Deletion permanently removes the job’s content — the OCR output, the uploaded document, and the rendered page images — as soon as the request returns (204). Use it once you have downloaded your results, or to scrub a document that should not have been submitted. A job that is still running is cancelled first, and ends in state failed with the error job deleted before completion. Repeating the delete returns another 204.

Common Usage Patterns

While the result.json does provide a comprehensive set of data extracted from submitted documents (along with their metadata), you may want to further process or aggregate the various fields within a single document’s results. Below are some common aggregation and processing patterns, expressed as Python scripts. Each example assumes you’ve already downloaded the result.json file to the current working directory.

Aggregating all text across pages

result.json only aggregates text up to the page-level, so you may want to concatenate the per-page text into one document string.

import json
with open("result.json", encoding="utf-8") as f:
result = json.load(f)
sorted_pages = sorted(result["pages"], key=lambda p: p["page_number"])
full_text = "\n\n".join(
page["text"] for page in sorted_pages if page.get("text")
)
print(full_text)

While the pages in result.json are already sorted by their page_number, it doesn’t hurt to guard against misordering by sorting them yourself.

The page.get("text") guard skips failed pages, whose text is null. You can instead directly check the truthiness of the error or error_type fields, as an alternative guard.

Replacing markdown with HTML

The page-level text renders table blocks as markdown. To keep tables as HTML instead, rebuild the page text from text_layout and prefer each block’s html. Table blocks contain both html and markdown, while text blocks only have text, so preferring html over text yields HTML tables alongside plain text.

import json
def page_text_html(page: dict) -> str:
"""Reassemble a page's text, using HTML for table blocks."""
parts = []
for block in page.get("text_layout") or []:
content = block.get("html") or block.get("text")
if content:
parts.append(content)
return "\n".join(parts)
with open("result.json", encoding="utf-8") as f:
result = json.load(f)
sorted_pages = sorted(result["pages"], key=lambda p: p["page_number"])
full_text = "\n\n".join(page_text_html(page) for page in sorted_pages)
print(full_text)

Collecting failed page details

In result.json, failed_pages is the quick list of affected page numbers. You can read the reason for each from the matching pages entry, where error_type specifies the stage and error contains the detailed error message.

import json
with open("result.json", encoding="utf-8") as f:
result = json.load(f)
print("failed pages:", result["failed_pages"])
for page in result["pages"]:
if page.get("error_type"):
print(f"page {page['page_number']}: {page['error_type']} - {page.get('error')}")