How to fix Webflow API 429 errors in a Webflow Cloud app

How to stop 429 rate limit errors in a Webflow Cloud app so CMS pages stay up under traffic

Learn how to fix 429 rate limit errors from a Webflow Cloud app by reading through the Content Delivery API.

How to stop 429 rate limit errors in a Webflow Cloud app so CMS pages stay up under traffic

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

A 429 means one API key spent its per-minute budget, and the fix is to read from Webflow's cached delivery host and send writes in batches.

Rate limit errors tend to appear after a successful launch. A page lists CMS items through a Webflow Cloud Route Handler, and once traffic picks up, every visitor triggers a fresh API call. Webflow's Data API allows 60 requests a minute per API key on Starter and Basic sites and 120 on the older CMS, Ecommerce and Business plans.

Past that, it returns a 429 with a Retry-After of about 60 seconds, and the page breaks.

In this guide, we’ll build a workaround with a Next.js app on Webflow Cloud by reading published content from the Content Delivery API behind a short server cache, and by sending writes in batches of up to 100 items per request.

What do you need to fix 429 rate limit errors in Webflow?

You need four things:

  • Webflow Cloud app: A Next.js 15 or higher app deployed on Webflow Cloud, per the bring-your-own-app page, which also states: "Currently, Webflow Cloud supports only the npm package manager."
  • A token for this app alone: A site token with cms:read, plus cms:write if the app creates items. Webflow applies rate limits per API key, so a token shared with an automation tool or a local script shares its budget too.
  • Collection ID: From the collection's settings in the CMS.
  • A sync secret: A long random string that protects the write route in step 5.

The first task is to assign one owner to every API call so you can see where the budget goes.

5 steps to fix 429 rate limit errors in Webflow Cloud

Centralize API calls, read the limit headers, move reads to the Content Delivery API, cache them with a stale fallback, and batch writes.

1. Put the token in environment variables and route every call through one wrapper

Move the token and the collection ID into Webflow Cloud environment variables, then make a single module the only place in the app that calls the API. A 429 is a budget problem, and a budget is only manageable from one place: three components each fetching the API on their own will exceed a limit that one shared call would never touch.

In your Webflow Cloud environment's variable settings, add WEBFLOW_API_TOKEN and SYNC_SECRET as secrets and WEBFLOW_COLLECTION_ID as a plain variable.

Webflow's environment variables docs confirm both kinds reach the app: "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime." Locally, put the same three names in .env.local.

Create the wrapper module every route will import:

// lib/webflow.ts
const API = 'https://api.webflow.com/v2';
const CDN = 'https://api-cdn.webflow.com/v2';

export class RateLimited extends Error {
  retryAfterSeconds: number;
  constructor(retryAfterSeconds: number) {
    super(`Webflow API rate limit, retry after ${retryAfterSeconds}s`);
    this.retryAfterSeconds = retryAfterSeconds;
  }
}

export async function webflowFetch(url: string, init: RequestInit = {}): Promise<Response> {
  const headers = new Headers(init.headers);
  headers.set('Authorization', `Bearer ${process.env.WEBFLOW_API_TOKEN}`);
  headers.set('accept', 'application/json');
  const res = await fetch(url, { ...init, headers });
  if (res.status === 429) {
    throw new RateLimited(Number(res.headers.get('retry-after')) || 60);
  }
  return res;
}

export function liveItemsUrl(offset = 0): string {
  return `${CDN}/collections/${process.env.WEBFLOW_COLLECTION_ID}/items/live?limit=100&offset=${offset}`;
}

export function stagedItemsUrl(): string {
  return `${API}/collections/${process.env.WEBFLOW_COLLECTION_ID}/items?limit=1`;
}

export function insertItemsUrl(): string {
  return `${API}/collections/${process.env.WEBFLOW_COLLECTION_ID}/items/insert`;
}

Every route now gets the token and the endpoint URLs from one module. A 429 surfaces as a RateLimited error that carries Webflow's Retry-After, so each caller decides what to do with it instead of retrying unthinkingly. Searching the project for Authorization now returns exactly one file.

2. Read the limit headers in a diagnosis route

Webflow reports the budget on every Data API response. <a href="https://developers.webflow.com/data/reference/rate-limits">X-RateLimit-Limit</a> is the per-minute allowance for the key, X-RateLimit-Remaining is what is left in the current minute, and a 429 adds Retry-After, typically 60 seconds.

