Data API: SDKs, Limits and Errors

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 Webflow Data API as it was on 2026-10-28, the date on which its facts were checked against the official developer documentation, the Data API reference pages and the published packages. The Data API is available to every Webflow account that can generate an API token, including the free Developer workspace used for all examples on this page. The rate limit of a request stream depends on the Site plan of the site it touches, named here by tier only: Starter and Basic, CMS, Ecommerce and Business, and Enterprise. Plans are never named by price. The pinned developer tools used in the snippets are the JavaScript SDK webflow-api 3.3.4 (it declares Node.js 18 or newer as its engine) and the Python SDK webflow 2.0.0 (it declares Python 3.8 or newer, below 4.0). Both SDKs are generated from the same API definition and are marked by their maintainers as Preview, so breaking changes can arrive without a major version bump. The README of each recommends pinning an exact version, which every dependency snippet below does.

The running example is Fernbrook Gardens, a fictitious botanical garden with a Plants Collection, a Garden zones Collection, an Events Collection and a Journal Collection, published in English with Spanish and German as secondary locales. Its developer, Marta, writes the scripts that keep the Plants Collection in step with the garden’s inventory spreadsheet and the scripts that publish the Journal. Those scripts are small, but they run unattended at night, so they have to cope with every behaviour described on this page: rate limits, partial failures, pagination and error codes. Every example uses placeholder values such as WEBFLOW_API_TOKEN and REPLACE_WITH_SITE_ID; real tokens and identifiers come from environment variables and are never committed. How tokens are created and which scopes they carry is covered in Data API: Authentication and Scopes; the map of every developer tool is in Developer Platform Overview.

The shape of the Data API

The Data API is a RESTful HTTP API with JSON request and response bodies. It lets a program read and change the resources of a Webflow site (sites, pages, components, Collections and their items, assets, forms, scripts, webhooks, Ecommerce products and orders, localization settings) and, for Enterprise customers, a few resources of the Workspace. The reference describes a resource model: a Workspace contains sites, a site contains pages, Collections, assets, forms and registered scripts, and a Collection contains fields and items. Every operation names the scope it needs (for example CMS:read for listing items), so a token carrying only the scopes a script needs can do only that.

Four ideas from the introduction of the reference shape everything below.

  • Authentication is a bearer token in the Authorization header: a Site token, a Workspace token or an OAuth access token from a Data Client App.

  • Rate limiting is per API key and per minute, with headers that report the remaining budget.

  • Versioning is explicit in the URL, with a /v2/ production namespace and a /beta/ namespace.

  • Error handling uses standard HTTP status codes and a small JSON error body.

The rest of this page works through those four ideas and the two official SDKs that wrap them.

Base URLs and the two namespaces

The Data API v2 is served from a single host. The versioning page of the reference states that v2 versioning is explicit and part of the endpoint path, and that API v2 is composed of two namespaces:

Namespace Description in the reference What it means in practice

/v2/

A subset of the v2 APIs that are considered production.

Stable endpoints with documented behaviour. Everything the SDKs call lives here. Build production integrations on it.

/beta/

A monolithic version that houses all new APIs.

New endpoints appear here first and may change shape. Call them with plain HTTP.

Tokens are compatible across both namespaces, so the same Site token or OAuth token that works against https://api.webflow.com/v2/sites also works against https://api.webflow.com/beta/... endpoints.

Base URL Used for Notes

https://api.webflow.com/v2

Every production Data API endpoint.

The default of both SDKs, as the dataApi and base values of their WebflowEnvironment.

https://api.webflow.com/beta

Endpoints in the beta namespace.

Not supported by the SDKs. The reference says the Webflow SDK does not support beta endpoints and recommends an HTTP client or an API tool.

https://api-cdn.webflow.com/v2

The Content Delivery API, which serves published CMS content from a cache.

Exposed by the SDKs as contentDeliveryApi. Covered in Content Delivery API and Headless.

