Terminology & Translation Memory

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

This page documents the ten endpoints that manage a tenant's linguistic assets: terminology (glossary) sets under /api/terminology and translation-memory (TM) sets under /api/tm, including TMX export, the approved-pair "golden" export, and the poll surface for large TMX imports that finish in the background. Both asset types are tenant-scoped, are consumed automatically by the translation pipelines (there is no per-request parameter that selects a set), and change what a translation costs — a 100% TM match is returned verbatim, with no call to the translation engine. Read this when you are loading a customer's glossary or TMX archive into TrueIdiom, exporting memory back out for a CAT tool, or reasoning about why a job published without touching a model.

Base URL: https://trueidiom.com. All paths are prefixed with /api; there is no /v1 prefix. Requests take an optional api-version=2026-09-01 query parameter; omitting it serves the oldest supported version. See Versioning.


Endpoint summary

MethodPathPurposeAuth
POST/api/terminologyCreate or replace a terminology set from a CSV or TMX uploadTenant credentials
GET/api/terminologyList the tenant's terminology setsTenant credentials
DELETE/api/terminology/{set_id}Delete a terminology setTenant credentials
POST/api/tmCreate or replace a TM set from a TMX 1.4b uploadTenant credentials
GET/api/tmList the tenant's TM setsTenant credentials
GET/api/tm/importsList this tenant's recent asynchronous TMX importsTenant credentials
GET/api/tm/imports/{job_id}Poll one asynchronous TMX importTenant credentials
DELETE/api/tm/{set_id}Delete a TM setTenant credentials
GET/api/tm/{set_id}/exportDownload one TM set as TMX 1.4bTenant credentials
GET/api/tm/export/goldenDownload the approved golden pairs for one language pair as TMX 1.4bTenant credentials

The generated document declares no securitySchemes and no global security requirement, so the interactive /docs page shows every endpoint as unauthenticated. That document (/openapi.json, rendered at /docs) also declares no requestBody for the two POST endpoints, whose multipart form is read from the request directly. Auth is real, and every endpoint on this page enforces it.

Treat this page, not /docs, as the contract. The gaps are not confined to auth and request bodies. Both DELETE responses are untyped; the three file downloads across this page and Text translation (GET /api/tm/{set_id}/export, GET /api/tm/export/golden, GET /api/translations/{job_id}/golden) are declared application/json with empty schemas, though the two TMX exports return application/xml and none of the three returns JSON; and the list and create responses here are typed as open objects rather than as the set shapes documented below.


Authentication

Every endpoint on this page resolves identity the same way — a bearer access token first, then a tenant API key, narrowed to one account by X-Tenant-ID when you send it — and then scopes the operation to the tenant that resolved.

