Translate Product Strings at Runtime with General Translation
AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.
Translate Product Strings at Runtime with General Translation
Translate user-facing strings on demand through General Translation's runtime endpoint, and use per-string context so ambiguous product terms come back right the first time.
What you will build
A small Node service that translates strings at request time and caches nothing itself, letting General Translation's own cache do the work.
your app
|
v
POST https://api.gtx.dev/v2/translate
{requests: {<your-id>: {source, metadata}}, sourceLocale, targetLocale, metadata}
|
v
{<your-id>: {success, dataFormat, translation, locale}}
201 = newly translated 200 = every string served from cache
AI Prompt
Implement a Node function that translates UI strings through General
Translation's runtime API.
Requirements:
- POST https://api.gtx.dev/v2/translate
- Auth: "Authorization: Bearer <key>". A project key is bound to one project
and needs nothing else. Project keys come in two types: production
(gtx-api-) and development (gtx-dev-). Both authenticate THIS endpoint,
but a development key is rejected by the CLI file workflow, which needs a
production key. An organization key (gtx-org-) also requires a
"gt-project-id: <projectId>" header.
- Request body requires all four of: requests, sourceLocale, targetLocale,
metadata. Send "metadata": {} if you have nothing to put in it. Omitting
it is a validation error.
- "requests" is a map keyed by ids you choose. The response comes back keyed
by those same ids, so use stable keys you can look up.
- Each request entry is {source, metadata: {id, dataFormat}}. dataFormat is
one of JSX, ICU, I18NEXT, STRING.
- Treat BOTH 200 and 201 as success. 201 means newly translated, 200 means
every string was served from cache. Do not branch on 200 alone.
- Pass metadata.context for any string whose meaning is ambiguous out of
context. Assert that the contextual result is appropriate for the domain,
not that it differs from the uncontextualised one; both can be acceptable.
- Never fall back to returning the untranslated source silently. If
success is false for an entry, surface it.
Prerequisites
- Node 18+ (the function below uses global fetch)
- A General Translation project and a project API key
- An organization key alone is not enough on its own. See step 1.
1. Get a key that can reach a project
Every content-bearing endpoint is project-scoped. An organization key authenticates but does not by itself identify a project:
curl -s -X POST https://api.gtx.dev/v2/translate \
-H "Authorization: Bearer $GT_ORG_KEY" \
-H "Content-Type: application/json" \
-d '{"requests":{"r1":{"source":"Hello","metadata":{"id":"greeting","dataFormat":"STRING"}}},"sourceLocale":"en","targetLocale":"es","metadata":{}}'
{"error":"projectId is required"}
That is HTTP 400, not 401. The credential is fine, the request just does not say which project. Two ways to fix it:
# Option A: organization key plus an explicit project header curl -s -X POST https://api.gtx.dev/v2/translate \ -H "Authorization: Bearer $GT_ORG_KEY" \ -H "gt-project-id: $GT_PROJECT_ID" \ -H "Content-Type: application/json" -d @body.json # Option B: a project key, which is already bound to its project curl -s -X POST https://api.gtx.dev/v2/translate \ -H "Authorization: Bearer $GT_API_KEY" \ -H "Content-Type: application/json" -d @body.json
Both work. Option B is what application code should use.
2. Translate a string
cat > body.json <<'JSON'
{
"requests": {
"r1": {
"source": "Your trial ends in 3 days.",
"metadata": { "id": "billing.trial_ends", "dataFormat": "STRING" }
}
},
"sourceLocale": "en",
"targetLocale": "es",
"metadata": {}
}
JSON
curl -s -w '\nHTTP %{http_code}\n' -X POST https://api.gtx.dev/v2/translate \
-H "Authorization: Bearer $GT_API_KEY" \
-H "Content-Type: application/json" --data @body.json
{"r1":{"success":true,"dataFormat":"STRING","translation":"Tu período de prueba termina en 3 días.","locale":"es"}}
HTTP 201
The response is keyed by r1, the id you chose in requests.
3. The Node function
The curl calls above show the wire format. This is what you would actually ship.
// translate.mjs
const API = 'https://api.gtx.dev/v2/translate';
/**
* Translate a batch of strings.
* @param {Record<string, {source: string, context?: string}>} entries keyed by your own ids
* @param {{from: string, to: string}} locales
* @returns {Promise<Record<string, string>>} translations keyed by the same ids
*/
export async function translate(entries, { from, to }) {
const apiKey = process.env.GT_API_KEY;
if (!apiKey) throw new Error('GT_API_KEY is not set');
const requests = Object.fromEntries(
Object.entries(entries).map(([id, { source, context }]) => [
id,
{ source, metadata: { id, dataFormat: 'STRING', ...(context ? { context } : {}) } },
])
);
const res = await fetch(API, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...(process.env.GT_PROJECT_ID ? { 'gt-project-id': process.env.GT_PROJECT_ID } : {}),
},
body: JSON.stringify({ requests, sourceLocale: from, targetLocale: to, metadata: {} }),
});
// HTTP-level failure. 200 and 201 are both success; do not branch on 200 alone.
if (!res.ok) {
const body = await res.text();
throw new Error(`GT ${res.status}: ${body.slice(0, 200)}`);
}
const payload = await res.json();
// Per-entry failure. A 2xx does not mean every string translated.
const out = {};
const failed = [];
for (const id of Object.keys(entries)) {
const entry = payload[id];
if (!entry || entry.success !== true || typeof entry.translation !== 'string') {
failed.push(id);
continue;
}
out[id] = entry.translation;
}
if (failed.length) {
throw new Error(`GT returned no translation for: ${failed.join(', ')}`);
}
return out;
}
Two failure modes, handled separately, because they are genuinely different. res.ok covers auth, validation and quota. The per-entry loop covers a 2xx response where one string still came back without a translation, which is the case that silently ships English to users if you skip it.
Using it:
import { translate } from './translate.mjs';
const out = await translate(
{
'billing.trial_ends': { source: 'Your trial ends in 3 days.' },
'billing.seat': {
source: 'Seat',
context: 'A billing seat: one paid user licence in a SaaS subscription. Not furniture.',
},
},
{ from: 'en', to: 'de' }
);
console.log(out);
{
'billing.trial_ends': 'Dein Testzeitraum endet in 3 Tagen.',
'billing.seat': 'Lizenzplatz'
}
The error paths, exercised:
| case | thrown |
|---|---|
GT_API_KEY unset | GT_API_KEY is not set |
| invalid key | GT 401: Unauthorized! |
| organization key with no project | GT 400: {"error":"projectId is required"} |
4. Add context for ambiguous terms
This is an easy step to miss, and it is where additional translation quality can come from. The word "Seat" in German, with no context:
curl -s -X POST https://api.gtx.dev/v2/translate \
-H "Authorization: Bearer $GT_API_KEY" -H "Content-Type: application/json" \
-d '{"requests":{"s":{"source":"Seat","metadata":{"id":"billing.seat","dataFormat":"STRING"}}},"sourceLocale":"en","targetLocale":"de","metadata":{}}'
{"s":{"success":true,"dataFormat":"STRING","translation":"Sitz","locale":"de"}}
Sitz is a chair, which is wrong for a SaaS billing string. Now with context:
curl -s -X POST https://api.gtx.dev/v2/translate \
-H "Authorization: Bearer $GT_API_KEY" -H "Content-Type: application/json" \
-d '{"requests":{"s":{"source":"Seat","metadata":{"id":"billing.seat","dataFormat":"STRING","context":"A billing seat: one paid user licence in a SaaS subscription. Not furniture."}}},"sourceLocale":"en","targetLocale":"de","metadata":{}}'
{"s":{"success":true,"dataFormat":"STRING","translation":"Lizenzplatz","locale":"de"}}
Same endpoint, same source string, correct answer only in the second case.
The specific words are model output from the verification run, not a fixed contract. A future run may return a different German word. What is reproducible is the effect: supplying metadata.context changes the translation of an ambiguous term.
5. Verify the result
Run the context pair above back to back.
Expected result: the call carrying metadata.context returns a term suitable for a SaaS billing string.
Observed across three runs with fresh ids:
| run | no context | with context |
|---|---|---|
| 1 | Sitz | Nutzerlizenz |
| 2 | Sitz | Lizenzplatz |
| 3 | Sitzplatz | Lizenzplatz |
Every contextual result is a licence or seat-entitlement term. Every bare result is a furniture or seating sense. Both columns vary between runs, which is why a test pinned to one German word will page you at 3am for no reason.
Do not assert that the two calls differ. The bare call can land on a billing sense by chance, and then a difference test fails on a perfectly correct translation. A separate run also returned Seat unchanged for the bare call, so the bare side is not reliably wrong either.
What to assert instead, in rough order of robustness:
- The contextual result is not in a small denylist of furniture senses:
Sitz,Sitzplatz,Stuhl,Platz. Cheap, deterministic, catches the failure this context exists to prevent. - The contextual result is in an allowlist of accepted billing terms you maintain, for example
Lizenzplatz,Nutzerlizenz. Tighter, but needs updating when the model picks a new valid synonym. - A reviewer checks the sense. Right for a launch checklist, wrong for CI.
Keep the observed pairs above as illustration, not as the check.
A second, independent demonstration that context is doing the work. Translate "Your trial ends in 3 days." to Spanish, once bare and once with a developer-docs context instructing that technical terms stay in English:
| date | bare | with developer-docs context |
|---|---|---|
| 2026-09-16 | Tu período de prueba termina en 3 días. | Tu trial termina en 3 días. |
| 2026-09-22, fresh ids | Tu período de prueba termina en 3 días. | Tu trial termina en 3 días. |
| 2026-09-22, fresh ids | Tu periodo de prueba termina en 3 días. | Tu trial termina en 3 días. |
The instruction was honoured in each of these runs. One earlier run was reported where the contextual call came back fully Spanish, so treat this as reliable but not guaranteed: metadata.context is a strong hint to the model, not a constraint it is obliged to satisfy. If a term must be fixed rather than nudged, that is what a Glossary is for, and per-request context is the wrong tool. See the terminology example.
Within one project, repeating a call with the same content and context returns the cached result (HTTP 200), so a pair looks perfectly stable until you change the id. Use fresh id values to force a new translation rather than a cache hit.
How it works
requests is a map you key yourself, so one call can carry a whole screen's worth of strings and you can look each result up by the same key. sourceLocale and targetLocale apply to the whole batch.
metadata at the top level of the body is required even when empty. This trips people up because it is easy to read as optional.
Context is attached per string, not per request. That matters because ambiguity is a property of the individual string. "Seat" needs disambiguating; "Your workspace" does not.
The status code tells you where the translation came from. This is documented contract, not an incidental observation: the published spec defines 201 as "Translations completed" and 200 as "All translations were served from cache". Both are success. Behavior was confirmed live in both directions, with fresh strings returning 201 and repeated strings returning 200, independent of whether an organization key or a project key was used.
Common issues
{"error":"projectId is required"} with a valid key. You are using an organization key with no project header. Add gt-project-id, or use a project key. The giveaway is that this is a 400, not a 401.
Everything returns 401. The key is missing or malformed. A bad token and no token at all produce the same 401, so check that your env var is actually populated before assuming the key is revoked.
Target not found or access denied, HTTP 403. The credential is valid but does not have access to the project or organization you named. This is distinct from 401 and means you are pointed at the wrong resource, not that you are unauthenticated.
Code that only checks for 200 silently drops fresh translations. The first call for a new string returns 201. Check for 2xx, not equality with 200.
A 2xx response with a missing translation. HTTP success does not guarantee every entry translated. Check success and the presence of translation per entry, as the function above does, or you will ship the English source to users without noticing.
Validation error with a body that looks complete. All four of requests, sourceLocale, targetLocale, and metadata are required. An empty metadata: {} satisfies the last one.
Tuning knobs that may not do what you expect at small sizes. metadata.actionType accepts fast and standard, and metadata.modelProvider accepts ANTHROPIC, OPENAI, XAI, and GOOGLE. All are accepted and return 201. On a single short sentence translated to French, fast and standard returned identical text at 1.91s and 1.94s, and three providers returned the same sentence differing only in apostrophe character. These options exist and are valid; this example did not find a measurable difference at one-sentence scale, and does not claim one.
Next steps
- Translate whole files including MDX docs, see the MDX docs example
- Keep one glossary governing several surfaces, see the terminology example
- Add translation to a Next.js app with no manual string extraction, see the Next.js example
Source
- Reference docs: https://generaltranslation.com/openapi.json, spec version
2026-03-06.v1
Verification
verification:
status: verified
tested_at: "2026-09-22"
product_version: "2026-03-06.v1"
command: "node demo.mjs and node errors.mjs against https://api.gtx.dev/v2/translate"
expected_result: "translate() returns a map keyed by the caller's ids; HTTP failures and per-entry failures both throw; the contextual result for an ambiguous term is appropriate for the domain"
documented_behavior:
"201": "Translations completed"
"200": "All translations were served from cache"
source: "https://generaltranslation.com/openapi.json, spec version 2026-03-06.v1"
observed_in_run:
demo: "{'billing.trial_ends': 'Dein Testzeitraum endet in 3 Tagen.', 'billing.seat': 'Lizenzplatz'}"
error_paths:
missing_key: "GT_API_KEY is not set"
invalid_key: "GT 401: Unauthorized!"
org_key_no_project: 'GT 400: {"error":"projectId is required"}'
context_effect:
run_1: "bare Sitz, context Nutzerlizenz"
run_2: "bare Sitz, context Lizenzplatz"
run_3: "bare Sitzplatz, context Lizenzplatz"
note: "both columns vary between runs. An earlier run returned Seat unchanged for the bare call, so the bare side is not reliably wrong. Assert the contextual result is not in a furniture denylist, not that the two differ."
spanish_context_pair: "the developer-docs context kept 'trial' in English on 2026-09-16 and in two fresh-id runs on 2026-09-22. One run was reported where it did not. Treated as a strong hint rather than a guarantee."
corrections_from_review:
- "an earlier version told readers to assert on the difference between the two calls. That is wrong: both can correctly return the billing sense, and such a test fails for the wrong reason. The doc now gives a denylist check as the concrete assertion."
- "the runnable Node function promised by the opening paragraph is now present and exercised, including both failure modes."
