Data API: Ecommerce and Localization

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 Ecommerce and Localization endpoints of the Webflow Data API v2 as they were on 2026-10-28, the date on which its facts were checked against the developer documentation and the reference of the Data API. The code uses the official JavaScript SDK webflow-api 3.3.4 (Node.js 22.13 or later, which also runs the @webflow/webflow-cli 2.15.0 tool) and the official Python SDK webflow 2.0.0, and plain fetch where the typings of the SDK lag behind the API; the pinned versions are repeated in Setup and dependencies. The Ecommerce endpoints answer only for a site whose Ecommerce is switched on, which needs an Ecommerce Site plan (the tiers Standard, Plus and Advanced; see Ecommerce Overview). The Localization endpoints need a site with localization enabled and at least one secondary locale; publishing a secondary locale needs a paid Site plan plus a Localize add-on (see Localization). Plans are named by tier only, never by price. Every example uses placeholder identifiers and tokens that you must replace, and a site that belongs to a Developer workspace, so that the CMS and localization calls can be run without a paid plan; the calls that need an Ecommerce plan say so.

The running example is Fernbrook Gardens, a fictitious botanical garden. Its Fernbrook Gardens Shop sells seed packets and a printed garden guide; its Plants, Garden zones, Events and Journal Collections are written in English and translated into Spanish (es-ES) and German (de-DE). The two halves of this page therefore belong to two different people. The shop manager reads orders, adds products and keeps stock honest; the content engineer keeps three languages in step. A third fact connects the halves and is easy to miss: the Data API localizes pages, components and CMS items, but Ecommerce products and orders are not part of a localized site. The reasons are in Ecommerce and localization in one site.

The authentication model, the scope list and the token types are explained in Data API: Authentication and Scopes; rate limits, pagination, errors and the SDK clients are in Data API: SDKs, Limits and Errors; the CMS endpoints used by the localization half are in Data API: CMS.

Setup and dependencies

{
  "name": "fernbrook-shop-and-i18n",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22.13" },
  "dependencies": {
    "webflow-api": "3.3.4"
  },
  "devDependencies": {
    "tsx": "4.19.2",
    "typescript": "7.0.2",
    "@types/node": "22.9.0"
  }
}
webflow==2.0.0

Source: PyPI: webflow

All scripts read the credentials from the environment. Create a site token (or a workspace token or an OAuth access token) with the scopes in the table, and never commit it.

Scope Allows Used on this page by

ecommerce:read

Listing and reading products, SKUs, orders, inventory levels and the ecommerce settings

Order reports, stock checks

ecommerce:write

Creating and updating products and SKUs, updating, fulfilling and refunding orders, updating inventory

Catalogue sync, fulfilment, stock corrections

sites:read

Reading the site, including its locales

Resolving locale identifiers

pages:read, pages:write

Reading and writing page metadata and page content, and component content and properties

Page and component localization

cms:read, cms:write

Reading and writing CMS items and publishing them

Localized Plants items

export WEBFLOW_API_TOKEN="REPLACE_WITH_SITE_TOKEN"
export WEBFLOW_SITE_ID="REPLACE_WITH_SITE_ID"
export WEBFLOW_PLANTS_COLLECTION_ID="REPLACE_WITH_COLLECTION_ID"
npm install

The base URL for every request is https://api.webflow.com/v2. Both the Ecommerce reference and the localization guides document the same behaviour for sites that cannot use a feature: a site without Ecommerce answers 409 Conflict ("The site does not have ecommerce enabled") on every Ecommerce endpoint, so a 409 from /sites/{site_id}/ecommerce/settings is the cheapest test of whether a site is a shop.

Ecommerce in the Data API

The Ecommerce endpoints expose the shop’s Products, their SKUs, the Orders placed by customers and the Inventory of each SKU, plus one read-only settings object. They sit under the ecommerce:read and ecommerce:write scopes, and the same OAuth app or site token that works on the CMS works here once the scopes are granted.

The data model

Concept What it is

Product

The thing a shopper sees on a product page: name, slug, description, category, tax category, whether it ships and the list of options (sku-properties) such as Size or Packet. A product is stored as an item of the site’s Products Collection.

SKU

One purchasable variant of a product. A product has at least one SKU, the default SKU. A SKU carries the price, the optional compare-at price, the main image, the user-defined sku code, the weight and dimensions, the billing method (one-time or subscription) and the combination of option values (sku-values). SKUs are items of the site’s SKUs Collection.

Order

A completed checkout. It has a short random orderId, a status, the purchased items, totals, customer information, addresses and payment details (Stripe, PayPal).

Inventory

