Content Delivery API and Headless

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 Webflow as it was on 2026-10-28, the date on which its facts were checked against the official Data API documentation. Webflow is a software-as-a-service product with no version numbers, so what follows is dated rather than versioned. The Content Delivery API is part of the Data API; the official pages do not state a separate plan requirement for it, and the only plan-based difference they describe is the length of the cache (shorter on Enterprise plans). Serving real traffic from a custom domain is a separate decision, described in Domains, Hosting and Publishing Options. Plans are named by tier only, never by price. All examples use a Developer workspace site and placeholder values you must replace.

Pinned versions, checked on 2026-10-28 on the package registries: the JavaScript SDK webflow-api 3.3.4, the Python SDK webflow 2.0.0, Next.js 16.4.3 with React 19.3.1 and TypeScript 7.0.2 for the headless reader, and sanitize-html 2.18.1. Node.js 24 is used for the commands.

The running example is Fernbrook Gardens, the fictitious botanical garden with a Plants Collection, a Garden zones Collection, an Events Collection and a Garden Journal Collection (described in CMS Concepts and Content Modeling). The garden’s site is published by Webflow, but its communications team also wants a separate reading application for the Journal: faster pages, a design that Webflow’s template pages cannot express and the same Journal text reused in a mobile app and a newsletter. All host names are placeholders under the reserved example names.

Two ways to read the same content

A Collection item can be read through two hosts. Both return the same JSON for the same request; they differ in freshness, in what they can reach and in what a request costs.

Aspect Data API (api.webflow.com) Content Delivery API (api-cdn.webflow.com)

Freshness

Always the latest data from Webflow’s backend

Served from a cache; may lag the CMS by up to the cache time

Reachable data

Everything the token’s scopes allow: staged items, drafts, writes, every other resource

Only two read-only endpoints for published (live) items

Rate limit

Every request counts against the plan’s limit

Cache hits do not count; only requests that reach the origin do

Intended for

Management, synchronization, previews, anything that writes

High-volume, multi-channel reads: websites, apps, email

Authentication

Site tokens and OAuth access tokens

The same site tokens and OAuth access tokens

The Data API’s documentation for listing live items itself points to the Content Delivery API for real-time application reads, because it is optimized for high-volume reads. Everything else in the Data API stays on the original host, so a program normally uses both: the cached host for what visitors read and the uncached host for what editors preview or scripts change. The write side is covered in Data API: CMS.

What the Content Delivery API is

The Content Delivery API serves published CMS content from a cache so that high-volume traffic does not hit Webflow’s backend. The change a developer makes is one word of configuration: replace the host api.webflow.com by api-cdn.webflow.com. Paths, query parameters, authentication headers and response bodies stay the same.

The cached endpoints are read-only and cover live items only:

  • List live collection items, which returns the published items of a Collection, with the usual offset, limit (up to 100), slug, name, cmsLocaleId, sort and filter parameters.

  • Get a live collection item, which returns one published item.

Everything else on api-cdn.webflow.com is not part of the contract: listing Collections, reading staged items, pages, forms, assets and every write belong to the Data API host. A request for a staged item sent to the cached host is the wrong tool, because the cache holds only the published version.

The first request, the cache and the header

Responses match the equivalent Data API responses with one addition: the cf-cache-status response header, which says where the answer came from.

cf-cache-status Meaning

HIT

Served from the CDN cache; the request did not reach Webflow’s backend

MISS

Not in the cache; served from the origin, then cached for the next identical request

BYPASS

Served from the origin without using the cache

The caching policy, as the official page states it:

  • The first request for a given query goes to the Data API. The response is then cached, and identical later requests are served from the cache. The cache key is the whole request, so limit=100&offset=0 and limit=100&offset=100 are two entries, and so are two slug lookups.

  • The time to live is 120 seconds on Enterprise plans and 300 seconds on the other plans. After that the entry is purged and the next request goes to the origin.

  • Changes to published items, including updates and deletions, may take up to the cache duration to appear, so users may see the previous version until the cache refreshes.

Two consequences matter in practice. First, a publish does not purge this cache: an editor who fixes a typo in the Journal and publishes will see the fix on the Webflow-hosted site at once, but a reader that goes through the cached host can show the old text for up to five minutes. Second, a deletion or unpublish has the same lag, so an item that was withdrawn for a legal reason can remain readable through this host for the length of the TTL. Applications that cannot tolerate that window read the live item from the Data API host when they regenerate a page, which is the approach taken in the Next.js reader below.

Rate limits and what the cache saves

