Data API: CMS

This content was generated with the assistance of AI and should be verified against the official Webflow documentation before being relied on in production.

This sub-section’s Webflow bibliography lists the reference material consulted while preparing these pages.

This page describes the CMS part of the Data API as it was on 2026-10-28, the date on which its facts were checked against the developers.webflow.com reference and guides. The examples use the Data API v2, webflow-api (the JavaScript SDK) version 3.3.4 where an SDK method exists, the Python package webflow version 2.0.0 for reference, requests 2.34.2, Node.js 24 with tsx 4.23.15 and TypeScript 7.0.2. The SDK is optional: every call on this page is shown as the plain HTTP request it sends. Writing to the CMS needs an API token with the cms:write scope and reading needs cms:read (see Data API: Authentication and Scopes). The API works on any site, including the free Developer-workspace sites used in all examples here. The request budget per minute depends on the Site plan of the site that owns the token: 60 requests per minute on Starter and Basic, 120 on CMS, Ecommerce and Business, and a custom limit on Enterprise. Plans are named by tier only, never by price.

The running example is Fernbrook Gardens, a fictitious botanical garden with Collections named Plants, Garden zones, Events and Journal, and with Spanish and German as secondary locales (see CMS Concepts and Content Modeling and Localization). A nursery supplies the garden with a JSON inventory feed every night, and a script named fernbrook-sync turns that feed into Plants items. The script is built up step by step in this page and listed in full in the section on the fernbrook-sync script. All identifiers, tokens and Collection IDs in the examples are placeholders that you must replace. Tokens come from environment variables and never appear in source files.

What the CMS API is for

The CMS API is the part of the Data API that reads and writes Collections, fields and items. It is used by four kinds of people.

  • A developer imports content from another system (a nursery feed, a spreadsheet, a legacy site) and keeps it in step, as fernbrook-sync does.

  • A content editor benefits indirectly: editors keep working in the CMS while a script fills the bulk of the items.

  • A marketer sees campaign content created and published at an exact moment, because a script can create items in draft and publish them on a schedule of its own.

  • A site owner or designer uses the schema endpoints to create a Collection and its fields in a repeatable way, for example when the same structure is needed on a second site.

The endpoint families are summarised below. Every path is relative to https://api.webflow.com/v2.

Operation Endpoint Scope

List, get, create, update, delete a Collection

/sites/{site_id}/collections and /collections/{collection_id} (GET, POST, PATCH, DELETE)

cms:read, cms:write

Create, update, delete a field

/collections/{collection_id}/fields and /collections/{collection_id}/fields/{field_id}

cms:write

List staged items

GET /collections/{collection_id}/items

cms:read

Create items (one to 100, with locales)

POST /collections/{collection_id}/items/insert

cms:write

Update items in bulk (up to 100) or one item

PATCH /collections/{collection_id}/items, PATCH /collections/{collection_id}/items/{item_id}

cms:write

Delete staged items

DELETE /collections/{collection_id}/items, DELETE /collections/{collection_id}/items/{item_id}

cms:write

Publish items

POST /collections/{collection_id}/items/publish

cms:write

List, get live items

GET /collections/{collection_id}/items/live, GET /collections/{collection_id}/items/{item_id}/live

cms:read

Create, update live items (published at once)

POST /collections/{collection_id}/items/live, PATCH /collections/{collection_id}/items/live

cms:write

Unpublish live items

DELETE /collections/{collection_id}/items/live, DELETE /collections/{collection_id}/items/{item_id}/live

cms:write

Two older create endpoints still work: POST /collections/{collection_id}/items and POST /collections/{collection_id}/items/bulk. The official guide on creating items says that new integrations should use items/insert (named Create Items in the reference), because it covers everything the two older endpoints do, adds locale variants of an existing item and is where new create features land. The older endpoints are no longer listed in the reference navigation. This page uses items/insert throughout. The JavaScript SDK 3.3.4 exposes the older endpoints as createItem and createItems, and it has no method for items/insert or for the filter[...] and sort[...] query parameters; the script therefore sends its requests with fetch, which also keeps it identical in behaviour to the Python variant.

Staged items and live items

All CMS content in the Data API exists in one of two states. A staged item is the working copy: it is what the Designer, the Editor and the CMS panel show, it can be previewed, and it is not visible on the live site. A live item is the published version that visitors see. A published item can have a staged version next to it, which holds edits that do not affect the live copy. The words in the URLs follow this model: the plain /items endpoints read and write staged items, and the /items/live endpoints read and write live ones. The Designer user interface calls the states Draft, Changes in draft, Queued to publish, Scheduled, Published and Archived (see Publishing and Scheduling); the word staged in older tutorials and in URL paths is a different use of the word than the Help Center’s former label "Staged for publish".

The API has no status property. Two properties on the item decide the status, and the guide on publishing documents this mapping.

Status in the Webflow UI lastPublished isDraft What it means

Published

a date

false

The content is live and visible.

Changes in draft

a date

true

The live item stays live. Edits wait in the staged copy.

Draft

null

true

The item was never published and is not on the live site.

Queued to publish

null

false

The item goes live on the next site-wide publish.

Scheduled

not applicable

not applicable

Scheduled publication cannot be controlled with the CMS API.

Archived

not applicable

not applicable

Set the isArchived flag to true. The item leaves the live site at the next site-wide publish and stays in the CMS.

The isDraft change of 2025-07-07

Older code and tutorials treated isDraft: true on a live item as a way to take it off the site. That stopped being true on 2025-07-07: setting isDraft: true on an already-published item now gives it the status Changes in draft, the live version stays published, and the edits are held back until you publish them. The reference for Update Items puts it this way: on an item that has never been published, isDraft: true gives the status Draft, on a published item it gives Changes in draft, and isDraft: false queues the item for the next site publish. To remove an item from the live site you must call the unpublish endpoint, which sets isDraft to true as a side effect and keeps the item in the CMS. Code that was written before the change and relied on isDraft to unpublish now leaves items online, which is the most likely cause of "the old plant is still on the site" after a migration (see Product Changes and Deprecations).

Which endpoint publishes what

Four routes put content on the live site, and they differ in how many steps they need.

Route Behaviour

Create or update staged items, then publish items

Two calls. items/insert or PATCH items write the staged copy. POST items/publish makes the listed items live. Publishing a draft item sets isDraft to false automatically, so no preparatory update is needed.

Create or update live items

One call. POST items/live creates items that are published at once, and PATCH items/live changes published items and publishes the change. There is no review stage.

Set isDraft: false and publish the site

The staged item is queued and goes live on the next site-wide publish (POST /sites/{site_id}/publish), together with all other queued content and design changes.

Unpublish

DELETE items/live removes the live copy, sets isDraft to true and leaves the staged item in the CMS.