The stock record of one SKU: inventoryType is infinite or finite, and finite stock has a quantity.

Ecommerce settings

The site identifier, the creation date and the defaultCurrency (a three-letter ISO code).

Because products and SKUs are CMS items, they also appear in the Collections list of the site (the Inventory endpoints take the identifier of the SKUs Collection, which the reference says to find with the List Collections endpoint) and they have the same item fields: id, cmsLocaleId, lastPublished, lastUpdated, createdOn, isArchived, isDraft and fieldData. Their fieldData keys use hyphens (sku-properties, default-sku, ec-product-type), which the JavaScript SDK exposes in camel case (skuProperties) and the Python SDK in snake case; raw HTTP uses the hyphenated names.

Products and SKUs

Reading the catalogue

GET /v2/sites/{site_id}/products returns every product with all of its SKUs. limit is capped at 100 and offset pages through the results; the limit, offset and total values count products only, not SKUs. Each entry of items has a product and a skus array.

// list-catalogue.ts
import { WebflowClient } from "webflow-api";

const client = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const siteId = process.env.WEBFLOW_SITE_ID!;

const limit = 100;
for (let offset = 0; ; offset += limit) {
  const page = await client.products.list(siteId, { limit, offset });
  for (const { product, skus } of page.items ?? []) {
    console.log(product?.fieldData?.name, "->", skus?.length ?? 0, "SKU(s)");
  }
  if (offset + limit >= (page.pagination?.total ?? 0)) break;
}

Creating a product with its default SKU

POST /v2/sites/{site_id}/products creates a product and its first SKU in one request. A product needs at minimum one SKU, so the body has a product and a sku, each with a fieldData, and an optional publishStatus of staging (the default) or live.

  • The SKU’s name, slug and price are required. price is an object with value, unit and currency; the reference examples write a price of 2499 with USD for a 24.99 dollar item, so read a known product of your site first to confirm how amounts are expressed before writing prices.

  • To build a product with several SKUs, give the product a list of sku-properties. Each property has an id, a name and an enum of options with id, name and slug. Webflow then creates a default SKU that is the combination of the first option of every property (for example Small, Red). The other combinations are created afterwards with the Create SKUs endpoint.

  • The sku-values map of a SKU maps a property id to the option id that SKU uses ({ "packet": "50-seeds" }).

  • A new product has the type Advanced, which makes every product and SKU field visible in the Designer. Updating a product or SKU through the API sets the product type to Advanced too, so a product that was a simplified type in the Designer changes its editing form after the first API write.

  • ec-product-type takes one of four opaque identifiers (listed in the reference in the order Physical, Digital, Service and Advanced), and tax-category takes one of a closed list such as standard-taxable, digital-goods or food-groceries.

// create-product.ts
import { WebflowClient } from "webflow-api";

const client = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const siteId = process.env.WEBFLOW_SITE_ID!;

const created = await client.products.create(siteId, {
  publishStatus: "staging",
  product: {
    fieldData: {
      name: "Heritage tomato seeds",
      slug: "heritage-tomato-seeds",
      description: "Open-pollinated tomato seeds grown in the Fernbrook kitchen garden.",
      shippable: true,
      skuProperties: [
        {
          id: "packet",
          name: "Packet",
          enum: [
            { id: "25-seeds", name: "25 seeds", slug: "25-seeds" },
            { id: "50-seeds", name: "50 seeds", slug: "50-seeds" },
          ],
        },
      ],
    },
  },
  sku: {
    fieldData: {
      name: "Heritage tomato seeds - 25 seeds",
      slug: "heritage-tomato-seeds-25-seeds",
      sku: "FG-TOM-25",
      price: { value: 450, unit: "EUR", currency: "EUR" },
    },
  },
});

console.log("product", created.product?.id, "default SKU", created.skus?.[0]?.id);

Adding SKUs and updating them

POST /v2/sites/{site_id}/products/{product_id}/skus adds more SKUs to an existing product (a body with a skus array and the same publishStatus). PATCH /v2/sites/{site_id}/products/{product_id} updates the product and PATCH /v2/sites/{site_id}/products/{product_id}/skus/{sku_id} updates one SKU. Both PATCH requests accept publishStatus: with the default staging the change waits for the next publish of the site, and with live it is published immediately.

// add-sku-and-update-price.ts
import { WebflowClient } from "webflow-api";

const client = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const siteId = process.env.WEBFLOW_SITE_ID!;
const productId = "REPLACE_WITH_PRODUCT_ID";

