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

Method Path Purpose Auth
POST /api/terminology Create or replace a terminology set from a CSV or TMX upload Tenant credentials
GET /api/terminology List the tenant's terminology sets Tenant credentials
DELETE /api/terminology/{set_id} Delete a terminology set Tenant credentials
POST /api/tm Create or replace a TM set from a TMX 1.4b upload Tenant credentials
GET /api/tm List the tenant's TM sets Tenant credentials
GET /api/tm/imports List this tenant's recent asynchronous TMX imports Tenant credentials
GET /api/tm/imports/{job_id} Poll one asynchronous TMX import Tenant credentials
DELETE /api/tm/{set_id} Delete a TM set Tenant credentials
GET /api/tm/{set_id}/export Download one TM set as TMX 1.4b Tenant credentials
GET /api/tm/export/golden Download the approved golden pairs for one language pair as TMX 1.4b Tenant 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:

Credential How to send it
Tenant API key X-API-Key: <tenant api key> — or Authorization: Bearer <tenant api key>
Access token Authorization: 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:

Endpoint Field Auth needed to call it
POST /api/auth/signup/email tenant_api_key None. This is the self-service path and the one to use if you are starting from nothing.
POST /api/tenants api_key Platform operator only — the operator key or a designated operator's session. A tenant admin's session gets 403 tenant provisioning is operator-only, and a tenant API key alone gets 401 admin credentials required.

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.


Language codes

Terminology and translation memory read language codes more loosely than the translation endpoints do, because the files you load come from other tools. Every code — an upload's source_lang/target_lang field, a CSV language cell, a TMX <tuv> tag — is read as the supported language it stands for:

Code Read as
A supported code, in any letter case That code: pt-br → pt-BR, fr-ca → fr-CA, EN → en
zh-TW, zh-HK, zh-MO, or a zh-Hant-… tag zh-Hant
zh-CN, zh-SG, zh-MY, or a zh-Hans-… tag zh-Hans
no, no-NO, nob nb
Any other regional tag whose base language is a supported code The base code: en-US and en-GB → en, fr-FR and fr-BE → fr, es-MX → es
Bare zh or pt, or a region of either that names no variant (pt-AO) No supported language
Anything else (eng, nn, a language not in the supported set) No supported language

A term, set or segment applies to a request only when each side of its pair is read as the same supported language as the request's. Regional spellings of one language therefore match each other — a term tagged en-US applies to an en request — but the variants of Chinese, Portuguese and French are kept apart: pt-BR and pt-PT are two languages here, as are zh-Hans and zh-Hant, and fr and fr-CA, so a European-Portuguese term or memory never applies to a Brazilian-Portuguese request, nor Simplified Chinese to Traditional, nor a fr or fr-FR one to a fr-CA request or a fr-CA one to a fr request. A code read as no supported language could never apply to anything, so it is refused at upload — bare zh and pt included, since they do not say which variant they mean.

fr-CA before it was offered. Until fr-CA became a supported language, a fr-CA code was read as fr. Two things follow:

  • A glossary entry tagged fr-CA was enforced on fr requests. It now applies to fr-CA requests only, and a set uploaded with target_lang=fr-CA now counts against the fr-CA pair's budget. If it was meant for French as written in France, re-upload the set with fr in its language fields or columns, under the same name to replace it.
  • A translation memory set imported with fr-CA was stored as fr — the variant was not kept — and its target_lang (or source_lang) reads fr in GET /api/tm. It keeps serving fr requests and never serves fr-CA ones. To use it for Canadian French, export it with GET /api/tm/{set_id}/export, change its fr <tuv> tags to fr-CA, and import the file with POST /api/tm declaring fr-CA.

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

Field Type Description
id string 32-char hex identifier. Stable across re-uploads of the same set name.
name string Set name as supplied.
source_lang string | null Language code as supplied at upload (not normalised). null when a CSV upload omitted it.
target_lang string | null As above.
entry_count integer Term pairs actually stored (after blank and unusable rows are skipped).
embedding_model string Identifier of the model that indexed the terms.
stub_embeddings boolean true 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.

