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-syncdoes. -
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 |
|
|
Create, update, delete a field |
|
|
List staged items |
|
|
Create items (one to 100, with locales) |
|
|
Update items in bulk (up to 100) or one item |
|
|
Delete staged items |
|
|
Publish items |
|
|
List, get live items |
|
|
Create, update live items (published at once) |
|
|
Unpublish live items |
|
|
Source: Data API: Working with the CMS
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 |
|
The content is live and visible. |
Changes in draft |
a date |
|
The live item stays live. Edits wait in the staged copy. |
Draft |
|
|
The item was never published and is not on the live site. |
Queued to publish |
|
|
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 |
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).
Source: Data API: Update Items
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. |
Create or update live items |
One call. |
Set |
The staged item is queued and goes live on the next site-wide publish ( |
Unpublish |
|
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"
Source: Data API: Get Collection Details
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.
Source: Data API: Create field
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 |
|---|---|---|
|
string |
|
|
string of HTML |
|
|
object with a public |
|
|
array of such objects |
|
|
string |
|
|
number |
|
|
ISO 8601 string |
|
|
boolean |
|
|
string: HEX, |
|
|
string: the ID of the option |
|
|
object with a public |
|
|
string: the item ID |
|
|
array of item IDs |
|
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
skipInvalidFilesdefaults totrue, which makes Webflow skip the invalid files and process the rest of the request; set it tofalseto 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"
}
}
]
}
Source: Data API: Create Items
Locale selectors
Each entry chooses its locales in one of three ways, and mixing them is an error.
| Selector | Result |
|---|---|
Neither |
The item is created in the primary locale only. |
|
The item is created in the listed locales. The variants share one new item ID and the same |
|
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 |
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" }
}
]
}
Source: Data API: Update Items
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
fieldDatais 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 |
|---|---|
|
Exact match on the item name or slug. |
|
Filter by date. |
|
Filter on custom field values. Filters combine with AND; OR and nested groups are not supported on |
|
Sort by |
|
Sort by a custom field, up to 3 sort fields per request. It takes precedence over |
|
One locale ID, or several separated by commas. |
|
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"
Source: Data API: List Collection Items
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.
-
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. -
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-hashfield and compares hashes. -
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.
-
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.
-
Back off on throttling and transient errors. A
429response carries aRetry-Afterheader that gives the wait, typically 60 seconds;500-range responses and network errors are retried with exponential backoff and jitter. Other4xxerrors are bugs in the request and are not retried.
The sequence of one run is shown in the diagram.
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"
}
}
Source: Data API: Quick start
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
Source: Data API: Site tokens
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;
});
Source: Data API: Create Items
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: truemakes 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
PATCHwithcmsLocaleIdchanges 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)
Source: Data API: Update Items
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).
Source: Data API: Rate limits
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 |
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 |
Every run reports changes |
Compare a stored hash, not the Rich Text HTML, which a read may return in a different formatting. |
A |
Check the locale selector rules and remove singular |
An image field stays empty |
The image URL was private, too large (maximum 4 MB) or unreachable; |
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 |
Source: Data API: Create Items
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:readandcms:writefor this script plussites:readfor the locale lookup. -
Run a dry run first, in CI. The
--dry-runflag 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
isDraftandlastPublished. -
Since 2025-07-07,
isDraft: trueon a live item creates Changes in draft and does not unpublish; unpublishing isDELETEon the live endpoint. -
items/insertcreates up to 100 items per request, counting each locale variant, and chooses locales withcmsLocaleIdsorallCmsLocales. -
PATCH itemsupdates up to 100 items and mergesfieldData;items/publishpublishes 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
429and500-range errors, and publishes only what it changed.