Content
The Content routes move long-form documents, such as articles, posts and pages, between a CMS and easyi18n. The CMS pushes the source text, starts the translation, and pulls each language once a person has published it in the dashboard. Keys and content explains how a document differs from a key.
All paths are under https://api.easyi18n.com/v1/projects/{projectId}. A
project where Content is not enabled answers 404 not_found on every route
here; content.enabled in
GET /meta tells
you before you start.
The API pushes, runs and pulls. It has no route to publish a document, to
delete one, or to poll a translation job: publishing is done in the dashboard,
and a content.published webhook or the listing below tells
the CMS when to pull.
Push a document
PUT /content/{docId} stores the source text of a document and creates the
document when the ID is new. Choose the ID yourself, 1 to 64 characters of
letters, digits, _ and -, so that the CMS needs no table of easyi18n IDs.
The key needs the write scope.
curl -X PUT "https://api.easyi18n.com/v1/projects/$PROJECT_ID/content/wp-post-42" \
-H "Authorization: Bearer $EASYI18N_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Spring opening hours",
"text": "# Spring opening hours\n\nFrom April we open at 9:00.",
"format": "markdown",
"externalId": "42",
"fields": {"excerpt": "New hours from April."}
}'
{"docId": "wp-post-42", "revisionId": "8f2e…", "changed": true, "created": true}
The first push answers 201 with created: true and needs a title. Later
pushes answer 200. Pushing the same text again writes nothing and answers
changed: false, so a CMS can send every document on every sync. When the
text did change, blocks that stayed the same keep their translations and the
changed ones are marked for translation again.
| Field | Notes |
|---|---|
text | The document. Required, except on a push that only updates fields. |
format | markdown, html or plaintext. Detected from the text when omitted, and fixed once the document exists. |
baseLang | The document's language. Defaults to the project's base language, and is read only when the push creates the document. |
externalId | The document's ID in your system, as a string: send "42", not 42. Returned by the listing and the pull. Not required to be unique. |
fields | Up to 16 short named texts translated with the document, such as an excerpt or an SEO title. A key you omit keeps its value and "" deletes it. title is reserved. |
Pushing never starts a translation and never spends credits. A body is capped at 256 KB and a project at 500 pushes per day.
POST /content does the same for a client that lets easyi18n choose the ID.
It answers 201 with the new docId. Send an Idempotency-Key header with
it: a retry with the same value returns the first document with
replayed: true instead of creating a second one.
| Status | error | Meaning |
|---|---|---|
| 402 | plan_limit | The project is read-only on its plan, or the new document would pass the plan's document limit. |
| 409 | edit_conflict | Another push to the same document kept winning. Retry, or send one push per document at a time. |
| 413 | payload_too_large, document_too_large | The body is over 256 KB, or the document is too large once stored. |
| 415 | unsupported_media_type | Content-Type is not application/json. |
| 429 | import_quota_exceeded | The project used its 500 pushes of the day. |
Translate a document
POST /content/{docId}/run prices a translation run or starts it. It needs
the translate scope. Price it first: on this route dryRun goes in the JSON
body.
curl -X POST "https://api.easyi18n.com/v1/projects/$PROJECT_ID/content/wp-post-42/run" \
-H "Authorization: Bearer $EASYI18N_TOKEN" \
-H "Content-Type: application/json" \
-d '{"langs": ["es", "fr"], "dryRun": true}'
The preview carries estimatedMc, the estimate in millicredits. Send the body
without dryRun to start the run, with an Idempotency-Key header so a
retry after a timeout cannot start, and charge, a second run:
curl -X POST "https://api.easyi18n.com/v1/projects/$PROJECT_ID/content/wp-post-42/run" \
-H "Authorization: Bearer $EASYI18N_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: wp-post-42-rev-8f2e" \
-d '{"langs": ["es", "fr"]}'
{
"jobs": [{"lang": "es", "jobId": "4Qm1xB7tNs"}],
"skipped": [{"lang": "fr", "reason": "up_to_date"}]
}
One job starts per language. A language that needed no work, or could not
start, is listed under skipped with its reason:
reason | Meaning |
|---|---|
up_to_date | Nothing to translate. |
freeform | The translation in this language is free-form text that a person owns. A fill leaves it alone. |
run_in_progress | Another request started this language at the same instant. |
field_unfittable | A named field is too long for this language. fields lists each one with its chars and maxChars. |
insufficient_credits, spend_cap_exceeded | The workspace could not fund this language. The others still ran. |
billing_contended | The credit ledger was busy. Nothing was charged; retry this language. |
To retry, send only the languages skipped names. Sending the whole list
again answers 409 run_in_progress while the others are still running.
Options in the body:
langs, orlangfor one. Without them the run covers every target language. An unknown code fails the whole request with400 unknown_languageand starts nothing.mode:fill(the default) translates what is missing or out of date.overwritetranslates everything again, approved translations included.force: trueretries the blocks an earlier run gave up on.confirm: truegoes ahead after a409 cost_warning, which the API answers when the estimate is above the project's cost warning threshold.model, one of the models the project offers.
A run reserves credits before it starts, about twice the estimate, and releases what it did not use when it ends. A refusal therefore quotes a larger number than the preview did.
| Status | error | Meaning |
|---|---|---|
| 400 | fill_mode_manual | A requested language is set to manual translation. |
| 402 | insufficient_credits, spend_cap_exceeded | No language could start. |
| 403 | authorization_required | The key is an agent key. No operation covers a content run, so an agent key cannot start or price one. |
| 409 | run_in_progress | A run is already going for a requested language, or the same Idempotency-Key is still being processed. |
| 409 | health_blocked, revision_missing | The document did not pass its checks, or its source has to be pushed again. |
| 429 | run_quota_exceeded | The project used its 500 runs of the day. |
| 503 | ai_unavailable, billing_contended | Nothing was charged. Retry. |
Find out what was published
GET /content lists every published, visible document with the version it
serves. It needs the read scope.
curl -i "https://api.easyi18n.com/v1/projects/$PROJECT_ID/content" \
-H "Authorization: Bearer $EASYI18N_TOKEN"
{
"project": "p7Hq2mXc",
"baseLang": "en",
"truncated": false,
"documents": [
{
"docId": "wp-post-42",
"title": "Spring opening hours",
"format": "markdown",
"externalId": "42",
"versionId": "7c1dZq4kLm",
"publishedAt": "2026-10-11T15:20:44Z",
"langs": [
{"code": "es", "source": "translated", "stage": "approved", "title": "Horario de primavera"},
{"code": "fr", "source": "fallback"}
]
}
]
}
Compare versionId per document with the one you stored and pull the
documents that moved. source: fallback means the language is served in the
base language because its translation was not published.
The response carries an ETag. Send it back as If-None-Match and an
unchanged project answers 304, which makes the listing cheap to poll on a
schedule.
The listing holds at most 200 documents. truncated: true means it is not
known to be complete, so never delete or re-push local documents because they
are missing from such a response.
externalId lets a CMS that lost its ID table rebuild it. Two documents can
carry the same externalId, for example after the same post was pushed twice,
so handle duplicates instead of keeping an arbitrary one.
Pull a published document
GET /content/{docId} returns the published text. With lang it returns the
document in its original format, ready to store:
curl "https://api.easyi18n.com/v1/projects/$PROJECT_ID/content/wp-post-42?lang=es" \
-H "Authorization: Bearer $EASYI18N_TOKEN"
{
"project": "p7Hq2mXc",
"docId": "wp-post-42",
"externalId": "42",
"versionId": "7c1dZq4kLm",
"format": "markdown",
"baseLang": "en",
"sourceFields": {"excerpt": "New hours from April."},
"content": {
"es": {
"source": "translated",
"stage": "approved",
"title": "Horario de primavera",
"fields": {"excerpt": "Nuevo horario desde abril."},
"text": "# Horario de primavera\n\nDesde abril abrimos a las 9:00."
}
}
}
| Parameter | Effect |
|---|---|
lang | The language to return. Required for shape=raw. |
shape | raw (the default) returns text. blocks returns the document as a list of blocks, for every language unless lang is set. |
version | A retained version instead of the one being served. |
include=reports | Adds the SEO and quality reports of the latest run. They are not tied to the version you asked for, and the response says so with reportsAnchored: false. |
A key missing from fields, or a missing title, means keep the source text.
stage is the review stage the translation had when it was published:
translated, reviewed or approved.
The route answers 404 for a document that was never published, was hidden
or deleted, or does not carry the requested language or version.