Cache hits do not count against the plan’s rate limits, so cached reads are effectively unlimited. Requests that reach the origin do count, and exceeding the limit returns 429 Too Many Requests. The plan-specific numbers are on the rate-limits reference page, which is also where the X-RateLimit-Remaining header is documented; the headers and the backoff pattern are the subject of Data API: SDKs, Limits and Errors. The official guidance for the Content Delivery API is to retry 429 responses with exponential backoff and to stagger the initial uncached requests, because a cold cache sends every distinct query to the origin at once. A build that fetches forty pages of items in parallel the first time is the typical cause.

The saving is therefore proportional to the repetition of requests, not to their number. A Journal list page requested a million times with the same query costs one origin request every five minutes. A search page that sends a different name filter per visitor defeats the cache and is billed against the rate limit like any Data API call.

Calling it

The raw HTTP request is the Data API request with a new host.

curl -G https://api-cdn.webflow.com/v2/collections/REPLACE_WITH_COLLECTION_ID/items/live \
  -H "Authorization: Bearer $WEBFLOW_CDN_TOKEN" \
  -H "Accept: application/json" \
  -d offset=0 \
  -d limit=100 \
  -D - -o /dev/null

The options -D - -o /dev/null print only the response headers. Run the command twice; the second answer carries cf-cache-status: HIT as long as the first was less than 120 or 300 seconds earlier.

The JavaScript SDK

The SDK takes the host from the baseUrl option of WebflowClient. A program that needs both hosts creates two clients, one per token, so that a read-only token can never be used for a write by accident.

{
  "name": "fernbrook-delivery",
  "private": true,
  "type": "module",
  "dependencies": {
    "webflow-api": "3.3.4"
  },
  "devDependencies": {
    "tsx": "4.21.0",
    "typescript": "7.0.2"
  }
}
import { WebflowClient } from "webflow-api";

const collectionId = process.env.WEBFLOW_JOURNAL_COLLECTION_ID ?? "REPLACE_WITH_COLLECTION_ID";

// Reads of published items: cached host, CMS read-only token for this channel.
const delivery = new WebflowClient({
  accessToken: process.env.WEBFLOW_CDN_TOKEN ?? "",
  baseUrl: "https://api-cdn.webflow.com",
});

const page = await delivery.collections.items.listItemsLive(collectionId, { limit: 100, offset: 0 });
for (const item of page.items ?? []) {
  console.log(item.fieldData?.slug, item.lastPublished);
}
console.log("total:", page.pagination?.total);

The method is listItemsLive. The official example names its client webflowCDN and calls the staged-items method listItems; copied as written, that call reads the staged items, which the cached host does not serve as a cache, so a delivery reader should call the live method explicitly. The same applies to the Python SDK.

The Python SDK

The Python SDK models the two hosts as a WebflowEnvironment with a base URL and one URL for each API.

webflow==2.0.0

Source: PyPI: webflow

import os

from webflow import Webflow
from webflow.environment import WebflowEnvironment

environment = WebflowEnvironment(
    base="https://api-cdn.webflow.com/v2",
    content_delivery_api="https://api-cdn.webflow.com/v2",
    data_api="https://api.webflow.com/v2",
)

client = Webflow(access_token=os.environ["WEBFLOW_CDN_TOKEN"], environment=environment)

page = client.collections.items.list_items_live(
    collection_id=os.environ["WEBFLOW_JOURNAL_COLLECTION_ID"],
    limit=100,
    offset=0,
)
for item in page.items or []:
    print(item.field_data.get("slug") if item.field_data else None, item.last_published)

Tokens for a delivery channel

Webflow recommends a separate token for each delivery channel, with CMS read-only permissions. The reasoning is operational: a token is the unit of revocation. If the newsletter tool leaks its token, revoking it leaves the website and the mobile app untouched. A token also identifies traffic, so the origin requests of one channel can be told from another in an incident.

For Fernbrook Gardens the garden creates one site token per consumer, each with only the cms:read scope (the authorization scope reported as CMS:read in the endpoint reference):

Channel Token name in the site’s API settings Holder

Journal website

journal-web-read

The Next.js server’s environment, never its client bundle

Mobile app backend

journal-app-read

The app’s backend-for-frontend, never the app binary

Newsletter

journal-mail-read

The mailing tool’s server-side connector

Preview

journal-preview

The Next.js server only; read-only too, but used on the uncached host for staged items

