Skip to main content
Pages are the structured surfaces a hub renders: homepages, sales pages, link-in-bio pages, and other public or member-facing views.

Admin page management

Pages are scoped to a team and hub:
Use pages when you need a hub-rendered layout, not just a content lesson. A page can carry an optional icon. Send it on create or PATCH as either a name from the hub icon set (the same names spaces accept, for example "calendar") or emoji: followed by exactly one emoji (for example "emoji:🎉"). Any other value returns 422 with source.pointer /data/attributes/icon, and nothing is stored. On PATCH, null clears the icon and omitting it leaves it unchanged. Admin page reads return icon (null when unset); member-facing page reads do not include it.

Managing pages in the admin

The admin Pages list lets you create and delete pages. To view a supported page, select its title or choose View page from the row’s More options menu; it opens the matching page on the member-facing hub in a new tab. Some rows do not have a View page action. A new, unpublished page is not visible to public visitors or members; its signed-in hub owner can open the draft in the inline editor, while other visitors may see not found. The hub workspace sidebar’s View hub link opens the member-facing hub. These external links use the hub origin configured for the current admin environment and are not shown when that origin is unavailable. Hub owners can edit the homepage, custom pages, and sales pages in mio-hub’s inline editor. Open a supported page while signed in to the hub as its owner and select the floating Edit button. The editor opens over the current page and adds ?edit=1 to its URL (for example, /{hubSlug}?edit=1 for the homepage or /{hubSlug}/{pageSlug}?edit=1 for a custom page). The editor is part of the mio-hub release, so its availability depends on the hub environment. Opening a page from the platform admin does not sign you in as a hub contact: public pages can be viewed without a hub session, while a private hub or member-only page may require sign-in through the hub’s existing sign-in flow. The CLI and API operations below remain available for page-tree authoring. DELETE .../pages/{page_id} refuses to delete a system page — login, register, or payments — with 409 system_page_protected, checked before the homepage-orphan guard below. The hub only falls back to that page type’s built-in default form while no row of that type exists at all; deleting an existing one would instead 404 a private hub’s anonymous auth route. Each item in the admin page list carries has_unpublished_changes, so API clients can identify pages with work that is not live yet without reading every tree. It is true when either:
  • the page is new — it has never been published and no legacy section rows serve it, so it is not live anywhere, and its type is one the editor actually renders (generic or sales); a never-published login, register, payments, or other non-editor page type reads false here, since there is nothing an owner could publish for it from the page builder; or
  • the page has a draft that differs from its published tree (on the server the comparison ignores JSON key order and whitespace) — this applies regardless of page type.
A page with no draft (tree_draft is null) never reads as changed on its own once it is published. The flag appears only on the admin list — single-page admin reads and the member-facing page list do not carry it. See Publishing or discarding a whole hub for how the flag lines up with hub-wide publish. Admin page-list items also include the required boolean has_published_tree. It is true only when tree_published exists; legacy section rows do not count. The field is list-only. A page must have a published tree before it can become the hub homepage. Admin page creation with is_homepage: true, page PATCH with is_homepage: true, and hub PATCH with homepage: {"kind":"custom","page_id":"..."} return 409 homepage_page_not_published otherwise. The error’s source.pointer identifies the rejected field (/data/attributes/is_homepage for page create/PATCH, /data/attributes/homepage/page_id for hub PATCH). The MCP create_page tool also rejects is_homepage: true with a conflict result because it creates a draft without publishing it. Publish the page first, then select it as the homepage.

Trash and restore

DELETE .../pages/{page_id} moves a page to the hub’s trash. List the trash with GET .../pages?filter[deleted]=true: deleted pages, most recently deleted first. Each item carries a read-only purge_at (deleted_at plus 30 days): when the page becomes eligible for permanent deletion. It is null on live pages. filter[deleted]=false or omitting it lists live pages, and any other value returns 422 with source.parameter filter[deleted].
A successful restore returns 200 with the page. It comes back in the state it was deleted in (privacy, draft and published tree), so a published page is served to members again, but it never comes back as the hub homepage. The sections deleted along with it come back too, and so do comments that the delete hid; a section or comment deleted on its own earlier stays deleted. Restore returns:
  • 409 slug_taken (source.pointer /data/attributes/slug) when a live page already uses the slug.
  • 409 auth_page_duplicate or 409 content_page_duplicate (source.pointer /data/attributes/type) when the hub already has a live page of a type it allows only once.
  • 404 when the page is live, does not exist, or belongs to another hub or team.