const { skus } = await client.products.createSku(siteId, productId, {
  publishStatus: "staging",
  skus: [
    {
      fieldData: {
        name: "Heritage tomato seeds - 50 seeds",
        slug: "heritage-tomato-seeds-50-seeds",
        sku: "FG-TOM-50",
        price: { value: 750, unit: "EUR", currency: "EUR" },
        skuValues: { packet: "50-seeds" },
      },
    },
  ],
});

await client.products.updateSku(siteId, productId, skus![0].id!, {
  publishStatus: "live",
  sku: { fieldData: { price: { value: 700, unit: "EUR", currency: "EUR" } } },
});

The reference lists no endpoint that deletes a product or a SKU, and none that archives one other than writing isArchived through the update call. Products are removed in the Designer’s Ecommerce panel or the Editor.

Orders

An order is created by the checkout, never through the API; the API reads orders and changes a few of their fields and their state.

Endpoint Scope Effect

GET /v2/sites/{site_id}/orders

ecommerce:read

Lists orders. Filter with status (pending, unfulfilled, fulfilled, disputed, dispute-lost, refunded); limit is at most 100, and offset pages.

GET /v2/sites/{site_id}/orders/{order_id}

ecommerce:read

Reads one order.

PATCH /v2/sites/{site_id}/orders/{order_id}

ecommerce:write

Updates only comment, shippingProvider, shippingTracking and shippingTrackingURL. These are notes for your own records; Webflow does not use them.

POST /v2/sites/{site_id}/orders/{order_id}/fulfill

ecommerce:write

Sets the status to fulfilled. The optional body flag sendOrderFulfilledEmail (default false) sends the customer the Order Fulfilled email.

POST /v2/sites/{site_id}/orders/{order_id}/unfulfill

ecommerce:write

Moves a fulfilled order back to unfulfilled.

POST /v2/sites/{site_id}/orders/{order_id}/refund

ecommerce:write

Reverses the Stripe charge and sets the status to refunded. The optional reason is duplicate, fraudulent or requested.

An order object carries more than the shop manager needs for fulfilment, which is why the fields that matter are grouped here.

Field group Fields

Identity and state

orderId (usually six hexadecimal characters, nine when a site has a very large number of orders), status, acceptedOn, fulfilledOn, refundedOn, disputedOn, disputeUpdatedOn, disputeLastStatus (a Stripe dispute status such as needs_response, under_review, won or lost)

Money

customerPaid, netAmount and applicationFee, each with unit (ISO currency), value (a string in the base unit of the currency) and string (display text), and totals with subtotal, extras (discounts, shipping and taxes) and total

People and addresses

customerInfo (fullName and email), shippingAddress, billingAddress and allAddresses, each address with addressee, line1, line2, city, state, country and postalCode

Items

purchasedItems: for each row count, rowTotal, productId, productName, productSlug, variantId, variantName, variantSlug, variantSKU, variantImage, variantPrice, and the optional weight, width, height and length; plus purchasedItemsCount

Fulfilment

isShippingRequired, hasDownloads, downloadFiles, shippingProvider, shippingTracking, shippingTrackingURL

Payment

paymentProcessor, stripeDetails (identifiers of the payment intent, charge, customer, dispute and refund), stripeCard (brand, last four digits, owner name, expiry) and paypalDetails

Free text

comment (yours, via the API), orderComment (the customer’s note), customData (extra checkout inputs) and metadata

The payment data is sensitive. A token with ecommerce:read can read customer names, e-mail addresses, postal addresses and the last four digits of cards, so treat it like a customer database: use a site token restricted to the scopes the job needs, keep it out of browsers and logs, and do not copy order exports into spreadsheets that are shared widely. The European Union’s data-protection rules apply to the garden’s German and Spanish customers like any other.

// fulfil-unfulfilled-orders.ts
import { WebflowClient } from "webflow-api";

const client = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const siteId = process.env.WEBFLOW_SITE_ID!;

const { orders = [] } = await client.orders.list(siteId, { status: "unfulfilled", limit: 100 });

for (const order of orders) {
  if (!order.orderId || !order.isShippingRequired) continue;

  // 1. record the carrier data (notes for the shop, not used by Webflow)
  await client.orders.update(siteId, order.orderId, {
    shippingProvider: "Correos",
    shippingTracking: "REPLACE_WITH_TRACKING_NUMBER",
    shippingTrackingURL: "https://example.com/track/REPLACE_WITH_TRACKING_NUMBER",
  });

  // 2. mark it fulfilled and let Webflow e-mail the customer
  await client.orders.updateFulfill(siteId, order.orderId, { sendOrderFulfilledEmail: true });
  console.log("fulfilled", order.orderId, order.customerInfo?.email);
}