The fernbrook-sync script uses the first route: it writes staged items, then publishes exactly the items it changed. That keeps a nursery import from publishing unrelated edits that an editor has saved in the meantime, because a full site publish would release those as well. The reference for the publish route also lists, in its response, a publishedItemIds array and an errors array of text messages; an ID that does not exist is reported there (for example "Staging item ID …​ not found") instead of failing the whole request, so a script must read errors and not only the status code.

Collections and fields

Reading the schema

GET /sites/{site_id}/collections lists the Collections of a site with their IDs, slugs and names. GET /collections/{collection_id} returns the full schema, with a fields array whose entries carry the field id, slug, displayName, type, isRequired, isEditable and, for Option fields, a validations.options array with the ID of each choice. Read the schema before writing items: Option and Reference values are IDs, and the IDs differ between sites, so a script must never hard-code them.

curl -s "https://api.webflow.com/v2/sites/REPLACE_WITH_SITE_ID/collections" \
  -H "Authorization: Bearer $WEBFLOW_API_TOKEN"

curl -s "https://api.webflow.com/v2/collections/REPLACE_WITH_PLANTS_COLLECTION_ID" \
  -H "Authorization: Bearer $WEBFLOW_API_TOKEN"

Creating a Collection

POST /sites/{site_id}/collections creates a Collection with its fields in one request. The body holds displayName, singularName, slug and a fields array. Every Collection has the default fields name and slug, which are not part of the request. A Collection can have up to 60 fields, and the official guide notes that slugs should be unique and written in lowercase without spaces (spaces become hyphens). The request below creates the Fernbrook Garden zones Collection, which must exist before Plants because Plants refers to it.

{
  "displayName": "Garden zones",
  "singularName": "Garden zone",
  "slug": "garden-zones",
  "fields": [
    {
      "type": "PlainText",
      "displayName": "Short code",
      "slug": "short-code",
      "isRequired": true,
      "helpText": "Two or three letters used on the garden map."
    },
    {
      "type": "RichText",
      "displayName": "Description",
      "slug": "description"
    }
  ]
}

The Plants Collection adds a unique external ID, an Option field and a Reference field. Option fields need a metadata object with an options array of names, and Webflow generates the option IDs. Reference and Multi-Reference fields need metadata.collectionId, the ID of the referenced Collection. The nursery SKU field is a plain text field whose values the script keeps unique: the documentation asks for unique slugs but defines no uniqueness rule for other fields, so an external ID is a convention that the sync code enforces.

{
  "displayName": "Plants",
  "singularName": "Plant",
  "slug": "plants",
  "fields": [
    { "type": "PlainText", "displayName": "Nursery SKU", "slug": "nursery-sku", "isRequired": true },
    { "type": "PlainText", "displayName": "Latin name", "slug": "latin-name" },
    { "type": "RichText", "displayName": "Care notes", "slug": "care-notes" },
    { "type": "Switch", "displayName": "Is endangered", "slug": "is-endangered" },
    {
      "type": "Option",
      "displayName": "Bloom month",
      "slug": "bloom-month",
      "metadata": {
        "options": [
          { "name": "March" }, { "name": "April" }, { "name": "May" },
          { "name": "June" }, { "name": "July" }, { "name": "August" }
        ]
      }
    },
    {
      "type": "Reference",
      "displayName": "Hardiness zone",
      "slug": "hardiness-zone",
      "metadata": { "collectionId": "REPLACE_WITH_GARDEN_ZONES_COLLECTION_ID" }
    },
    { "type": "PlainText", "displayName": "Sync hash", "slug": "sync-hash" }
  ]
}

Adding, changing and removing fields

POST /collections/{collection_id}/fields adds a single field (it does not accept several at once; to add several, include them when you create the Collection). PATCH /collections/{collection_id}/fields/{field_id} changes a field’s display name, help text or required flag, and DELETE removes it together with the values it holds. Three constraints from the reference matter in practice.

  • Field validations cannot be set through the API. Character limits, minimum and maximum values, and other validations are configured in the Designer or the CMS panel. A script that creates a Collection therefore produces a structure that still needs a manual pass for validations, and a script that writes items must not assume that validations will reject bad data.

  • Option IDs belong to the field. After you add or rename an option in the interface, the IDs in your script’s cache are stale. Read them from GET /collections/{collection_id} at the start of every run.

  • Deleting a field deletes its data. Take a named backup first (see Backups and Recovery), because deleted field values are not in the item history.

Field value formats

The fieldData object of an item is a map from field slug to value. The value format depends on the field type, and the official reference lists every type. The table summarises it with the Fernbrook examples.

Field type (API name) Write format Fernbrook example

PlainText

string

"latin-name": "Lavandula angustifolia"

RichText

string of HTML

"care-notes": "<p>Full sun, dry soil.</p>"

Image

object with a public url and optional alt

"photo": { "url": "https://cdn.example.org/lavender.jpg", "alt": "Lavender in bloom" }

MultiImage

array of such objects

"gallery": [ { "url": "https://cdn.example.org/1.jpg" } ]

VideoLink, Link, Email, Phone

string

"video": "https://www.youtube.com/watch?v=REPLACE"

Number

number

"height-cm": 60

DateTime

ISO 8601 string

"starts-on": "2027-04-18T09:00:00.000Z"

Switch

boolean

"is-endangered": false

Color

string: HEX, rgb(), hsl() with or without alpha, or a color name

"flower-color": "#8E6BBF"

Option

string: the ID of the option

"bloom-month": "REPLACE_WITH_OPTION_ID"

File

object with a public url

"leaflet": { "url": "https://cdn.example.org/guide.pdf" }

Reference

string: the item ID

"hardiness-zone": "REPLACE_WITH_ZONE_ITEM_ID"

MultiReference

array of item IDs

"companions": [ "ID_1", "ID_2" ]

The reference adds details that cause most import bugs.

  • Images are fetched from a public URL, with a maximum of 4 MB per image. Webflow downloads the file and stores it as an asset. A private URL, a redirect to a login page or a larger file does not import. On a create or update call the query parameter skipInvalidFiles defaults to true, which makes Webflow skip the invalid files and process the rest of the request; set it to false to make the whole request fail instead, which is the right choice for a migration that must be complete. Uploading assets by themselves is covered in Data API: Assets, Forms and Custom Code.

  • Rich text is HTML. The API does not currently support code blocks in Rich Text fields, and passing one results in an empty string. Components can appear inside Rich Text as markup, which has its own guide.

  • A Reference value needs the item ID, not the slug, and the referenced Collection must have been published to the site for a reference to be created. This is why the script reads the Garden zones items first and turns each short code into an ID.

  • An Option value needs the option ID, not its name. Send the ID that GET /collections/{collection_id} returns for the choice.

  • The User field is read-only. It holds the ID of a Webflow user and is used for the created-by and updated-by values.

