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"}. - 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, 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"}.
Platform operator (GET /api/documents/summary with a breakdown flag; 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 and who has an authenticator enrolled (both re-checked per request; it fails closed without the enrolment).
global_administratordoes not qualify — see the tenant-scoping note below. - Authenticated non-operator →
403 {"detail": "cross-tenant breakdowns require operator access"}.
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, optional — resolves a tenant when credentials are sent, serves the file when they are not | — |
DELETE /api/translations/{job_id}, DELETE /api/documents/{document_id} | Admin, tenant-scoped — the tenant's own global_administrator (or the operator key, which alone 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/PUT/DELETE /api/storage/binding, POST /api/storage/binding/verify | Tenant identity | 401 missing or invalid tenant credentials |
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, 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, PATCH /api/auth/users/{user_id}/role | Admin | 401 admin credentials required, 403 admin role required |
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. The reset-link route additionally returns 400 for an IdP member, who has no password to reset | 401 admin credentials required, 403 admin role required |
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 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".
Three 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}).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.
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. 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, de, it, pt-BR, pt-PT, nl, zh-Hans, zh-Hant, ja, ko, ar, ru, hi, tr, vi, th, id
Matching is case-insensitive on the exact code (EN → en, zh-hans → zh-Hans). Regional subtags and bare variant bases are rejected by design: en-US, fr-CA, bare zh, and bare pt all fail. 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, de, it, pt-BR, pt-PT, nl, zh-Hans, zh-Hant, ja, ko, ar, ru, hi, tr, vi, th, id"
}
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. 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. 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. |
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, 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, which no delay clears. |
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.
Gotcha: the JSON request_id field inside pipeline error bodies is read from the inbound X-Request-ID header. If you do not send one, the response header carries a generated id but the JSON field is null. Send your own X-Request-ID if you want the two to agree.
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 whose layout has not been produced yet returns
total: 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, from the Content-Length header before the body is parsed. | 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 |
| 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 | POST /api/translations, counted from the payload before any work starts and weighted by language pair — a Chinese, Japanese, Korean, Thai or Hindi pair counts 3.5 per source character (Dense-script character weighting). | 429 {"detail": "tenant usage limit exceeded"}, followed by whichever remedies that tenant actually has (Quotas) |
| 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. 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 |
| 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. 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 primary subtag, so fr-CA and fr share one budget; 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 (keyed additionally by challenge_id), mfa-enroll, mfa-confirm, mfa-recovery, invite-preview, password-reset, oauth-authorize, oauth-callback.
The client IP is taken from the first entry of X-Forwarded-For when present, otherwise the socket peer.
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 can run for minutes on a large document, so allow at least 480 s — four times the per-stage budget — before giving up on it.- Document processing runs in stages, each with a server-side budget of 120 s and up to two automatic retries. A stage that exhausts its budget surfaces as a
stage_timeoutfailure on the job. 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.
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 capacity rejections, and every billing request-body rejection (the tier on POST /api/billing/checkout and the packs on POST /api/billing/topup, including the raw validation message when the field is missing or of the wrong type) return 422 with a string detail. Branch on the type of detail, not on the status code.
Three 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:
| 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 | An asynchronous import into the same set name is still running; an import replaces its set wholesale, so the two cannot overlap. Nothing was imported — poll the job named in the message, then re-upload. |
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.
{
"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 | null | The inbound X-Request-ID header value, or null when you did not send one. |
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 document with no layout, an approve on a job that is not translated. |
not_found | 404 | A referenced job or resource does not exist. |
conflict | 409 | The request conflicts with current state. |
storage_binding_not_verified | 409 | Bring-Your-Own-Storage is configured for the tenant but the binding is not verified. The owning job is parked as persistence_failed and republishes automatically once the binding verifies. |
rate_limit | 429 | A caller or upstream rate limit was hit. |
internal_error | 500 | Unexpected internal failure. |
persistence_failed | 502 | Writing the golden record failed. The job is parked as persistence_failed and can be retried. |
transient_error | 503 | A retryable upstream failure. Safe to retry with backoff. |
service_unavailable | 503 | A required dependency is unavailable or misconfigured. |
stage_timeout | 504 | A pipeline stage exceeded its timeout budget. Retryable. |
transient_error, stage_timeout, and rate_limit are the retryable set. Everything else indicates the request will fail the same way if replayed unchanged.
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, 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, invalid webhook signature. Also every validation_error pipeline error. |
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; invalid tenant credentials, invalid or expired MFA challenge, invalid or expired refresh token on the auth endpoints. |
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. |
403 | Forbidden | Authenticated but not permitted: admin role required, cross-tenant breakdowns require operator access, email signup is disabled — use single sign-on, password sign-in is disabled — use single sign-on, Google sign-in is disabled. |
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. 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 queued), tm_import_in_progress: ... (an asynchronous TMX import into the same set name has not finished), no Stripe customer for this tenant yet — subscribe first, Bring Your Own Storage is not enabled on this service, secret storage is not configured, storage_binding_not_verified. |
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; batch expansion failures; 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); account_url is required for provider=adls; a secret is required for auth_mode=sas; stored secret is missing: ...; storage binding verification failures; 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); tenant usage limit exceeded (monthly character allowance — the detail continues with the tenant's remedies, see Quotas); 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); internal_error pipeline error. |
502 | Bad gateway | A backend required by a terminology or TM import was unavailable; 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); transient_error; service_unavailable. |
504 | Gateway timeout | stage_timeout. |
507 | Insufficient storage | Platform storage cap admission on POST /api/documents/translate: 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 before the upload is parsed, against the declared body size. 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, de, it, pt-BR, pt-PT, nl, zh-Hans, zh-Hant, ja, ko, ar, ru, hi, tr, vi, th, id"}
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; any other value 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 — layout document translation always requires an active subscription and returns 402 without one.
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). |
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 the job store, not to your tenant — use a value with enough entropy to be globally unique (a UUID, or a namespaced key like order-4471-en-fr).
On a replay, the original job is returned, and the pipeline does not run again:
POST /api/translationsreturns the existingTranslationJobwith its originalidand status.POST /api/documents/translatereturns the existingDocumentTranslationJob, with status200(not202) when that job has already reachedtranslatedorfailed.
Concurrent identical submissions collapse onto a single job: two requests carrying the same key race safely, and only one job is created.
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.
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 | No. A prepaid top-up clears it inside the current period; a plan switch raises the allowance only from the next one; otherwise the period must roll over. |
503 transient_error, 504 stage_timeout | Yes, with exponential backoff and the same Idempotency-Key. |
503 translation is temporarily disabled | Yes, but with a long backoff — this is a deployment kill switch, not a transient fault. |
502 persistence_failed, 409 storage_binding_not_verified | No. The job is parked and replays on its own once storage is healthy; poll the job instead. |
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 returns 409 document translation is still in progress while the job is queued rather than handing back untranslated source.
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.
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.