The same job in Python, with a refund path for orders the shop cannot deliver:

# orders.py
import os
from webflow import Webflow

client = Webflow(access_token=os.environ["WEBFLOW_API_TOKEN"])
site_id = os.environ["WEBFLOW_SITE_ID"]

offset, limit = 0, 100
while True:
    page = client.orders.list(site_id, status="unfulfilled", offset=offset, limit=limit)
    for order in page.orders or []:
        if order.is_shipping_required is False:
            continue
        client.orders.update(
            site_id,
            order.order_id,
            shipping_provider="Correos",
            shipping_tracking="REPLACE_WITH_TRACKING_NUMBER",
        )
        client.orders.update_fulfill(site_id, order.order_id, send_order_fulfilled_email=True)
    offset += limit
    if offset >= (page.pagination.total if page.pagination else 0):
        break

# An order that cannot be delivered is refunded instead (this reverses the Stripe charge).
# client.orders.refund(site_id, "REPLACE_WITH_ORDER_ID", reason="requested")

Three pitfalls are worth stating plainly.

  • Fulfilling is not shipping. The status change records that the shop has fulfilled the order; it does not talk to a carrier. The tracking fields are free text for the shop’s own use.

  • A refund is a payment operation. It reverses the Stripe charge, so it cannot be undone by the API; the unfulfill endpoint does not restore a refund. Run refunds from a reviewed queue, not from an unattended loop.

  • Paging while changing state. Listing unfulfilled orders with offset while fulfilling them shifts the result set under you, because each fulfilled order leaves the filter. Collect the identifiers first, then change them, or always read offset 0 until the page is empty.

Inventory

Inventory is addressed by the SKUs Collection and the SKU item: GET /v2/collections/{sku_collection_id}/items/{sku_id}/inventory returns id, inventoryType and, for finite stock, quantity; PATCH on the same path changes it. The body requires inventoryType (infinite or finite) and then takes either an absolute quantity or a relative updateQuantity that is added to the stored value and may be negative.

// stock.ts
import { WebflowClient } from "webflow-api";

const client = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const skuCollectionId = "REPLACE_WITH_SKU_COLLECTION_ID";
const skuId = "REPLACE_WITH_SKU_ID";

// absolute: the stock count after a physical inventory in the greenhouse shop
await client.inventory.update(skuCollectionId, skuId, { inventoryType: "finite", quantity: 120 });

// relative: a delivery of 40 packets arrived, so add instead of overwriting
const after = await client.inventory.update(skuCollectionId, skuId, {
  inventoryType: "finite",
  updateQuantity: 40,
});
console.log(after.quantity);

Prefer updateQuantity whenever more than one system can sell the same packet: two writers that both read 120 and write back absolute values lose a sale, while two relative changes both land. The webhook trigger types ecomm_new_order, ecomm_order_changed and ecomm_inventory_changed report new orders, order changes and stock changes, so a stock mirror can listen instead of polling; the delivery rules are in Webhooks.

Ecommerce settings

GET /v2/sites/{site_id}/ecommerce/settings returns the siteId, the createdOn date and the defaultCurrency. It is read-only; checkout, tax and shipping settings are not exposed.

curl -s "https://api.webflow.com/v2/sites/${WEBFLOW_SITE_ID}/ecommerce/settings" \
  -H "Authorization: Bearer ${WEBFLOW_API_TOKEN}"

Localization in the Data API

Localization is the second half of this page. The Help Center explains how the Designer handles locales; the Data API adds a programmatic way to write translations into the same places.

What can be localized

Content type Scope Endpoints

Page content

One page

Get Page Content and Update Page Content (the page DOM)

Page metadata

One page

Get Page Metadata and Update Page Metadata (SEO title and description, Open Graph, slug)

Component overrides

One page, one instance

The component-instance nodes of the page DOM

Component property defaults

Site-wide

Get and Update Component Properties

Component static content

Site-wide

Get and Update Component Content

CMS items

Site-wide

The ordinary CMS item endpoints with a cmsLocaleId

Two limits apply to all of them. Images cannot be localized through the API: the page and component endpoints return image nodes with their alt text and assetId, but updating an image is not supported and is done in the Designer. And styles and classes cannot be localized through the Data API either; they belong to the Designer.

Locale identifiers: localeId versus cmsLocaleId

The Get Site response carries a locales object: a primary locale and a list of secondary locales. Every locale has two identifiers, and mixing them up is the commonest localization bug.

Identifier Used for Parameter name

id (called localeId where it is used)

Pages and components

localeId query parameter or body field

cmsLocaleId

