
A wrong term or a truncated label usually turns up after the build has shipped. If translations are compiled into the service, fixing one string means a new build and a deploy. Over-the-air translation updates remove that step. Ownlate freezes approved translations into a versioned release and serves it over HTTP behind an access key, and the Go, Rust and NestJS clients pick up each new version while the service keeps running.
A release is a snapshot of approved translations
An export is a file you commit. A release is a snapshot your app downloads.
You create one from the project's Releases tab or with the GraphQL createRelease mutation. It takes the approved translations of the project as they stand at that moment and freezes them. Anything not yet approved stays out.
A few properties are worth knowing:
- The version is assigned automatically and increments per project. You add a name and a description to say what changed.
- An unfinished language does not block a release. It appears with the keys that are approved and nothing else, so check progress before you cut one.
- Plurals survive intact. A segment with plural forms carries its whole form map as the value.
- There are two bundle shapes. By default a bundle is a flat map of key to value, one map per language. With
splitByFiles, keys are grouped by the file they came from, which suits an app that loads one namespace at a time.
How over-the-air translation updates reach a running service
Every project has one distribution: a permanent access key that serves its releases over HTTP with no authentication. It lives under Releases → Distribution, and three public endpoints hang off it:
GET https://api.ownlate.com/public/v1/ota/{accessKey}/manifest
GET https://api.ownlate.com/public/v1/ota/{accessKey}/bundles
GET https://api.ownlate.com/public/v1/ota/{accessKey}/bundles/{language}
The manifest is small and cheap to poll:
API=https://api.ownlate.com/public/v1
curl $API/ota/$ACCESS_KEY/manifest
{ "version": 14, "languages": ["de", "sr", "hu"], "publishedAt": "2026-09-18T09:12:44Z" }
All three endpoints serve the latest release by default. Add ?version=5 to pin an older one, which helps when an old build expects keys the current release no longer has.
The path from a corrected string to production looks like this:
- A translator fixes the string and it is approved.
- Someone creates a new release, and the version goes up by one.
- On their next background refresh, the runtime clients load the new bundle and swap it in.
No build and no deploy are involved.
Go quick start
Install the module:
go get github.com/OwnLate/go-client
Create a client pointed at an OTA bundle, start the background refresh and wait for the first successful load:
client, err := ownlate.New(ownlate.Config{
Source: ownlate.OTASource{Bundles: []ownlate.OTABundle{{AccessKey: accessKey}}},
Locale: "ru",
})
if err != nil {
return err
}
defer client.Close()
client.Start(ctx) // refresh in the background every five minutes
<-client.Ready() // the first successful load
client.T("notification.title", "en_US")
client.Translate("emails", "greeting", map[string]any{"name": "Roman"}, "ru")
When a load fails, Start logs the error and retries. If you do not need the background refresh, a single client.Load(ctx) replaces Start and returns the load error instead.
A bundle without a Prefix lands in the __ota__ namespace (the ownlate.OTANamespace constant), and client.T(key, locale) is the shorthand for that case. To keep several bundles apart, give each one a prefix, which becomes its namespace:
ownlate.OTASource{Bundles: []ownlate.OTABundle{
{AccessKey: emailsKey, Prefix: "emails"},
{AccessKey: pushKey, Prefix: "push"},
}}
Bundles that share a prefix are merged into one namespace, and the last one wins on colliding keys.
The refresh period is the PollInterval field of Config, five minutes by default, and RetryInterval is the pause after a failed load. The client is safe for concurrent use: Translate reads a snapshot under an RWMutex, and a refresh replaces that snapshot as a whole.
Rust
[dependencies]
ownlate = { git = "https://github.com/OwnLate/rust-client" }
let client = ownlate::Client::ota(access_key, "en_US")?;
let refresh = client.start(); // refresh in the background every five minutes
client.ready().await; // the first successful load
client.t("notification.title", "en_US");
client.translate("emails", "subject", Some(&json!({ "plan": "Pro" })), "en_US");
Client is cheap to clone: every clone shares the same translations and the same refresh task. Dropping the RefreshHandle returned by start stops the refresh. As in Go, client.load().await? performs a single load and returns the error, and load failures are reported through tracing.
NestJS
npm install @globalart/ownlate-nestjs-translator
The module wraps the same behaviour for a Nest application: it loads a bundle at start-up and refreshes it in the background. The Go client is a port of this package, and the resolution rules below are shared by the clients.
How missing keys and locales are resolved
A bundle can lag behind the code. A new key may ship before its translation is approved, or a locale may not be translated at all. The clients resolve every lookup in a fixed order so that these gaps stay harmless:
- The locale comes from the call, otherwise from
Config.Locale. - The namespace is looked up. For an OTA source an unknown namespace falls back to
__ota__. - If the requested locale is missing, a locale of the same language is used:
en_USreachesenand the other way round. Failing that, the first locale in alphabetical order is used, which keeps the choice stable between calls. - An unknown key is returned as is, so a missing translation never leaves an empty string behind.
- Placeholders written as
{{name}}are replaced with the values you pass.
The Rust client adds one step before giving up on a key: it looks the key up in the other locales, so a translation still under review in one language does not appear as a raw key. It also offers Client::get, which reads a key exactly as stored, with no locale fallback and no echoing of the key.
So code that references a new key can ship before its translation is released. At worst the call returns the key itself.
Writing your own client: manifest, ETag and 304
The OTA endpoints need no credential, so any HTTP client can read them. Responses carry an ETag derived from the release version and publication time. Send it back in If-None-Match, and an unchanged bundle answers 304 Not Modified with no body:
curl -i --compressed $API/ota/$ACCESS_KEY/bundles/de
# note the ETag response header, then:
curl -i --compressed -H "If-None-Match: $ETAG" $API/ota/$ACCESS_KEY/bundles/de
# 304 Not Modified, empty body, while the release is unchanged
Bundles are compressed when the client says it accepts it, and the compressed form is cached too. A one-language bundle looks like this:
{
"version": 14,
"language": "de",
"splitByFiles": false,
"publishedAt": "2026-09-18T09:12:44Z",
"translations": { "home.title": "Hallo" }
}
A sensible loop asks for the manifest on start-up, compares its version with the one already stored, and downloads the bundle only when it differs.
OTA or a pull request
OTA is the better fit when:
- copy changes more often than code, and waiting for the next deploy to fix a string is not acceptable;
- several services read the same strings, so one release should update all of them;
- the content relies on plurals, since a bundle keeps the whole form map.
Committing exported files through a pull request is the better fit when:
- translations should be reviewed next to the code and versioned with it in git;
- the service should not depend on a runtime HTTP request to get its strings;
- the strings are not meant to be read by anyone outside your product.
The last point matters. Anyone with the access key can read the bundle, so distribution is meant for translations that ship inside your product anyway. Do not put anything in a project that you would not put in your app's assets. If the key leaks, regenerate it: the old key stops working immediately, and every client needs the new one.
One more distinction. The browser SDK and the Chrome extension are a different thing from these runtime clients. They let a translator edit strings on the running product, and those edits land as drafts that still go through review.
Further reading
- Releases and OTA, SDKs and clients and REST and GraphQL API
- Integrations and Files for the route through your repository
- Client source: go-client, rust-client, nestjs-translator
- Plans: ownlate.com/pricing