A temporary route that makes one call and returns those headers shows you how close the app runs to the limit before anything breaks.

Add app/api/diagnose/route.ts:

// app/api/diagnose/route.ts
import { RateLimited, stagedItemsUrl, webflowFetch } from '@/lib/webflow';

export async function GET() {
  try {
    const res = await webflowFetch(stagedItemsUrl());
    return Response.json({
      status: res.status,
      limit: res.headers.get('x-ratelimit-limit'),
      remaining: res.headers.get('x-ratelimit-remaining'),
    });
  } catch (error) {
    if (error instanceof RateLimited) {
      return Response.json({ status: 429, retryAfter: error.retryAfterSeconds });
    }
    throw error;
  }
}

Run npm run dev, open /api/diagnose, and reload a few times. remaining should drop by one per reload. If it is already near zero on a quiet site, another caller is spending the same key's budget, which the troubleshooting section covers.

Do not add export const runtime = 'edge' to this file, even though the bring-your-own-app page suggests it for API routes. The directive targets the Next.js edge runtime, which the OpenNext Cloudflare adapter behind Webflow Cloud does not support.

The route has no authentication and spends API calls for anyone who loads it, so delete it once you have your numbers.

3. Read published items from the Content Delivery API

Most 429s on a CMS page come from reads, and reads have a better host. The Content Delivery API serves the same live items from api-cdn.webflow.com, caches each response for up to five minutes, and does not count cached responses against your rate limit. It is read-only and serves published content only, which is exactly what a public page shows.

The liveItemsUrl helper in step 1 already points at that host, so the read path needs no extra configuration. A list call returns at most 100 items, so a larger collection needs one call per page, and the loader in step 4 pages through it. A response header of cf-cache-status: HIT tells you a read came from the cache and cost nothing.

Draft items never appear on this host. If an admin page needs drafts, read /items from api.webflow.com instead, and keep that page behind authentication, because those calls do count against the limit.

4. Cache reads in memory and serve stale content during a 429

Wrap the read path in a small time-to-live (TTL) cache and, when a load fails, return the previous value instead of an error. The Content Delivery API cuts how often reads cost budget; this cache cuts how often the app calls at all, and it keeps the page up if an upstream call fails.

The cache is a Map in module memory. Cloudflare notes that Worker isolates are not necessarily long-lived, so each instance keeps its own copy and a cold instance starts empty. I would still rather show a list that is a minute old than an error page

Create the cache module:

// lib/cache.ts
type Entry<T> = { value: T; expires: number };
const store = new Map<string, Entry<unknown>>();

export async function cached<T>(key: string, ttlMs: number, load: () => Promise<T>): Promise<T> {
  const hit = store.get(key) as Entry<T> | undefined;
  if (hit && hit.expires > Date.now()) return hit.value;

  try {
    const value = await load();
    store.set(key, { value, expires: Date.now() + ttlMs });
    return value;
  } catch (err) {
    if (hit) {
      store.set(key, { value: hit.value, expires: Date.now() + 15_000 });
      return hit.value;
    }
    throw err;
  }
}

When a load fails, the cache stores the last good value for 15 seconds, so the route re-serves it instead of calling the API on every request.

Caching read routes

After that, write the read route to page through the collection inside the cache:

// app/api/items/route.ts
import { cached } from '@/lib/cache';
import { liveItemsUrl, webflowFetch } from '@/lib/webflow';

type Item = { id: string; fieldData: Record<string, unknown> };
type Page = { items: Item[]; pagination: { offset: number; limit: number; total: number } };

async function loadAll(): Promise<Item[]> {
  const items: Item[] = [];
  let offset = 0;
  while (true) {
    const res = await webflowFetch(liveItemsUrl(offset));
    if (!res.ok) throw new Error(`Webflow API ${res.status}`);
    const page = (await res.json()) as Page;
    items.push(...page.items.map(({ id, fieldData }) => ({ id, fieldData })));
    offset += page.pagination.limit;
    if (page.items.length === 0 || offset >= page.pagination.total) return items;
  }
}

export async function GET() {
  try {
    const items = await cached('items', 60_000, loadAll);
    return Response.json({ items });
  } catch {
    return Response.json({ error: 'upstream_unavailable' }, { status: 503 });
  }
}

Webflow Cloud replaces any Cache-Control header a route sets with private, no-cache, so browser caching isn't available here, and you control only the server cache.