Site tokens are created in the site’s settings under Apps & integrations by an Administrator or a user who may manage the site, and OAuth access tokens come from an installed Data Client app; both flows, the full scope list and token introspection are described in Data API: Authentication and Scopes. Four rules apply to a delivery token.

  1. Least privilege. A read channel needs cms:read and nothing else. The Data API rejects a write attempted with it. A token with cms:write in a reading application is the most common avoidable incident.

  2. Server-side only. A token placed in browser JavaScript, a mobile binary or a public repository is public. Even a read-only token lets anyone enumerate the site’s published items and burn the plan’s rate limit through the origin by varying the query.

  3. Environment variables, not code. Keep tokens in the host’s secret store; locally, in an untracked .env.local file. Rotate by creating the new token, deploying it and then revoking the old one.

  4. One site per token. A site token is valid for one site, which limits the damage; a workspace-level token or an OAuth app reaches more sites and deserves tighter handling.

A headless reader of the Garden Journal

Headless means the CMS stores and serves content while another program renders it. Webflow is then used for what it is best at, the editing experience, the Collections and the validation of fields, while the pages are produced by a framework of the team’s choice. Next.js fits well, because its incremental static regeneration serves each page as a cached file and rebuilds only the pages whose content changed, which matches a CMS that publishes content in small units. The ISR model, the cache tags and revalidateTag are described in Incremental Static Regeneration, and the data-fetching and caching layers in Data Fetching and Caching.

Headless architecture: editors publish in the CMS, the Content Delivery API caches reads, a server-side Next.js app renders pages and a webhook triggers revalidation while the browser receives only HTML

The flow in the figure has four steps. An editor publishes a Journal item. The Data API sends a collection_item_published webhook to a route in the Next.js app. The route verifies the signature and marks the pages that depend on that item as stale by tag. The next visitor receives the old page immediately while Next.js regenerates it in the background by reading the live item again.

The Journal collection

The reader assumes the Garden Journal Collection has these fields, which are the fields of the collection built earlier in this sub-section.

Field Type Value in the API’s fieldData

Name

Plain text

name, required

Slug

Plain text

slug, required, unique within the Collection

Summary

Plain text

summary

Body

Rich text

body, a string of HTML

Cover photo

Image

cover-photo, an object with fileId, url and alt

Published on

Date/time

published-on, an ISO 8601 string

Field slugs in fieldData are the slugs shown in the Collection settings, which are lower case with hyphens, so a field named Cover photo is read as cover-photo. Check them in the Collection settings or with the Data API’s Collection details endpoint instead of assuming; a field whose slug differs from the one in the code is returned as absent and produces a page without a cover.

Project setup

{
  "name": "fernbrook-journal",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.4.3",
    "react": "19.3.1",
    "react-dom": "19.3.1",
    "sanitize-html": "2.18.1"
  },
  "devDependencies": {
    "@types/node": "24.0.0",
    "@types/react": "19.3.0",
    "@types/sanitize-html": "2.16.0",
    "typescript": "7.0.2"
  }
}

The environment file keeps every secret on the server. None of these names starts with NEXT_PUBLIC_, the prefix that makes Next.js copy a value into the browser bundle.

# .env.local (never committed)
WEBFLOW_COLLECTION_ID=REPLACE_WITH_COLLECTION_ID
WEBFLOW_CDN_TOKEN=REPLACE_WITH_READ_ONLY_TOKEN
WEBFLOW_PREVIEW_TOKEN=REPLACE_WITH_PREVIEW_TOKEN
WEBFLOW_WEBHOOK_SECRET=REPLACE_WITH_WEBHOOK_SECRET
PREVIEW_SECRET=REPLACE_WITH_RANDOM_STRING

The data layer

All access to Webflow goes through one module. It uses the plain fetch function instead of the SDK because Next.js extends fetch with cache tags and revalidation times, and those options are what connect a Webflow item to a cached page.

The module makes two decisions that follow from the cache behavior above. Lists are read from the cached host, because a list page is requested often and a delay of a few minutes in a list is acceptable. A single article is read from the Data API host when Next.js regenerates it, because regeneration happens once per page and per change, so the cost is small, and because it guarantees that the page rebuilt after a webhook contains the new text rather than the cached old one.

// lib/webflow.ts
import "server-only";

const CDN_HOST = "https://api-cdn.webflow.com/v2";
const DATA_HOST = "https://api.webflow.com/v2";

export interface WebflowImage {
  fileId?: string;
  url: string;
  alt?: string | null;
}

export interface JournalFields {
  name: string;
  slug: string;
  summary?: string;
  body?: string;
  "cover-photo"?: WebflowImage;
  "published-on"?: string;
}

