Skip to main content

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.

FieldNotes
textThe document. Required, except on a push that only updates fields.
formatmarkdown, html or plaintext. Detected from the text when omitted, and fixed once the document exists.
baseLangThe document's language. Defaults to the project's base language, and is read only when the push creates the document.
externalIdThe 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.
fieldsUp 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.

StatuserrorMeaning
402plan_limitThe project is read-only on its plan, or the new document would pass the plan's document limit.
409edit_conflictAnother push to the same document kept winning. Retry, or send one push per document at a time.
413payload_too_large, document_too_largeThe body is over 256 KB, or the document is too large once stored.
415unsupported_media_typeContent-Type is not application/json.
429import_quota_exceededThe 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:

reasonMeaning
up_to_dateNothing to translate.
freeformThe translation in this language is free-form text that a person owns. A fill leaves it alone.
run_in_progressAnother request started this language at the same instant.
field_unfittableA named field is too long for this language. fields lists each one with its chars and maxChars.
insufficient_credits, spend_cap_exceededThe workspace could not fund this language. The others still ran.
billing_contendedThe 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, or lang for one. Without them the run covers every target language. An unknown code fails the whole request with 400 unknown_language and starts nothing.
  • mode: fill (the default) translates what is missing or out of date. overwrite translates everything again, approved translations included.
  • force: true retries the blocks an earlier run gave up on.
  • confirm: true goes ahead after a 409 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.

StatuserrorMeaning
400fill_mode_manualA requested language is set to manual translation.
402insufficient_credits, spend_cap_exceededNo language could start.
403authorization_requiredThe key is an agent key. No operation covers a content run, so an agent key cannot start or price one.
409run_in_progressA run is already going for a requested language, or the same Idempotency-Key is still being processed.
409health_blocked, revision_missingThe document did not pass its checks, or its source has to be pushed again.
429run_quota_exceededThe project used its 500 runs of the day.
503ai_unavailable, billing_contendedNothing 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."
}
}
}
ParameterEffect
langThe language to return. Required for shape=raw.
shaperaw (the default) returns text. blocks returns the document as a list of blocks, for every language unless lang is set.
versionA retained version instead of the one being served.
include=reportsAdds 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.