Nothing changes on a 409 or 404.

Sections

Sections are the building blocks inside a page. They can represent text, calls to action, grids, media, search, and other structured page blocks.

Draft-tree authoring

The draft tree is the mutable, unpublished state of a page’s node hierarchy. Write it with:
Team-owner only. The request body is a JSON:API envelope; the If-Match header carries the optimistic-concurrency token (OCC). Bootstrapping the token: read the page first — the admin read (GET /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{identifier}) returns the current version in both attributes.draft_version and the ETag response header. Echo that value as If-Match on your first write; every subsequent write/publish response hands you the bumped token:
The tree must be wrapped in a top-level root key, and root must carry a children array. Below the root, every node needs an id (422 invalid_tree otherwise), settings/dataSource must be objects when present, children must be an array, and the whole tree is capped at 500 elements. The root itself is checked more loosely — its id is optional and its settings is not type-checked — but the renderer wants both, so author it exactly like any other node. See Node shape for what the renderer needs on top of that. On success you get 200 with the bumped version echoed in both the ETag response header and attributes.draft_version:
Store the new draft_version (or echo the ETag value verbatim) as your If-Match token for the next write or publish call. Error codes

Reading the author draft (resolved)

Use the author draft-resolve endpoint in CLI or API authoring tools to read the fully-transformed draft tree. The hub inline editor also uses authoring endpoints to load and save page drafts.
Authentication: platform bearer + team owner. Both query parameters are required. audience must be author and resolve must be true; omitting either is a 400 (invalid_audience / invalid_resolve). Note that the generated endpoint reference currently lists both as optional — the backend declares them as nullable and enforces them in the handler — so a client generated from the spec will compile without them and then fail at runtime. Send both. The endpoint applies the same cascade, action, and dataSource structure transforms as the portal render path so authoring tools can inspect the resolved draft; it reads tree_draft (the live working copy), not the published tree, and it never prunes nodes behind membership gates. Response type: page_trees
The read and write shapes differ. This endpoint returns tree as the bare root node, already unwrapped from the {"root": …} wrapper, so the hub renderer can consume it directly. PUT .../tree requires the wrapper. Re-wrap the node as {"root": <tree>} before writing it back, or the write fails with 422 invalid_tree — “Tree must have a ‘root’ key at the top level.”This is also a resolved projection — action transforms and dataSource.type: "page" expansions have already been applied — so it is a read-back for editing, not a byte-exact copy of what you last wrote.
Error codes

Preview tokens

This endpoint mints a short-lived JWT for an editor integration to use with mio-hub’s /preview/{hubSlug}/{pageId} iframe route. This route is a preview canvas; the hub’s inline editor opens over the regular page route instead.
Authentication: platform bearer + team owner. The token carries aud="preview", a 15-minute TTL, and embeds page_id, hub_id, and the author’s platform user id. The draft-preview route can render a tree pushed over its preview message channel; it is separate from the inline editor, which runs on the regular hub page. The current preview host does not validate this token. Do not treat the token as an alternate auth gate on the resolve endpoint — use the resolve endpoint directly with a platform bearer when you need to read the draft. Response type: preview_tokens
Error codes

Publish

Publishing compiles the editable tree into the served page state.
Send the current draft_version as If-Match (same OCC contract as the tree write). If a concurrent draft write happened after your last read, publish returns 409 — re-read and re-send. The backend uses optimistic concurrency for publish operations. If a frontend edits pages collaboratively, preserve and send the versioning headers shown in the generated endpoint reference.

Publishing or discarding a whole hub

