> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.emtelligent.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.emtelligent.com/_mcp/server.

# 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](/ocr/api) 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:

```bash
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`).

```bash
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:

```json
{
  "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:

```bash
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`.

```bash
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.

```bash
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.

```json
[
  {
    "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.

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

Which will return something like:

```json
{
  "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`.

```bash
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:

```json
{
  [...],
  "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:

| Field                         | Description                                                                                 |
| ----------------------------- | ------------------------------------------------------------------------------------------- |
| `count`                       | Number of pages that have finished this stage.                                              |
| `total_ms`                    | Sum of those pages' durations.                                                              |
| `min_ms`, `max_ms`, `mean_ms` | Smallest, largest, and average per-page duration. `null` until a page finishes the stage.   |
| `p50_ms`, `p95_ms`            | 50th and 95th percentile of the per-page durations. `null` until a page finishes the stage. |
| `slowest_page`                | 1-based page number with the largest duration in this stage.                                |

The two remaining fields describe the timeline as a whole:

| Field     | Description                                                                                                                                                      |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `span_ms` | Wall-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. |
| `pages`   | Per-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.

| Field                              | Description                                            |
| ---------------------------------- | ------------------------------------------------------ |
| `page`                             | 1-based page number.                                   |
| `render_start_ms`, `render_end_ms` | Start and end of the render bar, as offsets from `t0`. |
| `render_ms`                        | Render duration.                                       |
| `ocr_start_ms`, `ocr_end_ms`       | Start and end of the OCR bar, as offsets from `t0`.    |
| `ocr_ms`                           | OCR duration.                                          |

### Downloading results

```bash
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:

```bash
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

```bash
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.

```python
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.

```python
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.

```python
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')}")
```