Document Translation

Start here: README · Related: Authentication · Conventions & errors · Text translation · Storage

The document endpoints translate PDF, DOCX and XLSX files in place — the original layout, pagination, tables and styling are preserved, and only the text is replaced. Submission is asynchronous by default: you upload a file, receive a job, poll it to completion, then optionally walk the document's segments, post human corrections, approve, and download the translated file. Use this page when you need the source file back in its original format; for plain-text or batch string translation use the text endpoints (POST /api/translations).

  • Base URL (production): https://trueidiom.com
  • Path prefix: /api
  • API version: ?api-version=2026-09-01 — optional; omitting it serves the oldest supported version. X-Api-Version: 2026-09-01 is an accepted alias for the query parameter, which wins when both are sent, and a pin naming a version that has not been released is rejected with 400 naming the supported set. Full contract: Versioning

Authentication

These endpoints are published in the OpenAPI document served at /openapi.json. The generated document declares no securitySchemes and no global security requirement, so the interactive /docs page shows every endpoint as unauthenticated. They are not: auth is enforced server-side, and every endpoint on this page requires tenant credentials.

Send credentials in one of two ways:

Method Header(s) Where you get it
Tenant API key X-API-Key: <tenant_api_key> tenant_api_key in the POST /api/auth/signup/email response, or api_key from POST /api/tenants (admin-only). Returned exactly once — see Authentication. Optionally add X-Tenant-ID: <tenant_id> to pin the account when one key could match several.
Session access token Authorization: Bearer <access_token> Issued by POST /api/auth/signup/email, POST /api/oauth2/token and POST /api/auth/mfa/verify — see the token endpoint. A Bearer value is also accepted as an API key if it does not resolve to a session.

Every curl example on this page uses $TRUEIDIOM_API_KEY for a tenant API key. If you do not have one yet:

export TRUEIDIOM_API_KEY=$(curl -sS -X POST "https://trueidiom.com/api/auth/signup/email?api-version=2026-09-01" \
  -H "Content-Type: application/json" \
  -d '{"tenant_name":"Acme Legal","email":"you@example.com","password":"correct-horse-battery-staple"}' \
  | jq -r .tenant_api_key)

Use the session access token instead when you want the reviewer identity on /review and /approve derived server-side rather than declared in the body.

Without usable credentials every endpoint below returns 401:

{ "detail": "missing or invalid tenant credentials" }

On deployments that run with a single global operator key and no tenant accounts, the same 401 reads {"detail": "missing or invalid API key"}.

Tenant scoping. Reads are scoped to the calling tenant. A document belonging to another tenant returns 404 document job not found — never 403 — so document ids are not confirmed to callers who may not see them. The global_administrator role does not widen this; it is a per-tenant admin role. Only the platform operator API key or a designated platform-operator session sees across tenants.

Reviewer identity. For /review and /approve, the server derives the reviewer from the authenticated session (email, falling back to user id) and that value wins over any reviewer field in the request body. A client-supplied reviewer is only honoured when no session can be resolved (API-key-only clients). See Reviewer identity.

Every response carries an X-Request-ID header. Send your own X-Request-ID to correlate with server logs.


The job model

A document upload creates a document translation job (TenantDocumentJob) that moves through these statuses:

status Meaning
pending Model default; not observed on submitted jobs.
queued Accepted and persisted; text extraction has not finished.
extracted Text extraction finished; segment translation in progress.
translated Terminal success. The quality report and the translated output file are both stored.
failed Terminal failure. error holds the message and failure the structured detail.

status only becomes translated after the translated output artifact is registered, so a poller that sees translated can download the translated file immediately.

POST /api/documents/translate
        │
        ├── wait=true  ── runs inline ──┐
        │                               │
        └── default ──► queued ─────────┤
                          │  (poll GET /api/documents/{id} — there are no webhooks)
                          ▼             │
                      extracted ────────┤
                          │             │
              ┌───────────┴─────────────┴───────────┐
              ▼                                     ▼
          translated                             failed
              │                                (failure.code:
              │                                 stage_timeout | transient_error
              │                                 | unexpected_error | interrupted)
              ├──► GET  .../download          ← available now, and after every re-render
              ├──► GET  .../segments?flagged=true
              ├──► POST .../review     (repeatable; re-renders; status unchanged)
              └──► POST .../approve    (idempotent; stamps approved_at/approved_by;
                                        feeds the golden translation memory)

Neither review nor approve changes status: a reviewed, approved document stays translated. Approval state lives in approved_at / approved_by.

Pipeline stages, in order, as they appear in failure.stage:

ingestextractmap_translatereconstruct

Review adds four more stage names (review_apply_corrections, review_validate, review_persist_layout, review_reconstruct), and a job interrupted by a service restart is marked failed with failure.stage = "worker".

What a job response contains

Every endpoint on this page that returns a job returns the same shape, whether the job is queued, translated or failed:

  • layout is always null. The extracted layout is kept server-side and is not served. Reviewable content comes from GET /api/documents/{document_id}/segments, which pages the same server-side walk the renderer and the approval sweep use.
  • artifacts lists the source document you uploaded and the translated output — the two kinds you can act on. The pipeline's own intermediate files are not listed, and entries carry no server-side path.
  • quality_report and each segment's quality carry review signals — scores, needs_review, terminology compliance, fast-path provenance — and not the details of how a segment was produced.
  • billable_chars is the metering quantity for the job; see Tenants, usage & billing.

Platform-operator diagnostics (the platform operator API key or a designated platform-operator session) receive extended fields on these same routes; everything documented on this page is the tenant contract, which is also what /openapi.json publishes.

Layout preservation and in-place translation

A document is translated one unit at a time — a text region, or a single table cell — and each translation is written back into the document in place. What that preserves, per format:

  • PDF — page count, page size, rotation and images are unchanged, and translated text is laid back into the same position and typography as the source. Where a translation runs longer than the source it replaces, the text is shrunk to fit its original box and, at the limit, truncated; neither happens silently — each is reported as a quality finding on the job (see Finding codes).
  • DOCX / XLSX — styles, numbering, formulas and sheet structure survive untouched; only the text changes.

Because the unit of work is a region or a table cell, the reviewable segment ids you get back (region_id, cell_id) address exactly the units the translated file is written from. That is what lets POST /review put a corrected translation back into the one unit it belongs to. The re-render that follows is not partial: every review re-renders the entire output file and appends a new artifact.


Supported uploads

Format Extension Content-Type for the file part Job source_format
PDF .pdf application/pdf (also application/x-pdf) pdf
Word .docx application/vnd.openxmlformats-officedocument.wordprocessingml.document docx
Excel .xlsx application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx

Detection rules:

  1. The file extension wins. HTTP clients routinely send application/octet-stream for Office files, so report.docx with a generic content type is still detected as DOCX.
  2. If the name carries no known extension, the declared content type is used. When a file is matched on content type alone, the server appends the format's extension to the stored file name — uploading contract as application/pdf yields source_file_name: "contract.pdf".
  3. Anything else is rejected with 415, and the error names the accepted set.

Request bodies are capped at 30 MB (30,000,000 bytes). A body over the cap is rejected with 413 before it is read in full.

Language codes must come from the supported set — en, es, fr, de, it, pt-BR, pt-PT, nl, zh-Hans, zh-Hant, ja, ko, ar, ru, hi, tr, vi, th, id. Matching is case-insensitive on the exact code (ENen); regional subtags and bare variant bases (en-US, fr-CA, zh, pt) are not accepted.