When the deployment has tenant accounts (the case on https://trueidiom.com), you must present tenant credentials:

CredentialHow to send it
Tenant API keyX-API-Key: <tenant api key> — or Authorization: Bearer <tenant api key>
Access tokenAuthorization: Bearer <access token> from POST /api/oauth2/token
Tenant selector (optional)X-Tenant-ID: <tenant id> — narrows the key lookup to one account

Without a usable credential the request fails with 401 and body {"detail": "missing or invalid tenant credentials"}. Sending X-Tenant-ID for an account the key does not belong to also returns 401.

The platform operator API key is not accepted here. It is a break-glass credential for admin routes; on /api/terminology and /api/tm it resolves to no tenant and returns 401. Use the tenant's own API key or a user access token.

Where the tenant API key comes from. Two endpoints mint it, under two different field names:

EndpointFieldAuth needed to call it
POST /api/auth/signup/emailtenant_api_keyNone. This is the self-service path and the one to use if you are starting from nothing.
POST /api/tenantsapi_keyAdmin — the operator key or a global_administrator session. A tenant API key alone gets 401 admin credentials required here.

Either way the key is returned exactly once. The curl examples below use $TRUEIDIOM_API_KEY:

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

On a single-tenant deployment that authenticates with one configured platform key instead of per-tenant accounts, send that key as X-API-Key and every set is owned by the literal tenant id default.

These routes are never gated by the switches that disable translation or billing. Terminology and TM remain readable, writable, and exportable while translation submission is turned off, and importing a set is never billed.

Every response carries an X-Request-ID header (echoed from the request when you supply one).


Tenant scoping

Sets belong to exactly one tenant and are never shared, merged, or visible across tenants.

  • Create writes the caller's tenant id onto the set and every term/segment row.
  • List returns only the caller's sets.
  • Delete and export match on (tenant_id, set_id); another tenant's set id returns 404, indistinguishable from a set id that does not exist.
  • Import jobs are scoped the same way. GET /api/tm/imports lists only the caller's jobs, and another tenant's job id returns 404, never 403.
  • Uniqueness is per tenant on (tenant_id, name). Two tenants may both own a set named finance-en-fr.
  • At translation time, lookups are filtered by the tenant id carried on the job's request metadata, so one tenant's glossary can never leak into another's output.

Re-posting an existing name for the same tenant is an upsert: the set keeps its id and created_at, and its terms/segments are replaced wholesale. There is no partial-append endpoint — to add terms, re-upload the full file.


Terminology sets

A terminology set is a list of source_term → target_term pairs, optionally language-pinned and prioritised. Terms are indexed for matching when the set is uploaded.

The terminology set object

FieldTypeDescription
idstring32-char hex identifier. Stable across re-uploads of the same set name.
namestringSet name as supplied.
source_langstring | nullLanguage code as supplied at upload (not normalised). null when a CSV upload omitted it.
target_langstring | nullAs above.
entry_countintegerTerm pairs actually stored (after blank and unusable rows are skipped).
embedding_modelstringIdentifier of the model that indexed the terms.
stub_embeddingsbooleantrue when the deployment is running without translation-engine credentials, so the set was indexed deterministically instead. Expect false in production.

POST /api/terminology

Create a terminology set, or replace an existing set of the same name, from a CSV or TMX upload.

Auth: tenant credentials (see above). 401 without them.
Content type: multipart/form-data.

FieldTypeRequiredDescription
terminology_filefileYes.csv or .tmx upload. The legacy field name terminology_csv is accepted as a fallback when terminology_file is absent.
namestringYesSet name, 1–128 characters. Re-using a name replaces that set's terms and keeps its id.
source_langstringConditionalRequired for .tmx uploads; optional for CSV.
target_langstringConditionalRequired for .tmx uploads; optional for CSV.

File-type detection: the filename suffix decides. A suffix other than .csv or .tmx is rejected with 415. With no suffix, a content type in application/xml, text/xml, application/x-tmx+xml, or application/octet-stream is treated as TMX; anything else as CSV. A declared content type must then match the detected kind — CSV accepts text/csv, application/vnd.ms-excel, text/plain; TMX accepts the four types above. An empty content type skips the check.

CSV format. The delimiter is sniffed from the first 4 KB across ,, ;, tab, and |, defaulting to ,. Bytes must decode as UTF-8 (BOM tolerated) or UTF-16.

A header row is used only when the file has both a source-term column and a target-term column, matched case-insensitively after normalising punctuation to _:

Canonical columnAccepted header aliasesMeaning
source_termsource_term, source term, source, src_term, src term, term_sourceTerm as it appears in the source text. Required.
target_termtarget_term, target term, target, tgt_term, tgt term, term_targetMandated translation. Required.
source_langsource_lang, source lang, src_lang, src lang, from_lang, from langPins the entry to a source language. Empty = applies to any.
target_langtarget_lang, target lang, tgt_lang, tgt lang, to_lang, to langPins the entry to a target language. Empty = applies to any.
case_sensitivecase_sensitive, case sensitive, casesensitive1/true/yes/on = match case-sensitively. Anything else = case-insensitive. Default false.
prioritypriorityInteger, default 0. Higher priority wins when two entries could match the same span.

Without a recognised header pair, the file is read positionally, including the first row:

source_term,target_term[,source_lang,target_lang,case_sensitive,priority]

This is the shape of the sample enfr-terms.csv in the repository root:

Bank,Banque
Card,Carte
Crane,Grue
Office,Bureau
Tiger,Tigre
US,United States

A header row whose names are not in the alias table (for example EN,FR) is therefore imported as a term pair. Rows with a blank source or target term are skipped. A file with no usable rows returns 400 with error_code: validation_error.

TMX format. Each <tu> carrying a <tuv> for both requested languages becomes one term pair, keeping the unit's own language codes; case_sensitive is false and priority is 0 for every TMX-derived entry. Documents using forbidden XML constructs are rejected rather than resolved. Inline <bpt>/<ept>/<ph>/<it>/<ut> containers are dropped and <hi> contents kept.

Limits.

LimitValueBehaviour
name length128 characters400 above the limit.
Entries stored per tenant + language pair2000An upload that would take the pair past the cap is refused whole with 422 — see Capacity below. Nothing is truncated and nothing is stored.
Request body30 MB413 with {"detail": "request body exceeds MAX_UPLOAD_BYTES"}, checked both against the declared Content-Length and against the body as it arrives.

Capacity. The 2000-term cap is a ceiling on the tenant's terms for one language pair across all of its sets, not a per-set allowance. Before anything is stored, the server adds up the entry_count of every other set that applies to this upload's pair; the upload fits only if incoming ≤ cap − existing. Pairs are matched on primary subtag, so fr-CA and fr draw on the same budget, and a set that declared no language pair applies to every pair — so it counts against every pair, and an upload that declares no pair is weighed against the tenant's whole terminology.

The set an upload replaces is excluded from existing: re-uploading under an existing set's name swaps its terms rather than adding to them, which is the cheapest way to stay inside the cap. Otherwise free room with DELETE /api/terminology/{set_id}.

An upload over the allowance is rejected whole with 422 and a string detail whose first token is stable:

{"detail": "terminology_max_terms_exceeded: this upload has 1500 term(s) and the tenant already stores 900 term(s) for this language pair, which exceeds the TERMINOLOGY_MAX_TERMS cap of 2000 — upload at most 1100 term(s), re-upload under an existing set's name to replace that set, or free room with DELETE /api/terminology/{set_id}"}

Branch on the terminology_max_terms_exceeded: prefix; treat the rest as human-readable. The check runs before anything is indexed or written, so a rejected upload stores nothing and changes no existing set — fix it and resend.

Language-pair scoping. source_lang/target_lang on the request pin the whole set; a per-row language column overrides it for that row. Matching compares primary subtags, so an entry tagged fr matches a request for fr-CA and vice versa. An entry with no language (a CSV upload that omitted both fields and has no language columns) matches every language pair for that tenant — upload language-agnostic term lists deliberately.

curl -X POST "https://trueidiom.com/api/terminology?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  -F "terminology_file=@enfr-terms.csv;type=text/csv" \
  -F "name=finance-en-fr" \
  -F "source_lang=en" \
  -F "target_lang=fr"
{
  "id": "554147ff548e41b18a3d6d3b293989df",
  "name": "finance-en-fr",
  "source_lang": "en",
  "target_lang": "fr",
  "entry_count": 6,
  "embedding_model": "<model id>",
  "stub_embeddings": false
}

TMX variant (both language fields are mandatory here):

curl -X POST "https://trueidiom.com/api/terminology?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  -F "terminology_file=@legal-terms-en-fr.tmx;type=application/xml" \
  -F "name=legal-terms-en-fr" \
  -F "source_lang=en" \
  -F "target_lang=fr"

Errors.

StatusdetailCause
400terminology_file upload is requiredNo file part under terminology_file or terminology_csv.
400terminology_file is emptyZero-byte upload.
400name is requiredMissing or whitespace-only name.
400name must be at most 128 charactersName too long.
400source_lang and target_lang are required for TMX terminology uploadsTMX upload without both language fields.
400Glossary CSV is empty / Glossary CSV did not contain valid term rowsCSV parsed to zero usable rows. Carries error_code: validation_error.
400TMX parse message, e.g. Root element is <notmx>, expected <tmx>Malformed TMX, forbidden XML construct, or no unit for the language pair.
401missing or invalid tenant credentialsNo usable tenant credential.
413request body exceeds MAX_UPLOAD_BYTESUpload larger than the configured ceiling.
415terminology_file must be a .csv or .tmx fileUnsupported filename suffix.
415terminology_file must be text/csv / ... must be XML (TMX 1.4b)Declared content type does not match the detected kind.
422terminology_max_terms_exceeded: this upload has N term(s) and the tenant already stores M term(s) for this language pair, …The upload would take the tenant past the 2000-term cap for the pair (see Capacity above). Nothing is stored.
502A message naming the service that was unavailableA service TrueIdiom needs in order to index the upload could not be reached. Nothing is stored; retry.
502A message reporting sustained throttlingThat service stayed saturated for longer than the server waits it out; brief throttling is absorbed transparently. Nothing is stored — retry later or split the upload.

GET /api/terminology

List every terminology set owned by the caller's tenant, ordered by name, then id.

Auth: tenant credentials. 401 without them.
Parameters: none.

curl "https://trueidiom.com/api/terminology?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"
[
  {
    "id": "554147ff548e41b18a3d6d3b293989df",
    "name": "finance-en-fr",
    "source_lang": "en",
    "target_lang": "fr",
    "entry_count": 6,
    "embedding_model": "<model id>",
    "stub_embeddings": false
  }
]

An empty array is returned when the tenant owns no sets. Errors: 401.

DELETE /api/terminology/{set_id}

Delete one terminology set and its terms.

Auth: tenant credentials. 401 without them.

NameTypeRequiredDescription
set_idstring (path)YesThe id from create/list.
curl -X DELETE "https://trueidiom.com/api/terminology/554147ff548e41b18a3d6d3b293989df?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"

Returns 204 No Content with an empty body. (The generated spec lists a 200 for this operation; the implementation returns 204.)

Errors: 401; 404 {"detail": "terminology set not found"} when the id is unknown, already deleted, or owned by another tenant. Deletion takes effect on the next translation.


How terminology is enforced during translation

Enforcement is deterministic and runs before any model call: every term the tenant holds for the language pair is matched against the source text directly, with no model involved.

Per job, that pre-pass contributes four caller-visible effects:

  1. Whole-segment glossary hit. If the whitespace-collapsed source text is a glossary source term, its target is published verbatim without a translation being generated. The job comes back status: "published", request.metadata.provenance.fast_path: "glossary_exact", agent_run.quality_gate.score: 100.0, and no engine usage recorded.
  2. Term protection. Matched source terms are held out of the text the translation engine sees and restored afterwards, so a mandated term cannot be paraphrased. Matching is word-boundary aware at each edge, so C++ and .NET match while cat never matches inside category. Case sensitivity and priority come from the entry; longer and higher-priority terms win.
  3. Engine guidance. Matched pairs are supplied to the translation engine as an explicit list of required terms.
  4. A compliance gate. After translation, each expected target term is counted in the output, and a protected term that did not come back intact counts as a failure. The result is written to request.metadata.terminology_gate:
{
  "passed": false,
  "missing_terms": { "Virement": 1 },
  "expected_target_counts": { "Virement": 1 },
  "observed_target_counts": { "Virement": 0 },
  "matched_source_terms": ["Wire transfer"]
}

A non-compliant candidate cannot publish autonomously — it is routed to human review regardless of its quality score. The matched entries are also persisted on the request (request.metadata.terminology_matches, request.metadata.terminology_entries) so POST /api/translations/{job_id}/review rebuilds the same gate.

Both fast paths are suppressed when the request is sensitive or regulated — domain in finance, financial, healthcare, legal, medical, pharma, or metadata.sensitive / metadata.regulated / metadata.requires_human_review set. Such jobs fall through to the gated pipeline and land in human review (fast_path decision outcome glossary_exact_held).

In the document pipeline the same pre-pass runs per segment, so a tenant's terminology and TM apply to document segments no matter how the deployment is configured. There is no engine that bypasses them. Verbatim segments are stamped fast_path: "glossary_exact" in the per-segment quality report.


Translation memory sets

A TM set is a collection of bilingual segments for one language pair. Segments arrive from two origins:

originSourceWrite pattern
tmx_importPOST /api/tmWholesale replacement keyed by (tenant_id, name).
goldenHuman approval of a translation job or documentAppend-only into the reserved per-pair set golden-approved-<src>-<tgt>.

Language codes on TM sets are normalised to their primary subtag on write and on query (en-USen); regional variants share one memory.

Four of the codes the translation API accepts collapse this way. zh-Hans and zh-Hant both normalise to zh; pt-BR and pt-PT both normalise to pt. A tenant translating into both Chinese scripts therefore has one enzh memory rather than one per script: one golden-approved-en-zh set that approvals in either script feed, one 50,000-segment budget covering both, and one exact-match index. The consequence to plan for is that a Simplified-Chinese approval is a 100% match for a Traditional-Chinese request — and a 100% match is published verbatim, with no translation step to catch the mismatch. The same holds in reverse, and for pt-BR against pt-PT. There is no request parameter that selects or excludes a set, so within one tenant the variants cannot be held apart.

The TM set object

FieldTypeDescription
idstring32-char hex identifier, stable across re-imports of the same name.
namestringSet name.
source_langstringPrimary subtag, e.g. en.
target_langstringPrimary subtag, e.g. fr.
originstringtmx_import or golden.
entry_countintegerSegments stored.
embedding_modelstringIdentifier of the model that indexed the segment sources.
stub_embeddingsbooleantrue when the deployment is running without translation-engine credentials.

A finished import returns the same object plus four import counters — directly in the 200 when the import ran inline, or inside the job's result when it ran in the background:

FieldTypeDescription
total_unitsinteger<tu> elements inspected in the upload.
importedintegerSegments actually stored.
skipped_unitsintegerUnits missing one of the two languages or with an empty segment.
duplicates_droppedintegerDuplicate source texts collapsed; the newest changedate wins.

There is no truncated counter: an import either fits the tenant's remaining capacity for the language pair and is stored whole, or it is rejected with 422 and nothing is stored. A 200 — or a completed job's result — therefore means the set holds exactly imported segments.

POST /api/tm

Import a TMX 1.4b file into a TM set, replacing any set of the same name.

A small file is imported inline and answered with 200. A file holding more than 5000 segments after duplicate sources collapse is answered with 202 and a job to poll instead — see Synchronous or asynchronous below. Every rejection is synchronous at both sizes.

Auth: tenant credentials. 401 without them.
Content type: multipart/form-data.

FieldTypeRequiredDescription
tmx_filefileYes.tmx upload. A suffix other than .tmx is rejected with 415; with no suffix, the content type must be one of application/xml, text/xml, application/x-tmx+xml, application/octet-stream.
namestringYesSet name, 1–128 characters. Must not start with golden-approved- (case-insensitive).
source_langstringYesSource language of the pair to extract.
target_langstringYesTarget language. Must differ from source_lang by primary subtag.

Import semantics: units are matched on primary subtag (a <tuv xml:lang="en-GB"> satisfies source_lang=en); the first non-empty <tuv> per subtag wins inside a multilingual <tu>; tuid and changedate are preserved; duplicate sources collapse by normalised-source hash keeping the newest changedate; what survives that collapse must fit the tenant's remaining capacity for the language pair (see Capacity below) or the whole import is refused. Request bodies are bounded at 30 MB as for terminology uploads.

Capacity. The 50,000-segment cap is a ceiling on the tenant's segments for one language pair across all of its sets — imported sets and the golden-approved-<src>-<tgt> set alike — not a per-set allowance. Language codes are compared as primary subtags, so en-GB → fr-CA and en → fr draw on the same budget — and, among the codes the translation API accepts, zh-Hans and zh-Hant draw on one shared enzh budget, as pt-BR and pt-PT do on one enpt budget.

The allowance for an import is that cap minus what every other set for the pair already holds. A set being replaced by a same-name re-import is excluded from that total, since the replacement swaps its segments rather than adding to them — re-importing under the same name is the cheapest way to stay inside the cap. The count checked against the allowance is the post-dedupe one, so a file with many repeated sources can fit even when its raw <tu> count would not.

An import over the allowance is rejected whole with 422 and a string detail whose first token is stable:

{"detail": "tm_max_segments_exceeded: translation-memory set 'legal-en-fr' would hold 12000 segment(s) (12400 unit(s) in the file, 12000 after duplicate sources collapse), but only 4300 more en->fr segment(s) fit under the TM_MAX_SEGMENTS cap of 50000: your other en->fr sets already hold 45700 segment(s) (1200 of them approved golden pairs). Nothing was imported. Re-importing under the SAME set name replaces that set (its own segments do not count against the allowance); otherwise delete a translation-memory set with DELETE /api/tm/{set_id} to free budget."}

Branch on the tm_max_segments_exceeded: prefix; treat the rest as human-readable. The check runs before anything is indexed or written, so a rejected import stores nothing and leaves the existing set untouched. GET /api/tm is the way to see where the budget went — the counter to watch is the sum of entry_count over the sets sharing the pair.

curl -X POST "https://trueidiom.com/api/tm?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  -F "tmx_file=@legal-en-fr.tmx;type=application/xml" \
  -F "name=legal-en-fr" \
  -F "source_lang=en" \
  -F "target_lang=fr"
{
  "id": "dc1ac7f1606c46519a8b0f138da39dde",
  "name": "legal-en-fr",
  "source_lang": "en",
  "target_lang": "fr",
  "origin": "tmx_import",
  "entry_count": 1,
  "embedding_model": "<model id>",
  "stub_embeddings": false,
  "total_units": 3,
  "imported": 1,
  "skipped_units": 1,
  "duplicates_dropped": 1
}

Synchronous or asynchronous

One number decides which status you get: the segments left after duplicate sources collapse — the same post-dedupe count the capacity check measures, reported as deduped_units on a job.

Post-dedupe segmentsStatusWhat you get
At or under 5000200The finished set plus the four import counters, exactly as above. Indexing and storage are done before the response is written, so the segments are live the moment you read it.
Above 5000202{"job": …, "poll_url": …}. Parsing, name validation and the capacity check have already passed; the rest of the import runs in the background. Poll poll_url until the job reports completed or failed.

Why the split rather than one long request: a large import takes long enough to prepare and store that holding a request open for it would be unreliable, so large imports run in the background and report through a job.

Every rejection stays synchronous, at every size. The 400/415 upload and parse errors, the reserved golden-approved-* name, and the 422 tm_max_segments_exceeded capacity check all run before a job exists — a refused import never becomes a job you have to poll to find out it was refused. The one status that moves is 502: it is reachable only on the synchronous path, and on the asynchronous path the identical message is recorded on the job's error instead.

A 202 body:

{
  "job": {
    "id": "0f5b1a2c8d3e4f6a9b7c0d1e2f3a4b5c",
    "status": "queued",
    "set_name": "legal-en-fr",
    "source_lang": "en",
    "target_lang": "fr",
    "total_units": 41200,
    "deduped_units": 40118,
    "created_at": "2026-08-16T09:14:02.481902+00:00",
    "updated_at": "2026-08-16T09:14:02.481902+00:00",
    "error": null,
    "result": null
  },
  "poll_url": "/api/tm/imports/0f5b1a2c8d3e4f6a9b7c0d1e2f3a4b5c"
}

poll_url is a server-relative path — prefix it with the base URL. It is always /api/tm/imports/{job.id}, so you can build it yourself if you prefer.

The generated OpenAPI document declares only a 200 for this operation and types both bodies as a free-form object, so a generated client will not know the 202 exists. Branch on the status code, not on the schema.

The set being replaced stays live for the whole import. Nothing is written until the whole import is ready, and the swap is wholesale, so lookups keep serving the previous contents until the job completes — and keep serving them unchanged if it fails.

One import per set name at a time. A second POST /api/tm naming a set whose background import is still in flight is refused with 409 and a string detail whose first token is stable:

{"detail": "tm_import_in_progress: an import into translation-memory set 'legal-en-fr' is already running (job 0f5b1a2c8d3e4f6a9b7c0d1e2f3a4b5c); an import replaces the set wholesale, so wait for it to finish — poll /api/tm/imports/0f5b1a2c8d3e4f6a9b7c0d1e2f3a4b5c — and re-upload afterwards if you still need to"}

Branch on the tm_import_in_progress: prefix; treat the rest as human-readable. The claim is keyed on (tenant, set name) and is released the instant the job reaches completed or failed, so importing into a different set name is never blocked and a retry after the poll finishes succeeds. Synchronous imports are never refused this way, and the claim is held by the app instance running the import — on a multi-instance deployment, route retries of the same set name consistently rather than relying on the 409 as a global lock.

Errors.

StatusdetailCause
400tmx_file upload is requiredNo file part named tmx_file.
400tmx_file is emptyZero-byte upload.
400name is required / name must be at most 128 charactersName missing or too long.
400name is reserved: the 'golden-approved-*' namespace holds approved pairs and cannot be overwritten by import; choose a different nameImport targeted the reserved namespace.
400source_lang and target_lang are requiredOne of the two language fields missing.
400TMX parse message, e.g. TMX contains no translation units for en -> fr (12 unit(s) inspected), source_lang and target_lang must differ for TMX import, TMX is not well-formed XML: …, TMX rejected: forbidden XML construct (…)Unusable document.
401missing or invalid tenant credentialsNo usable tenant credential.
409tm_import_in_progress: an import into translation-memory set '<name>' is already running (job <id>) …A background import into the same set name has not finished yet (see Synchronous or asynchronous above). Nothing is imported; poll the named job and re-upload afterwards.
413request body exceeds MAX_UPLOAD_BYTESUpload too large.
415tmx_file must be a .tmx file / tmx_file must be XML (TMX 1.4b)Suffix or content type rejected.
422tm_max_segments_exceeded: translation-memory set '<name>' would hold N segment(s) …The import would take the tenant past the 50,000-segment cap for the language pair (see Capacity above). Nothing is imported.
502A message naming the service that was unavailableA service TrueIdiom needs in order to index the import could not be reached; nothing is stored. Synchronous imports only — an asynchronous import carries the same message on job.error instead.
502A message reporting sustained throttlingThat service stayed saturated for longer than the server waits it out. Nothing is stored — retry later or split the file. Synchronous imports only, as above.

Every status in that table is reachable at both sizes except the two 502s, which the asynchronous path reports on the job instead.

GET /api/tm

List every TM set owned by the caller's tenant, ordered by name, then id. Golden sets appear here alongside imported ones, distinguished by origin.

Auth: tenant credentials. 401 without them.
Parameters: none.

curl "https://trueidiom.com/api/tm?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"
[
  {
    "id": "4d3fa09d9d9c4be08f58e0c8584ca4e3",
    "name": "golden-approved-en-fr",
    "source_lang": "en",
    "target_lang": "fr",
    "origin": "golden",
    "entry_count": 128,
    "embedding_model": "<model id>",
    "stub_embeddings": false
  },
  {
    "id": "dc1ac7f1606c46519a8b0f138da39dde",
    "name": "legal-en-fr",
    "source_lang": "en",
    "target_lang": "fr",
    "origin": "tmx_import",
    "entry_count": 4213,
    "embedding_model": "<model id>",
    "stub_embeddings": false
  }
]

Errors: 401.

Asynchronous import jobs

An import job exists only for an upload that crossed the 5000-segment threshold and was answered with 202. Synchronous imports never produce one.

The import job object

FieldTypeDescription
idstring32-char hex job identifier. It is a job id, not a TM set id — the set's own id appears in result once the import completes.
statusstringqueued, running, completed, or failed.
set_namestringThe name the import targets.
source_langstringSource language as parsed from the file.
target_langstringTarget language as parsed from the file.
total_unitsinteger<tu> elements inspected in the upload.
deduped_unitsintegerSegments the import will store, after duplicate sources collapse. This is the count that put the upload on the asynchronous path and the count the capacity cap was checked against.
created_atstringWhen the job was accepted, UTC.
updated_atstringWhen the job last changed status, UTC.
errorstring | nullnull unless status is failed; then an actionable message (see When an import fails).
resultobject | nullnull until status is completed; then exactly the body a synchronous 200 would have returned — the TM set plus the four import counters.

Every key is present in every status; the two that can be absent are serialized as null, never omitted.

created_at and updated_at are UTC ISO 8601 with microsecond precision, serialized with a +00:00 offset rather than the Z suffix used on job and document timestamps elsewhere in the API (2026-08-16T09:14:02.481902+00:00). Parse them with a real RFC 3339 parser rather than a string suffix check.

Status lifecycle. queuedrunningcompleted or failed. Both terminal states are final: nothing is retried server-side, and a failed job is never resumed. Job records are persisted, so a poll outlives the request that started the import.

GET /api/tm/imports/{job_id}

Poll one asynchronous TMX import.

Auth: tenant credentials. 401 without them. Identical to every other route on this page.

NameTypeRequiredDescription
job_idstring (path)YesThe job.id from the 202, or from GET /api/tm/imports.

The response is the same envelope the 202 used — {"job": …, "poll_url": …} — so one parser handles both.

curl "https://trueidiom.com/api/tm/imports/0f5b1a2c8d3e4f6a9b7c0d1e2f3a4b5c?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"

A completed job carries the whole synchronous result under result:

{
  "job": {
    "id": "0f5b1a2c8d3e4f6a9b7c0d1e2f3a4b5c",
    "status": "completed",
    "set_name": "legal-en-fr",
    "source_lang": "en",
    "target_lang": "fr",
    "total_units": 41200,
    "deduped_units": 40118,
    "created_at": "2026-08-16T09:14:02.481902+00:00",
    "updated_at": "2026-08-16T09:16:35.902114+00:00",
    "error": null,
    "result": {
      "id": "dc1ac7f1606c46519a8b0f138da39dde",
      "name": "legal-en-fr",
      "source_lang": "en",
      "target_lang": "fr",
      "origin": "tmx_import",
      "entry_count": 40118,
      "embedding_model": "<model id>",
      "stub_embeddings": false,
      "total_units": 41200,
      "imported": 40118,
      "skipped_units": 214,
      "duplicates_dropped": 1082
    }
  },
  "poll_url": "/api/tm/imports/0f5b1a2c8d3e4f6a9b7c0d1e2f3a4b5c"
}

A failed job carries a message instead, and result stays null:

{
  "job": {
    "id": "0f5b1a2c8d3e4f6a9b7c0d1e2f3a4b5c",
    "status": "failed",
    "set_name": "legal-en-fr",
    "source_lang": "en",
    "target_lang": "fr",
    "total_units": 41200,
    "deduped_units": 40118,
    "created_at": "2026-08-16T09:14:02.481902+00:00",
    "updated_at": "2026-08-16T09:15:11.337208+00:00",
    "error": "the translation-memory import failed: a service needed to index the segments stayed rate limited — … Nothing was imported: the set's previous contents are unchanged, so the upload can be retried as-is.",
    "result": null
  },
  "poll_url": "/api/tm/imports/0f5b1a2c8d3e4f6a9b7c0d1e2f3a4b5c"
}

Poll until status is completed or failed. A 2-second interval is reasonable; back off to 5–10 seconds for an import in the tens of thousands of segments. As everywhere else in this API there are no webhooks — polling is the only completion signal.

Errors: 401; 404 {"detail": "translation-memory import job not found"} for an unknown id, an id from another tenant, or a well-formed id that never existed. Another tenant's job is a 404, never a 403 — existence is not confirmed to a caller who may not see it.

When an import fails

Every failure leaves the target set exactly as it was. The replacement is atomic: nothing is written until the whole import is ready, so a failed job means nothing was imported and the set's previous segments are still serving lookups. The upload can be retried as-is.

Causejob.errorWhat to do
A service the import depends on failed or stayed rate limitedThe same message the synchronous 502 would have carried, with Nothing was imported: the set's previous contents are unchanged, so the upload can be retried as-is. appendedWait it out, then re-upload the same file.
The server restarted or was redeployed mid-importthe import was interrupted by a server restart — nothing was imported and the set's previous contents are unchanged; re-upload the TMX file to retryRe-upload. Jobs are never resumed: the uploaded TMX bytes are deliberately not retained, so there is nothing to restart from.
Anything elsethe translation-memory import failed: <ExceptionType>: <message>. Nothing was imported: …Retry once; quote job.id and the message to support if it repeats.

A job left queued or running by a process that died is swept to failed with the restart message when the service comes back up, so a poll never hangs forever on a job whose worker is gone.

GET /api/tm/imports

List this tenant's recent asynchronous TMX imports, newest first.

Auth: tenant credentials. 401 without them.
Parameters: none.

The response is a bare JSON array of import job objects — the same keys as the job field above, with no poll_url and no wrapper. Build the poll URL as /api/tm/imports/{id}.

curl "https://trueidiom.com/api/tm/imports?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"
[
  {
    "id": "0f5b1a2c8d3e4f6a9b7c0d1e2f3a4b5c",
    "status": "running",
    "set_name": "legal-en-fr",
    "source_lang": "en",
    "target_lang": "fr",
    "total_units": 41200,
    "deduped_units": 40118,
    "created_at": "2026-08-16T09:14:02.481902+00:00",
    "updated_at": "2026-08-16T09:14:02.903551+00:00",
    "error": null,
    "result": null
  },
  {
    "id": "7c2e91d0b4a34f5e8d16c3fa0b52e7d9",
    "status": "completed",
    "set_name": "marketing-en-fr",
    "source_lang": "en",
    "target_lang": "fr",
    "total_units": 9800,
    "deduped_units": 9412,
    "created_at": "2026-08-15T17:02:44.118207+00:00",
    "updated_at": "2026-08-15T17:03:29.660914+00:00",
    "error": null,
    "result": {
      "id": "b7a4c0e91f6d4a2f8c35e0d17b924ac6",
      "name": "marketing-en-fr",
      "source_lang": "en",
      "target_lang": "fr",
      "origin": "tmx_import",
      "entry_count": 9412,
      "embedding_model": "<model id>",
      "stub_embeddings": false,
      "total_units": 9800,
      "imported": 9412,
      "skipped_units": 51,
      "duplicates_dropped": 388
    }
  }
]

This is a recent-activity view, not an audit log: it returns at most the 50 newest jobs, ordered by created_at descending, and there are no filters, no paging, and no retention guarantee beyond that window. Track the ids you care about from their 202 rather than rediscovering them here. An empty array means the tenant has never run an import over the threshold.

Errors: 401.

DELETE /api/tm/{set_id}

Delete one TM set and its segments.

Auth: tenant credentials. 401 without them.

NameTypeRequiredDescription
set_idstring (path)YesThe id from create/list.
curl -X DELETE "https://trueidiom.com/api/tm/dc1ac7f1606c46519a8b0f138da39dde?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"

Returns 204 No Content with an empty body. (The generated spec lists a 200; the implementation returns 204.)

Errors: 401; 404 {"detail": "translation memory set not found"} for an unknown id or another tenant's set.

This endpoint accepts a golden-approved-* set id. Import is blocked from that namespace, deletion is not — deleting a golden set discards the tenant's accumulated approved pairs for that language pair. Export it first (see below) if you want a copy.

GET /api/tm/{set_id}/export

Download one TM set as a TMX 1.4b document.

Auth: tenant credentials. 401 without them.

NameTypeRequiredDescription
set_idstring (path)YesThe id from create/list. Works for tmx_import and golden sets.

The set's own source_lang/target_lang determine the exported pair; there are no query parameters.

curl -L "https://trueidiom.com/api/tm/dc1ac7f1606c46519a8b0f138da39dde/export?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  -o legal-en-fr.tmx

Response 200 with Content-Type: application/xml and Content-Disposition: attachment; filename="<set name>.tmx":

<?xml version='1.0' encoding='utf-8'?>
<tmx version="1.4"><header creationtool="TrueIdiom" creationtoolversion="1.0" datatype="plaintext" segtype="sentence" adminlang="en" srclang="en" o-tmf="TrueIdiomTM" /><body><tu tuid="1" changedate="20250104T101500Z"><tuv xml:lang="en"><seg>Wire transfer fees apply.</seg></tuv><tuv xml:lang="fr"><seg>Des frais de virement s'appliquent.</seg></tuv></tu></body></tmx>

tuid and changedate survive an import/export round trip. Exports read up to the 50,000-segment cap, which no set can exceed anyway now that the same cap is enforced across the whole language pair — an export is complete.

Errors: 401; 404 {"detail": "translation memory set not found"} for an unknown id or another tenant's set; 404 with No translation-memory segments to export for en -> fr in set <id> when the set exists but holds no segments for its pair.

GET /api/tm/export/golden

Download the approved golden pairs for one language pair as TMX 1.4b — a portability export for CAT tools and any external tooling the tenant runs on its own corpus.

Nothing internal consumes this export. The same approved pairs are already leveraged in-product — as exact matches, and as context that informs the next translation — so exporting is a customer-facing convenience, not a step in any adaptation loop.

Auth: tenant credentials. 401 without them.

NameTypeRequiredDescription
source_langstring (query)YesSource language. Normalised to its primary subtag; en-US and en resolve to the same set. The generated spec marks this optional with default "", but a blank value returns 400.
target_langstring (query)YesTarget language, same normalisation and same 400 on blank.

The set resolved is golden-approved-<source primary subtag>-<target primary subtag>. So target_lang=zh-Hans and target_lang=zh-Hant both export golden-approved-en-zh — the one set both scripts' approvals feed — and pt-BR and pt-PT both export golden-approved-en-pt. The export carries whatever was approved; it does not label which variant each pair came from.

curl -L "https://trueidiom.com/api/tm/export/golden?source_lang=en&target_lang=fr&api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  -o golden-approved-en-fr.tmx

Response 200 with Content-Type: application/xml and Content-Disposition: attachment; filename="golden-approved-en-fr.tmx".

Errors:

StatusdetailCause
400source_lang and target_lang query params are requiredEither parameter missing or blank.
401missing or invalid tenant credentialsNo usable tenant credential.
404no approved golden pairs for this language pairThe tenant has never had an approval for that pair.
404No translation-memory segments to export for en -> fr in set <set_id>The golden set exists but is empty. The trailing in set <set_id> is always present — this route always exports a named set.

Translation memory at translation time

TM lookup runs in two tiers, both tenant-scoped and both spanning all of the tenant's sets for the language pair — imported and golden alike. There is no request parameter that selects or excludes a set.

Exact tier — a 100% match returns without a translation

The exact tier matches on the NFC-normalised, whitespace-collapsed source text. No translation engine is called at all. Design around this: it is the cheapest and fastest path through the product.

A 100% match that passes the deterministic terminology compliance check is published verbatim:

curl -X POST "https://trueidiom.com/api/translations?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"source_text":"Wire  transfer fees apply.","source_lang":"en","target_lang":"fr"}'
{
  "id": "6f1c9e0a5d4b4f6e9a2c7d3b8e5f1a24",
  "status": "published",
  "final_translation": "Des frais de virement s'appliquent.",
  "quality_assessment": null,
  "refinement": null,
  "verification": null,
  "request": {
    "metadata": { "provenance": { "fast_path": "tm_exact" } }
  },
  "agent_run": {
    "quality_gate": {
      "decision": "pass",
      "reasons": ["tm_exact fast path: deterministic verbatim match."],
      "score": 100.0,
      "threshold": 85.0,
      "metadata": { "fast_path": "tm_exact" }
    }
  }
}

Note the source text in that request has a double space: normalisation makes exact matching whitespace- and NFC-insensitive, but not case-insensitive. Observable characteristics of the fast path:

  • quality_assessment, refinement, and verification are all null, whichever response shape you receive. For an operator those three are the only places a TokenUsage object appears on a job, so a fast-path job carries no token usage anywhere; the tenant shape carries none on any job, fast path or not. (TranslationJob has no top-level usage field; do not look for one.) Metering is unaffected: the metered quantity is source characters, weighted per language pair — on a pair where either side is zh-Hans, zh-Hant, ja, ko, th or hi, each source character counts as 3.5 by default against allowance, trial and recorded usage, and every other pair counts 1:1 (Tenants & billing carries the full rule). A fast-path job is metered on that same number and draws down the free trial's character grant exactly like a job that ran the full pipeline.
  • status is published immediately; there is no review task and no golden-store persist, and the pair is not fed back into learning (it came from the TM).
  • Latency is a memory lookup rather than a model round trip.

Three cases fall through to the full pipeline instead:

CaseBehaviour
The match violates a matched glossary termPublished as a normal job; decision outcome tm_exact_noncompliant.
The request is sensitive/regulated (see the domain list above)Routed to human review; decision outcome tm_exact_held.
Several segments share the hash with different targetsOne is chosen deterministically: golden origin beats tmx_import, then newest changedate, then the owning set's updated_at, then store order (latest wins).

Fuzzy tier — close matches

When there is no exact match, the tenant's own memory still shapes the result. Close ("fuzzy") matches for the source text are given to the translation engine as leverage, alongside a sample of the tenant's approved pairs for the language pair, so a new translation follows the wording the tenant has already accepted. This runs on every job that reaches the pipeline; there is nothing to switch on and no request parameter to set.

A fresh import takes effect for new jobs immediately in most cases, and within a few minutes at most.


TM and the golden store

They are two different stores with two different jobs, written together on approval.

Golden storeGolden TM set
What it isThe system of record for approved work: one JSON record per approved job (plus document-approval audit records)A searchable memory of approved bilingual pairs
Where it livesTrueIdiom-managed storage, or the tenant's own storage under BYOSThe same store as imported TM sets
ContentsFull job payload — request, candidates, quality assessments, reviewer identitysource_text / target_text segments only
NamingPer-job blobs/recordsOne set per language pair: golden-approved-<src>-<tgt>
Read pathGET /api/translations/{job_id}/golden (txt/docx/pdf)GET /api/tm/export/golden, plus exact/fuzzy lookup on every subsequent job

Approving a translation (POST /api/translations/{job_id}/review with action: "approve") persists the job to the golden store and appends the approved pair to the golden TM set. Approving a document (POST /api/documents/{document_id}/approve) feeds its eligible reviewed segments in a batch and reports the count as approval_result.fed_pairs. The golden persist fails closed — a storage error parks the job as persistence_failed — while the TM append is best-effort and never fails an approval, whether the write errors or the language pair is simply full.

Consequences to design around:

  • Approvals compound. Each one makes the next identical (or near-identical) request cheaper: an exact hit later returns without a translation being generated at all.
  • The golden set is created lazily on the first approval for a pair. Until then GET /api/tm/export/golden returns 404.
  • Appends are additive and concurrency-safe. A corrected target for a source already in the set is appended as a new segment and supersedes the old one in lookup (newest golden wins); re-approving an identical pair is skipped.
  • The golden-approved-* namespace is closed to imports precisely because an import replaces a set wholesale. To seed memory from an external archive, import it under your own name — both sets are searched at translation time.
  • Untranslated pairs are not memorized. Two shapes are dropped at write time: a target of the form [<lang>] <source> (what the pipeline emits when the deployment has no translation-engine credentials), and a target that equals the source after normalization. The comparison uses the same NFC + whitespace-collapsed form as exact match. The approval still succeeds — only the memory write is skipped, so fed_pairs on a document approval can be lower than the number of segments approved, and a translation approval that is dropped this way returns 200 exactly as normal. The reason this guard exists: an exact hit is published verbatim without a translation, so one memorized untranslated pair would serve the source text back as a finished translation for every matching request thereafter.
  • The feed stops at the language pair's capacity, and says so. Once the pair holds 50,000 segments across all of the tenant's sets, an approved pair carrying a new source is skipped — not indexed, not stored. The approval still succeeds: a full memory is a counted skip, never an error. Corrections are exempt — a source the golden set already holds still receives its new target, so reviewer corrections keep improving quality after the memory is full. Document jobs report the running count as feed_manifest.golden_skipped_at_cap (see Documents); a non-zero value means those pairs are not memorized and later identical requests will not hit them. Free budget for the pair — DELETE /api/tm/{set_id} on an imported set, or re-import it smaller under the same name — if you want approvals memorized again.
  • The guard applies only to approved pairs. POST /api/tm imports are never filtered: a target that equals its source is a legitimate do-not-translate unit in a customer's own TMX, and your memory is yours.
  • If a term must survive verbatim (a brand, a SKU, a product name), put it in a terminology set rather than relying on approvals to teach it. Terminology is enforced deterministically and is not subject to this guard.

Limits

Every limit on these endpoints in one place. All of them are per tenant.

LimitValueWhat happens at it
Set name length128 characters400 on create or import.
Terminology entries per language pair2000Counted across all of the tenant's sets, not per set. An upload over the remaining allowance fails with 422 terminology_max_terms_exceeded: … and nothing is stored.
TM segments per language pair50000Counted across all of the tenant's sets including the golden one. An import over the remaining allowance fails with 422 tm_max_segments_exceeded: …; at the cap, approved pairs carrying a new source are skipped (counted in feed_manifest.golden_skipped_at_cap) while corrections still apply. Also the export read cap.
Post-dedupe segments that keep an import synchronous5000At or under it, POST /api/tm answers 200 with the finished set; above it, 202 with a job to poll. Rejections stay synchronous at every size, so the threshold never changes what an upload is allowed to do.
Request body30 MB413 {"detail": "request body exceeds MAX_UPLOAD_BYTES"} on any upload to these endpoints.
Import jobs listed by GET /api/tm/imports50 newestNo filters, no paging, no retention guarantee beyond that window.

Two behaviours worth knowing alongside them: these endpoints stay available when translation submission is disabled and returns 503, and stub_embeddings: true in a response means the deployment is running without translation-engine credentials, so close-match ranking is approximate. Exact matching, glossary matching, and TMX round-tripping are unaffected either way.


Error shapes

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

HTTPException responses (the majority above) return:

{ "detail": "translation memory set not found" }

Pipeline validation failures — CSV parsing today — return the richer envelope:

{ "detail": "Glossary CSV is empty", "error_code": "validation_error", "request_id": "d41e…" }

request_id echoes the X-Request-ID request header and is null when you did not send one; the response always carries a generated X-Request-ID header regardless.

Parameter-validation failures return the standard array form:

{ "detail": [ { "loc": ["path", "set_id"], "msg": "…", "type": "…" } ] }