Browser code that calls the route must include the mount path, because Webflow Cloud injects the base path at build time and does not rewrite client-side fetch calls.

Handling base path for client calls

Add NEXT_PUBLIC_BASE_PATH to the environment with the mount path as its value; Webflow Cloud does not create it for you.

A client component builds its request URL from that variable:

// app/components/items-list.tsx
'use client';

import { useEffect, useState } from 'react';

type Item = { id: string; fieldData: { name?: string } };

export default function ItemsList() {
  const [items, setItems] = useState<Item[] | null>(null);
  const [failed, setFailed] = useState(false);

  useEffect(() => {
    const base = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
    fetch(`${base}/api/items`)
      .then((r) => {
        if (!r.ok) throw new Error(String(r.status));
        return r.json();
      })
      .then((data: { items: Item[] }) => setItems(data.items))
      .catch(() => setFailed(true));
  }, []);

  if (failed) return <p>Items are unavailable right now.</p>;
  if (!items) return <p>Loading...</p>;
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.fieldData.name ?? item.id}</li>
      ))}
    </ul>
  );
}

Reload the page several times within a minute and watch /api/items in the Network panel. Repeat loads that reach a warm instance return from memory and make no upstream call within the 60-second TTL, and a 503 appears only when an instance has nothing cached and the upstream call fails.

5. Batch bulk writes and protect the sync route with a shared secret

Writes cannot use the delivery host, so they are where the remaining budget goes. The fix is fewer requests, not slower ones.

Webflow's Create Items endpoint, POST /collections/{collection_id}/items/insert, accepts up to 100 items in one request, and one request costs one unit of the per-minute budget. Sending fifty items one at a time costs fifty units; sending them together costs one.

Add the sync route:

// app/api/sync/route.ts
import { insertItemsUrl, RateLimited, webflowFetch } from '@/lib/webflow';

const BATCH = 100;
const MAX_ITEMS = 500;

type NewItem = { fieldData: { name: string; slug: string; [field: string]: unknown } };

function safeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

function isNewItem(value: unknown): value is NewItem {
  const fieldData = (value as NewItem)?.fieldData;
  return typeof fieldData?.name === 'string' && typeof fieldData?.slug === 'string';
}

export async function POST(req: Request) {
  const secret = process.env.SYNC_SECRET;
  if (!secret) return new Response('Server misconfigured', { status: 500 });
  if (!safeEqual(req.headers.get('x-sync-secret') ?? '', secret)) {
    return new Response('Unauthorized', { status: 401 });
  }

  const payload = (await req.json().catch(() => null)) as { items?: unknown[] } | null;
  const items = payload?.items;
  if (!Array.isArray(items) || items.length === 0 || items.length > MAX_ITEMS || !items.every(isNewItem)) {
    return Response.json(
      { error: `Send 1 to ${MAX_ITEMS} items, each with fieldData.name and fieldData.slug` },
      { status: 400 },
    );
  }

  let created = 0;
  for (let start = 0; start < items.length; start += BATCH) {
    const batch = items.slice(start, start + BATCH);
    try {
      const res = await webflowFetch(insertItemsUrl(), {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ items: batch }),
      });
      if (!res.ok) {
        return Response.json({ created, failedAt: start, status: res.status, detail: await res.text() }, { status: 502 });
      }
      created += batch.length;
    } catch (error) {
      if (error instanceof RateLimited) {
        return Response.json(
          { created, failedAt: start, retryAfter: error.retryAfterSeconds },
          { status: 429, headers: { 'Retry-After': String(error.retryAfterSeconds) } },
        );
      }
      throw error;
    }
  }

  return Response.json({ created });
}

The route caps a call at 500 items, five insert requests, which finishes well inside Webflow Cloud's 20-secondrequest timeout. It does not wait out a 429 inside the request, because Webflow's Retry-After of about 60 seconds is longer than the request may live.

Instead, it stops, reports how many items were created and where the failure started, and passes Retry-After on so the caller can resume from failedAt after the wait. Items are created as drafts unless an item sets isDraft: false.

The hand-written safeEqual exits early only on a length mismatch, which reveals the secret's length but not its content, and it runs both locally and on Workers, unlike crypto.subtle.timingSafeEqual.

Testing batch writes

Send a small test payload from a terminal, where items.json holds an items array, and each entry has a fieldData object with at least name and slug:

