Publish and delivery
Translations reach an app through a published version: an immutable copy of
the project's keys and translations, named YYYY.MM.DD.N. Publishing creates
one and points delivery at it. This page covers the two writes, publish and
restore, and the three ways to read what was published: files for a build,
project metadata, and the manifest and bundles a runtime SDK loads.
Versions and publishing
explains the model.
Publish a version
POST /publish needs the publish scope. The body is optional:
curl -X POST "https://api.easyi18n.com/v1/projects/$PROJECT_ID/publish" \
-H "Authorization: Bearer $EASYI18N_TOKEN" \
-H "Content-Type: application/json" \
-d '{"label": "Release 4.2", "require_complete": true, "skipIfIdentical": true}'
{"versionId": "2026.10.11.1", "keyCount": 412}
| Field | Effect |
|---|---|
label | A name stored on the version. |
incompleteMode | What a key without a translation gets in that language: skip leaves the key out of the language, source ships the base-language text. Without it, a project in Mode A uses skip and any other project uses source. GET /meta returns the project's mode. |
approvedOnly | true publishes approved translations only and treats the rest as missing. A workspace that requires approval before publishing does this whatever you send. |
require_complete | true refuses the publish with 409 fill_incomplete when a translation is missing, the AI gave up on one, or one fails the ICU or markup check. Nothing is published. |
skipIfIdentical | true answers 200 with the current versionId and "unchanged": true when nothing changed since the last version, so a CI job can publish on every run. |
requiredOutputFormat | Refuses with 409 output_format_conflict unless that format is configured on the project. |
operationId | The approved operation. Required for an agent key. |
The flags are JSON booleans. A string such as "true" is
400 invalid_argument. This route has no preview: dryRun in the query or
the body is refused with 400.
A successful publish can still leave cells out. icuRejected and
markupRejected list translations whose ICU syntax, placeholders or markup do
not match the source, as key [lang]: problem. Those cells were treated as
missing. Each list holds 50 entries; icuRejectedTotal and
markupRejectedTotal hold the real counts.
| Status | error | Meaning |
|---|---|---|
| 409 | fill_incomplete | require_complete refused it. pending counts the incomplete cells, exhausted lists the pairs the AI gave up on, refused the ICU and markup rejections. |
| 409 | export_collision | Two keys get the same name in a configured file format. collisions lists them. Rename one and publish again. |
| 409 | output_format_conflict, output_config_conflict | The required format is not configured, or the formats changed while the publish ran. Nothing was published. |
| 429 | rate_limited | The project used its 30 API publishes of the day. Publish from the dashboard or wait until tomorrow. |
| 503 | unavailable | The datastore refused the write. No version was published; publish again. |
Roll back
POST /versions/{versionId}/restore copies an earlier version into a new one
and points delivery at it. History is never rewritten: the version you roll
back from stays in the list. Use the literal previous for the version before
the current one.
curl -X POST "https://api.easyi18n.com/v1/projects/$PROJECT_ID/versions/previous/restore" \
-H "Authorization: Bearer $EASYI18N_TOKEN"
{"versionId": "2026.10.11.2", "keyCount": 409, "restoredFrom": "2026.10.09.1"}
The copy is verbatim. On a restore, icuRejected and markupRejected are
warnings about cells the restored version does serve and that a publish today
would reject.
In a workspace that requires approval before publishing, a version that holds
translations published without approval is refused whole with
409 version_not_approved. Restores by API key have their own budget of 30
per project and day, apart from publishes.
Read the published files
GET /translations renders a published version into the project's output
formats and returns the files inline. It needs the read scope.
curl "https://api.easyi18n.com/v1/projects/$PROJECT_ID/translations?format=arb&lang=es" \
-H "Authorization: Bearer $EASYI18N_TOKEN"
{
"project": "p7Hq2mXc",
"version": "2026.10.11.1",
"baseCode": "en",
"snapshotAt": "2026-10-11T14:02:07Z",
"formats": ["arb"],
"files": {"arb/app_es.arb": "{\n \"@@locale\": \"es\", … }"},
"coverage": {"es": {"translated": 409, "total": 412}},
"coverageBasis": "skip",
"diagnostics": []
}
| Parameter | Effect |
|---|---|
version | A version ID, or latest (the default). |
format | One configured output format. Without it, every configured format. |
lang | One language. Do not combine it with xcstrings, which is a single file holding every language. |
files maps a relative path to the file's content. Write each one to disk as
it is.
coverage says how many of the version's keys carry a value in each target
language. Read it with coverageBasis, the incompleteMode the version was
published under. Under skip, translated < total means the file is short of
keys. Under source the base-language text filled every gap, so coverage
always reads complete and cannot tell you whether the translation is.
snapshotAt is the instant the content was frozen, not the time of the
request. Send it back as pulledAt when you
sync the file.
The read answers 404 not_published before the first publish,
404 version_not_found or 404 lang_not_found for an unknown version or
language, 400 no_formats when the project has no output format, and
409 export_collision when two keys get the same name in the requested
format.
Read the project's configuration
GET /meta returns what an integration needs to know before it reads
anything else, and it answers before the first publish too:
{
"baseCode": "en",
"mode": "b",
"currentVersionId": "2026.10.11.1",
"lastPublishedAt": "2026-10-11T14:02:07Z",
"deliveryHost": "locales.easyi18n.com",
"languages": [
{"code": "en", "name": "English", "isBase": true, "fillMode": "ai", "inCurrentVersion": true},
{"code": "es", "name": "Spanish", "isBase": false, "fillMode": "ai", "inCurrentVersion": true}
],
"formats": ["arb"],
"outputFormatsApiVersion": 1,
"content": {"enabled": true, "currentVersionId": null, "lastPublishedAt": null}
}
languages is the list configured now. A language added after the last
publish has inCurrentVersion: false. deliveryHost is where the manifest
and bundles are served: the default host, or the project's custom domain once
it is active. The same payload is at GET /@{handle}/{slug}/meta.
Load translations at runtime
A runtime SDK does not download files. It reads a manifest that names one bundle per language, then the bundles. Both are public and take no key. The Flutter SDK does this for you; the routes are documented for other runtimes.
curl -i "https://api.easyi18n.com/v1/@acme/mobile-app/manifest"
{
"project": "p7Hq2mXc",
"channel": "production",
"revision": "2026.10.11.1",
"locales": {
"es": {
"version": "2026.10.11.1",
"bundleHash": "9b1c…",
"url": "/v1/projects/p7Hq2mXc/bundles/es/9b1c….json"
}
}
}
The manifest is addressed by workspace handle and project slug, or by ID at
/projects/{projectId}/manifest. It always describes the current version:
version accepts latest or the current version ID, and channel accepts
production only.
deliveryHost in GET /meta names the host a runtime should load them from.
The manifest is the part that changes, so it is served with
Cache-Control: no-cache and an ETag. Poll it with If-None-Match and an
unchanged project answers 304 with no body:
curl -i "https://api.easyi18n.com/v1/@acme/mobile-app/manifest" \
-H 'If-None-Match: "5d41…"'
Each url is a path on the host the manifest came from. A bundle is named by
the hash of its content and never changes, so it is served with
Cache-Control: public, max-age=31536000, immutable and can be cached for
good. Check the content against bundleHash before using it. Once a publish
changes a language, the API origin answers 404 for its old hash: fetch the
manifest again and follow the new url.