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.io staging 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.txt and 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

sites:read, sites:write

Any plan, Developer workspace included

Sites (workspace level)

Create, update, delete, get site plan

workspace:write, sites:write

Enterprise workspace

Pages

List pages, get and update metadata, get and update static content

pages:read, pages:write

Any plan

Components

List, get and update content, get and update properties

components:read, components:write

Any plan

Comments

List threads, get a thread, list and create replies, resolve

comments:read, comments:write

Any plan, OAuth app for writes

Site configuration

301 redirects, robots.txt, well-known files

sites:read, sites:write, site_config:read, site_config:write

Enterprise workspace

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

id

The 24-character site ID used in every other path. Treat it as an opaque string.

workspaceId

The workspace that owns the site. Needed for the Enterprise create-site endpoint.

displayName and shortName

The name shown in the dashboard and its slugified form. shortName is the part of the webflow.io host: a site named Fernbrook Gardens has a staging host starting with fernbrook-gardens.

createdOn, lastUpdated, lastPublished

ISO 8601 timestamps. lastPublished is the field to poll after a publish to learn that the build finished.

timeZone

The time zone from Site settings. It matters for scheduled publishing and for how analytics are bucketed.

previewUrl

The URL of a generated screenshot of the site.

parentFolderId

The workspace folder that holds the site, or null.

customDomains

An array of domain objects with an id and a url.

locales

The primary locale and the secondary locales, each with its id, cmsLocaleId, language tag and sub-directory or subdomain. The two IDs are not interchangeable; Data API: Ecommerce and Localization explains which endpoint wants which.

dataCollectionEnabled and dataCollectionType

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));
curl -s https://api.webflow.com/v2/sites \
  -H "Authorization: Bearer $WEBFLOW_API_TOKEN" \
  -H "accept: application/json"

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.

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}/sites creates a site. The body has a required name, an optional templateName (a workspace or Marketplace template) and an optional parentFolderId. It needs the scope workspace:write and answers 201 with the new site object.

  • An update endpoint (PATCH) and a delete endpoint (DELETE) on /v2/sites/{site_id} need sites:write and 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_id is found with the list-workspaces endpoint, with authorization.authorizedTo.workspaceIds from the token introspection call, or, with a site token, from the workspaceId of Get 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

customDomains

array of domain IDs

The custom domains to publish to, taken from Get Custom Domains.

publishToWebflowSubdomain

boolean, default false

Publish to the webflow.io staging domain.

pageId

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");
# 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"))

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 lastPublished on Get Site, or subscribe to the site_publish webhook 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_republish exists 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) and offset for 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. When localeId is 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

Page metadata

GET /v2/pages/{page_id} returns the metadata of one page, optionally for a locale (localeId). The fields:

Field Meaning

id, siteId

The page and its site.

title, slug

The page name and URL segment. The slug is derived from the title when the page is created.

parentId

The folder that contains the page.

collectionId

Set when the page is a Collection template page, otherwise null.

createdOn, lastUpdated

Timestamps.

archived and draft

Whether the page is archived or still a draft (a draft page is not published).

canBranch, isBranch, branchId

Page-branching state. A page that is already a branch cannot be branched again. Branching itself is described in Page Branching, Releases and Approvals.

seo

Object with title and description, the title and description shown in search results.

openGraph

Object with title, description and the two flags titleCopied and descriptionCopied, which say that the Open Graph value is copied from the SEO value.

localeId

The locale of this version of the page, null for a non-localized site.

publishedPath

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 slug is 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.",
  },
});

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

text

Headings, paragraphs, text blocks, rich text and form labels, as an object with html and text.

image

An image object with the alt text and the assetId.

text-input

A text input or textarea, with its placeholder.

select

A select field with its choices, each a value and a text.

submit-button

The button value and its waitingText.

search-button

The label of a site-search button.

component-instance

A component instance, with its componentId and propertyOverrides, each with propertyId, type, label and text.

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"
}

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 localeId query 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 text node the body is nodeId and text, where text is 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 carry data-w-id attributes. 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-instance node the body is nodeId and propertyOverrides, a list of propertyId and text. 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 select node is updated with its choices, a text-input with a placeholder, a submit-button with value and waitingText, a search-button with value.

  • 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);
}
# 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"])
sequenceDiagram participant S as Sync script participant W as Webflow Data API participant T as Translation service S->>W: GET /pages/{id}/dom?localeId=primary&translatable=de W-->>S: nodes (text, select, component-instance) S->>T: HTML with data-w-id attributes kept T-->>S: translated HTML S->>W: POST /pages/{id}/dom?localeId=de (up to 1000 nodes) W-->>S: 200 with errors array S->>W: PUT /pages/{id}?localeId=de (title, SEO, Open Graph) S->>W: POST /sites/{id}/publish W-->>S: 202 Accepted

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

GET /v2/sites/{site_id}/components

components:read

List the components of a site (limit up to 100, offset, optional branchId).

GET /v2/sites/{site_id}/components/{component_id}/dom

components:read

Static content of a definition: text, image, select, text-input and submit-button nodes and nested instances.

POST /v2/sites/{site_id}/components/{component_id}/dom

components:write

Write translated content into a secondary locale (up to 1,000 nodes).

GET /v2/sites/{site_id}/components/{component_id}/properties

components:read

The default values of the properties, localizable ones for the requested locale.

POST /v2/sites/{site_id}/components/{component_id}/properties

components:write

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 localeId must be a secondary locale; for the properties endpoint a missing localeId is an error and nothing is processed;

  • original data-w-id values 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

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

GET /v2/sites/{site_id}/comments

comments:read

List the threads of a site.

GET /v2/sites/{site_id}/comments/{comment_thread_id}

comments:read

Get one thread.

GET /v2/sites/{site_id}/comments/{comment_thread_id}/replies

comments:read

List the replies of a thread.

POST /v2/sites/{site_id}/comments/{comment_thread_id}/replies

comments:write

Create a reply. Answers 201.

PATCH /v2/sites/{site_id}/comments/{comment_thread_id}

comments:write

Resolve (resolved: true) or reopen (resolved: false) a thread.

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. mentionedUsers is 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 the content string.

  • 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_created webhook 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}'
// 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);
}

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

GET /v2/sites/{site_id}/redirects

sites:read

List all redirect rules.

POST /v2/sites/{site_id}/redirects

sites:write

Create a rule from fromUrl to toUrl.

PATCH /v2/sites/{site_id}/redirects/{redirect_id}

sites:write

Change a rule.

DELETE /v2/sites/{site_id}/redirects/{redirect_id}

sites:write

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);
  }
}

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"
  }'

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 .txt or .json, or there is no extension. The special extension .noext strips other extensions, so apple-app-site-association.noext.txt is published as apple-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"
  }'

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:write gets a 401 with the code missing_scopes rather 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_publish webhook. 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-id attribute, change a tag, or send plain text where the node expects HTML.

  • Check the errors array. 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:read and components:write, but not sites:write.

  • Destructive endpoints are Enterprise-only for a reason. Deleting a site, replacing robots.txt or 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.