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 and If-Match, dry runs, compact reads, drafts and preview links, 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) and item content are in the
site default locale, or in the locale you pass as ?content_locale=xx. Other locales sit under
translations. The same applies to GET …/draft and GET …/items/*. Only pages do this: products,
channel entries and other resources keep serving the default locale at top level. Disabled
editables and repeatables are omitted — there is no enabled key.
The response carries ETag and X-Nimbu-Content-ETag (same value); see ETag.
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.
Smaller reads: compact and fields
Canvas-heavy pages get large. Two query params trim the read:
| Param | Applies to | Effect |
|---|---|---|
compact=1 | GET /pages/{id}, GET …/draft, GET …/items/* | drops created_at / updated_at / type on every item and repeatable, drops slug where it is already the key of an items map (kept inside repeatables), drops the translations entry for the locale being served, and reduces file to url / filename / width / height. Ids and positions stay. |
fields=title,items | GET /pages/{id} only | returns only the listed top-level keys plus id and url. items is selected as a whole. Ignored on /draft and /items/*. |
curl "https://api.nimbu.io/pages/about/team?fields=title,items&compact=1&content_locale=nl" \
-H "Authorization: Bearer $TOKEN"Compact reads are for reading. Item type is gone, so take types from
the schema before writing. The ETag does not change with compact, fields or
content_locale, so a compact read still gives you a valid If-Match value.
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. Top-level values follow
?content_locale= (default: the site default locale); translations lists every locale (unless compact=1).
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 / insert 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. Writing a translated slug (either style) recomputes that locale's
fullpath and cascades it to child pages. 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 optional on PUT / PATCH /
POST and on DELETE /pages/{id}; when sent, it is checked inside the page write lock (see
Conditional writes). A successful PUT bumps updated_at
and therefore the ETag.
Only keys present in the payload are touched. Omitted top-level fields stay. Omitted items
stay. Unknown item slug → 422 editable_not_in_theme. Unknown repeatable slug → 422
repeatable_not_in_theme (bodies under Validation against the theme).
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 (optional with ?dry_run=1). 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.slugmust exist in the canvas template: an unknown slug fails that op withrepeatable_not_in_theme, and an unknown key invalue.itemsfails it witheditable_not_in_theme. Nothing is inserted. Take slugs from schemaavailable_blocks. Slugs with markup or other illegal characters →invalid_slug.
Insert applies scalar item values via content (scalar or { "content": "…" }). File items accept
the same values as a file set ({ "data", "filename", "content_type" }, { "url" }, a bare URL, or
a FileRef); any other shape → invalid_file_format. Reference hashes on insert are not
assigned — set the new path afterwards. Result includes id and path
(/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
The page ETag is a strong value derived from the page id and updated_at. Treat it as opaque. It
changes on every write and does not vary with compact, fields or content_locale.
Where to read it:
GET /pages/{id}: headersETagandX-Nimbu-Content-ETagcarry the same quoted value. Browser clients should readX-Nimbu-Content-ETag; CORS exposes it (and allowsIf-Matchon requests), but notETag.- Every successful live batch response: body
etagand headerETag.
Send it back as If-Match. Quotes are optional (If-Match: 3f1c… and If-Match: "3f1c…" both
work). Only one strong value is accepted: no W/"…" weak ETags, no comma-separated lists.
curl -si "https://api.nimbu.io/pages/66f0a1b2c3d4e5f60718293a" \
-H "Authorization: Bearer $TOKEN" | grep -i etag
# ETag: "3f1c8d9e2a4b5c6d7e8f9012abcdef01"
# X-Nimbu-Content-ETag: "3f1c8d9e2a4b5c6d7e8f9012abcdef01"Live batch responses:
| 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 |
| Page changed during the batch, or lock timeout | 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.
Conditional writes on PUT and DELETE
PUT / PATCH / POST /pages/{id} and DELETE /pages/{id} accept an optional If-Match. Without
it the write is unconditional. With it, the ETag is compared against the page inside
the write lock, so a write that lands between your read and this one makes yours fail instead of
overwriting it.
curl -X PUT "https://api.nimbu.io/pages/66f0a1b2c3d4e5f60718293a" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "3f1c8d9e2a4b5c6d7e8f9012abcdef01"' \
-d '{ "seo_title": "Our team | Acme" }'| Result | Response |
|---|---|
| ETag matches | normal response (PUT: page document; DELETE: 204) |
Stale If-Match | 412 { "code": "precondition_failed", "message": "Page was modified", "current_etag": "…" } |
| Write lock timeout | 409 { "code": "concurrent_modification", "message": "Page is being modified by another request" } |
| Lock service unavailable | 503 { "code": "service_unavailable", "message": "Unable to save page, please try again" } |
PUT takes the write lock even without If-Match, so the 409 / 503 rows apply to every PUT. An
unconditional DELETE skips the lock. The PUT response does not set X-Nimbu-Content-ETag; GET the
page again for the next If-Match.
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. Lock service unavailable → 503 service_unavailable (retry later).
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 | lock service unavailable; retry later |
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, repeatable_not_in_theme (repeatable slug change), 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, validation_failed, set_failed |
| insert | missing_value, missing_slug, invalid_slug, invalid_insert_path, not_a_canvas, repeatable_not_in_theme, editable_not_in_theme, file codes from set, insert_failed |
| FileRef (set / insert) | unauthorized (no admin user or site token, or a cross-site copy with a site-bound token), not_found (source record or field missing; "Source file is missing from storage" when the stored object is gone), gone (temporary upload expired), validation_failed (bad URI, incompatible storage, disallowed extension or size), copy_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 return 403 { "message": "Page drafts are not enabled" }
when page drafts are switched off on the platform. Redirect pages → 422
{ "code": "not_draftable", "message": "Redirect pages cannot have drafts" } on draft save, draft
batch and restore. Each route below has an API reference page, for example
GET /pages/{page_id}/draft and
GET /pages/{page_id}/versions.
| 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 | page-scoped JWT + preview_url, valid 24 hours; works with or without a draft |
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 and the same set / insert values,
including file items and same-site FileRefs (for example an existing upload). Extra codes:
slug_apply_failed, invalid_request. Draft routes reject ?dry_run=1 with 422 invalid_request
(a draft never changes the live page, so write the draft and discard it instead). Draft reads
honour ?content_locale= and ?compact=1 like live reads. 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.
The token is bound to the site and the page, not to one draft. What it shows is decided per request:
- a draft exists → the current draft (a later save shows up on refresh);
- no draft (never made, published, or discarded) → the live content, even when unpublished.
So one link keeps working across save, publish and discard, and you can request it before the
first draft exists. Preview renders send X-Robots-Tag: noindex, and templates get
{{ preview_mode }} ("draft" or "live", unset for normal visitors) to show a preview banner.
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 | Response | Fix |
|---|---|---|
| Unknown item key (PUT / POST) | 422 editable_not_in_theme | add the Liquid editable, nimbu themes push, retry |
| Unknown repeatable slug (PUT / POST) | 422 repeatable_not_in_theme | use available_blocks, or push the theme |
Unknown slug in batch insert / repeatable set | per-op repeatable_not_in_theme / editable_not_in_theme | same |
| Batch select / switch / reference | invalid_select_value / invalid_switch_value / reference_not_found | read schema / GET, then set a legal value |
PUT / POST bodies:
{
"code": "editable_not_in_theme",
"message": "invalid editable Subtitle",
"data": { "editables": ["Subtitle"], "canvas": "Blocks", "repeatable": "hero" }
}{
"code": "repeatable_not_in_theme",
"message": "invalid slug (gallery) for repeatable in canvas 'Blocks'",
"data": { "canvas": "Blocks", "slugs": ["gallery"] }
}canvas and repeatable on editable_not_in_theme only appear when the unknown editable sits
inside a repeatable. GET /pages/{id}/schema is the check for what the server currently knows.
Dry runs
Add ?dry_run=1 to validate a write without saving it:
| Route | Dry-run response |
|---|---|
POST /pages | 200 { "dry_run": true, "valid": true }, or the normal 422 |
PUT / PATCH / POST /pages/{id} | 200 { "dry_run": true, "valid": true }, or the normal 422 |
POST /pages/{id}/batch | 200 { "dry_run": true, "valid", "results": […], "etag", "updated_at", "error"? } |
The same processing runs as for a real write, including theme validation. FileRef sources are
checked (existence, access) but not copied. The batch dry run reports what each op would do in
results[]; valid is false when any op fails or the page would not validate (then error
holds validation_failed). etag is the current ETag, unchanged. If-Match is optional on a
batch dry run, but a stale one still returns 412. A PUT dry run ignores If-Match. Batch-level
errors (empty_batch, batch_too_large, …) return their normal 4xx. Draft routes reject
dry_run with 422 invalid_request.
curl -X POST "https://api.nimbu.io/pages/66f0a1b2c3d4e5f60718293a/batch?dry_run=1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "operations": [ { "op": "insert", "path": "/items/Blocks/repeatables", "value": { "slug": "gallery" } } ] }'{
"dry_run": true,
"valid": false,
"results": [
{
"index": 0,
"status": "error",
"path": "/items/Blocks/repeatables",
"error": { "code": "repeatable_not_in_theme", "message": "invalid slug (gallery) for repeatable in canvas 'Blocks'" }
}
],
"etag": "3f1c8d9e2a4b5c6d7e8f9012abcdef01",
"updated_at": "2026-09-10T14:22:01+02:00"
}?atomic=true without dry_run 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); keep itsETag/X-Nimbu-Content-ETag. ThenGET …/schema. Add?compact=1(andfields=) on big pages.- 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, on reads and writes. No per-op locale on batch.- 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. SendIf-Matchon PUT / DELETE too when you are editing something you read earlier.- Address repeatables by id from GET/schema. Do not put positions in paths.
- Validate risky writes with
?dry_run=1first.editable_not_in_theme/repeatable_not_in_theme→ push the theme, then retry.DELETE /pages/{id}removes the page and its subtree's drafts. To hide a page, setpublishedtofalseinstead.
Custom Field Types
Learn how to send and receive file uploads, references, galleries, and other complex field types when working with channel entries via the Nimbu REST API.
Site Search
Search pages, products, channel entries, and collections in one call with GET /search, plus opt-in customer and order search. Covers parameters, scopes, ACL filtering, and the result shape.