How it works
What happens behind an API call: who does what between Node and PHP, where everything is stored, and how frames are baked, printed on request and rebaked.
You can use the API without reading this page. It explains the shape of the responses — why a frame write returns bake and rebaked, why a page write does not wait for caches — and what to do when a change is made around the API.
Node owns the contract, PHP executes
Every public v2 endpoint lives in the Node service (nodejs/ in the site repository). Node owns the contract, authentication with the op_ token, the choice of network site and storage: it writes straight into the WordPress tables in one transaction, without booting WordPress, so saving a page does not cost a full load of core, plugins and theme.
PHP does not leave the picture and cannot: it executes whatever needs WordPress itself. Expanding <!-- wp:op/… /--> inserts is parse_blocks() plus render_block() over registered block types; data-op-bind attributes are rewritten with WP_HTML_Tag_Processor; menus with their visibility rules, Polylang languages and Yoast indexables are PHP code. Node reaches it through an internal contour — POST /wp-json/onpress/v1/internal/frame-bake and …/internal/content-saved on the site’s own domain, closed by a shared secret in the X-OnPress-Internal-Token header and not reachable from outside.
You never call that contour. It shows in one place: the bake field of frame endpoints, which says whether baking reached PHP.
bake.status | Meaning |
|---|---|
ok | PHP baked the frame; its answer is in bake.response |
unavailable | the bake endpoint is not deployed on that site; the source is saved, bake later |
failed | PHP answered with an error or did not answer in time; bake.reason says which |
skipped | baking was switched off for this call ("bake": false) or in the service config |
A failed bake never cancels the write: the source is already in the database, and the next save or a rebake call publishes it.
Only through an endpoint
The development rule of v2: every operation is done through an endpoint. If there is none, it is written first, and the work is done with it. The rest of this page is a consequence — the service can keep registries, dependencies and rebaking consistent only for changes it sees. For changes it does not see there is a safety net, described at the end, but it is a net, not a way of working.
Storage: options of one site
| Option | Contents |
|---|---|
onpress_frames | the frame registry: slug, name, BEM parts, size, hash, inserts, sources, frame_refs, binds, renders_on_request |
onpress_frame_<id> | a frame’s markup, exactly as uploaded |
onpress_frame_<id>_baked_<lang> | the baked copy for one language; the default language also under onpress_frame_<id>_baked |
onpress_datasets | the dataset registry |
onpress_dataset_<name>, onpress_dataset_<name>__<lang> | a dataset and its language variants |
onpress_custom_css, onpress_design_tokens | site CSS and design tokens, see Styles |
onpress_apps | the apps registry, which also holds statically published pages: page-<id> with the page path in custom_url |
A page is bound to its frame by the page meta _onpress_frame, holding the frame id — so renaming a frame never touches pages. The frame has no address of its own: whatever the site serves at an address is a page.
Everything lives in the options of the network site it belongs to, never in network options. The reason is concrete: the staging environment shares its database with production, and a network option would roll out to every site at once. For the same reason every request names its site, and a request without one goes to blog 1 — see Authentication.
After a page is written: content_saved
A page write commits first. Only after the commit does Node send the onpress/content_saved event to PHP on the site’s own domain, and it does not wait for the answer: the response returns as soon as the data is in the database. On the PHP side the event flushes the object cache, rebuilds the Yoast indexable and warms the page cache.
The event goes to the site’s domain rather than to a common address with blog_id, because Yoast silently skips rebuilding inside switch_to_blog(). When a translation group changes, the event is sent to every page that was or is in the group, so hreflang appears and disappears on all of them. The practical consequence: right after a write, a cached page may still show the old version for a moment.
Printing a frame: baked copy or on request
Frames are printed in one of two ways.
Baked copy. PHP expands the inserts and the bindings once and stores the result as onpress_frame_<id>_baked_<lang>. Frames whose inserts depend on language (op/menu, op/lang-switcher, op/repeat) are baked once per site language, so a Russian page gets the Russian menu; others are baked once. From that copy the pages bound to the frame are published: each becomes the apps-registry entry page-<id> with its path in custom_url and the file storage/apps/{blog_id}/page-<id>/index.html, which nginx serves without PHP. Publishing statically is therefore a property of the page — a frame is only a template, and it has no address to be published at.
On request. A frame whose output depends on the page being shown gets renders_on_request: true in the registry and has no baked copy at all — existing copies are deleted on the next bake. One copy would show one page’s text on another; that has happened, which is why the rule is strict. A frame is printed on request when it contains any of:
- the inserts
op/content,op/pager,op/docs-nav,op/outline,op/share-ai,op/featured-image,op/terms,op/assistant,op/faq-schema(and the legacyop/partial); - a
data-op-bindthat uses apage.*token; - an
op/repeatwhosematchtakes a value from the page (page.slugand the like); - an
op/repeatover a contextual source —pages.children,breadcrumbsand the other page-relative system sources; - an
op/framethat nests a frame printed on request.
POST /frames reports this as renders_on_request with live_reasons. "empty":"frame" on a repeater does not make a frame live: an empty list hides the frame in a copy just as well. A page whose frame is printed on request is never published statically — there is no copy to publish, and the theme renders it.
Pages of the site are printed by the theme through onpress_page_frame_html(), which expands the page’s frame from its source on every request, nested op/frame frames included, in the context of the page being shown. data-op-bind attributes are substituted in that pass too, so they are always current.
The source is never touched
The stored markup is always the source, with every <!-- wp:op/… /--> insert and every data-op-bind intact. Substitution happens when a frame is printed, never on the way in. That is what makes rebuilding possible: after the menu or the footer changes there is still something to bake from, and baking twice gives the same result as baking once.
Rebaking: automatic
The registry records each frame’s dependencies: inserts by name, sources for the datasets its repeaters read, frame_refs for nested frames. “What changed” turns into “who has to be baked” in one lookup, and the service does it after every write it makes itself:
| Write | Frames rebaked |
|---|---|
a frame (POST, PUT, PATCH with rename) | the frame itself and every frame that nests it, transitively along frame_refs |
a menu (/menus) | frames with a menu insert |
a dataset (PUT, PATCH, DELETE /data/{name}) | frames whose repeaters read that dataset |
The result comes back in rebaked: each frame, the language, the reason it was picked (because) and whether the bake succeeded. A bake also refreshes the static copies of the pages that name the frame; PHP lists them in bake.response.published.pages as {id, url, published, path, bytes, reason}. Design tokens and custom CSS trigger nothing: they reach the page as CSS at request time and are never baked into markup. Settings, languages and pages have no automatic trigger in the service — after such a change, rebake by hand.
Rebaking: manual, POST /frames/rebake
For changes the service did not see — a menu edited in wp-admin, an option written by hand, a new version of the printer itself.
| Field | Type | Description |
|---|---|---|
all | bool | bake every frame of the site |
menus | bool | frames with a menu insert (op/menu) |
languages | bool | frames with a language switcher (op/lang-switcher) |
settings | bool | frames with op/socials, op/copyright, op/footer-columns |
pages | bool | frames whose inserts list pages |
datasets | array | frames whose repeaters read these datasets |
frames | array | frames that nest these frames, by id or slug |
curl -s -X POST -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/frames/rebake" -d '{"blog_id": 233, "menus": true}'
{
"success": true,
"blog_id": 233,
"changed": { "menus": true },
"rebaked": {
"status": "ok",
"total": 5,
"baked": 5,
"frames": [
{ "id": "aad2f51f8ea02682", "slug": "site__header", "lang": "en", "because": ["menus: op/menu"], "bake": "ok" },
{ "id": "aad2f51f8ea02682", "slug": "site__header", "lang": "ru", "because": ["menus: op/menu"], "bake": "ok" }
]
}
}
Each entry of rebaked.frames also carries pages — the ids of the pages printed in that frame, whose static copies the bake refreshed — when the frame has any. rebaked.status is ok when every bake succeeded, partial when some did, failed when none did, and nothing when no frame depends on what you named. A call that names nothing is 400 nothing_to_rebake. At most 25 frames are baked per call; when more depend on the change, the response carries skipped and cap, and {"all": true} in a few passes is the way through.
Changes around the API: the PHP watcher
Node keeps the registries onpress_frames and onpress_datasets, but it cannot learn about a change made past it: an option rewritten by hand, a dataset edited in wp-admin, markup replaced by a migration. Then the registry would remember old sizes, hashes and — worse — old dependencies, and targeted rebaking would miss. So PHP recounts the registry entry on such a change (class-frames-registry.php), with a port of the same inspection code the service uses, so entries written by Node and by PHP do not differ.
Next to it, class-frames-watch.php rebakes the frames themselves on wp_update_nav_menu, save_post and updated_option. A hook only marks what is stale; baking runs on shutdown, after the response has been sent, in small portions from a queue. It does not rely on WP-Cron.
This net catches the usual cases. For anything else — or when in doubt — call POST /frames/rebake.
Related
Frames — upload, naming, linking. Inserts and Bindings — what gets substituted. Data — datasets and sources. Rename a frame — a rename with rebaking, step by step.