An editor that lets an author change several pages plus hub-level settings needs one “Publish all” and one “Discard all”. Two endpoints do that in a single request each:
Both are all or nothing: either every page, hub field and media item in the request is applied, or none is and the error tells you which item failed. A partial publish never happens, so the author never ends up with half the hub live. Both are team-owner only — a team-owner token, a team API key, or a signed-in owner of this hub. Other team members get 403; a hub that does not exist in the team (or that the caller has no standing in) answers 404.

The pending-ops version is the retry token

The hub editor keeps staged hub-level changes (branding, navigation, settings, media and playlist changes) in the hub’s pending-ops queue:
Both hub-wide endpoints take the queue’s current version as If-Match (the value of the queue’s ETag), and both replace the queue with the ops you did not apply (pending_ops_remaining), bumping its version. That makes a retry safe: if the response to a successful publish is lost and you resend the same request, the queue version has already moved, so the retry fails with 409 stale_pending_ops and nothing is published twice. Re-read the queue and the page list to see where you are. The new queue version comes back in the response’s ETag.

Publish all

Every attribute is optional, but the request must change something.
  • pages — the pages to publish, each with the draft_version you last read (the same token the single-page publish takes as If-Match). A page whose draft already equals its published tree is skipped, not republished, and a page with no draft is never published. Skipped ids come back in skipped_page_ids. Pages you leave out of the list are untouched, including pages created after you read the list.
  • hub — hub-level fields, written exactly as PATCH /api/v1/teams/{team_id}/hubs/{hub_id} would write them: branding and navigation replace the stored object; settings merges the top-level keys you send. clear_settings_keys removes top-level settings keys and accepts only appearance, customCss and footer, and no key may be both set and cleared. expected_etag is required whenever hub is present: one strong entity tag (the hub’s ETag from GET …/hubs/{hub_id}, quoted or bare). *, a weak W/ tag and a comma-separated list are rejected. If navigation newly links to a page that is not live and is not published in the same request, the publish is refused, so a menu never points at a page members cannot open.
  • media — files to publish to the hub. published_at is required: send a timestamp to schedule, or an explicit null to leave the file as a draft. There is no default, so a scheduled file can never go live because a field was left out.
  • playlists — publish (with visibility) or unpublish. Re-publishing a playlist that is already live or scheduled keeps its existing published_at, so changing its visibility never re-dates it. Unpublishing a playlist that is not published is a no-op, reported in skipped_playlist_ids.
  • pending_ops_remaining — the queued ops you did not apply. They stay staged. Send [] to empty the queue.
Pages, media and playlist ids must each appear at most once. A request may list at most 500 pages and 1,000 media and playlist items combined. This is a safety bound on request size, not a limit on how many pages a hub can publish. A successful publish answers 201 Created and records the publish:
Keep hub_etag as the hub’s new entity tag and the response ETag as the new queue version. pending_ops_version_consumed is the If-Match you sent, which lets a client that lost a response recognise its own publish. Afterwards, the pages you published read has_unpublished_changes: false in the admin list. Publishing holds a lock on the hub for the length of the request. If another publish or edit holds it too long, the request fails fast with 503 publish_timed_out; nothing was changed and it is safe to retry.

Discard all

For each listed page, discard does one of three things:
  • Reset — a published page whose draft differs from its published tree gets its draft set back to the published tree and its draft_version bumped. It is listed in pages with the new draft_version.
  • Remove — a never-published page of a type the page builder actually renders (generic, sales) has nothing to go back to, so discarding it deletes the page, exactly as DELETE …/pages/{page_id} would. Its id is listed in removed_page_ids.
  • Clear — a never-published login, register, payments, or other non-editor-owned page is never removed, but if it carries a draft — an editable “slot” inside its otherwise system-rendered form — that draft is cleared (tree_draft set to null, draft_version bumped) and it is listed in pages like a reset. If it has no draft at all, it is left untouched and reported in skipped_page_ids.
  • Skip — a page whose draft already equals its published tree (or that has no draft) is left untouched and listed in skipped_page_ids.
