Translations
Which pages are translations of each other: reading and writing Polylang translation groups, and linking a translation at publish time.
A translation group is Polylang’s own structure: a post_translations term whose description holds the serialized {lang: id} map, and a relation from every page of the group to that term. v2 writes it with a line-by-line port of Polylang’s save_translations() and delete_translation(), so a group written here is byte-identical to one written by Polylang in PHP: the same map order, the same term naming, the same clean-up of emptied groups.
The group is what hreflang and the language switcher (op/lang-switcher) are printed from. A page outside its group has neither and otherwise looks fine, which is why every check below runs before anything is written, and a failed check writes nothing.
| Method | Path | What it does |
|---|---|---|
| GET | /pages/{ref}/translations | the page’s group |
| PUT | /pages/{ref}/translations | write the whole group |
| POST | /pages/{ref}/translations/{lang} | add one translation, keep the rest |
| DELETE | /pages/{ref}/translations | take the page out of its group |
{ref} is a page id, a slug, or a path with ~ instead of /: ~ru~docs-ru~vvedenie~ for /ru/docs-ru/vvedenie/. A path with a language prefix resolves to the page of that language; a path without one, and a bare slug, to the page of the default language. Inside JSON bodies a path is written normally. A site without Polylang answers 409 polylang_inactive on every call.
GET /pages/{ref}/translations
curl -s "$BASE/pages/6409/translations" \
-H "Authorization: Bearer $OP_TOKEN" \
-H "X-OnPress-Blog-Id: 235"
{
"success": true,
"blog_id": 235,
"page": { "id": 6409, "lang": "en", "title": "Pomelli — Free AI Marketing Tool by Google for Small Businesses" },
"translations": { "en": 6409, "ru": 6419 },
"pages": {
"en": { "id": 6409, "title": "Pomelli — Free AI Marketing Tool by Google for Small Businesses", "status": "publish", "url": "/pomelli-en/", "link": "https://example.onpress.pro/pomelli-en/" },
"ru": { "id": 6419, "title": "Pomelli — бесплатный AI-инструмент от Google для маркетинга малого бизнеса", "status": "publish", "url": "/ru/pomelli/", "link": "https://example.onpress.pro/ru/pomelli/" }
},
"missing_languages": []
}
translations always holds the page itself under its own language; pages that no longer exist are left out. url and link in pages are the addresses the site serves, language prefix included — it follows the Polylang settings, so the default language has none when it is hidden. link is null for an unpublished page. missing_languages lists the site’s languages with no page in the group.
PUT /pages/{ref}/translations
| Field | Type | Description |
|---|---|---|
translations | object | {lang: ref} — the complete group; ref is an id, slug or path. null, "" or 0 means no page in that language |
curl -s -X PUT "$BASE/pages/6703/translations" \
-H "Authorization: Bearer $OP_TOKEN" \
-H "X-OnPress-Blog-Id: 240" \
-H 'Content-Type: application/json' \
-d '{"translations": {"en": 6703, "ru": "/ru/about/"}}'
{
"success": true,
"blog_id": 240,
"page": { "id": 6703 },
"translations": { "en": 6703, "ru": 6711 },
"pages": { "en": { "id": 6703, "…": "…" }, "ru": { "id": 6711, "…": "…" } },
"changed": true,
"group": { "term_id": 912, "created": true },
"unlinked": [],
"deleted_groups": [],
"content_saved": [6703, 6711]
}
The map is the group, not an addition to it: a page that was in the group and is not in the map leaves it and is listed in unlinked. The page in the URL is always a member under its own language and may be left out of the map. deleted_groups holds the terms of groups the move emptied and deleted, as Polylang does. content_saved lists the pages the event went to.
The same map sent again is a no-op: changed: false, group.term_id: null, nothing written, no events. A migration can PUT every group on every run.
| HTTP | Code | When |
|---|---|---|
| 400 | bad_translations | translations is not an object |
| 409 | translations_rejected | one or more entries are wrong; rejected[] names each with lang, ref and reason: unknown language, page not found, the page’s language differs from its key, the same page twice, another page under the language of the page in the URL |
| 409 | page_in_other_group | a listed page belongs to another group with other members, and linking would merge two groups; its_group and target_group are returned. DELETE it from its group first |
| 409 | page_has_no_language | the page in the URL has no language; set it with POST /pages/language |
| 404, 409 | page_not_found, page_language_mismatch, ambiguous_page | {ref} in the URL does not resolve to one page |
POST /pages/{ref}/translations/{lang}
| Field | Type | Description |
|---|---|---|
page | id, slug or path | the translation to put under {lang} |
curl -s -X POST "$BASE/pages/6703/translations/ru" \
-H "Authorization: Bearer $OP_TOKEN" \
-H "X-OnPress-Blog-Id: 240" \
-H 'Content-Type: application/json' \
-d '{"page": 6711}'
The rest of the group is kept. If the group already had another page in {lang}, that page leaves and is listed in unlinked. The answer is the PUT answer plus linked: {lang, id}.
Errors: 400 page_required, 422 unknown_language, 409 same_language ({lang} is the language of the page in the URL), 409 page_language_mismatch (page is in another language, actual_lang is returned), 409 page_in_other_group, 409 page_has_no_language.
DELETE /pages/{ref}/translations
{
"success": true,
"blog_id": 240,
"page": { "id": 6711 },
"was_in": { "en": 6703, "ru": 6711 },
"remaining": { "en": 6703 },
"unlinked": true,
"changed": true,
"group_deleted": false,
"content_saved": [6703, 6711]
}
The page stays published and keeps its language; only the link goes. A group left with no pages is deleted (group_deleted: true); a group left with one page stays, as in Polylang. A page that was in no group answers unlinked: false, changed: false.
translation_of at publish time
A new translation is usually linked while it is published: translation_of in the frontmatter of POST /pages/markdown joins the page to the original’s group in the same transaction as the write — either the page is saved and linked, or nothing is saved.
---
name: "Страницы и markdown"
slug: "stranicy"
url: "/docs-ru/stranicy/"
lang: "ru"
translation_of: 6187
---
translation_of is the original’s id, slug or path, and a path may carry a language prefix. It needs an explicit lang that differs from the original’s. The group is merged, not replaced, so a third language joins the first two; if the group already had a page in lang, that page leaves it and a warning says so. The answer carries translations and translation_group:
{
"id": 6353,
"lang": "ru",
"translations": { "ru": 6353, "en": 6187 },
"translation_group": { "origin": { "id": 6187, "lang": "en" }, "term_id": 126, "created": false, "changed": true, "unlinked": [6234] }
}
That is the real answer to publishing the Russian version of Pages and markdown: the group already held a placeholder Russian page, #6234, which left it. Publishing the same translation again gives changed: false. A reference that does not hold is 422 translation_link_failed before anything is written; a page matched by slug that sits in another group is 409 translation_group_conflict — moving it is PUT above.
Language, groups and events
A page joins a group only in its own language, so a page without one is refused (page_has_no_language). The language term is set by publishing with lang or repaired by POST /pages/language; that call never touches groups.
After every write that changed a group, onpress/content_saved goes to each page that was or is in it, including one that has just left, whose hreflang must disappear. PHP drops the Polylang relation and term caches of that page and its page-cache entry and rebuilds its Yoast indexable. The event is asynchronous; the answer lists the pages in content_saved without waiting.
The whole flow — publish the original, publish the translation, check the group — is the recipe Translate a page.