Nimbu Developer Docs

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

KeyTypeReadWrite
idstringObjectIdread-only
urlstringAPI /pages/{fullpath}read-only
public_urlstringsite URLread-only
fullpathstringleading /read-only (recomputed from parent + slug)
parent_pathstring | null"/" + parent.fullpathignored on write
parentstring | nullparent idid or fullpath (also parent_id)
depthintegertree depthread-only
created_at / updated_atdatetimeISO-8601 with msread-only (writes bump updated_at)
publishedbooleandefault trueyes
homepagebooleanyes
templatestring | nullnamename or id (also template_id)
channelstring | nullslugslug or id (also channel_id)
filterstring | nulltemplatized pagesyes
security_mechanismstringsee belownone | humans | customers
localesstring[]default []yes
fixed_localestring | nullyes (any Nimbu-supported locale, not checked against site locales)
positionintegersibling orderyes
og_imagefile | nullfile objectFile / FileRef / remove
slugstringtranslatedyes (slugified; changes live fullpath)
titlestringtranslated, default localeyes
seo_title / seo_description / seo_keywordsstringtranslatedyes
itemsobjectslug → itemmerge / replace
translationsobject{ 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

ValueSitemap / llms.txtrobots.txtHTML / headersLogin
noneincluded when published (not homepage)no extra Disallowno robots-nonepublic
humanshiddenDisallow each locale URL<meta name="robots" content="none"> + X-Robots-Tag: noneno login
customershiddennot listedno robots-nonelogged-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.

KeyOn item JSON?Writable on PUT
idno (yes on repeatables)repeatable id finds the existing block
type / created_at / updated_at / fallback_contentyes (fallback_content when content is blank + template default)no
slugyesno on items; yes on repeatables
positionno on items; yes on repeatablesyes on repeatables (?replace=1 rewrites it)
contentyes (not on references)yes
filefile itemswrite filesource
reference_id / reference_ids / reference_slug(s)referencesyes
reference_type / reference_title(s)referencesread-only
repeatablescanvasesarray or { "repeatables": […] }
translationsmulti-locale, non-reference items?content_locale= or nested translations

Editable types (read shape)

TypeRead 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.

InputEffect
{ "__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.

resourceentries | products | pages | uploads | tmp. Pages/entries require a field name. Products require images or attachments plus an embedded id. tmp requires a filename.

FormServer?Notes
__type: FileRef + source: "nimbu://…"yesonly FileRef form the API accepts
__type: File + attachment + filenameyesPUT upload
{ "data", "filename", "content_type" }batch set onlynot a PUT attachment
attachment_url / attachment_pathnonimbu 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 idupdate in placeupdate; positions rewritten to payload order
Repeatable without idappendcreated; positions rewritten
id + "_destroy": truedeleteddeleted
Repeatable not listeduntoucheddeleted (for that canvas)
Canvas omitted from itemsuntoucheduntouched
"items": { "Blocks": [] }nothing listed → nothing deleteddeletes 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=1 with "items": { "Blocks": [] } deletes every block in Blocks. Never resend a GET document with replace=1: read shape ≠ write shape (files come back as file/content URLs — a URL-only File is a no-op; references come back as reference_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

opRequiredTarget
setpath, valuepage field, editable, or repeatable (slug / position)
insertpath ending /repeatables, value.slug, optional value.items, optional aftercanvas
deletepathrepeatable only
movepath, optional afterrepeatable 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).

KindPathExample
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.slug is not checked against the theme: an unknown slug is created with status: "ok" and only disabled on the next template sync — take slugs from schema available_blocks. Insert applies item values via content (scalar or { "content": "…" }). File / 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

TargetAccepted value
field / textstring, or { "content": "…" }
switchtrue / false / "yes" / "no" / "1" / "0" / "on" / "off" → stored "true" / "false"; else invalid_switch_value
selectmust match a template option (case-insensitive) or invalid_select_value; null / blank clears the field
referenceObjectId, array of ids, { "reference_id" }, { "reference_ids" } (must exist → reference_not_found)
fileFileRef 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.
canvascannot setinvalid_operation
Repeatable{ "slug": "…", "position": N }N is 1-based, clamped; < 1invalid_position
Page fieldonly /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

  1. Every successful batch response: body etag and header ETag.
  2. GET /pages/{id} header X-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.

RequestResponse
If-Match missing or ""428 { "code": "precondition_required", "message": "If-Match header is required" }
Stale If-Match412 { "code": "precondition_failed", "message": "Page was modified", "current_etag": "<current>" } — re-read, retry once with current_etag
TOCTOU / lock422 { "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

HTTPcodeWhen
428precondition_requiredmissing If-Match
412precondition_failedstale If-Match (current_etag in body)
422invalid_requestoperations is not an array
422empty_batch[]
422batch_too_largemore than 10
422validation_failedpage save errors
422concurrent_modificationlock timeout or ETag changed before save
422atomic_failure?atomic=true and any op failed
503service_unavailableRedis unavailable

Per-op error codes

GroupCodes
anypath_not_found, unknown_operation
setmissing_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
insertmissing_value, missing_slug, invalid_insert_path, not_a_canvas, insert_failed
deleteinvalid_delete_target, delete_failed
moveinvalid_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.

MethodPathNotes
GET/pages/{id}/draftcurrent draft payload
POST / PUT / PATCH/pages/{id}/draftcreate / update (same handler)
DELETE/pages/{id}/draftdiscard → 204
POST/pages/{id}/draft/batchsame ops; no If-Match; no atomic
POST/pages/{id}/draft/publish{} or { "confirm": true } → live page document
POST/pages/{id}/draft/preview_tokenJWT + preview_url, valid 24 hours
GET/pages/{id}/versionslist
GET/pages/{id}/versions/{version_id}show + snapshot
POST/pages/{id}/versions/{version_id}/restorewrites 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.

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.

FailureBodyFix
Unknown item key422 { "message": "invalid editable <Slug>" }add the Liquid editable, nimbu themes push, retry
Unknown repeatable slug422 { "message": "invalid slug (<slug>) for repeatable in canvas '<Canvas>'" }use available_blocks, or push the theme
Batch select / switch / referenceinvalid_select_value / invalid_switch_value / reference_not_foundread 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_actionlive_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

  1. GET the page (id or fullpath without a leading slash). Then GET …/schema.
  2. Prefer POST /pages/{id}/batch with If-Match over PUT. Always send If-Match on live batch.
  3. Prefer set / insert / move / delete over a full-document PUT.
  4. Never resend a GET document with ?replace=1.
  5. Use ?content_locale= for translations. No per-op locale on batch. GET top-level fields stay on the default locale.
  6. Use drafts + preview_url for anything that will be screenshotted. Publish with {}; on 409 draft_base_changed resend { "confirm": true } only to overwrite live.
  7. Max 10 ops. Check results[], not just HTTP 200. Use ?atomic=true when partial apply is unacceptable.
  8. On 412, re-read (or take current_etag) and retry once. Until X-Nimbu-Content-ETag ships (Rails PR #1871), compute the ETag from id + updated_at or reuse the last batch etag.
  9. Address repeatables by id from GET/schema. Do not put positions in paths.
  10. Unknown editable / slug → push the theme, then retry. There is no dry-run endpoint yet.
  11. DELETE /pages/{id} removes the page and its subtree's drafts. To hide a page, set published to false instead.

On this page