Skip to main content

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}
FieldEffect
labelA name stored on the version.
incompleteModeWhat 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.
approvedOnlytrue publishes approved translations only and treats the rest as missing. A workspace that requires approval before publishing does this whatever you send.
require_completetrue 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.
skipIfIdenticaltrue 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.
requiredOutputFormatRefuses with 409 output_format_conflict unless that format is configured on the project.
operationIdThe 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.

StatuserrorMeaning
409fill_incompleterequire_complete refused it. pending counts the incomplete cells, exhausted lists the pairs the AI gave up on, refused the ICU and markup rejections.
409export_collisionTwo keys get the same name in a configured file format. collisions lists them. Rename one and publish again.
409output_format_conflict, output_config_conflictThe required format is not configured, or the formats changed while the publish ran. Nothing was published.
429rate_limitedThe project used its 30 API publishes of the day. Publish from the dashboard or wait until tomorrow.
503unavailableThe 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": []
}
ParameterEffect
versionA version ID, or latest (the default).
formatOne configured output format. Without it, every configured format.
langOne 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.