CMS items (and products, as items)

cmsLocaleId in item bodies, cmsLocaleId and cmsLocaleIds selectors

The documentation states it directly: localeId is for page and component content, and cmsLocaleId is used exclusively for CMS items; both come from the Get Site endpoint. A locale object also holds tag (such as es-ES), displayName, subdirectory (the URL prefix such as es), enabled and, on the primary locale, redirect. If a request omits the locale identifier it works on the primary locale.

flowchart LR SITE["Get Site: locales.primary and locales.secondary"] --> LID["id (localeId)"] SITE --> CID["cmsLocaleId"] LID --> PAGES["Pages: page DOM and metadata"] LID --> COMP["Components: content and properties"] CID --> ITEMS["CMS items: create, update, list, publish"] PAGES --> SEC["Writes only to secondary locales"] COMP --> SEC ITEMS --> BOTH["Reads and writes primary and secondary"]

Page and component writes work only on secondary locales: the primary locale is the source of truth, so a request that tries to update primary content fails, and changes to it are made on the canvas. CMS items can be created and edited in both primary and secondary locales.

// locales.ts
import { WebflowClient } from "webflow-api";

const client = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const site = await client.sites.get(process.env.WEBFLOW_SITE_ID!);

const primary = site.locales?.primary;
console.log("primary", primary?.tag, primary?.id, primary?.cmsLocaleId);

for (const l of site.locales?.secondary ?? []) {
  console.log(l.tag, "localeId:", l.id, "cmsLocaleId:", l.cmsLocaleId, "path:", l.subdirectory);
}

Localized pages

Localizing a page is two steps: read from the primary locale, write to the secondary locale.

Page content (the DOM)

GET /v2/pages/{page_id}/dom returns the page’s static content as a list of nodes, not the whole DOM: only text and images available for localization. Dynamic pages, such as Collection template pages, return an empty response; their content is CMS content. Each node has an id, a type and attributes, and a type-specific body.

Node type Content

text

Headings, text blocks, rich text and form labels; an object with html and text

image

image.alt and image.assetId; read-only for the API

text-input

placeholder

select

choices, each with a value and a text

submit-button

value and waitingText

search-button

The button text of a site search element

component-instance

componentId and propertyOverrides (each with propertyId, type, label and text)

POST /v2/pages/{page_id}/dom with a localeId query parameter updates the secondary locale. The body is a list of nodes, each with the nodeId and the new content. The rules from the reference are:

  • Up to 1,000 nodes per request.

  • Send only what changed. A node you leave out inherits the primary content.

  • Keep the HTML structure. The translated text must have exactly the tag structure of the html received from the Get endpoint, and nested elements that carry data-w-id attributes must keep those identifiers, because the identifiers preserve custom attributes and links on inner elements.

  • Check the body, not only the status. An error on a single node can still return 200; read the errors array.

  • In the primary locale a page that lists component instances shows all of them; in a secondary locale only instances with property overrides appear.

  • After an update the canvas may need a refresh to show the new content.

The Get endpoint also accepts limit and offset (at most 100 nodes per page of results) and a translatable filter that is covered in The translatable filter.

Page metadata

GET /v2/pages/{page_id} reads the metadata (title, slug, seo and openGraph) and PUT /v2/pages/{page_id} updates it; with a localeId they act on the secondary locale. Slug localization is available only with the Localize Advanced add-on and with Enterprise Localization.

// page-metadata.ts
const base = "https://api.webflow.com/v2";
const headers = {
  Authorization: `Bearer ${process.env.WEBFLOW_API_TOKEN}`,
  "Content-Type": "application/json",
};
const pageId = "REPLACE_WITH_PAGE_ID";
const spanishLocaleId = "REPLACE_WITH_SPANISH_LOCALE_ID";

const res = await fetch(`${base}/pages/${pageId}?localeId=${spanishLocaleId}`, {
  method: "PUT",
  headers,
  body: JSON.stringify({
    seo: {
      title: "Jardines Fernbrook | Plantas y eventos",
      description: "Visite los Jardines Fernbrook: plantas, zonas del jardin y eventos de temporada.",
    },
    openGraph: { title: "Jardines Fernbrook", description: "Plantas y eventos de temporada." },
  }),
});
if (!res.ok) throw new Error(`metadata update failed: ${res.status} ${await res.text()}`);

Localized components

A component is localized in two different places, and the choice matters.

  • Definition localization changes the component for the whole locale: its static content (text nodes, form elements and nested instances) with Get and Update Component Content, and its default property values with Get and Update Component Properties. Both take the localeId as a query parameter. A property is a Plain Text, Rich Text or Alt Text value.

  • Instance localization changes a property override on one instance on one page. It is done through the page DOM: the component-instance node’s propertyOverrides are read and written with the page endpoints. Only an overridden property can be localized on an instance; a property that still has its default value follows the definition.

