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:

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:

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": "..." } }
statuscodewhen
400invalid_requestthe body or a parameter does not parse or fails validation; message names the first problem
401unauthorizedmissing, unknown, or revoked key
404unknown_setthe set does not exist in your workspace
404unknown_localethe locale is not enabled for your workspace; the body lists locales that are
422invalid_placeholdersa pushed string carries a malformed placeholder; the body lists keys with the text around each problem
409not_deleteda delete matched nothing, so nothing was removed
429rate_limitedover either ceiling, per key or per workspace; message names which one and the body carries retryAfterSeconds
500internala fault on our side; the request did not complete
503internala 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:

What we do NOT track

Named plainly, because a guarantee is worth less when its edges are vague.

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 ) 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:

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:

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.

<!-- example:request id=push-dry-run -->
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.

<!-- example:request id=push-first -->
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 &amp; 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:

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:

<!-- example:request id=pull-conditional -->
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.

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:

<!-- harness:sync-off --> <!-- example:request id=push-change -->
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:

<!-- example:request id=pull-stale -->
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.

<!-- harness:translate locale=fr -->

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:

<!-- example:request id=push-held -->
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:

<!-- example:request id=pull-held -->
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:

<!-- harness:publish locale=fr --> <!-- example:request id=pull-held-approved -->
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:

roadhow it runswhat it costs you
fastsynchronously inside the push request, then published1 credit per translated word
economysubmitted to a provider batch, settled by a ten-minute cycle, then publishedhalf 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:

stagewhat happensmeasured
push returnsstrings stored, batch submitted2 to 5 seconds, any size
provider timeyour strings run at the provider, packed 30 to a request113s 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 waitwe settle as soon as the batch ends: the push that submitted it watches it directly, and a route runs every two minutes behind that13s and 115s in the latest run. Before those two mechanisms existed it was 0 to 600 seconds, and measured 484s, 527s, 528s
publishinside the same settleseconds

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):

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:

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.

<!-- example:request id=locales -->
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):

<!-- example:request id=set-locales-add -->
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:

<!-- example:request id=set-locales-remove -->
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.

<!-- example:request id=webhook-register -->
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.

<!-- example:request id=webhook-get -->
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.