Reference: i18n-keyless-core, i18n-keyless-react and i18n-keyless-node 3.3.0
(repository commit b819592). Every client statement below is derived from that code, not
from the README. Every server statement (what the API accepts, answers and counts) was
verified against the official API source, i18n-keyless-saas/api-express at commit
bacc3df; section 16 lists each verified item with its source location. A self-hosted
backend (API_URL) MUST reproduce the server statements to serve an unmodified SDK.
A port (PHP, Dart, Python, Vue, Angular, plain browser, ...) conforms when it satisfies the
MUST statements of this document and replays the vectors in conformance/vectors/ green
(see conformance/README.md). The words MUST, SHOULD and MAY follow RFC 2119.
Contents
- Vocabulary
- Configuration
- HTTP transport: base URL, headers, timeout, retry, error results
- Endpoints
- Text resolution on the client
- The translate-on-miss queue
- Bulk fetch, delta cursor, ETag replay, merge
- Language switch and boot sequence
- Usage analytics
- Identity: device id, server, the
sdkheader - Storage contract
- Server rendering (SSR) rules
- The node (server) SDK differences
- Languages: the 48 codes, v2 to v3,
resolveLang, App Store slots - Known limitations of the reference implementation
- Verified against the API
| Term | Meaning |
|---|---|
| key / source text | The text as written by the developer, in the primary language. It is the translation key. |
| primary language | languages.primary: the language the source texts are written in. Any of the 48 codes. |
| supported languages | languages.supported: the codes the project translates into. Sent on every translate request. |
| current language | The language the client renders in. |
| context | Optional per-call string that disambiguates a key. Part of the storage key (key__context). |
| namespace | Optional per-call string that partitions the project's translations into independently fetched and stored slices. The reserved default is the literal string default. |
| storage key | storageKeyFor(key, context): the key of an entry in a dictionary and in the usage map. |
| dictionary | Record<storageKey, translatedText> for one language (and, on the client, one namespace). |
delta cursor (lastRefresh) |
A string the API returns with a dictionary and the client echoes as last_refresh. The client treats it as opaque. The API writes it as a Unix time in milliseconds and uses it to skip the payload (an empty map) when the client is fresher than the namespace; it never sends a partial dictionary (section 4.2). |
| origin language (UGC) | originLanguage: the language a user-written key is in, when it is not the primary language. |
device id (unique_id) |
A 16-character id generated by a device client, persisted, and sent on every request. It is what the API counts as one user for a device runtime. |
runtime (sdk) |
react-client, react-server or node: how the API counts the request (a device by its id, a server by its connection). |
| Field | Required | Default / rule (client SDK, packages/react/store.ts: init) |
|---|---|---|
API_KEY |
yes, always | Non-empty string. init throws i18n-keyless: API_KEY is required when empty, even with API_URL or custom handlers. Sent as Authorization: Bearer <API_KEY>. |
API_URL |
no | Base URL of a self-hosted backend, no trailing slash. Default https://api.i18n-keyless.com. |
languages.primary |
yes | One of the 48 codes. init throws i18n-keyless: primary is required when absent. |
languages.supported |
yes | Array of codes. initWithDefault is appended when missing from it. |
languages.fallback |
no | Defaults to primary. Used by validateLanguage when a requested language is not in supported. |
languages.initWithDefault |
no | Defaults to primary. The language used when storage holds none, or when skipCurrentLanguageHydration is true. |
languages.skipCurrentLanguageHydration |
no | false. When true the stored language is ignored at boot (the URL drives the language). |
defaultNamespace |
no | Applied when a call passes no namespace. Absent means default. |
storage |
browser: yes, server: no | An adapter (section 11). In a browser (typeof window !== "undefined") a missing storage throws. On a server it defaults to an in-memory Map adapter. |
ssr |
no | false. When true the SDK behaves as a server: read-only usage, runtime react-server, no device id. |
debug |
no | false. Console logging only. |
addMissingTranslations |
no | Forced to true. The value is never read by the reference implementation. |
handleTranslate, getAllTranslations, sendTranslationsUsage |
no | Custom handlers, section 2.2. |
onInit(lang), onSetLanguage(lang) |
no | Callbacks. onInit fires after hydration with the resolved current language; onSetLanguage fires before every language switch. |
The node SDK (packages/node/service.ts: init) has the same API_KEY, API_URL,
languages, defaultNamespace, debug, onInit and handlers (handleTranslate,
getAllTranslationsForAllLanguages, sendTranslationsUsage), no storage and no ssr.
For each network operation the SDK picks the first available row:
| Priority | Operation | Custom handler (mode 1) | Self-hosted (mode 2) | Official (mode 3) |
|---|---|---|---|---|
| 1 → 3 | translate a missing key | handleTranslate(key) |
POST <API_URL>/translate |
POST https://api.i18n-keyless.com/translate |
| 1 → 3 | fetch a language dictionary (client) | getAllTranslations() |
GET <API_URL>/translate/:lang |
GET https://api.i18n-keyless.com/translate/:lang |
| 1 → 3 | fetch all languages (node) | getAllTranslationsForAllLanguages() |
GET <API_URL>/translate/ |
GET https://api.i18n-keyless.com/translate/ |
| 1 → 3 | report usage | sendTranslationsUsage(defaultBucket) |
POST <API_URL>/translate/last-used-translations |
POST https://api.i18n-keyless.com/translate/last-used-translations |
Modes 2 and 3 differ only by the base URL. API_KEY is sent in both.
Exact handler contracts, as the reference implementation calls them:
| Handler | Called with | Must return |
|---|---|---|
handleTranslate |
(key) only. The context, namespace, languages and origin language are not passed. |
Promise<{ ok, message, data: { translation: Record<Lang, string> } }>. The client SDK ignores data; the node SDK caches data.translation. |
getAllTranslations |
() with no argument. The handler must know the language from its own state. |
Promise<I18nKeylessResponse> (section 4.2 envelope). |
getAllTranslationsForAllLanguages |
() |
Promise<I18nKeylessAllTranslationsResponse> (section 4.3 envelope). |
sendTranslationsUsage |
(usageByNamespace["default"] ?? {}): the default-namespace bucket only. |
Promise<{ ok, message }>. |
A port MAY pass more arguments to its handlers, but MUST accept handlers that ignore them.
config.API_URL || "https://api.i18n-keyless.com". The SDK concatenates paths without
normalising slashes: API_URL MUST NOT end with /.
| Header | Value | Sent by |
|---|---|---|
Content-Type |
application/json |
all requests, GET included |
Authorization |
Bearer <API_KEY> (one space) |
all |
Version |
the SDK package version, semantic version string, e.g. 3.3.0 |
all |
sdk |
react-client, react-server or node (section 10) |
all |
unique_id |
the device id (section 10) | only when sdk is react-client |
If-None-Match |
the ETag remembered for this dictionary | dictionary GETs, only when an ETag is known |
Header names are written exactly as above (HTTP treats them case-insensitively). No other header is sent. Nothing is sent as a cookie or a query parameter except what section 4 lists.
On the server side (api-express/src/middlewares/withBearerToken.ts:36) the API key is the
text after the first space of Authorization; the scheme word is not checked. A header
without a space, an unknown key, a key not yet activated and a deactivated key all answer
401. The API reads no other request header than the five above (plus Origin, for the
public demo key only).
The API reads Version only to choose the language-code dialect of its answers
(api-express/src/middlewares/version-check.ts:14-17, wireDialect):
Version header |
Dialect |
|---|---|
parses with parseInt(value, 10) to a major >= 3 (3.0.0, 3.3.0, 4.1.0-beta) |
v3: zh-Hans, cs |
major < 3 (2.6.2, 1.0.0) |
v2: cn, cz |
absent, empty, or not starting with digits (v3.0.0, latest) |
v2 |
The dialect applies to every language code the API emits: the keys of the all-languages
dictionary (section 4.3), the POST /translate response row, error messages, and it is
part of the ETag (section 4.2). The usage body format is not gated on Version: both
translationsUsageByNamespace and the legacy flat translationsUsage are accepted from any
client (section 4.4). No minimum version is enforced: the version gate in versionCheck is
commented out and the middleware only calls next().
A port MUST send a semantic version whose major is >= 3 (the placeholder $SDK_VERSION
in the vectors) so the API answers in the v3 dialect described here. Sending a Version
that starts with a letter would silently switch the port to cn/cz.
One shared fetchWithRetry serves all four operations.
| Parameter | Value |
|---|---|
| Per-attempt timeout | 10000 ms (AbortController). A timed-out attempt reports the error string timeout. |
| Attempts | 3 in total. |
| Backoff before attempt 2 / attempt 3 | 500 ms / 1500 ms. No wait after the last attempt. No jitter, no exponential growth. |
| Retried | a network error (fetch rejected), a timeout, HTTP 429, HTTP 5xx (status >= 500). |
| Not retried | every other status: 4xx other than 429, every 2xx other than 200, every 3xx other than 304. The call ends at once with { ok: false, error }. |
| Result on 200 | the parsed JSON body. When the response carries an ETag header its value is copied into the body as etag. |
| Result on 304 | { ok: true, notModified: true }. The body is not read. |
| Result on failure | { ok: false, error } where error is the status text when non-empty, else HTTP <code>, else the network error message, else timeout. |
| Never | throws. Never clears a stored dictionary. A body that fails to parse on a 200 counts as a failed attempt and is retried. |
Vectors: backoff.json, retry-decision.json.
Every 200 body is an envelope:
{ "ok": true, "data": { ... }, "error": "", "message": "" }ok: false with an error string means the operation failed even though HTTP said 200;
the client treats it like a transport failure (keeps its stored copy, logs). A non-empty
message is informational: the SDK logs console.warn("i18n-keyless: ", message) and
continues. A port MUST surface message the same way (a warning, not an error).
Server facts (api-express/src/controllers/translate.ts): the official API never sends
ok: false with a 200, and today always sends message: ""; the usage endpoint answers
without a data field (section 4.4). The client rules above still apply, so a self-hosted
backend MAY use them.
| Case | Status | Body | Source |
|---|---|---|---|
| Missing, unknown, not activated or deactivated API key | 401 |
JSON { "ok": false, "code": "SERVER_ERROR", "error": "<generic French sentence>" }: the reason is not in the body |
withBearerToken.ts:37-61, errors.ts:75-79 |
Invalid POST /translate body (zod), empty key, key longer than 2000 characters, context longer than 200, namespace longer than 200, primary language different from the project's |
400 |
JSON envelope { "ok": false, "error": "<reason>", "data": { "translation": null }, "message": "" } |
translate.ts:585-608, translate-key.ts:102-147 |
Usage POST whose primaryLanguage is not the project's (or the project has none yet) |
400 |
same envelope | translate.ts:473-485 |
| Project whose stored primary language is not a known code (legacy data) | 400 on the dictionary GETs |
envelope with empty translations |
translate.ts:306-318, 391-403 |
| AI translation of a UGC key into the primary language failed | 500 |
envelope | translate-key.ts:176-185 |
| Unhandled server error | 500 |
the same generic JSON as the 401 | errors.ts:75-79 |
| Rate limit | 429 |
a text body (Too many requests, please try again later. or a longer variant), plus X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and Retry-After headers |
translate.ts:44-50, 435-451 |
If-None-Match matches |
304 |
no body, ETag and Cache-Control headers |
translate.ts:258-273 |
Rate limits are per API key (the raw Authorization header value is the bucket; the IP
only for unauthenticated probes): dictionary GETs 20000 per 10 s; POST /translate and the
usage POST 10000 per 1 s and 10000 per 10 s. They exist to stop a runaway loop; a fleet
cold start fits.
There is no quota status: the API has no plan or credit check on the SDK routes (the
only gates are activated_at / deactivated_at on the key, which answer 401). Billing is
metered from the usage table after the fact. A client therefore never sees 402 or 403
from the official API.
Because the SDK does not read non-200 bodies (section 3.4), a port MAY ignore them too; the shapes above are given so a port that logs them knows what to expect.
Request body (I18nKeylessRequestBody); a field whose value is absent is not serialised:
| Field | Type | Rule |
|---|---|---|
key |
string | the source text, not trimmed by this layer |
context |
string? | as given |
namespace |
string? | omitted when it resolves to default |
forceTemporary |
Record<Lang, string>? |
as given, when given |
languages |
Lang[] |
languages.supported, in config order |
primaryLanguage |
Lang |
languages.primary |
originLanguage |
Lang? |
the per-call originLanguage, omitted when absent or equal to the primary |
{ "key": "8 heures", "context": "time", "namespace": "checkout", "languages": ["fr", "en", "es"], "primaryLanguage": "fr" }Server-side rules (api-express/src/controllers/translate.ts:552-564,
api-express/src/service/translate-key.ts):
key,languagesandprimaryLanguageare required; every language code MUST be one of the 48 codes orcn/cz(zodwireLang), else400 Invalid request body. The keys offorceTemporaryfollow the same rule.- An empty
keyis400. Limits:key2000 characters,context200,namespace200 (translate-key.ts:102-126). A longer value is400with a descriptiveerror. Only thecontextandnamespacecaps defend the unique index entry (Translations_identity_keyholdssource_hash, not the text). The 2000 onkeyis a quality rule, and it is not a rule against long-form content: a client that translates a blog post sends one request per Markdown block — around 1000 characters each — with the Markdown kept inside the block and the samecontext(a one-sentence summary of the document) on every block of it. See https://docs.i18n-keyless.com/docs/guides/long-form-content - An absent
namespaceand an explicit"default"are the same bucket (translate-key.ts:98). - An
originLanguageequal toprimaryLanguageis ignored (translate-key.ts:95-96). - The first
POST /translateof a project sets its primary language permanently (translate-key.ts:130-138). Every later call, and every usage POST, MUST send the sameprimaryLanguage, else400 Invalid primary language: <sent>, expected <stored>. languagesoverwrites the project's stored supported-language list on every call that translates or appliesforceTemporary(translate-key.ts:290,342). This list, plus the primary, is whatGET /translate/(section 4.3) emits. Languages already filled on the row are not re-translated; only empty cells are sent to the AI (translate-key.ts:294).forceTemporary[lang](for alangpresent inlanguages) overwrites the stored cell for that language (translate-key.ts:276-284). The value is permanent: the AI never rewrites a non-empty cell, and only a laterforceTemporary, a dashboard edit or the MCP tool changes it again. The write bumps the row'supdated_at, so the next dictionary fetch (new ETag, new namespace version) returns the override at once. The name is historical; "temporary" is not a server behaviour.
Response body (api-express/src/utils/translation-row.ts:70-108, toWireRow):
{ "ok": true, "message": "", "error": "",
"data": { "translation": {
"fr": "8 heures", "en": "8 AM", "es": "8 de la mañana", "de": null, "...": "(one key per language code except id)",
"languages": { "fr": "8 heures", "en": "8 AM", "es": "8 de la mañana", "de": null, "id": null, "...": "(all 48 codes)" },
"id": 4213, "api_key": "<API_KEY>", "namespace": "checkout", "context": "time",
"source_lang": "fr", "source_text": "8 heures", "origin_language": "",
"llm_tokens": 12, "group": "", "created_at": "2026-08-26T12:00:00.000Z", "updated_at": "2026-08-26T12:00:00.000Z", "last_used_at": "2026-08-26" } } }data.translation is the stored row, not a map of the languages translated by this
call: for a v3 client it holds every one of the 48 codes (in languages), the primary
included (its cell holds the source text); a language never translated for this row is
null. Languages outside languages may already be filled when another namespace held the
same key (translate-key.ts:204-220). A v2 client receives the 19 v2 codes only.
The flat top-level keys are the v2 shape and cannot carry Indonesian: the key id is the
numeric row id (translation-row.ts:30). A port MUST read translations from
data.translation.languages[lang], never from the flat keys (see section 15, item 9).
The client SDK uses nothing of the body except message; the store is filled by the bulk
fetch that follows (section 7). The node SDK caches the flat keys for every language it
knows (section 13).
Vectors: translate-request.json.
URL: <base>/translate/<lang><query>. <lang> is the code verbatim (zh-Hans is not
encoded). Query rules (buildDictionaryUrl):
| ETag known for (API key, lang, namespace)? | namespace is default |
namespace is ns |
|---|---|---|
| no | ?last_refresh=<cursor> |
?last_refresh=<cursor>&namespace=<encodeURIComponent(ns)> |
| yes | (no query) | ?namespace=<encodeURIComponent(ns)> |
<cursor> is written by string interpolation: a null cursor is the literal null
(?last_refresh=null), an empty cursor is empty (?last_refresh=). The client treats the
cursor as opaque.
Cursor format and semantics (api-express/src/controllers/translate.ts:247-250, 375,
404-418): the API emits lastRefresh as the current Unix time in milliseconds, as a
decimal string ("1756209600000"), or null for a project that has no primary language
yet (no key was ever translated). On a request it reads Number(last_refresh); null,
the empty string, an absent parameter and any non-numeric value all become NaN or 0
and mean "not fresh": the full dictionary is sent. The cursor is not a delta: when
last_refresh >= newest updated_at of the namespace + 600000 (a 10-minute safety buffer)
the API answers 200 with translations: {} and a new lastRefresh; otherwise it sends
the whole namespace dictionary. A response is therefore always all or nothing, which is
why the client merge (section 7.3) never removes keys. The ETag check runs before the
cursor check, so a client that sends both gets a 304 first.
<lang>: either dialect is accepted and canonicalised (cn and zh-Hans read the same
column, translate.ts:373). A code that is none of the 50 accepted spellings is not
rejected: the API answers 200 with every key of the namespace mapped to ""
(translate.ts:167-181). A port MUST NOT request a language outside section 14.1.
namespace: an empty namespace= is the default namespace (translate.ts:374). A
namespace that was never written is not an error: 200, translations: {}, ETag
W/"0-0-<dialect>-<lang>", and a normal lastRefresh.
Response (I18nKeylessResponse):
{ "ok": true, "data": { "translations": { "Bonjour": "Hello", "8 heures__time": "8 AM" }, "uniqueId": "AbCdEfGhIjKlMnOp", "lastRefresh": "1756209600000" }, "error": "", "message": "" }data.translations is keyed by storage key; a UGC row is emitted twice, under its primary
text and under its raw origin text (translate.ts:174-178). A cell never translated is the
empty string "" (the client treats it as a miss, section 5). data.uniqueId echoes the
counting key (section 10.3). data.lastRefresh is the next cursor.
Response headers on every 200: ETag: W/"<rowCount>-<newestUpdatedAtMs>-<dialect>-<lang>"
(a weak validator, translate.ts:265) and Cache-Control: private, max-age=60. The
API compares If-None-Match to its current ETag with a plain string equality (no list, no
*, no weak-comparison stripping): a port MUST echo the ETag byte for byte. On a match:
304, no body, the same ETag and Cache-Control headers. The ETag is the content
version of (API key, namespace) plus the dialect and the language: it does not name the
namespace itself, so two namespaces with the same row count and newest timestamp produce
the same string. That is harmless because the client keys its ETag map by namespace
(section 7.2) and never replays an ETag across namespaces. The Version header changes the
ETag (section 3.3). No ETag is sent for a project without a primary language.
Vectors: dictionary-request.json, dictionary-response.json.
URL: <base>/translate/<query> with the trailing slash, same query rules as 4.2. The
node SDK never stores a cursor, so without an ETag the query is ?last_refresh=.
Response (I18nKeylessAllTranslationsResponse): same envelope, with
data.translations keyed by language then storage key:
{ "ok": true, "data": { "translations": { "fr": { "Bonjour": "Bonjour" }, "en": { "Bonjour": "Hello" }, "es": { "Bonjour": "Hola" } }, "uniqueId": "srv_x1Y2z3A4b5C6d7E8f9G0hI", "lastRefresh": "1756209600000" }, "error": "", "message": "" }Server rules (api-express/src/controllers/translate.ts:192-198, 275-355):
- This route honours
namespace,last_refreshandIf-None-Matchexactly like 4.2; the ETag's language segment is the literalall(W/"<count>-<ms>-<dialect>-all"). The cursor short-circuit returns one empty map per emitted language. - The languages emitted are the project's stored supported list (the
languagesof the last translatingPOST /translate, section 4.1) plus the primary language when it is not in that list, in that order, spelled in the client's dialect. The primary bucket holds the source text of every row (and the AI translation for a UGC row). Every emitted language holds every key of the namespace; a cell never translated is"". - A project without a primary language answers one empty map per stored supported
language,
lastRefresh: null, no ETag.
Languages the SDK does not know are dropped on merge.
Request body (I18nKeylessTranslationsUsageRequestBody):
{ "primaryLanguage": "fr", "translationsUsageByNamespace": { "default": { "Bonjour": "2026-08-04", "8 heures__time": "2026-08-04" }, "checkout": { "Payer": "2026-08-05" } } }Dates are UTC calendar dates YYYY-MM-DD (new Date().toISOString().slice(0, 10)). The
default namespace is present under the literal key default. Never sent with an empty map
by the reference client.
Response: { "ok": true, "error": "", "message": "" } (no data field,
api-express/src/controllers/translate.ts:537-541).
Server rules (translate.ts:453-545):
primaryLanguageMUST equal the project's stored primary language (either dialect). Otherwise, and when the project has no primary language yet (no key ever translated), the answer is400with the envelope of section 3.6. A port that reports usage before its firstPOST /translategets that400; the reference client only reports after hydration, when the map is non-empty, so it rarely hits it.- An empty map (
translationsUsageByNamespace: {}, or a namespace with no keys) is accepted:200, nothing written. The legacy flat bodytranslationsUsage(SDK < 2.4.1) is accepted too and read as thedefaultnamespace; an empty namespace name isdefault. - Each key is split at the first
__: the first segment is the source text, the second the context, further segments are dropped (translate.ts:501-503). A source text containing__is therefore never stamped (see section 5.1). A key whose first segment is empty is skipped. - The stamp is an
updateManyon the exact (API key, primary language, namespace, source text, context) rows, in chunks of 200 keys per (namespace, date). An unknown key, an unknown namespace and a UGC key sent as its raw origin text are silent no-ops. The date string is stored verbatim; the API does not validate its format.
Vectors: usage-request.json.
Inputs: key, the store (currentLanguage, config, flat translations map) and the
options (context, namespace, unpersistedNamespace, debug, forceTemporary,
replace, originLanguage). The function is synchronous and MUST return a string.
- If
config.API_KEYis empty: throwi18n-keyless: config is not initialized. sourceLanguage=originLanguagewhen given and different from the primary, else the primary language.- If
currentLanguage === sourceLanguage:text = key. No lookup, no request, even whenforceTemporary[currentLanguage]is set. - Else:
- if
forceTemporary[currentLanguage]is set: queue a translate request (section 6), text = translations[storageKeyFor(key, context)],- if
textis missing or an empty string: queue a translate request, andtext = key. A context miss never falls back to the entry without context.
- if
- Apply
replacetotext(section 5.2) and return it.
forceTemporary never changes what is rendered: its value only travels to the API, which
stores it; the override arrives with the next dictionary fetch.
Vectors: translation-lookup.json, storage-key.json.
storageKeyFor(key, context) = context ? key + "__" + context : key. An empty context is no
context. Nothing is escaped: a key containing __ is ambiguous by design.
applyReplace(text, replace):
- every map key is a literal placeholder: the characters
. * + ? ^ $ { } ( ) | [ ] \are escaped, then all placeholders are joined with|into one global regular expression, in the map's key order; - the text is scanned once, left to right, non-overlapping; at a given position the first placeholder in map order that matches wins;
- each match is replaced by
replace[matched] || matched: an empty replacement leaves the placeholder in place; - replacement values are inserted verbatim (no
$1,$&expansion) and never re-scanned; - an absent option or an empty map returns the text unchanged.
Vectors: replace.json.
The component path (<T>, useTranslation) trims the source text (text.trim()) before
everything else and warns in development when it had surrounding whitespace. The function
path (getTranslation) and the node SDK do not trim. A port MUST trim in its component /
template path and MUST NOT trim in its imperative function.
| Rule | Value |
|---|---|
| Scope | one process-wide queue shared by every namespace and language |
| Concurrency | 30 requests in flight at most |
| Priority | every translate task is added with priority 1; higher priority runs first; ties keep insertion order |
| Task id | queueIdFor(namespace, key) = namespace + ":" + key. The context and the origin language are not part of the id (section 15) |
| Dedupe (waiting) | adding an id that is still waiting returns the waiting task's promise; nothing is added |
| Dedupe (in flight) | a per-id translating flag: a task whose id is in flight returns at once without a request; the flag is cleared when the request settles, on success and on failure |
| Skips | an empty key; a key whose storage key is present and non-empty in the flat map, unless forceTemporary[currentLanguage] is set |
| Side effect before queueing | the resolved namespace is recorded in a namespacesToFetchAfterTranslationFinished map, with unpersistedNamespace as its value (last write wins) |
| Boot gate | before the request leaves, the task awaits the device-id gate (section 10.4) |
| Errors | caught and logged; the task never rejects |
empty event |
fired when nothing is waiting and nothing is in flight; the wrapper reacts by bulk-fetching (section 7.1) |
The response of POST /translate is not merged into the client store. Only the bulk fetch
fills it.
Vectors: queue.json.
On the queue's empty event the wrapper reads and clears the recorded namespaces and, for
each { namespace, unpersisted }, calls GET /translate/<currentLanguage> for that
namespace with the namespace's own cursor (lastRefreshByNamespace[namespace] ?? null), then
merges the answer (setTranslations). Nothing is fetched for a namespace that had no miss.
- The SDK remembers the ETag of every 200 dictionary answer in an in-memory map keyed
by
etagCacheKey(apiKey, lang, namespace) = apiKey + "|" + lang + "|" + (namespace || "default"). Nothing is persisted: after a restart the first fetch is a plain 200. - With a known ETag the next request for the same key sends
If-None-Match: <etag>and dropslast_refreshfrom the URL (section 4.2), so the URL is stable for shared caches. - A 304 returns nothing to the wrapper: the stored dictionary is kept, the ETag stays.
- The node SDK keys its map by namespace only (
namespace || "default"). - The official API's ETag is weak (
W/"...", section 4.2) and is compared as an exact string. The client never parses it: it stores and echoes the header value verbatim.
Given an ok response for (namespace, unpersisted):
- flat map:
translations = { ...translations, ...data.translations }(new values win; keys never removed); - per-namespace slice:
translationsByNamespace[namespace] = { ...previous, ...data.translations }; namespacesindex gains the namespace when new;unpersistedNamespacesgains it whenunpersisted;data.uniqueIdis adopted only when the device has none (section 10.3);- if
unpersisted: rememberdata.lastRefreshin memory and stop (nothing persisted); - persist the slice under
translationsKeyFor(namespace)as JSON; when the namespace is new persist the index (persisted namespaces only) underi18n-keyless-namespaces; - if
data.lastRefreshis truthy: setlastRefreshandlastRefreshByNamespace[namespace]and persist it underlastRefreshKeyFor(namespace).
A response with ok: false or no response changes nothing.
Because the flat map is shared across languages, after a language switch a key that has no entry in the new language's dictionary keeps showing the previous language's text until the translation arrives.
validated = supported.includes(lang) ? lang : fallback. NoresolveLangis applied here:cn,czorfr-CHfall back like any unknown code.currentLanguage = validated; persist it underi18n-keyless-current-language.- Reset every cursor:
lastRefresh = null,lastRefreshByNamespace = {}, and persist the empty string underlastRefreshKeyFor(ns)for every known persisted namespace. - Known namespaces =
namespaceswhen non-empty, else["default"]. - If
validated !== primary: fetch every known namespace in parallel with a null cursor and merge each (section 7.3), persisting unless the namespace is unpersisted. Else, iforiginNamespacesis non-empty: fetch only those (their primary-language text is an AI translation, not the key).
- Validate and default the config (section 2.1).
- Hold the device-id gate (section 10.4); set the config in the store.
hydrate()from storage (section 11.3). Release the gate infinally.onInit(currentLanguage).setLanguage(currentLanguage)(not awaited).- On a device runtime only:
sendTranslationsUsage()(section 9).
Consequence: every boot in a non-primary language performs a full fetch of each known
namespace (cursor null, no ETag), because step 5 resets the cursors and the ETag map is
in-memory. The stored cursor is only used by the in-session empty refetch.
| Aspect | Client SDK (react-client) |
Node SDK |
|---|---|---|
| Recorded when | every getTranslation / <T> render, on a microtask after render |
every awaitForTranslationOrThrow / awaitForTranslationOrFallbackToOriginal call, including primary-language calls |
| Skipped for | unpersistedNamespace calls; any server runtime |
unpersistedNamespace calls |
| Key / value | storageKeyFor(key, context) → UTC date YYYY-MM-DD, under resolveNamespace(...) |
same |
| Persisted | yes, JSON under i18n-keyless-translations-usage |
no |
| Sent when | once per init, right after hydration, when the map is non-empty |
on a 10000 ms debounce after a key's date changes; the timer must not keep the process alive |
| After a successful send | the map is cleared and the storage value set to "" |
the map is never cleared (cumulative) |
| Suppressed | when typeof window === "undefined" or config.ssr === true: neither recorded nor sent |
never |
Rule as a function (isUsageReportingEnabled(runtime)): usage is active unless the runtime
is react-server. Vectors: usage-reporting.json, usage-request.json.
| Package | Condition | sdk header |
unique_id header |
|---|---|---|---|
| react, vue, angular | typeof window !== "undefined" and ssr not true |
react-client, vue-client, angular-client |
yes |
| react, vue, angular | no window, or ssr: true |
react-server, vue-server, angular-server |
no |
| browser | always | browser |
yes |
| node | always | node |
no |
| laravel port | always | laravel |
no (reports usage like node) |
| rails port | always | rails |
no (reports usage like node) |
| flutter port | always | flutter |
yes |
Rule (isServerRuntime in core, isServerRuntime in the API): node, laravel, rails
and every label ending in -server are servers. Everything else, an absent header included, is a
device. A new port picks <name>-client / <name>-server when it can run on both sides,
or a bare name otherwise.
How the API reads the header (api-express/src/middlewares/user-count.ts:24-29,
sdkRuntime):
sdk header value |
Runtime the API assumes | Counting key (user-count.ts:147-150) |
|---|---|---|
node, laravel, rails, any *-server |
server | srv_<22 chars>: a SHA-256 of the server secret, the API key and the source address (IPv6 bucketed to its /64), user-count.ts:42-47. Any unique_id sent is ignored. |
react-client, vue-client, angular-client, browser, flutter |
device | the unique_id header; when absent or empty, anon_<API_KEY>: one shared row per project, user-count.ts:67-69 |
| absent | device | same as react-client (SDKs before 3.2.0 sent no header) |
| any other string (a typo) | device | same as react-client |
The value is never validated or rejected: the only test in the API is isServerRuntime
(node, laravel, rails, or a -server suffix); everything else is a device. The value itself
is not stored: the usage table holds only the counting key it selects, and Sentry tags
carry version, appbuild, appdevice, currentroute and unique_id but not sdk
(api-express/src/index.ts:92-100; the full header set reaches Sentry only inside the
report of an unhandled 5xx, errors.ts:51-70). The counting happens in memory per (counting key) and is flushed to the
Usage table every 30 s, one row per counting key, with a translate_req counter for the
POST routes and a translations_req counter for the dictionary GETs (user-count.ts:131-163).
The counting key is also echoed as data.uniqueId on the dictionary GETs (section 10.3).
Consequences for a port:
- a device port MUST send
sdk: react-client(or a value the API does not know, which is counted the same way) and a persistedunique_id, else its whole install base is counted as one anonymous user; - a server port MUST send
sdk: nodeorsdk: react-serverand nounique_id; any other value would make the API count the server as a device, and without an id every request of that server collapses intoanon_<API_KEY>; - a runtime label for a new port (
vue,angular,browser,flutter) is therefore accepted today, but counted as a device (laravelandrailsare known servers). Before such a label is sent from a server,sdkRuntimeinuser-count.tsMUST learn it, otherwise the server is miscounted. The docs ini18n-keyless-saas/docsdo not describe the header yet.
An empty unique_id header is never sent by the reference client; the API would then
count the device under the shared anon_<API_KEY> row (it no longer mints one user per
request, user-count.ts:49-66).
- Alphabet:
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz(63 characters). - Length: 16. Random bytes from a CSPRNG when available, else any PRNG; a byte
>= 252is discarded (rejection sampling), otherwisealphabet[byte % 63]. - Valid id (
isUniqueId): a string, 1 to 64 characters, every character in0x21..0x7E. A persisted value that is not valid is replaced by a fresh id.
resolveUniqueIdForRequest(storeId): the process-level id wins; else a validstoreIdis adopted and becomes the process-level id; else a fresh id is generated and kept.- At boot (device runtime),
hydrate()readsi18n-keyless-user-idfirst, generates and persists an id when missing or invalid, and only then releases the gate. - A dictionary answer's
data.uniqueIdis adopted (and persisted) only when the device has no id yet, only on a device runtime, and only when it passesisUniqueId(packages/react/store.ts:68-79). An existing id is never replaced by a response. - What the API echoes in
data.uniqueIdis its counting key (section 10.1): the device's ownunique_idheader;anon_<API_KEY>for a device that sent none;srv_<22 chars>for anode/react-serverrequest (api-express/src/controllers/translate.ts:297,425). A port MUST NOT persist ananon_orsrv_value as its device id: the reference client cannot, because a device runtime always has an id before its first request (the boot gate, 10.4) and a server runtime never adopts one. clearI18nKeylessStoragekeepsi18n-keyless-user-id.
holdRequestsUntilUniqueIdIsKnown() returns a release function; init calls it before
hydration and releases in finally. translateKey, the dictionary fetch and the usage POST
await whenUniqueIdIsKnown() (null when nothing holds) before sending. A second hold reuses
the same gate.
Vectors: unique-id.json, usage-reporting.json.
An object with, for each operation, the first present method: read getItem / get /
getString; write setItem / set; delete delete / del / removeItem / remove.
Methods may be synchronous or return promises. Values are always strings. A failing read is
logged and yields null; a failing write is logged and rethrown; a failing delete is logged.
Writes are not awaited by the store.
| Key | Value | Cleared by clearI18nKeylessStorage |
|---|---|---|
i18n-keyless-user-id |
device id, raw | no |
i18n-keyless-current-language |
language code, raw | yes |
i18n-keyless-translations |
JSON object: the default namespace slice |
yes |
i18n-keyless-translations__<ns> |
JSON object: the <ns> slice |
yes |
i18n-keyless-last-refresh |
cursor of default, raw; "" after a language switch |
yes |
i18n-keyless-last-refresh__<ns> |
cursor of <ns>, raw |
yes |
i18n-keyless-translations-usage |
JSON object { ns: { storageKey: "YYYY-MM-DD" } }; "" after a successful usage POST |
yes |
i18n-keyless-namespaces |
JSON array of persisted namespaces | yes |
i18n-keyless-origin-namespaces |
JSON array of namespaces holding UGC keys | yes |
The namespace is not encoded in the key (i18n-keyless-translations__check out). Unpersisted
namespaces never touch storage and never enter the two indexes.
- Runtime and device id (section 10.3). Requests are held until this step is done.
- Unless a server snapshot was applied synchronously (
hydrateFromServer): readi18n-keyless-namespaces(else["default"]); for each namespace read its translations slice then its cursor; set the flat map (merged in index order), the slices, the namespaces and the cursors when at least one slice was found. - Read
i18n-keyless-origin-namespaces. - Read
i18n-keyless-translations-usage; keep it only when its values are objects (a legacy flat map with string values is discarded). - Current language: keep the server-seeded one if any; else
initWithDefaultwhenskipCurrentLanguageHydration; else the stored value; elseinitWithDefault. The stored value is not validated here (the language switch that follows validates it). - Read
i18n-keyless-last-refreshintolastRefresh.
Vectors: storage-keys.json (replayed by the wrapper packages).
- On a server (
typeof window === "undefined") or withssr: true: runtimereact-server, no device id, no usage recording, no usage POST. Translate-on-miss still works. storagedefaults to an in-memory adapter on a server.getServerTranslations(lang): returns{}for the primary language; else one fetch per language per process (GET /translate/<lang>?last_refresh=null, default namespace only, no namespace parameter), cached in a process-wide map;{}on failure.- Per-request language travels in a render-scoped context (React context or
AsyncLocalStorage), never in the process-wide store. Seedocs/SSR.md. - A server snapshot applied on the client before the first render (
hydrateFromServer) is authoritative: hydration keeps its language and merges storage on top of it.
| Aspect | Node SDK |
|---|---|
| Store | in memory: translations[lang][storageKey], one flat map per language, no namespace dimension |
| Boot | init awaits GET /translate/ for config.defaultNamespace, merges every known language; never stores the cursor |
| Resolution | awaitForTranslationOrThrow(key, lang, options): async. Empty key returns "". Primary (or origin) language returns applyReplace(key) unless forceTemporary[lang] is set. Hit returns applyReplace(translation). Miss: handleTranslate(key) when configured, else POST /translate, then returns `applyReplace(data.translation[lang] |
| Dedupe | in-flight map keyed by namespace + ":" + storageKey + ":" + (originLanguage ?? ""), never for forceTemporary calls; plus the shared queue's empty event refetches GET /translate/ per recorded namespace |
| Cache after POST | the flat keys of data.translation are merged into the store for every known language (unknown keys and empty values dropped). The flat key id is the numeric row id, not Indonesian (section 4.1): see section 15, item 9 |
| Errors | a failed POST rejects with i18n-keyless: FATAL: awaitForTranslationOrThrow failed for key "<key>". ... and the original error as cause; an unhandled rejection terminates the process by design. awaitForTranslationOrFallbackToOriginal swallows the same error after logging it and returns the key |
| Usage | recorded on every call, flushed on a 10000 ms debounce, never cleared |
| Identity | sdk: node, no unique_id; the echoed uniqueId is ignored |
unpersistedNamespace |
no effect except that usage is not recorded |
ar bn ca zh-Hans zh-Hant hr cs da nl en en-GB fi fr fr-CA de el gu he hi hu id it ja kn ko ms ml mr no or pl pt pt-BR pa ro ru sk sl es es-MX sv ta te th tr uk ur vi
Exactly six codes carry a region or script: en-GB es-MX fr-CA pt-BR zh-Hans zh-Hant.
There is no bare zh. Any code can be the primary language.
| v2 | v3 |
|---|---|
cn |
zh-Hans |
cz |
cs |
The 17 other v2 codes (fr en nl it de es pl pt ro hu sv tr ja ru ko ar el) are unchanged.
A v3 client never sends cn or cz; resolveLang("cn") and resolveLang("cz") return
nothing, and a persisted cn falls back to languages.fallback at the next language
switch. A self-hosted backend must rename the two codes in its data.
- Normalise:
_→-, trim, lowercase. Empty → no candidates. - Candidates, most specific first, without duplicates:
- the whole tag matched case-insensitively against the 48 codes;
- if the language subtag is
zh: the script from the last subtag (cn,sg,hans→zh-Hans;tw,hk,mo,hant→zh-Hant; anything else →zh-Hans), then stop (Chinese never falls back to a bare language); - if the tag is
es-419:es-MX; - the bare language subtag matched against the 48 codes.
- Return the first candidate contained in
supported(when given), elsefallback, else nothing.
Vectors: resolve-lang.json, languages.json.
A fixed map onto App Store Connect slots (fr → fr-FR, en → en-US, pt → pt-PT,
de → de-DE, nl → nl-NL, ar → ar-SA, es → es-ES, variants and the other bare
codes unchanged). 48 distinct slots; en-AU and en-CA have no code. Vectors:
app-store-locales.json.
These are facts of 3.3.0 that a port MUST reproduce to behave identically, listed so that a future protocol revision can fix them everywhere at once.
- The queue id ignores the context and the origin language: two contexts of the same key
in the same batch produce one
POST /translate(the second context is translated on a later render).queue.jsonencodes this. handleTranslatereceives only the key;getAllTranslationsreceives nothing.- An empty replacement in
replaceleaves the placeholder in place. - Every boot in a non-primary language is a full fetch (section 8.2).
- The flat map is shared across languages (section 7.3).
- In the client path,
forceTemporaryin the primary language sends nothing; in the node SDK it does. - The
Content-Type: application/jsonheader is sent on GET requests. API_KEYis required in every mode, including custom handlers.- The node SDK reads the flat keys of the
POST /translateresponse row (packages/node/service.ts: knownLanguagesOnly). The flat keyidis the numeric row id (section 4.1), so a project that supports Indonesian caches a number undertranslations.id[key]until the next bulk fetch overwrites it. A port MUST NOT reproduce this: readdata.translation.languages[lang].
Each item below was an open question of the first revision of this document. All eleven
are settled by the API source (i18n-keyless-saas/api-express, commit bacc3df); the
verified behaviour is written in the section named, and the source location is given here.
No item remains open.
| # | Question | Verified answer | Source | Section |
|---|---|---|---|---|
| 1 | Version thresholds and dialect switch |
parseInt of the header; major >= 3 is v3, everything else (including absent or unparsable) is v2. Only the language-code dialect and the ETag depend on it; the usage body format does not; no minimum version is enforced. |
middlewares/version-check.ts:14-17, 19-49 |
3.3 |
| 2 | Accepted sdk values, unknown values |
Only node and react-server are recognised (as servers). Absent and every other value is a device: counted by unique_id, or by the shared anon_<API_KEY> row when no id is sent. Never rejected, never stored. |
middlewares/user-count.ts:24-29, 42-47, 67-69, 147-150 |
10.1 |
| 3 | lastRefresh format, null and empty |
Unix milliseconds as a decimal string, or null before the first translation. On input Number(value): null, "", absent and non-numeric all mean "send everything". Not a delta: full dictionary, or an empty map when the client is fresher than the newest change plus 10 minutes. |
controllers/translate.ts:247-250, 290, 375, 406-418 |
4.2, 1 |
| 4 | Strong or weak ETag; which routes honour If-None-Match |
Weak: W/"<count>-<newestMs>-<dialect>-<lang>" (all on the all-languages route). Both GET routes honour it, namespaced requests included; exact string comparison; 304 with no body; Cache-Control: private, max-age=60. |
controllers/translate.ts:258-273, 322, 405; __tests__/etag-cache.test.ts |
4.2, 4.3, 7.2 |
| 5 | Status codes and non-200 envelopes | 401 for every key problem (generic JSON, reason not included); 400 with the JSON envelope for validation errors; 429 with a text body and X-RateLimit-* / Retry-After headers; 500 for AI failure and unhandled errors. No quota status exists. |
middlewares/withBearerToken.ts:37-61, middlewares/errors.ts:75-79, controllers/translate.ts:44-50, 435-451, 585-608, service/translate-key.ts:102-147, 176-185 |
3.6 |
| 6 | POST /translate response contents |
The whole stored row: languages holds all 48 codes (v3 client), the primary included, null where never translated; flat keys hold the same minus id, which is the numeric row id. |
utils/translation-row.ts:30, 70-108 |
4.1 |
| 7 | forceTemporary semantics |
Overwrites the stored cell permanently (the AI never rewrites a non-empty cell); bumps updated_at, so the next dictionary fetch returns it. |
service/translate-key.ts:272-297 |
4.1 |
| 8 | data.uniqueId for server runtimes |
The counting key: srv_<22 chars> for node / react-server, anon_<API_KEY> for a device without id, else the device's own id. The client adopts it only on a device runtime that has no id. |
controllers/translate.ts:297, 425; middlewares/user-count.ts:147-151 |
10.3 |
| 9 | Key length limit | 2000 characters for the key, 200 for the context, 200 for the namespace; empty key rejected; all 400. |
service/translate-key.ts:102-126; __tests__/translate-post.test.ts:66-102 |
4.1 |
| 10 | Usage POST: empty map, unknown keys | Empty map accepted (200, no write). Unknown keys, unknown namespaces and UGC keys are silent no-ops. primaryLanguage must match the project's, else 400. Keys split at the first __. |
controllers/translate.ts:461-545 |
4.4 |
| 11 | Never-created namespace | Not an error: 200, translations: {}, ETag W/"0-0-<dialect>-<lang>". |
controllers/translate.ts:72-79, 404-430; __tests__/translate-get-lang.test.ts:174 |
4.2 |
Further server facts found during the verification and written into this document:
the primary language is set by the first POST /translate and is immutable (4.1); the
project's supported-language list is overwritten by each translating POST and drives the
all-languages payload (4.1, 4.3); an unknown <lang> on the dictionary GET answers every
key as "" (4.2); UGC rows are emitted under both their primary and their origin text
(4.2); the usage response carries error and no data (4.4); the Authorization scheme
word is not checked (3.2).