Creating items

POST /collections/{collection_id}/items/insert takes an items array. Each entry holds a fieldData object and, optionally, isDraft, isArchived, an id and a locale selector. A request can create up to 100 items, and the limit counts every locale variant: one item created in three locales counts as three, and an item in the primary locale alone counts as one. The success status is 202, and the response contains an items array with the created items.

POST /v2/collections/REPLACE_WITH_PLANTS_COLLECTION_ID/items/insert HTTP/1.1
Host: api.webflow.com
Authorization: Bearer REPLACE_WITH_API_TOKEN
Content-Type: application/json

{
  "items": [
    {
      "isDraft": true,
      "fieldData": {
        "name": "English lavender",
        "slug": "english-lavender",
        "nursery-sku": "NUR-0042",
        "latin-name": "Lavandula angustifolia",
        "care-notes": "<p>Full sun and dry, well-drained soil.</p>",
        "is-endangered": false,
        "bloom-month": "REPLACE_WITH_OPTION_ID",
        "hardiness-zone": "REPLACE_WITH_ZONE_ITEM_ID"
      }
    }
  ]
}

Locale selectors

Each entry chooses its locales in one of three ways, and mixing them is an error.

Selector Result

Neither cmsLocaleIds nor allCmsLocales

The item is created in the primary locale only.

"cmsLocaleIds": ["ID_ES", "ID_DE"]

The item is created in the listed locales. The variants share one new item ID and the same fieldData.

"allCmsLocales": true

The item is created in every current site locale, including the primary locale, without listing IDs. It is accepted only for new items and only with the value true.

To add locale variants to an item that exists, set id to the item’s ID and list the new locales in cmsLocaleIds; the item must exist and must not already have a variant in any requested locale. To give each locale its own content in one request, send several entries with the same id, each with one locale in cmsLocaleIds and its own fieldData. A request returns 400 when it combines allCmsLocales with cmsLocaleIds (even an empty array) or with id, when allCmsLocales is false, a string or null, when it repeats a locale within an entry or the same id and locale pair across entries, or when it contains an unrecognised property such as the singular cmsLocaleId. The message and details of the error name the offending entry, for example items[2].cmsLocaleIds. An entry that names an item that does not exist returns 404, and an item that already has a variant in a requested locale returns 409.

The ID of a locale is the cmsLocaleId in the locales object of GET /sites/{site_id}; it differs from the localeId that the Pages API uses (see Data API: Ecommerce and Localization).

The slug rules

The slug is part of the public URL (see CMS Concepts and Content Modeling). The documentation asks for unique slugs, and a sync must treat a slug clash as a data problem to report, not as a condition to retry. The sync script therefore never lets the nursery decide a slug on its own after creation: it computes the slug from the SKU and the name once, and an update does not send slug again unless the feed record’s slug has deliberately changed.

Updating, archiving and deleting items

PATCH /collections/{collection_id}/items updates up to 100 items. Each entry has the item id and the properties to change: fieldData (only the fields you list are changed), isDraft, isArchived and, for a locale variant, cmsLocaleId. Without a cmsLocaleId the update applies to the primary locale. The response is the updated items. A single item can be changed with PATCH /collections/{collection_id}/items/{item_id}.

PATCH /v2/collections/REPLACE_WITH_PLANTS_COLLECTION_ID/items HTTP/1.1
Host: api.webflow.com
Authorization: Bearer REPLACE_WITH_API_TOKEN
Content-Type: application/json

{
  "items": [
    {
      "id": "REPLACE_WITH_ITEM_ID",
      "fieldData": { "care-notes": "<p>Prune after flowering.</p>" }
    },
    {
      "id": "REPLACE_WITH_ITEM_ID",
      "cmsLocaleId": "REPLACE_WITH_SPANISH_CMS_LOCALE_ID",
      "fieldData": { "name": "Lavanda inglesa" }
    }
  ]
}

Three behaviours are easy to overlook.

  • An update never publishes by itself. The staged copy changes; a live item gets the status Changes in draft when the request sets isDraft: true, and a draft item stays a draft. To make the change visible, publish the item or use the live endpoints.

  • Files that cannot be fetched are skipped by default, as described for creating items, so a bulk update can succeed while an image field stays unchanged.

  • The old values stay if you omit a field. A partial fieldData is a merge, not a replacement. To clear a field, send an explicit empty value for it.

Archiving sets isArchived: true. The item stays in the CMS, and it leaves the live site at the next site-wide publish; archiving does not unpublish by itself between publishes. Deleting removes the staged item with DELETE /collections/{collection_id}/items/{item_id} or with DELETE .../items and a body of item IDs. Prefer archiving to deleting in a sync: a nursery that drops a plant from its feed for a week should not erase the plant page, its incoming links and its editorial text.

Publishing and unpublishing items

POST /collections/{collection_id}/items/publish publishes items. The body has one of two shapes: itemIds, a list of IDs published in the primary locale, or items, a list of objects with an id and cmsLocaleIds, which publishes the listed locales independently of each other. The response status is 202.

{
  "items": [
    { "id": "REPLACE_WITH_ITEM_ID", "cmsLocaleIds": ["REPLACE_WITH_EN_ID", "REPLACE_WITH_ES_ID", "REPLACE_WITH_DE_ID"] }
  ]
}

Unpublishing is a DELETE on the live endpoint, a naming that surprises many developers. DELETE /collections/{collection_id}/items/live removes up to 100 items from the live site and sets their isDraft property to true; the items remain in the CMS and the response is 204 with no body. As for updates, the primary locale is unpublished unless the entry lists cmsLocaleIds.

curl -X DELETE "https://api.webflow.com/v2/collections/REPLACE_WITH_PLANTS_COLLECTION_ID/items/live" \
  -H "Authorization: Bearer $WEBFLOW_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "items": [ { "id": "REPLACE_WITH_ITEM_ID" } ] }'

Publishing items does not publish the site. The page template, styles and static pages are released by POST /sites/{site_id}/publish, which is limited to one successful publish per minute (see Data API: Sites, Pages and Components). An item that is published through the items endpoint appears on its Collection page and in Collection lists without a site publish, which is the reason to use it in a sync that runs several times a day. Item publishing and the optional webhooks that report it are described in Webhooks.

Listing, filtering and sorting

GET /collections/{collection_id}/items returns staged items. The limit parameter has a maximum of 100 and offset skips items; the response has a pagination object with total, limit and offset. A Collection with 250 items needs three requests, with offsets 0, 100 and 200, and the loop ends when the offset reaches total.

Besides pagination the endpoint takes these query parameters.

Parameter Use

