Translate a page
Publish a translation that joins the original's group in the same call, check the group, and bring the datasets and menus of the new language along.
A translation is a separate page in its own language, joined to the original in a Polylang translation group. The group is what hreflang and the language switcher are printed from: a page outside its group looks fine and is invisible to both. The responses below are the real ones the Russian version of the Recipes section got when it was published.
1. Publish the translation
Take the original’s document and change four things: lang to the target language, translation_of to the original (id, slug or path), the text, and — instead of id — no id at all. Keep url without a language prefix: Polylang adds /ru/ itself, and the parent must already exist in that language.
---
name: "Рецепты"
excerpt: "Короткие пошаговые разборы того, что чаще всего делают через API v2."
slug: "recepty"
template: "docs"
status: "publish"
url: "/docs-ru/recepty/"
lang: "ru"
frame: "obolochka-dokumentaciya"
translation_of: 6349
---
On this site the Russian docs root has its own slug, docs-ru, so its children live under /docs-ru/; on a site where the translated section keeps the original’s slug, the url is the same as the original’s. Publish it with POST /pages/markdown as any other page.
{
"success": true,
"updated": false,
"id": 6356,
"status": "publish",
"lang": "ru",
"parent": 6231,
"url": "/docs-ru/recepty/",
"link": "https://test.onpress.pro/ru/docs-ru/recepty/",
"visible": true,
"translations": { "ru": 6356, "en": 6349 },
"translation_group": { "origin": { "id": 6349, "lang": "en" }, "term_id": 150, "created": true, "changed": true, "unlinked": [] },
"frame": { "id": "af33214f168f8efd", "slug": "obolochka-dokumentaciya", "meta_key": "_onpress_frame" },
"…": "…"
}
The page is written and linked in one transaction: a translation_of that does not hold is 422 translation_link_failed with a reason (lang_required, origin_not_found, origin_has_no_language, same_language, polylang_missing), and nothing is created. translations must hold both languages. Publishing the same translation again returns translation_group.changed: false. A third language joins the same group — always point translation_of at the same original.
2. Do not send the original’s id
The one mistake that used to overwrite originals: re-using the exported frontmatter with id and only changing lang. That request is refused before anything is written.
{
"success": false,
"error": "lang_mismatch",
"message": "page #6349 belongs to language en, ru requested; to publish a translation use \"translation_of\": 6349 instead of \"id\"",
"id": 6349,
"page_lang": "en",
"requested_lang": "ru"
}
id in a translation’s frontmatter is correct only after the translation exists and names the translation itself. Export with GET /wp-json/onpress/v1/pages/{id}/markdown?as=translation_source gives a document with translation_of in place of id.
3. Check the group
GET /pages/{ref}/translations shows the group from any of its pages. {ref} is an id, a slug, or a path with ~ in place of /.
curl -s -H "Authorization: Bearer $OP_TOKEN" -H "X-OnPress-Blog-Id: 233" "$BASE/pages/6349/translations"
{
"success": true,
"blog_id": 233,
"page": { "id": 6349, "lang": "en", "title": "Recipes" },
"translations": { "en": 6349, "ru": 6356 },
"pages": {
"en": { "id": 6349, "title": "Recipes", "status": "publish", "url": "/docs/recepty/", "link": "https://test.onpress.pro/docs/recepty/" },
"ru": { "id": 6356, "title": "Рецепты", "status": "publish", "url": "/ru/docs-ru/recepty/", "link": "https://test.onpress.pro/ru/docs-ru/recepty/" }
},
"missing_languages": []
}
missing_languages lists the site languages the group has no page for yet.
4. Link pages that already exist
When both pages are already published, set the group directly with PUT /pages/{ref}/translations. The map is the whole group: a page that was in it and is not in the map leaves it.
curl -s -X PUT -H "Authorization: Bearer $OP_TOKEN" -H "X-OnPress-Blog-Id: 240" -H 'Content-Type: application/json' \
"$BASE/pages/6703/translations" -d '{"translations": {"en": 6703, "ru": "/ru/about/"}}'
{
"success": true,
"blog_id": 240,
"page": { "id": 6703 },
"translations": { "en": 6703, "ru": 6711 },
"changed": true,
"group": { "term_id": 912, "created": true },
"unlinked": [],
"deleted_groups": [],
"content_saved": [6703, 6711]
}
Sending the same map again is a no-op with changed: false. A page whose language differs from its key, or a page that already sits in another group, is refused with 409 (translations_rejected, page_in_other_group) before any write. To add one language and keep the rest use POST /pages/{ref}/translations/{lang} with {"page": …}.
5. Translate the data
Datasets are per language too. PUT /data/<name> with lang stores a variant next to the shared list; a repeater on a page of that language reads it, other languages keep reading the shared one.
curl -s -X PUT -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/data/footer_legal" -d '{
"blog_id": 235,
"lang": "ru",
"items": [
{ "text": "Политика конфиденциальности", "url": "/privacy/" },
{ "text": "Условия использования", "url": "/terms/" }
]
}'
Reading with lang returns that variant, and option names where it lives:
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/data/footer_legal?blog_id=235&lang=ru"
{
"success": true,
"blog_id": 235,
"name": "footer_legal",
"origin": "site",
"contextual": false,
"option": "onpress_dataset_footer_legal__ru",
"lang": "ru",
"total": 2,
"items": [
{ "text": "Политика конфиденциальности", "url": "/privacy/" },
{ "text": "Условия использования", "url": "/terms/" }
],
"…": "…"
}
GET /data lists one entry per variant, each with its own lang. A language with no variant reads the shared list and says so in lang_fallback.
6. Give the language its menu
A menu is put into a theme location per language with PUT /menus/{ref}/location. With lang the Polylang map is written; for the default language the theme’s own map is written as well. Every frame with op/menu is rebaked in the same call.
curl -s -X PUT -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/menus/header-ru/location" -d '{"blog_id": 233, "location": "header", "lang": "ru"}'
{
"success": true,
"blog_id": 233,
"theme": "onpress-v2",
"menu": { "id": 107, "name": "header ru", "slug": "header-ru" },
"assigned": { "location": "header", "theme": "onpress-v2", "lang": "ru", "polylang": true, "verified": true },
"rebaked": { "status": "ok", "…": "…" }
}
GET /menus?blog_id=233 shows the result per menu, for example "assigned": [{"location": "header", "lang": "ru"}]; GET /data/menu.header?lang=ru returns the items a Russian page will print.
7. Repair a page without a language
A page created without a language on a multilingual site does not open: WordPress redirects it to itself while the API answered 200. POST /pages/language sets the language term; try it with dry_run first.
curl -s -X POST -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/pages/language" -d '{"blog_id": 233, "missing": true, "dry_run": true}'
{
"success": false,
"error": "nothing_to_update",
"message": "nothing to change: pass \"pages\" with the links, or \"missing\": true to fix every page without a language"
}
That answer is the healthy one: every page of the site already has a language, so there is nothing to repair. When there is, the response lists each page with "lang": {"from": null, "to": "en"}; send pages with specific ids or paths and lang to set another language than the default one.
Check
GET /pages/{ref}/translations on either page shows both languages and an empty missing_languages. The original and the translation both carry hreflang links to each other, and the language switcher on each leads to the other one. The translated page prints the menu and the data of its own language.
See also
- Translations — every translation endpoint and its errors.
- Pages and markdown —
lang,translation_of,link_softand the lookup rules. - Data — language variants of datasets.
- Menus — menu items and locations.