Pages and markdown
One call turns a markdown document with frontmatter into a page: created or updated, placed in the tree, given a language, a translation group and a frame.
POST /pages/markdown is how text gets onto a page. It takes a whole markdown document with a frontmatter, converts the body into Gutenberg blocks and writes the page, its meta, its language and its translation group in one database transaction: either all of it is saved or none of it is. What the converter understands and what it turns each construct into is on Markdown syntax.
Request
curl -s -X POST "$BASE/pages/markdown" \
-H "Authorization: Bearer $OP_TOKEN" \
-H "X-OnPress-Blog-Id: 233" \
-H 'Content-Type: application/json' \
-d "$(jq -n --rawfile md page.md '{markdown: $md}')"
| Field | Type | Description |
|---|---|---|
markdown | string | the whole document, frontmatter included; required |
blocks | array | extra blocks in API form, appended after everything converted from the markdown: {"type": "core/paragraph", "content": "…"}, onpress/* with attrs, or {"type": "raw", "content": "…"}. Any other type is 501 acf_block_unsupported |
frame | string | the page’s frame, same as the frontmatter field; the body wins |
faq_autodetect | boolean | turn the FAQ section into an onpress/faq-items block; off by default, see Markdown syntax |
faq_markers | array | your own list of FAQ heading texts instead of the built-in one |
blog_id, domain | int, string | the site, as in every v2 call |
Everything else lives in the frontmatter. create_missing_parents, link_soft and slug_lookup are read from the frontmatter only: the same keys in the JSON body are ignored by v2.
The site comes from the request — blog_id in the body or X-OnPress-Blog-Id. A domain key in the frontmatter does not pick the site, and a request that names no site writes to the main site of the network, blog_id 1.
A document
---
name: "Media"
slug: "media"
url: "/docs/spravochnik/media/"
lang: "en"
status: "publish"
frame: "obolochka-dokumentaciya"
excerpt: "Uploading images into the media library."
seo_title: "Media — OnPress API v2"
seo_description: "POST /media, idempotent uploads, lookups by source."
---
## Upload
One paragraph per line.
Frontmatter
The frontmatter is flat key: value lines split at the first colon, so colons inside values are safe. Surrounding single or double quotes are removed, lines starting with # are skipped. There are no lists, nested keys or multi-line values: every value is a string, and flags accept true, 1, yes or on.
| Field | What it does |
|---|---|
name | page title (post_title), HTML tags stripped; title is accepted as an alias |
slug | the page slug; without it the slug is made from name, Cyrillic transliterated. The last segment of url does not set the slug |
status | publish (default), draft, pending, private; published is read as publish |
url | the page’s place in the tree, /docs/spravochnik/media/: every segment but the last is an existing parent. See below |
parent | parent page id; used only when there is no url |
create_missing_parents | true creates missing url segments as draft stubs instead of failing |
lang | Polylang language of the page; on a multilingual site always pass it |
translation_of | the original this page translates: id, slug or path. The page joins the original’s translation group in the same transaction |
link_soft | true publishes the page even when translation_of does not resolve; the failure goes to warnings |
frame | the one frame the page is printed in, a slug or an id; "" removes it. See Frames |
template | ignored in v2, with the warning template_ignored: a look is a frame, and there is no second way to set it. The meta _wp_page_template is neither written nor erased, so a page that has one keeps it — it simply stops deciding anything. An item of the docs sidebar is still a page with that meta, so a new documentation page joins the sidebar through POST /docs/sidebar/items/{ref}/show, not through the frontmatter |
excerpt | post_excerpt, printed as the lead under the title; "" clears it, a missing key keeps the current one |
seo_title, seo_description | Yoast title and meta description |
keyword | Yoast focus keyword (also stored as a keyword meta, for parity with v1) |
id | update exactly this page; the slug lookup is skipped |
slug_lookup | false switches the slug lookup off: without id a new page is always created |
type | only page; anything else is 501 post_type_not_supported |
date | post_date, e.g. 2026-09-12T10:00:00; the current time on create, kept on update |
topic | a term of the topic taxonomy, created when missing |
target, anchor, max_publish_date | service meta for internal linking and the MAX channel; "" removes it |
category | ignored with a warning: pages have no categories |
| any other key | stored as post meta under its own name and named in warnings — usually a typo |
Which page gets written
With id the write goes to that page. Without it the page is looked up by slug among pages in publish, draft, pending or private; when url or parent is given, only under that parent. On a multilingual site the matches are narrowed to the page’s language. A page with no language at all counts as a match, so pages written before languages existed are updated rather than duplicated.
One match is updated and the response says updated: true. No match creates a page. Several matches are 409 ambiguous_page with the candidates in matches, and nothing is written: pass id, lang, or slug_lookup: false.
A slug is unique within one parent and one language, so the English and the Russian page can both be stranicy: the language prefix separates their addresses. If another page of the same language under the same parent already holds the slug, the page gets the next free one (stranicy-2), and the response carries slug_changed with requested, applied and reason, plus a warning. A new slug on a published page keeps the old one in _wp_old_slug, so the old address redirects. A new parent does not: the old address becomes 404.
id of a page in another language than lang is 409 lang_mismatch — that combination only ever means a translation, and a translation is translation_of. A page matched by slug that already sits in another translation group with other members is not dragged along by translation_of: 409 translation_group_conflict, or front_page_group_conflict for a language home page. Moving a page between groups is PUT /pages/{ref}/translations.
Path and language prefix
url is the page hierarchy, not an address. It is read segment by segment:
- A leading segment that is one of the site’s languages is dropped. The check uses the real language list, so a page slugged
itornoon a site without those languages stays a parent. - The last segment is the page itself and is dropped too.
- Every remaining segment must resolve to an existing page under the previous one. Among pages with the same slug the one in the page’s own language wins.
So a translation carries its own tree and its language in lang: url: "/about/" with lang: "ru" publishes at /ru/about/, and /ru/about/ in url behaves the same. The address the site actually serves is link in the response, with the prefix Polylang adds (none for the default language when it is hidden).
A segment that does not resolve is 422 parent_not_found with segment, and nothing is written. With create_missing_parents: true the missing segments become stubs instead: draft pages titled after the slug, marked with the _onpress_stub_parent meta, in the page’s language, written in the same transaction and listed in created_stubs. A draft stub hides everything below it — its path segment 404s — so the response says visible: false and names the parent in warnings. Publish the stub before relying on the address.
A scheme or a domain in url is 422 absolute_url_in_path, bare example.onpress.pro/docs/x/ included. There is no auto-repair.
Response
The response is the state of the page after the write, not an echo of the request. This is the real answer to publishing the Russian version of this page:
{
"success": true,
"updated": false,
"id": 6353,
"status": "publish",
"lang": "ru",
"parent": 6231,
"url": "/docs-ru/stranicy/",
"link": "https://example.onpress.pro/ru/docs-ru/stranicy/",
"visible": true,
"translations": { "ru": 6353, "en": 6187 },
"translation_group": {
"origin": { "id": 6187, "lang": "en" },
"term_id": 126,
"created": false,
"changed": true,
"unlinked": [6234]
},
"frame": { "id": "af33214f168f8efd", "slug": "obolochka-dokumentaciya", "meta_key": "_onpress_frame" },
"printed": {
"how": "on_request",
"url": "/docs-ru/stranicy/",
"link": "https://example.onpress.pro/ru/docs-ru/stranicy/",
"frame": { "id": "af33214f168f8efd", "slug": "obolochka-dokumentaciya", "renders_on_request": true }
},
"lang_changed": { "changed": true, "from": null, "to": "ru" },
"warnings": [
"page #6234 was the \"ru\" translation of #6187 and has left the group: a group holds one page per language",
"template_ignored: \"template\" is not applied in v2 — how a page looks is set by \"frame\". The theme template meta was not written; pass \"frame\": \"<slug>\" instead"
],
"edit_url": "https://example.onpress.pro/wp-admin/post.php?post=6353&action=edit",
"view_url": "https://example.onpress.pro/ru/docs-ru/stranicy/",
"meta": { "name": "Страницы и markdown", "…": "…" },
"blocks": [{ "index": 0, "type": "core/paragraph", "content": "<p>…</p>" }, "…"],
"preview_url": null
}
| Field | Meaning |
|---|---|
updated | true — an existing page was updated, false — a new one was created |
id, status, parent, lang | the page as stored |
url | path in the tree, no language prefix |
link | the public address, language prefix included; null while the page has none (draft, pending) |
preview_url | always present: null for an addressable page, the ?page_id=… address otherwise |
visible | true when the status is publish and no ancestor is unpublished; every reason for false is in warnings |
warnings | sentences about anything that is off but did not fail the request |
translations | the translation group after the write, {lang: id}; {} when there is none |
translation_group | with translation_of: origin, term_id, created, changed, unlinked |
translation_link_error | with link_soft, when the link failed: error and message |
frame | when frame was passed: {id, slug, meta_key}, or {cleared: true} for "" |
printed | how the page reaches the visitor, always present: how is static (the frame is baked and the page is published into the apps tree as a file, published_by: "php"), on_request (the frame depends on the page, PHP renders it every time), theme (no frame, the theme’s template prints it) or not_published (no address yet — a draft or an unpublished ancestor, url is null); plus url, frame: {id, slug, renders_on_request} and, when the page has an address, link |
lang_changed | the language term this write set: from and to; a new page shows from: null |
slug_changed, created_stubs | only when the slug was moved or stubs were created |
edit_url, view_url, meta, blocks | kept for older callers: admin link, raw permalink, parsed frontmatter, resulting blocks |
A template_ignored warning means exactly what it says: the field was not applied. On a page that already is in the docs sidebar nothing changes — its _wp_page_template is left alone. On a new page it does: drop template from the frontmatter and put the page into the sidebar with the sidebar endpoints.
Errors
Errors are {"success": false, "error": "<code>", "message": "…"} plus fields of that error; branch on error. Two checks of the body itself use the old form, with the text in error and no message: a missing markdown and a blocks that is not an array, both 400. See also Errors.
| HTTP | error | When |
|---|---|---|
| 400 | frame_single | frame is a list in any form: a, b, [a, b], an array or an object |
| 404 | frame_not_found | no such frame on the site |
| 409 | lang_mismatch | id names a page of another language than lang; use translation_of |
| 409 | ambiguous_page | the slug matches several pages; matches[] with id, status, url, link |
| 409 | translation_group_conflict | the page matched by slug belongs to another group; current_group, target_group |
| 409 | front_page_group_conflict | the same for a language home page; moved only by PUT /site/front-page |
| 422 | translation_link_failed | translation_of does not hold; reason is polylang_missing, lang_required, origin_not_found, origin_has_no_language or same_language. link_soft: true publishes anyway |
| 422 | unknown_language | lang is not a language of the site; languages lists them |
| 422 | parent_not_found | a url segment does not resolve; segment names it |
| 422 | absolute_url_in_path | url carries a scheme or a domain |
| 500 | parent_create_failed | a stub parent could not be inserted |
| 501 | post_type_not_supported | type other than page |
| 501 | acf_block_unsupported | blocks holds a type other than core/*, onpress/* or raw |
Language repair: POST /pages/language
Every page written by v2 gets a language — the named one or the site default, with a warning. Pages written before that have none, and on a multilingual site such a page does not open: redirect_canonical sends it to itself. This call gives pages their language term.
| Field | Type | Description |
|---|---|---|
lang | string | the language to set; the site default when omitted |
missing | boolean | take every page without a language — the repair mode |
pages | array | specific pages: id, slug or path |
statuses | string | comma-separated statuses scanned in missing mode; publish by default |
dry_run | boolean | report what would change, write nothing |
curl -s -X POST "$BASE/pages/language" \
-H "Authorization: Bearer $OP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"blog_id": 233, "pages": ["/docs/vvedenie/"], "dry_run": true}'
{
"success": true,
"blog_id": 233,
"lang": "en",
"dry_run": true,
"total": 1,
"changed": 0,
"pages": [
{ "id": 6186, "title": "Introduction", "url": "/docs/vvedenie/", "lang": { "from": "en", "to": "en" }, "changed": false }
]
}
On a healthy site {"missing": true, "dry_run": true} finds nothing, and “nothing” is an error code, not an empty list — this is the real answer from site 233:
{
"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"
}
Everything is written in one transaction. A site without Polylang is 409 polylang_inactive, an unknown language 422 unknown_language, a page reference that does not resolve 404 with the resolver’s code. The call sets the language term only; groups are Translations.
After the write: content_saved
Once the transaction commits, the service sends the onpress/content_saved event to the site’s own domain for the written page — and, when the translation group changed, for every page that is or was in it. PHP then drops the post and Polylang caches of those pages and their page-cache entries, and rebuilds their Yoast indexables, which is where the sitemap and hreflang come from. The event is asynchronous: the response does not wait for it, and a failure is only logged.
v2 has no export back to markdown. Reading a page as markdown is v1, GET /wp-json/onpress/v1/pages/{id}/markdown, which accepts the same op_ token.