The reference also carries three documentation sets: the default v2, a v2 Beta set and the legacy v1 set. Each page of the developer documentation begins with a note saying which set it belongs to, which matters when a search engine lands on a v1 page for a v2 question. Appending .md to any developer-documentation page returns clean Markdown, and the site publishes llms.txt indexes (https://developers.webflow.com/llms.txt for the whole site and https://developers.webflow.com/data/llms.txt for the Data API). Those two features are what AI coding tools read, as described in MCP Server and AI Agents.

Your first request

Everything in the SDKs reduces to an HTTP request. The cheapest request that proves a token works is GET /v2/token/authorized_by, the same call the rate-limits page uses as its example. Add -i to see the response headers, which is how the rate-limit headers described later are first noticed.

export WEBFLOW_API_TOKEN="REPLACE_WITH_TOKEN"

curl -i --request GET \
  --url https://api.webflow.com/v2/token/authorized_by \
  --header "accept: application/json" \
  --header "authorization: Bearer $WEBFLOW_API_TOKEN"

A Site token is bound to one site and a Workspace token to one Workspace. The token introspection endpoint GET /v2/token/introspect returns the token’s authorization, including its scopes and which sites it can reach; for Site and Workspace tokens (grant type client_credentials) the response omits the application object, which is present only for tokens issued to an OAuth app.

curl --request GET \
  --url https://api.webflow.com/v2/token/introspect \
  --header "authorization: Bearer $WEBFLOW_API_TOKEN"

Installing and configuring the JavaScript SDK

The JavaScript and TypeScript SDK is the npm package webflow-api. It ships typings and request builders for the whole Data API and requires an accessToken. Pin the exact version, because the package is a preview release.

{
  "name": "fernbrook-sync",
  "private": true,
  "type": "module",
  "dependencies": {
    "webflow-api": "3.3.4"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "@types/node": "22.10.0"
  }
}

The client is WebflowClient. Its options, from the package typings, are:

Option Default Meaning

accessToken

none (required)

A string, or a function returning a string, used as the bearer token. A function is how a long-running service picks up a rotated token without rebuilding the client.

baseUrl

the dataApi URL of the environment

Overrides the host. Useful for a recording proxy in tests or a mock server in CI.

environment

WebflowEnvironment.DataApi

Holds the three URLs (base, dataApi, contentDeliveryApi).

timeoutInSeconds

60

How long to wait for a response. The README documents 60 seconds as the default.

maxRetries

2

How many times a failed request is retried before the error is thrown.

headers

none

Extra headers sent on every request, for example a correlation identifier.

Each of timeoutInSeconds, maxRetries, abortSignal, queryParams and headers can also be passed per call as the last argument, which overrides the client default for that one request.

import { WebflowClient } from "webflow-api";

const token = process.env.WEBFLOW_API_TOKEN;
if (!token) {
  throw new Error("Set WEBFLOW_API_TOKEN before running this script");
}

export const webflow = new WebflowClient({
  accessToken: token,
  timeoutInSeconds: 30,
  maxRetries: 4,
  headers: { "x-fernbrook-job": "nightly-plant-sync" },
});

const siteId = process.env.WEBFLOW_SITE_ID ?? "REPLACE_WITH_SITE_ID";

const { sites } = await webflow.sites.list();
console.log(sites?.map((site) => `${site.displayName} (${site.id})`));

// Per-call override: this one slow endpoint gets a longer timeout.
const collections = await webflow.collections.list(siteId, { timeoutInSeconds: 90 });
console.log(collections.collections?.map((collection) => collection.displayName));

The resource names mirror the API structure: webflow.sites, webflow.pages, webflow.components, webflow.collections (with the nested webflow.collections.items and webflow.collections.fields), webflow.assets, webflow.forms, webflow.scripts, webflow.webhooks, webflow.products, webflow.orders, webflow.inventory, webflow.ecommerce, webflow.workspaces and webflow.token. The types live under the exported Webflow namespace (Webflow.CollectionItem, Webflow.BadRequestError and so on), so an editor’s completion list is the quickest reference.

How the SDK retries

The SDK source shows the retry policy precisely, and knowing it explains most "why did my script wait a minute" reports.

  • A request is retried when the status is 408, 429 or any 5xx, up to maxRetries times (default 2).

  • The wait before a retry comes from the Retry-After header when it is a positive number of seconds (or an HTTP date), capped at 60 seconds. Otherwise the SDK uses the X-RateLimit-Reset header when present, and otherwise exponential backoff starting at 1 second (1 s, 2 s, 4 s and so on) with a little random jitter, again capped at 60 seconds.

  • A 429 from Webflow carries Retry-After, normally 60 seconds, so the first retry of a rate-limited call happens about a minute later and a script that was rate limited stalls for that long without any code of its own.

Because the retry happens inside the call, a script that makes many calls in parallel can still exhaust its budget: the SDK retries each failed call separately and does not know about the other calls. A shared limiter, shown later in this page, is the fix.

The Python SDK follows the same policy. Its _should_retry helper retries 429, 408, 409 and every status of 500 or more, with a default of 2 retries and the same 1-second base and 60-second cap; it additionally honours a retry-after-ms header when one is sent.

Typed errors in the JavaScript SDK

Every failure the SDK throws is a subclass of WebflowError, which carries statusCode, body and rawResponse. A request that exceeds its timeout throws WebflowTimeoutError. Responses with a documented error status are thrown as specific classes in the Webflow namespace: BadRequestError (400), UnauthorizedError (401), ForbiddenError (403), NotFoundError (404), ConflictError (409), TooManyRequestsError (429) and InternalServerError (500). The body holds the parsed error object described in the error-format section.

import { Webflow, WebflowError, WebflowTimeoutError } from "webflow-api";
import { webflow } from "./client.js";

export async function getPlantBySlug(collectionId: string, slug: string) {
  try {
    const result = await webflow.collections.items.listItems(collectionId, { slug, limit: 1 });
    return result.items?.[0] ?? null;
  } catch (error) {
    if (error instanceof Webflow.NotFoundError) {
      // Collection id wrong, or the token cannot see this site.
      console.error("Not found:", (error.body as Webflow.Error_).message);
      return null;
    }
    if (error instanceof Webflow.TooManyRequestsError) {
      // Raised only after the SDK exhausted its own retries.
      console.error("Rate limit still exceeded after retries");
      throw error;
    }
    if (error instanceof Webflow.ForbiddenError || error instanceof Webflow.UnauthorizedError) {
      console.error("Token problem. Check scopes with GET /v2/token/introspect");
      throw error;
    }
    if (error instanceof WebflowTimeoutError) {
      console.error("Webflow did not answer in time");
      throw error;
    }
    if (error instanceof WebflowError) {
      console.error(`HTTP ${error.statusCode}`, error.body);
    }
    throw error;
  }
}

One caution from the SDK README applies here: its exception example imports from a package named webflow, but the package is webflow-api. Copying that import line verbatim fails with a module-not-found error.

Reading response headers

The SDK methods return a promise-like object that also offers withRawResponse(), which resolves to both the parsed data and the raw response, including headers. That is how a script reads X-RateLimit-Remaining without leaving the SDK.

import { webflow } from "./client.js";

const { data, rawResponse } = await webflow.sites
  .list()
  .withRawResponse();

const remaining = rawResponse.headers.get("x-ratelimit-remaining");
const limit = rawResponse.headers.get("x-ratelimit-limit");
console.log(`${data.sites?.length ?? 0} sites, ${remaining}/${limit} requests left this minute`);

Installing and configuring the Python SDK

The Python SDK is the PyPI package webflow. It offers a synchronous client built on httpx and an asynchronous client, with type definitions (Pydantic models) for the request and response fields. Pin it in requirements.txt.

webflow==2.0.0

Source: PyPI: webflow

The import is from webflow.client import Webflow for the synchronous client and from webflow.client import AsyncWebflow for the asynchronous one. Package-level names such as webflow.TooManyRequestsError and the WebflowEnvironment object are exported from the package root. The constructor takes access_token (a string or a callable returning one), timeout in seconds (60 by default unless a custom httpx client is supplied), headers, follow_redirects, httpx_client for proxies and custom transports, and logging. The number of retries is set per call through request_options.

import os

from webflow.client import Webflow

token = os.environ["WEBFLOW_API_TOKEN"]
site_id = os.environ.get("WEBFLOW_SITE_ID", "REPLACE_WITH_SITE_ID")

client = Webflow(
    access_token=token,
    timeout=30.0,
    headers={"x-fernbrook-job": "nightly-plant-sync"},
)

sites = client.sites.list()
for site in sites.sites or []:
    print(site.display_name, site.id)

# Per-call override of the retry count.
collections = client.collections.list(site_id, request_options={"max_retries": 4})
for collection in collections.collections or []:
    print(collection.display_name, collection.id)

The asynchronous client is the right choice inside a web framework or any program that already runs an event loop. It has the same resource tree, with every method awaited.

import asyncio
import os

from webflow.client import AsyncWebflow

client = AsyncWebflow(access_token=os.environ["WEBFLOW_API_TOKEN"])


async def main() -> None:
    site = await client.sites.get(os.environ.get("WEBFLOW_SITE_ID", "REPLACE_WITH_SITE_ID"))
    print("Received site", site.display_name)


asyncio.run(main())

Typed errors in the Python SDK

All errors are subclasses of webflow.core.ApiError, which exposes status_code, body and headers. The same specific classes exist as in JavaScript: BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, TooManyRequestsError and InternalServerError. Because ApiError keeps the response headers, a Python script can read Retry-After straight from a caught 429.

import time

import webflow
from webflow.client import Webflow
from webflow.core import ApiError


def get_plant(client: Webflow, collection_id: str, slug: str):
    try:
        result = client.collections.items.list_items(collection_id, slug=slug, limit=1)
        return result.items[0] if result.items else None
    except webflow.NotFoundError as error:
        print("Not found:", error.body)
        return None
    except webflow.TooManyRequestsError as error:
        wait = int((error.headers or {}).get("retry-after", "60"))
        print(f"Still rate limited after SDK retries, sleeping {wait}s")
        time.sleep(wait)
        raise
    except ApiError as error:
        print("HTTP", error.status_code, error.body)
        raise

Raw HTTP and the OpenAPI specification

An SDK is optional. Any language with an HTTP client can call the Data API, and any beta endpoint must be called that way. The rules of a raw call are short.

  • Send Authorization: Bearer <token> and Accept: application/json.

  • Send Content-Type: application/json on requests with a body.

  • Parse the error body on every non-2xx response and honour Retry-After on 429.

  • Encode query parameters carefully. The bracket syntax of CMS filters (filter[price][gte]=10) must be URL encoded by the client library or sent exactly as the reference shows it.

The next request lists published Plants items in Spanish, 50 at a time, newest first, using only documented query parameters of the list-items endpoint (cmsLocaleId, limit, offset, sortBy and sortOrder).

GET /v2/collections/REPLACE_WITH_COLLECTION_ID/items/live?cmsLocaleId=REPLACE_WITH_SPANISH_CMS_LOCALE_ID&limit=50&offset=0&sortBy=lastPublished&sortOrder=desc HTTP/1.1
Host: api.webflow.com
Authorization: Bearer REPLACE_WITH_TOKEN
Accept: application/json

A beta call differs only in the first path segment. The versioning page uses /beta/token/authorized_by as its example.

curl --request GET \
  --url https://api.webflow.com/beta/token/authorized_by \
  --header "accept: application/json" \
  --header "authorization: Bearer $WEBFLOW_API_TOKEN"

The OpenAPI specification

The developer documentation publishes the raw OpenAPI 3.1 specification of the Data API as JSON at https://developers.webflow.com/openapi.json and as YAML at https://developers.webflow.com/openapi.yaml. The specification is the source for the reference pages, so it is the right input for tooling: generating a typed client in a language without an official SDK, producing mock servers for tests, importing the API into Postman or Insomnia, or feeding an AI tool an exact description of every endpoint.

# Generate TypeScript types for every request and response (any OpenAPI 3.1 tool works the same way)
npx openapi-typescript https://developers.webflow.com/openapi.yaml -o src/webflow-openapi.d.ts

A generated client built from the specification inherits none of the SDKs' behaviour. It needs its own retry and rate-limit handling, exactly as described below.

Pagination

List endpoints return at most one page of results. Pagination in the Data API is offset based: the request carries limit and offset, and the response carries a pagination object. For the CMS list-items endpoint the reference documents:

Parameter Type Meaning

limit

integer

Maximum number of records to return. The maximum is 100, which is also the default in the response object.

offset

integer

Number of records to skip. Default 0.

and the response pagination object holds limit, offset and total (the total number of items in the Collection). Other list endpoints follow the same pattern with their own maximums, so check the maximum on the endpoint page before assuming 100.

The loop is therefore: request a page, append its items, add the page size to the offset, and stop when the offset reaches total or a page comes back short. Using total avoids one extra empty request. Offsets shift if items are created or deleted while the loop runs, so a long sync should either read in one pass and compare against a snapshot, or sort by a stable key such as createdOn so that new items land at the end.

import type { Webflow } from "webflow-api";
import { webflow } from "./client.js";

const PAGE_SIZE = 100;

export async function listAllItems(collectionId: string): Promise<Webflow.CollectionItem[]> {
  const all: Webflow.CollectionItem[] = [];
  let offset = 0;
  let total = Number.POSITIVE_INFINITY;

  while (offset < total) {
    const page = await webflow.collections.items.listItems(collectionId, {
      limit: PAGE_SIZE,
      offset,
      sortBy: "createdOn",
      sortOrder: "asc",
    });
    const items = page.items ?? [];
    all.push(...items);
    total = page.pagination?.total ?? all.length;
    if (items.length < PAGE_SIZE) {
      break;
    }
    offset += PAGE_SIZE;
  }
  return all;
}
from webflow.client import Webflow

PAGE_SIZE = 100


def list_all_items(client: Webflow, collection_id: str) -> list:
    items: list = []
    offset = 0
    total = None
    while total is None or offset < total:
        page = client.collections.items.list_items(
            collection_id, limit=PAGE_SIZE, offset=offset
        )
        batch = page.items or []
        items.extend(batch)
        total = page.pagination.total if page.pagination and page.pagination.total is not None else len(items)
        if len(batch) < PAGE_SIZE:
            break
        offset += PAGE_SIZE
    return items

A page of 100 items costs one request against the rate limit, however large the items are, so always ask for 100. Reading a 1,000-item Collection 20 items at a time would cost 50 requests instead of 10. The same applies to writes: the bulk item endpoints accept up to 100 items per call and are described in Data API: CMS.

The list-items endpoint also accepts up to 10 filter terms and 2 text-search terms per request, and up to 3 sort fields (sort[<fieldSlug>]), as stated in its reference. Filtering on the server is cheaper than paging through a whole Collection and discarding most of it.

Rate limits

Webflow enforces rate limits so that no integration degrades the service for others. The limit is a count of requests per minute and it depends on the Site plan:

Site plan tier Requests per minute

Starter and Basic

60

CMS, Ecommerce and Business

120

Enterprise

Custom

Four properties of the limit matter for design.

  • It is per API key. Each API key is tracked independently, whatever other keys are in use. Two scripts sharing one Site token share one budget; two scripts with two tokens have two.

  • Some endpoints have their own limits. Publishing a site is limited to one successful publish per minute, and any endpoint-specific limit is documented on the endpoint’s own page. A script that publishes after every item change will be refused long before it reaches 60 requests.

  • Cached delivery is nearly free. Requests to the Content Delivery API that are served from cache are effectively not rate limited; only uncached requests to the origin count against the plan limit. That is the main reason to read public content from the Content Delivery API in a headless front end (Content Delivery API and Headless).

  • A higher limit is a commercial matter. The documentation points integrations that need more to the Enterprise options.

Rate-limit headers

Every API response carries three headers, according to the rate-limits page:

Header Description

X-RateLimit-Limit

The overall rate limit per minute for the key.

X-RateLimit-Remaining

The number of requests still available in the current minute.

Retry-After

How long to wait before sending new requests; on a 429 response the reset time is typically 60 seconds.

The documentation’s example response shows X-RateLimit-Limit: 60, X-RateLimit-Remaining: 59 and Retry-After: 60 on a first request of the window. Always read these headers from the response rather than hard coding 60 or 120: an Enterprise customer has a custom number, and a plan change alters it.

What a 429 looks like

When the limit is exceeded the API answers 429 Too Many Requests with the standard error body.

{
  "message": "Too Many Requests",
  "code": "too_many_requests",
  "externalReference": null,
  "details": []
}

The documentation names two typical causes: aggressive polling while waiting for a resource to appear, and a large number of highly concurrent calls. Both are design problems rather than transient ones, and retrying alone does not cure them.

flowchart TD A["Send request"] --> B{"Status?"} B -->|"2xx"| C["Read X-RateLimit-Remaining"] C --> D{"Remaining below threshold?"} D -->|"yes"| E["Slow down: wait for the window"] D -->|"no"| F["Continue"] B -->|"429"| G["Read Retry-After (about 60 s)"] G --> H["Wait, then retry the same request"] H --> I{"Attempts left?"} I -->|"yes"| A I -->|"no"| J["Fail the job and alert"] B -->|"5xx"| K["Exponential backoff with jitter"] K --> I B -->|"4xx other"| L["Do not retry: fix the request"]

Staying under the limit

Four techniques, in order of effect, keep Fernbrook’s nightly sync inside its budget.

  1. Do less per run. Filter on the server, ask for 100 items per page, use the bulk endpoints for writes and skip items whose data did not change. A sync that compares a content hash before writing issues no write at all for an unchanged plant.

  2. Do not poll. Waiting for an asset upload to finish, for a publish to reach a domain or for an editor to submit a form is better handled with webhooks, which the documentation recommends as the way to stay inside the limits (Webhooks).

  3. Share one limiter. One process-wide object that every call passes through prevents parallel tasks from competing.

  4. Respect the server’s word. Use Retry-After on a 429, and X-RateLimit-Remaining to slow down before one.

The following limiter keeps a single budget for the whole process. It spaces calls evenly and, after any response, reads the remaining count; when fewer than five requests remain it waits for the window to reset.

import { Webflow, WebflowClient } from "webflow-api";

type Task<T> = () => Promise<{ data: T; rawResponse: { headers: Headers } }>;

export class BudgetedClient {
  private chain: Promise<unknown> = Promise.resolve();
  private remaining = Number.POSITIVE_INFINITY;
  private windowResetsAt = 0;

  constructor(private readonly minRemaining = 5) {}

  run<T>(task: Task<T>): Promise<T> {
    const next = this.chain.then(async () => {
      if (this.remaining < this.minRemaining && Date.now() < this.windowResetsAt) {
        await new Promise((resolve) => setTimeout(resolve, this.windowResetsAt - Date.now()));
      }
      try {
        const { data, rawResponse } = await task();
        const header = rawResponse.headers.get("x-ratelimit-remaining");
        this.remaining = header === null ? Number.POSITIVE_INFINITY : Number(header);
        this.windowResetsAt = Date.now() + 60_000;
        return data;
      } catch (error) {
        if (error instanceof Webflow.TooManyRequestsError) {
          this.remaining = 0;
          this.windowResetsAt = Date.now() + 60_000;
        }
        throw error;
      }
    });
    this.chain = next.catch(() => undefined);
    return next;
  }
}

// Usage: every call goes through the same budget.
const webflow = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN ?? "" });
const budget = new BudgetedClient();