export interface JournalItem {
  id: string;
  lastPublished: string | null;
  lastUpdated: string;
  isDraft: boolean;
  isArchived: boolean;
  fieldData: JournalFields;
}

interface ItemsResponse {
  items: JournalItem[];
  pagination: { limit: number; offset: number; total?: number };
}

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing environment variable ${name}`);
  return value;
}

async function webflowFetch<T>(
  host: string,
  path: string,
  token: string,
  next: { revalidate?: number | false; tags?: string[] },
): Promise<T> {
  const response = await fetch(`${host}${path}`, {
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
    next,
  });
  if (response.status === 429) {
    const wait = response.headers.get("retry-after") ?? "unknown";
    throw new Error(`Webflow rate limit reached; retry after ${wait} seconds`);
  }
  if (!response.ok) {
    throw new Error(`Webflow ${response.status} for ${path}`);
  }
  return (await response.json()) as T;
}

/** Published Journal items, newest first, from the cached host. */
export async function listJournal(): Promise<JournalItem[]> {
  const collection = required("WEBFLOW_COLLECTION_ID");
  const query = new URLSearchParams({ limit: "100", sortBy: "lastPublished", sortOrder: "desc" });
  const data = await webflowFetch<ItemsResponse>(
    CDN_HOST,
    `/collections/${collection}/items/live?${query}`,
    required("WEBFLOW_CDN_TOKEN"),
    { revalidate: 300, tags: ["journal"] },
  );
  return data.items;
}

/** One published article by slug, read from the uncached host when the page is regenerated. */
export async function getJournalPost(slug: string): Promise<JournalItem | null> {
  const collection = required("WEBFLOW_COLLECTION_ID");
  const query = new URLSearchParams({ slug, limit: "1" });
  const data = await webflowFetch<ItemsResponse>(
    DATA_HOST,
    `/collections/${collection}/items/live?${query}`,
    required("WEBFLOW_CDN_TOKEN"),
    { tags: ["journal", `journal:${slug}`] },
  );
  return data.items[0] ?? null;
}

/** The staged version of an article, drafts included, for previews. Never cached. */
export async function getJournalDraft(slug: string): Promise<JournalItem | null> {
  const collection = required("WEBFLOW_COLLECTION_ID");
  const query = new URLSearchParams({ slug, limit: "1" });
  const data = await webflowFetch<ItemsResponse>(
    DATA_HOST,
    `/collections/${collection}/items?${query}`,
    required("WEBFLOW_PREVIEW_TOKEN"),
    { revalidate: 0 },
  );
  return data.items[0] ?? null;
}

The server-only import is a build-time guard: if any client component imports this module, the build fails instead of shipping the token. The staged call uses the path without /live, the Data API’s list staged items endpoint, on the uncached host. Each helper returns the first item for a slug filter, which the endpoint accepts as an exact match.

Two details deserve attention. The limit is capped at 100; a Journal with more than a hundred posts needs the offset loop shown in Data API: CMS, or a dedicated archive page per year with a filter. And the list endpoint also supports filter[...] and sort[...] parameters, with at most 10 filter terms and 2 text-search terms per request, and at most 3 sort fields. Beyond those limits, read the items and filter them in the application.

Rich text and the image host

The body field is a string of HTML produced by Webflow’s rich text editor: headings, paragraphs, lists, links, figures and embedded images. It is trusted content in the sense that only the site’s editors write it, but a headless reader should still treat it as untrusted input, because a collaborator’s account, a CSV import or an API client with cms:write can put anything into it, and the reader renders it with dangerouslySetInnerHTML, which executes whatever markup it receives. A sanitizer with an allow-list closes that route.

// lib/rich-text.ts
import "server-only";
import sanitizeHtml from "sanitize-html";

export function cleanRichText(html: string | undefined): string {
  return sanitizeHtml(html ?? "", {
    allowedTags: [
      "h2", "h3", "h4", "p", "a", "ul", "ol", "li", "strong", "em", "blockquote",
      "figure", "figcaption", "img", "br", "hr", "code", "pre",
    ],
    allowedAttributes: {
      a: ["href", "title", "rel"],
      img: ["src", "alt", "width", "height", "loading"],
    },
    allowedSchemes: ["https", "mailto"],
    transformTags: {
      a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer" }),
    },
  });
}

Image fields are objects: the url points to Webflow’s asset CDN, alt carries the alternative text typed in the Collection item, and fileId identifies the asset in the Assets API. Webflow publishes the originals together with generated responsive variants on its own sites, but the API returns the original URL, and next/image creates the variants a headless site needs. Next.js refuses remote hosts that are not declared, so the asset host seen in your responses must be allowed explicitly. The host appearing in current responses is cdn.prod.website-files.com; inspect a real response and allow exactly the host you see.

// next.config.ts
import type { NextConfig } from "next";

const config: NextConfig = {
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "cdn.prod.website-files.com", pathname: "/**" },
    ],
  },
};

export default config;

The image value has no width and height, so the layout reserves the space with a CSS aspect ratio and fill, which avoids layout shift. An empty alt in the CMS is a decision for the editor, because it declares the image decorative; the component below passes the value through and never invents a description.

The pages

The list page is a server component. Its data comes from the cached host, and the page itself is revalidated by tag or by time.

// app/journal/page.tsx
import Image from "next/image";
import Link from "next/link";
import { listJournal } from "@/lib/webflow";

export const metadata = { title: "Garden Journal | Fernbrook Gardens" };

export default async function JournalIndex() {
  const posts = await listJournal();
  return (
    <main>
      <h1>Garden Journal</h1>
      <ul className="posts">
        {posts.map((post) => {
          const cover = post.fieldData["cover-photo"];
          return (
            <li key={post.id}>
              {cover && (
                <div style={{ position: "relative", aspectRatio: "3 / 2" }}>
                  <Image src={cover.url} alt={cover.alt ?? ""} fill sizes="(min-width: 800px) 400px, 100vw" />
                </div>
              )}
              <h2>
                <Link href={`/journal/${post.fieldData.slug}`}>{post.fieldData.name}</Link>
              </h2>
              <p>{post.fieldData.summary}</p>
            </li>
          );
        })}
      </ul>
    </main>
  );
}

The article page prebuilds the existing posts at build time and renders new ones on demand. Since Next.js 15 the params object is a promise, which the code awaits.

// app/journal/[slug]/page.tsx
import { draftMode } from "next/headers";
import Image from "next/image";
import { notFound } from "next/navigation";
import { cleanRichText } from "@/lib/rich-text";
import { getJournalDraft, getJournalPost, listJournal } from "@/lib/webflow";

export const dynamicParams = true;

export async function generateStaticParams() {
  const posts = await listJournal();
  return posts.map((post) => ({ slug: post.fieldData.slug }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getJournalPost(slug);
  return { title: post ? `${post.fieldData.name} | Fernbrook Gardens` : "Not found" };
}

export default async function JournalPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const { isEnabled } = await draftMode();
  const post = isEnabled ? await getJournalDraft(slug) : await getJournalPost(slug);
  if (!post) notFound();

  const cover = post.fieldData["cover-photo"];
  return (
    <article>
      {isEnabled && <p role="status">Preview: this version may not be published yet.</p>}
      <h1>{post.fieldData.name}</h1>
      <time dateTime={post.fieldData["published-on"]}>{post.fieldData["published-on"]?.slice(0, 10)}</time>
      {cover && (
        <div style={{ position: "relative", aspectRatio: "3 / 2" }}>
          <Image src={cover.url} alt={cover.alt ?? ""} fill priority sizes="(min-width: 800px) 800px, 100vw" />
        </div>
      )}
      <div dangerouslySetInnerHTML={{ __html: cleanRichText(post.fieldData.body) }} />
    </article>
  );
}

An unknown slug returns null from the helper, notFound() produces the 404 page, and a slug that exists only as a draft or an archived item is also a 404 for public visitors, because the live endpoint does not return it.

Keeping the reader fresh with a webhook

Webflow publishes; the reader should react, not poll. The Data API sends a collection_item_published webhook when an item is published, and collection_item_unpublished and collection_item_deleted when one is withdrawn. The payload of the published event carries triggerType and a payload object with the item’s id, siteId, collectionId, cmsLocaleId, lastPublished, lastUpdated, createdOn, isArchived, isDraft and fieldData, of which name and slug are always present. The slug is all the route needs to find the cached page.

The delivery rules, the creation of webhooks through the API, the 75-webhooks-per-trigger-type limit and the receivers in Node and Python are the subject of Webhooks. The route below is the Journal’s receiver. It follows the documented signature scheme: the x-webflow-signature header is a SHA-256 HMAC, in hexadecimal, of the string made of the x-webflow-timestamp header, a colon and the raw body, and a request older than five minutes is rejected. For a webhook created through the API with a site token, the signing key is that webhook’s own secretKey, returned in the creation response; for an OAuth app it is the app’s client secret. A webhook created in the dashboard has no signature headers, so it cannot be verified and should not drive revalidation of a public site.

// app/api/webflow/revalidate/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";

const MAX_AGE_MS = 5 * 60 * 1000;

function validSignature(timestamp: string, body: string, signature: string, secret: string): boolean {
  const expected = createHmac("sha256", secret).update(`${timestamp}:${body}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

