Data API: Sites, Pages and Components
|
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 site-level and page-level parts of the Webflow Data API v2 as they were on 2026-10-28, the date
on which its facts were checked against the developers.webflow.com API reference and the JavaScript SDK sources. The
examples use webflow-api 3.3.4 (JavaScript and TypeScript, Node.js 22 or later) and webflow 2.0.0 (the Python
package on PyPI, which at that version does not yet expose every endpoint described here; the Python examples therefore
call the REST API with httpx where the SDK has no method). Reading sites, pages, components and comments works with a
site token from a free Developer workspace site, so every example can be run without a paid plan. Four things need more:
creating, updating and deleting sites, 301 redirects, robots.txt and well-known files all require an Enterprise
workspace, and the comments endpoints need an OAuth app because the author of a reply is the user who authorized the
token. Plans are named by tier only, never by price. All tokens, site IDs and page IDs below are placeholders such as
WEBFLOW_API_TOKEN and REPLACE_WITH_SITE_ID.
The running example is Fernbrook Gardens, a fictitious botanical garden with a Spanish and a German version of its site. The team keeps a Plants Collection, a Garden zones Collection, an Events Collection and a Journal Collection (modelled in CMS Concepts and Content Modeling). This page is about everything that is not a CMS item: the site itself, its domains, its static pages, its components and its review comments. CMS items have their own page, Data API: CMS. The authentication model, token types and scope names used below are explained in Data API: Authentication and Scopes, and the SDK behaviour, rate limits and error format in Data API: SDKs, Limits and Errors.
What this part of the API is for
The Designer is where a person lays out a site. This part of the Data API exposes the read side of that work and a narrow write side. It does not let a program move elements around; the Designer API in Designer Extensions and the Designer API does that. What it does let a program do is:
-
find out which sites a token can reach, their domains, locales, time zone and last-published date;
-
publish a site to the
webflow.iostaging domain, to chosen custom domains, or publish a single page; -
read and write a page’s title, slug, SEO and Open Graph metadata, including per locale;
-
read the static text of a page and write translated text into a secondary locale;
-
do the same for the content and the property values of components;
-
read and resolve the comment threads that reviewers leave on the canvas;
-
on Enterprise workspaces, manage sites, 301 redirects,
robots.txtand files under/.well-known/.
The people who use these endpoints are the developer who wires a deployment pipeline, the localization manager who sends page text to a translation service, the SEO specialist who audits titles and descriptions across hundreds of pages, and the agency lead who runs the same change across many client sites. The editor and the designer seldom see them, but they benefit from the result.
| Area | Endpoints | Scope | Plan |
|---|---|---|---|
Sites |
List sites, get a site, get custom domains, publish |
|
Any plan, Developer workspace included |
Sites (workspace level) |
Create, update, delete, get site plan |
|
Enterprise workspace |
Pages |
List pages, get and update metadata, get and update static content |
|
Any plan |
Components |
List, get and update content, get and update properties |
|
Any plan |
Comments |
List threads, get a thread, list and create replies, resolve |
|
Any plan, OAuth app for writes |
Site configuration |
301 redirects, |
|
Enterprise workspace |
Source: Data API v2: documentation index
All paths in this page are relative to https://api.webflow.com/v2. Every request carries the header
Authorization: Bearer followed by the token, and a request with a token that lacks the scope answers with a 401 error
whose code is missing_scopes.
Sites
Listing and reading sites
GET /v2/sites returns every site the token can access. A site token (created under Site settings > Apps &
integrations) sees exactly one site; an OAuth token sees the sites the user selected during authorization; a
workspace token sees the sites in its workspace. GET /v2/sites/{site_id} returns one site. Both need sites:read.
The site object carries these fields (all but id are optional in the schema):
| Field | Meaning |
|---|---|
|
The 24-character site ID used in every other path. Treat it as an opaque string. |
|
The workspace that owns the site. Needed for the Enterprise create-site endpoint. |
|
The name shown in the dashboard and its slugified form. |
|
ISO 8601 timestamps. |
|
The time zone from Site settings. It matters for scheduled publishing and for how analytics are bucketed. |
|
The URL of a generated screenshot of the site. |
|
The workspace folder that holds the site, or |
|
An array of domain objects with an |
|
The primary locale and the secondary locales, each with its |
|
Whether the site collects visitor data and in which mode, as set in the site’s privacy settings. |
// list-sites.ts -- run with: node --env-file=.env list-sites.ts (Node.js 22.18 or later strips the types)
import { WebflowClient } from "webflow-api";
const webflow = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const { sites = [] } = await webflow.sites.list();
for (const site of sites) {
console.log(site.id, site.shortName, site.timeZone, site.lastPublished);
}
const siteId = process.env.WEBFLOW_SITE_ID ?? "REPLACE_WITH_SITE_ID";
const site = await webflow.sites.get(siteId);
console.log(site.locales?.primary?.tag, site.locales?.secondary?.map((l) => l.tag));
Source: Data API: List Sites
curl -s https://api.webflow.com/v2/sites \
-H "Authorization: Bearer $WEBFLOW_API_TOKEN" \
-H "accept: application/json"
Source: Data API: Get Site
Custom domains
GET /v2/sites/{site_id}/custom_domains (scope sites:read) returns the custom domains attached to the site, each
with its id and url. The IDs are what the publish endpoint wants, so a deployment script reads the domains first and
selects by url. The webflow.io subdomain is not a custom domain and has no ID: it is addressed with the boolean
publishToWebflowSubdomain. Adding, verifying and removing domains is a dashboard operation (see
Domains, Hosting and Publishing Options); the Data API
cannot do it.
Source: Data API: Get Custom Domains
Enterprise: creating, updating and deleting sites
Agencies and enterprises that provision sites from a template use the workspace endpoints. They need an Enterprise workspace and a workspace token or a Data Client app that was authorized for the workspace.
-
POST /v2/workspaces/{workspace_id}/sitescreates a site. The body has a requiredname, an optionaltemplateName(a workspace or Marketplace template) and an optionalparentFolderId. It needs the scopeworkspace:writeand answers 201 with the new site object. -
An update endpoint (
PATCH) and a delete endpoint (DELETE) on/v2/sites/{site_id}needsites:writeand an Enterprise workspace. Deletion cannot be undone from the API, so run it only with a token scoped to a staging workspace while testing. -
workspace_idis found with the list-workspaces endpoint, withauthorization.authorizedTo.workspaceIdsfrom the token introspection call, or, with a site token, from theworkspaceIdof Get Site.
Source: Data API: Create Site
Publishing
The publish endpoint
POST /v2/sites/{site_id}/publish (scope sites:write) queues a publish and answers 202 Accepted: the build runs
after the response, so a 202 means "queued", not "live". The body accepts three properties:
| Property | Type | Meaning |
|---|---|---|
|
array of domain IDs |
The custom domains to publish to, taken from Get Custom Domains. |
|
boolean, default |
Publish to the |
|
string |
Publish only this page instead of the whole site. |
The request must contain at least one of customDomains or publishToWebflowSubdomain; otherwise the call is rejected.
The response echoes the domains and a publishScope that says whether the whole site or an individual page was queued.
The endpoint has its own rate limit: one successful publish queue per minute. A second call inside that minute is answered with 429, independently of the general per-key limit. Pipelines therefore publish once per run, not once per changed page, and they wait before retrying. When several individual pages have been published to staging, publishing from staging to production publishes all staged changes, not only the last page.
// publish-staging-then-production.ts
import { WebflowClient } from "webflow-api";
const webflow = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const siteId = process.env.WEBFLOW_SITE_ID ?? "REPLACE_WITH_SITE_ID";
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// 1. Staging first: the webflow.io subdomain only.
await webflow.sites.publish(siteId, { publishToWebflowSubdomain: true });
console.log("Queued staging publish");
// 2. Wait out the one-per-minute limit, then publish the custom domain.
await sleep(61_000);
const { customDomains = [] } = await webflow.sites.getCustomDomain(siteId);
const production = customDomains.find((d) => d.url === "www.fernbrook-gardens.example");
if (!production?.id) throw new Error("Custom domain not found on this site");
await webflow.sites.publish(siteId, { customDomains: [production.id] });
console.log("Queued production publish");
Source: Data API: Publish Site
# publish.py -- pip install httpx==0.28.1
import os
import httpx
BASE = "https://api.webflow.com/v2"
HEADERS = {"Authorization": f"Bearer {os.environ['WEBFLOW_API_TOKEN']}"}
SITE_ID = os.environ.get("WEBFLOW_SITE_ID", "REPLACE_WITH_SITE_ID")
# Single-page publish: the pageId property is available in the REST API.
response = httpx.post(
f"{BASE}/sites/{SITE_ID}/publish",
headers=HEADERS,
json={"publishToWebflowSubdomain": True, "pageId": "REPLACE_WITH_PAGE_ID"},
timeout=30,
)
response.raise_for_status()
print(response.status_code, response.json().get("publishScope"))
Source: Data API: Publish Site
What a publish does and does not do
A site publish sends the pages, styles, scripts and settings, and it publishes CMS items according to their state. How items, drafts and scheduled items behave is explained in Publishing and Scheduling; publishing individual items through the API (and unpublishing them) is in Data API: CMS. Three points are specific to the endpoint on this page.
-
A 202 is not a completion signal. Poll
lastPublishedon Get Site, or subscribe to thesite_publishwebhook described in Webhooks, instead of sleeping for a guessed time. -
Single-page publishing is a staging operation for review. Publishing one page to the staging subdomain lets a reviewer see it without a full release, but it does not replace the full production publish.
-
Forms may require a republish. The error code
forms_require_republishexists in the API’s shared error list; a form that was changed through the API is live only after the site is published again.
Pages
Listing pages
GET /v2/sites/{site_id}/pages (scope pages:read) lists the pages of a site: static pages, folders' pages, utility
pages and Collection template pages. Query parameters:
-
limit(maximum 100) andoffsetfor pagination; -
localeId, to return the localized version of each page; -
filter[title][contains], a case-insensitive literal substring match on the page title with a value of 1 to 256 characters. Regular expressions and wildcards are not supported. WhenlocaleIdis set, the match uses the locale’s title and falls back to the default title when the locale has none.
curl -s -G "https://api.webflow.com/v2/sites/$WEBFLOW_SITE_ID/pages" \
-H "Authorization: Bearer $WEBFLOW_API_TOKEN" \
--data-urlencode "filter[title][contains]=plants" \
-d limit=100 -d offset=0
Source: Data API: List Pages
Page metadata
GET /v2/pages/{page_id} returns the metadata of one page, optionally for a locale (localeId). The fields:
| Field | Meaning |
|---|---|
|
The page and its site. |
|
The page name and URL segment. The slug is derived from the title when the page is created. |
|
The folder that contains the page. |
|
Set when the page is a Collection template page, otherwise |
|
Timestamps. |
|
Whether the page is archived or still a draft (a draft page is not published). |
|
Page-branching state. A page that is already a branch cannot be branched again. Branching itself is described in Page Branching, Releases and Approvals. |
|
Object with |
|
Object with |
|
The locale of this version of the page, |
|
The relative path at which the page is published. |
PUT /v2/pages/{page_id} (scope pages:write) updates the page settings: title, slug, seo and openGraph. The
optional localeId query parameter makes the change in that locale only. Two limits are stated in the reference:
-
The
slugis ignored for the home page, for Collection template pages and for utility pages (404, password, search). The other fields in the same request still apply. -
For secondary locales, changing the slug requires the Advanced or Enterprise localization add-on.
The *Copied flags default to true in the request schema. A bulk SEO job that sets seo.title and leaves the flags
alone therefore also changes the Open Graph title; send titleCopied: false together with an explicit
openGraph.title to keep them apart.
// seo-audit.ts -- report pages with a missing or long SEO description, then fix the Spanish title
import { WebflowClient } from "webflow-api";
const webflow = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const siteId = process.env.WEBFLOW_SITE_ID ?? "REPLACE_WITH_SITE_ID";
let offset = 0;
const limit = 100;
for (;;) {
const { pages = [], pagination } = await webflow.pages.list(siteId, { limit, offset });
for (const page of pages) {
const description = page.seo?.description ?? "";
if (page.collectionId || page.draft || page.archived) continue;
if (description.length === 0 || description.length > 160) {
console.log(`${page.publishedPath ?? page.slug}: description length ${description.length}`);
}
}
offset += limit;
if (!pagination || offset >= (pagination.total ?? 0)) break;
}
// Update the Spanish version of one page. The locale ID comes from site.locales.secondary.
const spanishLocaleId = process.env.WEBFLOW_LOCALE_ES ?? "REPLACE_WITH_SPANISH_LOCALE_ID";
await webflow.pages.updatePageSettings("REPLACE_WITH_PAGE_ID", {
localeId: spanishLocaleId,
seo: {
title: "Catalogo de plantas | Fernbrook Gardens",
description: "Mas de 400 plantas del jardin botanico, con luz, riego y epoca de floracion.",
},
});
Source: Data API: Update Page Settings
Reading page content
GET /v2/pages/{page_id}/dom (scope pages:read) returns the static content of a page as a list of nodes. It is
not the page’s HTML: the endpoint returns only the text, images, form controls and component instances that can be
localized, with the structure around them left out. Collection template pages, which are dynamic, return an empty
response; their text comes from the CMS items (see Data API:
Ecommerce and Localization).
Parameters are localeId (the primary locale when omitted), limit (maximum 100) and offset, and the response has
the pageId, the branchId (or null), the nodes, a pagination object and lastUpdated. A further parameter,
translatable, takes the ID of the secondary locale you are translating into and returns only the content that has
not been excluded from translation for that locale. It is independent of localeId: to fetch the source text, ask for
the primary locale and set translatable to the target locale, as in ?localeId=PRIMARY_ID&translatable=TARGET_ID.
Only exclusion rules scoped to manual translation are respected, so content that the dashboard marks "do not
translate" does not reach the translation service.
Each node has an id (a UUID), a type and an attributes map holding custom attributes such as aria-label or
data-w-id. The node types are:
type |
Content |
|---|---|
|
Headings, paragraphs, text blocks, rich text and form labels, as an object with |
|
An |
|
A text input or textarea, with its |
|
A select field with its |
|
The button |
|
The label of a site-search button. |
|
A component instance, with its |
A trimmed response for the Fernbrook home page:
{
"pageId": "REPLACE_WITH_PAGE_ID",
"nodes": [
{
"id": "a245c12d-995b-55ee-5ec7-aa36a6cad623",
"type": "text",
"text": { "html": "<h1>Welcome to Fernbrook Gardens</h1>", "text": "Welcome to Fernbrook Gardens" },
"attributes": {}
},
{
"id": "a245c12d-995b-55ee-5ec7-aa36a6cad627",
"type": "text",
"text": {
"html": "<div><h3>Opening hours</h3><p>Open <span data-w-id=\"b3107c2e\">every day</span> from 9:00.</p></div>",
"text": null
},
"attributes": {}
},
{
"id": "a245c12d-995b-55ee-5ec7-aa36a6cad629",
"type": "image",
"image": { "alt": "Glasshouse in spring", "assetId": "REPLACE_WITH_ASSET_ID" },
"attributes": {}
}
],
"pagination": { "limit": 100, "offset": 0, "total": 3 },
"lastUpdated": "2026-10-20T09:15:00.000Z"
}
Source: Data API: Localizing pages
Writing page content in a secondary locale
POST /v2/pages/{page_id}/dom (scope pages:write) writes translated content. Its rules are strict and they are the
source of most failures.
-
Secondary locales only. The required
localeIdquery parameter must be a secondary locale of the site; the primary locale cannot be written through this endpoint and any other ID fails the request. Primary-locale text is edited in the Designer or the Editor. -
Up to 1,000 nodes per request. A longer page is written in several requests.
-
Same tags back. For a
textnode the body isnodeIdandtext, wheretextis the HTML of the node including its outer tag, and the tags must be the same as those returned by the read call. Translating<h1>text and sending it back as<h2>is rejected. -
Keep
data-w-id. Inner elements may carrydata-w-idattributes. Webflow uses them to keep custom attributes and links in sync across locales, so the original values must be sent back unchanged inside the translated HTML. -
Component instances. For a
component-instancenode the body isnodeIdandpropertyOverrides, a list ofpropertyIdandtext. The value must be compatible with the property type: a multi-line value cannot be written to a single-line plain-text property. -
Other node types. A
selectnode is updated with itschoices, atext-inputwith aplaceholder, asubmit-buttonwithvalueandwaitingText, asearch-buttonwithvalue. -
Images are read-only. The localization guide states that updating images is not supported by the API; the image asset is changed in the Designer.
The response is {"errors": []} with a list of error messages, empty when every node was accepted. A request can
therefore return 200 and still carry per-node errors, so always inspect the array.
// translate-page.ts -- read the primary locale, translate, write the German version
import { WebflowClient } from "webflow-api";
const webflow = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const pageId = "REPLACE_WITH_PAGE_ID";
const primaryLocaleId = process.env.WEBFLOW_LOCALE_EN ?? "REPLACE_WITH_PRIMARY_LOCALE_ID";
const germanLocaleId = process.env.WEBFLOW_LOCALE_DE ?? "REPLACE_WITH_GERMAN_LOCALE_ID";
// Placeholder translator: a real pipeline calls a translation service and must preserve the HTML tags.
async function translate(html: string, target: "de" | "es"): Promise<string> {
return html; // replace with a call to your translation provider
}
const dom = await webflow.pages.getContent(pageId, {
localeId: primaryLocaleId,
limit: 100,
offset: 0,
});
const nodes = [];
for (const node of dom.nodes ?? []) {
if (node.type === "text" && node.text?.html) {
nodes.push({ nodeId: node.id, text: await translate(node.text.html, "de") });
}
}
const result = await webflow.pages.updateStaticContent(pageId, {
localeId: germanLocaleId,
nodes,
});
if (result.errors?.length) {
console.error("Some nodes were rejected:", result.errors);
}
Source: Data API: Update Static Content
# translate_page.py -- same flow with httpx
import os
import httpx
BASE = "https://api.webflow.com/v2"
HEADERS = {"Authorization": f"Bearer {os.environ['WEBFLOW_API_TOKEN']}"}
PAGE_ID = "REPLACE_WITH_PAGE_ID"
PRIMARY = os.environ.get("WEBFLOW_LOCALE_EN", "REPLACE_WITH_PRIMARY_LOCALE_ID")
GERMAN = os.environ.get("WEBFLOW_LOCALE_DE", "REPLACE_WITH_GERMAN_LOCALE_ID")
def translate(html: str, target: str) -> str:
return html # replace with a call to your translation provider
with httpx.Client(headers=HEADERS, timeout=30) as client:
dom = client.get(
f"{BASE}/pages/{PAGE_ID}/dom",
params={"localeId": PRIMARY, "translatable": GERMAN, "limit": 100},
)
dom.raise_for_status()
nodes = [
{"nodeId": n["id"], "text": translate(n["text"]["html"], "de")}
for n in dom.json()["nodes"]
if n["type"] == "text" and n["text"].get("html")
]
result = client.post(
f"{BASE}/pages/{PAGE_ID}/dom",
params={"localeId": GERMAN},
json={"nodes": nodes},
)
result.raise_for_status()
print(result.json()["errors"])
Source: Data API: Localizing pages
Source: Data API: Localizing pages
Components
A component is a reusable block with its own structure, props and variants (see
Components, Props, Slots and Variants). The Data API
distinguishes the definition from its instances. It reads and localizes the definition’s static content and its
default property values; instances placed on a page are handled through the page’s component-instance nodes.
| Endpoint | Scope | Purpose |
|---|---|---|
|
|
List the components of a site ( |
|
|
Static content of a definition: text, image, select, text-input and submit-button nodes and nested instances. |
|
|
Write translated content into a secondary locale (up to 1,000 nodes). |
|
|
The default values of the properties, localizable ones for the requested locale. |
|
|
Update default property values in a secondary locale. |
The read calls accept localeId (the primary locale when omitted), branchId to scope the operation to a branch,
limit, offset and translatable, with the same meaning as for pages. The write calls follow the same rules as page
writes:
-
the
localeIdmust be a secondary locale; for the properties endpoint a missinglocaleIdis an error and nothing is processed; -
original
data-w-idvalues inside rich-text properties are sent back unchanged; -
property values must match the property type; and
-
the answer is
{"errors": []}.
Fernbrook Gardens has a Footer component with a plain-text property Tagline and a rich-text property Opening
hours. Localizing the footer once localizes it on every page that uses it, which is why a component is the first thing
to translate on a new locale.
// translate-footer.ts
import { WebflowClient } from "webflow-api";
const webflow = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const siteId = process.env.WEBFLOW_SITE_ID ?? "REPLACE_WITH_SITE_ID";
const germanLocaleId = process.env.WEBFLOW_LOCALE_DE ?? "REPLACE_WITH_GERMAN_LOCALE_ID";
const { components = [] } = await webflow.components.list(siteId, { limit: 100 });
const footer = components.find((c) => c.name === "Footer");
if (!footer?.id) throw new Error("Footer component not found");
// 1. Which properties can be localized, and what do they hold in the primary locale?
const { properties = [] } = await webflow.components.getProperties(siteId, footer.id, {
limit: 100,
});
for (const p of properties) console.log(p.propertyId, p.type, p.label);
// 2. Write the German values. IDs come from the listing above.
const tagline = properties.find((p) => p.label === "Tagline");
if (tagline?.propertyId) {
const result = await webflow.components.updateProperties(siteId, footer.id, {
localeId: germanLocaleId,
properties: [{ propertyId: tagline.propertyId, text: "Ein Garten fuer alle Jahreszeiten" }],
});
if (result.errors?.length) console.error(result.errors);
}
# Read the footer definition's static content for the primary locale, ready for translation to German.
curl -s -G "https://api.webflow.com/v2/sites/$WEBFLOW_SITE_ID/components/REPLACE_WITH_COMPONENT_ID/dom" \
-H "Authorization: Bearer $WEBFLOW_API_TOKEN" \
-d localeId=REPLACE_WITH_PRIMARY_LOCALE_ID \
-d translatable=REPLACE_WITH_GERMAN_LOCALE_ID \
-d limit=100
Source: Data API: Get Component Content
Comments
Reviewers leave comment threads on the canvas of the Designer or the Editor; the collaboration features are described in Collaboration, Comments and Activity Log. The comments API lets a program read those threads, reply to them and mark them resolved, which is the basis of integrations that mirror comments into a tracker.
| Endpoint | Scope | Purpose |
|---|---|---|
|
|
List the threads of a site. |
|
|
Get one thread. |
|
|
List the replies of a thread. |
|
|
Create a reply. Answers 201. |
|
|
Resolve ( |
The list calls take localeId, limit (maximum 100), offset, and sortBy with sortOrder (sortBy is only
allowed when sortOrder is given). A thread carries the siteId, pageId, localeId, itemId (for a comment on a
CMS item), the breakpoint where it was left, the url of the page, the content, isResolved, the author, the
mentionedUsers and the two timestamps. Four behaviours are worth knowing:
-
Delay. New comments can take up to five minutes to appear in the API, so a webhook-less integration should poll with that in mind.
-
Mentions.
mentionedUsersis an empty array until the e-mail notifications have been sent, which can take up to five minutes after the comment is created. To mention a user in a reply, put the user’s ID inside double square brackets in thecontentstring. -
Author. The author of a reply is always the user who authorized the OAuth token, so a reply cannot be posted as a bot account with a site token; use an OAuth app with a dedicated user for integrations.
-
Rate limit. Resolving a thread is limited to 60 requests per minute per site. Creating a reply fires the
comment_createdwebhook automatically.
# Resolve a thread once its fix has been published
curl -s -X PATCH \
"https://api.webflow.com/v2/sites/$WEBFLOW_SITE_ID/comments/REPLACE_WITH_THREAD_ID" \
-H "Authorization: Bearer $WEBFLOW_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"resolved": true}'
Source: Data API: Resolve Comment Thread
// unresolved-comments.ts -- list threads on the Fernbrook site that are still open
import { WebflowClient } from "webflow-api";
const webflow = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const siteId = process.env.WEBFLOW_SITE_ID ?? "REPLACE_WITH_SITE_ID";
const { comments = [] } = await webflow.sites.comments.listCommentThreads(siteId, {
limit: 100,
sortBy: "createdOn",
sortOrder: "desc",
});
for (const thread of comments.filter((t) => !t.isResolved)) {
console.log(thread.url, thread.breakpoint, thread.content);
}
Source: Data API: List Comment Threads
Enterprise site configuration
Three groups of endpoints change how a site answers requests for specific paths. Each states in the reference that it requires an Enterprise workspace. They are the API counterpart of the dashboard settings described in Redirects and Site Migration and SEO and AEO.
301 redirects
| Endpoint | Scope | Purpose |
|---|---|---|
|
|
List all redirect rules. |
|
|
Create a rule from |
|
|
Change a rule. |
|
|
Remove a rule; the answer is the remaining list with pagination. |
A rule has an id, a fromUrl (the source path) and a toUrl (the destination path). During the Fernbrook migration
from an older WordPress site, the old /flora/ URLs are redirected to /plants/.
// redirects.ts -- Enterprise workspace required
import { WebflowClient } from "webflow-api";
const webflow = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const siteId = process.env.WEBFLOW_SITE_ID ?? "REPLACE_WITH_SITE_ID";
const existing = await webflow.sites.redirects.list(siteId);
const known = new Set((existing.redirects ?? []).map((r) => r.fromUrl));
const wanted = [
{ fromUrl: "/flora/roses", toUrl: "/plants/roses" },
{ fromUrl: "/flora/ferns", toUrl: "/plants/ferns" },
];
for (const rule of wanted) {
if (!known.has(rule.fromUrl)) {
await webflow.sites.redirects.create(siteId, rule);
}
}
Source: Data API: Create a 301 redirect
robots.txt
/v2/sites/{site_id}/robots_txt accepts GET (scope site_config:read) and PUT, PATCH and DELETE (scope
site_config:write). The body and the response have a rules array and a sitemap URL. Each rule has a required
userAgent and optional allows and disallows arrays of paths. PUT replaces the configuration, PATCH updates the
given user agents, and DELETE removes the rules of the given user agents; deleting a user agent with an empty rule set
removes its entry, which leaves the agent subject to the site’s default crawling behaviour and therefore makes access
unrestricted unless other directives apply.
curl -s -X PUT "https://api.webflow.com/v2/sites/$WEBFLOW_SITE_ID/robots_txt" \
-H "Authorization: Bearer $WEBFLOW_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"rules": [
{ "userAgent": "*", "allows": ["/"], "disallows": ["/staff-only/"] }
],
"sitemap": "https://www.fernbrook-gardens.example/sitemap.xml"
}'
Source: Data API: Replace robots.txt
Well-known files
PUT /v2/sites/{site_id}/well_known (scope site_config:write) uploads a file served under /.well-known/, and
DELETE /v2/sites/{site_id}/well_known removes files by a list of fileNames. The upload body has a required
fileName, a required fileData (the file contents as a string) and an optional contentType, which defaults to
application/json. The documented restrictions are:
-
each file is smaller than 100 KB;
-
fewer than 30 files in total;
-
the extension is
.txtor.json, or there is no extension. The special extension.noextstrips other extensions, soapple-app-site-association.noext.txtis published asapple-app-site-association; use it for tools that cannot upload extensionless files.
curl -s -X PUT "https://api.webflow.com/v2/sites/$WEBFLOW_SITE_ID/well_known" \
-H "Authorization: Bearer $WEBFLOW_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"fileName": "security.txt",
"fileData": "Contact: mailto:security@fernbrook-gardens.example\nExpires: 2027-12-31T23:59:59Z",
"contentType": "text/plain"
}'
Source: Data API: Set a well-known file
The API reference lists endpoints for redirects, robots.txt and well-known files, but no endpoint for an llms.txt
file. How an llms.txt file is provided on a Webflow site is discussed in SEO and
AEO.
Pitfalls and security
-
Scope errors look like authentication errors. A token without
pages:writegets a 401 with the codemissing_scopesrather than a 403. Compare the token’s scopes with the table at the top of this page before debugging the request. -
Do not loop publishes. Beyond the one-per-minute publish limit, publishing a site is a visible event for the team and for the
site_publishwebhook. Batch changes, then publish once. -
Never write the primary locale through the DOM endpoint. It is rejected; edit the primary locale in the Designer.
-
Preserve markup. Most failed translation jobs lose a
data-w-idattribute, change a tag, or send plain text where the node expects HTML. -
Check the
errorsarray. A 200 response from the content-write endpoints can still list rejected nodes. -
Treat site IDs as identifiers, not secrets, and tokens as secrets. A site token is bound to one site but can publish it; keep it in a secret store or an environment variable, never in client-side code. Prefer the narrowest scopes: a translation job needs
pages:read,pages:write,components:readandcomponents:write, but notsites:write. -
Destructive endpoints are Enterprise-only for a reason. Deleting a site, replacing
robots.txtor overwriting a redirect list affects live traffic. Run them first against a non-production site. -
Comment content is user input. Escape it before rendering it in a dashboard or tracker.