export const listPlants = (collectionId: string, offset: number) =>
  budget.run(() => webflow.collections.items.listItems(collectionId, { limit: 100, offset }).withRawResponse());

If you call the API without an SDK, implement the same two reactions yourself. The documentation says plainly that without an SDK you should implement your own retry logic that respects the Retry-After header.

import time

import httpx

BASE = "https://api.webflow.com/v2"


def get_json(client: httpx.Client, path: str, params: dict | None = None, attempts: int = 4) -> dict:
    for attempt in range(attempts):
        response = client.get(f"{BASE}{path}", params=params)
        if response.status_code == 429:
            wait = int(response.headers.get("Retry-After", "60"))
            time.sleep(wait)
            continue
        if response.status_code >= 500:
            time.sleep(min(2**attempt, 60))
            continue
        response.raise_for_status()
        remaining = response.headers.get("X-RateLimit-Remaining")
        if remaining is not None and int(remaining) < 5:
            time.sleep(1.0)
        return response.json()
    raise RuntimeError(f"Gave up on {path} after {attempts} attempts")


with httpx.Client(headers={"Authorization": "Bearer REPLACE_WITH_TOKEN"}, timeout=30.0) as http:
    print(get_json(http, "/token/authorized_by"))

Error format and codes

Every error response is a JSON object with four properties, as documented in the error-handling page.