function freshEnough(timestamp: string): boolean {
  const sent = Number(timestamp);
  if (!Number.isFinite(sent)) return false;
  // Accept both seconds and milliseconds since the epoch.
  const sentMs = sent < 1e12 ? sent * 1000 : sent;
  return Math.abs(Date.now() - sentMs) <= MAX_AGE_MS;
}

export async function POST(request: Request) {
  const body = await request.text(); // raw body: the signature covers these exact bytes
  const timestamp = request.headers.get("x-webflow-timestamp") ?? "";
  const signature = request.headers.get("x-webflow-signature") ?? "";
  const secret = process.env.WEBFLOW_WEBHOOK_SECRET ?? "";

  if (!secret || !validSignature(timestamp, body, signature, secret) || !freshEnough(timestamp)) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }

  const event = JSON.parse(body) as {
    triggerType?: string;
    payload?: { fieldData?: { slug?: string } };
  };
  const slug = event.payload?.fieldData?.slug;

  revalidateTag("journal", "max"); // the list page
  if (slug) revalidateTag(`journal:${slug}`, "max"); // the article

  return NextResponse.json({ ok: true, trigger: event.triggerType, slug });
}

Several details make this receiver correct rather than merely working.

  • It reads the body once, as text. Parsing JSON first and re-serializing it changes whitespace and key order, and the signature no longer matches.

  • It compares in constant time and checks the lengths first, because timingSafeEqual throws on buffers of different length.

  • It accepts the timestamp in seconds or milliseconds. The documentation describes the header as Unix epoch time while its examples compare it in milliseconds, so the code normalizes both rather than guessing.

  • It answers 200 quickly. Webflow treats any non-200 answer, a redirect, a certificate failure or a timeout as a failure, retries up to three more times ten minutes apart, and deactivates the webhook after repeated failures; reactivation then requires contacting Webflow support. The route does only cheap work, since revalidateTag with the max profile marks entries stale and returns, leaving the regeneration to the next request.

  • It is idempotent. Revalidating twice for a duplicate delivery is harmless.