An unsupported code is rejected before any job is created. The pair is checked at the API boundary, the same check and the same body as POST /api/translations: 422, naming the codes it rejected and the whole supported set — {"detail": "unsupported language(s): 'en-US'. Supported: en, es, fr, …"}. Validating client-side still saves the round trip.


POST /api/documents/translate

Upload a source document and start a translation job.

Auth: tenant credentials required (401 without). Also gated by the deployment's translation switch (503 when off) and, when billing is enabled, by an active subscription (402). Layout document translation is never covered by the free-character trial — a subscription is required whenever billing is on.

Content type: multipart/form-data. The endpoint reads the form directly, which is why no request body appears in openapi.json; the fields it reads are below.

Form fields

Name Type Required Description
source_document file Yes The source file. Missing or non-file → 400 source_document upload is required. Zero bytes → 400 source_document is empty.
target_lang string Yes Target language code. Empty or absent → 400 target_lang is required.
source_lang string No Source language code. Defaults to en.
domain string No Domain hint (e.g. legal, medical) forwarded to the translation engine and echoed on request.domain.
tone string No Register instruction applied to every segment's baseline translation (e.g. formal, informal, neutral). Same semantics as the TranslationRequest field. Blank or absent means no register instruction, not a default.
gender string No Grammatical gender for agreement, applied to every segment's baseline translation. Governs agreement only — see the TranslationRequest field.
text_type string No Accepted for request-shape symmetry with TranslationRequest and echoed on request.text_type. Plain (the default) is the only value that does anything here — see the note below.
wait string No 1, true or yes (case-insensitive) runs the pipeline inline and returns 200 with the finished job. Anything else, or absent, queues the job and returns 202.

text_type: "Html" does nothing on a document job. This endpoint handles PDF/DOCX/XLSX, and none of those formats carry their inline formatting as HTML tags — asking for markup to be reproduced verbatim would only put stray angle brackets in the output. The value is recorded on the job and otherwise ignored. To translate HTML, post the fragment or an .html/.htm upload to POST /api/translations, which honors text_type in full.

Headers

Name Required Description
Idempotency-Key No Recorded on the job as request.idempotency_key. A resubmit with the same key returns the existing job instead of creating a second one.
X-Request-ID No Echoed back and bound to the server's log context.

Response codes

Status When
202 Queued. Body is the job in queued status. This is the default path.
200 Returned for wait=true, and also for an idempotent resubmit whose job already reached translated or failed.
400 Missing file, empty file, missing target_lang, or a text_type outside Plain/Html.
401 Missing or invalid tenant credentials.
402 Billing is enabled and the tenant has no active subscription.
413 Request body exceeds the 30 MB upload cap.
415 File type not in the supported set.
422 source_lang or target_lang outside the supported set — unsupported language(s): …. Supported: …. No job is created.
429 tenant usage limit exceeded — the tenant's monthly character quota is spent. Checked at submission, before the upload is read.
503 Translation is switched off for the deployment.
507 The platform storage cap is reached for this tenant. The detail names the bytes used and the cap, and points at deleting document jobs (DELETE /api/documents/{document_id}) or moving to your own storage (PUT /api/storage/binding).

Example

curl -sS -X POST "https://trueidiom.com/api/documents/translate?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  -H "Idempotency-Key: contract-2026-07-26-001" \
  -F "source_document=@contract.pdf;type=application/pdf" \
  -F "source_lang=en" \
  -F "target_lang=fr" \
  -F "domain=legal"
HTTP/1.1 202 Accepted
{
  "id": "b92fa968814843b08aea7b78f84d3894",
  "tenant_id": "7fa2bff864961656",
  "request": {
    "source_lang": "en",
    "target_lang": "fr",
    "domain": "legal",
    "idempotency_key": "contract-2026-07-26-001",
    "metadata": {
      "content_type": "application/pdf",
      "size_bytes": 83421,
      "idempotency_key": "contract-2026-07-26-001",
      "tenant": { "id": "7fa2bff864961656", "name": "acme" }
    },
    "text_type": null,
    "tone": null,
    "gender": null
  },
  "status": "queued",
  "source_file_name": "contract.pdf",
  "source_format": "pdf",
  "layout": null,
  "quality_report": null,
  "billable_chars": 0,
  "review_history": [],
  "artifacts": [
    {
      "id": "fef4070fce1241dbb28a3d9ffd910478",
      "kind": "source_pdf",
      "stage": "ingest",
      "content_type": "application/pdf",
      "checksum": "8f27627500903fb03ee21bac28b291bde1e451900a31e0763f496f2ec8e180ab",
      "size_bytes": 83421,
      "created_at": "2026-07-26T09:14:02.370869Z",
      "metadata": { "source_file_name": "contract.pdf" }
    }
  ],
  "approved_at": null,
  "approved_by": null,
  "feed_manifest": { "fed_pairs": [], "skipped": {}, "golden_skipped_at_cap": 0, "last_feed_error": null },
  "error": null,
  "failure": null,
  "created_at": "2026-07-26T09:14:02.368957Z",
  "updated_at": "2026-07-26T09:14:02.370877Z"
}

Choosing sync vs async

wait=true holds the connection open until the document is finished and returns the completed job. Budgets are server-side: each stage gets 120 s and up to two automatic retries, and a busy service queues submissions rather than running them all at once. Prefer the default async path for anything larger than a couple of pages.

With wait=true, a stage failure surfaces as an error response rather than a job body — but the job is still persisted with status: "failed" and a populated failure object. Only a pipeline-typed failure produces the documented JSON error shape; an exception raised outside the pipeline's own stages can reach you as an untyped 500 with a body that is not JSON at all. Either way the persisted job record is the authoritative outcome, so read it rather than the response: resubmit with the same Idempotency-Key (which returns the existing job), or list GET /api/documents?status=failed.

Uploading a .pdf/.docx/.xlsx to POST /api/translations as multipart/form-data with a source_document part routes into this same document pipeline and returns a document job in the same shape this endpoint returns. Poll and download it through the endpoints on this page.


GET /api/documents

List the calling tenant's document jobs, most recently updated first (updated_at descending).

Auth: tenant credentials required (401 without). Results are filtered to the caller's tenant.

layout is null here, as it is on every document response. Leaving it out is what keeps list responses small. quality_report, artifacts and review_history are included; page through reviewable content with GET /api/documents/{document_id}/segments.

Query parameters

Name Type Required Description
status string No One of pending, queued, extracted, translated, failed. Anything else → 422.
failure_code string No One of stage_timeout, transient_error, unexpected_error, interrupted. Enforced by pattern; other values → 422.
failure_stage string No Exact stage name, min length 1 (e.g. extract, map_translate, reconstruct, worker).
created_since string (date-time) No ISO 8601. Keeps jobs with created_at >= created_since.
created_until string (date-time) No ISO 8601. Keeps jobs with created_at <= created_until.

Example

curl -sS -G "https://trueidiom.com/api/documents?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  --data-urlencode "status=failed" \
  --data-urlencode "failure_code=stage_timeout" \
  --data-urlencode "created_since=2026-07-01T00:00:00Z"
