Working with Pages
Read and safely change page content through the REST API: the page document, merge vs replace updates, surgical batch operations with ETags, drafts and preview tokens, schema and item reads, and page versions.
This guide is for changing content on an existing page without wiping canvases or racing other
writers. Auth and the shared error envelope live in the API overview and
Authentication. Send Authorization: Bearer $TOKEN. User-scoped tokens
also need X-Nimbu-Site: <site_id>; site-scoped tokens do not. Theme editables:
Pages in themes. Create a page with
POST /pages.
{page_id} accepts a Mongo ObjectId or the fullpath without a leading slash. Read JSON emits fullpath with a slash (/about/team). Nested paths work on GET/PUT /pages/* and on draft/version routes; schema, items, and live batch capture [^/]+ — use the id when the path contains a slash.
The page document
GET /pages/{page_id} returns the live page. Top-level
translated fields (title, slug, fullpath, seo_*, public_url) are always the site
default locale, even with ?content_locale=. Other locales sit under translations. Disabled
editables and repeatables are omitted — there is no enabled key.
curl "https://api.nimbu.io/pages/about/team" \
-H "Authorization: Bearer $TOKEN"{
"id": "66f0a1b2c3d4e5f60718293a",
"url": "https://api.nimbu.io/pages/about/team",
"created_at": "2026-03-01T09:00:00.000Z",
"updated_at": "2026-09-10T14:22:01.123Z",
"published": true,
"homepage": false,
"template": "page.liquid",
"channel": null,
"filter": null,
"depth": 1,
"security_mechanism": "none",
"locales": [],
"fixed_locale": null,
"position": 2,
"og_image": null,
"parent": "66f0a1b2c3d4e5f607182930",
"parent_path": "/about",
"public_url": "https://www.acme.be/about/team",
"slug": "team",
"fullpath": "/about/team",
"title": "Our team",
"seo_title": "Our team | Acme",
"seo_description": "Meet the people behind Acme.",
"seo_keywords": "team, about",
"items": {
"Title": {
"slug": "Title",
"type": "field",
"created_at": "2026-03-01T09:00:00.000Z",
"updated_at": "2026-09-10T14:22:01.123Z",
"content": "Our team",
"translations": { "en": { "content": "Our team" }, "nl": { "content": "Ons team" } }
},
"Blocks": {
"slug": "Blocks",
"type": "canvas",
"created_at": "2026-03-01T09:00:00.000Z",
"updated_at": "2026-09-10T14:22:01.123Z",
"content": null,
"repeatables": [
{
"id": "66f0a1b2c3d4e5f607182941",
"created_at": "2026-03-01T09:10:00.000Z",
"updated_at": "2026-09-10T14:22:01.123Z",
"slug": "hero",
"position": 0,
"items": {
"Title": { "slug": "Title", "type": "field", "content": "People first" },
"Theme": { "slug": "Theme", "type": "select", "content": "Primary" },
"Show CTA": { "slug": "Show CTA", "type": "switch", "content": "true" },
"Related page": { "slug": "Related page", "type": "reference", "reference_type": "pages", "reference_id": "66f0a1b2c3d4e5f60718293b", "reference_slug": "contact", "reference_title": "Contact" }
}
},
{
"id": "66f0a1b2c3d4e5f607182942",
"created_at": "2026-04-12T11:00:00.000Z",
"updated_at": "2026-09-10T14:22:01.123Z",
"slug": "text_with_image",
"position": 1,
"items": {
"Title": { "slug": "Title", "type": "field", "content": "How we work" },
"Body": { "slug": "Body", "type": "text", "content": "<p>Small teams, clear owners.</p>" },
"Image": {
"slug": "Image",
"type": "file",
"content": "https://cdn.nimbu.io/files/studio.jpg",
"file": { "filename": "studio.jpg", "url": "https://cdn.nimbu.io/files/studio.jpg", "content_type": "image/jpeg", "version": "v1", "checksum": "9f86d081884c7d659a2feaa0c55ad015", "size": 184320, "width": 1600, "height": 900 }
}
}
}
]
}
},
"translations": {
"en": { "title": "Our team", "slug": "team", "fullpath": "/about/team", "seo_title": "Our team | Acme", "seo_description": "Meet the people behind Acme.", "seo_keywords": "team, about", "public_url": "https://www.acme.be/about/team" },
"nl": { "title": "Ons team", "slug": "team", "fullpath": "/about/team", "seo_title": "Ons team | Acme" }
}
}Nested items also carry created_at / updated_at (elided above except on Title and each
repeatable). url is the API self URL; public_url is the site URL. template is the template
name. parent is the parent id. translations only appears on multi-locale sites.
Top-level fields
| Key | Type | Read | Write |
|---|---|---|---|
id | string | ObjectId | read-only |
url | string | API /pages/{fullpath} | read-only |
public_url | string | site URL | read-only |
fullpath | string | leading / | read-only (recomputed from parent + slug) |
parent_path | string | null | "/" + parent.fullpath | ignored on write |
parent | string | null | parent id | id or fullpath (also parent_id) |
depth | integer | tree depth | read-only |
created_at / updated_at | datetime | ISO-8601 with ms | read-only (writes bump updated_at) |
published | boolean | default true | yes |
homepage | boolean | yes | |
template | string | null | name | name or id (also template_id) |
channel | string | null | slug | slug or id (also channel_id) |
filter | string | null | templatized pages | yes |
security_mechanism | string | see below | none | humans | customers |
locales | string[] | default [] | yes |
fixed_locale | string | null | yes (any Nimbu-supported locale, not checked against site locales) | |
position | integer | sibling order | yes |
og_image | file | null | file object | File / FileRef / remove |
slug | string | translated | yes (slugified; changes live fullpath) |
title | string | translated, default locale | yes |
seo_title / seo_description / seo_keywords | string | translated | yes |
items | object | slug → item | merge / replace |
translations | object | { locale: { … } } | nested hash or ?content_locale= |
Write-only aliases (not in the read JSON): template_id, parent_id, channel_id. Not in this API:
redirect / redirect_to. Writable but not serialized: caching_strategy. Dropped on write:
security_password / security_access_groups.
security_mechanism
| Value | Sitemap / llms.txt | robots.txt | HTML / headers | Login |
|---|---|---|---|---|
none | included when published (not homepage) | no extra Disallow | no robots-none | public |
humans | hidden | Disallow each locale URL | <meta name="robots" content="none"> + X-Robots-Tag: none | no login |
customers | hidden | not listed | no robots-none | logged-in customer required |
humans hides the page from crawlers. It does not require login.
items and repeatables
items is a hash keyed by slug, not an array. Page items have no id and no position
in JSON. Types: field, text, file, switch, select, reference, canvas. A canvas has
repeatables[]. Each repeatable has id, slug, position, items. Address repeatables by
id only.
| Key | On item JSON? | Writable on PUT |
|---|---|---|
id | no (yes on repeatables) | repeatable id finds the existing block |
type / created_at / updated_at / fallback_content | yes (fallback_content when content is blank + template default) | no |
slug | yes | no on items; yes on repeatables |
position | no on items; yes on repeatables | yes on repeatables (?replace=1 rewrites it) |
content | yes (not on references) | yes |
file | file items | write file → source |
reference_id / reference_ids / reference_slug(s) | references | yes |
reference_type / reference_title(s) | references | read-only |
repeatables | canvases | array or { "repeatables": […] } |
translations | multi-locale, non-reference items | ?content_locale= or nested translations |
Editable types (read shape)
| Type | Read JSON |
|---|---|
field / text / select / switch | { "slug": "Title", "type": "field", "created_at": "…", "updated_at": "…", "content": "…" } — select stores the option value; switch stores "true" / "false" |
file | { "slug": "Image", "type": "file", "content": "https://…/studio.jpg", "file": { "filename", "url", "content_type", "version", "checksum", "size" } } — content is the URL; images add width / height; X-Nimbu-Client-Version adds "__type": "File" |
reference (one) | { "slug": "Related page", "type": "reference", "reference_type": "pages", "reference_id": "66f0…", "reference_slug": "contact", "reference_title": "Contact" } — no content; page slugs are the referenced fullpath |
reference (many) | { "reference_type": "products", "reference_ids": ["…"], "reference_slugs": ["…"], "reference_titles": ["…"] } |
canvas | { "slug": "Blocks", "type": "canvas", "content": null, "repeatables": [{ "id", "slug", "position", "items" }] } |
On multi-locale sites the page adds translations ({ locale: { title, slug, fullpath, seo_*, public_url } }).
Item content is also translated. Repeatable containers are not.
Read shape vs write shape
Do not round-trip a GET body as a PUT body. Read-only: id, url, public_url, fullpath,
parent_path, depth, created_at, updated_at. translations is readable; write via nested
translations or ?content_locale=. parent_path is ignored on write — send parent.
File editable write forms (PUT)
PUT/PATCH run FilesProcessor on file / source.
| Input | Effect |
|---|---|
{ "__type": "File", "filename": "x.jpg", "attachment": "<base64>" } | upload (raw base64, no data: prefix) |
{ "__type": "FileRef", "source": "nimbu://<site_short_id>/<resource>/<id>/<field>[/<embedded_id>]" } | copy an existing Nimbu file |
{ "__type": "File", "remove": true } | clear the file |
{ "__type": "File", "url": "https://…" } (GET shape, no attachment) | ignored (no-op) |
Files on private storage read back with extra private, permanent_url, permanent_relative_url keys.
resource ∈ entries | products | pages | uploads | tmp. Pages/entries require a field
name. Products require images or attachments plus an embedded id. tmp requires a filename.
| Form | Server? | Notes |
|---|---|---|
__type: FileRef + source: "nimbu://…" | yes | only FileRef form the API accepts |
__type: File + attachment + filename | yes | PUT upload |
{ "data", "filename", "content_type" } | batch set only | not a PUT attachment |
attachment_url / attachment_path | no | nimbu CLI only (pages update --file, pages get --download-assets) — see CLI workflows |
References (PUT)
Scalar id, or { "reference_id" } / { "reference_ids" } / { "reference_slug" } /
{ "reference_slugs" }. A scalar array of ids writes reference_ids. PUT does not check that
the id exists. Batch set does (reference_not_found) and does not accept slugs — only an
ObjectId, an array of ids, { "reference_id" }, or { "reference_ids" }.
Translations
Both styles leave unmentioned locales untouched. Unknown/disabled content_locale → 422
{ "message": "Invalid content_locale: xx is not enabled for this site" }.
Page-level via query param:
curl -X PUT "https://api.nimbu.io/pages/about/team?content_locale=nl" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "title": "Ons team", "seo_title": "Ons team | Acme" }'Item-level (same param; writes content in that locale):
curl -X PUT "https://api.nimbu.io/pages/about/team?content_locale=nl" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "items": { "Title": "Ons team" } }'Nested hash (no query param):
curl -X PUT "https://api.nimbu.io/pages/about/team" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "translations": { "nl": { "title": "Ons team", "seo_title": "Ons team | Acme" } } }'Page translated fields: title, slug, fullpath, seo_title, seo_description, seo_keywords.
Item: content only. Batch has no per-op locale — use ?content_locale= on the batch URL; it
scopes every localized write in that request and leaves the default locale untouched.
Full-document updates: PUT /pages/{id}
PUT /pages/{page_id} uses merge semantics. PATCH and
POST on the same path run the same code. Use PUT. If-Match is not required on PUT/PATCH
today (Rails PR #1871 adds optional If-Match on PATCH/DELETE; not deployed yet). A successful PUT
still bumps updated_at and the content ETag.
Only keys present in the payload are touched. Omitted top-level fields stay. Omitted items
stay. Unknown item slug → 422 { "message": "invalid editable <Slug>" }. Unknown repeatable slug →
422 { "message": "invalid slug (<slug>) for repeatable in canvas '<Canvas>'" }. PUT stores any
string on select / switch (no option check). Batch set validates.
Canvas rules
Default (no replace) | ?replace=1 | |
|---|---|---|
Repeatable with existing id | update in place | update; positions rewritten to payload order |
Repeatable without id | append | created; positions rewritten |
id + "_destroy": true | deleted | deleted |
| Repeatable not listed | untouched | deleted (for that canvas) |
Canvas omitted from items | untouched | untouched |
"items": { "Blocks": [] } | nothing listed → nothing deleted | deletes every block in Blocks |
replace applies only to canvases present in the payload. _destroy still deletes under
replace.
Canvas-wipe risk.
PUT /pages/{id}?replace=1with"items": { "Blocks": [] }deletes every block inBlocks. Never resend a GET document withreplace=1: read shape ≠ write shape (files come back asfile/contentURLs — a URL-only File is a no-op; references come back asreference_id+reference_slug+ titles).
Prefer PUT when creating or reordering many blocks at once, or for an initial fill you fully own. For one or two fields, use batch.
curl -X PUT "https://api.nimbu.io/pages/about/team" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Our team",
"published": true,
"items": {
"Title": "Our team",
"Blocks": [
{
"id": "66f0a1b2c3d4e5f607182941",
"items": {
"Title": "People first",
"Theme": "Secondary",
"Show CTA": "true",
"Related page": { "reference_id": "66f0a1b2c3d4e5f60718293b" }
}
},
{
"slug": "cards",
"items": { "Title": "What we offer", "Theme": "Primary" }
}
]
}
}'No replace: updates the hero in place, appends cards, leaves 66f0a1b2c3d4e5f607182942
alone.
Surgical updates: POST /pages/{id}/batch
POST /pages/{page_id}/batch applies up to
10 operations. Live batch requires If-Match. Prefer this over PUT for existing pages.
Operations
op | Required | Target |
|---|---|---|
set | path, value | page field, editable, or repeatable (slug / position) |
insert | path ending /repeatables, value.slug, optional value.items, optional after | canvas |
delete | path | repeatable only |
move | path, optional after | repeatable only |
Path grammar
Segments are URL-decoded (CGI.unescape, so + also becomes a space; a literal + in a slug needs %2B). A slug with a space is Button%20primary in URLs; inside
a JSON path either %20 or a literal space works. Repeatables are addressed by id only — a
non-id after /repeatables is Invalid repeatable ID. Positions are not addressable. Max canvas
nesting is 2. Get ids from GET or schema current_structure. Stored position is an opaque
sort key (PUT replace often starts at 0; insert assigns from 1).
| Kind | Path | Example |
|---|---|---|
| Page field | /<field> | /title |
| Top-level editable | /items/<Slug> | /items/Title |
| Repeatable | /items/<Canvas>/repeatables/<id> | /items/Blocks/repeatables/66f0a1b2c3d4e5f607182941 |
| Editable inside a repeatable | /items/<Canvas>/repeatables/<id>/items/<Slug> | /items/Blocks/repeatables/66f0a1b2c3d4e5f607182941/items/Title |
| Nested canvas | /items/<Canvas>/repeatables/<id>/items/<Nested>/repeatables/<id2>/items/<Slug> | /items/Blocks/repeatables/66f0…/items/Cards/repeatables/66f1…/items/Title |
New repeatable (insert) | /items/<Canvas>/repeatables | /items/Blocks/repeatables |
insert after: "<id>" (after that block), null (first), omitted (append). Unknown id → append
warning:Anchor '<id>' not found, appending to end.value.slugis not checked against the theme: an unknown slug is created withstatus: "ok"and only disabled on the next template sync — take slugs from schemaavailable_blocks. Insert applies item values viacontent(scalar or{ "content": "…" }). File / reference hashes on insert are not assigned —setthe new path afterwards. Result includesidandpath(/items/Blocks/repeatables/<new_id>).
move uses the same after rules except omitted after is a no-op. Unknown anchor → end +
warning. delete on anything but a repeatable → invalid_delete_target.
set values
| Target | Accepted value |
|---|---|
field / text | string, or { "content": "…" } |
switch | true / false / "yes" / "no" / "1" / "0" / "on" / "off" → stored "true" / "false"; else invalid_switch_value |
select | must match a template option (case-insensitive) or invalid_select_value; null / blank clears the field |
reference | ObjectId, array of ids, { "reference_id" }, { "reference_ids" } (must exist → reference_not_found) |
file | FileRef hash, or { "data": "<base64 or data: URL>", "filename": "…", "content_type": "…" } (max 25 MB; html/js/svg/xml/css → invalid_content_type). Unlike PUT, batch also accepts { "url": "https://…" } or a bare URL string and stores it as the file source. |
canvas | cannot set → invalid_operation |
| Repeatable | { "slug": "…", "position": N } — N is 1-based, clamped; < 1 → invalid_position |
| Page field | only /title, /slug, /seo_title, /seo_description, /seo_keywords, /published — else invalid_field. /slug changes the live fullpath. /published uses the same truthy/falsy set as switch (admin users can also POST /pages/{id}/publish / /unpublish without If-Match). |
ETag
MD5 of "<page_id>-<updated_at as float seconds>" (Digest::MD5.hexdigest("#{page.id}-#{page.updated_at.to_f}")).
Compared as one strong value. Quotes optional (If-Match: 3f1c… and If-Match: "3f1c…").
Where to read it today on production
- Every successful batch response: body
etagand headerETag. GET /pages/{id}headerX-Nimbu-Content-ETag— (pending Rails PR #1871). Not deployed yet.
The plain ETag header on GET is Rack's weak body digest (W/"…"). If-Match takes one strong value and will not accept it.
Workaround until X-Nimbu-Content-ETag ships: compute it from the GET body. updated_at is
ISO-8601 with milliseconds. Use the shortest float representation of that timestamp in seconds:
import hashlib
from datetime import datetime
page_id = "66f0a1b2c3d4e5f60718293a"
updated_at = "2026-09-10T14:22:01.123Z"
ts = datetime.fromisoformat(updated_at.replace("Z", "+00:00")).timestamp()
etag = hashlib.md5(f"{page_id}-{ts!r}".encode()).hexdigest()If this disagrees with a later batch etag, trust the batch value.
| Request | Response |
|---|---|
If-Match missing or "" | 428 { "code": "precondition_required", "message": "If-Match header is required" } |
Stale If-Match | 412 { "code": "precondition_failed", "message": "Page was modified", "current_etag": "<current>" } — re-read, retry once with current_etag |
| TOCTOU / lock | 422 { "code": "concurrent_modification", "message": "Page was modified by another request" } (lock timeout: "Page is being modified by another request") |
Error envelope: { "message", "code" }. 412 also has top-level current_etag.
atomic, include=result, results[]
Default is non-atomic: HTTP 200 even if some ops failed. Successful ops are saved.
Always inspect results[], not only the status code. Failed op:
{ "status": "error", "error": { "code", "message" } }.
?atomic=true: any failure → 422 { "code": "atomic_failure", "message": "Atomic batch failed, changes rolled back", "results": […] } and nothing saved.
?include=result adds page (full page document) on success.
Success (200) body: { "results": [{ "index": 0, "status": "ok", "path": "/items/Title" }], "etag": "…", "updated_at": "…" } plus header ETag. Insert results also carry id and path. warning may appear. Limit 10. operations: [] → 422 empty_batch. Non-array → 422 invalid_request. Redis down → 503 service_unavailable.
Batch-level error codes
| HTTP | code | When |
|---|---|---|
| 428 | precondition_required | missing If-Match |
| 412 | precondition_failed | stale If-Match (current_etag in body) |
| 422 | invalid_request | operations is not an array |
| 422 | empty_batch | [] |
| 422 | batch_too_large | more than 10 |
| 422 | validation_failed | page save errors |
| 422 | concurrent_modification | lock timeout or ETag changed before save |
| 422 | atomic_failure | ?atomic=true and any op failed |
| 503 | service_unavailable | Redis unavailable |
Per-op error codes
| Group | Codes |
|---|---|
| any | path_not_found, unknown_operation |
| set | missing_value, invalid_target, invalid_field, invalid_position, invalid_container_value, invalid_container_parent, invalid_boolean, invalid_operation (canvas), invalid_switch_value, invalid_select_value, invalid_reference_format, invalid_reference_id, reference_not_found, invalid_file_format, invalid_base64, file_too_large, invalid_content_type, unauthorized, not_found, copy_failed, validation_failed, set_failed |
| insert | missing_value, missing_slug, invalid_insert_path, not_a_canvas, insert_failed |
| delete | invalid_delete_target, delete_failed |
| move | invalid_move_target, move_failed |
Examples
1. Change one editable
curl -X POST "https://api.nimbu.io/pages/66f0a1b2c3d4e5f60718293a/batch" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "3f1c8d9e2a4b5c6d7e8f9012abcdef01"' \
-d '{
"operations": [
{ "op": "set", "path": "/items/Title", "value": "Our team" }
]
}'{
"results": [{ "index": 0, "status": "ok", "path": "/items/Title" }],
"etag": "9a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d",
"updated_at": "2026-09-10T16:25:00+02:00"
}2. Same write in locale en
?content_locale=en scopes every localized write in this request to en and leaves the default
locale untouched when en is not the default.
curl -X POST "https://api.nimbu.io/pages/66f0a1b2c3d4e5f60718293a/batch?content_locale=en" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "9a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d"' \
-d '{
"operations": [
{ "op": "set", "path": "/items/Title", "value": "Our team" },
{ "op": "set", "path": "/seo_title", "value": "Our team | Acme" }
]
}'{
"results": [
{ "index": 0, "status": "ok", "path": "/items/Title" },
{ "index": 1, "status": "ok", "path": "/seo_title" }
],
"etag": "c0ffee01c0ffee01c0ffee01c0ffee01",
"updated_at": "2026-09-10T16:25:12+02:00"
}3. Insert a block after another
curl -X POST "https://api.nimbu.io/pages/66f0a1b2c3d4e5f60718293a/batch" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "c0ffee01c0ffee01c0ffee01c0ffee01"' \
-d '{
"operations": [
{
"op": "insert",
"path": "/items/Blocks/repeatables",
"after": "66f0a1b2c3d4e5f607182941",
"value": {
"slug": "cards",
"items": { "Title": "What we offer", "Theme": "Primary" }
}
}
]
}'{
"results": [
{
"index": 0,
"status": "ok",
"path": "/items/Blocks/repeatables/66f0a1b2c3d4e5f607182943",
"id": "66f0a1b2c3d4e5f607182943"
}
],
"etag": "aa11bb22cc33dd44ee55ff6677889900",
"updated_at": "2026-09-10T16:26:00+02:00"
}4. Reorder + delete in one atomic batch
curl -X POST "https://api.nimbu.io/pages/66f0a1b2c3d4e5f60718293a/batch?atomic=true" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "aa11bb22cc33dd44ee55ff6677889900"' \
-d '{
"operations": [
{
"op": "move",
"path": "/items/Blocks/repeatables/66f0a1b2c3d4e5f607182942",
"after": "66f0a1b2c3d4e5f607182943"
},
{
"op": "delete",
"path": "/items/Blocks/repeatables/66f0a1b2c3d4e5f607182941"
}
]
}'{
"results": [
{ "index": 0, "status": "ok", "path": "/items/Blocks/repeatables/66f0a1b2c3d4e5f607182942" },
{ "index": 1, "status": "ok", "path": "/items/Blocks/repeatables/66f0a1b2c3d4e5f607182941" }
],
"etag": "11223344556677889900aabbccddeeff",
"updated_at": "2026-09-10T16:26:30+02:00"
}A bad delete path on the same URL would return 422 atomic_failure and persist neither op.
5. 412 retry
curl -X POST "https://api.nimbu.io/pages/66f0a1b2c3d4e5f60718293a/batch" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "3f1c8d9e2a4b5c6d7e8f9012abcdef01"' \
-d '{
"operations": [
{ "op": "set", "path": "/items/Title", "value": "Our team" }
]
}'{
"code": "precondition_failed",
"message": "Page was modified",
"current_etag": "11223344556677889900aabbccddeeff"
}Re-read (or take current_etag only if the write is still correct), then retry once:
curl -X POST "https://api.nimbu.io/pages/66f0a1b2c3d4e5f60718293a/batch" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "11223344556677889900aabbccddeeff"' \
-d '{
"operations": [
{ "op": "set", "path": "/items/Title", "value": "Our team" }
]
}'{
"results": [{ "index": 0, "status": "ok", "path": "/items/Title" }],
"etag": "deadbeefcafebabe0123456789abcdef",
"updated_at": "2026-09-10T16:27:00+02:00"
}Reading smaller pieces
Schema
GET /pages/{page_id}/schema is what the
active theme currently knows. Use it before insert and before set on a select. No template
→ {}.
curl "https://api.nimbu.io/pages/66f0a1b2c3d4e5f60718293a/schema" \
-H "Authorization: Bearer $TOKEN"{
"template": { "id": "66f0a1b2c3d4e5f607182950", "name": "page.liquid" },
"current_structure": {
"Title": { "id": "66f0a1b2c3d4e5f607182980", "slug": "Title", "type": "field" },
"Blocks": {
"id": "66f0a1b2c3d4e5f607182981", "slug": "Blocks", "type": "canvas",
"repeatables": [
{ "id": "66f0a1b2c3d4e5f607182941", "slug": "hero", "position": 0, "items": {
"Title": { "id": "66f0a1b2c3d4e5f607182990", "slug": "Title", "type": "field" },
"Theme": { "id": "66f0a1b2c3d4e5f607182991", "slug": "Theme", "type": "select" },
"Related page": { "id": "66f0a1b2c3d4e5f607182993", "slug": "Related page", "type": "reference" }
} }
]
}
},
"available_blocks": {
"Blocks": [
{ "slug": "hero", "label": "Hero", "fields": [
{ "slug": "Title", "label": "Title", "type": "field" },
{ "slug": "Image", "label": "Image", "type": "file" },
{ "slug": "Theme", "label": "Theme", "type": "select", "options": ["Primary", "Secondary", "Tertiary"] },
{ "slug": "Related page", "label": "Related page", "type": "reference", "reference": "pages", "references_many": false }
] },
{ "slug": "cards", "label": "Cards", "fields": [
{ "slug": "Title", "label": "Title", "type": "field" },
{ "slug": "Theme", "label": "Theme", "type": "select", "options": ["Primary", "Secondary", "Tertiary"] }
] }
]
},
"select_options": {
"Blocks.cards.Theme": [
{ "label": "Primary", "value": "Primary" }, { "label": "Secondary", "value": "Secondary" }, { "label": "Tertiary", "value": "Tertiary" }
]
}
}available_blocks and current_structure are objects keyed by slug. Field defs may also include
hint, default_content, icon on the block, and nested available_blocks for a canvas field.
select_options keys are Canvas.block.Field for repeatable fields and the bare slug for top-level selects. Field defs omit nil keys; block defs always carry hint and icon (often null).
Item subtree
GET /pages/{page_id}/items/{path} —
path is everything after /items/ and may contain slashes literally
(/pages/66f0a1b2c3d4e5f60718293a/items/Blocks/repeatables/66f0a1b2c3d4e5f607182942/items/Title). Only enabled
items. Unresolvable → 404 { "message": "…" } (no code; text includes not found or
not a canvas).
curl "https://api.nimbu.io/pages/66f0a1b2c3d4e5f60718293a/items/Title" \
-H "Authorization: Bearer $TOKEN"{
"type": "item",
"path": "/items/Title",
"parent_path": "/items",
"position": 1,
"siblings_count": 2,
"data": { "slug": "Title", "type": "field", "created_at": "2026-03-01T09:00:00.000Z", "updated_at": "2026-09-10T14:22:01.123Z", "content": "Our team" }
}curl "https://api.nimbu.io/pages/66f0a1b2c3d4e5f60718293a/items/Blocks/repeatables/66f0a1b2c3d4e5f607182942" \
-H "Authorization: Bearer $TOKEN"{
"type": "repeatable",
"path": "/items/Blocks/repeatables/66f0a1b2c3d4e5f607182942",
"parent_path": "/items/Blocks/repeatables",
"position": 1,
"siblings_count": 2,
"data": {
"id": "66f0a1b2c3d4e5f607182942",
"created_at": "2026-04-12T11:00:00.000Z",
"updated_at": "2026-09-10T14:22:01.123Z",
"slug": "text_with_image",
"position": 1,
"items": {
"Title": { "slug": "Title", "type": "field", "content": "How we work" },
"Body": { "slug": "Body", "type": "text", "content": "<p>Small teams, clear owners.</p>" },
"Image": { "slug": "Image", "type": "file", "content": "https://cdn.nimbu.io/files/studio.jpg", "file": { "filename": "studio.jpg", "url": "https://cdn.nimbu.io/files/studio.jpg", "content_type": "image/jpeg", "size": 184320 } }
}
}
}type is "item" or "repeatable". data is that object’s to_api document. siblings_count is
enabled siblings of the parent. position is the stored position on the target.
Drafts and preview
Drafts, preview tokens, and versions 403 { "message": "Page drafts are not enabled" } when
PAGE_DRAFTS_DISABLED is 1 / true / yes. Redirect pages → 422
{ "code": "not_draftable", "message": "Redirect pages cannot have drafts" } on draft save, draft
batch and restore. No OpenAPI reference pages for these routes yet.
| Method | Path | Notes |
|---|---|---|
GET | /pages/{id}/draft | current draft payload |
POST / PUT / PATCH | /pages/{id}/draft | create / update (same handler) |
DELETE | /pages/{id}/draft | discard → 204 |
POST | /pages/{id}/draft/batch | same ops; no If-Match; no atomic |
POST | /pages/{id}/draft/publish | {} or { "confirm": true } → live page document |
POST | /pages/{id}/draft/preview_token | JWT + preview_url, valid 24 hours |
GET | /pages/{id}/versions | list |
GET | /pages/{id}/versions/{version_id} | show + snapshot |
POST | /pages/{id}/versions/{version_id}/restore | writes a draft, not live |
Draft payload: { id, page_id, future_page_id, reserved_fullpath, content, updated_at }. content
is a draftable snapshot (Mongoid keys such as page_items and translations, not the public
items hash). Do not PUT that snapshot to /pages/{id}.
Draftable: title, seo_title, seo_description, seo_keywords, og_image, page_items
(items), translations, fixed_locale, locales.
Not draftable: published, slug, template, security_mechanism, parent (also homepage,
filter, channel, position, caching_strategy). A draft save
(or set /slug in a draft batch) applies slug to the live page immediately and re-bases the
draft; the other non-draftable fields are dropped by draft saves — change them on the live page with
PUT. slug / fullpath are stripped from translation snapshots.
Recommended flow: draft → preview → publish
curl -X POST "https://api.nimbu.io/pages/about/team/draft/batch" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"operations": [
{ "op": "set", "path": "/title", "value": "Our team (draft)" },
{ "op": "set", "path": "/items/Title", "value": "Our team (draft)" }
]
}'{
"results": [
{ "index": 0, "status": "ok", "path": "/title" },
{ "index": 1, "status": "ok", "path": "/items/Title" }
],
"draft": {
"id": "66f0a1b2c3d4e5f607182960",
"page_id": "66f0a1b2c3d4e5f60718293a",
"future_page_id": null,
"reserved_fullpath": null,
"content": { "title": "Our team (draft)" },
"updated_at": "2026-09-10T15:00:00+02:00"
}
}Draft batch: any failing op → 422 { "code": "operation_failed", "message": "Draft batch failed", "results": […] } and nothing saved. Same 10-op cap. Extra codes: slug_apply_failed,
invalid_request. Draft save, draft batch and version restore share a per-page lock → 422
concurrent_modification on timeout. /draft/batch works on live pages only; the other draft routes
also accept a draft-only page id.
curl -X POST "https://api.nimbu.io/pages/about/team/draft/preview_token" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'{
"token": "eyJhbGciOiJIUzI1NiJ9.eyJwYWdlIjoiLi4uIiwiZHJhZnQiOnRydWV9.signature",
"preview_url": "/about/team?preview=eyJhbGciOiJIUzI1NiJ9.eyJwYWdlIjoiLi4uIiwiZHJhZnQiOnRydWV9.signature"
}preview_url is site-relative (locale prefix + fullpath); prefix it with the host from public_url before fetching. JWT expires in 24 hours.
curl -X POST "https://api.nimbu.io/pages/about/team/draft/publish" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'Publish returns the live page document. If live changed after the draft was based → 409:
{
"code": "draft_base_changed",
"message": "Live page changed after this draft was based on it; confirm publish to replace live content."
}Force with { "confirm": true }. Discard: DELETE /pages/about/team/draft → 204.
Validation against the theme
Editables come from the active theme template. Every page save (PUT and live batch alike) re-syncs them when the page is new, the template id changed, or the template is newer than the page — right after a theme push, the next save disables items and repeatables the template no longer defines. You cannot invent a slug via the API.
| Failure | Body | Fix |
|---|---|---|
| Unknown item key | 422 { "message": "invalid editable <Slug>" } | add the Liquid editable, nimbu themes push, retry |
| Unknown repeatable slug | 422 { "message": "invalid slug (<slug>) for repeatable in canvas '<Canvas>'" } | use available_blocks, or push the theme |
| Batch select / switch / reference | invalid_select_value / invalid_switch_value / reference_not_found | read schema / GET, then set a legal value |
GET /pages/{id}/schema is the check for what the server currently knows.
Pending: a
dry_run/validate option is being added on the Rails side; this section will be updated when it ships.
?atomic=true still persists if every op succeeds. It is not a dry run.
Versions
Archived when drafts are enabled (live_save on PUT/PATCH and live batch, draft_publish on
publish; restore_publish only via an internal rake task, never via the REST API). Retention: latest 10 or anything
from the last 7 days, whichever set is larger.
curl "https://api.nimbu.io/pages/about/team/versions?limit=50&offset=0" \
-H "Authorization: Bearer $TOKEN"limit 1–100 (default 50). offset default 0. source_action ∈ live_save | draft_publish (| restore_publish, internal only).
[
{ "id": "66f0a1b2c3d4e5f607182970", "page_id": "66f0a1b2c3d4e5f60718293a", "source_action": "live_save", "created_at": "2026-09-10T14:26:30+02:00" },
{ "id": "66f0a1b2c3d4e5f607182971", "page_id": "66f0a1b2c3d4e5f60718293a", "source_action": "draft_publish", "created_at": "2026-09-09T18:00:00+02:00" }
]curl "https://api.nimbu.io/pages/about/team/versions/66f0a1b2c3d4e5f607182970" \
-H "Authorization: Bearer $TOKEN"{ "id": "66f0a1b2c3d4e5f607182970", "page_id": "66f0a1b2c3d4e5f60718293a", "source_action": "live_save", "created_at": "2026-09-10T14:26:30+02:00", "snapshot": { "title": "Our team", "seo_title": "Our team | Acme", "page_items": [] } }snapshot is archived attributes (same family as draft content), not the public GET document.
curl -X POST "https://api.nimbu.io/pages/about/team/versions/66f0a1b2c3d4e5f607182970/restore" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'{ "id": "66f0a1b2c3d4e5f607182960", "page_id": "66f0a1b2c3d4e5f60718293a", "future_page_id": null, "reserved_fullpath": null, "content": { "title": "Our team" }, "updated_at": "2026-09-10T15:10:00+02:00" }Restore creates or overwrites the page draft. Live is unchanged until you publish.
For agents
GETthe page (id or fullpath without a leading slash). ThenGET …/schema.- Prefer
POST /pages/{id}/batchwithIf-Matchover PUT. Always sendIf-Matchon live batch.- Prefer
set/insert/move/deleteover a full-document PUT.- Never resend a GET document with
?replace=1.- Use
?content_locale=for translations. No per-op locale on batch. GET top-level fields stay on the default locale.- Use drafts +
preview_urlfor anything that will be screenshotted. Publish with{}; on 409draft_base_changedresend{ "confirm": true }only to overwrite live.- Max 10 ops. Check
results[], not just HTTP 200. Use?atomic=truewhen partial apply is unacceptable.- On 412, re-read (or take
current_etag) and retry once. UntilX-Nimbu-Content-ETagships (Rails PR #1871), compute the ETag fromid+updated_ator reuse the last batchetag.- Address repeatables by id from GET/schema. Do not put positions in paths.
- Unknown editable / slug → push the theme, then retry. There is no dry-run endpoint yet.
DELETE /pages/{id}removes the page and its subtree's drafts. To hide a page, setpublishedtofalseinstead.