name, slug

Exact match on the item name or slug.

createdOn, lastUpdated, lastPublished

Filter by date.

filter[<fieldSlug>][<operator>]=<value>

Filter on custom field values. Filters combine with AND; OR and nested groups are not supported on GET. One request accepts up to 10 filter terms and 2 text-search terms.

sortBy, sortOrder

Sort by createdOn, lastPublished, lastUpdated, name or slug, ascending or descending.

sort[<fieldSlug>]=asc or sort[<fieldSlug>]=desc

Sort by a custom field, up to 3 sort fields per request. It takes precedence over sortBy. Sortable types are PlainText, Email, Phone, Number, DateTime and Switch; other types return 400.

cmsLocaleId

One locale ID, or several separated by commas.

translatable

The ID of a secondary locale you are translating into; returns only content not excluded from translation for it.

The operators that a filter accepts depend on the field type: eq, ne, in and nin for most types; contains and ncontains for PlainText, Email, Phone and Link; gt, gte, lt and lte for Number and DateTime; and exists for every type except Option and id. RichText, Image, MultiImage, VideoLink and MultiReference support exists only. More filter terms can increase latency.

# Endangered plants whose SKU starts a nursery batch, newest first, 20 per page
curl -G "https://api.webflow.com/v2/collections/REPLACE_WITH_PLANTS_COLLECTION_ID/items" \
  -H "Authorization: Bearer $WEBFLOW_API_TOKEN" \
  --data-urlencode "filter[is-endangered][eq]=true" \
  --data-urlencode "filter[nursery-sku][contains]=NUR-" \
  --data-urlencode "sort[name]=asc" \
  --data-urlencode "limit=20" \
  --data-urlencode "offset=0"

A sync should avoid per-item lookups. Fetching the whole Collection once (three or four requests for a few hundred items) is cheaper than one filtered GET per feed record, because every request counts against the same per-minute budget. Use the filter parameter for targeted reads, such as an editor tool that looks up one SKU.

Idempotent synchronisation

A sync is idempotent when running it twice with the same feed produces the same CMS and the second run makes no writes. That property turns a failed run into a non-event: you run it again. Five design choices give it.

  1. Match on a stable external key. The slug can be edited by an editor, and the item ID exists only after creation. A dedicated plain text field holding the nursery SKU (nursery-sku) identifies a plant for ever. The script looks a record up by SKU first, by slug second (for items that editors created by hand) and writes the ID it finds into a local mapping file for the next run.

  2. Compare a hash, not the content. Rich Text fields hold HTML, and the HTML that a read returns can differ in formatting from the HTML that was sent, so a field-by-field comparison can report a change on every run. The script stores a SHA-256 hash of the feed record in the sync-hash field and compares hashes.

  3. Create, update, skip. A record with no match is created, a record with a different hash is updated, and a record with the same hash is skipped. A record that is in the CMS but no longer in the feed is archived only when the operator passes an explicit flag.

  4. Write in batches of 100 requests' worth of items. The create and update endpoints accept up to 100 items per call, where each locale variant counts as an item. With three locales a batch holds 33 plants.

  5. Back off on throttling and transient errors. A 429 response carries a Retry-After header that gives the wait, typically 60 seconds; 500-range responses and network errors are retried with exponential backoff and jitter. Other 4xx errors are bugs in the request and are not retried.

The sequence of one run is shown in the diagram.

sequenceDiagram participant Op as Operator or CI participant Sync as fernbrook-sync participant Feed as Nursery feed participant API as Webflow Data API v2 Op->>Sync: node run, flags dry-run or publish Sync->>Feed: read inventory JSON Sync->>API: GET /sites/SITE_ID (locales) Sync->>API: GET /collections/PLANTS (option IDs) Sync->>API: GET /collections/ZONES/items (short code to ID) loop pages of 100 Sync->>API: GET /collections/PLANTS/items, limit 100, offset N end Sync->>Sync: match by SKU or slug, compare sync-hash alt record not found Sync->>API: POST items/insert, allCmsLocales true API-->>Sync: 202 with item IDs else hash differs Sync->>API: PATCH items, primary locale else hash equal Sync->>Sync: skip end Sync->>API: PATCH items with cmsLocaleId, translations opt 429 Too Many Requests API-->>Sync: 429 and Retry-After Sync->>Sync: wait, then retry the same request end Sync->>API: POST items/publish, changed IDs only API-->>Sync: 202 publishedItemIds and errors Sync->>Sync: write mapping file and print report Sync-->>Op: exit code 0, or 1 on errors

The feed

The nursery delivers a JSON array. Each record has the SKU, a slug, the English name and care notes, the zone’s short code, the bloom month and, when available, translations keyed by the first part of the locale tag.

[
  {
    "sku": "NUR-0042",
    "slug": "english-lavender",
    "name": "English lavender",
    "latinName": "Lavandula angustifolia",
    "careNotesHtml": "<p>Full sun and dry, well-drained soil.</p>",
    "endangered": false,
    "zoneCode": "MT",
    "bloomMonth": "June",
    "translations": {
      "es": { "name": "Lavanda inglesa", "careNotesHtml": "<p>Pleno sol y suelo seco y bien drenado.</p>" },
      "de": { "name": "Echter Lavendel", "careNotesHtml": "<p>Volle Sonne und trockener, gut durchlaessiger Boden.</p>" }
    }
  }
]

The fernbrook-sync script

The script needs Node.js 22.13 or later (Node 24 is used here) because it relies on the built-in fetch and AbortSignal.timeout. It has no runtime dependencies. Install the development tools and run it with tsx.

{
  "name": "fernbrook-sync",
  "private": true,
  "type": "module",
  "scripts": {
    "sync": "tsx fernbrook-sync.ts",
    "check": "tsc --noEmit"
  },
  "devDependencies": {
    "@types/node": "26.6.6",
    "tsx": "4.23.15",
    "typescript": "7.0.2"
  }
}

The environment supplies the secrets and identifiers. The token needs cms:read, cms:write and sites:read.

export WEBFLOW_API_TOKEN="REPLACE_WITH_SITE_TOKEN"
export WEBFLOW_SITE_ID="REPLACE_WITH_SITE_ID"
export WEBFLOW_PLANTS_COLLECTION_ID="REPLACE_WITH_PLANTS_COLLECTION_ID"
export WEBFLOW_ZONES_COLLECTION_ID="REPLACE_WITH_GARDEN_ZONES_COLLECTION_ID"

npm install
npx tsx fernbrook-sync.ts --feed ./nursery-feed.json --dry-run     # report only, no writes
npx tsx fernbrook-sync.ts --feed ./nursery-feed.json --publish     # write and publish what changed
npx tsx fernbrook-sync.ts --feed ./nursery-feed.json --publish --archive-missing