Property Type Description

code

string

A machine-readable error code.

message

string

A human-readable error message.

externalReference

string

A link to more information about the error. Often null.

details

array

Additional error details, for example the individual validation failures of a multi-item request.

An example from the documentation:

{
  "message": "Requested resource not found: The site cannot be found",
  "code": "resource_not_found",
  "externalReference": null,
  "details": []
}

The standard codes are:

Status code Meaning Typical reaction

400

bad_request

The request was malformed.

Fix the payload. Never retry unchanged. Read details for the offending field.

401

not_authorized

The request lacks valid authentication credentials.

Token missing, revoked or mistyped. Check the header and the token’s validity.

403

forbidden

The authenticated user lacks permission for the action.

The token’s scopes or site access do not cover the call. Compare with GET /v2/token/introspect.

404

resource_not_found

The resource was not found.

Wrong identifier, deleted resource, or a resource on a site the token cannot see.

409

conflict

The request conflicts with the current state of the resource.

Re-read the resource, merge, and retry. The SDKs also treat 409 specially (see below).

429

too_many_requests

Too many requests in a given amount of time.

Wait for Retry-After, then retry.

500

internal_error

An unexpected error on Webflow’s side.

Retry with backoff; report a persistent one with the request identifiers you have.

The error-code enumeration on individual endpoint pages is much longer than this table, because it is shared across endpoints. It includes specific codes such as validation_error, item_not_found, collection_not_found, insufficient_permissions, missing_scopes, duplicate_collection, batch_too_large, forms_require_republish, service_unavailable, ecommerce_not_enabled and unsupported_webhook_trigger_type. Branch on the specific code where an endpoint documents one, and fall back to the status class otherwise. The code string is more stable than the message text, which exists for people.

