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-01is 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 with400naming 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 (operator-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
│ | quota_exceeded)
├──► 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:
ingest → extract → admit → map_translate → reconstruct
admit runs once extraction has counted the document's characters: when the month's allowance has no headroom left for them, the job stops there with failure.code = "quota_exceeded", before anything is translated or billed (see Server flags). A job interrupted by a service restart is marked failed with failure.stage = "worker".
A failing review never changes a job's status (see POST .../review). The four review_* stage names (review_apply_corrections, review_validate, review_persist_layout, review_reconstruct) appear only on a job that a failing review marked failed under an earlier release. Such a job's translation still counts as delivered — its segments and its download are still served — and a successful review returns it to translated.
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:
layoutis alwaysnull. The extracted layout is kept server-side and is not served. Reviewable content comes fromGET /api/documents/{document_id}/segments, which pages the same server-side walk the renderer and the approval sweep use.artifactslists 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-sidepath.quality_reportand each segment'squalitycarry review signals — scores,needs_review, terminology compliance, fast-path provenance — and not the details of how a segment was produced.billable_charsis the metering quantity for the job: the source characters of its translated segments, as a raw count — the job's usage record, which is what the allowance and the invoice count, weights them by language pair (see Dense-script character weighting). A job is billed once, when it reachestranslated; afailedjob is not billed, even whenbillable_charsis already filled in. The one exception is a tenant using its own storage: a job whose translated output had already reached that storage account is billed even if a later step fails or the job is deleted, and a failure after delivery says in its message that the file was delivered and needs no resubmission.
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; a left-to-right document translated into Arabic, Persian or Urdu is set flush right. Bookmarks, page labels, links and fillable form fields carry over — except an XFA form, and the fields of a signed form (a signature cannot cover a translation). Only text on the visible page is translated and billed. Where a translation runs longer than the source it replaces, it first takes free space beside and below its box — never a neighbouring region's, and never past the edge of the sheet — then is shrunk 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. Formatting that varies inside one paragraph or cell does not survive: a DOCX paragraph takes the formatting of its dominant run, and a rich-text cell becomes plain text in the cell's own style. Hyperlinks keep their targets, and page and column breaks stay where they were. Fields Word computes itself — page numbers and counts, dates and times, and every field in a header or footer — keep their live value, with the text around them translated; other fields are translated as part of their sentence. A DOCX translated into Arabic, Persian or Urdu gets right-to-left paragraphs. Some text deliberately stays in the source language: an Excel Table's column headers (its formulas refer to them by name), sheet names, text inside formulas, and dropdown lists typed directly into a data-validation rule. Hyperlinks the write-back could not place exactly, and Table headers it kept, are reported as findings. Known limits: DOCX tracked changes, content controls and text boxes are left untranslated, and a translated PDF does not carry over the source's accessibility tags (its tagged structure).
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 |
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:
- The file extension wins. HTTP clients routinely send
application/octet-streamfor Office files, soreport.docxwith a generic content type is still detected as DOCX. - 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
contractasapplication/pdfyieldssource_file_name: "contract.pdf". - 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.
Office files are also bounded by what they hold, not only by their upload size. A DOCX or XLSX is refused with 400 when it unpacks to more than 200,000,000 bytes, holds more than 80,000,000 bytes of XML that must be read whole, or — a DOCX — holds more than 1,000,000 XML elements; and one document may carry at most 2,000,000 characters of translatable text and 50,000 translatable segments. These checks run before any job exists, except the segment count and an XLSX's text, which are only known once the file is read: such a job fails at the extract stage instead (400 with wait=true). Each message names the limit and asks you to split the file.
Language codes must come from the supported set — en, es, fr, fr-CA, de, it, pt-BR, pt-PT, nl, zh-Hans, zh-Hant, ja, ko, ar, ru, hi, tr, vi, th, id, pl, uk, cs, ro, hu, el, sv, da, nb, fi, ms, fa, ur, mr, ne. Matching is case-insensitive on the exact code (EN → en); any other regional tag or bare variant base (en-US, fr-FR, zh, pt) is not accepted. fr is French as written in France and fr-CA is Canadian French.
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/.htmupload toPOST /api/translations, which honorstext_typein full.
Headers
| Name | Required | Description |
|---|---|---|
Idempotency-Key |
No | Recorded on the job as request.idempotency_key. Keys are scoped to your tenant: a resubmit with the same key returns your existing job — whatever its status, a failed one included — instead of creating a second one, and another tenant using the same key gets its own job. The resubmit starts no work, so it is answered before the billing, allowance and storage checks — never refused with 402, 429 or 409 for a job that already exists — with 202 while that job is still in flight (200 with wait=true). |
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, a text_type outside Plain/Html, or an Office file over a per-document limit. With wait=true, also a file that cannot be read as its format — corrupt, truncated, not really a PDF/DOCX/XLSX, or password-protected: error_code: validation_error, with a detail such as source_document could not be read as PDF: … or source_document is a password-protected PDF; remove the password and upload it again. Without wait, such a file is accepted and its job fails at extract with the same message. |
401 |
Missing or invalid tenant credentials. |
402 |
Billing is enabled and the tenant has no active subscription. A tenant whose free trial is still running gets document translation (PDF, DOCX, XLSX) is not included in the free trial — it requires an active subscription; subscribe at /billing; any other tenant gets an active subscription is required to run translations — add a payment method at /billing. |
409 |
error_code: storage_binding_not_verified — the tenant keeps its documents in its own storage account and that binding is not verified, or cannot be used: its stored secret is missing or unreadable, or its stored account URL no longer passes validation. Refused before the upload is read; no job is created. Detail: workspace storage (BYOS) binding is not verified — verify it in Storage settings and resubmit, or switch the workspace back to platform storage. |
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 detail may add a remedy) — the month's allowance has no headroom left once the characters already committed are counted: recorded usage plus text requests and documents still in flight. Checked at submission, before the upload is read. A job accepted here can still fail with failure.code = "quota_exceeded" once extraction has counted its characters — with wait=true that is a 429 with error_code: quota_exceeded. See Server flags. |
503 |
Translation is switched off for the deployment — or, for a tenant using its own storage, its stored credential cannot be read right now: error_code: transient_error, secret storage is temporarily unavailable — try again shortly. |
504 |
With wait=true only: a processing stage ran out of its time budget — error_code: stage_timeout, document translation timed out in stage <stage>; job <id> is marked failed. The job is failed with failure.code = "stage_timeout"; split the document rather than resubmitting it unchanged. |
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"
import os
import requests
with open("contract.pdf", "rb") as handle:
resp = requests.post(
"https://trueidiom.com/api/documents/translate?api-version=2026-09-01",
headers={
"X-API-Key": os.environ["TRUEIDIOM_API_KEY"],
"Idempotency-Key": "contract-2026-07-26-001",
},
files={"source_document": ("contract.pdf", handle, "application/pdf")},
data={"source_lang": "en", "target_lang": "fr", "domain": "legal"},
timeout=300,
)
resp.raise_for_status()
doc = resp.json()
print(doc["id"], doc["status"])
// Node 20+ for fs.openAsBlob
import { openAsBlob } from "node:fs";
const form = new FormData();
form.append(
"source_document",
await openAsBlob("contract.pdf", { type: "application/pdf" }),
"contract.pdf",
);
form.append("source_lang", "en");
form.append("target_lang", "fr");
form.append("domain", "legal");
const res = await fetch("https://trueidiom.com/api/documents/translate?api-version=2026-09-01", {
method: "POST",
headers: {
"X-API-Key": process.env.TRUEIDIOM_API_KEY,
"Idempotency-Key": "contract-2026-07-26-001",
},
body: form,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const doc = await res.json();
console.log(doc.id, doc.status);
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": "Plain",
"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 one 1,800-second (30-minute) budget, inside which a transient failure is retried up to twice, 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. Typed failures use the documented JSON error shape: 400 validation_error for a file that cannot be read or is over a per-document limit, 429 quota_exceeded when the month has no headroom left for the document, 429 rate_limit or 503 transient_error when the model provider stayed saturated or unavailable through the retries, 422 content_filtered when its content policy refused a segment, and 504 stage_timeout when a stage ran out of its time budget. Anything else is an unexpected internal failure: 500 with {"detail": "internal error", "error_code": "internal_error", "request_id": "<id>"}. 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
.docx/.xlsxtoPOST /api/translationsasmultipart/form-datawith asource_documentpart routes into this same document pipeline and returns a document job in the same shape this endpoint returns — with the same format detection, so a name without an extension gets the detected format's. That door always answers200and takes uploads up to the same 30 MB cap; see Text translation. Poll and download the job 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, quota_exceeded. Enforced by pattern; other values → 422. |
failure_stage |
string | No | Exact stage name, min length 1 (e.g. extract, admit, map_translate, reconstruct, worker). |
created_since |
string (date-time) | No | ISO 8601. Keeps jobs with created_at >= created_since. A value without an offset — a date-only one included — is read as UTC. |
created_until |
string (date-time) | No | ISO 8601. Keeps jobs with created_at <= created_until. Read as UTC when it carries no offset, so a date-only value means 00:00 UTC at the start of that day, not its end. |
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": "Plain",
"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": 0,
"transient": true,
"timeout": true,
"metadata": {}
},
"created_at": "2026-07-26T09:14:02.368957Z",
"updated_at": "2026-07-26T09:44:09.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. Read as UTC when it carries no offset, as on GET /api/documents. |
created_until |
string (date-time) | No | ISO 8601 upper bound on created_at. Read as UTC when it carries no offset — a date-only value is the start of that day. |
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"
import os
import requests
resp = requests.get(
"https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894?api-version=2026-09-01",
headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
timeout=60,
)
resp.raise_for_status()
doc = resp.json()
print(doc["status"]) # poll until "translated" or "failed"
// Node 18+
const res = await fetch(
"https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894?api-version=2026-09-01",
{ headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const doc = await res.json();
console.log(doc.status); // poll until "translated" or "failed"
{
"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": "Plain",
"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:
indexis the segment's position in the full, unfiltered walk, so it stays stable across pages and across theflaggedfilter.totalis the size of the selected set (the filtered set whenflagged=true).flagged_totalalways counts flagged segments in the whole document, regardless offlagged, so you can render "N need attention" without a second request.- Only a delivered translation is served. A job that is not
translated— stillqueuedorextracted, orfailedat any stage — returnstotal: 0,flagged_total: 0,segments: []with yourlimit/offsetechoed 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
indexis not a positional index intolayout.
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, 0–100. |
needs_review |
boolean | The segment was flagged for a human reviewer — by its quality assessment, or because its translation came back empty (such a segment keeps its source text in the output file). 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 (needs_review also, on any deployment, when a segment's translation came back empty), 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"
import os
import requests
resp = requests.get(
"https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/segments?api-version=2026-09-01",
headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
params={"flagged": "true", "limit": 50, "offset": 0},
timeout=60,
)
resp.raise_for_status()
page = resp.json()
print(page["total"], page["flagged_total"])
for segment in page["segments"]:
print(segment["region_id"], segment["cell_id"], segment["source"])
// Node 18+
const url = new URL(
"https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/segments?api-version=2026-09-01",
);
url.searchParams.set("flagged", "true");
url.searchParams.set("limit", "50");
url.searchParams.set("offset", "0");
const res = await fetch(url, {
headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const page = await res.json();
console.log(page.total, page.flagged_total);
for (const segment of page.segments) {
console.log(segment.region_id, segment.cell_id, segment.source);
}
{
"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:
- Unless the job's translation was delivered — it is
translated— respond409; the source document is never served in its place. A job still in flight (pending,queuedorextracted) getsdocument translation is still in progress— keep polling. Afailedjob getsdocument translation failed; no translated output is available. - Otherwise take the most recent
translated_pdf/translated_documentartifact — the latest review re-render, if there has been one. - If the job has no downloadable artifact, respond
404.
So call this once status is translated; every other status gets 409.
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_type — application/pdf, or the DOCX/XLSX content type for Office sources. |
Content-Disposition |
attachment; filename="<source_file_name>" when the name is printable ASCII without ", \, % or /. Any other name is sent in the RFC 6266 form attachment; filename="<ASCII stand-in, other characters replaced by _>"; filename*=UTF-8''<percent-encoded source_file_name> — read filename* when it is present. Either way 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"
import os
import requests
resp = requests.get(
"https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/download?api-version=2026-09-01",
headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
timeout=300,
)
resp.raise_for_status()
print(resp.headers["content-type"], resp.headers["content-disposition"])
with open("contract.fr.pdf", "wb") as handle:
handle.write(resp.content)
// Node 18+
import { writeFile } from "node:fs/promises";
const res = await fetch(
"https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/download?api-version=2026-09-01",
{ headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(res.headers.get("content-type"), res.headers.get("content-disposition"));
await writeFile("contract.fr.pdf", Buffer.from(await res.arrayBuffer()));
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 not translated: still in flight, or failed — or storage_binding_not_verified when the file is held in the tenant's own storage and that binding is not verified, or its stored secret is missing or unreadable), 500 (checksum mismatch), 503 transient_error (the service's own secret storage could not be read — retry shortly).
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 → rebuild quality_report — the document checks run again, the translate pass's unresolved per-segment findings are kept, and a segment a reviewer corrected stops counting as low-quality or non-compliant → 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, and only a translated job can be reviewed. A review never approves the document and never takes a job out of translated: one that succeeds leaves it translated, and one that fails once it is under way returns its error and changes nothing — the job keeps its previous segments, quality report and output file. The typed refusals below (400, 404) are checked before any correction is applied. Reviews and approvals of one document run one at a time, so concurrent calls never drop each other's corrections.
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. Control characters a document cannot hold are removed (a vertical tab or form feed becomes a line break); what is stored is what the file gets. |
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. Against the manifest only the latest target fed for each source counts, so a review that reverts a segment to a target this job fed earlier feeds it again. |
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"
}
]
}'
import os
import requests
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"},
]
resp = requests.post(
"https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/review?api-version=2026-09-01",
headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
json={"reviewer": "reviewer@acme.example", "notes": "Legal review, pass 1.",
"corrections": corrections},
timeout=300,
)
resp.raise_for_status()
result = resp.json()["review_result"]
print(result["applied_corrections"], result["rejected_corrections"], result["conflict_codes"])
// Node 18+
const res = await fetch(
"https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/review?api-version=2026-09-01",
{
method: "POST",
headers: {
"X-API-Key": process.env.TRUEIDIOM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
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" },
],
}),
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { review_result: result } = await res.json();
console.log(result.applied_corrections, result.rejected_corrections, result.conflict_codes);
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. |
400 |
{"detail": "document job <id> is not in a completed state (status=extracted)", "error_code": "validation_error"} — only a translated job can be reviewed. |
401 |
Missing or invalid tenant credentials. |
404 |
{"detail": "document job <id> not found", "error_code": "not_found"}, or another tenant's job. |
409 |
storage_binding_not_verified — the job's files are held in the tenant's own storage and that binding is not verified, or its stored secret is missing or unreadable. The job is left unchanged. |
503 |
transient_error — secret storage is temporarily unavailable — try again shortly: the service's own secret storage could not be read to reach the tenant's storage. The job is left unchanged; retry shortly. |
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 | DocumentReviewRequest — corrections[], notes, optional reviewer |
ReviewSubmission — action (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. A correction to a source the memory already holds is 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."}'
import os
import requests
resp = requests.post(
"https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/approve?api-version=2026-09-01",
headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
json={"reviewer": "reviewer@acme.example", "notes": "Approved for delivery."},
timeout=300,
)
resp.raise_for_status()
approval = resp.json()["approval_result"]
print(approval["already_approved"], approval["fed_pairs"], approval["skipped"])
// Node 18+
const res = await fetch(
"https://trueidiom.com/api/documents/b92fa968814843b08aea7b78f84d3894/approve?api-version=2026-09-01",
{
method: "POST",
headers: {
"X-API-Key": process.env.TRUEIDIOM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
reviewer: "reviewer@acme.example",
notes: "Approved for delivery.",
}),
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { approval_result: approval } = await res.json();
console.log(approval.already_approved, approval.fed_pairs, approval.skipped);
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> (or the signed-in browser session) 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 platform-operator credentials — the operator API key, or a designated platform-operator session — cross 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_approvalrecord 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. A delete that arrives while a review or approval of the same document is running waits for it to finish, then removes what it fed as well.
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 — with the format's extension appended when the upload was matched on content type alone (scan → scan.pdf; see Supported uploads). 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 |
Source characters across the job's translated segments — the metering quantity, as a raw count; the job's usage record weights it by language pair. Filled in once the segments are translated, whether or not the job then completes; see What a job response contains for when a job is billed. |
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 |
The failure message — the same text as failure.message. |
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, tenant ({"id": …, "name": …}) for an authenticated tenant, and submitted_via: "translations_upload" on a document uploaded through POST /api/translations. |
text_type |
"Plain" | "Html" | null |
No | null |
From the text_type form field — "Plain" when the field is absent — 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" | "quota_exceeded" |
Yes | — | The stable value to branch on. transient_error: a transient failure — the model provider rate-limited or unavailable, say — outlasted the automatic retries; worth retrying later. stage_timeout: a stage ran out of its time budget; it is not retried automatically, and the same file is likely to run out again — split it. unexpected_error: not worth retrying unchanged — the file could not be read or is over a per-document limit (stage extract), the provider's content policy refused a segment, or something failed internally. interrupted: a service restart dropped the work. quota_exceeded: the month's allowance had no headroom left for the document (stage admit); nothing was translated or billed — resubmit once the allowance has room again. A resubmit needs a new Idempotency-Key: the same key returns this failed job. |
stage |
string | Yes | — | Which stage failed: ingest, extract, admit, map_translate, reconstruct, or worker. A job that a failing review marked failed under an earlier release carries a review_* stage — see The job model. |
message |
string | Yes | — | What failed, in words written for you: a file that could not be read or is over a limit, the month's allowance, the provider's content policy, a transient outage, or — for a tenant using its own storage — the binding's problem or that storage account's own answer. A stage that ran out of its time budget reads TimeoutError. An unexpected internal fault reads internal error; its details stay in the service's logs. For a tenant using its own storage, a job whose translated output had already been delivered there says so here, and that it has been billed. |
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 transient (retryable). A stage that exhausted its time budget reports true but is not retried. |
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 0–1 fraction. Note this is a different scale from the text pipeline's 0–100 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 segments were scored, or any was flagged for review — 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 unit the finding is about, when it names one — a region id, a cell_id, or a table's id. Per-segment findings (missing_translation, translation_expansion, low_segment_quality, terminology_noncompliant) carry the segment's own address — its cell_id, or its region_id — the keys GET .../segments and POST .../review use, before and after a review alike. |
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 cut is marked […]). Also raised for lines that ended up outside the visible sheet. The finding names the region or table cell and how much was lost. |
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. |
reconstruction_glyphs_dropped |
warning |
The rendered file lost characters that no embedded font could draw — an emoji, say, or a symbol outside the shipped Noto families. The finding names the region or table cell, how many characters were dropped, and a sample of them. |
reconstruction_text_overlap |
warning |
A region's translated text was drawn on the same part of the page as another region's — over its text, or under the patch that covers its source — so part of it may be hard or impossible to read. The finding names the region it was raised for and the size of the overlap in points. |
docx_hyperlink_approximate |
warning |
A hyperlink's text did not survive translation verbatim, so the link was placed on the closest words of the translated paragraph; check that it covers the right words. |
docx_hyperlink_dropped |
warning |
A hyperlink in the paragraph had no translated text to hold it and was left empty. |
xlsx_table_headers_kept |
warning |
Excel Table column headers were left in the source language, because a Table's header cells must match the column names its formulas refer to. The finding counts them, and says when other cells showing the same text as a header were left untranslated too. |
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. |
overall_score starts as the share of units that came back translated, and any error-severity finding caps it at 0.5. Two warnings also hold it down, because text a reader cannot see was not delivered: it is never above the share of units that were neither truncated (reconstruction_text_truncated) nor placed entirely off the page (geometry_region_off_page). Every other warning and info finding leaves it alone: 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 .../segmentsis 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_idandcell_idare the layout's addressing scheme, surfaced per segment. Treat them as opaque: echo them back verbatim on corrections. Acell_idis{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:
- Try the server-derived session identity — the authenticated principal's email, falling back to its user id. It is read from the
Authorization: Beareraccess token or the browser session cookie. - If a session resolves, it wins. A different
reviewerin the body is ignored (the server logs the discrepancy) and the response echoes the authenticated identity inreview_result.reviewer/approval_result.reviewer. - If no session resolves — API-key-only clients such as a CAT tool integration — the body's
revieweris used. - 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 the request's id — the same value as the X-Request-ID response header: the one you sent, or the server-generated one when you sent none (never null):
{
"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 |
content_filtered |
422 |
rate_limit |
429 |
quota_exceeded |
429 |
internal_error |
500 |
transient_error |
503 |
service_unavailable |
503 |
stage_timeout |
504 |
stage_timeout reaches you only from a wait=true request; on the job it is failure.code = "stage_timeout". internal_error is the answer to an unexpected internal failure — {"detail": "internal error", "error_code": "internal_error", "request_id": "<id>"} — on any of these endpoints.
One storage code can also surface on document calls when a tenant uses its own storage: storage_binding_not_verified (409) — the binding is not verified, or its stored credential can no longer be used. A submission gets it before any job is created (verify the binding, then resubmit); a review, approval or download that needs files held in that storage gets it too — when the stored secret is gone, with workspace storage (BYOS) cannot be used: the stored secret for its binding is missing or unreadable — re-enter the SAS token or connection string in Storage settings and verify the binding, or switch the workspace back to platform storage. When it is the service's own secret storage that cannot be read, those calls answer 503 transient_error (secret storage is temporarily unavailable — try again shortly) instead; retry shortly. Document jobs are never parked, so persistence_failed does not occur here.
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 twice. At submission, POST /api/documents/translate returns 429 tenant usage limit exceeded when the month's allowance has no headroom left once the characters already committed are counted — recorded usage plus text requests and documents still in flight. Then, once extraction has counted the document's characters (the admit stage), a job that finds no headroom left fails with failure.code = "quota_exceeded" before anything is translated or billed; a document that fits only partly into the remaining headroom is admitted whole. Until it finishes, a document's characters also count against the tenant's text requests. Enforced whether or not billing is enabled. |
| Platform storage cap | off | A per-deployment cap on the document bytes a tenant keeps on platform storage, or an operator-set per-tenant override. Off by default. 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 | 1,800 s | One budget per stage, its automatic retries included. A stage that exhausts it fails the job with failure.code = "stage_timeout" and is not retried. |
| Automatic stage retries | 2 | Retries of a transient failure — a rate limit, an outage or a dropped connection, say — within the stage's time budget; surfaced as failure.retry_attempts. |
| Per-segment quality scoring | on | Determines whether segment.quality carries a score. With scoring off, quality never carries a score — only the fast_path marker, and needs_review on a segment whose translation came back empty — so flagged_total counts only those empty segments. |
| Interrupted-job recovery | on | A server that shuts down marks the jobs it was running failed with failure.code = "interrupted" and failure.stage = "worker". At startup and then every 300 s, a job that has shown no sign of life for 300 s — its worker is gone — is marked the same way, so pollers are not left waiting; jobs another live server is still running are left alone. |
The quota is measured in weighted billed characters, not raw ones: a pair touching Chinese (Simplified or Traditional), Japanese, Korean, Thai, Hindi, Marathi or Nepali 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": 0,
"transient": true,
"timeout": true,
"metadata": {}
}
failure.message and job.error say what failed (an unexpected internal fault reads internal error; a stage that ran out of time reads TimeoutError, as above); failure.code is the stable value to branch on — transient_error is worth retrying later, stage_timeout and unexpected_error are not worth retrying unchanged, interrupted means a service restart dropped the work, and quota_exceeded means the month had no headroom left for the document (see DocumentFailureDetail). Resubmit with a new Idempotency-Key — the same key returns the failed job.
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.
What's next
- Authentication — get the credential every call on this page needs.
- Conventions & errors — the error catalog every endpoint here can return.
- Text translation — the inline pipeline for plain text and batches.
- Storage — where approved document output lands when a tenant brings its own account.