A signed Webflow webhook and a server-side REST call can share one Next.js app on Webflow Cloud, with no token ever reaching the browser.
If a form comes in, or a CMS item changes, and another system needs to know, Webhooks can help on the push side, where Webflow calls your app. The REST API is the pull side, where your app calls Webflow.
In this guide, we’ll build both in one Next.js app on Webflow Cloud. One POST route checks Webflow's signature and timestamp before it trusts the payload. A GET route reads published collection items with a token the browser never sees.
What do you need to set up webhooks and REST API integrations in Webflow?
You need these 5 prerequisites:
- Webflow site: Any site you can publish, plus its site ID from Site settings.
- Site token: Create it in Site settings under Apps & integrations with the
cms:readandsites:writescopes. The first lets you read collection items, and the second lets you register the webhook through the API. - Next.js 15 and npm: The bring-your-own-app page sets Next.js 15 or higher as the framework floor and states that Webflow Cloud supports only the npm package manager.
- Node.js 22 or later: Webflow Cloud apps build on Node.js 22 or later.
- A collection to read: Its collection ID, from the collection's settings in the CMS.
The webhook secret is the one credential you do not have yet. Webflow returns it when you register the webhook in step 5.
5 steps to set up webhooks and REST API integrations in Webflow Cloud
The build is two Route Handlers in one Next.js app: a POST route that verifies webhook signatures, and a GET route that reads collection items with a server-side token. A client component calls the GET route through the app's mount path.
1. Create the Next.js app with npm
Scaffold the project with the App Router and TypeScript, and use npm for every install from here on. Webflow Cloud builds with npm only, so pnpm and yarn steps will not work there. Pinning create-next-app@15 keeps the scaffold on a version Webflow Cloud runs, and on the release where middleware.ts is still the standard file name.
Run this from the directory where you keep your projects:
npx create-next-app@15 webflow-integrations --typescript --app
cd webflow-integrations
npm run dev
Before the first commit, confirm the project root has a package-lock.json and no pnpm-lock.yaml or yarn.lock, and that node -v reports 22 or later. When npm run dev prints the local URL and the default page loads, the scaffold is working.
Leave next.config alone; Webflow's configuration docs say you don't need to add an adapter, a base path, or an output mode.
2. Store the credentials and IDs as environment variables
Put every value the routes depend on into environment variables, locally in .env.local and on Webflow Cloud in the project environment's settings.
Webflow's environment variables documentation states: "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime." Secrets are redacted from build logs.
The four variables and what each one is for:
- WEBFLOW_SITE_TOKEN: The site token from the prerequisites. Mark it secret, and never give it the
NEXT_PUBLIC_prefix. - WEBFLOW_COLLECTION_ID: The collection the GET route reads.
- WEBFLOW_WEBHOOK_SECRET: The
secretKeyWebflow returns when you register the webhook in step 5. Mark it secret, and leave it empty until then. - NEXT_PUBLIC_BASE_PATH: The app's mount path on Webflow Cloud, such as
/app. Webflow Cloud does not create this variable, and client code needs it because browser fetches are never rewritten.
Set NEXT_PUBLIC_BASE_PATH to an empty string locally and to the real mount path on Webflow Cloud. A restarted npm run dev picks up the local values, and the next deploy picks up the Cloud values.
3. Write the webhook Route Handler and verify the signature
Webflow signs webhooks that are created through the API with two headers. x-webflow-timestamp carries the send time in milliseconds, and x-webflow-signature carries a hex HMAC SHA256 of the timestamp, a colon, and the request body.
The handler reads the raw body, rejects anything older than five minutes, recomputes the signature with Web Crypto, and only then parses JSON.
Create app/api/webhooks/webflow/route.ts:
// app/api/webhooks/webflow/route.ts
import { NextResponse } from 'next/server';
const MAX_AGE_MS = 5 * 60 * 1000;
const encoder = new TextEncoder();
async function hmacHex(secret: string, message: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message));
return Array.from(new Uint8Array(signature))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
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;
}
export async function POST(request: Request) {
const secret = process.env.WEBFLOW_WEBHOOK_SECRET;
if (!secret) {
return NextResponse.json({ error: 'Webhook configuration missing' }, { status: 500 });
}
const rawBody = await request.text();
const signature = request.headers.get('x-webflow-signature') ?? '';
const timestamp = request.headers.get('x-webflow-timestamp') ?? '';
const sentAt = Number(timestamp);
if (!timestamp || !Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > MAX_AGE_MS) {
return NextResponse.json({ error: 'Stale or missing timestamp' }, { status: 401 });
}
const expected = await hmacHex(secret, `${timestamp}:${rawBody}`);
if (!safeEqual(signature, expected)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
let event: { triggerType?: string; payload?: unknown };
try {
event = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
console.log('Webflow webhook accepted', event.triggerType);
return NextResponse.json({ received: true });
}
Webflow's own Node samples sign JSON.stringify(req.body), a re-serialized copy of the parsed body. Signing the raw text, as this handler does, is byte-for-byte equivalent and avoids any differences in key order or spacing.
The comparison is written by hand because crypto.subtle.timingSafeEqual exists on the deployed Workers runtime but not under local next dev.
Leave export const runtime = 'edge' out of this file. "Edge" means two things on Webflow Cloud: the platform, Cloudflare Workers, where every route already runs, and the Next.js runtime target, which the OpenNext Cloudflare adapter does not support.
Send an unsigned request to confirm the handler rejects it:
curl -i -X POST http://localhost:3000/api/webhooks/webflow -H 'Content-Type: application/json' -d '{}'
With a secret set in .env.local, a 401 with Stale or missing timestamp confirms the handler refuses a request that Webflow did not sign.
4. Call the Webflow REST API from a server route
The GET route reads published items with the site token, so whoever inherits the site receives a bundle without the token in it. It calls the Content Delivery API host, api-cdn.webflow.com, which serves published items from a cache, and cached responses do not count against the Data API rate limit.
Create app/api/cms/items/route.ts:
// app/api/cms/items/route.ts
import { NextResponse } from 'next/server';
type Item = { id: string; fieldData: Record<string, unknown> };
export async function GET() {
const token = process.env.WEBFLOW_SITE_TOKEN;
const collectionId = process.env.WEBFLOW_COLLECTION_ID;
if (!token || !collectionId) {
return NextResponse.json({ error: 'API configuration missing' }, { status: 500 });
}
const response = await fetch(
`https://api-cdn.webflow.com/v2/collections/${collectionId}/items/live?limit=100`,
{ headers: { Authorization: `Bearer ${token}`, accept: 'application/json' } }
);
if (!response.ok) {
return NextResponse.json(
{ error: 'Webflow API request failed', status: response.status },
{ status: 502 }
);
}
const data = (await response.json()) as { items: Item[] };
return NextResponse.json({
items: data.items.map(({ id, fieldData }) => ({ id, fieldData })),
});
}
The route returns up to 100 items, the most one list call allows; a larger collection needs offset paging. /api/cms/items is public as written, which suits published content, and it only ever reads the one collection named in the environment.
Unlike a plain Next.js deploy, Webflow Cloud requires browser fetches to include the app's mount path. The bring-your-own-app page is explicit: "Client-side fetch calls must manually include the base path to reach your endpoints correctly." A component that fetches /api/cms/items works on localhost and 404s after deployment.
app/components/ItemList.tsx prefixes the mount path before every fetch:
// app/components/ItemList.tsx
'use client';
import { useEffect, useState } from 'react';
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
type Item = { id: string; fieldData: { name?: string } };
export function ItemList() {
const [items, setItems] = useState<Item[] | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
fetch(`${basePath}/api/cms/items`)
.then((response) => {
if (!response.ok) throw new Error(String(response.status));
return response.json();
})
.then((data: { items: Item[] }) => setItems(data.items))
.catch(() => setFailed(true));
}, []);
if (failed) return <p>Could not load items.</p>;
if (!items) return <p>Loading...</p>;
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.fieldData.name ?? item.id}</li>
))}
</ul>
);
}
Render ItemList on a page and open it locally; the names of the published items in the collection should appear.
5. Deploy to Webflow Cloud and register the webhook through the API
Deploy the app to your Webflow Cloud project and let the build run, then register the webhook through the Data API rather than the dashboard. Webhooks added in Site settings arrive without signature headers so every delivery would fail the check in step 3.
The create webhook endpoint takes a triggerType, such as form_submission or collection_item_changed, and the URL of your route, which is the site domain, then the mount path, then /api/webhooks/webflow.
For an app mounted at /app, export your site ID and token, then register it:
curl -X POST "https://api.webflow.com/v2/sites/$SITE_ID/webhooks" \
-H "Authorization: Bearer $WEBFLOW_SITE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"triggerType":"form_submission","url":"https://www.example.com/app/api/webhooks/webflow"}'
The response includes a secretKey, shown only this once. Save it as WEBFLOW_WEBHOOK_SECRET in the Webflow Cloud environment and redeploy. An app built on an OAuth app instead of a site token signs with the app's client secret, so that is the value to store there.
Trigger the event on the site (e.g., a test form submission), then check the deployed app's logs in the Webflow Cloud project. A Webflow webhook accepted line with the trigger type means the signature matched and the handler is live.
From the same deploy, the page with ItemList should list the collection through the mount path.
What causes Webflow webhooks and REST API integrations to fail on Webflow Cloud?
Most failures come from the signing setup; the rest come from Webflow Cloud's build and routing rules. Check the signature path and the browser request path separately.
A real Webflow delivery returns 401
Cause: The webhook was created in the dashboard, so it carries no signature headers, and the handler answers Stale or missing timestamp. When the headers are present and the answer is Invalid signature, the stored secret is wrong: a site token webhook signs with its own secretKey, while an OAuth app signs with the app's client secret.
A handler that signs the body without the timestamp: prefix, or compares the timestamp in seconds rather than milliseconds, fails the same way.
Fix: Delete any dashboard webhook for the same trigger and register it through the API as in step 5. Store the secretKey from that response and redeploy, then send a new event.
Fix it quickly: Webflow treats any response other than a 200 as a failure, retries up to three more times ten minutes apart, and deactivates a webhook that keeps failing until Webflow support turns it back on.
The deployed app breaks after you follow the edge runtime instruction in the docs
Cause: The bring-your-own-app page still tells you to add the Next.js edge runtime directive to API routes. The same page describes the platform as deploying "using the Edge runtime," so a reader following the docs faithfully adds a directive that ships a broken build. The platform sentence is about Cloudflare Workers; the directive is a separate Next.js feature the adapter cannot honor.
Fix: Delete the line from every route and rebuild. If you need logic that runs before a route, put it in middleware.ts. The framework customization page puts the limit plainly: "Node.js runtime middleware isn't supported.
Only Edge runtime middleware works on the Workers runtime." Do not rename that file to proxy.ts under Next 16, because proxy runs on the Node runtime and cannot run here.
The signature check throws under next dev but works after deploy
Cause: Someone replaced safeEqual with Cloudflare's crypto.subtle.timingSafeEqual. It exists on the deployed Workers runtime and not under local next dev, so the route works in production and throws on a laptop.
Fix: Keep the plain JavaScript safeEqual helper, which XORs every character instead of stopping at the first mismatch and run the same in both places. Test every signature path twice, once against npm run dev and once against the deployed URL.
A browser fetch to /api/cms/items returns 404 after deploy but answers on localhost
Cause: Locally there is no mount path, so a bare /api/cms/items resolves. On the deployed app the route lives at the mount path plus /api/cms/items, and the bare path reaches the Webflow site's own 404 page instead.
Fix: Check that every browser fetch uses the mount-path prefix shown in ItemList, and that NEXT_PUBLIC_BASE_PATH matches the mount path exactly. The network tab on the deployed page should show the mount path ahead of /api.
If it still 404s, confirm the variable was saved before the last build, since Next.js inlines NEXT_PUBLIC_ values into the client bundle at build time.
What you can build next with webhooks and the Webflow REST API
I read triggerType from the verified payload and branch before doing any work, so a form submission and a CMS publish take different paths through the receiver. A collection_item_changed event can tell a search index to reindex one item, and the GET route pattern extends to any collection by adding offset paging.
For background on why the signature check matters, see the webhook security guide, which covers the threats it blocks. For per-trigger payloads and the full list of events, see Webflow's developer docs.
Frequently asked questions
Can MD5 deduplicate repeated webhook deliveries?
A handler can hash the raw body to spot a repeated delivery with crypto.subtle.digest('MD5', ...), which Cloudflare offers on Workers as a non-standard extension. Use it only for deduplication. Cloudflare's own note says "MD5 is considered a weak algorithm. Do not rely upon MD5 for security," so keep it out of access control.
Can the webhook route write a delivery log to disk?
No. Workers only provide node:fs from compatibility date 2025-09-01, which is later than the date in Webflow Cloud's example configuration, and even then its files do not persist between requests. I log with console.log instead; its output appears in the deployed logs that step 5 checks.
Can I build the webhook receiver with Astro or Vite instead of Next.js?
Yes. Webflow Cloud also supports Astro 5 and later, and Vite 6.1 or higher. The signature check ports are unchanged, because it only uses Web Crypto, and the mount-path rule for browser fetches applies in every framework.
Does a webhook secret need to change when a client takes over the site?
At handover, I rotate the site token and re-register the webhook, which issues a new secretKey. Put both new values in the Webflow Cloud environment and redeploy. If the stored secret and the webhook's secret differ, every real delivery returns a 401 with Invalid signature.