curl -X POST http://localhost:3000/api/sync \
  -H "x-sync-secret: $SYNC_SECRET" \
  -H "Content-Type: application/json" \
  -d @items.json

A response of {"created": N} matching the number of items you sent means the batch went through in a single request per 100 items.

What causes a 429 fix to fail in Webflow Cloud?

Check the build first, then the shared budget, then the cache. Each entry starts from what you see in the deploy log or the Network panel.

The Webflow Cloud build fails with an unsupported edge runtime error

Cause: Webflow Cloud runs on Cloudflare Workers, an edge platform, and the bring-your-own-app page describes that as deploying "using the Edge runtime." The same page then says to add export const runtime = 'edge' to API routes. That directive is the Next.js edge runtime target, a separate thing, and the OpenNext Cloudflare adapter does not support it.

Fix: Remove the directive from every Route Handler and leave the runtime unset; the routes run on Workers without it. Middleware follows its own rule: middleware.ts works on Workers, while Next.js 16's proxy.ts runs on Node and does not.

The framework customization docs state that "Node.js runtime middleware isn't supported. Only Edge runtime middleware works on the Workers runtime."

The items list returns 404 on the deployed site but works locally

Cause: Webflow Cloud mounts the app at a path and injects that base path at build time, but client-side fetch calls are not rewritten. The bring-your-own-app page says so directly: "Client-side fetch calls must manually include the base path to correctly reach your endpoints." Locally, there is no mount path, so fetch("/api/items") works; deployed, the same call lands outside the app.

Fix: Set NEXT_PUBLIC_BASE_PATH to the mount path in the Webflow Cloud environment, prefix every client-side URL with it as items-list.tsx does, and redeploy so the value is inlined into the client bundle.

429s keep appearing on /api/diagnose while the site has no visitors

Cause: The app is not the only caller. Webflow applies the limit per API key, so an automation or script on someone's machine using the same token can spend the app's budget.

Fix: Give the app its own token so that no other integration can spend its budget. Then find the other callers by checking your automation tools and any scripts that call the Webflow API for this site, and move their bulk jobs to a quiet hour. Reload /api/diagnose and confirm remaining stays near the limit.

429s return in short bursts after a deploy

Cause: The in-memory cache is held per Workers instance, so several cold instances starting within the same minute each make their own first call. Reads through the Content Delivery API mostly absorb this, so bursts that persist usually come from a route still reading api.webflow.com.

Fix: Check that every public read goes through liveItemsUrl. If bursts persist, raise the TTL passed to cached(), for example from 60_000 to 300_000, which matches the delivery host's own cache window. For a cache every instance shares, move it into a Key Value Store binding with a minimum TTL of 60 seconds.

What you can build next with the Webflow API and Webflow Cloud

A search index rebuilt on a schedule is what I'd build next with the same wrapper and cache, since it reads the whole collection through the delivery host and writes nothing back. For a two-way sync with a database you own, the external database guide uses the same paging loop.

For deeper customization beyond what this wrapper and cache cover, Webflow's developer docs include the full Data API reference.

Frequently asked questions

Can a resumed sync create duplicate CMS items?

It can if you resend items that already went through. The route stops at the first failed batch and returns created and failedAt, so resume by sending only the items from failedAt onward. A 429 means Webflow refused that batch outright, so nothing in it was created.

Should the app wait and retry when it gets a 429?

Not inside a visitor's request. Webflow's Retry-After is typically 60 seconds, and Webflow Cloud ends a request after 20, so waiting would time the request out anyway. Serve the cached value to visitors, and let a sync job wait out the Retry-After between calls.

Can I persist the cache to a JSON file with node:fs instead of a Map?

Not on Webflow Cloud. Workers only provide node:fs at a later compatibility date than Webflow Cloud's configuration uses, and even then, its files don't persist between requests. For a cache that survives restarts and is shared by every instance, use a Key Value Store binding.

Is the Webflow Cloud request allowance the same limit as a Data API 429?

No. Webflow Cloud counts requests to your app, and its usage docs note that requests your app makes to other services do not count toward that total. A 429 comes from the Data API's own per-key limit on the calls your app sends to Webflow.

Will the 60-second cache delay CMS edits from showing on the page?

Yes. A published edit can take up to the 60-second TTL in cached() to reach the route, plus up to five minutes in the Content Delivery API's own cache. For faster updates, lower the TTL; the delivery host's window is fixed.


Last Updated
October 19, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.