The tag has a second purpose. getJournalPost reads from the uncached host, so when the page behind journal:fernbrook-roses-in-bloom regenerates after the webhook, the text it renders is the text just published, not the five-minute-old entry in the Content Delivery API cache. The list page, which reads from the cached host, can show the previous list for up to the TTL; if that is not acceptable for the list either, move listJournal to the Data API host too. That is a trade of rate-limit budget for freshness, and a per-site decision.

Register the webhook once, with a site token that has the sites:write scope (the endpoint needs write access to the site’s webhooks). The secret key appears in the response only for this kind of token.

curl -X POST "https://api.webflow.com/v2/sites/REPLACE_WITH_SITE_ID/webhooks" \
  -H "Authorization: Bearer $WEBFLOW_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "triggerType": "collection_item_published",
    "url": "https://journal.fernbrook-gardens.example/api/webflow/revalidate"
  }'

Repeat the call for collection_item_unpublished and collection_item_deleted, so that a withdrawn article disappears without waiting for the time-based revalidation. Store each returned secretKey in the host’s secret store; because the receiver above uses a single WEBFLOW_WEBHOOK_SECRET, a project that registers three webhooks either keeps a map from trigger type to key or registers all three before choosing the one that will be checked.

Previewing drafts

Editors need to see an unpublished article in the real design before they publish it. The Content Delivery API cannot help, because it holds only published items; the preview must read staged items from the Data API host, which is why getJournalDraft exists and uses its own token. The staged list endpoint returns drafts as well as published items; each item carries isDraft and isArchived, so the preview can show the state.

Next.js provides Draft Mode for exactly this: a cookie that makes a route skip the static cache and render on demand. The route that turns it on must be protected, otherwise anyone who guesses the URL sees unpublished content.