What to retry

Condition Retry? Reasoning

429

Yes, after Retry-After

The request was valid and was refused only for volume.

500, 502, 503, 504, timeouts

Yes, with backoff and a cap

Transient on the server or network. Reads are always safe to repeat.

408

Yes

The request timed out; the SDKs retry it.

409

Re-read first

The Python SDK retries a 409 blindly; for a write that depends on state, check the state before replaying it.

400, 401, 403, 404

No

Repeating an identical request returns the identical error.

The retry rule needs care for writes. A POST that creates an item may succeed on the server while the response is lost to a timeout. Retrying it blindly creates a duplicate. Fernbrook avoids that by matching items on their slug before creating them, the idempotent pattern explained with the full sync script in Data API: CMS; the same advice applies to every create endpoint.

A defensive wrapper for a nightly job

The helper below classifies errors into those a job can skip, those it must stop on, and those that were already retried. It logs the code and the details array, which is where validation messages for a rejected item appear.

import { Webflow, WebflowError } from "webflow-api";

export type Outcome = "skipped" | "fatal" | "transient";

export function classify(error: unknown): Outcome {
  if (error instanceof Webflow.BadRequestError || error instanceof Webflow.NotFoundError) {
    return "skipped";
  }
  if (error instanceof Webflow.UnauthorizedError || error instanceof Webflow.ForbiddenError) {
    return "fatal";
  }
  if (error instanceof WebflowError) {
    return "transient";
  }
  throw error;
}