[
  {
    "id": "b92fa968814843b08aea7b78f84d3894",
    "tenant_id": "7fa2bff864961656",
    "request": {
      "source_lang": "en",
      "target_lang": "fr",
      "domain": "legal",
      "idempotency_key": null,
      "metadata": {
        "content_type": "application/pdf",
        "size_bytes": 83421,
        "idempotency_key": null,
        "tenant": { "id": "7fa2bff864961656", "name": "acme" }
      },
      "text_type": null,
      "tone": null,
      "gender": null
    },
    "status": "failed",
    "source_file_name": "contract.pdf",
    "source_format": "pdf",
    "layout": null,
    "quality_report": null,
    "billable_chars": 0,
    "review_history": [],
    "artifacts": [
      {
        "id": "fef4070fce1241dbb28a3d9ffd910478",
        "kind": "source_pdf",
        "stage": "ingest",
        "content_type": "application/pdf",
        "checksum": "8f27627500903fb03ee21bac28b291bde1e451900a31e0763f496f2ec8e180ab",
        "size_bytes": 83421,
        "created_at": "2026-07-26T09:14:02.370869Z",
        "metadata": { "source_file_name": "contract.pdf" }
      }
    ],
    "approved_at": null,
    "approved_by": null,
    "feed_manifest": { "fed_pairs": [], "skipped": {}, "golden_skipped_at_cap": 0, "last_feed_error": null },
    "error": "TimeoutError",
    "failure": {
      "code": "stage_timeout",
      "stage": "map_translate",
      "message": "TimeoutError",
      "error_type": "TimeoutError",
      "retry_attempts": 2,
      "transient": true,
      "timeout": true,
      "metadata": {}
    },
    "created_at": "2026-07-26T09:14:02.368957Z",
    "updated_at": "2026-07-26T09:20:11.114003Z"
  }
]

Errors: 401 (no credentials), 422 (invalid filter value).


GET /api/documents/summary

Aggregate job counts grouped by status and failure taxonomy.

Auth: tenant credentials required for the plain summary (401 without); the counts cover only the caller's tenant. The two breakdown flags aggregate across all tenants, so they are reserved for the platform operator API key or a designated platform-operator session — and which refusal you get depends on how you authenticated. A tenant API key is stopped at the admin gate with 401 admin credentials required and never reaches the flags. A signed-in admin who is not a platform operator — a global_administrator is exactly that — reaches them and gets 403 cross-tenant breakdowns require operator access; a signed-in non-admin is turned away one step earlier with 403 admin role required.

Query parameters

Name Type Required Description
status string No Same enum as GET /api/documents.
failure_code string No Same pattern as GET /api/documents.
failure_stage string No Exact stage name, min length 1.
created_since string (date-time) No ISO 8601 lower bound on created_at.
created_until string (date-time) No ISO 8601 upper bound on created_at.
include_tenant_breakdown boolean No Default false. Adds by_tenant. Operator only.
include_stage_timing_breakdown boolean No Default false. Adds stage_timings_ms. Operator only.

Example

curl -sS "https://trueidiom.com/api/documents/summary?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"
{
  "total": 38,
  "failed": 2,
  "by_status": { "translated": 35, "queued": 1, "failed": 2 },
  "failures": {
    "by_code": { "stage_timeout": 1, "unexpected_error": 1 },
    "by_stage": { "map_translate": 1, "reconstruct": 1 }
  }
}

With include_stage_timing_breakdown=true (operator credentials), the response gains:

{
  "stage_timings_ms": {
    "overall": {
      "extract": { "count": 38, "avg_ms": 812.4, "p50_ms": 640.2, "p95_ms": 2104.9 },
      "map_translate": { "count": 38, "avg_ms": 9310.7, "p50_ms": 7420.0, "p95_ms": 24110.5 },
      "reconstruct": { "count": 37, "avg_ms": 1962.4, "p50_ms": 1801.0, "p95_ms": 3550.7 }
    }
  }
}

by_tenant (from include_tenant_breakdown=true) maps tenant_id — or the literal "unassigned" — to {"total": n, "failed": n}, and when both flags are set stage_timings_ms.by_tenant carries the same per-stage stats per tenant.

Errors: 401 (no credentials, or a tenant API key on the breakdown flags), 403 (a non-operator session on the breakdown flags), 422 (invalid filter value).


GET /api/documents/{document_id}

Fetch one job. This is the poll target: read status until it is translated or failed.

Auth: tenant credentials required (401 without). A job belonging to another tenant returns 404.

Path parameters

Name Type Required Description
document_id string Yes Job id returned by POST /api/documents/translate.

Example

curl -sS "https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"
{
  "id": "b92fa968814843b08aea7b78f84d3894",
  "tenant_id": "7fa2bff864961656",
  "request": {
    "source_lang": "en",
    "target_lang": "fr",
    "domain": "legal",
    "idempotency_key": "contract-2026-07-26-001",
    "metadata": {
      "content_type": "application/pdf",
      "size_bytes": 83421,
      "idempotency_key": "contract-2026-07-26-001",
      "tenant": { "id": "7fa2bff864961656", "name": "acme" }
    },
    "text_type": null,
    "tone": null,
    "gender": null
  },
  "status": "translated",
  "source_file_name": "contract.pdf",
  "source_format": "pdf",
  "layout": null,
  "quality_report": {
    "schema_version": "1.0",
    "document_id": "e67fe11f03804045ab65cabe55473d82",
    "source_lang": "en",
    "target_lang": "fr",
    "overall_score": 0.97,
    "translated_units": 24,
    "total_units": 24,
    "findings": [],
    "metadata": {
      "page_count": 3,
      "schema_version": "1.0",
      "llm_quality": { "needs_review_units": 2, "noncompliant_units": 1 }
    }
  },
  "billable_chars": 4820,
  "review_history": [],
  "artifacts": [
    {
      "id": "fef4070fce1241dbb28a3d9ffd910478",
      "kind": "source_pdf",
      "stage": "ingest",
      "content_type": "application/pdf",
      "checksum": "8f27627500903fb03ee21bac28b291bde1e451900a31e0763f496f2ec8e180ab",
      "size_bytes": 83421,
      "created_at": "2026-07-26T09:14:02.370869Z",
      "metadata": { "source_file_name": "contract.pdf" }
    },
    {
      "id": "2adee39df2994c288e2de190108f0040",
      "kind": "translated_pdf",
      "stage": "reconstruct",
      "content_type": "application/pdf",
      "checksum": "3515540984a6e48aff7821df05b50b97334c06c6b94d1a81b9b50a05bfd81670",
      "size_bytes": 91234,
      "created_at": "2026-07-26T09:14:12.403125Z",
      "metadata": { "rendered_pages": 3 }
    }
  ],
  "approved_at": null,
  "approved_by": null,
  "feed_manifest": { "fed_pairs": [], "skipped": {}, "golden_skipped_at_cap": 0, "last_feed_error": null },
  "error": null,
  "failure": null,
  "created_at": "2026-07-26T09:14:02.368957Z",
  "updated_at": "2026-07-26T09:14:12.408112Z"
}

The two artifacts above are the whole list for a completed PDF job: the source you uploaded and the translated output. For reviewable text, use GET /api/documents/{document_id}/segments, which is paged.

