API Conventions, Errors & Limits
Start here: README · Related: Authentication · Text translation · Documents · Terminology & TM · Storage · Tenants & billing
This page is the cross-cutting reference for the TrueIdiom REST API: base URLs, credentials and the headers that carry them, JSON and datetime conventions, the complete error model, the payload limits the server enforces, and the middleware behavior that affects every request. Read it once before you write your first integration, then come back to it when you need to map a status code, size a payload, or decide how to retry.
Endpoint-specific request and response schemas live on the per-resource reference pages. Everything documented here applies to every /api/* endpoint unless an endpoint page says otherwise.
Base URLs
| Environment | Base URL |
|---|---|
| Production | https://trueidiom.com |
All API endpoints are served under the /api prefix. There is no version segment in the path — the contract is selected by the api-version query parameter instead. See Versioning.
Liveness: GET /healthz
Unauthenticated liveness probe. Reports whether the translation and quality-scoring backends are running against live credentials or in stub mode. Not under /api.
Auth required: none.
curl -s https://trueidiom.com/healthz
{
"status": "ok",
"stub_mode": {
"translator": false,
"refiner": false
},
"live_services": true
}
| Field | Type | Description |
|---|---|---|
status |
string | Always "ok" when the process is serving. |
stub_mode.translator |
boolean | true when the translation backend is stubbed and echoes the source text back instead of translating it. |
stub_mode.refiner |
boolean | true when the quality-scoring backend has no credentials and returns synthetic scores. |
live_services |
boolean | true only when both stub_mode flags are false. |
The two stub_mode flags report the same credential check and therefore always agree. Both keys are retained so the response shape does not change for existing consumers, but one credential decides both. Keep reading whichever field you already read; do not treat a disagreement between them as possible.
Errors: none. This endpoint does not fail while the process is up.
Treat live_services: false as a hard signal that translations coming out of that deployment are not real model output.
Machine-readable schema
FastAPI's generated artifacts are served at their defaults and are not disabled:
| Path | Content |
|---|---|
GET /openapi.json |
OpenAPI 3.1.0 document (title: TrueIdiom API, version: 2026-09-01). |
GET /docs |
Swagger UI over that document. |
The generated document declares no securitySchemes and no global security requirement, so the interactive /docs page shows every endpoint as unauthenticated. Authentication is enforced server-side rather than declared to OpenAPI, so /docs will let you fire requests that the server rejects with 401. Use the auth rules on this page and the per-endpoint pages as the authority on what credentials an endpoint needs — not the generated docs.
The service also serves browser pages (/, /app, /signin, /billing, /jobs/{job_id}, /about, /privacy, /terms, /contact, /resources*) and a static asset mount at /static. These return HTML, not JSON, and are not part of the integration surface. They matter to an integrator in exactly one place: several billing errors direct the end user to /billing.
Authentication
Three credential types exist. Which one an endpoint accepts is stated on its reference page; this section defines the mechanics.
| Credential | Header | Notes |
|---|---|---|
| Tenant API key | X-API-Key: <key> or Authorization: Bearer <key> |
Issued once at tenant creation. Returned as api_key by POST /api/tenants and as tenant_api_key by POST /api/auth/signup/email and POST /api/oauth2/{provider}/callback — two different field names for the same credential. 43-character URL-safe string. Only its SHA-256 hash and an 8-character prefix are stored server-side, so it cannot be recovered later. |
| OAuth2 access token | Authorization: Bearer <access_token> |
Minted by POST /api/oauth2/token, POST /api/auth/mfa/verify, and the OAuth callbacks. Default lifetime 3600 s (one hour). |
| Operator API key | X-API-Key: <key> or Authorization: Bearer <key> |
The deployment-wide operator key. Break-glass only. |
When both an X-API-Key header and an Authorization: Bearer header are present, X-API-Key wins for API-key resolution.
Authorization: Bearer <value> is resolved as an OAuth2 access token first. If that fails, the same value is retried as a tenant API key. A bearer token that is neither yields 401.
Optional tenant disambiguation
| Header | When to send |
|---|---|
X-Tenant-ID: <tenant_id> |
Optional. Pins credential resolution to a specific tenant. Without it, an API key resolves to whichever active tenant owns that key hash. |
What each guard does
Endpoints sit behind one of four guards. The distinction matters because the same missing credential produces different statuses depending on the guard.
Tenant identity (/api/translations*, /api/documents*, /api/terminology*, /api/tm*, /api/storage/*)
- Tenant credentials are required. Missing or invalid →
401 {"detail": "missing or invalid tenant credentials"}. GET /api/translations/{job_id}/goldenalso accepts the browser session cookie, because the review page's download links are plain navigations that carry no headers.- On a single-tenant deployment that authenticates with a single configured operator key instead of per-tenant accounts, a missing or invalid key returns
401 {"detail": "missing or invalid API key"}, and/api/terminologyand/api/tmfile everything under the literal tenant iddefault.
Admin (/api/tenants*, DELETE /api/translations/{job_id}, DELETE /api/documents/{document_id}, POST /api/oauth2/revoke, POST /api/oauth2/introspect, GET /api/auth/users, DELETE /api/auth/users/{user_id}, POST /api/auth/users/{user_id}/restore, PATCH /api/auth/users/{user_id}/role, POST /api/auth/users/{user_id}/mfa/reset, POST /api/auth/users/{user_id}/password-reset-link)
- The operator key, or an authenticated principal whose role is
global_administrator. - No usable credentials →
401 {"detail": "admin credentials required"}. - Authenticated but not an admin →
403 {"detail": "admin role required"}. POST /api/tenantspasses this gate and then also requires platform-operator standing (below).
Platform operator (GET /api/documents/summary with a breakdown flag, POST /api/tenants; also what widens the scope of the admin reads above)
- The operator key, or a signed-in session whose email is on the deployment's platform-operator list, that is signed in to the deployment's designated operator workspace, and that has an authenticator enrolled (all re-checked per request; it fails closed without any of them).
global_administratordoes not qualify — see the tenant-scoping note below. - Authenticated non-operator →
403 {"detail": "cross-tenant breakdowns require operator access"}on the summary breakdowns, and403 {"detail": "tenant provisioning is operator-only"}onPOST /api/tenants.
Bearer-only (GET /api/auth/me)
- Requires
Authorization: Bearer <access_token>specifically. A cookie session or API key is not accepted here. Missing →401 {"detail": "missing bearer token"}.
Guard matrix — every /api operation
Public means no credential is checked; the endpoint's own inputs (a state, a challenge_id, a Stripe signature) are the proof.
| Endpoints | Guard | Missing / wrong credential |
|---|---|---|
POST /api/translations, GET /api/translations, GET /api/translations/{job_id}, POST /api/translations/{job_id}/review |
Tenant identity | 401 missing or invalid tenant credentials |
GET /api/translations/{job_id}/golden |
Tenant identity — tenant credentials, or the browser session cookie. A presented but invalid credential is refused, not ignored; another tenant's job id is a 404 |
401 missing or invalid tenant credentials |
DELETE /api/translations/{job_id}, DELETE /api/documents/{document_id} |
Admin, tenant-scoped — the tenant's own global_administrator, matched on the admin's own sign-in (bearer token or session cookie), never on X-Tenant-ID; only platform-operator standing crosses tenants. A cross-tenant id is a 404, never a 403 |
401 admin credentials required, 403 admin role required |
POST /api/documents/translate, GET /api/documents, GET /api/documents/{document_id}, .../segments, .../artifacts, .../download, .../review, .../approve |
Tenant identity | 401 missing or invalid tenant credentials |
GET /api/documents/summary (no breakdown flags) |
Tenant identity | 401 missing or invalid tenant credentials |
GET /api/documents/summary?include_tenant_breakdown=true / ?include_stage_timing_breakdown=true |
Platform operator | 401 then 403 cross-tenant breakdowns require operator access |
POST /api/terminology, GET /api/terminology, DELETE /api/terminology/{set_id} |
Tenant identity | 401 missing or invalid tenant credentials |
POST /api/tm, GET /api/tm, GET /api/tm/imports, GET /api/tm/imports/{job_id}, DELETE /api/tm/{set_id}, GET /api/tm/{set_id}/export, GET /api/tm/export/golden |
Tenant identity | 401 missing or invalid tenant credentials |
GET /api/storage/binding, GET /api/storage/usage |
Tenant identity — any member may read | 401 missing or invalid tenant credentials |
PUT /api/storage/binding, DELETE /api/storage/binding, POST /api/storage/binding/verify |
Tenant admin — the tenant API key, or an admin's own sign-in (bearer token or session cookie); a signed-in member is refused. Each change is recorded in the audit log with who made it | 401 missing or invalid tenant credentials, 403 admin role required |
POST /api/billing/checkout, POST /api/billing/topup, POST /api/billing/portal |
Tenant identity | 401 billing requires an authenticated tenant / 401 missing or invalid tenant credentials |
POST /api/tenants |
Admin, then platform operator — a tenant's own global_administrator is refused |
401 admin credentials required, 403 admin role required, 403 tenant provisioning is operator-only |
GET /api/tenants, GET /api/tenants/usage/daily, GET /api/tenants/{tenant_id}/billing, GET /api/tenants/{tenant_id}/billing/export |
Admin | 401 admin credentials required, 403 admin role required |
POST /api/oauth2/revoke, POST /api/oauth2/introspect, GET /api/auth/users |
Admin | 401 admin credentials required, 403 admin role required |
PATCH /api/auth/users/{user_id}/role, DELETE /api/auth/users/{user_id}, POST /api/auth/users/{user_id}/restore, POST /api/auth/users/{user_id}/mfa/reset, POST /api/auth/users/{user_id}/password-reset-link |
Admin, tenant-scoped — a session/bearer admin may only act on their own tenant's members; a cross-tenant id is a 404 (only the operator key reaches any tenant). The reset-link route additionally returns 400 for a member who signs in through an identity provider, or whose access has been revoked. A platform operator's own sign-in can be reset (mfa/reset, password-reset-link) only with the operator key |
401 admin credentials required, 403 admin role required, 403 a platform operator's sign-in can only be reset with the operator API key |
PATCH /api/tenants/security |
Admin, tenant-scoped — an operator must name a tenant (400 without ?tenant_id), including a designated operator session; a tenant admin naming another tenant gets 404 |
401 admin credentials required, 403 admin role required |
POST /api/tenants/invites, GET /api/tenants/invites, DELETE /api/tenants/invites/{invite_id} |
Admin, tenant-scoped — a tenant admin naming another tenant gets 404; the break-glass key must name one (400), while an operator session defaults to its own. 409 when the tenant already holds 20 pending invites |
401 admin credentials required, 403 admin role required |
GET /api/auth/me |
Bearer-only | 401 missing bearer token |
POST /api/auth/mfa/enroll, POST /api/auth/mfa/confirm |
Pending challenge_id or ll_session cookie (the bearer header is not consulted) |
401 authentication required |
POST /api/auth/mfa/recovery/regenerate |
ll_session cookie only — a pending challenge_id is not accepted (that caller has cleared one factor) |
401 authentication required, 409 when no authenticator is enrolled |
POST /api/auth/signup/email, POST /api/oauth2/token, POST /api/auth/mfa/verify, POST /api/auth/signout |
Public | — |
POST /api/auth/invites/preview |
Public — the invite token is the proof. Every unusable token is 200 {"valid": false}, never a 404, so the route cannot be used to enumerate tenants |
— |
POST /api/auth/password-reset |
Public — the reset token is the proof | 401 invalid or expired reset link (one generic answer for malformed, unknown, expired, or consumed) |
POST /api/oauth2/{provider}/authorize, POST /api/oauth2/{provider}/callback, GET /api/oauth2/{provider}/client-config |
Public (gated by the per-provider flag). google is the only {provider} value available to public integrators |
403 when the provider is disabled; 404 for a {provider} the service does not implement |
POST /api/stripe/webhook |
Stripe signature over the raw body | 400 invalid webhook signature |
Tenant scoping is not widened by the admin role
global_administrator is a per-tenant role — every tenant creator holds it. It grants admin actions inside that tenant only. Cross-tenant reads require platform-operator standing: the operator key, or a session whose email is on the deployment's platform-operator list, signed in to the deployment's designated operator workspace, with an authenticator enrolled. Neither is reachable from the API — there is no route that promotes anyone to operator.
Practically: GET /api/translations, GET /api/documents, and their detail routes filter to the caller's own tenant, and a cross-tenant id returns 404 rather than 403 so the id's existence is never confirmed.
Browser session cookie
Browser panes cannot set headers on navigations, so the server also accepts a session cookie (ll_session by default) carrying the same opaque access token. It is set HttpOnly, SameSite=Lax, Path=/, Max-Age matching the access token's lifetime, and Secure on HTTPS deployments. API clients should use headers and ignore the cookie entirely.
Request conventions
Content types
| Endpoint shape | Request Content-Type |
|---|---|
Most POST/PUT/PATCH JSON endpoints |
application/json |
POST /api/documents/translate, POST /api/terminology, POST /api/tm |
multipart/form-data |
POST /api/oauth2/token, POST /api/oauth2/revoke, POST /api/oauth2/introspect |
application/x-www-form-urlencoded |
POST /api/stripe/webhook |
application/json (raw bytes; signature is verified against the exact body) |
POST /api/translations is content-type-routed and accepts several shapes:
Incoming Content-Type |
Handling |
|---|---|
multipart/form-data |
Form upload. A source_document in one of the layout-preserving formats (PDF/DOCX/XLSX) is routed to the document endpoints; anything else is flattened to text. |
text/*, application/xml, text/xml, application/octet-stream, application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document |
Raw body upload. target_lang must be supplied as a query parameter; the filename comes from the X-Source-Filename header or the filename query parameter, defaulting to source_document.txt. |
| anything else | Parsed as JSON. A non-object body is rejected with 400. |
Responses are application/json except for download endpoints, which return binary or text with a Content-Disposition: attachment header:
| Endpoint | Response media type |
|---|---|
GET /api/translations/{job_id}/golden?format=txt |
text/plain; charset=utf-8 |
GET /api/translations/{job_id}/golden?format=docx |
application/vnd.openxmlformats-officedocument.wordprocessingml.document |
GET /api/translations/{job_id}/golden?format=pdf |
application/pdf |
GET /api/documents/{document_id}/download |
The stored artifact's own content type (typically application/pdf) |
GET /api/tm/{set_id}/export, GET /api/tm/export/golden |
application/xml |
GET /api/tenants/{tenant_id}/billing/export |
Per the format parameter (json, jsonl, or csv) |
JSON conventions
- Send only the fields documented on the endpoint pages, and validate them client-side. A misspelled optional field name will not be applied — check the returned object (
request,metadata) to confirm the server received what you intended. - Nulls are explicit in responses. Optional fields are serialized as
null, not omitted."error": nulland"cell_id": nullappear in real payloads. - Enums are lowercase snake_case strings.
JobStatusis one ofpending,translated,refined,awaiting_review,approved,human_edited,rejected,published,failed,persistence_failed.DocumentJobStatusis one ofpending,queued,extracted,translated,failed. - Free-form
metadataobjects appear on requests, jobs, and tenants. They round-trip as arbitrary JSON objects. OnPOST /api/translationsthe server writes atenantkey intometadatawhen a tenant is resolved; do not rely on that key surviving your own writes. - Numbers are JSON numbers. Quality scores are
0–100floats in the text pipeline (overall_scoreon a document quality report is a0–1float — the two scales differ).
Datetimes
All timestamps are RFC 3339 / ISO 8601 in UTC, serialized with a Z suffix and microsecond precision when non-zero:
2026-07-02T00:00:00Z
2026-07-02T14:33:21.481902Z
In the OpenAPI document these fields carry "type": "string", "format": "date-time".
These exceptions serialize the same UTC instant with a +00:00 offset instead of Z — 2026-08-16T09:14:02.481902+00:00:
created_atandupdated_aton a TM import job (GET /api/tm/imports,GET /api/tm/imports/{job_id}, and the job in a202fromPOST /api/tm).created_atandexpires_aton the invite entries stored in tenant metadata (GET /api/tenants/invites, and theinvites_pendingledger on a tenant).created_atin the CSV usage export (GET /api/tenants/{tenant_id}/billing/export?format=csv). Its JSON and JSONL twins of the same records useZ, so the two formats of one export disagree on timestamp spelling — a CSV column and a JSON field carrying the same instant will not compare equal as strings.verified_aton a storage binding (GET/PUT /api/storage/binding,POST /api/storage/binding/verify).expires_aton an MFA challenge (themfa_challengeobject in sign-in and signup responses) and on thePOST /api/oauth2/{provider}/authorizeresponse.created_aton a user (GET /api/auth/users, and the user object the member-management routes return).
Parse timestamps with an RFC 3339 parser rather than by stripping a trailing Z. Both spellings denote the same instant.
Datetime query parameters (created_since, created_until on the document list and summary endpoints) accept the same format, and both bounds are inclusive. A value without an offset is read as UTC, and so is a bare date (2026-09-01), which means midnight UTC at the start of that day. Date-only parameters (since, until on GET /api/tenants/usage/daily) use YYYY-MM-DD and are inclusive on both bounds.
Identifiers
| Id | Format | Example |
|---|---|---|
Translation job id |
32-character lowercase hex (UUID4 without dashes) | 9f2b1c7a4e5d4b8fa1c0e6d3b7a92f14 |
Document job id |
same | 3a7d0c19b6e34f28a55c1d90ef7b2c46 |
Artifact id, layout document_id |
same | c41e88f0a2d7401b9e3c6a5f7d20b913 |
Terminology set id, TM set id |
same | 7b6e2f0c9a1d4e3fb8c25a6d0f314e77 |
Tenant id |
16-character lowercase hex | 9f2c1a7b4e6d0835 |
User id (user_id, sub) |
24-character lowercase hex | 9f3a71c4d0b25e88a147c60d |
Segment region_id |
32-character lowercase hex — the region's own id | 577ab4ee0485426fac76b74e22f4fdbc |
Segment cell_id |
{table_id}:{row}:{column}, where table_id is 32-character hex |
4bde2f9c1a7048e6b39d5c81f0a2e743:0:3 |
Tenant and user ids are not 32 characters. Size database columns and format assertions from this table, not from the job-id row.
Ids are opaque. Do not parse them, and do not assume a prefix. The doc_12345 / tenant_abc values in the OpenAPI examples are illustrative only, not the real format. The one id with internal structure is cell_id, whose three colon-separated parts are documented above because you must echo it back verbatim on a document correction.
Languages
Source and target languages are validated against a fixed allowlist at the API boundary:
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, zh-hans → zh-Hans). A code that is not in the list is rejected by design, regional tags and bare variant bases included: en-US, fr-FR, bare zh, and bare pt all fail. fr is French as written in France and fr-CA is Canadian French. source_lang defaults to en where it is optional; target_lang is always required.
A rejected code returns 422 with a string detail:
{
"detail": "unsupported language(s): 'en-US'. Supported: 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"
}
Request and response headers
Request headers
| Header | Applies to | Description |
|---|---|---|
Authorization: Bearer <token> |
All authenticated endpoints | OAuth2 access token, or a tenant/operator API key. |
X-API-Key: <key> |
All authenticated endpoints | Tenant or operator API key. Takes precedence over Authorization. |
X-Tenant-ID: <tenant_id> |
All authenticated endpoints | Optional. Pins credential resolution to one tenant. |
X-Api-Version: <YYYY-MM-DD> |
All /api endpoints |
Optional alias for the api-version query parameter, which wins when both are sent. See Versioning. |
Idempotency-Key: <key> |
POST /api/translations, POST /api/documents/translate |
Collapses repeat submissions onto one job. Scoped to your tenant. See Idempotency and retries. |
X-Request-ID: <id> |
Any | Optional client-supplied correlation id. Echoed back and bound into server logs. |
X-Source-Filename: <name> |
POST /api/translations (raw body upload) |
Names the uploaded source. Falls back to the filename query parameter, then source_document.txt. |
Content-Length |
POST /api/translations |
Checked against the declared-body-size limit — before the body is read on a JSON or raw-body submission, once the form shows it is not a PDF, DOCX or XLSX on a multipart upload (see Limits). A non-integer value returns 400. |
Stripe-Signature |
POST /api/stripe/webhook |
Verified against the configured webhook signing secret before any field is trusted. |
Response headers
| Header | Description |
|---|---|
X-Request-ID |
Present on every response. Echoes the inbound X-Request-ID when you send one, otherwise a server-generated 32-character hex id. Quote it in support requests. |
X-Api-Version |
The API version the request was served as. Present on every versioned /api response — including error responses, and including when you sent no pin and got the default. Absent on the unversioned endpoints below, and on a request whose own pin was rejected (there is no version to report). |
Content-Disposition: attachment; filename="..." |
On all download endpoints listed above. When the file name is not plain printable ASCII — or contains ", \, % or / — filename carries an ASCII stand-in (each such character replaced by _) and the exact name follows UTF-8 percent-encoded per RFC 6266: attachment; filename="_.pdf"; filename*=UTF-8''%E5%A5%91.pdf. Prefer filename* when it is present. The golden download's name is always plain ASCII. |
Retry-After |
On 429 responses from a request-velocity limiter. The value is always that limiter's window in seconds, so it differs by endpoint: the auth limiter's window (60 by default) on the authentication endpoints, the per-user MFA budget's 900 on POST /api/auth/mfa/verify and the mfa-otp grant, and the top-up limiter's window (3600 by default) on POST /api/billing/topup. Read the header rather than assuming 60. Not present on the monthly-allowance 429 (quota_exceeded included), which no delay clears, nor on a 429 rate_limit pipeline error. |
Set-Cookie |
On sign-in, signup, MFA verify, and OAuth callback responses, establishing the browser session cookie. API clients can ignore it. |
Correlation
The server binds request_id — and tenant_id when X-Tenant-ID is present — into its structured log context for the lifetime of the request, so every log line for one request is correlatable by that id.
The request_id field of a JSON error body is never null: it is always the same id as the response's X-Request-ID header — yours when you sent one, otherwise the server-generated 32-character hex id. Quote either when reporting a problem.
Browser origins
The API is built for server-side callers. Responses carry no cross-origin sharing headers, so a browser on a different origin will block reads of the response. Call the API from your backend, or proxy it through your own origin. The first-party web app at https://trueidiom.com/app is same-origin and unaffected.
Pagination
Pagination exists on exactly one endpoint.
GET /api/documents/{document_id}/segments
| Name | Type | Required | Description |
|---|---|---|---|
document_id |
string (path) | Yes | Document job id. |
flagged |
boolean (query) | No | Default false. When true, returns only segments flagged for review. |
limit |
integer (query) | No | Default 100. Minimum 1, maximum 500. |
offset |
integer (query) | No | Default 0. Minimum 0. |
curl -s "https://trueidiom.com/api/documents/3a7d0c19b6e34f28a55c1d90ef7b2c46/segments?limit=2&offset=0&api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_API_KEY"
{
"total": 248,
"flagged_total": 11,
"limit": 2,
"offset": 0,
"segments": [
{
"index": 0,
"page": 1,
"target": "region",
"region_id": "577ab4ee0485426fac76b74e22f4fdbc",
"cell_id": null,
"source": "Termination for cause",
"translated": "Résiliation pour motif valable",
"quality": {"fast_path": "tm_exact"}
},
{
"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}
}
]
}
Semantics you can rely on:
totalis the count after theflaggedfilter — it is what you page through.flagged_totalcounts flagged segments across the whole document regardless of theflaggedfilter, so you can render "N need attention" without a second request.indexis the segment's position in the full unfiltered walk. It stays stable across filters and pages, which makes it a usable client-side key. It is not the offset within the returned array.- A job that has not delivered a translation — still in progress, or
failed— returnstotal: 0,flagged_total: 0, and an emptysegmentsarray — not an error.
Loop until offset + len(segments) >= total.
Errors: 401 (no tenant credentials), 404 (document job not found, including cross-tenant ids), 422 (limit outside 1..500 or negative offset).
Endpoints that return complete collections
The following return the full result set in one response, with no limit/offset/cursor parameters. Filter server-side where filters exist, and size your client buffers accordingly.
| Endpoint | Server-side filters available |
|---|---|
GET /api/translations |
status |
GET /api/documents |
status, failure_code, failure_stage, created_since, created_until |
GET /api/tenants |
none |
GET /api/tenants/usage/daily |
since, until, tenant_id (range capped at 366 days) |
GET /api/terminology |
none |
GET /api/tm |
none |
GET /api/tm/imports |
none — capped at the 50 newest jobs, see below |
GET /api/documents/{document_id}/artifacts |
none |
GET /api/auth/users |
tenant_id (required for API-key callers) |
layout is always null on document job responses — the extracted layout is kept server-side (see What a job response contains). Use the segments endpoint above to page through reviewable content.
GET /api/translations and GET /api/documents both return jobs sorted by updated_at descending.
GET /api/tm/imports is the one endpoint in that table which does not return a complete collection: it is a recent-activity view of asynchronous TMX imports, sorted by created_at descending and truncated to the 50 newest jobs, with no way to page past them. Keep the job.id from the 202 if you need a specific import later.
Limits
These are the payload constraints the server enforces. A deployment can raise or lower them, so treat them as the contract's shape rather than immutable numbers.
| Limit | Value | Enforced on | Response when exceeded |
|---|---|---|---|
| Absolute request body size | 30,000,000 bytes (30 MB) | Every request. Checked against Content-Length up front and again against the streamed body. |
413 — request body too large |
| Declared body size | 10,000,000 bytes | POST /api/translations JSON and raw-body submissions, from the Content-Length header before the body is read, and a multipart upload that is flattened to text. A PDF, DOCX or XLSX uploaded as multipart is held to the absolute 30 MB bound instead, as on POST /api/documents/translate. |
413 — declared body too large |
| Source text length | 50,000 characters | Each source_text (simple shape) or each inputs[].Text (batch shape) on POST /api/translations. |
413 — source text too long |
| Batch size | 50 translations | POST /api/translations, counted as the total number of translations — the sum of Targets across all inputs, not the number of inputs. |
413 — batch too large |
| Document page count (text flow) | 50 pages | A text document submitted to POST /api/translations is split into pages of at most 5,000 characters; the page count is checked against the same ceiling. |
413 — too many pages |
| Office document limits (DOCX, XLSX) | 200,000,000 bytes unpacked; 80,000,000 bytes of XML that must be read whole; 1,000,000 XML elements (DOCX); 2,000,000 translatable characters; 50,000 translatable segments | POST /api/documents/translate and DOCX/XLSX uploads to POST /api/translations. The sizes, the element count and a DOCX's text are checked before a job exists; the segment count and a workbook's text are known only once extraction has read the file. PDFs are not subject to these. |
400 naming the limit and asking you to split the file (source_document unpacks to … bytes, over the 200,000,000-byte limit for Office documents; split it into smaller files) — a validation_error on the document pipeline. A limit found during extraction fails an asynchronous job at its extract stage instead; a wait=true request still answers 400 |
| Terminology / TM set name | 128 characters | POST /api/terminology, POST /api/tm. |
400 {"detail": "name must be at most 128 characters"} |
| Segments page size | 1–500, default 100 |
GET /api/documents/{document_id}/segments. |
422 (FastAPI validation array) |
| Usage date range | 366 days | GET /api/tenants/usage/daily. |
400 {"detail": "date range must not exceed 366 days"} |
| Monthly character allowance | 500,000 source characters, or the tenant's own monthly_char_limit |
Every submission that starts translation work — POST /api/translations and document submissions — weighted by language pair: a Chinese, Japanese, Korean, Thai, Hindi, Marathi or Nepali pair counts 3.5 per source character (Dense-script character weighting). Usage counts from the later of the start of the UTC calendar month and the moment the tenant's current plan took effect, and everything admitted but not yet recorded counts too — text requests still running and document jobs in flight. A text submission is counted from its payload before any work starts. A document needs some headroom left when it is submitted, and again once extraction has measured it, before any model call; its measured characters are then held against the month, and one larger than the remaining headroom is still admitted whole. |
429 {"detail": "tenant usage limit exceeded"}, followed by whichever remedies that tenant actually has (Quotas). A document that finds no headroom left after extraction fails with failure.code quota_exceeded — a wait=true request answers 429 with error_code quota_exceeded — and nothing is translated or billed |
| Monthly character ceiling | 10,000,000 source characters per UTC calendar month | A subscribed tenant whose included allowance is below it: included characters and prepaid top-ups together, counted over the whole calendar month — a plan change restarts the allowance, never the ceiling. An allowance provisioned at or above it is not clamped; it simply has no top-up headroom. | 429 {"detail": "tenant usage limit exceeded — this month's character ceiling is reached; contact us at /contact to raise your allowance"}; on POST /api/billing/topup, 409 for a purchase the ceiling leaves no room to spend |
| Platform storage cap | Off by default; a per-deployment byte ceiling, optionally overridden per tenant | POST /api/documents/translate, against the declared body size before the upload is parsed, and PDF/DOCX/XLSX uploads to POST /api/translations, against the declared body size before any job is created. Skipped entirely for a tenant bound to its own storage — those bytes never rest on the platform volume. |
507 — see the status code reference |
| Top-up purchase velocity | 5 attempts per 3,600-second window, per client IP and per tenant | POST /api/billing/topup. Both budgets are consumed on every attempt. Independent of the auth rate limiter — switching that off does not unthrottle purchases. |
429 {"detail": "too many top-up attempts — slow down and retry later"}, with Retry-After |
| MFA code attempts per challenge | 5 | POST /api/auth/mfa/verify and the mfa-otp grant on POST /api/oauth2/token together, TOTP and recovery codes alike. Keyed on the challenge, never the client IP, and enforced even when auth rate limiting is switched off. |
The next attempt burns the challenge: 401 {"detail": "invalid or expired MFA challenge"} — sign in again from the password step |
| Failed MFA codes per user | 10 per 900 seconds (15 minutes) | The same two routes, across every challenge minted for the user; a correct code does not count. Keyed on the user, never the client IP, and enforced even when auth rate limiting is switched off. | 429 {"detail": "too many MFA attempts — wait a few minutes, then sign in again"}, with Retry-After: 900 |
| TM segments per tenant + language pair | 50,000 | TMX import on POST /api/tm, counted after duplicate sources collapse and across every set the tenant holds for the pair — the golden-approved-* set included. Pairs are matched on the supported language each code stands for, so en-GB and en share one budget, as do fr-FR and fr, while pt-BR and pt-PT have one each, as do fr and fr-CA (Language codes). A same-name re-import replaces its set wholesale, so that set's own segments do not count against the allowance. |
422 with a string detail beginning tm_max_segments_exceeded: — the whole import is refused and nothing is stored |
| Terminology entries per tenant + language pair | 2,000 | POST /api/terminology ingest, counted across every set the tenant holds for the pair (matched on the supported language each code stands for, so en-US and en share one budget, as do fr-FR and fr, while pt-BR and pt-PT have one each, as do fr and fr-CA; a set with no declared pair counts against every pair). A same-name re-upload replaces its set, so that set's own entries do not count against the allowance. |
422 with a string detail beginning terminology_max_terms_exceeded: — the whole upload is refused and nothing is stored |
Auth endpoint rate limiting
Authentication endpoints are rate limited per client IP per bucket. On by default: 30 attempts per 60-second window.
Buckets: signup, token-password, token-mfa and mfa-verify (both keyed additionally by challenge_id), mfa-enroll, mfa-confirm, mfa-recovery, invite-preview, password-reset, oauth-authorize, oauth-callback.
The client IP is the entry the platform's own ingress appends to X-Forwarded-For — the right-most one. Entries a client writes into the header itself are ignored, so they cannot move a request into another bucket; a request that arrives without the header is keyed on the connecting address. The same client IP keys the top-up purchase velocity limit and the per-IP free-trial grant check.
MFA codes carry two further budgets, keyed on the challenge and on the user rather than on the client IP, and enforced even when this limiter is switched off: 5 attempts per challenge, and 10 failed codes per user per 15 minutes — see Limits.
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{"detail": "too many requests — slow down and retry later"}
Honor Retry-After. Do not retry inside the window.
Practical sizing notes
- A single
POST /api/translationsruns the full pipeline inline and returns the finished job. Size your HTTP client timeout for pipeline latency, not for a queue acknowledgment: allow 120 seconds for a single-text submission and scale up with the batch size, since batch items run through the same pipeline in sequence. Do not set a 10–30 s default and treat the timeout as a failure — resend with the sameIdempotency-Keyinstead. POST /api/documents/translateis asynchronous by default and returns202with aqueuedjob. Sendwait=truein the form to run it inline and get200with the completed job instead — that request holds the connection for the whole pipeline, which on a large document can run far longer than an HTTP client should wait (see the stage budget below). Keepwait=truefor small documents, and submit anything larger asynchronously and poll.- Document processing runs in stages. Extraction, translation and reconstruction each run under one server-side time budget — 1,800 s by default — that also covers the stage's automatic retries: a transient failure is retried, up to two times, only while budget remains. A stage that exhausts its budget fails the job with
failure.codestage_timeoutand is not retried; the stage's work is fixed by the document, so resubmitting it unchanged normally times out again. POST /api/tmfollows the same202-and-poll pattern once an import is large, so size its timeout for a small import only. A TMX file holding more than 5,000 segments after duplicate sources collapse returns202with a job to poll atGET /api/tm/imports/{job_id}; at or under it, the import runs inline and returns200. Rejections are synchronous at both sizes, so a202already means the file parsed, the name was legal and the capacity check passed. See Terminology & TM.
Errors
This section is the canonical error catalog for the API. The endpoint pages list only the statuses a given endpoint can produce and the exact detail strings it uses; the body shapes, the error_code vocabulary, and the status-code semantics are defined once, here.
Error body shapes
The API returns one of three JSON shapes. All three are application/json — an unexpected internal failure included, which answers 500 in shape 3.
1. Standard error — a string detail. The common case for every explicit rejection.
{"detail": "job not found"}
2. Validation error — an array detail. Emitted by the framework for malformed path, query, header, or body parameters. Always 422.
{
"detail": [
{
"loc": ["query", "limit"],
"msg": "Input should be less than or equal to 500",
"type": "less_than_equal",
"input": "900",
"ctx": {"le": 500}
}
]
}
| Field | Type | Required | Description |
|---|---|---|---|
loc |
array of string | integer | Yes | Path to the offending value, e.g. ["body", "inputs", 0, "Targets"]. |
msg |
string | Yes | Human-readable message. |
type |
string | Yes | Machine-readable error type, e.g. missing, string_type, less_than_equal. |
input |
any | No | The value that was rejected. |
ctx |
object | No | Constraint context, e.g. {"le": 500}. |
Gotcha: not every 422 uses this shape. Language-allowlist rejections, batch expansion failures, the storage-binding validations, the terminology/TM language and capacity rejections, and every billing request-body rejection (the tier on POST /api/billing/checkout, and a packs below 1 or of the wrong type on POST /api/billing/topup, including the raw validation message when the field is missing) return 422 with a string detail. A packs value above the per-purchase maximum is a 400, not a 422 — the ceiling is a deployment setting rather than a schema constant. Branch on the type of detail, not on the status code.
Four rejections put a stable token at the front of a string detail so you can branch without parsing prose — everything after the token is human-readable detail (counts, allowance, remediation) whose wording may change. The first two are the capacity rejections above; the third is a 409, the fourth a 401:
| Token | Status | Endpoint | Meaning |
|---|---|---|---|
terminology_max_terms_exceeded: |
422 |
POST /api/terminology |
The upload would take the tenant past its terminology allowance for the language pair. Nothing was stored. |
tm_max_segments_exceeded: |
422 |
POST /api/tm |
The import would take the tenant past its translation-memory allowance for the language pair. Nothing was imported. |
tm_import_in_progress: |
409 |
POST /api/tm, DELETE /api/tm/{set_id} |
An import into the same set name — asynchronous or synchronous — is still running; an import replaces its set wholesale, so nothing may overlap it. Nothing was imported or deleted — wait for that import to finish (an asynchronous one is named in the message, with the URL to poll), then retry. |
workspace_id_required: |
401 |
POST /api/oauth2/token (password grant without tenant_id) |
The address is held in more workspaces than a sign-in without a workspace ID checks, and none of those it checked matched the password. Retry the grant with tenant_id set to the workspace's ID. |
3. Pipeline error — detail plus error_code and request_id. Emitted when a typed pipeline exception reaches the boundary — the status comes from the exception class — and by the storage errors below and the 500 for an unexpected internal failure: {"detail": "internal error", "error_code": "internal_error", "request_id": "<id>"}.
{
"detail": "document layout is unavailable",
"error_code": "validation_error",
"request_id": "b0a1f2c3d4e5f60718293a4b5c6d7e8f"
}
| Field | Type | Description |
|---|---|---|
detail |
string | Message, falling back to error_code when the exception carries no message. |
error_code |
string | Stable machine-readable code. See the table below. |
request_id |
string | Always present: the same id as the response's X-Request-ID header — the inbound one when you sent it, otherwise a server-generated 32-character hex id. |
error_code values
error_code |
HTTP status | Meaning |
|---|---|---|
validation_error |
400 | The request or the referenced resource's state was invalid — e.g. an unsupported document format, a corrupt or password-protected file, an Office document over the limits, a document with no layout, an approve on a job that is not translated. |
not_found |
404 | A referenced job or resource does not exist — including a job deleted while its own request was still running (translation job <id> was deleted while it was processing). |
conflict |
409 | The request conflicts with current state. |
storage_binding_not_verified |
409 | The tenant's own storage (Bring Your Own Storage) cannot take the work: its binding is not verified, or a verified binding is unusable — its stored secret is missing or unreadable, or its stored account URL no longer passes validation. A submission refused this way — any shape of POST /api/translations, or POST /api/documents/translate — created no job and translated nothing; its detail says to verify the binding and resubmit. A document download, review or approval that has to reach a binding whose secret is gone gets it too, 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 a review approval of a text job meets an unverified binding, the job is parked as persistence_failed and the detail says the parked job will publish automatically: it is republished by the next successful POST /api/storage/binding/verify, or when the workspace switches back to platform storage. See Storage. |
rate_limit |
429 | The model provider's capacity stayed saturated past the service's own retries. |
quota_exceeded |
429 | A document's characters, counted after extraction, found no headroom left in the tenant's monthly allowance. Nothing was translated or billed. Returned by a wait=true document request; an asynchronous job records it as its failure.code instead. |
content_filtered |
422 | The model provider's content policy refused the source text. The same text is refused on every retry. |
internal_error |
500 | An unexpected internal failure: {"detail": "internal error", "error_code": "internal_error", "request_id": "<id>"}. Retry once; if it persists, give support the request_id. |
persistence_failed |
502 | Writing the golden record failed after the translation finished. The job is parked as persistence_failed — complete, readable through GET /api/translations/{job_id}, and billed — and is republished by the next successful POST /api/storage/binding/verify (on platform storage too), or when the workspace switches back to platform storage. A parked job that was deleted while its request was still running is neither promised nor billed: that request answers 404 not_found instead. |
transient_error |
503 | A retryable upstream failure: the model provider's server errors, timeouts or dropped connections that outlasted the service's own retries (the detail is a generic message), or the platform's secret storage being briefly unreachable — secret storage is temporarily unavailable — try again shortly, on any submission, and on a document download, review or approval that has to reach the tenant's own storage. Safe to retry with backoff. |
service_unavailable |
503 | A required dependency is unavailable or misconfigured. |
stage_timeout |
504 | A wait=true document request whose processing stage ran out of its time budget: document translation timed out in stage <stage>; job <id> is marked failed. The job is failed with failure.code stage_timeout. Not worth retrying unchanged — split the document. |
transient_error and rate_limit are the retryable set. Everything else — quota_exceeded, content_filtered and stage_timeout included — indicates the request will fail the same way if replayed unchanged.
Document jobs report how they ended on failure.code rather than as an HTTP error: stage_timeout, transient_error, unexpected_error, interrupted or quota_exceeded — see DocumentFailureDetail.
Status code reference
| Status | Meaning | When it occurs |
|---|---|---|
200 |
Success | Standard success for reads, reviews, approvals, and synchronous submissions. Also content deletion: DELETE /api/translations/{job_id} and DELETE /api/documents/{document_id} return 200 with a body of truthful cascade counts, not a 204. |
202 |
Accepted | POST /api/documents/translate without wait=true, when the job is genuinely in flight. An idempotent resubmit that resolves to an already-finished job returns 200. Also POST /api/tm when the TMX file holds more than 5,000 segments after duplicate sources collapse — body {"job": ..., "poll_url": ...}, polled at GET /api/tm/imports/{job_id}. |
204 |
No content | DELETE /api/terminology/{set_id}, DELETE /api/tm/{set_id}. |
400 |
Bad request | Malformed or missing inputs the endpoint itself checks: unsupported api-version: ..., unknown api-version: ... is not a released version, invalid JSON body: ..., JSON body must be an object, invalid Content-Length header, source_document upload is required, source_document is empty, target_lang is required, target_lang query parameter is required for text uploads, source_document has no translatable content, source_text has no translatable content (blank or whitespace-only text; one blank batch item rejects the whole batch, named as inputs[<n>].Text has no translatable content), source_document must be UTF-8 or UTF-16 (with BOM) text, unsupported text_type ... (the document pipeline only — a pdf/docx/xlsx upload validates text_type as a validation_error; the same bad value on the text path is a 422, see below), name is required, tmx_file is empty, source_lang and target_lang are required, since must be on or before until, date range must not exceed 366 days, reviewer could not be determined: sign in or supply a reviewer, invalid verification code, unsupported grant_type, refresh_token is required, invalid or expired oauth state, tenant_id does not match authorized oauth state, this invite was issued for a different email address, this email is already linked to a different Google account — contact support, invalid webhook signature, at most {n} packs per purchase. Also every validation_error pipeline error — among them a corrupt or password-protected document and an Office document over the limits. |
401 |
Unauthenticated | missing or invalid tenant credentials; missing or invalid API key; admin credentials required; billing requires an authenticated tenant; missing bearer token / invalid or expired bearer token / tenant not found for bearer token on GET /api/auth/me; on the auth endpoints, invalid credentials (a password grant without a workspace ID) and invalid tenant credentials (with one), workspace_id_required: ... (see the token table above), invalid or expired MFA challenge (also once a challenge has used up its attempts), invalid or expired refresh token, invalid or expired reset link, and authentication required (the MFA enrolment routes). |
402 |
Payment required | Billing is enabled and the tenant cannot start billable work: an active subscription is required to run translations — add a payment method at /billing, your free trial is used up — add a payment method at /billing to continue, your free trial has ended — add a payment method at /billing to continue (the trial's calendar clock ran out with characters still on the grant), this request exceeds your remaining free trial characters — add a payment method at /billing, document translation (PDF, DOCX, XLSX) is not included in the free trial — it requires an active subscription; subscribe at /billing (a layout document from a tenant whose trial still covers text), the free trial covers the workspace only — API access requires an active subscription; subscribe at /billing (a trial tenant's request authenticated with its tenant API key). Also top-ups require an active subscription — subscribe at /billing first on POST /api/billing/topup. |
403 |
Forbidden | Authenticated but not permitted: admin role required, cross-tenant breakdowns require operator access, tenant provisioning is operator-only, a platform operator's sign-in can only be reset with the operator API key, email signup is disabled — use single sign-on, password sign-in is disabled — use single sign-on, Google sign-in is disabled, this organization requires an invitation — ask your workspace admin for an invite link (a newcomer's Google sign-in pinned to a workspace that does not admit them without an invite). |
404 |
Not found | job not found, document job not found, document artifact not found, document file missing, golden translation is empty, terminology set not found, translation memory set not found, translation-memory import job not found, no approved golden pairs for this language pair, tenant not found, user not found, invite not found, unknown oauth provider. Also the not_found pipeline error for a job deleted while its own request was still running. A cross-tenant id also returns 404, not 403 — existence is never confirmed to a caller who may not see it. |
409 |
Conflict | golden translation is not available for this job status (the job is not published or human_edited), document translation is still in progress (the job is still in flight — pending, queued or extracted), document translation failed; no translated output is available, tm_import_in_progress: ... (an import into the same set name, asynchronous or synchronous, has not finished — on POST /api/tm and DELETE /api/tm/{set_id}), no Stripe customer for this tenant yet — subscribe first, your allowance already reaches the monthly character ceiling, so top-ups cannot add to it — contact us at /contact to raise your allowance, this purchase would exceed the monthly character ceiling — use your current balance first, an account with this email already exists in this workspace — sign in with your password (a Google sign-in meeting a password account it cannot take over), Bring Your Own Storage is not enabled on this service, secret storage is not configured, storage_binding_not_verified (a submission refused before any work, or a download, review or approval that cannot reach the workspace's own storage). |
413 |
Payload too large | Any limit in the Limits table that maps to 413. |
415 |
Unsupported media type | unsupported source_document type; unsupported source_document type; supported formats: ...; terminology_file must be a .csv or .tmx file; terminology_file must be text/csv; terminology_file must be XML (TMX 1.4b); tmx_file must be a .tmx file; tmx_file must be XML (TMX 1.4b). |
422 |
Unprocessable entity | Framework parameter validation (array detail); unsupported language codes — a language field of the wrong type (a number, a list) reads as unsupported too, e.g. unsupported language(s): '5'. Supported: ...; batch expansion failures, including a wrong-typed inputs or Targets; body fields the request model rejects on the text path of POST /api/translations — including a text_type outside Plain/Html, whether sent as JSON, a multipart form field, or a query parameter on a raw text upload (the same value on a layout-document upload is a 400, see above); content_filtered pipeline error (the model provider's content policy refused the text); on PUT /api/storage/binding, account_url is required for provider=adls, account_url must be an Azure Storage endpoint of the form https://<account>.dfs.core.windows.net, account_url names this service's own storage account (...); bind a storage account your organization owns, a secret is required for auth_mode=sas, and the stored secret for this storage binding is missing or unreadable — re-enter the SAS token or connection string, then verify the binding; a terminology or TM upload whose source_lang or target_lang is not a supported language (unsupported language(s): 'X'. Expected language codes such as 'en' or 'pt-BR' ...); terminology_max_terms_exceeded: ... and tm_max_segments_exceeded: ... when an upload would take the tenant past its per-language-pair terminology or TM capacity (nothing is stored). |
429 |
Too many requests | too many requests — slow down and retry later (auth rate limiter, with Retry-After); too many MFA attempts — wait a few minutes, then sign in again (the per-user MFA budget, with Retry-After: 900); too many top-up attempts — slow down and retry later (top-up velocity, with Retry-After); tenant usage limit exceeded (monthly character allowance — the detail continues with the tenant's remedies, or says this month's character ceiling is reached; see Quotas); quota_exceeded pipeline error (a wait=true document that found no headroom once extraction had counted it); rate_limit pipeline error. |
500 |
Internal server error | document artifact failed integrity check (the stored checksum does not match the bytes on disk — do not use the download). Any other unexpected failure answers {"detail": "internal error", "error_code": "internal_error", "request_id": "<id>"}, with the same id in the X-Request-ID header. |
502 |
Bad gateway | A backend required by a terminology or TM import was unavailable; could not provision platform storage container — nothing was changed; retry shortly (PUT /api/storage/binding back to platform storage); persistence_failed. A TM import that ran asynchronously never returns 502 — the same message is recorded on the import job's error instead. |
503 |
Service unavailable | translation is temporarily disabled (translation is switched off for the deployment); billing is not enabled (billing is switched off for the deployment); top-ups are not configured; secret storage is temporarily unavailable — try again shortly (the platform's secret storage cannot be reached: on the storage binding routes nothing was changed; on a submission nothing was created, with error_code transient_error, as on a document download, review or approval that has to reach the tenant's own storage); transient_error; service_unavailable (for example google sign-in is temporarily unavailable — try again). |
504 |
Gateway timeout | stage_timeout pipeline error: a wait=true document request whose stage ran out of its time budget (document translation timed out in stage <stage>; job <id> is marked failed). |
507 |
Insufficient storage | Platform storage cap admission on POST /api/documents/translate and on PDF/DOCX/XLSX uploads to POST /api/translations: this tenant's stored document artifacts, plus the upload being submitted, would pass the cap this deployment sets for platform-hosted bytes. The detail reports how many bytes are used of how many, and names both remedies — delete document jobs you no longer need (DELETE /api/documents/{document_id}), or bring your own storage (PUT /api/storage/binding) so the bytes land in your account instead. Checked against the declared body size: before the upload is parsed on POST /api/documents/translate, and before any job is created on POST /api/translations. Skipped entirely for a tenant already bound to its own storage, and inert on a deployment that sets no cap — which is the default. |
Handling errors: worked example
curl -i -X POST "https://trueidiom.com/api/translations?api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_API_KEY" \
-H "Idempotency-Key: order-4471-en-fr" \
-H "X-Request-ID: $(uuidgen | tr -d - | tr 'A-Z' 'a-z')" \
-H "Content-Type: application/json" \
-d '{
"source_text": "Payment is due within thirty days of invoice.",
"source_lang": "en",
"target_lang": "en-CA"
}'
HTTP/1.1 422 Unprocessable Entity
content-type: application/json
x-request-id: 4f1c9a02b6d84e7f9c3a5e81d2704b6a
{"detail":"unsupported language(s): 'en-CA'. Supported: 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"}
Feature flags
Several capabilities are gated by deployment configuration. They are set per deployment; check GET /healthz for backend liveness and handle these statuses defensively rather than assuming a capability's state.
| Capability | Default | What callers see when it is off |
|---|---|---|
| Translation | on | The endpoints that start new work return 503 {"detail": "translation is temporarily disabled"} — POST /api/translations and POST /api/documents/translate. Reads, reviews, approvals, and downloads of existing jobs keep working. |
| Billing | off | POST /api/billing/checkout, POST /api/billing/topup, POST /api/billing/portal, and POST /api/stripe/webhook return 503 {"detail": "billing is not enabled"}. No subscription or trial gate applies to translation, so no 402 is raised. |
| Bring Your Own Storage | off | All /api/storage/binding* endpoints return 409 {"detail": "Bring Your Own Storage is not enabled on this service"}. Every tenant resolves to platform storage. |
| Email signup | on | POST /api/auth/signup/email returns 403 {"detail": "email signup is disabled — use single sign-on"}. |
| Email sign-in | on | The password grant on POST /api/oauth2/token returns 403 {"detail": "password sign-in is disabled — use single sign-on"}. |
| Google sign-in | off | GET /api/oauth2/google/client-config and the google authorize/callback routes return 403 {"detail": "Google sign-in is disabled"}. google is the only {provider} value available to public integrators; a value the service does not implement is 404, not 403 — a disabled provider and an unknown one are deliberately distinguishable, since only the first is something an operator can switch on. |
| Deployment-wide required MFA | off | When on, sign-in and signup return {"mfa_required": true, "mfa_challenge": {...}} instead of a token pair; complete the flow at POST /api/auth/mfa/verify or the urn:ietf:params:oauth:grant-type:mfa-otp grant. A challenge with "method": "totp_enroll" is completed by enrolling instead (POST /api/auth/mfa/enroll → /confirm), which is how a user with no authenticator gets in — no mailer is involved, TOTP is the only method required. This setting is not the only source of a challenge: any tenant can require MFA of its own members via PATCH /api/tenants/security, and completed Google sign-ins are exempt from both unless that tenant also sets mfa_required_for_sso. See Authentication → MFA. |
Auth-endpoint rate limiting is on by default at 30 attempts per 60-second window per client IP per bucket. Budget your sign-in retries accordingly.
When billing is enabled, a newly created tenant is granted a one-time free-trial allowance of source characters (150,000 by default). It covers text translation only, and only for signed-in users — layout document translation always requires an active subscription and returns 402 without one, and so does a request authenticated with the tenant API key (No API during the free trial).
Idempotency and retries
Idempotency-Key
Two endpoints honor an Idempotency-Key request header:
| Endpoint | Behavior |
|---|---|
POST /api/translations |
Simple shape: the header becomes the job's idempotency_key unless the body already sets one (an explicit body idempotency_key wins). Batch shape (inputs): the header is expanded per resulting translation as <key>:0, <key>:1, … in expansion order. Text-document shape: expanded per page as <key>:1, <key>:2, … (1-indexed) — the body's idempotency_key when a JSON body sets one, otherwise the header. A PDF/DOCX/XLSX upload becomes a document job and is keyed exactly as on POST /api/documents/translate. |
POST /api/documents/translate |
The header is stored on the document request and checked before a new job is created. |
Keys are matched exactly and are scoped to your tenant: the same key sent by another tenant creates that tenant's own job and never returns yours, so a key only has to be unique within your tenant (a UUID, or a namespaced key like order-4471-en-fr).
On a replay, the job that already holds the key is returned as it stands:
POST /api/translationsreturns the existingTranslationJobwith its originalidand status, without running the pipeline again — with two exceptions, both of which run it again under the sameidand return the outcome:- a job that ended
failed(for example after a503 transient_error) is re-run; - a job still
pending10 minutes (by default) after its last update lost the process that was running it, and the replay re-runs it once.
- a job that ended
POST /api/documents/translatereturns the existingDocumentTranslationJob— afailedone included; document jobs are never re-run — with status200(not202) when that job has already reachedtranslatedorfailed.
A replay that starts no work is answered before the billing, quota and storage gates, so it is never refused with 402, 429 or 409 storage_binding_not_verified for work that already happened — the retry of a job that took the month's last characters gets that job back. On POST /api/translations that holds when every job the request names already exists and none of them is failed or stale pending (those are re-run, so they pass the gates like new work); on POST /api/documents/translate it holds whatever the existing job's status.
Concurrent identical submissions collapse onto a single job: two requests carrying the same key race safely, and only one job is created. The later request gets the job as it stands at that moment — possibly still pending (text) or queued (documents); poll it for the outcome.
On the JSON shape of POST /api/translations you may set idempotency_key in the request body instead of the header; it is the same mechanism, and a body value takes precedence. POST /api/documents/translate is multipart and has no equivalent form field — use the header there.
Billing and idempotency
Per-job usage recording is deduplicated by job id, so an idempotent replay that returns an existing job does not record usage a second time, and does not draw the prepaid balance twice for the same work.
Only finished text work is metered. A job that ends failed is never billed, and when a keyed retry re-runs it, only the attempt that finishes is billed, once. Each item of a batch, and each page of a text document, is billed as it completes, so the items before a failing one stay billed — their translations were delivered and remain readable. A job parked as persistence_failed is billed even though the request that parked it answered 409 or 502: its translation is complete and readable through GET /api/translations/{job_id}.
Document approval is idempotent
POST /api/documents/{document_id}/approve can be called repeatedly. A second approve returns approval_result.already_approved: true with fed_pairs: 0, and approval_result.reviewer is the original approver, not the current caller.
Retry guidance
| Situation | Retry? |
|---|---|
429 with Retry-After |
Yes, after the stated delay. |
429 tenant usage limit exceeded, 429 quota_exceeded |
No — not until there is headroom again. A prepaid top-up adds it within the current month. An upgrade applies immediately — the prorated difference is charged at once — and restarts the allowance, which then counts only usage from the switch, so it clears the refusal as soon as it takes effect; a downgrade waits for renewal. Otherwise the UTC month must roll over. When the detail says this month's character ceiling is reached, neither remedy can add characters this month — contact us. |
503 transient_error |
Yes, with exponential backoff and the same Idempotency-Key — a keyed retry of a text job that failed runs it again. |
503 translation is temporarily disabled |
Yes, but with a long backoff — this is a deployment kill switch, not a transient fault. |
422 content_filtered |
No. The same text is refused again. |
409 storage_binding_not_verified on a submission |
Not as is. Nothing was created or translated: verify the binding (POST /api/storage/binding/verify) or switch back to platform storage, then resubmit. |
409 storage_binding_not_verified on a review approval, 502 persistence_failed |
No. The job is parked as persistence_failed; POST /api/storage/binding/verify republishes it once storage accepts it, and so does switching the workspace back to platform storage — see Storage. Poll the job rather than resubmitting. |
A document job that failed with failure.code stage_timeout, or 504 stage_timeout on a wait=true request |
Not unchanged. The stage ran out of its time budget and was not retried; the same document normally times out again, so split it into smaller files first. |
500 internal_error |
Once, with the same Idempotency-Key. If it persists, give support the request_id. |
4xx other than 429 |
No. Fix the request. |
| Network timeout with no response | Yes, with the same Idempotency-Key. That is what the key is for. |
Async jobs are poll-only
There are no webhooks and no callback URL in this API. No endpoint accepts a notification target, and the server never calls out to a client. The only completion signal for an asynchronous job — document or TMX import — is polling.
Poll GET /api/documents/{document_id} until status is translated or failed. A 2-second interval is reasonable; back off to 5–10 seconds for documents over a few pages. GET /api/documents/{document_id}/download never hands back the untranslated source: it returns 409 document translation is still in progress while the job is in flight (pending, queued or extracted), and 409 document translation failed; no translated output is available once it has failed.
Large TMX imports follow the same pattern: poll the poll_url from the 202 (GET /api/tm/imports/{job_id}) until job.status is completed — job.result then holds exactly what a synchronous 200 would have returned — or failed, when job.error explains what to do. Neither terminal state is retried server-side; a failed import imported nothing and is re-run by re-uploading the file. Idempotency-Key is not honored on POST /api/tm, so guard a retry with the 409 tm_import_in_progress: answer rather than resending blindly.
(POST /api/stripe/webhook is an inbound receiver that Stripe calls — it is not a notification channel you can point at your own service.)
The service automatically retries transient upstream failures on your behalf, honoring any upstream Retry-After, so a 503 reaching you means those retries were already exhausted.
An upstream quota that stays saturated past those retries surfaces as 429 rate_limit on the synchronous endpoints, and as failure.code = "transient_error" on asynchronous document jobs (after the automatic stage retries are also spent). Both are retryable — back off further than usual, since the constraint is the deployment's throughput, not your request.
An upstream outage — server errors, timeouts, dropped connections — that outlasts those retries surfaces the same way: 503 transient_error on the synchronous endpoints, failure.code = "transient_error" on document jobs. A content-policy refusal is different: it is not retried, answers 422 content_filtered on the synchronous endpoints, and fails an asynchronous document job with failure.code = "unexpected_error".
Versioning
The API is versioned by date, supplied as a query parameter:
"https://trueidiom.com/api/translations?api-version=2026-09-01"
There is no version segment in the path. Do not construct URLs containing /v1 — those paths do not exist and return 404. If you are looking at an older internal document that describes /v1/agents, /v1/agents/{id}/runs, or /v1/auth/refresh, those endpoints are not part of this API.
Released versions
| Version | Status | Notes |
|---|---|---|
2026-09-01 |
Current | Launch contract. Everything documented in this reference. |
Supplying the version
| Where | Example | Notes |
|---|---|---|
| Query parameter (canonical) | ?api-version=2026-09-01 |
Visible in a curl line and in access logs, which is where you will be debugging. |
X-Api-Version header |
X-Api-Version: 2026-09-01 |
Accepted alias, for clients that would rather set it once on a session than on every URL. |
When both are sent, the query parameter wins — the same direction as X-API-Key beating Authorization.
Every versioned response echoes the version it was served as:
HTTP/1.1 200 OK
X-Request-ID: 4f1c9a02b6d84e7f9c3a5e81d2704b6a
X-Api-Version: 2026-09-01
If you are not sure what a caller is actually pinned to, read that header rather than inferring it from the request.
The parameter is optional
Omitting it is legal and serves the oldest supported version — today, 2026-09-01. It never means "latest".
That is a deliberate guarantee: an unversioned integration written today keeps receiving today's contract after new versions ship. Silently re-pointing unversioned callers at a newer version is the one change guaranteed to break existing integrations, and this API will not make it.
Send the parameter anyway. An explicit pin is what lets you upgrade on your own schedule instead of on the deprecation clock, and it makes your integration visible in adoption telemetry when a version is being retired.
Unknown versions are rejected, not rounded
A pin that is not a released version returns 400 — including a well-formed date that has not shipped:
curl -sS "https://trueidiom.com/api/translations?api-version=2027-01-01" \
-H "X-API-Key: $TRUEIDIOM_API_KEY"
{"detail": "unknown api-version: '2027-01-01' is not a released version. Supported: 2026-09-01"}
A malformed value (v1, 2026/09/01, an empty string) returns 400 with unsupported api-version: ... and the same supported list. Both messages name every released version, so a broken pin is fixable from the error alone.
Rounding an unknown date to the nearest release would let a client pinned ahead of a deploy silently ride a contract it was never written against. Failing is the safer default.
Endpoints that take no version
| Endpoint | Why |
|---|---|
GET /healthz |
Liveness probe, not part of the integration surface. Load balancers and platform probes cannot be taught to send a parameter. |
POST /api/stripe/webhook |
Inbound — Stripe calls it, and will never send our parameter. |
The browser pages (/, /app, /signin, …) |
Not JSON, not an integration surface. |
These ignore the parameter entirely and emit no X-Api-Version. Sending one is harmless.
Note also that GET /api/translations/{job_id}/golden is frequently opened as a plain browser link, which carries no parameter. Because the pin is optional, those links keep working.
What lands in a new version, and what does not
Additive changes ship inside the current version — they do not get a new date:
- New optional response fields. Parse defensively: ignore unknown fields rather than failing on them, and never depend on JSON key ordering.
- New optional request fields. Pin your integration to the fields documented on the endpoint pages, and assert on the returned object rather than assuming a field you sent was applied.
- New endpoints, and relaxed validation.
- New enum members.
JobStatusandDocumentJobStatuscan gain values within a version. Treat an unfamiliar status as "not one I handle" rather than as a parse failure — a strict client that throws on an unknown enum will break without a version change to warn it.
A new version date is minted only for a breaking change: removing or renaming a field, tightening validation, changing a status code, or changing a default.
Deprecation policy
No version is deprecated today — there is only one, and nothing emits deprecation headers yet.
The policy for when that changes: at most two versions run concurrently, with at least six months of overlap, and requests pinned to a retiring version will carry Deprecation: true and Sunset: <HTTP-date> (RFC 8594) alongside the normal response for the whole overlap window. Watch for those two headers rather than for an announcement.
Machine-readable schema
GET /openapi.json declares api-version as an optional query parameter (and X-Api-Version as an optional header) on every operation that accepts it, so a generated client exposes the pin without hand-editing.
That document's own info.version reads 2026-09-01, matching the API version it describes. It is a static property of the published document, not a negotiation mechanism: it does not vary per request, and it is not what pins a call. Pin with the api-version query parameter, and read the version a request was actually served as off the X-Api-Version response header.
Diffing GET /openapi.json between deploys remains the most reliable signal that the surface changed.
What's next
- Authentication — how to obtain and send each credential the guard matrix names.
- Text translation — the inline text endpoints these conventions govern first.
- Documents — the asynchronous document jobs, with their own upload limits.
- Terminology & TM — glossary and TMX uploads, where the payload limits bite hardest.