# RODMENA L10n > RODMENA L10n stores your source strings, translates them with translation memory and AI, gates every translation > through QA and a review and approve workflow, and exports catalogues in your files' own format. This is the > integration guide for agents and build scripts. The full contract is GET /openapi.json (OpenAPI 3.1). ## Base URL https://l10n.rodmena.co.uk Public, unauthenticated: GET /healthz, GET /readyz, GET /openapi.json, GET /llms.txt (each also answers HEAD with the same status and headers and an empty body). ## Authentication Every /v1 request carries two headers: - `Authorization: Bearer `: a service-account key; GET /v1/me shows its principal and permissions. - `l10n-tenant: `: the tenant the key belongs to. Writes carry an `Idempotency-Key: <1-200 printable ASCII>` header. It is required on file uploads, on PUT /v1/keys/{key_id}/translations/{locale} and on POST /v1/keys/{key_id}/translations/{locale}/transition, and honoured on every other POST; POST /v1/projects/{project_id}/releases and POST /v1/releases/{release_id}/withdraw require it too. A retry with the same key and the same request replays the stored answer; the same key with a different request is 422 idempotency_key_reused; a retry while the first is still running is 409 idempotency_in_progress (Retry-After 1). Permissions are per project (`p__read`, `files-write`, `jobs-run`, `export`, ...) and per locale (`p__loc__translate`, `review`, `approve`). A missing permission is 403 forbidden. Grant templates (POST /v1/grants with `{"principal_id", "template", "scope": {"project_id"[, "locale"]}}`): `push` = read + files-write (a CI job that uploads sources), `build` = read + export (a build that exports catalogues and metadata), `ci` = both, `runner` = read + jobs-run (a job that runs AI translate and nothing else), `terminologist` = read + glossary-write (maintain the project glossary and nothing else), `publisher` = read + release (Futex approval requests), `viewer` = read, `project_manager` = every project permission, and `translator`, `reviewer`, `approver` per locale. ## Client rules - Always export with `state=approved` for a build: the default export also ships draft, reviewed and outdated text. - After a crash or a timeout, retry with a NEW Idempotency-Key: a key whose first request never finished answers 409 idempotency_in_progress until it expires (24 h). - Never ship invisible characters the source does not have: review and approve refuse (422 qa_failed, check Q18) a target holding a TAG character, a bidi override or isolate, LRM/RLM/ALM, ZWSP, WJ, BOM or a control character other than tab, LF and CR that the source does not hold; the check's detail names them as U+XXXX. - ICU plural/select/selectordinal keys need a human: AI translate never attempts them and reports them as `needs_human` and `needs_human_keys`; translate them with PUT /v1/keys/{key_id}/translations/{locale}. ## The website flow One catalogue per namespace per locale. A namespace (for example `about` or `legal.privacy`) is one uploaded FILE in one project; flat JSON `{"stable.key": "ICU message"}` is the JSON kind. 1. Push the English source of each namespace (a new version of the same path replaces it; changed keys mark their translations `outdated`, removed keys become obsolete): POST /v1/projects/{project_id}/files?kind=json&path=about.json with the raw file bytes as the body and `Content-Type: application/octet-stream`. The answer holds `file_id`. 2. List the keys (to address single keys): GET /v1/projects/{project_id}/keys?limit=500, then follow `next_cursor` with `&cursor=` until it is null. 3. AI translate the untranslated keys of a file (with a `runner` key): POST /v1/projects/{project_id}/translate with `{"file": "", "locale": "de-DE", "batch": 50}`. `batch` (1-200, default 50) is the number of keys one call attempts. `"include_outdated": true` also re-drafts keys whose source changed since their translation (outdated -> draft, a new revision); they count in `drafted` and `remaining`. Each key is written in its own transaction as soon as its translation arrives, so progress persists; a call stops after about 25 s and answers 200 with `drafted`, `remaining` (untranslated keys the AI can still take), `stopped` (null, `deadline`, `ai_busy` or `ai_unavailable`) and a per-key report. Keys with ICU plural/select/selectordinal are drafted too: L10n expands each message into full sentences (one per target CLDR category and select key, `=N` branches kept, at most 36), the AI translates plain sentences only (it never sees ICU syntax), and L10n reassembles a valid ICU message for the target locale (for example fr-FR gets one/many/other). The result must pass QA Q01-Q05 or it is not written: it appears in `failed_keys` with reason `icu_qa_failed: ...` and the rejected message. Messages needing more than 36 sentences, `choice` arguments, and plural JSON keys holding ICU are counted in `needs_human` and listed in `needs_human_keys` (translate them by hand); they never block progress. Loop while `remaining > 0` and the last call drafted at least one key; stop when `drafted` is 0 (a key whose engine output failed validation stays untranslated and is reported `skipped`). Exception: `stopped: "deadline"` with `drafted` 0 means the AI answered too slowly, not that nothing is left. An answer that arrives after the budget is kept for your tenant and the next call for the same key uses it with no new engine call (per-key `origin` `memo`), so wait about 45 s and call again, at most 3 times in a row. When the engine loses, repeats or invents a placeholder token, L10n re-asks it up to twice, naming the broken tokens. If the output is still invalid, the key is recorded as failed for its current source: it is reported with `reason` (for example `placeholders_lost: ⟦0⟧`) and the `rejected` draft in `failed_keys`, counts in `needs_human`, and is not retried, so `remaining` reaches 0. A changed source makes it eligible again; `"retry_failed": true` retries it on request (for example after you change style text). Keys that share the locale, the style text and the forbidden list are sent up to 20 per engine call (style and forbidden terms once per call, each key with its own context, max_length, glossary hits and examples); each key's answer is checked on its own, and a key missing, duplicated or invalid in the batch answer is asked again on its own, so one bad key never costs the batch. `engine_calls` counts engine requests sent (one per batch, one per single key; placeholder re-asks are not counted) and `tokens` ({prompt, completion, reasoning}) is what they cost in total, re-asks included; a batch's tokens are split evenly across its keys in the per-key reports. Daily AI spend cap: every tenant has a daily cap on engine tokens (UTC day; default 1,000,000 tokens; the operator can change it per tenant). GET /v1/usage (permission org_usage-read, held by org_admin and org_billing keys) shows today's `ai_tokens` {limit, used, remaining, window_start, reset_at, capped}. When the cap is nearly spent, translate sends only the work that fits and answers 200 with `stopped: "ai_daily_cap"`; when nothing fits and nothing could be drafted, it answers 429 ai_daily_cap with `limit`, `used`, `reset_at` and Retry-After (seconds to the reset). TM matches and kept answers still draft while capped. 409 ai_spend_unassigned means no cap is assigned to the tenant yet (ask the operator); 503 metering_unavailable means the cap cannot be checked right now (retry after Retry-After). Every result is a `draft` and carries `qa_failed`. Every translation reports `origin`: `ai` (AI draft), `human` (PUT), `import` (XLIFF) or null (written before origin was recorded). Re-draft AI drafts after changing style, context or glossary with `{"file", "locale", "include_drafts": true, "drafted_before": ""}`: only drafts still in state draft with origin ai and written before that time are re-drafted. Human edits and reviewed keys are never touched, and a loop with the same drafted_before ends when remaining is 0. 409 ai_processing_disabled means the tenant has not enabled AI; 503 ai_unavailable (engine down, nothing drafted) and 503 ai_busy (the engine queue is full) mean retry after Retry-After. 4. Human edits: PUT /v1/keys/{key_id}/translations/{locale} with `{"text": "..."}` (or `{"forms": {...}}` for a plural key). The forms are the TARGET locale's CLDR cardinal categories, not the source's: fr-FR is one/many/other, ar and cy zero/one/two/few/many/other, de-DE one/other (anything else is 422 edit_shape). Scots (sco-GB, sco-ulster) has no CLDR plural data: its only category is `other`, so write Scots plural text count-neutral. AI translate and export follow the same rule: a category the source lacks (French `many`) is translated from the source's `other`, and export writes every target category (for example `files_many`). Read one with GET /v1/keys/{key_id}/translations/{locale}, a page with GET /v1/projects/{project_id}/translations?locale=de-DE. 5. Review and approve: POST /v1/keys/{key_id}/translations/{locale}/transition with `{"action": "review"}`, then `{"action": "approve"}` (`reject` and `revoke` step back). Review and approve run the blocking QA checks; a failure is 422 qa_failed with the failed checks in `checks`. GET /v1/keys/{key_id}/annotations/{locale} shows every check. Translation-memory suggestions: GET /v1/keys/{key_id}/suggestions/{locale}. Human sign-off through Futex (a `publisher` key: read + release): POST /v1/projects/{project_id}/approval-requests with `{"file": "", "locale": "de-DE"}` batches the file's draft and reviewed translations that pass every blocking QA check and are not under the project's `legal_prefixes` (PATCH /v1/projects/{project_id} with `{"legal_prefixes": ["legal."]}`, a manage setting), and asks the configured reviewers in one Futex decision. 409 nothing_to_approve: nothing qualifies; 409 approval_pending: a request for that file and locale is open; 503 futex_unavailable: retry later. Poll GET /v1/approval-requests/{request_id} until `status` is approved, rejected, changes_requested, timed_out or cancelled; the request also returns `items` [{key, key_id, revision, targetHash}], `context` (the exact canonical JSON sent to Futex) and `futex_context_hash`, so you can check what the reviewer saw. An optional `review_url` (https, at most 2048 characters) in the POST body is passed to the reviewer in that context. On approved, the items whose text is unchanged since the request become `approved` (export metadata then shows `approvedBy` = the reviewer and `approvalRef` = the decision); edited items are listed as skipped_changed; on rejected they return to draft with the reviewer's `reason`. Glossary (needs `glossary-write`; read with `read`): POST /v1/projects/{project_id}/glossary with `{"kind": "term", "source": "file", "target": "Datei", "locale": "de-DE"}` (a required translation, reported by Q09), `{"kind": "dnt", "source": "RODMENA"}` (must appear verbatim in every target whose source holds it; Q10 blocks review and approve) or `{"kind": "forbidden", "target": "Akte", "locale": "de-DE"}` (must not appear in a target; Q11 blocks). List with GET /v1/projects/{project_id}/glossary, change with PUT /v1/projects/{project_id}/glossary/{term_id}, remove with DELETE. AI translate keeps DNT terms verbatim and passes the matched terms and the forbidden terms to the engine as constraints. Style (glossary-write; read with read): PUT /v1/projects/{project_id}/style with `{"locale": "de-DE" or "*", "scope": "default" or "legal", "text": "..."}` (at most 8000 characters; an empty text deletes it). AI translate sends the `*` and locale `default` texts as the engine's style guide, plus the `legal` texts for keys under `legal_prefixes`. List with GET /v1/projects/{project_id}/style. Add `"register": "formal"` (scope default) to turn on Q19 for that locale. Blocking project checks (they block review, approve and approval batches, and are listed in qa_failed): Q08 length_limits (graphemes against the key context's min_length/max_length; skipped without limits), Q19 register (with register formal: no informal address forms; built-in markers for de, fr, es, it, nl, cy (ti, di, dy, tithau, dithau, infixed 'th; contractions such as ti'n count; 'di for wedi does not) and gd (thu, thusa and the informal prepositional pronouns such as agad, leat, ort; not 'do', which is also 'to'); other languages list their informal forms as forbidden terms; imperatives are not detected), and Q20 consistency (a key whose source text and context equal another key's must get the same target as that key's reviewed, approved or human-written translation; the detail names the other keys). Key context (files-write, e.g. a `push` key): PUT /v1/projects/{project_id}/files/{file_id}/context with `{"keys": [{"path": "nav.trace", "context": "Product: Trace. Page: /trace. Role: card title.", "max_length": 40, "class": "prose"}]}` (at most 1000 per call; context at most 2000 characters; class is prose, claim or legal, and a key without one counts as claim). Entries are kept by path, so they survive source re-uploads. An unknown path answers 422 key_unknown and nothing is written. AI translate sends the context and max_length to the engine. List with GET on the same path. Policy approval of prose (gate authority ruling 2026-10-06): a locale is LICENSED only after a human sample passes: POST /v1/projects/{project_id}/licences {"locale", "sample_size" (300 or more), "corrections" (at most 5%), "meaning_errors" (must be 0), "sample_ref"} raises one Futex decision; the licence is active once it is approved, and lapses after 30 days, when more than 10% of the locale's prose keys change, or when a later sample fails (a failing sample is recorded as sample_failed). With an active licence and the kill switch on (PUT /v1/projects/{project_id}/policy {"locale", "enabled"}, manage), POST /v1/projects/{project_id}/policy-approve {"file", "locale"} (release) approves draft and reviewed keys whose context class is prose, that are outside legal_prefixes and that pass every blocking QA check. Claim keys, keys without a class and legal keys are never policy-approved. Export metadata shows approvedBy policy:@1 and approvalRef = the run's evaluation_id. 6. Export the catalogue for a build: GET /v1/projects/{project_id}/files/{file_id}/export?locale=de-DE[&state=approved][&fallback=source|omit] The body is the file in its own format; the ETag is `"r"`, the (file, locale) revision. 7. Export the metadata for the same revision: GET /v1/projects/{project_id}/files/{file_id}/export?locale=de-DE&metadata=true&revision= answers `{"project_id", "file_id", "locale", "revision", "entries": {"": {"sourceHash", "targetHash", "status", "approvedAt", "approvedBy", "approvalRef"}}}`: one entry per key of the exported catalogue. `sourceHash` is the SHA-256 hex of the UTF-8 source value exactly as you sent it (no normalisation) that the translation was made from; compare it with the hash of your current source to refuse a stale entry (`status` is then `outdated`). `targetHash` is the SHA-256 hex of the exported value. `approvedAt` and `approvedBy` are set only when `status` is `approved`. Pass `revision=` from the catalogue's ETag so both views are the same revision: a superseded revision is 410 revision_superseded with the current ETag; export again. 8. Optional: XLIFF 2.0 round trip (POST /v1/projects/{project_id}/xliff/export and /xliff/import) and release bundles (POST /v1/projects/{project_id}/releases, GET /v1/releases/{release_id}/bundle.zip). Statuses: `untranslated`, `draft`, `needs_attention`, `reviewed`, `approved`, `outdated`. ## Errors Errors are `application/problem+json`: `{"type", "title", "status", "code", "detail"?, "field"?}`. Act on `code`. - 400 unknown_parameter, invalid_parameter, unknown_field, invalid_field, malformed_json, invalid_body - 401 unauthenticated (missing, unknown or revoked key) - 403 forbidden - 404 not found in this tenant (also revision_not_found for a revision ahead of the current one) - 409 conflict, invalid_transition, revision_mismatch, key_obsolete, format_mismatch, idempotency_in_progress, ai_processing_disabled - 410 revision_superseded - 413 upload_too_large - 415 unsupported_media_type - 422 qa_failed, edit_shape, value_too_long, idempotency_key_reused, format_not_offered, a file reader's refusal code (malformed, duplicate_key, encoding_invalid, ...), or a locale tag refusal - 503 ai_unavailable, ai_busy, dependency_unavailable, language_core_unavailable (honour Retry-After) ## Limits - Upload: 50 MiB per file. - One value: 64 KiB of UTF-8 (65536 bytes). - AI translate: `batch` 1-200 keys per call (default 50), about 25 s per call. - Pagination: `limit` 1-500 (default 50) and an opaque `cursor`; a page answers `next_cursor`, null on the last page.