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:
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).
The server responds with 201 and a JSON body describing the accepted job:
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:
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.
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:
- The server does not compare document bytes. An idempotency key sent again with a different document returns the original job.
- A deleted or expired job releases its key, so you can process the same document again after its results are purged.
- A
failedjob 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.
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.
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.
Which will return something like:
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
stateisdone - 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.
The augmented response carries a top-level page_timings object alongside the fields shown above:
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:
The two remaining fields describe the timeline as a whole:
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.
Downloading results
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:
Deleting a job’s data
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.
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.
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.