Errors: 401 (no credentials), 404 (unknown id, or another tenant's job).


GET /api/documents/{document_id}/segments

Paged list of reviewable segments, walked server-side off the persisted layout. This walk is the single source of truth for what counts as a segment.

Auth: tenant credentials required (401 without). Cross-tenant → 404.

Semantics that matter:

  • index is the segment's position in the full, unfiltered walk, so it stays stable across pages and across the flagged filter.
  • total is the size of the selected set (the filtered set when flagged=true).
  • flagged_total always counts flagged segments in the whole document, regardless of flagged, so you can render "N need attention" without a second request.
  • A job whose layout does not exist yet (queued, or failed before extraction) returns total: 0, flagged_total: 0, segments: [] with your limit/offset echoed back — not an error.
  • Table regions yield one segment per non-empty cell; every other region yields one segment from its source text. Empty-source regions and cells are dropped by the walk, which is exactly why index is not a positional index into layout.

Path parameters

Name Type Required Description
document_id string Yes Job id.

Query parameters

Name Type Required Description
flagged boolean No Default false. When true, only segments needing attention are returned (quality.needs_review is truthy or quality.terminology_compliant is exactly false).
limit integer No Default 100. Minimum 1, maximum 500. Out of range → 422.
offset integer No Default 0. Minimum 0. Negative → 422. An offset past the end returns an empty segments array with total unchanged.

Response body (DocumentSegmentsResponse)

Field Type Description
total integer Segments in the selected set.
flagged_total integer Flagged segments across the whole document.
limit integer Echo of the effective limit.
offset integer Echo of the effective offset.
segments array of DocumentSegment The page of segments.

Each DocumentSegment:

Field Type Description
index integer Position in the full unfiltered walk.
page integer 1-based page number.
target string region or table_cell.
region_id string 32-character hex region id — pass this back on a correction.
cell_id string | null "{table_id}:{row}:{column}" for table cells — a 32-character hex table id, a 0-based row and a 0-based column. null for regions. Echo it back verbatim on a correction; do not construct it yourself.
source string Source text of the unit.
translated string | null Current translation; null if the unit was not translated.
quality object The segment's review signals; {} when none were recorded. See below.

quality carries the review signals for the segment and nothing else. The complete set of keys it can contain:

Key Type Meaning
score number Quality score for this segment, 0100.
needs_review boolean The segment was flagged for a human reviewer. Cleared when a reviewer corrects it.
below_threshold boolean The score fell under the configured quality threshold.
terminology_compliant boolean Whether the translation used the tenant's enforced glossary terms.
missing_terms object Glossary terms expected but not found, as {term: count}.
fast_path string | null "tm_exact" or "glossary_exact" when the text was published verbatim from translation memory or the glossary — such a segment carries needs_review: false.
corrected boolean true once a human review edited this segment.

Keys are present only when the service recorded them: score and needs_review appear when per-segment quality scoring is enabled on the deployment, and fast_path appears whenever the text came straight from translation memory or the glossary. Scoring internals — traces, token counts, and what produced a given segment — are not part of this object.

Example

curl -sS -G "https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/segments?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  --data-urlencode "flagged=true" \
  --data-urlencode "limit=50" \
  --data-urlencode "offset=0"
{
  "total": 2,
  "flagged_total": 2,
  "limit": 50,
  "offset": 0,
  "segments": [
    {
      "index": 0,
      "page": 1,
      "target": "region",
      "region_id": "577ab4ee0485426fac76b74e22f4fdbc",
      "cell_id": null,
      "source": "Termination for cause",
      "translated": "Résiliation pour cause",
      "quality": {
        "score": 74.0,
        "below_threshold": true,
        "terminology_compliant": true,
        "missing_terms": {},
        "needs_review": true
      }
    },
    {
      "index": 7,
      "page": 2,
      "target": "table_cell",
      "region_id": "9c31e0af26b4471da58f7d0c1b6e2453",
      "cell_id": "4bde2f9c1a7048e6b39d5c81f0a2e743:0:3",
      "source": "Net 30",
      "translated": "Net 30",
      "quality": { "needs_review": true, "terminology_compliant": false }
    }
  ]
}

Errors: 401 (no credentials), 404 (unknown id, or another tenant's job), 422 (limit/offset out of bounds).


GET /api/documents/{document_id}/artifacts

List the job's document files: the source you uploaded and the translated output.

Auth: tenant credentials required (401 without). Cross-tenant → 404.

This is a metadata listing (TenantDocumentArtifact[]) — there is no per-artifact fetch endpoint. Retrieve the bytes with GET /api/documents/{document_id}/download, read the quality report inline from the job object, and page reviewable content with GET /api/documents/{document_id}/segments. The pipeline's own intermediate files are not listed (see What a job response contains).

Artifact kinds

kind Produced at stage content_type
source_pdf ingest application/pdf
source_document ingest the DOCX or XLSX content type (non-PDF sources)
translated_pdf reconstruct, and again at review application/pdf
translated_document reconstruct, and again at review the DOCX or XLSX content type

Artifacts are append-only. Each review re-render appends a new translated_pdf / translated_document entry with a fresh checksum; the last entry of that kind is the one whose checksum matches the stored bytes, and the one GET .../download serves.

Artifact fields (TenantDocumentArtifact)

Field Type Description
id string Artifact id.
kind string One of the kinds above.
stage string Pipeline stage that produced it.
content_type string MIME type of the stored bytes.
checksum string SHA-256 hex digest, verified on download.
size_bytes integer Size of the stored bytes.
created_at string (date-time) Creation timestamp.
metadata object Kind-specific extras about your document: source_file_name (ingest), rendered_pages (PDF render), applied_segments (DOCX/XLSX render), and applied_corrections / rejected_corrections / impacted_pages (review re-renders).

Example

curl -sS "https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/artifacts?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"
{
  "document_id": "b92fa968814843b08aea7b78f84d3894",
  "artifacts": [
    {
      "id": "fef4070fce1241dbb28a3d9ffd910478",
      "kind": "source_pdf",
      "stage": "ingest",
      "content_type": "application/pdf",
      "checksum": "8f27627500903fb03ee21bac28b291bde1e451900a31e0763f496f2ec8e180ab",
      "size_bytes": 83421,
      "created_at": "2026-07-26T09:14:02.370869Z",
      "metadata": { "source_file_name": "contract.pdf" }
    },
    {
      "id": "2adee39df2994c288e2de190108f0040",
      "kind": "translated_pdf",
      "stage": "reconstruct",
      "content_type": "application/pdf",
      "checksum": "3515540984a6e48aff7821df05b50b97334c06c6b94d1a81b9b50a05bfd81670",
      "size_bytes": 91234,
      "created_at": "2026-07-26T09:14:12.403125Z",
      "metadata": { "rendered_pages": 3 }
    }
  ]
}

Errors: 401 (no credentials), 404 (unknown id, or another tenant's job).


GET /api/documents/{document_id}/download

Stream the translated file.

Auth: tenant credentials required (401 without). Cross-tenant → 404.

Selection rules:

  1. If the job is still queued, respond 409 — serving what exists would hand back an untranslated document. Keep polling. The 409 is keyed on queued only; a job in any other status falls through to artifact selection.
  2. Otherwise take the most recent translated_pdf / translated_document artifact.
  3. If there is none (e.g. the job failed after ingest, or is mid-pipeline in extracted), fall back to the most recent source_pdf / source_document. Check status == "translated" before treating the bytes as a translation.
  4. If neither exists, respond 404 — this is what a pending job (which has no artifacts) returns, not 409.

Only call this once status is translated if you want the translated file. Any earlier status either blocks you (409) or hands you the source bytes with a 200.

The stored SHA-256 is re-verified against the bytes before they are returned; a mismatch fails the request with 500 document artifact failed integrity check rather than serving suspect content.

Response headers:

Header Value
Content-Type The selected artifact's content_typeapplication/pdf, or the DOCX/XLSX content type for Office sources.
Content-Disposition attachment; filename="<source_file_name>" — note this is the source file name, not a translated-suffixed name. Rename client-side if you need contract.fr.pdf.

Example

curl -sS -o contract.fr.pdf -D - \
  "https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/download?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"
HTTP/1.1 200 OK
content-type: application/pdf
content-disposition: attachment; filename="contract.pdf"
content-length: 91234
x-request-id: c2ac6a3b91ad4608ad744a56fd74d448

Errors: 401 (no credentials), 404 (unknown id, another tenant's job, no downloadable artifact, or the stored bytes are missing), 409 (job still queued), 500 (checksum mismatch).


POST /api/documents/{document_id}/review

Apply human corrections to individual segments, then re-validate, re-render and persist the document.

Auth: tenant credentials required (401 without). Cross-tenant → 404, checked before anything is mutated. The reviewer identity is derived server-side; see Reviewer identity.

What one call does, in order: apply each correction to the layout → re-run the document validator and replace quality_report → persist the corrected layout server-side → re-render the entire output file — not only the pages corrections touched — and append a new translated artifact → feed the applied corrections into the tenant's golden translation memory (best-effort) → append a DocumentReviewRecord to review_history.

Review is repeatable. A review that succeeds does not approve the document and does not change status — the job stays translated. A review that raises once it is under way is the exception: the job is marked failed, with the stage it died in recorded as a review_* value in failure.stage. The typed refusals below (400, 404) are checked before any stage begins and leave the job exactly as it was.

Path parameters

Name Type Required Description
document_id string Yes Job id.

Request body (DocumentReviewRequest, application/json, required)

Name Type Required Description
corrections array of DocumentReviewCorrection No Defaults to []. An empty array is accepted and records a review with zero applied corrections.
notes string | null No Free-text note stored on the review record.
reviewer string | null No Only used when no session identity can be derived. A signed-in caller's identity always wins.

DocumentReviewCorrection:

Name Type Required Description
region_id string Yes From the segment you are correcting.
translated_text string Yes The reviewer-typed replacement translation.
target string No region (default) or table_cell.
cell_id string | null No Required when target is table_cell; use the segment's cell_id verbatim.
note string | null No Per-correction note.

Conflicts

A correction that cannot be located is rejected, not fatal — the rest of the batch still applies. Each rejection appears in conflicts with a reason, and the distinct reasons are collected into conflict_codes:

reason Meaning
region_not_found No region with that region_id on any page.
cell_id_missing target: "table_cell" with no cell_id.
cell_id_not_found The region exists but holds no cell with that cell_id.

Response body (TenantDocumentReviewResponse)

Field Type Description
job TenantDocumentJob The updated job, including the new review_history entry, the new output artifact and the refreshed quality_report.
review_result DocumentReviewSummary Compact summary of this call.

DocumentReviewSummary:

Field Type Description
reviewer string The identity the review was actually recorded against.
applied_corrections integer Corrections written into the layout.
rejected_corrections integer Corrections that could not be located.
impacted_pages array of integer Sorted page numbers touched.
conflict_codes array of string Sorted distinct rejection reasons.
conflicts array of object One entry per rejection: target, region_id, reason, plus cell_id on table-cell rejections.
fed_pairs integer Corrected pairs newly entered into the golden translation memory. Deduplicated within the batch and against the job's feed manifest, so an unchanged re-review feeds 0.
feed_error string | null Set when the memory feed failed. The feed is best-effort and never fails the review.

A pair counted in fed_pairs is one this document offered to the memory, which is not always one the memory kept: when the tenant is at its translation-memory cap for the language pair, a pair carrying a new source is skipped rather than stored. That is not a feed_error — the running count is on the job as feed_manifest.golden_skipped_at_cap.

Example

curl -sS -X POST \
  "https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/review?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reviewer": "reviewer@acme.example",
    "notes": "Legal review, pass 1.",
    "corrections": [
      {
        "target": "region",
        "region_id": "577ab4ee0485426fac76b74e22f4fdbc",
        "translated_text": "Résiliation pour motif valable",
        "note": "House style."
      },
      {
        "target": "table_cell",
        "region_id": "0000000000000000000000000000dead",
        "cell_id": "4bde2f9c1a7048e6b39d5c81f0a2e743:0:3",
        "translated_text": "Paiement à 30 jours"
      }
    ]
  }'

The second correction carries a deliberately bogus region_id so the response below shows what a rejection looks like. Ids returned by GET .../segments always resolve — echo region_id and cell_id back exactly as you received them and both corrections apply.

reviewer is supplied here because the call authenticates with an API key and has no session to derive an identity from. A caller sending an Authorization: Bearer session token can omit it — and if it is sent anyway, the session identity is what gets recorded.

The job field carries the whole updated job, in the same shape every document endpoint returns; the abbreviated form below shows only the parts this call changed.

{
  "job": {
    "id": "b92fa968814843b08aea7b78f84d3894",
    "status": "translated",
    "review_history": [
      {
        "reviewer": "reviewer@acme.example",
        "notes": "Legal review, pass 1.",
        "applied_corrections": 1,
        "rejected_corrections": 1,
        "impacted_pages": [1],
        "conflicts": [
          {
            "target": "table_cell",
            "region_id": "0000000000000000000000000000dead",
            "cell_id": "4bde2f9c1a7048e6b39d5c81f0a2e743:0:3",
            "reason": "region_not_found"
          }
        ],
        "fed_pairs": 1,
        "feed_error": null,
        "created_at": "2026-07-26T09:28:09.071039Z"
      }
    ]
  },
  "review_result": {
    "reviewer": "reviewer@acme.example",
    "applied_corrections": 1,
    "rejected_corrections": 1,
    "impacted_pages": [1],
    "conflict_codes": ["region_not_found"],
    "conflicts": [
      {
        "target": "table_cell",
        "region_id": "0000000000000000000000000000dead",
        "cell_id": "4bde2f9c1a7048e6b39d5c81f0a2e743:0:3",
        "reason": "region_not_found"
      }
    ],
    "fed_pairs": 1,
    "feed_error": null
  }
}

Errors:

Status Body / cause
400 reviewer could not be determined: sign in or supply a reviewer.
400 {"detail": "document layout is unavailable", "error_code": "validation_error"} — the job has no layout yet.
401 Missing or invalid tenant credentials.
404 {"detail": "document job <id> not found", "error_code": "not_found"}, or another tenant's job.
422 Malformed body (e.g. a correction missing region_id or translated_text).

How this differs from text-pipeline review

Document review (POST /api/documents/{document_id}/review) Text review (POST /api/translations/{job_id}/review)
Unit of correction Many segments per call, addressed by region_id / cell_id One final_translation for the whole job
Body DocumentReviewRequestcorrections[], notes, optional reviewer ReviewSubmissionaction (save/approve/modify/reject), final_translation, notes, required reviewer
Reviewer identity reviewer is optional: a resolvable session identity is used, otherwise the body value reviewer is required in the body
Side effects Re-validates, re-renders the output file, appends new artifacts Transitions the text job's status
Approval Separate call — POST /api/documents/{document_id}/approve Expressed as action: "approve" in the same call
Repeatable Yes; each call appends to review_history Status-transition driven

POST /api/documents/{document_id}/approve

Stamp the document approved and feed every eligible segment into the tenant's golden translation memory.

Auth: tenant credentials required (401 without). Cross-tenant → 404, checked before anything is mutated, so a cross-tenant caller can never feed another tenant's content into the golden store. Reviewer identity is derived server-side.

Approval is the countable learning event. Review feeds only the human-corrected pairs; approve sweeps the remaining clean segments and records the golden audit.

Eligibility. A segment feeds the golden store unless it is excluded, and every exclusion is tallied in skipped:

skipped key Excluded because
empty Source or target is blank, or the two are identical after normalisation.
fast_path The text came verbatim from translation memory or the glossary (quality.fast_path), so it teaches nothing new.
needs_review Still flagged for review and never corrected by a human.
duplicates Another segment with the same normalised source already won; a corrected pair beats an uncorrected one.

Human-corrected segments (quality.corrected) always feed, and are ordered first, regardless of fast_path / needs_review.

Idempotent. A second approve returns already_approved: true, fed_pairs: 0 and replays the recorded skipped tally. approval_result.reviewer is then the original approver, not the current caller.

Path parameters

Name Type Required Description
document_id string Yes Job id.

Request body (DocumentApprovalRequest, application/json, required)

The body is required; send {} when you have nothing to add.

Name Type Required Description
notes string | null No Free-text approval note.
reviewer string | null No Only used when no session identity can be derived.

Response body (TenantDocumentApprovalResponse)

Field Type Description
job TenantDocumentJob The job with approved_at, approved_by and the updated feed_manifest.
approval_result.approved boolean Always true on a 200.
approval_result.already_approved boolean true on an idempotent replay.
approval_result.reviewer string | null job.approved_by — who the approval is recorded against.
approval_result.fed_pairs integer Pairs newly entering the feed manifest on this call. Pairs already fed during review are not double-counted.
approval_result.skipped object {"fast_path": n, "needs_review": n, "duplicates": n, "empty": n}.
approval_result.feed_error string | null Set when the memory/audit feed failed. Best-effort: approval still stamps the job.

A full translation memory does not fail an approval. When the tenant is at its translation-memory cap for the job's language pair, approved pairs carrying a source the memory does not already hold are skipped and not stored, and counted cumulatively in job.feed_manifest.golden_skipped_at_cap. Corrections are still admitted at the cap, so reviewer edits keep teaching the memory. A non-zero golden_skipped_at_cap means later identical requests will not get a free exact hit from those pairs; free budget for the pair with DELETE /api/tm/{set_id} on an imported set (see Terminology & translation memory).

Example

curl -sS -X POST \
  "https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/approve?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reviewer": "reviewer@acme.example", "notes": "Approved for delivery."}'

The job below is abbreviated to the fields approval changes, and feed_manifest.fed_pairs to its first entry — the manifest holds one entry per pair ever fed for this document, so after an approve that fed 22 it carries many more than the one shown.

{
  "job": {
    "id": "b92fa968814843b08aea7b78f84d3894",
    "status": "translated",
    "approved_at": "2026-07-26T09:31:44.512004Z",
    "approved_by": "reviewer@acme.example",
    "feed_manifest": {
      "fed_pairs": [
        {
          "source_text": "Termination for cause",
          "target_text": "Résiliation pour motif valable",
          "source_hash": "1a3f9c0e2b7d4854af1b6c9d0e5f2a73c8b41d6e9f0a2b3c4d5e6f708192a3b4",
          "corrected": true,
          "stage": "review",
          "created_at": "2026-07-26T09:28:09.070112Z"
        }
        …
      ],
      "skipped": { "fast_path": 1, "needs_review": 1, "duplicates": 0, "empty": 0 },
      "golden_skipped_at_cap": 0,
      "last_feed_error": null
    }
  },
  "approval_result": {
    "approved": true,
    "already_approved": false,
    "reviewer": "reviewer@acme.example",
    "fed_pairs": 22,
    "skipped": { "fast_path": 1, "needs_review": 1, "duplicates": 0, "empty": 0 },
    "feed_error": null
  }
}

Errors:

Status Body / cause
400 reviewer could not be determined: sign in or supply a reviewer.
400 {"detail": "document job <id> is not in a completed state (status=extracted)", "error_code": "validation_error"} — only a translated job can be approved.
400 {"detail": "document layout is unavailable", "error_code": "validation_error"}.
401 Missing or invalid tenant credentials.
404 {"detail": "document job <id> not found", "error_code": "not_found"}, or another tenant's job.
422 Malformed body.

DELETE /api/documents/{document_id}

Hard-delete one document job and everything it produced (self-service content deletion).

Auth: admin only, and — the platform operator API key aside — it has to be a session: an Authorization: Bearer <access_token> for a user holding the global_administrator role in the document's own tenant, as in the example below. A tenant API key never satisfies this gate. It is refused with 401 admin credentials required — not 403, and never success — however admin the tenant it belongs to. A session that authenticates but does not hold the role gets 403 admin role required. An admin of another tenant gets 404 (deletion never confirms a foreign document id exists). Only the operator key crosses tenants.

The delete cascades. Removed along with the job record:

  • every artifact — every file stored for the job (source, intermediate data, quality report, translated output) and, for a Bring-Your-Own-Storage tenant, the blobs in the tenant's own storage account. A BYOS deletion failure is logged and never blocks the platform-side delete; the count reflects only what was actually removed.
  • all segments — they live inside the job record and the files deleted with it.
  • golden audit record(s) — the document_approval record the approval wrote into the golden system-of-record (platform store, plus the tenant's BYOS golden store when bound).
  • TM pairs — exactly the pairs this document fed into the tenant's golden-approved-<src>-<tgt> set (from the job's feed manifest, matched by normalized source hash). Sibling pairs and client TMX imports are never touched.
  • learning rows — only rows provably derived from this document. A row without that linkage is left in place rather than risk deleting another tenant's data.

A job still in flight is cancelled before anything is removed, so a running translation cannot write artifacts back after the delete.

Deliberately retained: usage/billing records and the auth audit log survive deletion (legal/tax retention — see the privacy policy).

Deletion is permanent — there is no undo. Afterwards GET /api/documents/{document_id}, /artifacts, /segments, and /download all return 404.

Path parameters

Name Type Required Description
document_id string Yes Document job to delete.

Response

200 OK. Counts report what was actually removed, not what was expected:

{
  "deleted": true,
  "document_id": "b92fa968814843b08aea7b78f84d3894",
  "artifacts_removed": 5,
  "segments_removed": 24,
  "golden_records_deleted": 1,
  "tm_pairs_removed": 22,
  "learning_rows_removed": 2
}

Example

curl -X DELETE \
  "https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894?api-version=2026-09-01" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Errors:

Status Body / cause
401 admin credentials required — no usable credentials, or a tenant API key, which never passes this gate.
403 admin role required — a session authenticated, but not holding global_administrator.
404 document job not found — unknown id, another tenant's job, or an already-deleted job (double delete).

Schemas

TenantDocumentJob

The body of POST /api/documents/translate, every entry of GET /api/documents, GET /api/documents/{document_id}, and the job field of the review and approval responses. This is the shape /openapi.json publishes; see What a job response contains for what it deliberately does not carry.

Field Type Required Default Description
id string No generated 32-character hex job id.
tenant_id string | null No null Owning tenant. null on a single-tenant deployment with no tenant accounts.
request DocumentTranslationRequest Yes The submitted request. See below.
status DocumentJobStatus No pending pending, queued, extracted, translated, failed.
source_file_name string Yes Stored upload name. Also the Content-Disposition filename on download.
source_format string No "pdf" pdf, docx, or xlsx.
layout null No null Always null. The key is kept so job.layout reads keep parsing; reviewable content comes from GET .../segments.
quality_report DocumentQualityReport | null No null Populated when the document is validated, and refreshed by every review.
billable_chars integer No 0 Billable source characters for the job — the metering quantity; see Tenants, usage & billing.
review_history DocumentReviewRecord[] No [] One entry appended per POST .../review.
artifacts TenantDocumentArtifact[] No [] Append-only; source and translated documents only. See Artifact kinds.
approved_at string (date-time) | null No null Set by POST .../approve.
approved_by string | null No null The approving identity.
feed_manifest DocumentFeedManifest No empty manifest What this document has fed into the golden translation memory.
error string | null No null Raw failure message, or the exception class name.
failure DocumentFailureDetail | null No null Structured failure detail. Branch on failure.code, not on error.
created_at string (date-time) No generated UTC.
updated_at string (date-time) No generated UTC. Also the list sort key.

DocumentTranslationRequest

Field Type Required Default Description
source_lang string No "en" Must be in the supported set.
target_lang string Yes Must be in the supported set.
domain string | null No null Domain hint from the domain form field.
idempotency_key string | null No null From the Idempotency-Key header.
metadata object No {} Server-written: content_type, size_bytes, idempotency_key, and tenant ({"id": …, "name": …}) for an authenticated tenant.
text_type "Plain" | "Html" | null No null From the text_type form field, echoed back on the job. Validated and otherwise ignored on a document job — see the note under Form fields. A value outside Plain/Html is rejected with 400 unsupported text_type …; supported values: Plain, Html.
tone string | null No null From the tone form field. Register instruction applied to every segment's baseline translation.
gender string | null No null From the gender form field. Grammatical gender for agreement, applied to every segment's baseline translation.

DocumentFailureDetail

Field Type Required Default Description
code "stage_timeout" | "transient_error" | "unexpected_error" | "interrupted" Yes The stable value to branch on. stage_timeout and transient_error are worth retrying; unexpected_error is not; interrupted means a service restart dropped the work.
stage string Yes Which stage failed: ingest, extract, map_translate, reconstruct, a review_* stage, or worker.
message string Yes Exception message, falling back to the class name.
error_type string Yes Exception class name.
retry_attempts integer No 0 Automatic retries already spent on the failing stage.
transient boolean No false Whether the runner classified the failure as retryable.
timeout boolean No false Whether the stage budget was exhausted.
metadata object No {} Free-form.

DocumentFeedManifest and DocumentFedPair

feed_manifest is the durable record of what this document has contributed to the tenant's golden translation memory. It is what makes re-review and re-approve non-duplicating.

DocumentFeedManifest:

Field Type Default Description
fed_pairs DocumentFedPair[] [] One entry per pair ever fed for this document.
skipped object (string → integer) {} The last approve's per-reason tally: empty, fast_path, needs_review, duplicates. Replayed verbatim by an idempotent re-approve.
golden_skipped_at_cap integer 0 Cumulative across this document's review and approve feeds: pairs the translation memory could not take because the language pair is at its cap (see Terminology & translation memory). Non-zero means those pairs are not memorized — free TM budget for the pair (delete or shrink an imported set) if you want them. Corrections to sources already in memory are never counted here: they are admitted even at the cap.
last_feed_error string | null null The most recent feed failure, if any. The feed is best-effort. A full memory is not a feed error — it lands in golden_skipped_at_cap instead.

DocumentFedPair:

Field Type Required Default Description
source_text string Yes Segment source.
target_text string Yes Segment target as fed.
source_hash string Yes Normalised-source hash; the deduplication key.
corrected boolean No false true when a human typed this target.
stage "review" | "approve" No "review" Whether the pair was fed by an incremental correction or by the bulk approve sweep.
created_at string (date-time) No generated UTC.

DocumentQualityReport and DocumentQualityFinding

Field Type Required Default Description
schema_version string No "1.0"
document_id string Yes The layout's document_id, which is not the job id.
source_lang / target_lang string Yes
overall_score number No 1.0 A 01 fraction. Note this is a different scale from the text pipeline's 0100 quality_assessment.score.
translated_units integer No 0 Units with a translation.
total_units integer No 0 Units the validator inspected.
findings DocumentQualityFinding[] No [] Per-unit validator findings.
metadata object No {} e.g. page_count, schema_version, and — when per-segment quality scoring ran — llm_quality with the review counters needs_review_units and noncompliant_units.

DocumentQualityFinding:

Field Type Required Default Description
severity "info" | "warning" | "error" Yes Note this is a different vocabulary from the text pipeline's QualityIssue.severity (minor/major/critical).
code string Yes Stable finding code.
message string Yes Human-readable description.
unit_id string | null No null The region or cell the finding is about.
metadata object No {} Free-form.

Finding codes

code is the stable value to branch on; message is prose written for a human and may be reworded. The codes a document job can carry:

code severity What it reports
missing_translation error A translatable unit came back with no translated text at all.
invalid_page_geometry error A page reports a non-positive width or height.
invalid_region_geometry error A region reports a non-positive width or height.
invalid_table_shape error A table reports a non-positive row or column count.
reconstruction_text_truncated warning The translation would not fit its box even at the smallest size that fits, so trailing lines were dropped from the rendered file. The finding names the region or table cell and how much was dropped.
reconstruction_text_shrunk warning The translation was rendered at a noticeably smaller size than the source it replaced in order to stay inside its box. The finding names both sizes.
geometry_region_off_page warning A region's box sits entirely outside the page it belongs to — the text was translated, but its placement cannot be trusted.
geometry_region_edge_overrun warning A region's box runs past a page edge by more than the tolerance allowed for glyph overhang, and the finding says by how far.
geometry_region_overlap warning A region's box overlaps a neighbouring one by enough of the smaller box that one of the two is misplaced.
extraction_geometry_rerouted info “the extracted page geometry failed validation, so the layout was re-derived with a fallback extractor (…)” — the routing gate working as intended, which is why it does not move the score. It is raised as a warning instead when re-derivation was not possible and the page had to be rendered from geometry that could not be trusted.
missing_region_translation warning A text-bearing region — body text, heading, list, caption, header, footer or footnote — has source text but no translated text.
missing_table_cell_translation warning A table cell has source text but no translated text.
translation_expansion warning The translation grew far longer than its source, which is the shape that leads to shrinking or truncation at render time.
low_segment_quality warning A segment was still scoring below the quality threshold after the pipeline had finished repairing it.
low_segment_quality_omitted warning Per-segment low_segment_quality findings were capped, and this one counts the segments below threshold that were not listed individually.
terminology_noncompliant warning Enforced glossary terms were still missing from a segment after repair.

Any error-severity finding caps the report's overall_score at 0.5. warning and info findings do not move it: the file was produced and the text was translated, and what they report is fit, placement or confidence.

The layout

The extracted layout is internal to the service and is not part of any response — job.layout is always null (see What a job response contains). What crosses the API instead:

  • GET .../segments is the structured view of the document's translatable content. Its server-side walk is the single source of truth for what counts as a segment.
  • region_id and cell_id are the layout's addressing scheme, surfaced per segment. Treat them as opaque: echo them back verbatim on corrections. A cell_id is {table_id}:{row}:{column} — a 32-character hex table id, a 0-based row and a 0-based column — but you never need to construct one.
  • Layout, fonts and formatting are applied server-side when the output file is produced; that is what makes the translation land in place in the downloaded document.

Reviewer identity

POST /review and POST /approve resolve the reviewer as follows:

  1. Try the server-derived session identity — the authenticated principal's email, falling back to its user id. It is read from the Authorization: Bearer access token or the browser session cookie.
  2. If a session resolves, it wins. A different reviewer in the body is ignored (the server logs the discrepancy) and the response echoes the authenticated identity in review_result.reviewer / approval_result.reviewer.
  3. If no session resolves — API-key-only clients such as a CAT tool integration — the body's reviewer is used.
  4. If neither exists, the call fails:
{ "detail": "reviewer could not be determined: sign in or supply a reviewer" }

The practical consequence for an API-key integration: always send reviewer on /review and /approve, or the call returns 400.


Error format

The canonical error catalog — body shapes, the full error_code vocabulary, and status-code semantics — is in Conventions & errors. Below is what these endpoints emit.

HTTPException-based errors return:

{ "detail": "document job not found" }

Errors raised from the pipeline layer add a machine-readable code and echo back the X-Request-ID you sent (null when you sent none — the server-generated id is still returned in the X-Request-ID response header):

{
  "detail": "document job doc_missing not found",
  "error_code": "not_found",
  "request_id": "c2ac6a3b91ad4608ad744a56fd74d448"
}
error_code HTTP status
validation_error 400
not_found 404
conflict 409
rate_limit 429
transient_error 503
service_unavailable 503
stage_timeout 504
internal_error 500

Two deployment-specific codes can also surface on document calls when a tenant uses its own storage: storage_binding_not_verified (409 — verify the binding and the parked job publishes automatically) and persistence_failed (502 — the job is parked and can be retried).

Validation failures raised by FastAPI (bad enum value, out-of-range limit) use the standard 422 shape:

{
  "detail": [
    {
      "type": "less_than_equal",
      "loc": ["query", "limit"],
      "msg": "Input should be less than or equal to 500",
      "input": "1000",
      "ctx": { "le": 500 }
    }
  ]
}

Server flags that change what you see

Setting Default Effect on these endpoints
Translation on When off, POST /api/documents/translate returns 503 translation is temporarily disabled. Reads, review, approve and download of existing jobs keep working.
Billing off When on, POST /api/documents/translate returns 402 unless the tenant holds a subscription in a current status. Document translation is never covered by the free-character trial, so a trial-only tenant gets 402 here even while text translation still works.
Upload cap 30 MB Ceiling on any request body; over it → 413.
Monthly character quota per tenant Checked at submission: POST /api/documents/translate returns 429 tenant usage limit exceeded once the tenant's billing period has spent its allowance. Enforced whether or not billing is enabled.
Platform storage cap off PLATFORM_STORAGE_CAP_BYTES, or an operator-set per-tenant override. Off by default (0 caps nothing). When set, a submission whose bytes would carry the tenant past the cap is refused with 507 before the upload is read. Skipped entirely for a tenant whose artifacts land in its own storage account.
Per-stage time budget 120 s Exceeding it fails the job with failure.code = "stage_timeout".
Automatic stage retries 2 Retries per stage before the job fails; surfaced as failure.retry_attempts.
Per-segment quality scoring on Determines whether segment.quality carries a score. With scoring off, quality carries at most the deterministic fast_path marker, never a score, so flagged_total will be 0.
Interrupted-job recovery on On startup, jobs left in a non-terminal status are marked failed with failure.code = "interrupted" and failure.stage = "worker" so pollers are not left waiting.

The quota is measured in weighted billed characters, not raw ones: a pair touching Chinese (Simplified or Traditional), Japanese, Korean, Thai or Hindi counts each source character as 3.5 by default, on either side of the pair. See Tenants, usage & billing for the allowance formula, the top-up headroom and the remedies the 429 names.


Worked example: upload → poll → review → approve → download

BASE=https://trueidiom.com
KEY=$TRUEIDIOM_API_KEY

1. Upload. Async submit returns 202 with the job id.

DOC_ID=$(curl -sS -X POST "$BASE/api/documents/translate" \
  -H "X-API-Key: $KEY" \
  -H "Idempotency-Key: contract-2026-07-26-001" \
  -F "source_document=@contract.pdf;type=application/pdf" \
  -F "source_lang=en" \
  -F "target_lang=fr" \
  -F "domain=legal" | jq -r .id)

echo "$DOC_ID"
# b92fa968814843b08aea7b78f84d3894

2. Poll until terminal. Stop on translated or failed.

while true; do
  STATUS=$(curl -sS "$BASE/api/documents/$DOC_ID" -H "X-API-Key: $KEY" | jq -r .status)
  echo "status=$STATUS"
  case "$STATUS" in
    translated|failed) break ;;
  esac
  sleep 2
done

If failed, read the reason:

curl -sS "$BASE/api/documents/$DOC_ID" -H "X-API-Key: $KEY" | jq '.failure'
{
  "code": "stage_timeout",
  "stage": "map_translate",
  "message": "TimeoutError",
  "error_type": "TimeoutError",
  "retry_attempts": 2,
  "transient": true,
  "timeout": true,
  "metadata": {}
}

failure.message and job.error carry the raw exception message (falling back to the exception class name, as above); failure.code is the stable value to branch on — stage_timeout and transient_error are worth retrying, unexpected_error is not, and interrupted means a service restart dropped the work.

3. List the segments that need attention.

curl -sS -G "$BASE/api/documents/$DOC_ID/segments" \
  -H "X-API-Key: $KEY" \
  --data-urlencode "flagged=true" \
  --data-urlencode "limit=100" \
  --data-urlencode "offset=0" | jq '{total, flagged_total, first: .segments[0]}'
{
  "total": 2,
  "flagged_total": 2,
  "first": {
    "index": 0,
    "page": 1,
    "target": "region",
    "region_id": "577ab4ee0485426fac76b74e22f4fdbc",
    "cell_id": null,
    "source": "Termination for cause",
    "translated": "Résiliation pour cause",
    "quality": { "needs_review": true, "terminology_compliant": false, "score": 74.0 }
  }
}

Page through with offset while offset + limit < total. flagged_total stays constant, so it is safe to use as a progress denominator.

4. Correct a segment. Reuse region_id (and cell_id for table cells) exactly as returned.

curl -sS -X POST "$BASE/api/documents/$DOC_ID/review" \
  -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reviewer": "reviewer@acme.example",
    "notes": "Legal review, pass 1.",
    "corrections": [
      {
        "target": "region",
        "region_id": "577ab4ee0485426fac76b74e22f4fdbc",
        "translated_text": "Résiliation pour motif valable"
      }
    ]
  }' | jq '.review_result'
{
  "reviewer": "reviewer@acme.example",
  "applied_corrections": 1,
  "rejected_corrections": 0,
  "impacted_pages": [1],
  "conflict_codes": [],
  "conflicts": [],
  "fed_pairs": 1,
  "feed_error": null
}

The output file has already been re-rendered at this point — a new translated_pdf artifact with a new checksum is appended. Re-fetch the segments to confirm the corrected unit now reports quality.corrected: true and needs_review: false.

5. Approve. This stamps the job and sweeps the clean segments into the golden translation memory.

curl -sS -X POST "$BASE/api/documents/$DOC_ID/approve" \
  -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"reviewer": "reviewer@acme.example", "notes": "Approved for delivery."}' | jq '.approval_result'
{
  "approved": true,
  "already_approved": false,
  "reviewer": "reviewer@acme.example",
  "fed_pairs": 22,
  "skipped": { "fast_path": 1, "needs_review": 1, "duplicates": 0, "empty": 0 },
  "feed_error": null
}

6. Download the translated file. The response is named after the source file, so rename on the way out.

curl -sS -o "contract.fr.pdf" \
  "$BASE/api/documents/$DOC_ID/download" \
  -H "X-API-Key: $KEY"

Approval is not a precondition for download — you can download as soon as status is translated, and again after every review re-render.