Field Type Required Description
terminology_file file Yes .csv or .tmx upload. The legacy field name terminology_csv is accepted as a fallback when terminology_file is absent.
name string Yes Set name, 1–128 characters. Re-using a name replaces that set's terms and keeps its id.
source_lang string Conditional Required for .tmx uploads; optional for CSV. When supplied it must be a code that stands for a supported language (see Language codes) — en-US passes as en, zh-TW as zh-Hant, and no, no-NO and nob count as nb — or the upload is refused with 422; bare zh and pt are refused too. Language names are not accepted here.
target_lang string Conditional As source_lang.

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. Media-type parameters are ignored (text/csv; charset=utf-8 counts as text/csv), and an empty content type skips the check. curl labels a file part application/octet-stream unless told otherwise, which a CSV upload refuses — hence ;type=text/csv in the examples below.

CSV format. The delimiter is sniffed from the first 4,096 characters across ,, ;, tab, and |, defaulting to ,. Bytes must be UTF-8 (a BOM is tolerated) or UTF-16 with a byte-order mark. Anything else is refused with 400 terminology_file must be UTF-8 or UTF-16 (with BOM) text — accented text saved as Windows-1252 by Excel's plain "CSV" export, for example, and also UTF-16 without a byte-order mark, UTF-32, or any text containing a NUL character; re-save it as "CSV UTF-8". Blank lines are skipped wherever they appear, including above the header, and CR-only (classic Mac) line endings are read like any other. A term, language, case_sensitive or priority cell may not run over several lines: a quoted cell that does — which almost always means a quote opened there is never closed — is refused with 400 Glossary CSV line N: the <column> value runs over several lines, which usually means a quote opened there is never closed. Close or remove the quote, or put the term on one line., and nothing is stored.

The first non-blank row is used as a header only when it has both a source-term column and a target-term column, matched case-insensitively after normalising spaces and punctuation to _:

Canonical column Accepted header aliases Meaning
source_term source_term, source term, source, src_term, src term, term_source Term as it appears in the source text. Required.
target_term target_term, target term, target, tgt_term, tgt term, term_target Mandated translation. Required.
source_lang source_lang, source lang, source_language, source language, src_lang, src lang, from_lang, from lang Pins the entry to a source language. Empty = applies to any.
target_lang target_lang, target lang, target_language, target language, tgt_lang, tgt lang, to_lang, to lang Pins the entry to a target language. Empty = applies to any.
case_sensitive case_sensitive, case sensitive, casesensitive 1/true/yes/on = match case-sensitively. Anything else = case-insensitive. Default false.
priority priority Integer, 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 enfr-terms.csv, the sample file the examples below upload:

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.

A non-empty language cell — in a language column, or in columns 3–4 of the positional form — must be a code that stands for a supported language (see Language codes: en-US passes as en, zh-TW as zh-Hant, and no, no-NO and nob count as nb), or the English name of a supported language in any letter case: English, Chinese (Simplified), Portuguese (Brazil), French (Canada), or the bare Norwegian; French names fr. A name is stored as its code; a code is stored as written. A cell that names Chinese or Portuguese without its variant — Chinese, Portuguese, zh, pt, pt-AO — rejects the whole upload with 400 Glossary CSV line N: source_lang 'Chinese' does not say which variant it means, and a term applies only to the variant it is tagged with. Use 'zh-Hans' or 'zh-Hant', or the name 'Chinese (Simplified)' or 'Chinese (Traditional)'. Any other value (eng, Klingon) rejects the whole upload with 400 — Glossary CSV line N: unsupported source_lang 'eng'. …. Both messages count the file's lines from 1, and nothing is stored.

TMX format. Each <tu> carrying a <tuv> for both requested languages becomes one term pair, keeping the unit's own language codes; <tuv> tags are picked as for a TM import. A <tuv> tag that names only the base of a split language (pt, zh, pt-AO) is stored under the language the upload declared instead, since the tag alone would apply to no request. 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.