The script itself follows. It is long on purpose: the HTTP client with backoff, the schema lookups, the planning step and the write steps are all needed in a production sync.

// fernbrook-sync.ts - keeps the Fernbrook Gardens Plants Collection in step with the nursery feed.
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";

const API = "https://api.webflow.com/v2";
const MAX_ITEMS_PER_REQUEST = 100; // create, update, publish and unpublish accept up to 100 items
const MAX_RETRIES = 6;
const MAP_FILE = ".fernbrook-sync-map.json";

type FieldData = Record<string, unknown>;

interface Item {
  id: string;
  cmsLocaleId?: string;
  isArchived?: boolean;
  isDraft?: boolean;
  lastPublished?: string | null;
  fieldData: FieldData;
}

interface ItemPage {
  items: Item[];
  pagination: { total: number; limit: number; offset: number };
}

interface FeedPlant {
  sku: string;
  slug: string;
  name: string;
  latinName: string;
  careNotesHtml: string;
  endangered: boolean;
  zoneCode: string;
  bloomMonth?: string;
  translations?: Record<string, { name: string; careNotesHtml: string }>;
}

interface Locale {
  cmsLocaleId: string;
  tag: string;
}

class WebflowError extends Error {
  constructor(
    readonly status: number,
    readonly code: string | undefined,
    message: string,
    readonly details: unknown,
  ) {
    super(`HTTP ${status} ${code ?? ""} ${message}`.trim());
  }
}

const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