// app/api/preview/route.ts
import { timingSafeEqual } from "node:crypto";
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const given = Buffer.from(searchParams.get("secret") ?? "");
  const expected = Buffer.from(process.env.PREVIEW_SECRET ?? "");
  const slug = searchParams.get("slug") ?? "";

  if (given.length === 0 || given.length !== expected.length || !timingSafeEqual(given, expected)) {
    return new Response("Invalid token", { status: 401 });
  }
  if (!/^[a-z0-9-]+$/.test(slug)) {
    return new Response("Invalid slug", { status: 400 });
  }

  (await draftMode()).enable();
  redirect(`/journal/${slug}`);
}

The editor opens https://journal.fernbrook-gardens.example/api/preview?secret=...&slug=fernbrook-roses-in-bloom, usually from a bookmark or a link in the editorial checklist, and sees the staged text with the preview banner. The route validates the slug against a strict pattern before redirecting, so it cannot be turned into an open redirect, and it compares the secret in constant time. The link can live in an internal page or in the editors' checklist. The preview secret is a second reason to keep the preview token and the delivery token apart: a leaked preview URL exposes drafts, a leaked delivery token exposes only what is already public.

Why tokens never go to the browser

It is tempting to skip the Next.js server and call Webflow from the visitor’s browser: a fetch in a client component, no backend to run. Two separate obstacles stand in the way, and neither is a bug to work around.

The token would be public. Anything the browser can send, the user can read in the network panel and reuse. A read-only token is less dangerous than a write token, but it still lets anyone read every published item, enumerate Collections the token can reach, and send unlimited distinct queries that miss the cache and are charged against the plan’s rate limit. Revoking it then breaks every visitor at once, and so do rotations.

CORS is a browser rule, not a security feature of Webflow’s API. The same-origin policy blocks a page on journal.fernbrook-gardens.example from reading a response from api-cdn.webflow.com unless the API answers with the right Access-Control-Allow-* headers. Whether an API allows browser origins is the API’s decision, not the page’s; the official Content Delivery API documentation describes server and application use, with a bearer token in the Authorization header, which also forces a CORS preflight request in a browser. Do not design around a hoped-for allowance. Even if a browser call worked, it would still carry the token, which is the first problem. The mechanism and the correct way to configure it in your own backend are explained in What is CORS?.

The robust pattern is a backend-for-frontend: the browser calls your own origin (a Next.js route handler or server component), and your server calls Webflow with the token. The route handler for client-side features, such as a "load more" button, looks like this:

// app/api/journal/route.ts
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  const offset = Math.max(0, Number(new URL(request.url).searchParams.get("offset") ?? "0") || 0);
  const url = new URL(
    `https://api-cdn.webflow.com/v2/collections/${process.env.WEBFLOW_COLLECTION_ID}/items/live`,
  );
  url.searchParams.set("limit", "12");
  url.searchParams.set("offset", String(offset));

  const upstream = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.WEBFLOW_CDN_TOKEN}` },
    next: { revalidate: 300, tags: ["journal"] },
  });
  if (!upstream.ok) return NextResponse.json({ error: "upstream" }, { status: 502 });

  const data = await upstream.json();
  return NextResponse.json(
    { items: data.items.map((i: { id: string; fieldData: Record<string, unknown> }) => ({ id: i.id, ...i.fieldData })) },
    { headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" } },
  );
}

The handler clamps offset to a non-negative integer, fixes limit instead of passing the visitor’s value through, projects only the fields the front end needs, and tells shared caches how long they may keep the answer. These four choices turn the route from an open proxy into a narrow, cheap endpoint. Route handlers and their security are covered in Route Handlers and Backend for Frontend.

The hybrid pattern: a Webflow site plus a headless app

A full rewrite is rarely the goal. The usual shape is hybrid: Webflow keeps the pages it is good at (home page, landing pages, the Plants catalogue with its Collection template pages, forms and e-commerce), and a headless application serves the part that needs behavior Webflow cannot give: an interactive garden map, a members area, a Journal with search and personalized recommendations. Both read the same Collections.

Option How it is arranged Consequences

Separate sub-domain

journal.fernbrook-gardens.example points to the headless host; the Webflow site links to it

Simplest. Two origins, so cookies and analytics are separate. Search engines treat the sub-domain as its own site

Path on the main domain

A reverse proxy sends /journal/ to the headless app and everything else to Webflow

One origin and one SEO authority, at the price of operating the proxy; see Reverse Proxy and Custom Hosting

Webflow Cloud

The app runs on Webflow Cloud, mounted on a path of the Webflow site

One vendor and one deployment flow; see Webflow Cloud and Hybrid Apps

Embedded component

A code component or a Designer-side script loads a small widget from your server

The page stays in Webflow; the widget reads through your server route, never with a token in the page; see Code Components

A few rules keep a hybrid site coherent.

  • One source of truth for content. Editors write the Journal once, in Webflow; the headless app only reads. Do not mirror items into a second database unless you need queries the API cannot answer, because a mirror needs its own synchronization and its own failure handling (see Data API: CMS).

  • Shared design tokens. The headless app should reuse the colors, type scale and spacing of the Webflow site, either by copying the values of the site’s variables (see Variables and Modes) or by exporting code with DevLink (see DevLink Export). Otherwise the two halves drift apart.

  • Shared navigation. Keep the header and footer in one place and render them in both, or at least generate the headless app’s navigation from the same Collection or the site’s page list.

  • Canonical URLs and the sitemap. The Webflow sitemap lists only the pages Webflow hosts. If the headless app serves /journal/, publish its own sitemap and make sure each article has one canonical URL; see SEO and AEO.

  • Forms and e-commerce stay on Webflow unless you accept moving them: a Webflow form posts to Webflow and appears in the site’s submissions, which the app would have to replace (see Forms).

  • Localization. The cached endpoints accept cmsLocaleId; a reader for Spanish and German content passes the locale ids from the site’s localization settings, as described in Localization.

A Spanish and German list

The reader above serves the primary locale. A locale-aware variant passes the locale id to the list call, one request per locale, and caches each separately:

// lib/webflow-locales.ts
import "server-only";

const LOCALES: Record<string, string> = {
  es: process.env.WEBFLOW_LOCALE_ES ?? "REPLACE_WITH_SPANISH_CMS_LOCALE_ID",
  de: process.env.WEBFLOW_LOCALE_DE ?? "REPLACE_WITH_GERMAN_CMS_LOCALE_ID",
};

export async function listJournalIn(locale: "es" | "de") {
  const query = new URLSearchParams({ limit: "100", cmsLocaleId: LOCALES[locale] });
  const response = await fetch(
    `https://api-cdn.webflow.com/v2/collections/${process.env.WEBFLOW_COLLECTION_ID}/items/live?${query}`,
    {
      headers: { Authorization: `Bearer ${process.env.WEBFLOW_CDN_TOKEN}` },
      next: { revalidate: 300, tags: ["journal", `journal:${locale}`] },
    },
  );
  if (!response.ok) throw new Error(`Webflow ${response.status}`);
  return (await response.json()).items as Array<{ id: string; fieldData: { name: string; slug: string } }>;
}

The identifiers differ between the site’s locale list and the CMS (localeId against cmsLocaleId), a trap described in Data API: E-commerce and Localization. Use the CMS locale id here.

Pitfalls

Symptom Cause and remedy

Edited text is unchanged for minutes

The Content Delivery API cache lasts 120 or 300 seconds and a publish does not purge it. Read the page’s own item from the Data API host when it is regenerated, or accept the delay

An unpublished item still appears

The same TTL applies to unpublish and delete. Webhooks mark the Next.js cache stale, not the Webflow one

cf-cache-status is always MISS or BYPASS

Every request has a different query (a changing parameter, a cache-busting value) or the request is not a cacheable read. Keep parameters stable

429 Too Many Requests during a build

A cold cache sends every distinct query to the origin. Build in small batches, back off exponentially and stagger the first requests

Preview shows the published version

The preview read used the live endpoint or the cached host. Use the staged endpoint on the Data API host

Images 400 in next/image

The asset host is not in remotePatterns, or the URL changed host. Inspect a real url and allow exactly that host

Cover photo missing

The code reads a field slug that differs from the Collection’s. Read the slug in the Collection settings

Webhook receiver always answers 401

The body was parsed before the signature check, the wrong secret was used, or the webhook was created in the dashboard and sends no signature

The webhook stops arriving

Webflow deactivated it after repeated failures; fix the receiver and recreate it, or contact Webflow support

Token visible in the browser’s network panel

A client component fetched Webflow directly or a variable begins with NEXT_PUBLIC_. Move the call to a server component or route handler and rotate the token

Corrections to older material

Older books and tutorials describe the CMS as something a developer reads only through the Data API, or through webflow.io pages scraped with a script. The Content Delivery API is a later addition to the platform: it separates cached public reads from the authoritative host and was introduced to answer high-volume traffic. The older Collection API (v1) is gone, and tutorials that call api.webflow.com without the /v2 prefix or use the site-id header of v1 are history, as listed in Where Older Books and Tutorials Are Outdated. Advice to put an API token in a browser script "because it is read-only" is the opposite of the current recommendation: use one read-only token per channel, held by a server.