Limit Value Behaviour
name length 128 characters 400 above the limit.
Entries stored per tenant + language pair 2000 An 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 body 30 MB 413 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 counted against this upload's pair; the upload fits only if incoming ≤ cap − existing. Pairs are matched on the supported language each code stands for, so en-US and en draw on the same budget, as do fr-FR and fr, while pt-BR and pt-PT each have their own, as do zh-Hans and zh-Hant, and fr and fr-CA. A set is counted against the pair its source_lang/target_lang form fields declared — per-row language columns do not move its count — so a set uploaded without them 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 the supported language each code stands for (see Language codes), so an entry tagged en-US applies to en requests — but, as with translation memory, the two Chinese scripts are kept apart, as are the two Portuguese variants and the two French ones: an entry tagged zh-Hans (or named Chinese (Simplified)) is enforced on zh-Hans requests only, never on zh-Hant, a pt-PT entry never on pt-BR, and a fr entry never on fr-CA, nor a fr-CA entry on fr. Entries tagged fr-CA before fr-CA was offered changed scope; see Language codes. Entries stored under a bare zh or pt before the variants were kept apart — from a Chinese or Portuguese language cell, for example — apply to no request now; re-upload the set with the variant in its language fields or columns, under the same name to replace it. 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. Because per-row columns can bring more than 2000 terms into one pair even when every upload fitted the cap, translation-time enforcement reads at most 2000 terms per pair; any beyond that are not applied.

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"
import os
import requests

with open("enfr-terms.csv", "rb") as handle:
    resp = requests.post(
        "https://trueidiom.com/api/terminology?api-version=2026-09-01",
        headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
        files={"terminology_file": ("enfr-terms.csv", handle, "text/csv")},
        data={"name": "finance-en-fr", "source_lang": "en", "target_lang": "fr"},
        timeout=120,
    )
resp.raise_for_status()
term_set = resp.json()
print(term_set["id"], term_set["entry_count"])
// Node 20+ for fs.openAsBlob
import { openAsBlob } from "node:fs";

const form = new FormData();
form.append(
  "terminology_file",
  await openAsBlob("enfr-terms.csv", { type: "text/csv" }),
  "enfr-terms.csv",
);
form.append("name", "finance-en-fr");
form.append("source_lang", "en");
form.append("target_lang", "fr");