pages, removed_page_ids and skipped_page_ids never share an id. An empty pages list is valid: it only replaces the queue, which discards staged hub-level changes. Draft trees are not echoed back; re-read the open page’s draft with the author draft read.
A page that is live from legacy section rows but has no published tree cannot be reset (there is no published tree to return to) and cannot be removed (it is live), so listing one fails the whole discard with 409 no_published_tree. Removing the page that is the hub’s only homepage is refused with 409 homepage_unset_would_orphan_hub; point the hub at another homepage first.

Errors

Every error names the failing item: source.pointer points at it in request order (for example /data/attributes/pages/1), and item errors carry meta.item with its kind, id and index. After any error, nothing in the request was applied. Both endpoints are rate limited per client IP address (publish: 30 requests per hour; discard: 60 per hour; over the limit answers 429) and accept request bodies up to 1 MB (larger bodies answer 413).

Node shape

The API validates a tree’s structure, not its renderability. A node with the wrong shape is stored, returns 200, and then renders as nothing — with no error anywhere. Everything in this section is a case where that happens.

The node envelope

A section node is the same envelope plus a template and children:
  • value is a sibling of settings, never settings.value. This is the single biggest silent-drop trap: the API stores settings.value happily, the renderer reads node.value, and you get an empty heading, button, or image with a 200 and no error. The one exception is progress-ring, which reads a numeric settings.value.
  • The renderer dispatches on kind, never on type. type is a JSON:API resource-type word and has no meaning inside a node.
  • template marks a node as a section. It opts the node into the surface renderer and tells the publish-time converter which section type to compile. Section roots carry one, and so does the page root — scaffold --template page-homepage emits a root of {"kind":"stack","template":"page-homepage"}. Inner containers legitimately carry none.
  • settings should always be present ({} minimum). Hub-level defaults are resolved at render time, but the code and template layers of the cascade are not wired up, so in practice every value that must render has to be inlined on its own node — and each leaf also applies its own built-in defaults (see the table below).
  • weight must be a number (700), never a CSS keyword ("bold"). An unrecognized weight is discarded rather than dropping the node: a headline lands on its 400 default, while text and field simply render with no weight class — which for a field means losing the weight its role would have applied. A numeric string ("700") happens to resolve, but pages tree set rejects it client-side — write the number.
  • Exactly one level: 1 headline per page. Extra level-1 headlines are demoted to <h2>, keeping their visual size.
  • Every node needs an id, and ids should be unique within the tree. Uniqueness is renderer hygiene rather than a server rule — the API only checks that the key is present, so duplicate ids are accepted and then confuse id-based behaviour like the one-<h1> guard. Short symbolic ids are legal (the catalog’s own recipes use root and hero), but pages catalog scaffold mints fresh UUIDv7 ids, which is the safer habit when you splice subtrees together.

Leaf kinds