export function describe(error: unknown): string {
  if (error instanceof WebflowError) {
    const body = (error.body ?? {}) as { code?: string; message?: string; details?: unknown[] };
    return `${error.statusCode} ${body.code ?? "unknown"}: ${body.message ?? error.message} ${JSON.stringify(body.details ?? [])}`;
  }
  return String(error);
}

Idempotency, concurrency and timeouts

The Data API does not document an idempotency-key header, so repeat safety must come from the design of the request.

  • Reads and deletes by identifier are safe to repeat.

  • Updates with the same body are safe to repeat, and an update of a field to the value it already has changes nothing.

  • Creates are not. Match on a natural key (the slug of a plant, an external identifier stored in a field) first.

  • Publishes are limited to one successful publish per minute, so a retried publish within the minute is refused.

Concurrency interacts with the rate limit rather than escaping it. Ten parallel workers sharing one token share one budget of 60 or 120 requests per minute, so extra workers only turn into extra 429 responses and extra waiting. For write-heavy jobs one worker with batches of 100 is typically both faster and calmer than many workers with single items. For reads across several sites, use a token per site (each Site token has its own budget) rather than one Workspace token.

Timeouts need the same attention. The SDK default of 60 seconds is generous for ordinary calls and short for none. A script that must never hang should set a smaller timeoutInSeconds or timeout and let the retry policy deal with the exception. Only idempotent calls should have maxRetries raised.