A site-wide "Call to action" component with the button text "Plan your visit" therefore becomes "Planifique su visita" in Spanish by updating the definition once, while one landing page that overrides the text to "Join the spring festival" gets its own Spanish override through the page. The component endpoints are listed with GET /v2/sites/{site_id}/components (100 per page).

// localize-component.ts
import { WebflowClient } from "webflow-api";

const client = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const siteId = process.env.WEBFLOW_SITE_ID!;
const spanishLocaleId = "REPLACE_WITH_SPANISH_LOCALE_ID";

const { components = [] } = await client.components.list(siteId);
const cta = components.find((c) => c.name === "Call to action");
if (!cta?.id) throw new Error("component not found");

// default property values: the label of the button in the Spanish locale
const props = await client.components.getProperties(siteId, cta.id);
await client.components.updateProperties(siteId, cta.id, {
  localeId: spanishLocaleId,
  properties: (props.properties ?? [])
    .filter((p) => p.label === "Button text")
    .map((p) => ({ propertyId: p.propertyId!, text: "Planifique su visita" })),
});

// static content of the definition: keep the HTML shape, change the words
const content = await client.components.getContent(siteId, cta.id);
console.log("static nodes:", content.nodes?.length);

Localized CMS items

A CMS item becomes a group of variants once the site has locales: the variants share one item id and differ by cmsLocaleId. Each variant has its own field values, its own slug, its own isDraft flag and its own publishing state.

  • Create in every locale. POST /v2/collections/{collection_id}/items/insert with one entry that has allCmsLocales: true and the primary content in fieldData creates a variant in every current locale, including the primary. The variants share the item ID and the same initial content, and each variant counts toward the 100-item request limit. cmsLocaleIds selects particular locales instead; omitting both creates the item in the primary locale only. allCmsLocales applies to new items only and cannot be combined with id or cmsLocaleIds.

  • Add a locale to an existing item. Call the same endpoint with the item’s id and the new locale in cmsLocaleIds; the new variant joins the existing item.

  • Write the translations. Update each variant with Update Items, giving the shared id, the target cmsLocaleId and its translated fieldData.

  • Read. List Items takes cmsLocaleId as a query parameter, including a comma-separated list for several locales at once; combine it with filter[id][eq] and the item’s ID to fetch every variant of one item. Get Item takes a single locale. Omitting the locale returns the primary locale.

  • Publish per locale. Each locale has independent publishing state: publishing the Spanish variant does not change the German one. Publish Items accepts either a plain itemIds list or an items list of { id, cmsLocaleIds }, and the Data API cannot control scheduled publication.

{
  "items": [
    { "id": "REPLACE_WITH_ITEM_ID", "cmsLocaleIds": ["REPLACE_WITH_SPANISH_CMS_LOCALE_ID"] },
    { "id": "REPLACE_WITH_ITEM_ID", "cmsLocaleIds": ["REPLACE_WITH_GERMAN_CMS_LOCALE_ID"] }
  ]
}

The translatable filter, the node rules and the CMS selectors are combined in the next section’s script. The webflow-api 3.3.4 typings model cmsLocaleIds on bulk creation and localeId on page content, but they do not declare allCmsLocales or translatable, so those two parameters are sent with fetch in the script.

The translatable filter

Workspaces that use translation exclusions mark parts of the primary content as not translatable (a brand name, a legal phrase, a product code). The Get Page Content endpoint has a translatable query parameter that applies those rules.

  • Its value is the identifier of the secondary locale you are translating into.

  • It is independent of localeId, which selects which version is returned. To fetch the source text for translation, request the primary content and name the target: ?localeId={primary locale id}&translatable={target locale id}.

  • Only exclusion rules scoped to manual translation are respected; rules scoped only to automatic translation do not change the response.

  • Omitting translatable returns the unfiltered response. A value that is the primary locale, or any other value that is not a secondary locale, returns 400, and a site that has not enabled translation exclusions returns 403.

The script below treats a 403 as "no exclusions configured" and retries without the filter.

A translation-sync script

The script fernbrook-i18n.ts makes the Plants Collection and the Home page available in Spanish and German. It reads the locales, creates a new plant in every locale, writes the translations of the variants, translates the page DOM node by node while keeping the HTML shape, and publishes the variants of each locale separately. The function translate stands for the project’s own source of translations (a glossary, an agency export or a translation service); the example uses a small dictionary so that the script runs without any external service.

