Data
Where the op/repeat insert gets its list: platform-computed system sources, the site's own datasets with per-language variants, or an inline array. One resolver, one fixed order, and the endpoints that read, write and test it.
A repeater inside a frame needs a list. The op/repeat insert names it, and the printer resolves that name when the frame is baked:
<!-- wp:op/repeat {"source":"faq"} -->
<div class="faq__item">
<h3 data-op-bind='{"text":"item.q"}'></h3>
<p data-op-bind='{"text":"item.a"}'></p>
</div>
<!-- /wp:op/repeat -->
Three sources, one resolver
A list can come from three places, and one resolver walks them in a fixed order: system, then site, then inline. The origin field of every answer says which one produced it.
System source is state the platform computes itself: menus, languages, site settings, the front page, the page tree, the children of the current page, its neighbours, breadcrumbs. These names are reserved.
Site dataset is a free name (faq, tariffs, team, reviews) uploaded through /data. Plain JSON with no imposed schema: whatever fields you put into the items are the fields available inside the repeater as item.<field>.
Inline list is an array written straight into the insert, {"items":[…]}, for a list needed in exactly one place.
The printer and the API disagree on one thing only: a name that resolves to nothing. On a page the repeater quietly falls through to its inline items (usually empty) and prints nothing; the API answers 404 source_not_found, so check names through the API rather than by looking at the page.
Reading is a source, changing is an endpoint
That rule decides which endpoint you want. Languages are read as site.languages and changed through the language endpoints; menu contents are read as menu.header and edited with the menu endpoints; the docs tree is read as pages.tree and rearranged through the sidebar endpoints. /data writes only what the user owns: their own datasets. System sources are a read surface over the site, not a second way to configure it.
Reserved are the namespaces menu.*, site.*, pages.* and the name breadcrumbs, plus the two words the endpoints themselves occupy, sources and resolve. A dataset under a reserved name is refused before anything is checked, dry_run included:
curl -s -X PUT -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/data/menu.header" -d '{"blog_id": 235, "dry_run": true, "items": []}'
{
"success": false,
"error": "reserved_dataset_name",
"message": "the name \"menu.header\" belongs to the platform: the menu.*, site.*, pages.* namespaces and breadcrumbs are reserved for system sources and cannot be overwritten by a dataset",
"reserved": true
}
Datasets and language
A dataset can have a variant per language. PUT /data/faq with "lang": "ru" stores a Russian faq next to the shared one, not instead of it. PATCH and DELETE take lang the same way (in the body or the query string) and address exactly one variant; without lang every call refers to the shared dataset.
| What | Shared | Variant for ru |
|---|---|---|
registry key in onpress_datasets | faq | faq@ru |
| option with the list | onpress_dataset_faq | onpress_dataset_faq__ru |
Which variant the repeater reads. The language is taken, first match wins, from the lang attribute of op/repeat, from the language of the page being printed, and from the language of the print run (the request language on a live page, the bake language for a baked copy). If a variant exists for that language it is used, otherwise the shared dataset. GET /data/{name} and POST /data/resolve follow the same order: the lang parameter, then the language of page.
The fallback goes to the shared dataset only, never to another language. A read with a language that has no variant returns the shared list and says so in lang_fallback. If there is no shared list either, it is source_not_found. Site 235 is built exactly like this: faq, footer and footer_legal exist only as en and ru variants, so GET /data/faq without lang and without page is a 404, while every page gets its own language. Keep a shared dataset if you need a list for languages you have not translated yet.
A write to any variant rebakes every frame that reads the dataset by its base name.
Where datasets live
In the options of that one network site, like frames and custom CSS. onpress_datasets is the registry (items, bytes, sha256, fields, dates and author per entry), onpress_dataset_<name> or onpress_dataset_<name>__<lang> holds the list as JSON. A dataset up to 32 KB is written with autoload = yes, a larger one without. There is deliberately no network-wide option: staging shares its database with production, and a network option would reach every site at once.
The registry is a cache, not the truth. If a dataset option was changed around the API, a read returns the real items plus a registry_stale object comparing the registry entry with what is stored; writing the dataset through PUT or PATCH brings the two back in step.
Contextual sources and rendering on request
Every source declares whether it depends on the page being printed. menu.*, site.*, pages.all, pages.tree, pages.top and every site dataset do not: the printer computes them once per frame. pages.children, pages.siblings, pages.adjacent and breadcrumbs do, so their result is cached per frame and page.
Nothing is guessed from ambient state: baking runs outside a page request, where there is no current page. A contextual source gets the page explicitly (page in the API, the printed page in the printer) and, called without one, returns an empty list with needs_page: true.
A frame whose repeater depends on the page has no baked copy at all: it is marked renders_on_request and printed on every request for the page at hand. That is covered in How it works.
Filtering with match
match on op/repeat keeps only the items whose fields equal the given values. One dataset for the whole site, filtered per page:
<!-- wp:op/repeat {"source":"faq","match":{"page":"page.slug"},"empty":"frame"} -->
<details class="faq__item">
<summary data-op-bind='{"text":"item.q"}'></summary>
<p data-op-bind='{"text":"item.a"}'></p>
</details>
<!-- /wp:op/repeat -->
The rules the printer and the API share:
- Keys are item fields; a dot addresses a nested field,
meta.lang. - Values are bind tokens from the same dictionary as
data-op-bind(page.slug,page.id,page.parent_id,site.lang,option.<name>and the rest, see Bindings), orraw:<value>for a constant. - Comparison is strict string equality:
3equals"3",trueis"1",falseis an empty string; arrays, objects,nulland missing fields never match. - All pairs must match. If a token has no value (for example
page.parent_idon a top-level page), the list is empty and the API names the token inmatch_unresolved. matchworks on any source: system, site dataset or inline.
A page.* token makes the frame page-dependent: it gets renders_on_request: true in the frame registry and each page gets its own list. Without a page the repeater prints nothing and reports needs_page.
"empty": "frame" on the same insert makes the whole frame the repeater stands in print as an empty string when the filtered list is empty; a frame nested through op/frame leaves an empty spot. That is how pages without questions get no FAQ block at all instead of an empty heading. Without the attribute an empty list leaves an HTML comment in its place and the rest of the frame is printed. Frame linking is covered in Frames, the whole recipe in FAQ from data.
System sources
| Source | Contextual | What it returns |
|---|---|---|
menu.header | no | items of the menu assigned to the theme’s header location; with lang the Polylang map is used |
menu.footer_1, menu.footer_2, menu.footer_3 | no | the same for the three footer locations |
site.settings | no | name, description, addresses, timezone, date formats, locale, what is on the front page, the active theme |
site.languages | no | the project’s languages: slug, name, locale, flag, rtl, which one is default, the locale URL |
site.front_page | no | what actually opens at the root: page id, title, path |
pages.all | no | every published page as a flat list, with addresses |
pages.tree | no | the same pages nested by parent |
pages.top | no | pages without a parent |
pages.children | yes | children of the current page |
pages.siblings | yes | pages on the same level, the current one included |
pages.adjacent | yes | one item with prev and next |
breadcrumbs | yes | the chain from the site root to the current page, each item marked current |
A menu source also returns meta with the menu term it used and the number of dropped items. An item pointing at a trashed or unpublished page is invalid, a visitor never sees it, and the source prints the same. An empty list with "invalid_items": 4 means the menu is broken, not the source.
GET /data
Lists the site’s own datasets, one row per variant. System sources are not mixed in.
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/data?blog_id=235"
{
"success": true,
"blog_id": 235,
"home_url": "https://example.onpress.pro",
"registry_option": "onpress_datasets",
"option_prefix": "onpress_dataset_",
"resolution_order": ["system", "site", "inline"],
"system_sources": "/data/sources",
"total": 6,
"datasets": [
{
"name": "faq",
"lang": "en",
"origin": "site",
"contextual": false,
"items": 42,
"bytes": 9692,
"sha256": "31b4808cde7f9285afb8e7f53029f0ed6cec4965c5af47c719a1f5ae48a9ec61",
"fields": [
{ "field": "page", "types": ["string"], "in_items": 42 },
{ "field": "n", "types": ["number"], "in_items": 42 },
{ "field": "q", "types": ["string"], "in_items": 42 },
{ "field": "a", "types": ["string"], "in_items": 42 }
],
"created_at_gmt": "2026-09-21 19:35:22",
"updated_at_gmt": "2026-09-21 19:35:22",
"updated_by": 1,
"option": "onpress_dataset_faq__en"
},
{
"name": "faq",
"lang": "ru",
"items": 34,
"option": "onpress_dataset_faq__ru",
"…": "…"
},
{ "name": "footer", "lang": "en", "items": 1, "option": "onpress_dataset_footer__en", "…": "…" }
]
}
lang is null on a shared dataset. fields is what the items actually contain, sampled from the first 200 of them: more useful than a declared schema when you write the markup that consumes the list.
GET /data/sources
The system sources, read-only, each marked contextual or not.
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/data/sources?blog_id=235"
{
"success": true,
"blog_id": 235,
"home_url": "https://example.onpress.pro",
"readonly": true,
"note": "system sources are read-only: the state of the site is edited through its own endpoints (languages, menus, settings) and arrives here already computed",
"contextual_note": "contextual=true — the source depends on which page is being baked; such a result is cached per frame-and-page pair, the rest are cached once per frame",
"total": 14,
"sources": [
{
"name": "menu.header",
"origin": "system",
"contextual": false,
"description": "Items of the menu assigned to the \"header\" location of the active theme; when a language is given, the Polylang map is used"
},
{
"name": "breadcrumbs",
"origin": "system",
"contextual": true,
"description": "Breadcrumbs of the current page: from the site root down to the page itself"
}
]
}
GET /data/{name}
Reads one source. A system source and a site dataset are read the same way and answer in the same shape.
| Parameter | Description |
|---|---|
page | the current page: id, slug or path; required by contextual sources and by page.* in match, ignored otherwise |
lang | language slug; picks the menu and the dataset variant |
statuses | page statuses for the pages.* sources, comma-separated; publish by default |
match | the same filter as on op/repeat, URL-encoded JSON |
theme | theme whose location map a menu.* source reads; the site’s stylesheet option by default |
A menu in a given language:
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/data/menu.footer_1?blog_id=235&lang=ru"
{
"success": true,
"blog_id": 235,
"name": "menu.footer_1",
"origin": "system",
"contextual": false,
"page_id": null,
"needs_page": false,
"description": "Items of the menu assigned to the \"footer_1\" location of the active theme; when a language is given, the Polylang map is used",
"total": 4,
"meta": { "menu_id": 167, "invalid_items": 0 },
"items": [
{ "id": 6438, "title": "Возможности", "url": "/features/", "type": "post_type", "object": "page", "object_id": 6417, "parent": 0, "order": 1, "children": [], "…": "…" },
{ "id": 6441, "title": "ИИ-инструменты для маркетинга", "url": "https://aitoolsformarketing.pro", "type": "custom", "object": "custom", "object_id": 6441, "parent": 0, "order": 4, "children": [], "…": "…" }
]
}
A dataset variant has option and lang in the answer, and fields and updated_at_gmt from the registry:
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/data/faq?blog_id=235&lang=ru"
{
"success": true,
"blog_id": 235,
"name": "faq",
"origin": "site",
"contextual": false,
"option": "onpress_dataset_faq__ru",
"lang": "ru",
"fields": [
{ "field": "page", "types": ["string"], "in_items": 34 },
{ "field": "n", "types": ["number"], "in_items": 34 },
{ "field": "q", "types": ["string"], "in_items": 34 },
{ "field": "a", "types": ["string"], "in_items": 34 }
],
"updated_at_gmt": "2026-09-21 19:35:24",
"total": 34,
"items": [
{
"page": "pomelli-agent",
"n": 1,
"q": "Что такое Pomelli AI?",
"a": "Это экспериментальный ИИ-инструмент для маркетинга от Google Labs и Google DeepMind, который анализирует ваш сайт и создаёт кампании в фирменном стиле бренда."
},
{ "…": "…" }
]
}
With lang naming a language that has no variant, a shared dataset comes back with "lang": null and a lang_fallback sentence.
PUT /data/{name}
Puts a dataset in place, whole.
| Field | Type | Description |
|---|---|---|
items | array | the list, required; a single object is wrapped into a list of one, with a warning |
lang | string | write the variant for this language instead of the shared dataset |
dry_run | bool | validate and report, write nothing |
blog_id / domain | int / string | network site |
The name is lowercased and must match [a-z0-9][a-z0-9_-]{0,63}; otherwise it is 400 bad_dataset_name with a suggested name in the message. The size limit is 512 KB (DATASET_MAX_BYTES); above it is 413 dataset_too_large with bytes and max_bytes: at that size it is content, not a list for a repeater. Items that are not objects are accepted with a warning, since inside the repeater they have no fields.
curl -s -X PUT -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/data/team" -d '{
"blog_id": 235,
"lang": "ru",
"dry_run": true,
"items": [
{ "id": 1, "name": "Анна", "role": "редактор" },
{ "id": 2, "name": "Олег", "role": "разработчик" }
]
}'
{
"success": true,
"dry_run": true,
"applied": false,
"blog_id": 235,
"dataset": {
"name": "team",
"lang": "ru",
"items": 2,
"bytes": 113,
"sha256": "95966cda6797fb639da02739ce4b22349a88126d2550678f649d29bac795b72d",
"fields": [
{ "field": "id", "types": ["number"], "in_items": 2 },
{ "field": "name", "types": ["string"], "in_items": 2 },
{ "field": "role", "types": ["string"], "in_items": 2 }
],
"updated_at_gmt": "2026-09-21 21:11:01",
"updated_by": 1,
"origin": "site",
"option": "onpress_dataset_team__ru"
},
"warnings": [],
"items": [
{ "id": 1, "name": "Анна", "role": "редактор" },
{ "id": 2, "name": "Олег", "role": "разработчик" }
]
}
A real write answers with "applied": true, updated (whether the variant existed before), the same dataset with created_at_gmt, warnings and rebaked. The dry run does not return rebaked, since nothing was baked.
PATCH /data/{name}
Edits a dataset in place without resending it. The body is {"ops": [ … ]} or a single operation inline; lang picks the variant.
op | Addresses | Payload | Effect |
|---|---|---|---|
set | index or match | item | replaces the item |
merge | index or match | fields | merges fields into the item |
append / prepend | — | item or items | adds to the end or the start |
remove | index or match | — | drops the item |
move | index or match | to | moves the item to that position |
match here is a plain object compared with the item’s fields as strings, first hit wins: {"match": {"id": 2}}. It is not the token filter of op/repeat. Operations are applied in order in one transaction: one failing operation leaves the dataset untouched.
curl -s -X PATCH -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/data/faq" -d '{
"blog_id": 233,
"ops": [
{ "op": "merge", "match": { "id": 2 }, "fields": { "a": "Нет, всё делается агентом" } },
{ "op": "append", "item": { "id": 3, "q": "Есть ли API?", "a": "Да, v2" } }
]
}'
{
"success": true,
"blog_id": 233,
"dataset": { "name": "faq", "items": 3, "bytes": 233, "option": "onpress_dataset_faq", "…": "…" },
"applied": [
{ "op": "merge", "index": 1, "fields": ["a"] },
{ "op": "append", "added": 1, "total": 3 }
],
"total": 3
}
applied reports each operation: index for set, merge and remove, from and to for move. The answer also carries rebaked. A variant that does not exist is 404 dataset_not_found: create it with PUT first.
DELETE /data/{name}
Removes a dataset and its registry entry. With lang (body or query) only that variant goes, the shared dataset and the other variants stay. The answer is deleted with name, lang, items and option, plus rebaked. System sources cannot be deleted.
curl -s -X DELETE -H "Authorization: Bearer $OP_TOKEN" \
"$BASE/data/team?blog_id=235&lang=ru"
POST /data/resolve
Resolves a source exactly the way the printer will, which makes it the check before uploading a frame: you see what op/repeat is going to get, where it came from and whether it is cached per page.
| Field | Type | Description |
|---|---|---|
source | string | the name to resolve; omit it to test an inline list |
items | array | the inline list, used when there is no name or the name resolves to nothing |
page | int / string | the current page: id, slug or path; supplies the page.* values and, without lang, the dataset language |
lang | string | language slug; forces the dataset variant |
match | object / string | the same filter as on the insert, as an object or its JSON string |
statuses | string | page statuses for the pages.* sources |
What a page gets from a filtered FAQ:
curl -s -X POST -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/data/resolve" -d '{"blog_id": 235, "source": "faq", "page": 6412, "match": {"page": "page.slug"}}'
{
"success": true,
"blog_id": 235,
"name": "faq",
"origin": "site",
"contextual": true,
"option": "onpress_dataset_faq__en",
"lang": "en",
"fields": [
{ "field": "page", "types": ["string"], "in_items": 42 },
{ "field": "n", "types": ["number"], "in_items": 42 },
{ "field": "q", "types": ["string"], "in_items": 42 },
{ "field": "a", "types": ["string"], "in_items": 42 }
],
"updated_at_gmt": "2026-09-21 19:35:22",
"total": 6,
"items": [
{
"page": "pomelli-workflow",
"n": 1,
"q": "Is Pomelli still free?",
"a": "Yes. Pomelli is a free beta from Google Labs. Available in the US, Canada, Australia, and New Zealand. Use a VPN if you are outside these countries."
},
{ "…": "…" }
],
"page_id": 6412,
"match": { "page": "pomelli-workflow" },
"match_total": 42
}
The language came from the page (English), match shows the resolved value of every token, match_total counts items before filtering, total after. contextual is true because page.slug is in the filter, even though the dataset itself is not contextual. Without page the same call returns an empty list with needs_page: true and a match_note.
A contextual system source, with the page named by path:
curl -s -X POST -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/data/resolve" -d '{"blog_id": 235, "source": "breadcrumbs", "page": "/pomelli/"}'
{
"success": true,
"blog_id": 235,
"name": "breadcrumbs",
"origin": "system",
"contextual": true,
"page_id": 6419,
"needs_page": false,
"description": "Breadcrumbs of the current page: from the site root down to the page itself",
"total": 2,
"items": [
{ "id": 0, "title": "Pomelli", "slug": "", "url": "/", "current": false },
{ "id": 6419, "title": "Pomelli — бесплатный AI-инструмент от Google для маркетинга малого бизнеса", "slug": "pomelli", "url": "/pomelli/", "current": true }
]
}
Which tokens the service computes
The service computes page.id, page.slug, page.title (the raw post title), page.status, page.type, page.parent_id, site.lang (the language in use), site.blog_id and raw:<value>. Any other token is 400 match_token_unsupported with unsupported and supported lists. The printer knows the whole bind dictionary, so such a filter still works on the page; check it by baking the frame for that page.
{
"success": false,
"error": "match_token_unsupported",
"message": "the service cannot compute \"option.blogname\" for match; supported here: page.id, page.slug, page.title, page.status, page.type, page.parent_id, site.lang, site.blog_id and raw:<value>. The baker knows the full bind dictionary, so such a filter still works on the page — check it by baking the frame for the page",
"unsupported": ["option.blogname"],
"supported": ["page.id", "page.slug", "page.title", "page.status", "page.type", "page.parent_id", "site.lang", "site.blog_id"]
}
A dataset change rebakes frames
A repeater is expanded when a frame is baked, not on every request, so a changed dataset on its own does not reach the page. The service handles that: PUT, PATCH and DELETE bake again every frame whose repeaters read the dataset (the frame registry keeps those names in sources) and report it in rebaked, with status ok, partial, failed or nothing, and the list of frames with the reason each one was picked. Frames with a language-dependent insert are baked once per site language. Frames that render on request have no copy and pick up the change at once.
A dataset written around the API (straight into the option, by a migration or a plugin) is not noticed. Bake the readers by hand: POST /frames/rebake with {"datasets": ["faq"]}, see Frames.
Error codes
| Code | HTTP | When |
|---|---|---|
dataset_name_required | 400 | no name given |
bad_dataset_name | 400 | the name does not match [a-z0-9][a-z0-9_-]{0,63}; a suggestion is in the message |
bad_dataset_items | 400 | items is missing, or is neither an array nor an object |
bad_patch_op | 400 | unknown operation, or the operation lacks its payload |
item_ref_required | 400 | the item was addressed by neither index nor match |
nothing_to_update | 400 | PATCH without operations |
match_token_unsupported | 400 | match uses a token the service cannot compute |
dataset_not_found | 404 | no dataset (or no variant for that lang) with this name |
item_not_found | 404 | no item at that index or matching that object |
source_not_found | 404 | the name is neither a system source nor a dataset, and no inline items were given |
page_not_found | 404 | page names a page that does not exist |
reserved_dataset_name | 409 | the name belongs to the platform |
dataset_too_large | 413 | over 512 KB, before or after a PATCH |
The common codes (token, site, rate limit) are on the Errors page.