Versions, deprecation and migrating from v1

The v1 API (the original Webflow API, with site tokens that appeared as a single "API key" and webhooks created through the dashboard without scopes) is deprecated. The official deprecation notice sets out the timeline:

Date Event

Announced in the notice

Webflow discontinued the creation of v1 API Site Tokens, no longer supports manual creation of v1 webhooks and no longer accepts submissions of new v1 Apps.

2024-08-01

Marketplace Apps that still relied on v1 (non-scoped) authorization were de-listed.

2025-03-31

The v1 API was deprecated. The notice says it may continue to function after that date but no longer receives maintenance, updates or support, and that integrations created by v1 Apps stop receiving maintenance and support.

As of 2026-10-28 the documentation still labels v1 as deprecated, and its guidance has not changed: build on v2.

The differences that cost the most time during a migration, from the migration guide and the v2 reference, are:

Area v1 v2

Tokens

One "API key" per site, no scoping.

Site tokens and Workspace tokens with selectable scopes, and OAuth access tokens limited by scopes.

Apps

OAuth without permission scopes; no permission list on the App listing.

A new Data Client App with a new client ID and secret, and v2 scopes in the authorization URL. An existing v1 App cannot be upgraded in place; a new App is registered and users re-authorize it.

Webhooks

Created in the dashboard, with the API version shown beside them.

