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: npm: webflow-api
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 |
|---|---|---|
|
Listing and reading products, SKUs, orders, inventory levels and the ecommerce settings |
Order reports, stock checks |
|
Creating and updating products and SKUs, updating, fulfilling and refunding orders, updating inventory |
Catalogue sync, fulfilment, stock corrections |
|
Reading the site, including its locales |
Resolving locale identifiers |
|
Reading and writing page metadata and page content, and component content and properties |
Page and component localization |
|
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
Source: Data API: List Products and SKUs
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 |
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 |
Order |
A completed checkout. It has a short random |
Inventory |
The stock record of one SKU: |
Ecommerce settings |
The site identifier, the creation date and the |
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;
}
Source: Data API: List Products and SKUs
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,slugandpriceare required.priceis an object withvalue,unitandcurrency; the reference examples write a price of2499withUSDfor 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 anid, anameand anenumof options withid,nameandslug. 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-valuesmap of a SKU maps a propertyidto the optionidthat 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-typetakes one of four opaque identifiers (listed in the reference in the order Physical, Digital, Service and Advanced), andtax-categorytakes one of a closed list such asstandard-taxable,digital-goodsorfood-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);
Source: Data API: Create Product and SKU
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" } } },
});
Source: Data API: Create SKUs
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 |
|---|---|---|
|
|
Lists orders. Filter with |
|
|
Reads one order. |
|
|
Updates only |
|
|
Sets the status to |
|
|
Moves a fulfilled order back to unfulfilled. |
|
|
Reverses the Stripe charge and sets the status to |
Source: Data API: List Orders
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 |
|
Money |
|
People and addresses |
|
Items |
|
Fulfilment |
|
Payment |
|
Free text |
|
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);
}
Source: Data API: Fulfill Order
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")
Source: Data API: Refund Order
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
unfulfillendpoint does not restore a refund. Run refunds from a reviewed queue, not from an unattended loop. -
Paging while changing state. Listing
unfulfilledorders withoffsetwhile 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);
Source: Data API: Update Item Inventory
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}"
Source: Data API: Get Ecommerce Settings
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 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 |
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 |
|---|---|---|
|
Pages and components |
|
|
CMS items (and products, as items) |
|
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.
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 |
|---|---|
|
Headings, text blocks, rich text and form labels; an object with |
|
|
|
|
|
|
|
|
|
The button text of a site search element |
|
|
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
textmust have exactly the tag structure of thehtmlreceived from the Get endpoint, and nested elements that carrydata-w-idattributes 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 theerrorsarray. -
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()}`);
Source: Data API: Localizing pages
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
localeIdas a query parameter. A property is aPlain Text,Rich TextorAlt Textvalue. -
Instance localization changes a property override on one instance on one page. It is done through the page DOM: the
component-instancenode’spropertyOverridesare 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);
Source: Data API: Localizing components
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/insertwith one entry that hasallCmsLocales: trueand the primary content infieldDatacreates 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.cmsLocaleIdsselects particular locales instead; omitting both creates the item in the primary locale only.allCmsLocalesapplies to new items only and cannot be combined withidorcmsLocaleIds. -
Add a locale to an existing item. Call the same endpoint with the item’s
idand the new locale incmsLocaleIds; the new variant joins the existing item. -
Write the translations. Update each variant with Update Items, giving the shared
id, the targetcmsLocaleIdand its translatedfieldData. -
Read. List Items takes
cmsLocaleIdas a query parameter, including a comma-separated list for several locales at once; combine it withfilter[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
itemIdslist or anitemslist 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"] }
]
}
Source: Data API: Publish Items
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
translatablereturns the unfiltered response. A value that is the primary locale, or any other value that is not a secondary locale, returns400, and a site that has not enabled translation exclusions returns403.
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
Source: Data API: Localizing pages
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.
allCmsLocalesavoids creating the same item once per language, and the shared itemidkeeps 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-idattributes 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
200from the page endpoint is followed by a count of theerrorsarray.
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)
Source: Data API: Localizing pages
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 |
|---|---|
|
The site does not have Ecommerce enabled. Check the Site plan and the Ecommerce settings before debugging the payload. |
|
The token lacks |
|
The |
Content is not found in the new language |
The |
Translated text appears but a link is lost |
The |
|
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 |
Stock is wrong after a sale |
An absolute |
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.