const res = await fetch("https://trueidiom.com/api/terminology?api-version=2026-09-01", {
  method: "POST",
  headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY },
  body: form,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const termSet = await res.json();
console.log(termSet.id, termSet.entry_count);
{
  "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.

Status detail Cause
400 terminology_file upload is required No file part under terminology_file or terminology_csv.
400 terminology_file is empty Zero-byte upload.
400 name is required Missing or whitespace-only name.
400 name must be at most 128 characters Name too long.
400 source_lang and target_lang are required for TMX terminology uploads TMX upload without both language fields.
400 terminology_file must be UTF-8 or UTF-16 (with BOM) text The CSV is in another encoding — UTF-16 without a byte-order mark and UTF-32 included — or contains a NUL character (see CSV format above).
400 Glossary CSV line N: the <column> value runs over several lines, which usually means a quote opened there is never closed. Close or remove the quote, or put the term on one line. A term, language, case_sensitive or priority cell spans lines (see CSV format above). Carries error_code: validation_error. Nothing is stored.
400 Glossary CSV is empty / Glossary CSV did not contain valid term rows CSV parsed to zero usable rows. Carries error_code: validation_error.
400 Glossary CSV is malformed: …, e.g. Glossary CSV is malformed: field larger than field limit (131072) A cell longer than 131,072 characters — usually an unclosed quote that runs on through the rest of the file. Carries error_code: validation_error.
400 Glossary CSV line N: unsupported source_lang 'X'. … (or target_lang) A per-row language cell that is neither a supported code nor a supported language name. Carries error_code: validation_error. Nothing is stored.
400 Glossary CSV line N: source_lang 'X' does not say which variant it means, and a term applies only to the variant it is tagged with. Use … or …, or the name … or …. (or target_lang) A per-row language cell names Chinese or Portuguese without its variant (Chinese, zh, pt, pt-AO). Carries error_code: validation_error. Nothing is stored.
400 TMX parse message, e.g. Root element is <notmx>, expected <tmx> Malformed TMX, forbidden XML construct, no unit for the language pair, or source_lang and target_lang that stand for the same language (en and en-GB).
401 missing or invalid tenant credentials No usable tenant credential.
413 request body exceeds MAX_UPLOAD_BYTES Upload larger than the configured ceiling.
415 terminology_file must be a .csv or .tmx file Unsupported filename suffix.
415 terminology_file must be text/csv / ... must be XML (TMX 1.4b) Declared content type does not match the detected kind.
422 unsupported language(s): 'X'. Expected language codes such as 'en' or 'pt-BR' (regional variants such as en-US also match; a split language needs its variant — pt-BR or pt-PT, zh-Hans or zh-Hant). Supported: … The source_lang or target_lang form field does not stand for a supported language — bare zh and pt included. Nothing is stored.
422 terminology_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.
502 The embeddings service is temporarily unavailable. Retry shortly; if this persists, contact support. A service TrueIdiom needs in order to index the upload could not be reached. Nothing is stored; retry.
502 The embeddings service stayed rate limited for longer than the server waits it out. Retry later, or split the upload into smaller files. That 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"
import os
import requests

resp = requests.get(
    "https://trueidiom.com/api/terminology?api-version=2026-09-01",
    headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
    timeout=60,
)
resp.raise_for_status()
for term_set in resp.json():
    print(term_set["id"], term_set["name"], term_set["entry_count"])
// Node 18+
const res = await fetch("https://trueidiom.com/api/terminology?api-version=2026-09-01", {
  headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
for (const termSet of await res.json()) {
  console.log(termSet.id, termSet.name, termSet.entry_count);
}
[
  {
    "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.

Name Type Required Description
set_id string (path) Yes The 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"
import os
import requests

resp = requests.delete(
    "https://trueidiom.com/api/terminology/554147ff548e41b18a3d6d3b293989df?api-version=2026-09-01",
    headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
    timeout=60,
)
resp.raise_for_status()
print(resp.status_code)  # 204, empty body
// Node 18+
const res = await fetch(
  "https://trueidiom.com/api/terminology/554147ff548e41b18a3d6d3b293989df?api-version=2026-09-01",
  { method: "DELETE", headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(res.status); // 204, empty body

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 without a translation being generated. The comparison ignores letter case for a case-insensitive entry but never accents, tone marks or dakuten, apart from the spelling variants listed under term protection, and canonically equivalent Unicode spellings count as equal. A case-insensitive entry's target follows the segment's casing — an ALL-CAPS segment gets an ALL-CAPS target, and a capitalised segment (or one in a script without letter case) gets a capitalised target when the target is stored in lowercase; a case-sensitive entry's target is published exactly as stored. 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 and भाषा never matches inside भाषाविद्. A term is never cut out of the middle of a word, including before an attached vowel sign, accent or zero-width joiner, or after a Thai or Lao vowel written ahead of its consonant. Case sensitivity and priority come from the entry: the higher priority wins, then the longer term. In addition:
    • Accents count. año never matches ano, and Bán never matches Bàn. The only exceptions are spellings the source language itself treats as one word: Russian ё written as е; the optional vowel marks and tatweel of Arabic, Persian and Urdu; and — only in an all-caps span — Greek capitals without the tonos, French capitals without accents (ETAT for état) and German SS for ß.
    • Multi-word terms match across any run of spaces or tabs (no-break spaces included) and at most one line break, so a term wrapped onto the next line still matches; a blank line between the words never does.
    • Addresses and markup are left alone. A term is not matched inside a URL, an e-mail address or a dotted name such as ASP.NET or report.pdf (an entry that is the whole address still applies), nor, for text_type: "Html", inside tags or character entities — except quoted title, alt, placeholder and aria-label values, which are matched like text.
    • Casing carries over. For a case-insensitive entry, a term written in capitals inside an all-caps heading or label comes back in capitals, and a target stored in lowercase is capitalised where the source term was capitalised (or written in a script without letter case) and the term begins a sentence, a line or a list item in the translation — or, for Html, a block element such as <p> or <li>. Case-sensitive entries are restored exactly as stored.
  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. Counting uses the target language's spelling variants and, like matching, skips occurrences inside URLs, e-mail addresses, dotted names and (for Html) markup. For Marathi and Nepali targets, whose nouns take attached case suffixes, a target term followed by a suffix still counts (कंपनी in कंपनीची); every other target language — Turkish, Finnish and Hungarian included — uses the same word-boundary rule as matching, so an inflected form of the term does not count. 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 or tm_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" (or "tm_exact" for a translation-memory hit) in the per-segment quality report. The sensitive-request hold above belongs to text jobs: document segments take both fast paths whatever the document's domain.


Translation memory sets

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

origin Source Write pattern
tmx_import POST /api/tm Wholesale replacement keyed by (tenant_id, name).
golden A translation job published by a reviewer's approval or autonomously by the quality gate (never by a fast path), or a reviewed or approved document Append-only into the reserved per-pair set golden-approved-<src>-<tgt>.

Language codes on TM sets are stored and matched as the supported language they stand for (see Language codes): an import with source_lang=en-GB is stored as en and serves en translations, one with target_lang=zh-TW is stored as zh-Hant, and one with target_lang=no is stored as nb. Nynorsk (nn) is not supported.

Each variant of Chinese, Portuguese and French has its own memory. en→pt-BR and en→pt-PT are two language pairs, as are en→zh-Hans and en→zh-Hant, and en→fr and en→fr-CA. Each has its own golden set — golden-approved-en-pt-BR, golden-approved-en-zh-Hant, golden-approved-en-fr-CA — its own 50,000-segment budget and its own matches: a European-Portuguese segment is never an exact match, a close match or an example for a Brazilian-Portuguese request, nor Simplified Chinese for Traditional, nor French for Canadian French or the other way round. Golden sets for every other pair keep the plain form, such as golden-approved-en-fr.

Sets created before the variants were kept apart. Earlier, the two variants of each language shared one memory stored under the bare code: imported sets and segments carried pt or zh, and approvals in either variant fed one golden set per pair, golden-approved-en-pt or golden-approved-en-zh. Those sets now serve no translation and count toward no pair's budget. They still appear in GET /api/tm, export with GET /api/tm/{set_id}/export, and delete with DELETE /api/tm/{set_id}; deleting a translation or document still removes the pairs it fed from the old golden set. To put one back to work:

  1. Export it with GET /api/tm/{set_id}/export. Its <tuv> tags read pt or zh. An old golden set can mix both variants, and the export does not say which variant each pair came from — split the file by variant before going on.
  2. Import each file with POST /api/tm, declaring the variant it holds (target_lang=pt-BR, say). A bare pt or zh tag in the file is read as the variant you declare. The golden-approved- prefix is reserved, so an old golden set comes back under a name of your own, as an ordinary imported set; new approvals go to the variant's own golden set.
  3. Delete the old set with DELETE /api/tm/{set_id}.

The TM set object

Field Type Description
id string 32-char hex identifier, stable across re-imports of the same name.
name string Set name.
source_lang string The supported code the set's source language stands for, e.g. en (also for an en-GB import). A set created before the variants were kept apart can hold a bare pt or zh.
target_lang string Likewise, e.g. fr or pt-BR.
origin string tmx_import or golden.
entry_count integer Segments stored.
embedding_model string Identifier of the model that indexed the segment sources.
stub_embeddings boolean true 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:

Field Type Description
total_units integer <tu> elements inspected in the upload.
imported integer Segments actually stored.
skipped_units integer Units missing one of the two languages or with an empty segment.
duplicates_dropped integer Duplicate 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.

Field Type Required Description
tmx_file file Yes .tmx upload. A suffix other than .tmx is rejected with 415, and so is a declared content type other than application/xml, text/xml, application/x-tmx+xml or application/octet-stream (media-type parameters such as ; charset=utf-8 are ignored; an empty content type skips the check).
name string Yes Set name, 1–128 characters. Must not start with golden-approved- (case-insensitive).
source_lang string Yes Source language of the pair to extract: a code that stands for a supported language (see Language codes) — en-GB passes as en, zh-TW as zh-Hant, and no, no-NO and nob count as nb. Anything else — bare zh and pt and language names included — is refused with 422.
target_lang string Yes Target language, validated the same way. Must stand for a different language than source_lang; pt-BR → pt-PT is a valid pair.

Import semantics: each <tuv> is read as the supported language its tag stands for, so a <tuv xml:lang="en-GB"> satisfies source_lang=en and a zh-TW one satisfies target_lang=zh-Hant. A <tuv> tagged with the other variant of a split language — pt-PT in a pt-BR import, zh-CN in a zh-Hant one — is never used. A tag that names only the base language, or a region with no variant of its own (pt, pt-AO, zh), stands in for the variant you declared, but only when the <tu> has no <tuv> for that variant, and never when both sides of the pair are variants of one language (pt-BR → pt-PT). fr is a supported language in its own right, so a fr or fr-FR <tuv> never stands in for fr-CA, nor a fr-CA one for fr. A <tu> without a usable <tuv> for both languages is skipped and counted in skipped_units; if none is left, the import is refused with 400 TMX contains no translation units for en -> pt-BR (N unit(s) inspected). Inside a multilingual <tu> holding several usable <tuv>s for one language, the one tagged exactly as requested (ignoring letter case) wins, otherwise the first non-empty one. tuid and changedate are preserved; duplicate sources, compared in the same normalised form exact matching uses, collapse to one, keeping the newest changedate (the later unit in the file on a tie); 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 the supported language they stand for, so en-GB → fr and en → fr draw on the same budget, while en → pt-BR and en → pt-PT each have their own, as do en → zh-Hans and en → zh-Hant, and en → fr and en → fr-CA. Sets created before the variants were kept apart count toward no pair's 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"
import os
import requests

with open("legal-en-fr.tmx", "rb") as handle:
    resp = requests.post(
        "https://trueidiom.com/api/tm?api-version=2026-09-01",
        headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
        files={"tmx_file": ("legal-en-fr.tmx", handle, "application/xml")},
        data={"name": "legal-en-fr", "source_lang": "en", "target_lang": "fr"},
        timeout=300,
    )
resp.raise_for_status()
body = resp.json()
if resp.status_code == 202:  # large file: poll the import job
    print(body["job"]["id"], body["poll_url"])
else:
    print(body["id"], body["imported"])
// Node 20+ for fs.openAsBlob
import { openAsBlob } from "node:fs";

const form = new FormData();
form.append(
  "tmx_file",
  await openAsBlob("legal-en-fr.tmx", { type: "application/xml" }),
  "legal-en-fr.tmx",
);
form.append("name", "legal-en-fr");
form.append("source_lang", "en");
form.append("target_lang", "fr");

const res = await fetch("https://trueidiom.com/api/tm?api-version=2026-09-01", {
  method: "POST",
  headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY },
  body: form,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
// 202 means a large file: poll the import job instead.
console.log(res.status === 202 ? body.job.id : body.id);
{
  "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 segments Status What you get
At or under 5000 200 The 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 5000 202 {"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, the unsupported-language 422, the 422 tm_max_segments_exceeded capacity check and the 409 for an import already running into the set 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 POST /api/tm naming a set that another import — background or synchronous — is still writing is refused with 409 and a string detail whose first token is stable. When the running import is a background job, the message names it:

{"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"}

When it is a synchronous import there is no job to poll, and the message reads tm_import_in_progress: an import into translation-memory set 'legal-en-fr' is already running; an import replaces the set wholesale, so wait for it to finish 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 import finishes — for a background job, before it reports completed or failed — so importing into a different set name is never blocked and a retry after the poll finishes succeeds. DELETE /api/tm/{set_id} on a set whose import is still running is refused the same way, since the import would re-create the set when it lands; that message ends … and delete the set afterwards if you still need to. 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.

Status detail Cause
400 tmx_file upload is required No file part named tmx_file.
400 tmx_file is empty Zero-byte upload.
400 name is required / name must be at most 128 characters Name missing or too long.
400 name is reserved: the 'golden-approved-*' namespace holds approved pairs and cannot be overwritten by import; choose a different name Import targeted the reserved namespace.
400 source_lang and target_lang are required One of the two language fields missing.
400 TMX 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.
401 missing or invalid tenant credentials No usable tenant credential.
409 tm_import_in_progress: an import into translation-memory set '<name>' is already running … Another import into the same set name — background or synchronous — has not finished yet (see Synchronous or asynchronous above). Nothing is imported; wait for it (poll the job the message names, if any) and re-upload afterwards.
413 request body exceeds MAX_UPLOAD_BYTES Upload too large.
415 tmx_file must be a .tmx file / tmx_file must be XML (TMX 1.4b) Suffix or content type rejected.
422 unsupported language(s): '<code>'. Expected language codes such as 'en' or 'pt-BR' (regional variants such as en-US also match; a split language needs its variant — pt-BR or pt-PT, zh-Hans or zh-Hant). Supported: … source_lang or target_lang does not stand for a supported language — bare zh and pt included. Nothing is imported.
422 tm_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.
502 The embeddings service is temporarily unavailable. Retry shortly; if this persists, contact support. A 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.
502 The embeddings service stayed rate limited for longer than the server waits it out. Retry later, or split the upload into smaller files. That 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

Field Type Description
id string 32-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.
status string queued, running, completed, or failed.
set_name string The name the import targets.
source_lang string The supported code the requested source_lang stands for (en for en-GB), as the set will store it.
target_lang string Likewise for target_lang (zh-Hant for zh-TW).
total_units integer <tu> elements inspected in the upload.
deduped_units integer Segments 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_at string When the job was accepted, UTC.
updated_at string When the job last changed status, UTC.
error string | null null unless status is failed; then an actionable message (see When an import fails).
result object | null null 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. queued → running → completed 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.

Name Type Required Description
job_id string (path) Yes The 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"
import os
import requests

resp = requests.get(
    "https://trueidiom.com/api/tm/imports/0f5b1a2c8d3e4f6a9b7c0d1e2f3a4b5c?api-version=2026-09-01",
    headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
    timeout=60,
)
resp.raise_for_status()
job = resp.json()["job"]
print(job["status"], job["error"])  # poll until "completed" or "failed"
if job["status"] == "completed":
    print(job["result"]["imported"])
// Node 18+
const res = await fetch(
  "https://trueidiom.com/api/tm/imports/0f5b1a2c8d3e4f6a9b7c0d1e2f3a4b5c?api-version=2026-09-01",
  { headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { job } = await res.json();
console.log(job.status, job.error); // poll until "completed" or "failed"
if (job.status === "completed") {
  console.log(job.result.imported);
}

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 import was interrupted by a server restart — nothing was imported and the set's previous contents are unchanged; re-upload the TMX file to retry",
    "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.

Cause job.error What to do
A service the import depends on failed or stayed rate limited The 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. appended Wait it out, then re-upload the same file.
The server restarted or was redeployed mid-import the import was interrupted by a server restart — nothing was imported and the set's previous contents are unchanged; re-upload the TMX file to retry Re-upload. Jobs are never resumed: the uploaded TMX bytes are deliberately not retained, so there is nothing to restart from.
Anything else the translation-memory import failed because of an internal error. Nothing was imported: the set's previous contents are unchanged, so the upload can be retried as-is. Retry once; quote job.id to support if it repeats — the details are in the service's logs.

A server that shuts down or is redeployed records its own in-flight imports as failed with the restart message on the way out — or as completed, if the import had already been stored. A job whose worker died without recording anything is swept to failed when the service comes back up, and, as a backstop, a poll or listing that finds a job still queued or running 30 minutes after its last status change, with no worker driving it, reports it failed with the same message. 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.

Name Type Required Description
set_id string (path) Yes The 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; 409 tm_import_in_progress: … while an import into that set is still running (see One import per set name at a time above) — wait for it to finish, then delete.

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.

Name Type Required Description
set_id string (path) Yes The 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
import os
import requests

resp = requests.get(
    "https://trueidiom.com/api/tm/dc1ac7f1606c46519a8b0f138da39dde/export?api-version=2026-09-01",
    headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
    timeout=300,
)
resp.raise_for_status()
with open("legal-en-fr.tmx", "wb") as handle:
    handle.write(resp.content)
// Node 18+
import { writeFile } from "node:fs/promises";

const res = await fetch(
  "https://trueidiom.com/api/tm/dc1ac7f1606c46519a8b0f138da39dde/export?api-version=2026-09-01",
  { headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await writeFile("legal-en-fr.tmx", Buffer.from(await res.arrayBuffer()));

Response 200 with Content-Type: application/xml and Content-Disposition: attachment; filename="<set name>.tmx". When the set name holds anything beyond printable ASCII, or a ", \, % or /, filename carries an ASCII stand-in with each such character replaced by _, followed by the exact name as filename*=UTF-8''<percent-encoded name>.tmx (RFC 6266), which browsers prefer. The body:

<?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. Characters XML 1.0 cannot carry, such as control characters other than tab, line feed and carriage return, are removed from exported text, so the file always parses. Exports read at most 50,000 segments per set. An imported set never holds more than that; a golden set can, because corrections are still appended once the language pair is full (see TM and the golden store), and its export then carries its newest 50,000 segments — the oldest, typically targets a correction has since superseded, are left out.

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.

Name Type Required Description
source_lang string (query) Yes Source language, read as the supported language it stands for (see Language codes); en-US and en resolve to the same set. The generated spec marks this optional with default "", but a blank value returns 400.
target_lang string (query) Yes Target language, read the same way, with the same 400 on blank.

The set resolved is golden-approved-<source>-<target>, each side named by the supported code it stands for. So target_lang=fr exports golden-approved-en-fr, target_lang=fr-CA exports golden-approved-en-fr-CA, target_lang=pt-BR exports golden-approved-en-pt-BR, target_lang=zh-TW exports golden-approved-en-zh-Hant, and target_lang=no exports golden-approved-en-nb; a variant's set holds only that variant's approvals. A golden set from before the variants were kept apart (see Translation memory sets) is exported by passing the bare code — target_lang=pt exports golden-approved-en-pt, target_lang=zh exports golden-approved-en-zh — and that export does not label which variant each pair came from. Because approvals append rather than overwrite, a source can appear more than once — once per target appended for it, in approval order — and the target now served is its last.

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
import os
import requests

resp = requests.get(
    "https://trueidiom.com/api/tm/export/golden?source_lang=en&target_lang=fr&api-version=2026-09-01",
    headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
    timeout=300,
)
resp.raise_for_status()
with open("golden-approved-en-fr.tmx", "wb") as handle:
    handle.write(resp.content)
// Node 18+
import { writeFile } from "node:fs/promises";

const res = await fetch(
  "https://trueidiom.com/api/tm/export/golden?source_lang=en&target_lang=fr&api-version=2026-09-01",
  { headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await writeFile("golden-approved-en-fr.tmx", Buffer.from(await res.arrayBuffer()));

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

Errors:

Status detail Cause
400 source_lang and target_lang query params are required Either parameter missing or blank.
401 missing or invalid tenant credentials No usable tenant credential.
404 no approved golden pairs for this language pair No golden set exists for the pair: nothing has been approved for it yet, or its golden set was deleted.
404 No 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, hi, mr or ne, 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.

Two cases fall through to the full pipeline instead, and a third is settled before the match is used:

Case Behaviour
The match violates a matched glossary term Runs the full pipeline 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 stored segments share the source with different targets Not a fall-through: one 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. For a text job, close ("fuzzy") matches for the source text are given to the translation engine as leverage, together with the most relevant pairs from the tenant's memory for the language pair as examples — imported and approved pairs alike, each source carrying the target an exact match would serve, so a reviewer's correction is offered rather than the target it replaced — so a new translation follows the wording the tenant has already accepted. A document segment gets the examples. This runs on every job that reaches the pipeline; there is nothing to switch on and no request parameter to set.

Close matching works at sentence and paragraph scale: a source text longer than 2,000 characters gets no close matches (an exact match still applies at any length), and a stored pair whose source or target is longer than 2,000 characters is never offered as a close match or an example.

If the service TrueIdiom uses to search the memory is unavailable, the job still translates — without close matches or examples — rather than failing. Exact matches and terminology enforcement do not depend on it.

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 store Golden TM set
What it is The 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 lives TrueIdiom-managed storage, or the tenant's own storage under BYOS The same store as imported TM sets
Contents Full job payload — request, candidates, quality assessments, reviewer identity source_text / target_text segments only
Naming Per-job blobs/records One set per language pair: golden-approved-<src>-<tgt>
Read path GET /api/translations/{job_id}/golden (txt/docx/pdf) GET /api/tm/export/golden, plus exact/fuzzy lookup on every subsequent job

Publishing a translation job — by a reviewer's approve or modify (POST /api/translations/{job_id}/review), or autonomously when the quality gate passes it — persists the job to the golden store and appends its pair to the golden TM set. A job served by a glossary or TM fast path does neither. 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 faster: an exact hit later returns without a translation being generated at all. It is billed like any other job — the memory saves time, not characters.
  • 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, and the newest golden target for a source is the one an exact match serves and the one examples carry. A published translation job appends its pair every time, subject only to the guards below. From a document, a reviewer's correction is appended and becomes the served target — even when it goes back to a target the set held before (A → B → A) — and is skipped only when it repeats the target currently served; a segment approved without edits is skipped when the set has ever held that same target for its source, so re-approving text a reviewer has since corrected cannot bring the old target back.
  • 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.

Limit Value What happens at it
Set name length 128 characters 400 on create or import.
Terminology entries per language pair 2000 Counted 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 pair 50000 Counted 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: when a set holds more, the newest segments are exported.
Post-dedupe segments that keep an import synchronous 5000 At 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 body 30 MB 413 {"detail": "request body exceeds MAX_UPLOAD_BYTES"} on any upload to these endpoints.
Import jobs listed by GET /api/tm/imports 50 newest No 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 is never null: it is the same id as the X-Request-ID response header — the one you sent, or the one the server generated when you sent none.

Parameter-validation failures return the standard array form:

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

What's next