Localization & strings
ContentFlow serves your app's text as string keys: a key such as home.welcome, its source text, and a translation per language. Keys come from your code or CI, or from a file imported in the dashboard. Translators work on them in the dashboard, and only approved translations reach apps. Any key can also be switched on as a sensor, so the app reports when that text is seen and used. This page follows one key through both flows.
- Keys are created from a server or CI job with the write key (
registerStrings, orPOST /api/v1/strings/sync), or with Localization → Import strings in the dashboard. - Translations are approved in the dashboard; apps load the catalog with
getStringsand read it witht()(useCFStringin React), and get the source text until a translation is approved. - Turn a key into a sensor under Sensors → Enable sensors: it then arrives in
smartKeys, and the SDK recordsstring_impressionandstring_interactionfor it. - Turn a sensor off in the dashboard, or with
disableStrings(POST /api/v1/strings/disable): its events are dropped, and its text keeps being served. - The most common trap: the write key is a server or CI credential. Never call
registerStringsordisableStringsfrom a shipped app.
How a key flows
A key has one source text (in the workspace's source language) and a value and a status for each other language. It is created once, translated and approved in the dashboard, then served to every app that asks for its language. Edits after that follow the same path: a changed source text sends the key's translations back to review, and the app picks up the result at its next strings refresh.
- Creates the key with its source text, or updates an existing one. Sending the same rows again changes nothing.
If this step is skipped: A new key without source text is rejected (missing_source_text) and never created. A dashboard import does the same job, but only after a second person approves it.
- In Localization in the dashboard, a saved translation is marked Needs review. Auto-translate writes drafts the same way.
- The translation becomes Approved, the only status that is delivered.
If this step is skipped: Until a translation is approved, apps get the source text for that key, and the key is listed in withheldKeys.
- The SDK asks for the current language, with the last ETag so an unchanged catalog costs a
304. - The server keeps the catalog for up to 60 seconds, so a change can take that long to be served.
- Every visible key, with its approved translation or else the source text.
- The app reads the text. A key that isn't in the catalog renders as your fallback, or as the key itself.
Create and update keys from code
Register keys from a server or a CI job, where the strings live in your code. These calls use the workspace's write key (wk_live_…, sent as X-CF-Write-Key), from Developers → Your keys. Anyone holding it can change your workspace's text, so it never goes in an app (see Security: keys). Each SDK sends the request to /api/v1/strings/sync on the API host it derives from its base URL.
// Run in CI or on your server (Node 18+), never in an app.
// `client` is your ContentFlow client (createCFClient, or the ContentFlow facade after ContentFlow.init).
// A CI job without one can send the same request with the REST tab (one curl).
// 1. Send each key with its source text (and a note for translators).
const result = await client.registerStrings(
[
{ key: 'home.welcome', defaultValue: 'Welcome back', context: 'Home screen heading' },
{ key: 'offer.apply_cta', defaultValue: 'Apply now' },
],
{ writeKey: process.env.CF_WRITE_KEY! },
);
// 2. Keys created, keys changed, keys sent but neither created nor changed.
console.log(result.registered, result.updated, result.skipped);Don't do this in a React app: registerStrings needs the write key, which must never ship in a browser bundle. Run it from a server or CI job (the JavaScript or REST tab). The SDK logs a warning when it is called in a browser.
Don't do this in a React Native app: registerStrings needs the write key, which must never ship in an app. Run it from a server or CI job (the JavaScript or REST tab). The SDK logs a warning when it is called in React Native.
// Run from a build tool or backend, never in the shipped app.
// The SDK logs a warning whenever this is called.
// 1. Send each key with its source text (and a note for translators).
let result = try await client.registerStrings(
[
StringRegistration(key: "home.welcome", defaultValue: "Welcome back", context: "Home screen heading"),
StringRegistration(key: "offer.apply_cta", defaultValue: "Apply now"),
],
writeKey: ProcessInfo.processInfo.environment["CF_WRITE_KEY"]!
)
// 2. Keys created, keys changed, keys sent but neither created nor changed.
print(result.registered, result.updated, result.skipped)// Run from a JVM CI tool or server (a client from ContentFlowClientFactory), never in an app.
// On Android the SDK logs a one-time warning when this is called.
// 1. Send each key with its source text (and a note for translators).
val result = client.extensions.registerStrings(
listOf(
StringRegistration(key = "home.welcome", defaultValue = "Welcome back", context = "Home screen heading"),
StringRegistration(key = "offer.apply_cta", defaultValue = "Apply now"),
),
writeKey = System.getenv("CF_WRITE_KEY"),
)
// 2. Keys created, keys changed, keys sent but neither created nor changed.
println("${result.registered} ${result.updated} ${result.skipped}")// The Flutter SDK needs a running Flutter app, so from CI use the REST tab.
// From an internal Flutter tool (never a store build):
final cf = ContentFlow.instance;
// 1. Send each key with its source text (and a note for translators).
final result = await cf.registerStrings([
const CFStringRegistration(key: 'home.welcome', defaultValue: 'Welcome back', context: 'Home screen heading'),
const CFStringRegistration(key: 'offer.apply_cta', defaultValue: 'Apply now'),
], writeKey: const String.fromEnvironment('CF_WRITE_KEY'));
// 2. Keys created, keys changed, keys sent but neither created nor changed.
print('${result.registered} ${result.updated} ${result.skipped}');# 1. Send each key with its source text. REST also takes namespace and translations.
curl -X POST https://api.contentflow.click/api/v1/strings/sync \
-H "X-CF-Write-Key: $CF_WRITE_KEY" -H 'Content-Type: application/json' \
-d '{"strings":[{"key":"home.welcome","sourceText":"Welcome back","namespace":"home","description":"Home screen heading","values":{"ar":"أهلاً بعودتك"}}]}'
# 2. The answer counts created, updated, unchanged and translationsWritten,
# and lists rejected keys and skippedLocales. Check the status AND "success".- What a sync does. A new key is created (in namespace
appunless you name one); an existing key gets the new source text. A row identical to what is stored is counted as unchanged and not written, so running the job on every build is safe. - Source text changes. When a sync changes a key's source text, including text someone edited in the dashboard, that key's other translations go back to Needs review, and apps get the new source text until they are approved again.
- What it never does. It never hides or deletes a key, and never turns a sensor on or off. Keys you stop sending stay in the catalog.
- Fields. The SDKs send
key, the source text and the note. Namespaces and translations (values) are sent with the REST route or a strings file. A translation sent this way is stored as approved, unless the same row also changed the source text, when it is stored for review. - Limits. Up to 2,000 rows per request; a key is at most 200 characters. The SDKs throw on any failure. With REST, a server-side failure can answer
200withsuccess: falseandcode: "STRINGS_SYNC_FAILED", so a CI step checks both.
Create keys in the dashboard
The dashboard doesn't create keys one at a time. To add or change many keys without code, import a file:
- In Localization, download Import template (CSV or JSON). It has a column for each of your workspace's languages. The format is in Strings file format, and the JSON file is the same body
strings/synctakes. - Choose Import strings and pick the file (up to 20,000 rows and 5 MB). The Review import dialog shows how many keys are new, changed, unchanged and rejected, and which languages will be skipped.
- Choose Submit for approval. Nothing changes yet: the import is approved on the Approvals page, by a workspace admin or by a teammate with approval rights other than you, and only then are the rows written, with the same rules as a sync.
A workspace holds one pending import at a time; submitting another answers 409 IMPORT_ALREADY_PENDING. Export strings writes the catalog in the same formats, so an export can be edited and imported again.
Translate, review and publish
Every language of every key has a status. Only Approved is delivered; for any other status the app gets the source text.
| Dashboard label | Status | How a translation gets here | What apps get |
|---|---|---|---|
| Approved | ok | Approve on a reviewed row; a translation sent by sync or import (when the row didn't change the source text); the source language itself | The translation |
| Needs review | review | A translation saved in the editor; an Auto-translate draft; every translation of a key whose source text changed | The source text, and the key is listed in withheldKeys |
| Missing | missing | No translation yet | The source text |
- Auto-translate drafts translations with AI for one language or all of them, never touching approved ones. If your account can't approve translations, it is sent as an approval request and nothing is written yet.
- Hide string takes a key out of delivery without deleting it, and turns its sensor off. It moves to the Hidden folder, where Restore string brings it back.
- Right-to-left is a property of the language (set per locale, and inferred for Arabic, Farsi, Hebrew and Urdu), not of each string.
Read strings in the app
Apps use the publishable SDK key. Load the catalog for the current language, then read keys with a fallback for text that isn't there yet:
// 1. Load the catalog for the current locale (start() also does this; ETag-cached).
await client.getStrings(); // or getStrings('home') for one namespace
// 2. Read a key: the delivered text, else your fallback, else the key itself.
title.textContent = client.t('home.welcome', 'Welcome back');import { useCFString } from '@contentflow/sdk/react';
function Welcome() {
// 1. The hook loads the catalog for the current locale and re-renders
// when it arrives or changes (useCFTranslations() returns the whole map).
// 2. Read a key: the delivered text, else your fallback, else the key itself.
const title = useCFString('home.welcome', 'Welcome back');
return <h1>{title}</h1>;
}import { Text } from 'react-native';
import { useCFString } from '@contentflow/sdk/react-native';
function Welcome() {
// 1. The hook loads the catalog for the current locale and re-renders
// when it arrives or changes (useCFTranslations() returns the whole map).
// 2. Read a key: the delivered text, else your fallback, else the key itself.
const title = useCFString('home.welcome', 'Welcome back');
return <Text>{title}</Text>;
}// 1. Load the catalog for the current locale.
let strings = try await client.getStrings()
// 2. Read a key with a fallback.
titleLabel.text = strings.string("home.welcome", fallback: "Welcome back")// 1. Load the catalog for the current locale.
val strings = client.getStrings()
// 2. Read a key with a fallback.
titleView.text = strings.value("home.welcome", "Welcome back")final cf = ContentFlow.instance;
// 1. Load the catalog for the current locale (a key-to-text map).
final strings = await cf.getStrings();
// 2. Read a key with a fallback.
final title = strings['home.welcome'] ?? 'Welcome back';# 1. Load the catalog for one locale (add &namespace=home for one namespace).
curl "https://api.contentflow.click/sdk/v1/strings?locale=ar" \
-H "X-CF-Key: $CF_SDK_KEY" # a content read: the key alone, no device id
# 2. Read the key from data.strings. Send the ETag back as If-None-Match: 304 means unchanged.{"success":true,"data":{
"locale":"ar",
"strings":{"home.welcome":"أهلاً بعودتك","offer.apply_cta":"Apply now"},
"smartKeys":["offer.apply_cta"],
"withheldKeys":["offer.apply_cta"],
"version":"…"}}In this answer offer.apply_cta has an Arabic translation that isn't approved yet, so it is served in the source language and listed in withheldKeys; it is also an enabled sensor (smartKeys, see below). Hidden keys are never delivered. With a namespace, both strings and smartKeys cover only that namespace.
Locales and fallback
The server picks the language from ?locale=, then the first Accept-Language tag, then the workspace's source language, then English. For each key it then serves, in order:
- the approved translation for the exact tag (
ar-sa), then for its parent (ar), never a sibling such asar-eg; tags are matched case-insensitively; - otherwise the source language's text.
The SDK never invents a translation: t() and the hooks fall back to your fallback, then to the key, only for a key the catalog doesn't contain. To offer a language picker, list the workspace's languages and switch:
// 1. The workspace's languages (code, name, native, rtl, coverage).
const { locales } = await client.getLocales();
// 2. Switch: content and strings are fetched again for Arabic.
await client.setLocale('ar');
title.textContent = client.t('home.welcome', 'Welcome back');import { useEffect, useState } from 'react';
import { useCF, useCFString, type CFLocale } from '@contentflow/sdk/react';
function LanguagePicker() {
const cf = useCF();
const [locales, setLocales] = useState<CFLocale[]>([]);
// 1. The workspace's languages (code, name, native, rtl, coverage).
useEffect(() => { cf.getLocales().then((c) => setLocales(c.locales)).catch(() => undefined); }, [cf]);
// 2. Switch: useCFString components re-render with the new catalog.
const title = useCFString('home.welcome', 'Welcome back');
return <><h1>{title}</h1>{locales.map((l) => <button key={l.code} onClick={() => cf.setLocale(l.code)}>{l.native ?? l.code}</button>)}</>;
}import { useEffect, useState } from 'react';
import { Button, Text } from 'react-native';
import { useCF, useCFString, type CFLocale } from '@contentflow/sdk/react-native';
function LanguagePicker() {
const cf = useCF();
const [locales, setLocales] = useState<CFLocale[]>([]);
// 1. The workspace's languages (code, name, native, rtl, coverage).
useEffect(() => { cf.getLocales().then((c) => setLocales(c.locales)).catch(() => undefined); }, [cf]);
// 2. Switch: useCFString components re-render with the new catalog.
const title = useCFString('home.welcome', 'Welcome back');
return <><Text>{title}</Text>{locales.map((l) => <Button key={l.code} title={l.native ?? l.code} onPress={() => cf.setLocale(l.code)} />)}</>;
}// 1. The workspace's languages (code, name, native, rtl, coverage).
let catalog = try await client.getLocales()
// 2. Switch: the SDK revalidates content and strings for Arabic.
await client.setLocale(Locale(identifier: "ar"))
let strings = try await client.getStrings()// 1. The workspace's languages (code, name, nativeName, rtl).
val locales = client.extensions.getAvailableLocales()
// 2. Switch, then load the catalog for the new language.
client.setLocale(Locale.forLanguageTag("ar"))
val strings = client.getStrings()final cf = ContentFlow.instance;
// 1. The workspace's languages (null if the catalog could not be read).
final catalog = await cf.getLocales();
// 2. Switch (re-syncs blocks), then load the strings for the new language.
await cf.setLocale('ar');
final strings = await cf.getStrings();An empty language list is a real answer; the SDKs never guess en or ar. In JavaScript, a first launch with no cache and no network can still show text from fallbackUrl (a static mirror) or bundledCatalog (a snapshot built into the app).
How changes reach the app
An approved translation, a new key, a hidden key and a sensor switched on or off all reach an app the same way: the next time it fetches the strings catalog. No live-update event is sent for a string or sensor change itself, and the server can serve a catalog up to 60 seconds old. The server folds the sensor list into the catalog's ETag, so a sensor change alone is enough to return a new catalog.
| SDK | When the catalog is fetched again |
|---|---|
| JavaScript, React, React Native | On start() and setLocale(), on every poll tick (every 30 seconds by default, pollMs) and on every live-update signal; an unchanged catalog costs a 304. The hooks re-render when it changes. |
| Swift | On setLocale, on every live-update signal, and on the periodic refresh (default every 15 minutes) while the live stream isn't connected. updates() publishes .strings when a new catalog arrives. |
| Kotlin | When the app calls getStrings() (stale-while-revalidate: the cached catalog first, then a background refresh) or extensions.fetchStrings() / refreshStrings(). Call it again when a screen opens or when updates emits. |
| Flutter | When the app calls getStrings(). The liveUpdates stream carries blocks only. |
Sensors: turn tracking on and off
Every key is dormant until it is enabled as a sensor; a dormant key is served as text and produces no events. Turning tracking on is a dashboard decision. Turning it off can be done in the dashboard or from code. In both directions the key's text is unchanged and keeps being served.
- Turn on: Sensors → Enable sensors, then the row's switch (Enable sensor: track this string) or Enable selected. The key must exist in the catalog (
404 UNKNOWN_STRINGotherwise). If your account can't enable sensors directly, the request goes to the approvals queue (Sent for approval) and the sensor stays off until it is approved. There is no SDK or write-key call to turn a sensor on. - Turn off in the dashboard: the same switch (Disable sensor: stop tracking this string). It takes effect at once, with no approval. Hiding or deleting a string also stops its tracking.
- Turn off from code:
disableStrings, orPOST /api/v1/strings/disable, with the write key, for 1 to 500 keys per call. LikeregisterStrings, it belongs in a server or CI job.
- The key's sensor is switched on. The key must already be in the catalog.
If this step is skipped: If your account can't enable sensors directly, the request waits in the approvals queue and nothing changes until it is approved.
- The app's next strings refresh (see How changes reach the app); the server's catalog can be up to 60 seconds old.
- Only enabled sensors on visible keys, within the requested namespace.
If this step is skipped: Until the app has a catalog that lists the key in smartKeys, it records nothing for it.
- Batched with the app's other events.
If this step is skipped: Without analytics consent the SDK drops the signal before sending it (consent_not_granted).
- ContentFlow checks each string signal against the workspace's enabled keys.
- Or the dashboard switch. Immediate, no approval. The answer lists
disabled,alreadyDisabledandunknownkeys. - An app that hasn't refreshed its catalog may still send a few.
If this step is skipped: Once the server's list has refreshed, signals for the key are dropped with sensor_not_enabled and nothing is recorded. Signals already recorded are kept.
- At its next refresh the app keeps showing the text and stops emitting for the key.
// Run in CI or on your server (Node 18+), never in an app.
// 1. Stop tracking keys (1 to 500 per call; duplicates are dropped).
const { disabled, alreadyDisabled, unknown } = await client.disableStrings(
['legacy.promo_title', 'legacy.promo_body'],
{ writeKey: process.env.CF_WRITE_KEY! },
);
// 2. Turned off now, were already off, not in the catalog (nothing changed for those).
console.log(disabled, alreadyDisabled, unknown);Don't do this in a React app: disableStrings needs the write key, which must never ship in a browser bundle. Run it from a server or CI job (the JavaScript or REST tab), or use the switch under Sensors → Enable sensors.
Don't do this in a React Native app: disableStrings needs the write key, which must never ship in an app. Run it from a server or CI job (the JavaScript or REST tab), or use the switch under Sensors → Enable sensors.
// Run from a build tool or backend, never in the shipped app.
// 1. Stop tracking keys (1 to 500 per call; duplicates are dropped).
let result = try await client.disableStrings(
["legacy.promo_title", "legacy.promo_body"],
writeKey: ProcessInfo.processInfo.environment["CF_WRITE_KEY"]!
)
// 2. Turned off now, were already off, not in the catalog (nothing changed for those).
print(result.disabled, result.alreadyDisabled, result.unknown)// Run from a JVM CI tool or server, never in an app.
// 1. Stop tracking keys (1 to 500 per call; duplicates are dropped).
val result = client.extensions.disableStrings(
listOf("legacy.promo_title", "legacy.promo_body"),
writeKey = System.getenv("CF_WRITE_KEY"),
)
// 2. Turned off now, were already off, not in the catalog (nothing changed for those).
println("${result.disabled} ${result.alreadyDisabled} ${result.unknown}")// From CI use the REST tab. From an internal Flutter tool (never a store build):
final cf = ContentFlow.instance;
// 1. Stop tracking keys (1 to 500 per call; duplicates are dropped).
final result = await cf.disableStrings(
['legacy.promo_title', 'legacy.promo_body'],
writeKey: const String.fromEnvironment('CF_WRITE_KEY'),
);
// 2. Turned off now, were already off, not in the catalog (nothing changed for those).
print('${result.disabled} ${result.alreadyDisabled} ${result.unknown}');# 1. Stop tracking keys (1 to 500 distinct keys of at most 200 characters).
curl -X POST https://api.contentflow.click/api/v1/strings/disable \
-H "X-CF-Write-Key: $CF_WRITE_KEY" -H 'Content-Type: application/json' \
-d '{"keys":["legacy.promo_title","legacy.promo_body"]}'
# 2. data.disabled, data.alreadyDisabled, data.unknown. Check the status AND "success".Calling it again is safe: keys already off come back in alreadyDisabled. Each SDK validates the keys before sending and throws on bad input or a failed request. A relationship that uses a key which is no longer a sensor shows as broken in Sensors.
What gets recorded
For a key in smartKeys, and only with analytics consent, the SDK sends two kinds of signal. A string_impression carries the key, locale, namespace, page path and a source: usage when the app read the text, viewport when it was actually visible. A string_interaction (a tap or click) carries the same fields without a source. Interactions are never deduplicated.
| SDK | Impressions | Interactions |
|---|---|---|
| JavaScript, React, React Native | t() records a usage impression (useCFString does not). Report a visible element with trackString(key, 'impression', { source: 'viewport' }); on the web, bindStringSensors does it for [data-cf-t] elements at least 50% visible. Once per key and source per session; a usage impression is skipped after a viewport one. | trackString(key, 'interaction'); bindStringSensors records every click |
| Swift | Reading records nothing: call trackSensorImpression(key:source:). Same deduplication as JavaScript. | trackSensorInteraction(key:) |
| Kotlin | Load the catalog with extensions.fetchStrings(); extensions.getString then records an impression for a sensor key, or call trackStringImpression(key, screen). Once per key and screen. | extensions.trackStringInteraction(key, screen) |
| Flutter | Reading the map records nothing: call trackStringImpression(key, source: …). Once per key and source per session. | trackStringInteraction(key) |
The server accepts keys up to 256 characters and a source of usage or viewport, and drops anything else with invalid_string_signal. Accepted signals feed the Sensors dashboard, trackers, relationships and rules. Per-platform instrumentation samples are in Sensors: instrumenting the app.
When it doesn't work
| What you see | Why, and the fix |
|---|---|
| The app shows the source text, not the translation | The translation isn't approved (the key is in withheldKeys): approve it in Localization. Or the app asked for another language: check ?locale= or Accept-Language. Or the app hasn't refreshed its catalog yet (see How changes reach the app). |
| The app shows your fallback or the raw key | The key isn't in the catalog the app loaded: it was never created, it is hidden, or it is in a different namespace from the one requested. |
registerStrings counts a key as skipped, and it never appears | A new key with empty source text is rejected (missing_source_text); the SDKs count rejected keys as skipped. The REST answer lists them under rejected, and translations for languages the workspace doesn't have under skippedLocales. |
| A CI step passed, but nothing changed | With REST, strings/sync and strings/disable can answer 200 with success: false (STRINGS_SYNC_FAILED, STRINGS_DISABLE_FAILED). Check both. A missing or invalid write key answers 401. |
Import refused with 409 IMPORT_ALREADY_PENDING | Another import is waiting for approval. Approve, reject or withdraw it first. |
| No sensor signals after enabling a key | The enable is waiting for approval; the app hasn't refreshed its catalog; analytics consent is off (consent_not_granted); or, in Swift and Flutter, the app reads the text without calling the impression method. Signals for a key that isn't enabled are dropped by the server with sensor_not_enabled. |
Next
- Sensors: turn sensor signals into trackers, relationships and rules that move people into segments.
- REST guide: write key: the exact
strings/syncandstrings/disablerequests, answers and file format.