Delivery (CDN)

Updated

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.

Anything you publish is public – never put secrets in translations
Anyone who knows a distribution's URL can download its strings. Keep API keys, internal URLs and personal data out of every branch you publish.

How it works

  1. You create a distribution in Project Settings → Delivery (CDN). It follows one branch, in one file format, with everything or approved labels only.
  2. Each publish creates a new numbered release: one JSON file per language and a manifest that points at the live release.
  3. 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.

FieldDescription
NameUp to 100 characters, for example "Mobile app – production". Only your team sees it.
BranchThe branch it publishes. Defaults to main.
File formatNested 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 onlyOff: every non-empty value is published. On: only labels with the Approved status.
Publish automatically on mergePublishes 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.
ActionWho can do it
Create, edit, disable and enable distributionsDeveloper, admin, owner
Publish a releaseDeveloper, admin, owner, or an API token with the developer role or higher
See the release history and this month's requestsDeveloper, admin, owner
Roll back to an earlier release, delete a distributionAdmin, 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.

terminal
# 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 --json
output
Published 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:

.github/workflows/deploy.yml
      - 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.

terminal
# 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 '{}'
ErrorStatusMeaning
nothing_to_publish422The branch has no non-empty value, or nothing approved under the approved-only filter.
branch_not_allowed400The token may not act on the distribution's branch.
branch_not_found409The distribution's branch was deleted. Pick another branch in Edit.
distribution_not_found404No 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.

terminal
npm install @translatize/ota i18next react-i18next

With i18next

src/i18n.ts
// 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, translation by default. Set backend.namespace to 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: false to 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, or de-AT when the project has de – is looked up on the CDN at every start. Set fallbackLng to a real language and, if you detect the device language, supportedLngs to your project's languages.
  • If distributionId is 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
// 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

src/strings.ts
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

OptionDefaultDescription
distributionIdrequiredThe distribution's public ID from its manifest URL.
cdnUrlhttps://cdn.translatize.comWhere the client fetches from. You rarely need to change it.
storagein memoryWhere 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.
minRefreshMs300000 (5 minutes)A cached copy younger than this is used without asking the CDN.
timeoutMs3000Timeout per request, in milliseconds.
fetchglobal fetchYour own fetch implementation.
namespacetranslationi18next 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-After time 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 storage adapter. 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:

manifest
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.

PlanPrice per block a monthBlocks you can add
Freenot available–
Starter$5up to 10
Professional$4up to 50
Agency$3up 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

  1. At 80 % and at 100 % of the month's requests, the billing owner gets an email and an in-app notification.
  2. Delivery keeps running up to 120 %, at no extra charge.
  3. 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.

ResponseMeaningWhat the client does
200The 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.