These sixteen are the leaf kinds — the nodes that carry content and take no children. They are one family among the catalog’s 33 kinds; the rest are containers (stack, row, grid, carousel, horizontal-scroll, tabs, container, content-card, accordion, banner — a sticky or reveal-after-scroll notice-bar-style band, plan-group — a radiogroup of plan-card children that publishes the selected card), data bindings (field, media-slot, cta-slot) and system blocks (search-bar, file-player, file-attachments). The table names the settings that matter in practice.
The catalog is the authority on settings shapes. Its settingsSchema map carries the complete vocabulary — types, enums, defaults and per-field notes for all 33 node kinds, plus the shared surface, background, gradient and structural shapes — split into core and presentational buckets matching the consumer profiles. GET /api/v1/page-builder/catalog serves it; the catalog reference tracks the current catalogVersion and content digest.
A plan-price node only resolves through a plan-group it can actually find. settings.groupId first tries an exact match against a plan-group node’s id; if none matches, it falls back to the page’s sole registered plan-group — but with zero or two-or-more plan-group nodes and no exact id match, nothing links up and the node renders settings.fallback forever, even after a visitor picks a plan. Keep fallback byte-identical to the group’s defaultSelected plan-card’s own price/period so the pre-hydration and never-linked states don’t visibly disagree.
A freshly scaffolded testimonials section renders an empty carousel. scaffold --template testimonials emits its three quote children with settings: {} and no value at all, and a quote node whose value.quote is missing or empty renders null. Nothing errors — you publish, section_count is correct, and the band is blank. Fill each child’s value object before publishing:
subheadline, paragraph, embed, html, spacer, stat, input, section, and sidebar are not node kinds. They were removed from the renderer or never existed, and authoring one gets you a blank node with a 200. Use headline with level: 3 instead of subheadline, text instead of paragraph, a stack/row gap instead of spacer, and a row whose stack children carry settings.width (1/3 + 2/3) instead of a sidebar. content-grid is a template id, not a node kind. The catalog reference lists every kind the catalog knows.
A button’s action is an object, and its value is always canonical — no scheme prefix, no #:
type is url, page, email, scroll, or playlist. email takes a bare address (no mailto:), scroll a bare anchor id (no #), url a full URL including its scheme, page a system page type, a page id, or a /-prefixed path, and playlist a playlist id. A malformed or missing action leaves a button that renders but navigates nowhere. The legacy settings.href still works but is deprecated.

Surfaces and backgrounds

A section’s settings.surface takes background, gradient, padding, borderRadius, shadow, visibility, and minHeight. background is a discriminated union of exactly six type values:
Two traps live here.Any background.type outside those six is accepted on publish and renders nothing. The renderer falls through to a default case that returns no class and no style, with no warning in production. If a band looks see-through, check the type string first. thumbnail appears in the renderer’s own types but is implemented nowhere and used by no recipe — treat it as one of the values that silently renders nothing.The gradient config is a sibling of background, not nested inside it. Nesting it is silently ignored and you get the default gradient:
gradient.type is monochrome, analogous, complementary, triadic, split, warm-shift, or custom. custom requires valid hex customStart and customEnd; if either is missing or malformed it degrades to split, which is also what an absent gradient resolves to.

Which id a dataSource takes

A data-bound node names what it renders in dataSource: {"type": …, "id": …}, and the namespace of id depends on type. The tree write checks only that dataSource is an object, and publish accepts an id it cannot resolve, so an id from the wrong namespace publishes cleanly and renders an empty section. A file binding that holds a playlist id or a file id makes the renderer request GET /api/v1/hub/{hub_id}/content/{id} on every view, which answers 404, so the section renders as an empty shell. A playlist binding with "resume": true (the “continue watching” hero) still takes the playlist id. To get a file’s content-node id, reconcile its playlist into content items, then pick the lesson whose file_id is the file:
content reconcile adopts content items that already exist, so it is safe to run on a playlist that was reconciled before. content children returns one page of at most 100 rows, and the last line stops the script rather than leaving an empty id. publish does check one thing about a content node: binding a members or paid node with no gate on the node or any ancestor fails with 422 (exit 2) and an R3 invariant violation message.

Hub homepage descriptor

The hub bootstrap response (GET /api/v1/hubs/{slug}, and hub admin read/write routes) carries a resolved homepage descriptor on data.attributes.homepage:

Set a page as home in Admin V3

A team owner can set a published page as the hub homepage from the Admin V3 Pages list by choosing Set as home on that page. Pages without a published page-builder tree are not eligible. Saving the selection replaces the current custom homepage or discussions index; the Home indicator moves after the save succeeds. If the save fails, the existing homepage remains in place. Always read homepage from the bootstrap response before calling GET /api/v1/hub/{hub_id}/pages/home. The descriptor is authoritative; /pages/home is a convenience route that only resolves for one of the three states: /pages/home 404s by design when homepage.kind is "discussions_index". This is not an error state — it means the hub has no custom-kind homepage page, and the homepage surface is the discussions index instead. Gate the call on homepage.kind; don’t call /pages/home unconditionally and branch on its status code, and don’t log the 404 as a failure in that case.

Writing the descriptor

Accepts the same homepage object on data.attributes.homepage. An explicit null clears the descriptor — distinct from omitting the key, which leaves it unchanged (standard PATCH / exclude_unset semantics). On a hub that still carries a legacy is_homepage page, null re-resolves to that page as kind: "custom" on the next read; it does not destructively clear the legacy flag. Error codes Both homepage_discussions_disabled rows are two directions of the same coupling guard — one on the page-side write, one on the hub meta write — and both reject rather than leaving the hub in an inconsistent state.

Rollout note

Existing hubs read homepage: null until the legacy backfill runs; after backfill, any hub with an existing is_homepage page resolves to {"kind": "custom", "page_id": ...}. Single-hub bootstrap reads (GET /api/v1/hubs/{slug}) are always fully resolved, including the legacy fallback — there’s no stale-null window there. List endpoints (GET /api/v1/teams/{team_id}/hubs) and the PATCH .../hubs/{identifier} write-path echo may show a stale homepage: null for legacy, pre-backfill hubs — both intentionally skip the legacy-fallback lookup to avoid extra queries, and self-heal once the backfill completes.

Member and public rendering

Portal routes return hub-renderable pages:
Access rules can prune or hide page nodes for a contact. Anonymous render endpoints return pre-pruned public artifacts when available. GET /api/v1/hub/{hub_id}/pages/{identifier}?resolve=true (and /pages/content?resolve=true) also returns attributes.page_links: a map of the hub’s pages the requesting viewer may open, keyed by page id, so a renderer can turn an Opens › Page action ({"type": "page", "value": "<page id>"}) into a link without another request:
It uses the same rule as opening the page: anonymous viewers and non-members see public pages only, and members (or access-override holders) also see members pages. Any other privacy value, soft-deleted pages, and pages that aren’t published are never listed. content, login, register and payments pages are listed without a published tree, because the hub serves them on their own routes. An id that isn’t in the map should stay unresolved. The map is {} when there’s nothing to list, and it never includes privacy. GET /api/v1/hubs/{slug}/pages/{page_type} is the anonymous, unauthenticated render — page_type is restricted to the anon whitelist (login, register, payments); anything else, or an unknown slug, 404s without disclosing which. Its response carries an additive tree attribute alongside the legacy branding/sections fields — sourced from the fresh anon-tree artifact when available, live-pruned from the published tree otherwise, and null when the page has never been published. tree is served only when the page’s privacy is public — the auth page types default to public on create, but an admin can still set one to members, in which case this endpoint still returns 200 with branding/sections populated as usual and tree: null. Every tree node is additionally filtered through the same catalog anonSafe boundary the sections half already enforces — a node whose resolved type isn’t anon-safe is dropped, subtree included, before the response is built, so the two halves can never disagree about what an anonymous caller may see. See the auth brand panel guide for the full authoring contract this tree field feeds on login/register/payments pages.

Authoring from the CLI

The mio CLI wraps this whole draft → publish contract so you don’t manage the If-Match header by hand:
pages tree set --if-match is optional and defaults to 0 — omit it for the first tree on a draft-less page (pages tree get 404s until a draft exists, so there is no draft_version to read back yet). The 0 default does not bypass the concurrency guard: sending 0 (or any stale value) against a page that already has a draft is rejected as a conflict. For every later write, pass the draft_version from a prior pages tree get — including the first write against a page created by mio hubs scaffold, whose draft already exists at version 1. pages publish --if-match is always required, and its response section_count tells you how many sections actually compiled — treat a shortfall as a dropped section, not a success. Round-tripping an existing tree means re-wrapping it, because tree get returns the bare root node and tree set wants the {"root": …} wrapper:
Catalog discovery has three verbs — mio pages catalog templates, mio pages catalog section-types, and mio pages catalog scaffold. There is no pages catalog list. All three are emit-only and work offline against the CLI’s embedded, digest-pinned catalog copy. See Create a hub for the full homepage recipe and the render-contract gotchas that silently drop malformed nodes.

Templates and section types

The full list of available templates and section types is published in the Page-builder catalog, generated from the live catalog. Page templates come in three kinds. An outline (page-homepage, page-generic) carries no copy: scaffold each section from its own template and splice it into the page root’s children. A complete page (page-sales, page-homepage-starter) is finished sections with placeholder copy: edit its values in place. A system page (page-login, page-file-detail, and the other pages the hub routes itself) holds fixed regions: fill in their values and add no sections. See Create a hub for how to tell them apart and for a sales-page recipe.