Menus
Navigation menus are read through the menu.* data sources and changed through the /menus endpoints. How a menu is stored, why the theme name is part of the assignment key, and what an invalid item is.
A menu in OnPress is an ordinary WordPress menu. The v2 endpoints cover it completely, but menu items are not read here.
Read through a source, write through an endpoint
Menu contents are read through the data sources menu.header, menu.footer_1, menu.footer_2 and menu.footer_3: reading state for markup is a source, changing state is an endpoint (see Data). A frame prints a menu with the op/menu insert, which reads the same thing (see Inserts). The /menus endpoints therefore only write.
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/data/menu.header?blog_id=233&lang=ru"
The one read here is GET /menus: the list of menus and where they are assigned. That is management state rather than content, and without it there is nothing to address a menu by.
How a menu is stored
A menu is a term of the nav_menu taxonomy. An item is a nav_menu_item post: its position is menu_order, it is tied to the menu term through term relationships, and its type, target, URL, classes and parent item live in post meta.
Assignment to a theme location lives in the theme_mods_<theme> option under nav_menu_locations. On a multilingual site the authoritative map is polylang.nav_menus[<theme>][<location>][<language>], because Polylang swaps the location map at runtime for the language of the request. The four locations the theme registers are header, footer_1, footer_2 and footer_3.
The theme name is part of the key
Both maps are keyed by the theme’s name, and that matters more than it looks. Write into the wrong theme and nothing breaks loudly: the menu is assigned, the location on the page is simply empty. The theme is taken from the site’s stylesheet option unless you pass theme, and every answer says which one was used.
The staging trap: onpress against onpress-v2. On staging an mu-plugin swaps the rendering theme of some sites to onpress-v2 through a filter and writes nothing to the database. Which theme the database names and which one draws the page can therefore differ, and assignments pile up under both names. On site 233 today the database says onpress-v2, the current footers are assigned there, and the old footers are still assigned under onpress:
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/menus?blog_id=233"
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/menus?blog_id=233&theme=onpress"
{
"success": true,
"blog_id": 233,
"theme": "onpress-v2",
"total": 14,
"menus": [
{ "id": 84, "name": "footer left en", "slug": "footer-left-en", "items": 10, "assigned": [] },
{ "id": 83, "name": "header en", "slug": "header-en", "items": 3, "assigned": [{ "location": "header", "lang": "en" }] },
{ "id": 143, "name": "Документация", "slug": "footer-docs-en", "items": 5, "assigned": [{ "location": "footer_1", "lang": "en" }] },
{ "…": "…" }
],
"…": "…"
}
{
"success": true,
"blog_id": 233,
"theme": "onpress",
"total": 14,
"menus": [
{ "id": 84, "name": "footer left en", "slug": "footer-left-en", "items": 10, "assigned": [{ "location": "footer_1", "lang": "en" }] },
{ "id": 83, "name": "header en", "slug": "header-en", "items": 3, "assigned": [{ "location": "header", "lang": "en" }] },
{ "id": 143, "name": "Документация", "slug": "footer-docs-en", "items": 5, "assigned": [] },
{ "…": "…" }
],
"…": "…"
}
The practice that follows: compare both maps before writing, and pass theme only when you deliberately write into another theme’s map. Empty assigned on every menu means you are looking at the wrong theme, not that nothing is assigned. If a location is empty on the rendered page while GET /menus shows it assigned, the mismatch between the two theme names is the first thing to check. The same theme parameter is accepted by the menu.* sources, by every write below, and by ~location addressing.
Addressing a menu
{ref} in a path is the menu’s term id, its slug, its name, or a location with a leading tilde: ~header means whatever menu currently sits in the header, resolved with lang and theme when they are given (without lang, the site’s default language). A name shared by several menus is 409 ambiguous_menu with the ids in candidates. Site 233 has two menus called Документация (143 and 146), so there only the id, the slug or the location works. A location with nothing assigned is 404 menu_not_assigned.
Invalid items
An item pointing at a trashed, deleted or unpublished page is invalid to the WordPress core: it is not printed, and a menu made entirely of such items renders as empty with no explanation. The source prints the same and reports the count in meta. The old centre footer of site 233 under the onpress theme has nine items, five of them invalid:
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/data/menu.footer_2?blog_id=233&lang=en&theme=onpress"
{
"success": true,
"blog_id": 233,
"name": "menu.footer_2",
"origin": "system",
"contextual": false,
"page_id": null,
"needs_page": false,
"description": "Items of the menu assigned to the \"footer_2\" location of the active theme; when a language is given, the Polylang map is used",
"total": 4,
"meta": { "menu_id": 87, "invalid_items": 5 },
"items": [
{ "id": 5885, "title": "Widgets", "url": "/title/", "type": "post_type", "object_id": 5847, "parent": 0, "order": 1, "children": [], "…": "…" },
{ "…": "…" }
]
}
Writes behave the same way, only earlier: every link is resolved before anything is written. An item pointing at a page that does not exist is 409 menu_items_unresolved, and nothing is written. An item pointing at a page that exists but is not published is 409 menu_items_invalid, with each offender listed by at, page, status, title and reason. Pass "allow_invalid": true to write anyway; the answer then still marks each item with valid and a reason, so a deliberately early link is visible rather than silent.
GET /menus
Which menus exist and where they are assigned.
| Parameter | Description |
|---|---|
theme | whose location maps to read; the site’s stylesheet by default |
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/menus?blog_id=235"
{
"success": true,
"blog_id": 235,
"theme": "onpress-v2",
"locations": ["header", "footer_1", "footer_2", "footer_3"],
"default_lang": "en",
"read_items_at": "/data/menu.{location}",
"total": 6,
"menus": [
{ "id": 165, "name": "Articles", "slug": "pomelli-footer-2-en", "items": 5, "assigned": [{ "location": "footer_2", "lang": "en" }] },
{ "id": 164, "name": "Guide", "slug": "pomelli-footer-1-en", "items": 4, "assigned": [{ "location": "footer_1", "lang": "en" }] },
{ "id": 166, "name": "Project", "slug": "pomelli-footer-3-en", "items": 3, "assigned": [{ "location": "footer_3", "lang": "en" }] },
{ "id": 167, "name": "Гид", "slug": "pomelli-footer-1-ru", "items": 4, "assigned": [{ "location": "footer_1", "lang": "ru" }] },
{ "id": 169, "name": "Проект", "slug": "pomelli-footer-3-ru", "items": 3, "assigned": [{ "location": "footer_3", "lang": "ru" }] },
{ "id": 168, "name": "Статьи", "slug": "pomelli-footer-2-ru", "items": 4, "assigned": [{ "location": "footer_2", "lang": "ru" }] }
]
}
items is the number of published items, invalid ones included. assigned merges both maps: the theme’s own map (reported with the default language) and the Polylang map.
POST /menus
Creates a menu, and optionally assigns and fills it in the same call.
| Field | Type | Description |
|---|---|---|
name | string | required |
slug | string | derived from the name when omitted, made unique |
location | string | assign straight away: header, footer_1, footer_2, footer_3 |
lang | string | language for the Polylang map |
theme | string | theme whose location map to write; the site’s stylesheet by default |
items | array | fill it straight away, same shape as PUT …/items |
allow_invalid | bool | accept items pointing at unpublished pages |
curl -s -X POST -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/menus" -d '{
"blog_id": 233,
"name": "Quick links ru",
"items": [ { "page": "/ru/docs-ru/vvedenie/" }, { "page": 6235, "title": "Старт" }, { "url": "https://onpress.pro/", "title": "OnPress" } ]
}'
PUT /menus/{ref}/items
Replaces the whole item tree in one transaction. This is the main path for an agent, and the shape is the same as the docs sidebar.
An item is {"page": …} with a page id, slug or path for a page of this site, or {"url": "https://…"} for anything else; a path such as /pricing/ in url is expanded against the site’s home address. title overrides the label (a page item without one shows the page title, as the core does), target, classes and children do what they say. A bare value instead of an object is treated as a page reference. lang and theme only matter when {ref} is a ~location.
curl -s -X PUT -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/menus/~header/items" -d '{
"blog_id": 233, "theme": "onpress-v2",
"items": [
{ "page": 6183, "title": "Документация", "children": [ { "page": 6186 }, { "page": 6187 } ] },
{ "url": "https://onpress.pro/", "title": "OnPress", "target": "_blank", "classes": "btn btn--primary" }
]
}'
{
"success": true,
"blog_id": 233,
"menu": { "id": 123, "name": "Шапка", "slug": "shapka" },
"total": 4,
"invalid": [],
"items": [
{ "id": 6202, "at": "0", "title": "Документация", "type": "post_type", "page": 6183, "url": "/docs/", "parent": 0, "order": 1, "valid": true },
{ "id": 6203, "at": "0.0", "title": "Введение", "type": "post_type", "page": 6186, "url": "/docs/vvedenie/", "parent": 6202, "order": 2, "valid": true }
],
"read_items_at": "/data/menu.{location}"
}
The answer above is from the development run recorded in the API reference. at is the item’s coordinate in the tree you sent ("0.0" is the first child of the first item), parent is the id of the parent item, and order runs through the whole menu, since nesting is carried by the parent, not by numbering. The answer also carries rebaked.
The rest of the item endpoints
POST /menus/{ref}/items appends one item: {"item": {…}}, optionally with "parent": <item id>. PATCH /menus/{ref}/items/{id} changes title, page, url, target, classes or parent; passing page turns a custom link into a page link and url turns it back. DELETE /menus/{ref}/items/{id} removes the item together with its branch, because orphaned children would otherwise surface at the top level. POST /menus/{ref}/reorder takes {"order": [ids…], "parent": <id or 0>} and renumbers one level, keeping anything unnamed after the named items and leaving neighbouring branches alone.
PATCH /menus/{ref} with name and/or slug renames the menu itself; assignments and items stay as they are.
PUT /menus/{ref}/location
Puts a menu into a theme location.
curl -s -X PUT -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/menus/footer-docs-ru/location" -d '{"blog_id": 233, "location": "footer_1", "lang": "ru"}'
With lang the Polylang map is written. Without lang, or when lang is the default language, the theme’s own nav_menu_locations is written too, because that is what Polylang serves to a request with no language and what a site without Polylang reads. Every write is read back and compared; if the stored map did not keep the value, the call fails with 500 menu_assignment_not_stored and nothing is committed. The answer names theme, the menu, and assigned with the flags of what was written (polylang, theme_mods).
An unknown location is 400 unknown_location with the registered ones in locations, an unknown language is 400 unknown_language with the site’s languages, and a language on a site without Polylang is 409 polylang_inactive.
DELETE /menus/{ref}
Deletes the menu with all its items and frees every location it occupied in both maps of the chosen theme: a location left pointing at a term that no longer exists is one more silently empty menu.
A menu change rebakes frames
A menu inside a frame is an insert, and an insert materialises when the frame is baked. So every write here, except the rename, bakes again every frame with an op/menu insert, once per site language, and reports it in rebaked. A menu edited in wp-admin around the API does not trigger that: the frame keeps its old copy until it is baked with POST /frames/rebake and {"menus": true}. See Frames and How it works.
Error codes
| Code | HTTP | When |
|---|---|---|
menu_name_required | 400 | POST /menus without a name |
location_required | 400 | assignment without a location |
unknown_location | 400 | the theme does not register that location |
unknown_language | 400 | no such language on the site |
bad_items | 400 | items is missing or not an array |
bad_reorder | 400 | reorder without an order list |
nothing_to_update | 400 | a PATCH with no changeable field |
menu_ref_required | 400 | no menu reference given |
menu_not_found | 404 | no menu with that id, slug or name |
menu_not_assigned | 404 | ~location was used but nothing is assigned there |
item_not_found | 404 | no such item in this menu, or not on that level |
page_not_found | 404 | an item points at a page that does not exist |
ambiguous_menu | 409 | the name is shared by several menus |
menu_items_unresolved | 409 | some items did not resolve, nothing was written; the breakdown with at is in errors |
menu_items_invalid | 409 | some items point at pages a visitor cannot see; pass allow_invalid to write anyway |
polylang_inactive | 409 | a language was given but the site has no Polylang |
bad_parent | 409 | an item was made its own parent |
menu_assignment_not_stored | 500 | the location map did not keep the value on read-back; nothing was committed |
Inside menu_items_unresolved each entry has its own code: page_not_found and the other page-addressing codes, or item_target_required for an item with neither page nor url.
The docs tree on the left is not a menu: it is built from the pages themselves, see Docs sidebar.