Created through the API or the dashboard as v2 webhooks. The guide advises creating the v2 webhook, confirming the workflow, and then deleting the v1 one.

Resources

A smaller resource set.

An expanded resource model: sites, pages, components, Collections, fields, staged and live items, assets, forms, scripts, Ecommerce, localization and more.

SDKs

Older package majors.

The current JavaScript and Python SDKs; the JavaScript SDK has its own v2 to v3 migration notes.

The JavaScript SDK’s own MIGRATION.md records the breaking changes of its major version 3. For example, deleting a Collection became client.collections.delete(), deleting a Collection field became client.collections.fields.delete(), creating items in several locales moved to client.collections.items.createItems() with cmsLocaleIds, and the locale query parameter of the pages endpoints became localeId.

A migration checklist for a site owner

  1. Open the site settings, section Apps & Integrations, and look at API access. A v1 token shows a legacy warning and a Last used date. If the date is old, the token can simply be removed.

  2. For a token still in use, generate a v2 Site token with the least scopes the integration needs, and replace it in the integration. For a third-party tool, ask the vendor whether it supports v2 tokens.

  3. In the Webhooks section, find webhooks marked with the old API version, create an equivalent v2 webhook with the same event type, test the workflow, and delete the old one.

  4. In Connected Apps, check that each App is a v2 App (its listing shows permissions). Re-authorize any App that has a v2 replacement.

A migration checklist for a developer

  1. Register a new Data Client App and add the scopes your calls need.

  2. Update the OAuth flow to the new client ID and secret and the v2 scopes (Data API: Authentication and Scopes).

  3. Replace calls to v1 endpoints with their v2 equivalents or, simpler, adopt the JavaScript or Python SDK.

  4. Move all polling to webhooks.

  5. Re-test with a Developer-workspace site before pointing the integration at production.

Search results, blog posts and older books frequently show v1 requests and a single per-site API key. Those samples no longer apply; the section on outdated advice in Where Older Books and Tutorials Are Outdated lists the other places where printed material and the current product disagree.

Common pitfalls

  • Calling beta endpoints through an SDK. The SDKs only know the /v2/ paths. A method that does not exist in the SDK may exist in /beta/; use raw HTTP.

  • Copying v1 snippets. A 404 or 401 on an endpoint that "should" exist is usually a v1 path or a v1 token.

  • Trusting the first page. A list endpoint returns at most its maximum (100 for items). A script that reads one page of a 400-item Collection silently processes a quarter of the data.

  • Retrying inside a tight loop. A hand-written loop that retries a 429 immediately only produces more 429 responses. Wait for Retry-After.

  • Parallel workers on one token. They share one budget; add a limiter instead of workers.

  • Hard coding the limit. A plan change alters it. Read the headers.

  • Logging the Authorization header. Error objects and raw responses can contain request metadata. Scrub headers before logging and never print the token.

  • Putting the token in a browser bundle. A Site or Workspace token grants API access to everyone who can read the bundle. Keep tokens on a server, in an environment variable or a secrets manager. Public pages read cached content from the Content Delivery API through a server route, as shown in Content Delivery API and Headless.

  • Pinning loosely. Both SDKs are preview releases. A caret range can pull in a breaking change on the next install; commit a lock file and pin exact versions.

  • Assuming a publish succeeds immediately. Site publishing is limited to one successful publish per minute; use the site-publish webhook instead of polling for completion.

Where this fits

The pages of this sub-section build on one another. This page gives the transport rules; the pages that follow use them.

Page What it adds

Data API: Authentication and Scopes

How each token type is created, what the scopes mean and how OAuth works.

Data API: Sites, Pages and Components

Site, domain, page and component endpoints, including publishing.

Data API: CMS

Collections, fields and items, bulk writes and the complete Fernbrook sync script.

Data API: Assets, Forms and Custom Code

Uploads, form submissions and registered scripts.

Data API: Ecommerce and Localization

Products, orders, inventory and localized content.

Webhooks

Event delivery that replaces polling.

Webflow CLI

Terminal access to the same data, with --json output for scripts.