Delivery (CDN)
Over-the-air delivery publishes a branch's translations to our CDN as JSON files. Your app loads them at runtime with @translatize/ota, so a text fix reaches users without an app-store release or a redeploy. The strings bundled with your app stay as the fallback.
How it works
- You create a distribution in Project Settings → Delivery (CDN). It follows one branch, in one file format, with everything or approved labels only.
- Each publish creates a new numbered release: one JSON file per language and a manifest that points at the live release.
- Your app asks the CDN for the manifest and downloads a language file only when it changed. Until then, and whenever the CDN can't answer, it uses its cached or bundled strings.
Published files are stored in the European Union and delivered worldwide through our CDN at cdn.translatize.com. Delivery is an add-on on Starter, Professional and Agency, billed in request blocks.
Distributions
Open Project Settings → Delivery (CDN) and choose New distribution. The tab is shown to developers, admins and the owner.
| Field | Description |
|---|---|
| Name | Up to 100 characters, for example "Mobile app – production". Only your team sees it. |
| Branch | The branch it publishes. Defaults to main. |
| File format | Nested JSON ({ "checkout": { "cta": "Buy now" } }, the default, suits i18next and most libraries) or Flat JSON ({ "checkout.cta": "Buy now" }, dotted keys as they are). |
| Approved labels only | Off: every non-empty value is published. On: only labels with the Approved status. |
| Publish automatically on merge | Publishes a new release whenever a branch is merged into the distribution's branch. |
Changes to the branch, format or filter apply from the next publish. Each distribution card shows whether the CDN is serving it, the live version, its manifest URL and buttons to publish, see releases, copy an SDK snippet, edit, disable or delete.
- Distribution ID. The client needs the distribution's public ID, the 22-character part of its manifest URL:
https://cdn.translatize.com/d/<distribution id>/manifest.json. Copy SDK snippet fills it in for you. - What goes into a release. Empty values are left out, and so is a language with no value at all, so an empty file never replaces your bundled strings. A branch with nothing to publish, or nothing approved under the approved-only filter, is refused with a message instead of creating an empty release.
- Disable stops the CDN serving a distribution within a few minutes; apps keep their cached or bundled strings, and you can enable it again at any time.
- Delete (admin or owner) removes its files and release history, and its URL stops working. It can't be undone.
| Action | Who can do it |
|---|---|
| Create, edit, disable and enable distributions | Developer, admin, owner |
| Publish a release | Developer, admin, owner, or an API token with the developer role or higher |
| See the release history and this month's requests | Developer, admin, owner |
| Roll back to an earlier release, delete a distribution | Admin, owner |
Publishing
Publishing works on every plan and at any time, even while the account holds no request blocks. If the CDN won't serve the result yet, the app says so and asks before it publishes. There are five ways to publish.
From Delivery (CDN) settings
Choose Publish on the distribution card. The app confirms with the new version number, for example "Published v7".
From the Publish dialog
On the labels page, choose Publish and open the Delivery (CDN) tab. Pick a distribution – the one that publishes the branch you're viewing is preselected, and the dialog warns you when you pick one for another branch – then choose Publish to Delivery (CDN). Creating and editing distributions stays in Project Settings.
With the CLI
translatize release publishes a distribution and prints the version and the manifest URL. It needs an API token with the developer role or higher whose allowed branches include the distribution's branch, and it runs without a config file. See the CLI reference for every flag.
# the only distribution this token can see
npx translatize release
# several distributions: pick one by name, id or public id, or by branch
npx translatize release --distribution "Mobile app – production"
npx translatize release --branch main --jsonPublished version 7 of "Web app" (branch main) – 312 key(s), 3 language(s): de, en, fr
manifest: https://cdn.translatize.com/d/k3J9xQ2mP0aZ7vB1cD5eFg/manifest.json
Anything you publish is public – never put secrets in translations.In CI, run it as the last step of a deploy job, so the strings your users see match the release you just shipped:
- run: npx --yes @translatize/cli release --branch main
env:
TRANSLATIZE_API_TOKEN: ${{ secrets.TRANSLATIZE_API_TOKEN }}With the API
Two token routes cover it: list the distributions a token may publish, then publish one with an empty JSON body. The response carries the new version, its languages and the manifest URL. Details and error codes are in the Integration API reference. An AI agent connected through the MCP server uses the same two calls as the list_distributions and publish_release tools, and publishes only after you have confirmed it.
# list the distributions this token may publish
curl https://api.translatize.com/v1/integrations/projects/$PROJECT_ID/ota/distributions \
-H "Authorization: Bearer $TRANSLATIZE_API_TOKEN"
# publish one of them (use its "id", not its "publicId")
curl -X POST https://api.translatize.com/v1/integrations/projects/$PROJECT_ID/ota/distributions/$DISTRIBUTION_ID/releases \
-H "Authorization: Bearer $TRANSLATIZE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'| Error | Status | Meaning |
|---|---|---|
nothing_to_publish | 422 | The branch has no non-empty value, or nothing approved under the approved-only filter. |
branch_not_allowed | 400 | The token may not act on the distribution's branch. |
branch_not_found | 409 | The distribution's branch was deleted. Pick another branch in Edit. |
distribution_not_found | 404 | No such distribution in this project. |
Automatically on merge
With Publish automatically on merge switched on, every merge into the distribution's branch publishes a new release. A merge that leaves every file as it was publishes nothing. If you use the GitHub App, merging a pull request merges its translation branch, so it triggers this too.
If an automatic publish fails, the project's admins and owner get a notification and the distribution card shows "Auto-publish failed" with the date and the reason. Publish manually once it's fixed.
Release history and rollback
Releases on a distribution card lists its releases, newest first: version, status (Live, Earlier or Rolled back), date, number of labels, languages, and who published it – a person, an API token by name, or "Auto-publish on merge".
- The last 20 releases are kept; older ones and their files are deleted. The live release is always kept, even after a rollback to an older one.
- An admin or owner can choose Roll back on any earlier release. The CDN serves that version again within a few minutes, and apps pick it up on their next check. The release you rolled back from is marked Rolled back.
- Version numbers are never reused: the next publish after a rollback gets a new, higher number.
Publishes, rollbacks and distribution changes are recorded in the project's audit log.
The @translatize/ota client
@translatize/ota is a small client with no dependencies for browsers, React Native and Node.js 18.17 or newer. It ships an i18next backend, so an i18next app needs only its init call changed.
npm install @translatize/ota i18next react-i18nextWith i18next
// src/i18n.ts
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { localStorageAdapter } from '@translatize/ota';
import { OtaBackend } from '@translatize/ota/i18next';
import en from './locales/en.json';
import de from './locales/de.json';
i18next
.use(OtaBackend)
.use(initReactI18next)
.init({
lng: 'de',
fallbackLng: 'en',
// re-render components when a newer release arrives in the background
react: { bindI18nStore: 'added' },
backend: {
distributionId: 'YOUR_DISTRIBUTION_ID',
storage: localStorageAdapter(),
bundled: { en, de },
},
});
export default i18next;- The distribution fills one namespace,
translationby default. Setbackend.namespaceto use another; other namespaces load empty. - A newer release downloaded in the background is added to the running i18next instance. With react-i18next,
react: { bindI18nStore: 'added' }makes components re-render when it arrives. - For a Flat JSON distribution, add
keySeparator: falseto the init options so dotted keys are looked up as they are. - Language codes are your project's language codes. Only a language that is in the release gets cached, so a code that isn't – i18next's default fallback
dev, orde-ATwhen the project hasde– is looked up on the CDN at every start. SetfallbackLngto a real language and, if you detect the device language,supportedLngsto your project's languages. - If
distributionIdis missing, the backend logs a warning and serves your bundled strings instead of breaking the app.
In React Native, keep downloads in AsyncStorage instead:
// src/i18n.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { asyncStorageAdapter } from '@translatize/ota';
import { OtaBackend } from '@translatize/ota/i18next';
import en from './locales/en.json';
i18next
.use(OtaBackend)
.use(initReactI18next)
.init({
lng: 'en',
fallbackLng: 'en',
react: { bindI18nStore: 'added' },
backend: {
distributionId: 'YOUR_DISTRIBUTION_ID',
storage: asyncStorageAdapter(AsyncStorage),
bundled: { en },
},
});
export default i18next;Without i18next
import { createOtaClient, localStorageAdapter } from '@translatize/ota';
import en from './locales/en.json';
const ota = createOtaClient({
distributionId: 'YOUR_DISTRIBUTION_ID',
storage: localStorageAdapter(),
bundled: { en },
});
// resolves at once with the cached copy, else the bundled one
const strings = await ota.load('en');
// called when a background check downloaded a newer release
const stop = ota.onUpdate((lang, translations) => {
showStrings(lang, translations); // your own re-render
});refresh(lang) checks the CDN now and resolves with the new translations, or null when nothing changed or the check failed. onUpdate returns a function that removes the listener.
Options
| Option | Default | Description |
|---|---|---|
distributionId | required | The distribution's public ID from its manifest URL. |
cdnUrl | https://cdn.translatize.com | Where the client fetches from. You rarely need to change it. |
storage | in memory | Where downloaded releases are kept: localStorageAdapter() in browsers (it falls back to memory when storage is blocked), asyncStorageAdapter(AsyncStorage) in React Native, or any object with getItem and setItem. |
bundled | – | Translations shipped with the app, per language, e.g. { en, de }. Used while nothing has been downloaded yet. |
minRefreshMs | 300000 (5 minutes) | A cached copy younger than this is used without asking the CDN. |
timeoutMs | 3000 | Timeout per request, in milliseconds. |
fetch | global fetch | Your own fetch implementation. |
namespace | translation | i18next backend only: the namespace the distribution fills. |
client | – | i18next backend only: reuse a client made with createOtaClient. |
Caching and fallback
The client never makes your app wait for the network when it has something to show. load(lang) resolves at once with the cached copy of that language, else the bundled one, and checks the CDN in the background when there is no cached copy yet or it is older than minRefreshMs. Only when it has neither does it wait for one download attempt, and if that fails too it resolves with an empty object – so always bundle at least your fallback language.
- On a network error, a timeout, a file that doesn't match the manifest, a 404 or a 429, the app keeps what it has: the cached copy, else the bundled one.
- After a 429, the client stops calling the CDN until the
Retry-Aftertime has passed. - Once a release has been downloaded, the app uses it instead of the bundled copy for that language from the next start on. A key that isn't in the release – for example one without an approved translation under the approved-only filter – then falls back the way i18next always does, to
fallbackLng. - Pass a
storageadapter. With the default in-memory storage, every app start downloads the manifest and the language file again, which costs requests and gives the app nothing to fall back to but its bundle.
Native apps and other platforms
Apps that don't run JavaScript can read the same files with any HTTP client. Everything is a plain GET over HTTPS, with no authentication and no cookies. Fetch the manifest, then the file for your language at its url, relative to https://cdn.translatize.com:
GET https://cdn.translatize.com/d/<distribution id>/manifest.json
{
"version": 3,
"publishedAt": "2026-11-02T09:00:00.000Z",
"languages": {
"de": { "url": "/d/<distribution id>/v3/de.json", "hash": "…", "bytes": 1234 },
"en": { "url": "/d/<distribution id>/v3/en.json", "hash": "…", "bytes": 1180 }
}
}Download a language file only when its hash differs from the one you stored. A release's files never change – each release gets new URLs – while the manifest changes with every publish or rollback, so check it at most every few minutes. Treat 404 and 429 as the table under When the limit is reached describes.
Requests and blocks
Over-the-air delivery is an add-on on Starter, Professional and Agency. The account that pays for the project buys request blocks on the Subscription page, under Over-the-air delivery.
| Plan | Price per block a month | Blocks you can add |
|---|---|---|
| Free | not available | – |
| Starter | $5 | up to 10 |
| Professional | $4 | up to 50 |
| Agency | $3 | up to 200 |
- One block is 1,000,000 requests per calendar month (UTC). The count starts again on the 1st.
- Blocks are shared by every distribution of every project billed to the same account.
- Every manifest or file request the CDN serves counts, including requests answered from its cache. Refused requests are not counted and not billed. The count updates about once an hour.
- A distribution serves only while the account holds at least one block. You can create distributions and publish at any time; without a block, apps get a 429 and keep their own strings. On Free you can set everything up and publish, but the CDN doesn't serve it.
- Blocks you add are charged straight away, pro rata for the rest of the billing period. Blocks you remove are not refunded: the removal takes effect at the end of the paid period, and the volume stays available until then. You can't remove blocks this month's requests already need.
- Changing to another plan with delivery carries your blocks over at that plan's block price, up to its maximum. Prices exclude VAT. For more blocks than your plan offers, write to support@translatize.com.
With a storage adapter and the default settings, each language an app loads costs one manifest request when its cached copy is older than five minutes, plus one file request when there is a new release to download. An app started 25,000 times a day in one language makes at most about 750,000 manifest requests in a 30-day month.
When the limit is reached
- At 80 % and at 100 % of the month's requests, the billing owner gets an email and an in-app notification.
- Delivery keeps running up to 120 %, at no extra charge.
- Past 120 %, the CDN refuses requests with HTTP 429 until blocks are added or the month rolls over (UTC). The Delivery (CDN) tab shows the distributions as Paused, and the billing owner gets one more email.
Adding blocks lifts the pause straight away; apps resume on their next check after their Retry-After wait. Because the count updates about once an hour, delivery can pause a little after the 120 % mark rather than exactly on it.
| Response | Meaning | What the client does |
|---|---|---|
| 200 | The distribution is serving. | Uses the new release when it changed. |
429 {"error":"quota_exceeded"} with Retry-After (seconds) | The account holds no block, or this month's requests went past 120 %. | Keeps its cached or bundled strings and doesn't call again before Retry-After. Do the same in your own client. |
404 {"error":"not_found"} | The distribution doesn't exist, is disabled or deleted, or the plan doesn't include delivery. | Keeps its cached or bundled strings. |
Published content is public
A distribution's manifest and files have no access control: anyone who knows its URL can download every string in it. That is what lets any app or device fetch them without a token. It also means:
- Never put secrets, API keys, credentials or internal URLs in translations you publish.
- Keep personal data out of published branches. Use a separate branch, or the approved-only filter, for anything not ready to be seen.
- Deleting a distribution deletes its files and its URL stops working, but copies that apps or other people already downloaded are out of our reach.
