Translation API v1
Push your application's strings, we translate them, you pull the translations and render them natively. Your strings live in our database; nothing of ours runs in front of your site, and nothing of ours runs inside it. One resource type: a keyed string set.
This is the reference, published at
https://translate.ultimsuite.com/docs/api/v1 (no account needed), and
every example request and response in it is executed as a test against the
real implementation on every build (see
How this document is tested). The published
page renders the same file the tests execute, so the reference cannot drift
from the code without a gate failing.
Base URL and versioning
https://translate.ultimsuite.com/api/v1
The version lives in the path, and the policy is stated here before anyone depends on a shape we might want to change:
- Within v1, responses may GAIN fields at any time; parse what you need and ignore fields you do not know. Field removals, meaning changes, and status-code changes do not happen within v1.
- A breaking change means
/api/v2, with v1 kept serving unchanged for at least six months after v2 ships, and never retired while a known client still calls it (keys record last use, so "known" is measured, not assumed). - Deprecations are announced in this document and to every key holder before any date is set.
Authentication
Every request carries an API key:
Authorization: Bearer utk_...
You create and revoke your own keys in the dashboard, under Settings then API
keys. The secret is shown once, at creation, and never again: we store only a
hash of it, so if you lose it the only path is to revoke and create another.
A key is scoped to exactly one workspace: it can never reach another
customer's content, and that isolation is one of the executed examples below,
not a promise. Keys are revocable at any time and we record when each key was
last used. A request with a missing, unknown, or revoked key answers 401
with the standard error body.
Treat the key like a password. It is shown once at issue time and we store only a hash of it.
Rate limits
Two ceilings, both a fixed one minute window, both counted from the same rows:
- 60 requests per minute per key.
- 300 requests per minute per workspace, across every key it holds.
The second exists because the first is not a limit on a customer: nothing caps how many keys you may issue, so per key counting alone would mean the ceiling rises with the number of keys. A key per environment never meets the workspace ceiling; a polling loop does.
Either refusal answers 429 with a Retry-After header (seconds) and the
standard error body carrying retryAfterSeconds, and says which of the two you
hit. Both are checked before any work, so a refused request costs you nothing
and costs us almost nothing.
Separately from the rate limits, one push carries at most 1,000 keys; send more in consecutive requests.
If you are anywhere near either, the recommended pattern is the fix and not a
higher limit: poll /status, compare the per locale etag, and pull only the
locales that moved.
Errors
Every error is JSON with one shape:
{ "error": { "code": "...", "message": "..." } }
| status | code | when |
|---|---|---|
| 400 | invalid_request | the body or a parameter does not parse or fails validation; message names the first problem |
| 401 | unauthorized | missing, unknown, or revoked key |
| 404 | unknown_set | the set does not exist in your workspace |
| 404 | unknown_locale | the locale is not enabled for your workspace; the body lists locales that are |
| 422 | invalid_placeholders | a pushed string carries a malformed placeholder; the body lists keys with the text around each problem |
| 409 | not_deleted | a delete matched nothing, so nothing was removed |
| 429 | rate_limited | over either ceiling, per key or per workspace; message names which one and the body carries retryAfterSeconds |
| 500 | internal | a fault on our side; the request did not complete |
| 503 | internal | a declared ?channel= could not be saved, so the push stored nothing; retry it |
The resource: string sets
A string set is a named collection of keyed source strings, for example one
set per application. Set names are 1 to 64 characters of a-z, 0-9 and
-. Keys are 1 to 200 characters of a-z, 0-9, ., _ and -. Values
are UTF-8 text up to 10,000 characters.
Placeholders and markers are the subject of their own section, which is the one to read first: Tags and placeholders.
The source language of your pushed strings is your workspace's configured
source locale, and the target languages are your workspace's enabled
locales; GET /locales returns both.
We return the best translation for the context. We do not manage length: what you do with the result, including truncating it, is yours.
Before you integrate
Six decisions about how you author strings. Each is cheap to make now and expensive to change once a catalogue exists, because changing a source string retranslates it in every language. Each links to the section that explains it; if you read nothing else, read these six.
1. Never assemble a sentence from pieces. Send whole sentences. If your
code does t('cart.you_have') + count + t('cart.items'), you have not sent us
a sentence, you have sent us two fragments and asked us to guarantee they
still fit together after translation. They will not. Word order is a fact
about each language, not a detail of formatting: the verb moves in German,
the adjective moves in French, and the number takes a counter word in
Japanese. We translate each key independently and never see the assembled
result, so nothing in our system can catch it and nothing in yours will
either until a native speaker reads the page.
Send cart.summary as You have {count} items in your cart and let the
placeholder carry the number. One key, one sentence, one thing to review.
2. Name your keys meaningfully. We send the key to the translator as
context, and for a short string it is the ONLY context there is.
filters.applied and label_47 are the same word to your code and a
different question to a translator: the first can be answered, the second is
a coin flip. Name the thing rather than the slot it sits in
(account.balance, not col_3.value). Measured: 47 of 92 short labels came
back different once the key was supplied.
Detail.
3. Put placeholders where they do not force an agreement. A {token}
inside a sentence can force a grammatical agreement the surrounding words
cannot make, because the value is unknown when the sentence is written.
Measured on a real catalogue in August 2026: 9 placeholders across 7 strings
of the 73 that carried any, about one in ten, and that proportion has not
been re-measured since the catalogue grew. Messages: {count} needs no
agreement; {count} new messages does. Prefer the first shape where you have
the choice. Detail.
4. Keep markup out of the string. HTML is carried through as text and is
NOT tracked, so a dropped <b> is served like any other translation and no
check anywhere will catch it. Either keep the markup outside the translated
string, or send it as numbered markers (<0>...</0>), which we do track and
hold the translation to. Detail.
5. ICU message format is refused at push, today. {count, plural, one {...} other {...}} returns a 422 rather than being stored, because its
nested braces are not a brace token under our rule. Expand the variants into
separate keys and choose between them in your code.
Detail.
6. Two identical English strings get one translation. A string is
identified by its text, not by its key, so filters.applied and
status.applied, both Applied, share a single translation and a single
key's worth of context. We cannot give you Appliqué in one place and
Candidature envoyée in the other. Where two occurrences genuinely mean
different things, make them different in English, which is also what tells a
human reviewer they are different.
Detail.
Tags and placeholders: what we guarantee
This is the section to read before any other, because it is the thing that goes wrong at every translation service and the thing nobody forgives. Your strings carry tokens that your code substitutes, and a translation that comes back with them renamed, duplicated or quietly dropped is not a translation, it is an outage in a language you cannot read.
What we accept, exactly
Two shapes are UNDERSTOOD, meaning we track them and hold the translation to them. Everything else in your text is carried through as text.
Brace tokens. Any {...} with no braces or newlines inside it. The name
may contain spaces, which is what most template languages allow and most
translation tools do not:
{
"strings": {
"welcome.greeting": "Welcome back, {first name}",
"reward.line": "You earned {points per video} points for {video title}",
"cart.total": "Total: {amount}"
}
}
Numbered markers. Paired <0>…</0> and self-closing <1/>, numbered
from zero, nestable. These come from our proxy product, where they stand in
for a page's own markup, and you can use them directly:
{
"strings": {
"terms.line": "Read the <0>terms</0> before you continue",
"hero.body": "<0>TOSSIT</0> is a game that uses <1>soft darts<2/></1>"
}
}
What we guarantee comes back
For those two shapes: every token in your source is in the translation, the same number of times, with the same name, and none invented. Numbered markers additionally keep their nesting and their form, so a pair does not come back self-closing.
This is enforced rather than hoped for, at every door, by one rule: a
translation whose tokens do not reconcile with its source is refused, not
served. On a pull it is listed under rejected with the reason and never
appears in strings, so your fallback to the source language happens by
itself. In our proxy product the original text is served instead. In the
dashboard the row carries a badge naming what is missing.
What happens when the model gets it wrong
It does, and the number is worth having rather than an adverb. Measured on 2026-08-31 across every translated string in our estate, 27,986 of which carried a tag: 1.93 per thousand come back with a tag inventory that does not match. Of all translated rows the figure is 0.35 per thousand, which is the flattering way to say it and not the one to plan against. The estate grows daily, so the figure carries its date; re-measured two days later it was 1.92, the same 54 rows over a larger denominator.
None of them reach you. That is the point of the paragraph above: the rate at which the model errs and the rate at which you are served something wrong are different numbers, and the second is zero because the check runs at delivery rather than at generation.
Two repairs sit in front of that refusal. A translation that INVENTS a numbered marker where your source had none has it stripped, deterministically, because a source with nothing to wrap cannot have a translation with something wrapped. A translation that mangles markers your source did have is refused rather than guessed at, and the string stays visibly pending, so a retranslation fixes it rather than a customer reading it.
The one honest limit
A tag inside a sentence can force a grammatical agreement that the words
around it cannot make, because the value is not known when the sentence is
written. French, German and Spanish inflect articles, adjectives and
participles for the gender and number of the thing they refer to. {count} nouveau message is right for one and wrong for two, and no translation of
that source can be right for both.
Measured on a customer's real catalogue in August 2026: 9 placeholders across 7 strings, of the 73 that carried any at the time. About one in ten. That catalogue has since grown to 199 tokens across 124 strings and the proportion has not been re-measured, so treat one in ten as the order of magnitude rather than a current reading.
What you can do about it, in order of preference:
- Move the tag out of the agreeing clause.
Messages: {count}needs no agreement.{count} new messagesdoes. - Send the variants as separate keys and choose between them in your code, which is what you would do for pluralisation anyway.
- Accept it where the value is always the same shape, which is the common
case:
{first name}never forces an agreement.
What we do NOT track
Named plainly, because a guarantee is worth less when its edges are vague.
- HTML tags (
<b>,<a href="...">,<span>) are carried through as text and are NOT tracked. They usually survive, and we do not check, so a dropped<b>is served like any other translation. If your markup matters, send it as numbered markers. - ICU message format (
{count, plural, one {...} other {...}}) is refused at push, because its nested braces are not a brace token under the rule above. Expand the variants into separate keys. {{double braces}}are refused at push for the same reason.- printf placeholders (
%s,%d) are carried as text and not tracked.
What shapes the translation itself
Three things decide how a string comes out, and two of them are yours to set.
Formality is a per-language control
Whether a language addresses your users familiarly (French tu, German
du, Spanish tú) or politely (vous, Sie, usted) is a setting on
each target language, not something we infer from your brand description.
You are asked when you add the language, and the answer binds every string
in it.
It works this way because prose does not bind a decision made on every string. Measured on 60 real strings with a brand voice that said "address readers as peers, informal you", the same model produced 30 informal and 29 formal, with 1 string using both. The same prompt with the choice as a control produced 60 informal and 0 formal.
A language you have not answered for uses the polite form, and the screen says so rather than showing a default as though you had chosen it. Change it any time under Translate then Languages; it applies to strings translated after the change, so re-translate a language if you change your mind about one that is already live.
We use your key as context
The key is sent to the translator alongside the text, as CONTEXT only. It is
never translated and never returned to you. It exists because a short string
on its own is a coin flip: Applied became Appliqué, which is what you do
with paint, where contribution_stage.mission.applied says plainly that
somebody applied FOR something.
So a meaningful key gets a better translation, and this is worth knowing before you name things. A key that names the THING beats one that names the slot it sits in:
{
"strings": {
"contribution_stage.mission.applied": "Applied",
"loan.contract.signed": "Signed"
}
}
Measured before we shipped it: with the key supplied, 47 of 92 short labels came back different (51%), and the ones we independently knew were wrong were the ones that changed.
Where a key adds nothing beyond the word itself, the push response says so
under needsContext, listing the strings we translated from the word alone
so you can skim them.
The limit, since it decides how you name things. A string is identified
by its TEXT, not by its key, so two keys carrying identical source text in
one project share a single translation. filters.applied and
status.applied, both Applied, get one translation and one key's worth of
context (the first key alphabetically). We cannot give you Appliqué in one
place and Candidature envoyée in the other.
If two occurrences genuinely need different translations, make the source text differ, which is also what tells a human translator that they are different. Reviewers can then edit each one.
The channel, if you set one
Covered under ?channel=: it selects the
register a set is written in, and nothing else.
Push source strings
POST /api/v1/string-sets/{set}
The body is JSON key-value:
{ "format": "json", "strings": { "checkout.title": "Your cart" } }
The format is the request's content type, and you send what you already have:
application/json(the default): astringsobject of key to text, as above. The body's optionalformatfield accepts onlyjson.text/csv: a header row of exactlykey,text, then one record per string, RFC 4180 quoting, BOM tolerated (executed example below).application/xml(orapplication/xliff+xml): an XLIFF 1.2 document; each trans-unit'sidbecomes the key and its<source>text the string, which is what a file exported from another translation tool carries when you migrate in. Targets and states in the file are ignored on push.
Whatever the wire format, everything after parsing is one pipeline: the same key grammar, the same idempotence, the same response shape.
Semantics, in order of importance:
- Merge, never replace. Keys absent from a push are left untouched. Deleting a key from a set is stage two.
- Idempotent by key and text. A key whose source text is unchanged does nothing and costs nothing; you can push your entire catalogue on every deploy. A changed text is one unit to re-translate, and nothing else moves.
- A changed source never serves its outdated translation. From the
moment a key's text changes, pulls list that key under
missinguntil the new translation exists, rather than serving the translation of a sentence you no longer say. If you push a key back to an earlier text, its earlier translations are still there and come back at once, free. - Committed in chunks. Large pushes are written in chunks of 500; a failed request can be retried as-is (idempotence makes the retry safe), and a push that stored nothing never answers 200.
- Two roads, chosen by size unless you choose. When a push leaves 30
or fewer strings pending for a locale, translation runs synchronously
inside the push and the response says
{"translated": n}for that locale: an edited label is served seconds later, and the next pull has it. Larger pushes queue to the economy road and the response says{"queued": n}; those usually serve within about ten minutes, and the wait is dominated by the provider rather than by how much you pushed (see How long the economy road takes). The size rule is a default, not a wall:?road=fastor?road=economyon the push overrides it per push (see The two roads). - Nothing stored can silently stay untranslated. If the synchronous road fails, the push falls back to the queue in the same request; if the queue submission itself fails after storing, a sweep resubmits within ten minutes with no action from you. You never need a second push to unstick a first one.
The first push of a set creates it.
Say what the set is for: ?channel=
A set can declare what its strings are FOR, so they are written in the register their destination wants:
POST /api/v1/string-sets/transactional-emails?channel=email
page (the default), email, push, sms, other. A query parameter,
so it works the same for JSON, CSV and XLIFF bodies. Send it once, when
the set is created or on any later push; leave it off and the set keeps
what it had. We never guess it from your key names: email.welcome.subject
looks obvious and would be wrong for somebody.
It matters because the same sentence wants different writing in different places. A notification speaks in the imperative. An email body is written out the way a person writes to a customer. A page label is a label. One instruction cannot serve all three, and until you tell us, they all get the same one.
channel appears in the push response only when it is something other than
page, so you can see what we understood.
One set per channel, not one set per page. Sets are how you tell us what a string is for; your key names already say where it lives, and we do not need a set to read them. If you are weighing a reorganisation, the question to ask is whether a group would have a different CHANNEL, not whether it is a different screen.
Start with one language
Before the walkthrough, the one piece of advice that saves the most money and rework: enable a single target language, push your real catalogue into it, and read the result before you enable the rest.
Credit counts translated words, so languages multiply. Ten thousand words into one language is ten thousand credits; into five it is fifty thousand, spent before you have seen a single translated string. Everything you would learn from five languages (whether your placeholders survive, whether the register is right, whether your glossary and brand voice are doing what you expect, whether your keys carry enough context) you learn from one, at a fifth of the cost.
Adding the other languages afterwards costs exactly what it would have cost on day one. There is no discount for enabling them all at once and no penalty for adding them later, so there is nothing to weigh against doing this.
Enable languages from the dashboard, or with
the locales endpoint. The rest of this
walkthrough uses a fresh workspace whose source locale is en and whose one
enabled target is fr.
Then a dry run: ?dry_run=true
Do this before your first real push. It validates the request exactly as a push does, tells you what the push would do, and stores nothing: no set is created, no string is stored, neither road is called, and no credit is consumed.
The reason to run it is the placeholders block, which is our reading of
your strings handed back to you. Most integration surprises are a
disagreement about what a placeholder IS, and this is where you find out:
if you send {{user_name}} or an ICU plural you get a 422 here, with the
offending keys named, rather than discovering a fortnight later that the
shape you standardised on was never one we track. Where the push is accepted,
the block is the positive half of the same answer: every token we read, so
you can compare it against what you meant.
POST /api/v1/string-sets/checkout?dry_run=true
Authorization: Bearer utk_EXAMPLE_A
Content-Type: application/json
{
"strings": {
"checkout.title": "Your cart",
"checkout.add": "Add to cart",
"checkout.greeting": "Welcome back, {first name}! You have {points} points."
}
}
<!-- example:response id=push-dry-run status=200 -->
{
"ok": true,
"dryRun": true,
"set": "checkout",
"received": 3,
"created": 3,
"changed": 0,
"unchanged": 0,
"translation": { "fr": { "translated": 3 } },
"placeholders": {
"strings": 1,
"braceTokens": 2,
"markers": 0,
"tokens": ["{first name}", "{points}"],
"unreadableMarkers": [],
"sample": [
{
"key": "checkout.greeting",
"tokens": ["{first name}", "{points}"],
"markers": 0
}
]
}
}
The field names are the ones a real push returns, so you can wire your
client against this response and change nothing when you go live. dryRun
is what marks the numbers as a projection, and one of them can move:
translated means "small enough for the synchronous road", not a promise
about which road runs, because a synchronous attempt that fails falls back
to the queue and a dry run does not call either.
Reading it: created: 3 says all three keys are new to us. The inventory
says one of the three carries anything we track, two {brace} tokens and no
markup markers, and names them. unreadableMarkers would list keys whose
marker structure we could not parse, which is worth acting on because a
source we cannot read is one whose translation we cannot validate.
true is the only value that runs a dry run. false is the same as omitting
the parameter, and anything else is a 400 rather than a real push, because a
client that sent ?dry_run=1 and meant it must never have its catalogue
stored on the strength of a typo.
The first real push
Same three strings, this time for real. Note created: 3 again: the dry run
above stored nothing, so nothing about this push has changed.
POST /api/v1/string-sets/checkout
Authorization: Bearer utk_EXAMPLE_A
Content-Type: application/json
{
"strings": {
"checkout.title": "Your cart",
"checkout.add": "Add to cart",
"checkout.greeting": "Welcome back, {first name}! You have {points} points."
}
}
<!-- example:response id=push-first status=200 -->
{
"ok": true,
"set": "checkout",
"received": 3,
"created": 3,
"changed": 0,
"unchanged": 0,
"translation": { "fr": { "translated": 3 } }
}
Three strings is under the synchronous threshold, so they were translated and published inside the request: the very next pull serves them.
Pushing the same three again does nothing and costs nothing:
<!-- example:request id=push-idempotent -->POST /api/v1/string-sets/checkout
Authorization: Bearer utk_EXAMPLE_A
Content-Type: application/json
{
"strings": {
"checkout.title": "Your cart",
"checkout.add": "Add to cart",
"checkout.greeting": "Welcome back, {first name}! You have {points} points."
}
}
<!-- example:response id=push-idempotent status=200 -->
{
"ok": true,
"set": "checkout",
"received": 3,
"created": 0,
"changed": 0,
"unchanged": 3,
"translation": {}
}
A malformed placeholder is refused at push, naming the key and the text around the problem, and nothing from that request is stored:
<!-- example:request id=push-bad-braces -->POST /api/v1/string-sets/checkout
Authorization: Bearer utk_EXAMPLE_A
Content-Type: application/json
{ "strings": { "checkout.broken": "Hello {first name, welcome back" } }
<!-- example:response id=push-bad-braces status=422 -->
{
"error": {
"code": "invalid_placeholders",
"message": "1 string(s) carry malformed placeholders and nothing from this request was stored.",
"keys": [ { "key": "checkout.broken", "at": "Hello {first name, welcome back" } ]
}
}
CSV and XLIFF, executed
The same three-string workspace, pushed to a second set as CSV:
<!-- example:request id=push-csv -->POST /api/v1/string-sets/catalog
Authorization: Bearer utk_EXAMPLE_A
Content-Type: text/csv
key,text
catalog.name,Suction cup darts
catalog.tagline,"Safe for walls, doors and {surface}"
<!-- example:response id=push-csv status=200 -->
{
"ok": true,
"set": "catalog",
"received": 2,
"created": 2,
"changed": 0,
"unchanged": 0,
"translation": { "fr": { "translated": 2 } }
}
And one unit as XLIFF, entities decoded on the way in:
<!-- example:request id=push-xliff -->POST /api/v1/string-sets/catalog
Authorization: Bearer utk_EXAMPLE_A
Content-Type: application/xml
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext">
<body>
<trans-unit id="catalog.cta"><source>Throw & stick</source></trans-unit>
</body>
</file>
</xliff>
<!-- example:response id=push-xliff status=200 -->
{
"ok": true,
"set": "catalog",
"received": 1,
"created": 1,
"changed": 0,
"unchanged": 0,
"translation": { "fr": { "translated": 1 } }
}
Pull translations
GET /api/v1/string-sets/{set}/{locale}
The response contains, for every key in the set, exactly one of:
- an entry in
strings: a translation that is published and whose placeholders reconcile with the current source. This is what you render. - an entry in
missing: no served translation exists yet (never pushed through translation, or the source changed and the new translation has not arrived). - an entry in
unreviewed: a translation exists but has not yet been published, or is flagged for review. Not served by default. - an entry in
rejected: a translation exists but its tags do not reconcile with the source, so it is never served. The entry carries areason:placeholdersmeans a{brace}token was lost, added or renamed, andmissingandunexpectedname them;markupmeans the numbered markers do not match, and those two lists are empty, because a marker mismatch is a structural difference rather than a set of tokens to name.
Nothing is silently absent: strings, missing, unreviewed and
rejected together always cover every key in the set, so you can fall back
to your authoring language per key, or fall back whole when the status
endpoint's coverage is below your bar.
Right after the first push, the synchronous road has already served it:
<!-- example:request id=pull-after -->GET /api/v1/string-sets/checkout/fr
Authorization: Bearer utk_EXAMPLE_A
<!-- example:response id=pull-after status=200 -->
{
"set": "checkout",
"locale": "fr",
"sourceLocale": "en",
"etag": "bd445795695a57fe21e74d6fc4e6fc2c6fb4faa8f5dbe4b6c363d4e4b2484527",
"strings": {
"checkout.add": "Ajouter au panier",
"checkout.greeting": "Content de vous revoir, {first name} ! Vous avez {points} points.",
"checkout.title": "Votre panier"
},
"missing": [],
"unreviewed": [],
"rejected": []
}
Note the placeholders: {first name} and {points} survive translation
byte-identically, in whatever order the target grammar needs them.
ETags: pull only when something changed
Every pull response carries an etag (also as the ETag header) computed
from the set's visible content for that locale: the keys, their source
texts, and what would be served. Send it back as If-None-Match and an
unchanged set answers 304 with no body:
GET /api/v1/string-sets/checkout/fr
Authorization: Bearer utk_EXAMPLE_A
If-None-Match: "bd445795695a57fe21e74d6fc4e6fc2c6fb4faa8f5dbe4b6c363d4e4b2484527"
<!-- example:response id=pull-conditional status=304 -->
What the 304 saves is the body and your parse of it, which on a large set
is most of what you were paying for. It is honest about what it does not
save: the etag is computed from the same pass that would have built the
answer, so a conditional pull costs us what an unconditional one costs. We
would rather say that than let you plan around a saving you do not get.
The recommended pattern: poll status, pull when it changes
Poll GET /api/v1/string-sets/{set}/status on your own schedule, compare the
per locale etag against the one you last pulled, and pull only the locales
whose etag moved. One status call covers every locale at once, so this is one
request per poll however many languages you run.
// Your build, or a worker, or a cron. Keep `known` wherever you keep state.
const known = {}; // locale -> etag last pulled
async function refresh(set) {
const res = await fetch(`${BASE}/string-sets/${set}/status`, { headers });
const { locales } = await res.json();
const changed = Object.entries(locales)
.filter(([locale, info]) => info.etag !== known[locale]);
for (const [locale, info] of changed) {
const pull = await fetch(`${BASE}/string-sets/${set}/${locale}`, { headers });
if (pull.status === 304) continue; // belt and braces
await write(locale, (await pull.json()).strings);
known[locale] = info.etag;
}
return changed.map(([locale]) => locale);
}
Send If-None-Match on the pull as well if it is easy for you. It is not a
substitute for the status check: the status call is what lets you skip the
pull entirely, and skipping the request is the only saving that is real on
both sides.
A changed source falls back, never lies
Change one string's text and push again; exactly one unit re-translates
and nothing else moves. On the synchronous road the response would say
{"translated": 1} and the story would already be over, so for this part
of the walkthrough the test harness forces the QUEUE road (the visible
harness:sync-off comment in this file's source), which is also exactly
what a push larger than the threshold does:
POST /api/v1/string-sets/checkout
Authorization: Bearer utk_EXAMPLE_A
Content-Type: application/json
{ "strings": { "checkout.title": "Your shopping cart" } }
<!-- example:response id=push-change status=200 -->
{
"ok": true,
"set": "checkout",
"received": 1,
"created": 0,
"changed": 1,
"unchanged": 0,
"translation": { "fr": { "queued": 1 } }
}
Until the new translation arrives, that key is missing (its OLD
translation is not served, because it translates a sentence you no longer
say), and the other two keys are untouched:
GET /api/v1/string-sets/checkout/fr
Authorization: Bearer utk_EXAMPLE_A
<!-- example:response id=pull-stale status=200 -->
{
"set": "checkout",
"locale": "fr",
"sourceLocale": "en",
"etag": "4f711ae3cf4b5c7970b560453c065a7c1875e1a32cea9e4a6422d153d8ad9242",
"strings": {
"checkout.add": "Ajouter au panier",
"checkout.greeting": "Content de vous revoir, {first name} ! Vous avez {points} points."
},
"missing": [ "checkout.title" ],
"unreviewed": [],
"rejected": []
}
An unknown locale answers 404 and names what is enabled:
<!-- example:request id=pull-unknown-locale -->GET /api/v1/string-sets/checkout/de
Authorization: Bearer utk_EXAMPLE_A
<!-- example:response id=pull-unknown-locale status=404 -->
{
"error": {
"code": "unknown_locale",
"message": "de is not an enabled locale for this workspace.",
"locales": [ "fr" ]
}
}
Removing a set
DELETE /api/v1/string-sets/{set}
Removes the set and its keys. It answers what it actually removed:
{ "set": "scratch", "deleted": true, "keysRemoved": 2156, "translationsKept": true }
Your translations are kept, on purpose. They are keyed by content rather than by set, and the same text can be referenced from several sets, so a delete that removed them could take text another set is still serving. It also means removing a set costs nothing and re-creating it costs nothing: push the same strings again and they are already translated.
A set that does not exist answers 404. Nothing else is affected: your other
sets, your locales and your keys are untouched.
Status
GET /api/v1/string-sets/{set}/status
Per enabled locale: how many keys are served, unreviewed, rejected, and
missing, plus translating (how many of the missing have a translation
in flight) and the same etag the pull for that locale would carry, so
one status call tells you whether ANY locale needs a fresh pull.
served, unreviewed, rejected and missing always sum to keys;
translating counts a subset of missing.
Continuing the walkthrough, the changed string is queued and status shows work in flight; the etag equals the stale pull's etag, because nothing visible has changed yet:
<!-- example:request id=status -->GET /api/v1/string-sets/checkout/status
Authorization: Bearer utk_EXAMPLE_A
<!-- example:response id=status status=200 -->
{
"set": "checkout",
"sourceLocale": "en",
"keys": 3,
"locales": {
"fr": {
"served": 2,
"unreviewed": 0,
"rejected": 0,
"missing": 1,
"translating": 1,
"etag": "4f711ae3cf4b5c7970b560453c065a7c1875e1a32cea9e4a6422d153d8ad9242"
}
}
}
The waiting rule this gives you: missing with translating above zero
means the pipeline is working and the next pulls will fill in; missing
with translating at zero self-heals within ten minutes (a sweep
resubmits anything stored and unqueued), and staying in that state past
ten minutes is worth reporting to us.
Once the queue lands (typically two to ten minutes), the pull serves the new translation and the etag moves:
<!-- example:request id=pull-retranslated -->GET /api/v1/string-sets/checkout/fr
Authorization: Bearer utk_EXAMPLE_A
<!-- example:response id=pull-retranslated status=200 -->
{
"set": "checkout",
"locale": "fr",
"sourceLocale": "en",
"etag": "334361a205f1332b9907fee329417ceb6a3a0ac0a6e9077333bc3e4590ae4c72",
"strings": {
"checkout.add": "Ajouter au panier",
"checkout.greeting": "Content de vous revoir, {first name} ! Vous avez {points} points.",
"checkout.title": "Votre panier d'achat"
},
"missing": [],
"unreviewed": [],
"rejected": []
}
<!-- harness:sync-on -->
Publishing, and the optional review hold
Translations are published as they are produced. A push translates and
the result becomes pullable, on both roads, with nobody asked. If you want
to gate what your users see, gate it in your own application: you hold the
locale we hand you, and deciding when to render it is one if in your
code, which no setting of ours can do better.
That is the default and it is what we recommend. We translate everything and make it available; a translation that is not yet perfect beats showing somebody the wrong language.
There is an optional hold for review, a workspace setting (not a
per-push flag) you can turn on from Translate settings in the dashboard.
While it is on, translations still happen on both roads but stop at
unreviewed, and a person publishes them from the translation dashboard.
It is a workflow for teams who want one, not a safety net we operate on
your behalf: it is off unless you turn it on, and turning it on withholds
work you have already paid for until somebody gets to it.
The examples below execute the held behaviour, so you can see exactly what a held workspace answers.
<!-- harness:hold state=on -->With the hold turned on, a small push still answers translated, with
held saying why it will not be served yet:
POST /api/v1/string-sets/emails
Authorization: Bearer utk_EXAMPLE_A
Content-Type: application/json
{ "strings": { "emails.mission.subject": "Your first mission, {first name}" } }
<!-- example:response id=push-held status=200 -->
{
"ok": true,
"set": "emails",
"received": 1,
"created": 1,
"changed": 0,
"unchanged": 0,
"translation": { "fr": { "translated": 1, "held": true } }
}
The translation exists, and while the hold is on the default pull leaves it
in unreviewed rather than in strings:
GET /api/v1/string-sets/emails/fr
Authorization: Bearer utk_EXAMPLE_A
<!-- example:response id=pull-held status=200 -->
{
"set": "emails",
"locale": "fr",
"sourceLocale": "en",
"etag": "cfbdf34c37fbf96cdc6cae3ddffae30c98835781743746651384ac1be9c01b90",
"strings": {},
"missing": [],
"unreviewed": [ "emails.mission.subject" ],
"rejected": []
}
Somebody publishes in the translation dashboard (the harness:publish
step in this file's source stands in for that click), and the pull serves
it:
GET /api/v1/string-sets/emails/fr
Authorization: Bearer utk_EXAMPLE_A
<!-- example:response id=pull-held-approved status=200 -->
{
"set": "emails",
"locale": "fr",
"sourceLocale": "en",
"etag": "60c9772d933d9ce7a5e8854753b14676b76fd1309750ea806d0865a3e94fb625",
"strings": { "emails.mission.subject": "Votre première mission, {first name}" },
"missing": [],
"unreviewed": [],
"rejected": []
}
<!-- harness:hold state=off -->
Where review happens, if you want it: in our translation dashboard. Your reviewers get accounts there, invited with a publisher role scoped to exactly the languages they review (an invite takes minutes; scoping and expiry are built in), they see source and translation side by side, edit inline with placeholder validation at the keystroke, and the publish button releases a locale. Editing and publishing work whether or not the hold is on, so you can correct live content without holding anything back first. A review-and-publish endpoint on this API (pull drafts, approve by key) is designed and costed for a later stage.
The two roads
Every translation runs on one of two roads, and you can always tell which
one ran from the push response (translated against queued).
The rule when you say nothing: a push leaving 30 or fewer strings pending for a locale runs on the FAST road; anything larger queues to the ECONOMY road. Size correlates with intent (an edited label wants seconds, a catalogue wants cheapness), but it is a guess, so it is overridable.
What each road is, measured live against production rather than hoped:
| road | how it runs | what it costs you |
|---|---|---|
| fast | synchronously inside the push request, then published | 1 credit per translated word |
| economy | submitted to a provider batch, settled by a ten-minute cycle, then published | half a credit per translated word |
So the roads differ in two things: latency, and half your credit. An edited label wants the fast road because somebody is waiting for it. A catalogue wants the economy road because nobody is, and it costs half as much.
Your usage screen shows the saving as its own figure rather than folding it into the total, so you can see what the economy road bought you.
How long the economy road takes, and why it is not a rate
It is tempting to quote seconds per thousand strings. We did, and the first customer's first push fell outside the range, because the shape is not a rate. What a push actually waits for is:
end to end = per round: (provider time + wait for the next settle tick)
x number of rounds
Measured, with the provider's own timestamps:
| stage | what happens | measured |
|---|---|---|
| push returns | strings stored, batch submitted | 2 to 5 seconds, any size |
| provider time | your strings run at the provider, packed 30 to a request | 113s for 30 strings, 180s for 961, 197s for 820, 199s for 892, 323s for 1,000. NOT proportional to size, and it is now the dominant term |
| settle wait | we settle as soon as the batch ends: the push that submitted it watches it directly, and a route runs every two minutes behind that | 13s and 115s in the latest run. Before those two mechanisms existed it was 0 to 600 seconds, and measured 484s, 527s, 528s |
| publish | inside the same settle | seconds |
A rate per thousand strings is still not honest, and here is the evidence. In one run, two batches of 1,000 and 961 strings were submitted two seconds apart. They took 323 seconds and 180 seconds at the provider. Nearly the same size, in the same minute, on the same account: a rate per thousand would have predicted the same number for both. What we removed was OUR quantisation; what remains is the provider's own variance, which is also not a rate.
What is honest to design against, from a 1,961-string catalogue pushed as two requests (the 1,000-key cap):
- the first translations serve at about 3 to 4 minutes (measured 208s)
- everything serves by about 8 minutes (measured 455s)
- the same catalogue before the settle changes: 726s to 98 percent and 1,331s to the last string
A partial failure no longer costs a round. When a packed request comes back unparseable, the settle retries that pack immediately rather than letting the strings wait for the next sweep. In the first customer's run, before this existed, 30 of 892 strings failed that way and their retry round added 597s: 45 percent of their total for 1.7 percent of their catalogue.
Keys are not units of work. That same push carried 1,961 keys holding 1,717 distinct source texts: identical text is translated once, so 12 percent of it cost nothing and a rate per key would have been measuring the wrong denominator before the clock even started.
So: expect the first pull to be worth attempting about four minutes
after a push, and to have everything within about ten. Because the
dominant term is the provider's and it varies, use status (its
translating count) rather than a timer, or set a webhook and be told.
The override, per push and never per project, because the same project
wants both on different days: POST /api/v1/string-sets/{set}?road=fast
or ?road=economy, any body format. road=economy on a small push skips
the synchronous road (useful when you push from CI and nobody is
waiting). road=fast forces the synchronous road, with a bound: at most
300 strings per push, refused up front with a 400 naming the bound
before anything is stored, so a client cannot accidentally run a whole
catalogue synchronously in one request. If a fast push meets a
pre-existing untranslated backlog beyond the bound, it falls back to the
queue and the response says so by answering queued.
The walkthrough's small pushes above all ran the fast road by the size rule; here is the same shape forced onto the economy road:
<!-- example:request id=push-road-economy -->POST /api/v1/string-sets/roadtest?road=economy
Authorization: Bearer utk_EXAMPLE_A
Content-Type: application/json
{
"strings": {
"shipping.tomorrow": "Ships tomorrow",
"shipping.returns": "Free returns"
}
}
<!-- example:response id=push-road-economy status=200 -->
{
"ok": true,
"set": "roadtest",
"received": 2,
"created": 2,
"changed": 0,
"unchanged": 0,
"translation": { "fr": { "queued": 2 } }
}
What translation costs you
Credit counts TRANSLATED words, not source words. This is the single number to hold on to, because it is the one that decides what an integration costs: ten thousand words into five languages consumes fifty thousand credits, not ten thousand.
The words are counted on the string you push (placeholder tokens included, markup markers not) and charged once per target locale. Only work the machine actually does consumes them:
- A NEW or CHANGED string consumes its source words once per target locale, when it is translated. Five locales, five times.
- An unchanged string consumes nothing, however often you push it: a full
re-push of an unchanged catalogue is free, produces no ledger entry at
all, and the
push-idempotentexample above executes exactly that ("translation": {}). - Manual edits, reviews, publishing, pulling, and re-pulling never consume words.
Confirmed from the live ledger rather than from the code's intentions: the thousand-string drill billed 11,300 source words once; re-pushing all 1,000 with one string changed billed 11 words (the one string); re-pushing all 1,000 unchanged wrote no ledger row at all.
Where to see what you have used
In the dashboard, under Translate then Usage. There is no usage endpoint and there is not going to be one: your subscription and its consumption are managed in the same place everyone else manages theirs, and building a second answer would give you two numbers to reconcile.
That screen shows, for the current period and for previous ones: words translated, the share of your allowance used, and a breakdown per language and per day. For an API workspace it shows no page view figures at all, because we serve you no pages.
If your application needs to react to consumption rather than a person reading it, tell us what you would do with the number and we will look at it again. Asking for a total is easy to build and rarely what anybody actually wanted.
Running out of credit never blanks your application
When an account's word credit is exhausted we stop translating NEW text. We do not stop serving text that is already translated. A pull of a locale you have already published returns exactly what it returned yesterday, for as long as the account exists, whether or not there is any credit left and whether or not a card has expired. New or changed strings simply stay untranslated until there is credit again, and fall back to your source the way any untranslated string does.
This is the same behaviour the proxy has, and on the pull road it is
structural rather than promised: the entire v1 surface (app/api/v1/** and
lib/api/**) imports nothing from the billing code and calls no meter, so
there is no branch in a pull that could consult a balance.
tests/api-plan-stacking.test.ts fails if that ever stops being true, and
was proved to fail by adding the import.
Locales: your list is authoritative
GET /api/v1/locales
PUT /api/v1/locales
Two locale lists drift, and a member gets a half-translated product; the
answer is that there is ONE list and it is yours. PUT the full list of
target locales your product serves, and the diff against what is enabled
does the rest. Re-sending the same list is always safe: both sides
converge on it.
GET /api/v1/locales
Authorization: Bearer utk_EXAMPLE_A
<!-- example:response id=locales status=200 -->
{ "source": "en", "targets": [ "fr" ], "restorable": [] }
Adding a locale enables it, seeds every existing set for it, and starts
translation on the same road split a push uses (the counts arrive in
translation exactly as they do there):
PUT /api/v1/locales
Authorization: Bearer utk_EXAMPLE_A
Content-Type: application/json
{ "targets": [ "fr", "de" ] }
<!-- example:response id=set-locales-add status=200 -->
{
"source": "en",
"targets": [ "de", "fr" ],
"added": [ "de" ],
"restored": [],
"removed": [],
"translation": { "de": { "translated": 9 } }
}
Removing a locale stops serving and translating it IMMEDIATELY (pulls
answer unknown_locale), and the translations we hold for it are KEPT
under a grace window. Removing a language never deletes work: that rule
predates this API and cost a merchant thirteen languages to learn. A hard
delete only ever happens by an explicit human approval after the window,
never by this endpoint:
PUT /api/v1/locales
Authorization: Bearer utk_EXAMPLE_A
Content-Type: application/json
{ "targets": [ "fr" ] }
<!-- example:response id=set-locales-remove status=200 -->
{
"source": "en",
"targets": [ "fr" ],
"added": [],
"restored": [],
"removed": [ { "locale": "de", "keptTranslations": 9, "restoreDays": 30 } ],
"translation": {}
}
The removed locale shows as restorable:
<!-- example:request id=locales-after-remove -->GET /api/v1/locales
Authorization: Bearer utk_EXAMPLE_A
<!-- example:response id=locales-after-remove status=200 -->
{ "source": "en", "targets": [ "fr" ], "restorable": [ "de" ] }
And putting it back inside the window restores everything instantly and free: nothing re-translates, because nothing was deleted:
<!-- example:request id=set-locales-restore -->PUT /api/v1/locales
Authorization: Bearer utk_EXAMPLE_A
Content-Type: application/json
{ "targets": [ "fr", "de" ] }
<!-- example:response id=set-locales-restore status=200 -->
{
"source": "en",
"targets": [ "de", "fr" ],
"added": [],
"restored": [ "de" ],
"removed": [],
"translation": {}
}
An empty targets list is refused (400): it would remove every locale,
which is a decision, not a diff. A code outside the translation catalogue,
or your own source locale, is refused naming the code. A list longer than 30
target locales is refused too: since this endpoint replaces the whole list,
30 is the ceiling on a workspace's target languages.
Webhooks: completion without polling
GET /api/v1/webhook
PUT /api/v1/webhook
DELETE /api/v1/webhook
Register one https endpoint per workspace and CI stops polling status:
whenever a QUEUED batch finishes translating (synchronous pushes already
answer in their response), we POST a translation.settled event. Payloads
carry counts and locale codes and never translated content, so the
endpoint learns nothing a leak could spend.
PUT /api/v1/webhook
Authorization: Bearer utk_EXAMPLE_A
Content-Type: application/json
{ "url": "https://ci.example.com/hooks/translations" }
<!-- example:response id=webhook-register status=200 -->
{
"url": "https://ci.example.com/hooks/translations",
"secret": "whsec_EXAMPLE_SIGNING_SECRET"
}
The secret signs every delivery and is shown ONCE, here; store it like a
password. Re-registering (another PUT) rotates it.
GET /api/v1/webhook
Authorization: Bearer utk_EXAMPLE_A
<!-- example:response id=webhook-get status=200 -->
{ "url": "https://ci.example.com/hooks/translations" }
<!-- example:request id=webhook-delete -->
DELETE /api/v1/webhook
Authorization: Bearer utk_EXAMPLE_A
<!-- example:response id=webhook-delete status=200 -->
{ "url": null }
A delivery looks like this (a sample, not executed; the pipeline produces it live):
{ "id": 42, "attempt": 1, "event": "translation.settled", "locale": "fr", "translated": 1000 }
with headers x-ultim-timestamp (unix seconds) and x-ultim-signature
(v1= plus the hex HMAC-SHA256 of timestamp.body under your secret).
Verify the signature, check the timestamp is recent, respond with any 2xx
quickly, then pull with your etags: the webhook is the nudge, the pull is
the truth.
When your endpoint is down. Delivery is at-least-once and may repeat. A failed delivery retries on a growing backoff (one minute up to daily) for about three days across ten attempts; after that it is marked exhausted and kept, never silently dropped, and we see it loudly on our side. Order between deliveries is not guaranteed; the payload's counts are informative, the status endpoint is authoritative.
Isolation, revocation, limits: executed, not asserted
A key from another workspace cannot see your sets. This request uses a valid key belonging to a DIFFERENT workspace against the set built above, and the set is simply not there for it:
<!-- example:request id=isolation -->GET /api/v1/string-sets/checkout/fr
Authorization: Bearer utk_EXAMPLE_B
<!-- example:response id=isolation status=404 -->
{ "error": { "code": "unknown_set", "message": "No string set named checkout." } }
<!-- harness:revoke key=utk_EXAMPLE_B -->
A revoked key stops working on its next request:
<!-- example:request id=revoked -->GET /api/v1/locales
Authorization: Bearer utk_EXAMPLE_B
<!-- example:response id=revoked status=401 -->
{ "error": { "code": "unauthorized", "message": "The API key is unknown or revoked." } }
<!-- harness:exhaust-rate-limit key=utk_EXAMPLE_A -->
Over the per-minute limit, the answer is a documented 429, not an odd failure:
<!-- example:request id=rate-limited -->GET /api/v1/locales
Authorization: Bearer utk_EXAMPLE_A
<!-- example:response id=rate-limited status=429 -->
{
"error": {
"code": "rate_limited",
"message": "Over the limit of 60 requests per minute for this key.",
"retryAfterSeconds": 60
}
}
Integrating a product: the settled answers
These are recommendations rather than executed examples (see How this document is tested), settled now because they are cheap with one client and expensive after six.
Namespacing. One workspace per product, one set per surface within it
(storefront, storefront-emails, oms). A workspace is the isolation
boundary: keys are scoped to it, so two teams cannot collide on a set name
or read each other's content even by accident, and each product's usage
and keys are its own. Suite products' workspaces sit under one ULTIM
account, which is where shared terminology will live (below). Free-form
set names within your own workspace are yours to choose; the convention
that scales is one set per deployable surface.
Source of truth. Your repository owns the SOURCE strings; this API owns the TRANSLATIONS. Push from CI on merge (idempotent, so pushing your whole catalogue on every deploy is free), and treat the translation dashboard as where translations are reviewed and edited, never where source strings are authored. There is deliberately no way to edit a source string on our side: if it is not in your repo, it does not exist.
CI behaviour. Never block a deploy on translation, and never ship a
half-translated surface silently. The documented pattern: push on merge;
at build or deploy time, pull with If-None-Match and commit the pulled
files as your last known good; when a pull fails or a locale's coverage is
below your bar (the status endpoint gives you the exact counts), ship the
last known good for that locale, or fall back to your authoring language
whole, and let the next build catch up. The missing list makes per-key
fallback exact; served / keys from status makes whole-locale fallback a
one-line decision.
Your language list drives ours. Built: see Locales: your list is authoritative. Sync it from your product's own locale settings (on save, or from CI) and there is one list, yours.
Consistency across products. Today, every workspace already gets three things applied at translation time: a mandatory brand voice prompt, a protected-substrings list, and a per-workspace glossary with do-not-translate support. What does not exist yet: terminology shared ACROSS the suite's workspaces (so the OMS and the storefront translate "Order" identically) and translation memory (the same source string translated once and reused). Both are designed and costed, the sharing boundary for both is the ACCOUNT (suite products share one; an external client's account shares nothing with anyone), and when they are built that isolation will be proved by executed tests, as everything else here is.
Sandbox
There is a sandbox workspace so you can integrate before touching your real content: ask your contact for a sandbox key. What is real there: the whole API surface, real storage, real machine translation into French (its one enabled target), real rate limits. What is not: it is shared with other integrators, nothing in it is private, and no emails are ever sent from it. Sandbox content CLEARS ITSELF: a string set untouched for seven days is removed by a daily sweep, with its keys and translations; pushing to a set keeps it alive, and your API key and webhook registration survive every clear. Do not put production content in the sandbox.
How this document is tested
Every request/response pair above marked in the source of this file with
example:request / example:response comments is executed, in order, as
one continuous story against the real route handlers by
tests/api-doc-examples.test.ts, with the database replaced by an
in-memory stand-in and the translation roads replaced by the harness steps
you can see in the file's source: the synchronous road fills in exactly the
French shown here, harness:sync-off forces the queue road for the
walkthrough's waiting chapter, harness:translate stands in for a queue
landing, harness:hold flips the workspace's review hold, and
harness:publish stands in for the operator's publish click. Status
codes, headers named in the examples, and full JSON
bodies are asserted byte-for-byte; the etag values are the
implementation's real output. If the implementation changes what any
example shows, the build fails until this file is updated.
Two things the executed examples deliberately do NOT cover, and how each is
covered instead: the real translation pipeline (the synchronous road, the
economy batches, settling, auto-publish, the self-heal sweep) and the real
HTTP stack are exercised by live drills against the sandbox
(scripts/club-client.ts and scripts/drill-api-heal.ts), which push a
thousand placeholder-bearing strings, wait for real translation, verify
every placeholder byte-identical, prove exactly one unit re-translates on
a one-string change, and prove a stored-but-never-queued string heals with
no human and no second push. Prose sections of this document (this
section, the latency numbers, the integration recommendations, the sandbox
notes, stage two) are not executed.
Stage two, so you can plan
Shipped since this section was first written, and documented above: CSV and XLIFF push, the completion webhook, synchronous translation on push (the fast road), and self-serve key management.
Still not built: deleting an individual key from a set. Removing a whole set is documented above; there is no way to remove one key from a set, and the workaround is to stop pulling it.
Nothing in stage two changes the v1 semantics above.