function env(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing environment variable ${name}`);
  return value;
}

function chunk<T>(list: T[], size: number): T[][] {
  const out: T[][] = [];
  for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
  return out;
}

// --- HTTP client: timeout, Retry-After on 429, exponential backoff with jitter on 5xx and network errors ---
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
  const token = env("WEBFLOW_API_TOKEN");
  for (let attempt = 0; ; attempt++) {
    let res: Response;
    try {
      res = await fetch(`${API}${path}`, {
        method,
        headers: {
          Authorization: `Bearer ${token}`,
          Accept: "application/json",
          ...(body === undefined ? {} : { "Content-Type": "application/json" }),
        },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(30_000),
      });
    } catch (error) {
      if (attempt >= MAX_RETRIES) throw error;
      await sleep(backoff(attempt)); // network error or timeout
      continue;
    }

    if (res.ok) {
      if (res.headers.get("X-RateLimit-Remaining") === "0") await sleep(61_000); // the minute budget is spent; wait for the window to reset
      return (res.status === 204 ? undefined : await res.json()) as T;
    }

    const payload = (await res.json().catch(() => ({}))) as {
      code?: string;
      message?: string;
      details?: unknown;
    };
    const retryable = res.status === 429 || res.status >= 500;
    if (!retryable || attempt >= MAX_RETRIES) {
      throw new WebflowError(res.status, payload.code, payload.message ?? res.statusText, payload.details);
    }
    const retryAfter = Number(res.headers.get("Retry-After"));
    const wait = res.status === 429 ? (Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 60_000) : backoff(attempt);
    console.warn(`  ${res.status} on ${method} ${path}; retry ${attempt + 1} in ${Math.round(wait / 1000)} s`);
    await sleep(wait);
  }
}

function backoff(attempt: number): number {
  return Math.min(30_000, 1000 * 2 ** attempt) + Math.floor(Math.random() * 500);
}

// --- Schema lookups ---
async function getLocales(siteId: string): Promise<{ primary: Locale; secondary: Locale[] }> {
  const site = await request<{ locales: { primary: Locale; secondary?: Locale[] } }>("GET", `/sites/${siteId}`);
  return { primary: site.locales.primary, secondary: site.locales.secondary ?? [] };
}

async function getOptionIds(collectionId: string, fieldSlug: string): Promise<Map<string, string>> {
  const collection = await request<{
    fields: { slug: string; validations?: { options?: { id: string; name: string }[] } }[];
  }>("GET", `/collections/${collectionId}`);
  const field = collection.fields.find((f) => f.slug === fieldSlug);
  return new Map((field?.validations?.options ?? []).map((o) => [o.name, o.id]));
}

async function listAll(collectionId: string): Promise<Item[]> {
  const items: Item[] = [];
  for (let offset = 0; ; ) {
    const page = await request<ItemPage>("GET", `/collections/${collectionId}/items?limit=100&offset=${offset}`);
    items.push(...page.items);
    offset += page.items.length;
    if (page.items.length === 0 || offset >= page.pagination.total) return items;
  }
}

// --- Planning ---
const hashOf = (plant: FeedPlant): string => createHash("sha256").update(JSON.stringify(plant)).digest("hex").slice(0, 32);

function toFieldData(plant: FeedPlant, zoneIds: Map<string, string>, bloomIds: Map<string, string>): FieldData {
  const zoneId = zoneIds.get(plant.zoneCode);
  if (!zoneId) throw new Error(`Unknown zone code ${plant.zoneCode} for ${plant.sku}`);
  const data: FieldData = {
    name: plant.name,
    slug: plant.slug,
    "nursery-sku": plant.sku,
    "latin-name": plant.latinName,
    "care-notes": plant.careNotesHtml,
    "is-endangered": plant.endangered,
    "hardiness-zone": zoneId,
    "sync-hash": hashOf(plant),
  };
  if (plant.bloomMonth) {
    const optionId = bloomIds.get(plant.bloomMonth);
    if (!optionId) throw new Error(`Unknown bloom month ${plant.bloomMonth} for ${plant.sku}`);
    data["bloom-month"] = optionId;
  }
  return data;
}

interface Plan {
  create: FeedPlant[];
  update: { plant: FeedPlant; id: string }[];
  skip: number;
  archive: Item[];
}

function plan(feed: FeedPlant[], existing: Item[], map: Record<string, string>, archiveMissing: boolean): Plan {
  const bySku = new Map<string, Item>();
  const bySlug = new Map<string, Item>();
  for (const item of existing) {
    const sku = item.fieldData["nursery-sku"];
    if (typeof sku === "string" && sku) bySku.set(sku, item);
    bySlug.set(String(item.fieldData.slug), item);
  }
  const byId = new Map(existing.map((i) => [i.id, i]));
  const result: Plan = { create: [], update: [], skip: 0, archive: [] };
  const seen = new Set<string>();

  for (const plant of feed) {
    if (seen.has(plant.sku)) throw new Error(`Duplicate SKU ${plant.sku} in the feed`);
    seen.add(plant.sku);
    const found = bySku.get(plant.sku) ?? (map[plant.sku] ? byId.get(map[plant.sku]) : undefined) ?? bySlug.get(plant.slug);
    if (!found) result.create.push(plant);
    else if (found.fieldData["sync-hash"] === hashOf(plant) && !found.isArchived) result.skip++;
    else result.update.push({ plant, id: found.id });
  }
  if (archiveMissing) {
    result.archive = existing.filter((i) => {
      const sku = i.fieldData["nursery-sku"];
      return typeof sku === "string" && sku && !seen.has(sku) && !i.isArchived;
    });
  }
  return result;
}

// --- Writes ---
async function main(): Promise<void> {
  const args = process.argv.slice(2);
  const flag = (name: string) => args.includes(name);
  const feedPath = args[args.indexOf("--feed") + 1] ?? "./nursery-feed.json";
  const dryRun = flag("--dry-run");
  const publish = flag("--publish");

  const siteId = env("WEBFLOW_SITE_ID");
  const plantsId = env("WEBFLOW_PLANTS_COLLECTION_ID");
  const zonesId = env("WEBFLOW_ZONES_COLLECTION_ID");

  const feed = JSON.parse(await readFile(feedPath, "utf8")) as FeedPlant[];
  const map: Record<string, string> = await readFile(MAP_FILE, "utf8").then(JSON.parse, () => ({}));

  const { primary, secondary } = await getLocales(siteId);
  const allLocales = [primary, ...secondary];
  const bloomIds = await getOptionIds(plantsId, "bloom-month");
  const zoneIds = new Map(
    (await listAll(zonesId)).map((z) => [String(z.fieldData["short-code"]), z.id] as [string, string]),
  );
  const existing = (await listAll(plantsId)).filter((i) => !i.cmsLocaleId || i.cmsLocaleId === primary.cmsLocaleId);

  const p = plan(feed, existing, map, flag("--archive-missing"));
  console.log(`feed ${feed.length}: create ${p.create.length}, update ${p.update.length}, skip ${p.skip}, archive ${p.archive.length}`);
  if (dryRun) return;

  const changedIds = new Set<string>();
  const translations: { id: string; localeId: string; data: FieldData }[] = [];
  const addTranslations = (plant: FeedPlant, id: string) => {
    for (const locale of secondary) {
      const t = plant.translations?.[locale.tag.split("-")[0]];
      if (t) translations.push({ id, localeId: locale.cmsLocaleId, data: { name: t.name, "care-notes": t.careNotesHtml } });
    }
  };

  // 1. Create: every locale counts toward the limit of 100 items per request
  const createBatch = Math.max(1, Math.floor(MAX_ITEMS_PER_REQUEST / allLocales.length));
  for (const batch of chunk(p.create, createBatch)) {
    const res = await request<{ items: Item[] }>("POST", `/collections/${plantsId}/items/insert`, {
      items: batch.map((plant) => ({ isDraft: true, allCmsLocales: true, fieldData: toFieldData(plant, zoneIds, bloomIds) })),
    });
    for (const item of res.items) {
      const sku = String(item.fieldData["nursery-sku"]);
      map[sku] = item.id;
      changedIds.add(item.id);
    }
    for (const plant of batch) if (map[plant.sku]) addTranslations(plant, map[plant.sku]);
  }

  // 2. Update the primary locale (the slug is not sent again)
  for (const batch of chunk(p.update, MAX_ITEMS_PER_REQUEST)) {
    await request("PATCH", `/collections/${plantsId}/items`, {
      items: batch.map(({ plant, id }) => {
        const { slug: _slug, ...rest } = toFieldData(plant, zoneIds, bloomIds);
        return { id, isArchived: false, fieldData: rest };
      }),
    });
    for (const { plant, id } of batch) {
      map[plant.sku] = id;
      changedIds.add(id);
      addTranslations(plant, id);
    }
  }

  // 3. Translations: one entry per item and locale, each with its own cmsLocaleId
  for (const batch of chunk(translations, MAX_ITEMS_PER_REQUEST)) {
    await request("PATCH", `/collections/${plantsId}/items`, {
      items: batch.map((t) => ({ id: t.id, cmsLocaleId: t.localeId, fieldData: t.data })),
    });
  }

  // 4. Archive items that vanished from the feed (opt-in)
  for (const batch of chunk(p.archive, MAX_ITEMS_PER_REQUEST)) {
    await request("PATCH", `/collections/${plantsId}/items`, {
      items: batch.map((i) => ({ id: i.id, isArchived: true })),
    });
  }

  // 5. Publish exactly the items that changed, in every locale
  let publishErrors: string[] = [];
  if (publish && changedIds.size > 0) {
    for (const batch of chunk([...changedIds], createBatch)) {
      const res = await request<{ publishedItemIds?: string[]; errors?: string[] }>(
        "POST",
        `/collections/${plantsId}/items/publish`,
        { items: batch.map((id) => ({ id, cmsLocaleIds: allLocales.map((l) => l.cmsLocaleId) })) },
      );
      publishErrors = publishErrors.concat(res.errors ?? []);
    }
  }

  await writeFile(MAP_FILE, JSON.stringify(map, null, 2) + "\n");
  console.log(`written ${changedIds.size} items, published ${publish ? changedIds.size - publishErrors.length : 0}`);
  if (publishErrors.length > 0) {
    console.error("Publish errors:", publishErrors);
    process.exitCode = 1;
  }
}

main().catch((error) => {
  if (error instanceof WebflowError) console.error(error.message, JSON.stringify(error.details));
  else console.error(error);
  process.exitCode = 1;
});

The two variants compute their hash over differently serialised JSON, so do not alternate between them on one Collection; the first run of the other variant would rewrite every item once.

The script is idempotent for the reasons given above: a second run with the same feed finds every SKU, sees equal hashes and prints skip for all records. Four details deserve a remark.

  • The first three reads establish context. The locale list, the Option IDs and the zone IDs are read at the start of each run, never cached, because an editor can change any of them between runs.

  • The create batch size is derived from the number of locales. With Spanish and German as secondary locales, allCmsLocales: true makes every plant count three times, so a request holds 33 plants.

  • Translations use a second pass. A locale variant is its own item version, and PATCH with cmsLocaleId changes one locale at a time. A feed that does not provide translations leaves the Spanish and German variants with the English text, which the editors then translate in the interface or with the localization tools.

  • The mapping file is a cache, not the source of truth. If it is lost, the next run finds every plant again by SKU. The backup page describes how the team keeps it in version control (see Backups and Recovery).

The Python variant

The Python variant does the same work with requests 2.34.2. Python 3.10 or later is enough, and the code has only that one dependency.

requests==2.34.2

Source: PyPI: requests

"""fernbrook_sync.py - keeps the Fernbrook Gardens Plants Collection in step with the nursery feed."""
import argparse
import hashlib
import json
import os
import random
import sys
import time
from pathlib import Path

import requests

API = "https://api.webflow.com/v2"
MAX_ITEMS = 100
MAX_RETRIES = 6
MAP_FILE = Path(".fernbrook-sync-map.json")

session = requests.Session()
session.headers.update({"Accept": "application/json"})


class WebflowError(RuntimeError):
    def __init__(self, status, code, message, details):
        super().__init__(f"HTTP {status} {code or ''} {message}".strip())
        self.details = details


def env(name):
    value = os.environ.get(name)
    if not value:
        raise SystemExit(f"Missing environment variable {name}")
    return value


def chunks(items, size):
    for i in range(0, len(items), size):
        yield items[i : i + size]


def backoff(attempt):
    return min(30.0, 2.0**attempt) + random.random() * 0.5


def request(method, path, body=None):
    headers = {"Authorization": f"Bearer {env('WEBFLOW_API_TOKEN')}"}
    for attempt in range(MAX_RETRIES + 1):
        try:
            res = session.request(method, API + path, headers=headers, json=body, timeout=30)
        except (requests.ConnectionError, requests.Timeout):
            if attempt >= MAX_RETRIES:
                raise
            time.sleep(backoff(attempt))
            continue

        if res.ok:
            if res.headers.get("X-RateLimit-Remaining") == "0":
                time.sleep(61)  # the minute budget is spent; wait for the window to reset
            return None if res.status_code == 204 else res.json()

        try:
            payload = res.json()
        except ValueError:
            payload = {}
        retryable = res.status_code == 429 or res.status_code >= 500
        if not retryable or attempt >= MAX_RETRIES:
            raise WebflowError(res.status_code, payload.get("code"), payload.get("message", res.reason), payload.get("details"))
        if res.status_code == 429:
            wait = float(res.headers.get("Retry-After") or 60)
        else:
            wait = backoff(attempt)
        print(f"  {res.status_code} on {method} {path}; retry {attempt + 1} in {wait:.0f} s", file=sys.stderr)
        time.sleep(wait)


def list_all(collection_id):
    items, offset = [], 0
    while True:
        page = request("GET", f"/collections/{collection_id}/items?limit=100&offset={offset}")
        items += page["items"]
        offset += len(page["items"])
        if not page["items"] or offset >= page["pagination"]["total"]:
            return items


def hash_of(plant):
    return hashlib.sha256(json.dumps(plant, separators=(",", ":")).encode()).hexdigest()[:32]


def to_field_data(plant, zone_ids, bloom_ids):
    if plant["zoneCode"] not in zone_ids:
        raise SystemExit(f"Unknown zone code {plant['zoneCode']} for {plant['sku']}")
    data = {
        "name": plant["name"],
        "slug": plant["slug"],
        "nursery-sku": plant["sku"],
        "latin-name": plant["latinName"],
        "care-notes": plant["careNotesHtml"],
        "is-endangered": plant["endangered"],
        "hardiness-zone": zone_ids[plant["zoneCode"]],
        "sync-hash": hash_of(plant),
    }
    if plant.get("bloomMonth"):
        if plant["bloomMonth"] not in bloom_ids:
            raise SystemExit(f"Unknown bloom month {plant['bloomMonth']} for {plant['sku']}")
        data["bloom-month"] = bloom_ids[plant["bloomMonth"]]
    return data


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--feed", default="./nursery-feed.json")
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--publish", action="store_true")
    parser.add_argument("--archive-missing", action="store_true")
    args = parser.parse_args()

    site_id = env("WEBFLOW_SITE_ID")
    plants_id = env("WEBFLOW_PLANTS_COLLECTION_ID")
    zones_id = env("WEBFLOW_ZONES_COLLECTION_ID")

    feed = json.loads(Path(args.feed).read_text(encoding="utf-8"))
    mapping = json.loads(MAP_FILE.read_text()) if MAP_FILE.exists() else {}

    locales = request("GET", f"/sites/{site_id}")["locales"]
    primary, secondary = locales["primary"], locales.get("secondary") or []
    all_locale_ids = [primary["cmsLocaleId"]] + [loc["cmsLocaleId"] for loc in secondary]

    collection = request("GET", f"/collections/{plants_id}")
    bloom_field = next((f for f in collection["fields"] if f["slug"] == "bloom-month"), {})
    bloom_ids = {o["name"]: o["id"] for o in bloom_field.get("validations", {}).get("options", [])}
    zone_ids = {z["fieldData"]["short-code"]: z["id"] for z in list_all(zones_id)}

    existing = [i for i in list_all(plants_id) if not i.get("cmsLocaleId") or i["cmsLocaleId"] == primary["cmsLocaleId"]]
    by_sku = {i["fieldData"].get("nursery-sku"): i for i in existing if i["fieldData"].get("nursery-sku")}
    by_slug = {i["fieldData"]["slug"]: i for i in existing}
    by_id = {i["id"]: i for i in existing}

    create, update, skipped, seen = [], [], 0, set()
    for plant in feed:
        if plant["sku"] in seen:
            raise SystemExit(f"Duplicate SKU {plant['sku']} in the feed")
        seen.add(plant["sku"])
        found = by_sku.get(plant["sku"]) or by_id.get(mapping.get(plant["sku"])) or by_slug.get(plant["slug"])
        if not found:
            create.append(plant)
        elif found["fieldData"].get("sync-hash") == hash_of(plant) and not found.get("isArchived"):
            skipped += 1
        else:
            update.append((plant, found["id"]))
    archive = []
    if args.archive_missing:
        archive = [i for sku, i in by_sku.items() if sku not in seen and not i.get("isArchived")]
    print(f"feed {len(feed)}: create {len(create)}, update {len(update)}, skip {skipped}, archive {len(archive)}")
    if args.dry_run:
        return 0

    changed, translations = set(), []

    def add_translations(plant, item_id):
        for loc in secondary:
            t = (plant.get("translations") or {}).get(loc["tag"].split("-")[0])
            if t:
                translations.append({"id": item_id, "cmsLocaleId": loc["cmsLocaleId"], "fieldData": {"name": t["name"], "care-notes": t["careNotesHtml"]}})

    create_batch = max(1, MAX_ITEMS // len(all_locale_ids))
    for batch in chunks(create, create_batch):
        res = request("POST", f"/collections/{plants_id}/items/insert", {
            "items": [{"isDraft": True, "allCmsLocales": True, "fieldData": to_field_data(p, zone_ids, bloom_ids)} for p in batch]
        })
        for item in res["items"]:
            mapping[item["fieldData"]["nursery-sku"]] = item["id"]
            changed.add(item["id"])
        for plant in batch:
            if plant["sku"] in mapping:
                add_translations(plant, mapping[plant["sku"]])

    for batch in chunks(update, MAX_ITEMS):
        items = []
        for plant, item_id in batch:
            data = to_field_data(plant, zone_ids, bloom_ids)
            data.pop("slug")  # the slug is not sent again
            items.append({"id": item_id, "isArchived": False, "fieldData": data})
            mapping[plant["sku"]] = item_id
            changed.add(item_id)
            add_translations(plant, item_id)
        request("PATCH", f"/collections/{plants_id}/items", {"items": items})

    for batch in chunks(translations, MAX_ITEMS):
        request("PATCH", f"/collections/{plants_id}/items", {"items": batch})

    for batch in chunks(archive, MAX_ITEMS):
        request("PATCH", f"/collections/{plants_id}/items", {"items": [{"id": i["id"], "isArchived": True} for i in batch]})

    errors = []
    if args.publish and changed:
        for batch in chunks(sorted(changed), create_batch):
            res = request("POST", f"/collections/{plants_id}/items/publish", {
                "items": [{"id": i, "cmsLocaleIds": all_locale_ids} for i in batch]
            })
            errors += res.get("errors") or []

    MAP_FILE.write_text(json.dumps(mapping, indent=2) + "\n")
    print(f"written {len(changed)} items, published {len(changed) - len(errors) if args.publish else 0}")
    if errors:
        print("Publish errors:", errors, file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except WebflowError as error:
        print(error, json.dumps(error.details), file=sys.stderr)
        sys.exit(1)

The official Python package webflow (version 2.0.0, Python 3.8 or later) wraps the same endpoints with typed methods and is described in Data API: SDKs, Limits and Errors. The plain requests variant is used here so that the two scripts have exactly the same shape and the same retry logic.

Rate limits, batching and backoff

The per-minute budget applies per API key: 60 requests on Starter and Basic, 120 on CMS, Ecommerce and Business, custom on Enterprise. Three response headers describe it: X-RateLimit-Limit, X-RateLimit-Remaining and, on a refused request, Retry-After. Requests that are answered from the Content Delivery API cache do not count, but a sync writes to the origin, so every call counts.

The import of 600 plants shows why batching matters. One request per plant would need 600 create calls and, on a Basic plan, ten minutes of waiting at 60 requests per minute. In batches the same import needs:

Step Requests Notes

Read locales, schema and zones

3

One request each for the site, the Plants schema and the zones page.

Read the Plants Collection

6

600 items at 100 per page.

Create 600 plants in three locales

19

33 plants per request, because each plant counts as three items.

Translations for 600 plants and two locales

12

1,200 variants at 100 per request.

Publish 600 plants

19

33 items per request, with three locales each.

Total

59

Below one minute on every plan.

A script that retries blindly makes things worse, since a retried request still counts against the budget. The script therefore waits for Retry-After on 429 (about 60 seconds), waits for the window to reset when X-RateLimit-Remaining reaches zero, and retries 500-range answers with backoff. If you run several scripts with the same token they share one budget; give each a token of its own (see Data API: Authentication and Scopes).

Errors and pitfalls

An error response is a JSON object with code, message, externalReference and a details array. The status codes that the CMS endpoints return are 400 (malformed request or validation error), 401 (invalid token), 404 (a Collection or item that does not exist), 409 (a conflict such as a locale variant that already exists), 429 (throttled) and 500. The code values include validation_error, collection_not_found, item_not_found, missing_scopes, conflict, too_many_requests and internal_error; log the whole body, because details names the failing entry of a bulk request.

Pitfall Remedy

Publishing a live item with isDraft: true expecting it to go offline

Use the unpublish endpoint. Since 2025-07-07 the flag keeps the item live and marks the edits as a draft.

A Reference field is rejected

Use the item ID of the referenced item, and publish the referenced Collection to the site first.

An Option field is rejected

Use the option ID from the Collection schema, not the label.

The slug already exists

Slugs are unique within a Collection. Compute them once, and treat a 409 or 400 on a slug as a data problem.

Every run reports changes

Compare a stored hash, not the Rich Text HTML, which a read may return in a different formatting.

A 400 on an items/insert entry

Check the locale selector rules and remove singular cmsLocaleId properties.

An image field stays empty

The image URL was private, too large (maximum 4 MB) or unreachable; skipInvalidFiles defaults to true, so the request still succeeded. Set it to false to fail the request instead.

A validation you set in the Designer is not enforced

Field validations cannot be configured through the API and are not guaranteed to reject API writes; validate in the script.

Items published but the page is unchanged

An item publish releases content, not the template. Publish the site for design changes.

The mapping file points to deleted items

Rebuild it from the CMS by SKU. The item_not_found error and the errors array of the publish response reveal stale IDs.

Security and operations

  • Keep the token in the environment or a secret store. A token in the repository, in the Designer’s custom code or in a browser is public the moment the page is published (see Introduction and Architecture). Use a site token with only the scopes the script needs, cms:read and cms:write for this script plus sites:read for the locale lookup.

  • Run a dry run first, in CI. The --dry-run flag prints the plan without writing, so a pull request can show how many plants a change to the script would create or update.

  • Take a backup before the first large import. A sync can overwrite editorial changes. The team takes a named backup before the first run and keeps the mapping file in version control.

  • Do not log field data you do not own. Form submissions are not part of this script, but the habit matters: log counts and IDs.

  • Limit who can trigger a publish. A job that publishes items every night should run under a service account and a token that is not the personal token of an editor.

  • Watch for conflicts with editors. The staged copy can be edited in the interface while the script runs. The script writes the hash it saw, so the next run overwrites the editor’s text with the feed; if the garden wants editors to own the care notes, remove that field from toFieldData.

  • Webhooks can close the loop. The CMS events (collection_item_created, collection_item_changed, collection_item_deleted, collection_item_unpublished) notify an external system when an editor changes a plant; see Webhooks.

For the structure of the Collections that this script fills, see Collections and Field Types and Reference and Multi-Reference Fields. Limits that apply to items and Collections are in CMS Limits and Scaling, and a delivery layer that reads the published content without spending origin requests is in Content Delivery API and Headless.

Summary

  • The CMS API has two views of an item: staged (the working copy) and live (the published version). The status shown in the interface follows from isDraft and lastPublished.

  • Since 2025-07-07, isDraft: true on a live item creates Changes in draft and does not unpublish; unpublishing is DELETE on the live endpoint.

  • items/insert creates up to 100 items per request, counting each locale variant, and chooses locales with cmsLocaleIds or allCmsLocales.

  • PATCH items updates up to 100 items and merges fieldData; items/publish publishes by ID and per locale.

  • References need item IDs, Options need option IDs, images need public URLs of at most 4 MB, and field validations cannot be set through the API.

  • An idempotent sync matches on an external ID, compares a stored hash, works in batches of 100, backs off on 429 and 500-range errors, and publishes only what it changed.