// fernbrook-i18n.ts
import { WebflowClient } from "webflow-api";

const token = process.env.WEBFLOW_API_TOKEN!;
const siteId = process.env.WEBFLOW_SITE_ID!;
const plantsId = process.env.WEBFLOW_PLANTS_COLLECTION_ID!;
const homePageId = process.env.WEBFLOW_HOME_PAGE_ID!;
const API = "https://api.webflow.com/v2";

const client = new WebflowClient({ accessToken: token });

// --- a minimal fetch helper with 429 backoff (the SDK retries too, this covers typings gaps)
async function wf<T>(path: string, init: RequestInit = {}, attempt = 0): Promise<{ status: number; body: T }> {
  const res = await fetch(`${API}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...init.headers },
  });
  if (res.status === 429 && attempt < 5) {
    const wait = Number(res.headers.get("retry-after") ?? "60");
    await new Promise((r) => setTimeout(r, wait * 1000));
    return wf<T>(path, init, attempt + 1);
  }
  const text = await res.text();
  return { status: res.status, body: (text ? JSON.parse(text) : {}) as T };
}

// --- the source of translations; replace with a glossary file or a translation service
const glossary: Record<string, Record<string, string>> = {
  "es-ES": { "Welcome to Fernbrook Gardens": "Bienvenido a los Jardines Fernbrook", "Plan your visit": "Planifique su visita",
    "Japanese maple": "Arce japones", "A slow-growing tree with red autumn leaves.": "Un arbol de crecimiento lento con hojas rojas en otono." },
  "de-DE": { "Welcome to Fernbrook Gardens": "Willkommen in den Fernbrook Gardens", "Plan your visit": "Besuch planen",
    "Japanese maple": "Japanischer Faecherahorn", "A slow-growing tree with red autumn leaves.": "Ein langsam wachsender Baum mit roten Herbstblaettern." },
};
const translate = (tag: string, text: string) => glossary[tag]?.[text] ?? text;

// 1. locale identifiers: localeId for pages, cmsLocaleId for items
const site = await client.sites.get(siteId);
const secondary = (site.locales?.secondary ?? []).filter((l) => l.tag === "es-ES" || l.tag === "de-DE");
if (secondary.length !== 2) throw new Error("enable the es-ES and de-DE locales first");

// 2. CMS: one request creates the plant in every locale (primary + secondary), same item id
const created = await wf<{ items: { id: string; cmsLocaleId: string }[] }>(`/collections/${plantsId}/items/insert`, {
  method: "POST",
  body: JSON.stringify({
    items: [{ allCmsLocales: true, fieldData: {
      name: "Japanese maple", slug: "japanese-maple", "short-description": "A slow-growing tree with red autumn leaves.",
    } }],
  }),
});
if (created.status >= 300) throw new Error(`create failed: ${created.status}`);
const itemId = created.body.items[0].id;

// 3. write the translated field values per variant, with a localized slug
await wf(`/collections/${plantsId}/items`, {
  method: "PATCH",
  body: JSON.stringify({
    items: secondary.map((l) => ({
      id: itemId,
      cmsLocaleId: l.cmsLocaleId,
      fieldData: {
        name: translate(l.tag!, "Japanese maple"),
        slug: l.tag === "es-ES" ? "arce-japones" : "japanischer-faecherahorn",
        "short-description": translate(l.tag!, "A slow-growing tree with red autumn leaves."),
      },
    })),
  }),
});

// 4. publish each locale on its own
for (const l of secondary) {
  const r = await wf(`/collections/${plantsId}/items/publish`, {
    method: "POST",
    body: JSON.stringify({ items: [{ id: itemId, cmsLocaleIds: [l.cmsLocaleId] }] }),
  });
  console.log("published", l.tag, r.status);
}

// 5. pages: read the primary DOM, translate text nodes, write to each secondary locale
type Node = { id: string; type: string; text?: { html?: string | null; text?: string | null } };
const primaryLocaleId = site.locales!.primary!.id!;

for (const l of secondary) {
  let q = `?localeId=${primaryLocaleId}&translatable=${l.id}&limit=100`;
  let dom = await wf<{ nodes: Node[] }>(`/pages/${homePageId}/dom${q}`);
  if (dom.status === 403) {
    q = `?localeId=${primaryLocaleId}&limit=100`; // translation exclusions are not enabled
    dom = await wf<{ nodes: Node[] }>(`/pages/${homePageId}/dom${q}`);
  }

  const nodes = dom.body.nodes
    .filter((n) => n.type === "text" && n.text?.html)
    .map((n) => ({
      nodeId: n.id,
      // keep the tag structure and data-w-id attributes; swap only the words between the tags
      text: n.text!.html!.replace(/>([^<]+)</g, (_m, words: string) => `>${translate(l.tag!, words.trim())}<`),
    }));

  const r = await wf<{ errors?: unknown[] }>(`/pages/${homePageId}/dom?localeId=${l.id}`, {
    method: "POST",
    body: JSON.stringify({ nodes }),
  });
  console.log("page", l.tag, r.status, "errors:", r.body.errors?.length ?? 0);
}
export WEBFLOW_HOME_PAGE_ID="REPLACE_WITH_PAGE_ID"
npx tsx fernbrook-i18n.ts

The script shows the patterns that make a sync safe to repeat.

  • Identifiers first. It reads the locales from the site on every run, so no identifier is hard-coded.

  • One creation, many variants. allCmsLocales avoids creating the same item once per language, and the shared item id keeps the variants linked.

  • Locale-scoped publishing. A German variant that is not ready never blocks the Spanish one.

  • Structure-preserving page edits. Only the text between tags changes, so data-w-id attributes survive. A real translation service must be told to keep the markup, and the result should be validated before it is sent.

  • Status checks. Both page writes and item writes are checked; a 200 from the page endpoint is followed by a count of the errors array.

A Python variant of the page-translation step for teams that run their content pipeline in Python:

# translate_page.py
import os
from webflow import Webflow, TextNodeWrite

client = Webflow(access_token=os.environ["WEBFLOW_API_TOKEN"])
page_id = os.environ["WEBFLOW_HOME_PAGE_ID"]
spanish_locale_id = "REPLACE_WITH_SPANISH_LOCALE_ID"

dom = client.pages.get_content(page_id, limit=100)  # primary locale
nodes = [
    TextNodeWrite(node_id=n.id, text=n.text.html.replace("Plan your visit", "Planifique su visita"))
    for n in dom.nodes or []
    if n.type == "text" and n.text and n.text.html
]
client.pages.update_static_content(page_id, locale_id=spanish_locale_id, nodes=nodes)

Ecommerce and localization in one site

The two halves of this page meet in one question: can the shop be Spanish and German? The Help Center states that Localization does not support the features of an Ecommerce Site plan, so products, categories and the Ecommerce pages (cart, checkout and order confirmation) are not localized. The Data API guides follow the same split: the product and SKU responses carry a cmsLocaleId, since they are CMS items, but the documented localization workflows cover pages, components and ordinary CMS Collections, not the shop. For Fernbrook Gardens this means:

  • the Plants, Garden zones, Events and Journal Collections are available in English, Spanish and German;

  • the shop stays English, and a German visitor who opens the shop leaves the localized part of the site;

  • the order e-mails come from the shop’s Ecommerce settings, outside the translation API.

A team that needs a multilingual shop therefore builds the product copy in separate English-language products per market or uses a separate site per language, both outside the scope of the localization endpoints. The decision is part of Comparison and Decision Guide.

Errors and pitfalls

Symptom Cause and fix

409 on every ecommerce call

The site does not have Ecommerce enabled. Check the Site plan and the Ecommerce settings before debugging the payload.

403 on a write

The token lacks ecommerce:write, pages:write or cms:write; scopes are fixed when the token is created.

400 when updating page content

The localeId is the primary locale, the node structure does not match the Get response, or more than 1,000 nodes were sent.

Content is not found in the new language

The cmsLocaleId was used where a localeId is required, or the reverse.

Translated text appears but a link is lost

The data-w-id attributes on inner elements were dropped when the HTML was rewritten.

429

The plan’s per-minute limit was exceeded (60 requests per minute on the Starter and Basic tiers, 120 on the CMS, Ecommerce and Business tiers, custom on Enterprise); wait for the Retry-After seconds. The cost of a localization sync is high, because every page and every variant is a request.

Stock is wrong after a sale

An absolute quantity overwrote a concurrent change. Use updateQuantity for relative changes.

The Designer shows a product in a different editing mode after a script ran

Creating or updating a product or SKU through the API sets the product type to Advanced.

Security and privacy notes

  • An Ecommerce token can read customers' names, e-mail addresses and postal addresses; give it the smallest scope that works and rotate it when a team member leaves.

  • Refunds and fulfilments change money and e-mails sent to customers; use a staging copy of the site while developing and keep the live token in the deployment platform’s secret store.

  • Translated content is user-facing; run it through the same review as the source, because the API publishes what it is given.

  • Do not send customer data to a translation service. Translate field values and page text, not order or customer records.