Webhooks
|
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 webhooks as they were on 2026-10-28, the date on which its facts were checked against the
Data API reference and the guide "Working with webhooks" on developers.webflow.com. Webhooks are part of the Data API v2
and are available on every site, including the free Developer workspace sites used by all the examples, because they
need no paid Site plan; individual triggers depend on the feature behind them (the e-commerce triggers fire only on a
site with e-commerce enabled, which needs an Ecommerce plan tier, and the form triggers need a published form). Plans are
named by tier only, never by price. The examples use the JavaScript SDK webflow-api 3.3.4 (Node.js 22 or later),
Express 5, the Python package webflow 2.0.0 and Flask 3, and placeholder tokens and identifiers that must be replaced.
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, Spanish and German (see CMS Concepts and Content Modeling). The garden’s developer wants three things: an email to the visitor-services team whenever somebody submits the "Book a guided tour" form, a cache revalidation of the Next.js reader of the Journal whenever an item is published, and a log of every site publish. All three are webhooks.
What a webhook is and why it exists
A webhook is an HTTP request that Webflow sends to a URL you control when something happens on a site. The alternative is polling: calling the Data API every few minutes and comparing the result with the previous one. Polling spends rate limit (see Data API SDKs, Limits and Errors), reacts late and needs state to detect changes; a webhook spends nothing, arrives within moments and carries the changed data. Webhooks are the building block of every integration that must react: sending form submissions to a CRM, rebuilding a headless site when content is published, mirroring orders to an accounting system, or notifying a chat channel when an editor publishes.
Four kinds of people meet webhooks:
-
The site owner decides which events matter and who may receive them.
-
The content editor triggers the content events by saving, publishing and deleting items.
-
The marketer triggers form events indirectly, through visitors who submit forms.
-
The developer creates the webhooks, writes the receiver, verifies signatures and operates the queue behind it.
Webhook request anatomy
When an event occurs, Webflow sends an HTTP POST request to the registered URL. The request has these properties, as
documented in "Working with webhooks".
| Part | Value |
|---|---|
Method and body |
|
|
|
|
When the webhook was sent. The guide describes it as a Unix epoch time (see the units note in the verification section). |
|
The request signature: a hex-encoded SHA-256 HMAC, covered in detail below. |
Success |
HTTP status |
The endpoint must be reachable over HTTPS with a certificate that validates; a self-signed certificate, an expired one
or a failed negotiation counts as a failed delivery. Because the event is only acknowledged by 200, a receiver that
does slow work before answering will time out and see the same event again. The pattern on this page (verify, enqueue,
answer, process later) follows from that single rule.
Trigger types
The create endpoint accepts a fixed set of triggerType values. They fall into groups.
| Group | triggerType |
Fires when |
|---|---|---|
Forms |
|
A visitor submits a form on the published site. Payload: form name, site ID, submitted data, schema, timestamp, form ID. |
Site and pages |
|
The site is published. Payload: site ID, the published timestamp, the domains published to and the user who published. |
|
A page is created, its metadata (title, SEO, Open Graph settings) is updated, or it is deleted. Payload: site ID, page ID, page title and a timestamp. |
|
E-commerce |
|
A customer places an order. Payload: order ID, status, customer information, shipping and the purchased items. |
|
An order changes (status, comments, fulfilment details). |
|
|
Inventory of an item or SKU changes: item ID, quantity and inventory type. |
|
CMS items |
|
An item is created, including as a draft. Payload: item ID, site ID, Workspace ID, Collection ID, creation date and draft status. |
|
An item is updated. |
|
|
An item is deleted. |
|
|
An item is published (see Publishing and Scheduling). |
|
|
An item is unpublished. |
|
Comments |
|
A comment is created on the site. Payload: thread ID, comment ID, type, author, content, resolution state and mentioned users. |
Branches |
|
A page branch is created, merged or deleted (see Page Branching, Releases and Approvals). |
The guide "Working with webhooks" lists the first fourteen of these in its event table; the create endpoint reference additionally lists the three branch triggers. The per-event pages of the reference document each payload field by field. Treat the table above as the list on 2026-10-28 and check the reference for events added later: new triggers appear in the create endpoint’s enumeration before they appear in every guide.
Payload shapes that matter at Fernbrook
Form submission. The payload carries the form name, the site ID, the submitted values under data, a schema array
describing each field, the submission time, an event ID, the form submission ID, the form element ID and the locale:
| Field | Meaning |
|---|---|
|
The name of the form. This is what the |
|
The site the form belongs to. |
|
An object of the submitted values, keyed by the field name set in the Designer. This is why every form field needs a unique, stable name: the names become the payload keys. |
|
One entry per field: |
|
The submission time as an ISO 8601 string. |
|
The event ID. |
|
The ID of the form submission. |
|
The unique ID of the Form element; may be null. |
|
The locale the form was submitted from. It is null for submissions on the primary locale and on sites without localization. |
Collection item published. The payload contains the item identity (id, siteId, workspaceId, collectionId), the
CMS locale (cmsLocaleId, which can be null), the timestamps lastPublished, lastUpdated and createdOn, the flags
isArchived and isDraft, and fieldData with at least the required name and slug. The reference description of
id on the published page reuses the word "unpublished", a wording slip from the neighbouring event; the field is the
ID of the item that was published.
An abridged example of a Fernbrook submission, with invented values:
{
"triggerType": "form_submission",
"payload": {
"name": "Book a guided tour",
"siteId": "REPLACE_WITH_SITE_ID",
"data": {
"Full name": "Marta Ibarra",
"Email": "marta@example.com",
"Visit date": "2026-11-14",
"Group size": "6",
"Language": "Spanish"
},
"schema": [
{ "fieldName": "Full name", "fieldType": "FormTextInput", "fieldElementId": "REPLACE_WITH_ELEMENT_ID" },
{ "fieldName": "Email", "fieldType": "FormTextInput", "fieldElementId": "REPLACE_WITH_ELEMENT_ID" }
],
"submittedAt": "2026-10-28T09:12:44.117Z",
"id": "REPLACE_WITH_EVENT_ID",
"formId": "REPLACE_WITH_FORM_SUBMISSION_ID",
"formElementId": "REPLACE_WITH_FORM_ELEMENT_ID",
"localeId": null
}
}
An abridged collection_item_published payload for a Journal entry:
{
"triggerType": "collection_item_published",
"payload": {
"id": "REPLACE_WITH_ITEM_ID",
"siteId": "REPLACE_WITH_SITE_ID",
"workspaceId": "REPLACE_WITH_WORKSPACE_ID",
"collectionId": "REPLACE_WITH_JOURNAL_COLLECTION_ID",
"cmsLocaleId": null,
"lastPublished": "2026-10-28T08:00:03.000Z",
"lastUpdated": "2026-10-28T07:58:40.000Z",
"createdOn": "2026-10-20T10:15:00.000Z",
"isArchived": false,
"isDraft": false,
"fieldData": {
"name": "Why the cloud forest blooms in November",
"slug": "cloud-forest-november"
}
}
}
Two ways to create a webhook
There are two creation paths, and they differ in the one respect that matters for security: whether the request is signed.
| Path | Created by | Signing |
|---|---|---|
API webhook (this page’s main subject) |
A |
Signed, with |
Dashboard webhook |
The site’s settings in the Webflow dashboard, without code |
Not signed: the requests do not carry the timestamp and signature headers, so they cannot be validated |
The guide states this explicitly for dashboard webhooks: "they do not include the x-webflow-timestamp and
x-webflow-signature headers, so their requests cannot be signature-validated." If a dashboard webhook is the only
option, protect the receiver with a secret path segment or an IP-independent check on the payload (for example
confirming the form or item against the Data API before acting) and treat its input as untrusted. Whenever a code path can
use an API webhook, prefer it.
Create a webhook with the API
The endpoint is POST https://api.webflow.com/v2/sites/{site_id}/webhooks. It needs the sites:write scope (see
Data API Authentication and Scopes). The body names the
triggerType and the destination url; filter is optional and valid only for form_submission. A successful call
returns 201 with id, triggerType, url, workspaceId, siteId, filter, lastTriggered and createdOn.
export WEBFLOW_API_TOKEN="REPLACE_WITH_TOKEN"
export SITE_ID="REPLACE_WITH_SITE_ID"
curl -sS -X POST "https://api.webflow.com/v2/sites/$SITE_ID/webhooks" \
-H "Authorization: Bearer $WEBFLOW_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"triggerType": "form_submission",
"url": "https://hooks.fernbrook-gardens.example/webflow/forms",
"filter": { "name": "Book a guided tour" }
}'
Source: Data API: Create Webhook
The same call with the JavaScript SDK, registering all the Fernbrook webhooks idempotently (the script lists the existing registrations first, so running it twice does not create duplicates against the 75-per-trigger limit):
import { WebflowClient } from "webflow-api";
const webflow = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
const siteId = process.env.WEBFLOW_SITE_ID ?? "REPLACE_WITH_SITE_ID";
const base = "https://hooks.fernbrook-gardens.example/webflow";
const wanted = [
{ triggerType: "form_submission", url: `${base}/forms`, filter: { name: "Book a guided tour" } },
{ triggerType: "collection_item_published", url: `${base}/items` },
{ triggerType: "site_publish", url: `${base}/site` },
] as const;
const existing = await webflow.webhooks.list(siteId);
const have = new Set((existing.webhooks ?? []).map((w) => `${w.triggerType}|${w.url}`));
for (const hook of wanted) {
if (have.has(`${hook.triggerType}|${hook.url}`)) {
console.log(`already registered: ${hook.triggerType}`);
continue;
}
const created = await webflow.webhooks.create(siteId, hook);
console.log(`created ${created.triggerType} -> ${created.id}`);
}
Source: Data API: Create Webhook
The Python SDK equivalent:
import os
from webflow.client import Webflow
client = Webflow(access_token=os.environ["WEBFLOW_API_TOKEN"])
site_id = os.environ.get("WEBFLOW_SITE_ID", "REPLACE_WITH_SITE_ID")
base = "https://hooks.fernbrook-gardens.example/webflow"
wanted = [
{"trigger_type": "form_submission", "url": f"{base}/forms", "filter": {"name": "Book a guided tour"}},
{"trigger_type": "collection_item_published", "url": f"{base}/items"},
{"trigger_type": "site_publish", "url": f"{base}/site"},
]
existing = client.webhooks.list(site_id=site_id)
have = {(w.trigger_type, w.url) for w in (existing.webhooks or [])}
for hook in wanted:
if (hook["trigger_type"], hook["url"]) in have:
print("already registered:", hook["trigger_type"])
continue
created = client.webhooks.create(site_id=site_id, **hook)
print("created", created.trigger_type, created.id)
Source: Webflow Python SDK on GitHub
The SDKs generate their method names and parameter casing from the OpenAPI specification, so a minor release can rename a parameter. If a call fails with an unexpected-argument error, check the pinned SDK’s type definitions or fall back to the raw HTTP form above, which is the stable contract.
The form filter
A site often has several forms (the garden has "Book a guided tour", "Newsletter" and "Volunteer application"). Without a
filter, a form_submission webhook fires for every form on the site. The optional filter object holds the form name
and restricts the webhook to that one form. Two consequences follow:
-
To handle three forms differently, create three webhooks with three filters and, if desired, three URLs, instead of one unfiltered webhook that branches on
payload.nameinside the receiver. -
The match is on the form name, so renaming a form in the Designer silently detaches the filter from it. After a rename, delete and recreate the webhook.
The filter exists only for form_submission; sending it with another trigger type is a client error.
List, get and delete
| Operation | Endpoint | Scope | Notes |
|---|---|---|---|
List |
|
|
Returns the webhooks registered by apps for the site, with |
Get |
|
|
Returns one registration, including |
Delete |
|
|
Returns |
The reference lists sites:read for deletion as well as for reading, an unusual pairing next to sites:write for
creation; use the scope the page states for the version you call, and expect a 401 if the token lacks it. The listing
describes its result as the webhooks "registered for a given site" by apps, so a webhook created in the dashboard may not
appear in the API listing. Keep a record of the dashboard webhooks in your runbook instead.
# list
curl -sS "https://api.webflow.com/v2/sites/$SITE_ID/webhooks" \
-H "Authorization: Bearer $WEBFLOW_API_TOKEN"
# delete one
curl -sS -X DELETE "https://api.webflow.com/v2/webhooks/REPLACE_WITH_WEBHOOK_ID" \
-H "Authorization: Bearer $WEBFLOW_API_TOKEN"
Source: Data API: List Webhooks
Limits
| Criterion | Limit |
|---|---|
Webhooks per |
75 |
Retry attempts after a failed delivery |
3 |
Interval between retries |
10 minutes |
Seventy-five registrations per trigger type is far above what one site needs, but it is a ceiling that an app installed on many sites does not reach (the limit is per site), while a script that registers a webhook on every run reaches it quickly. The idempotent registration script above avoids that failure.
Delivery, retries and deactivation
The delivery rules are short and strict.
-
Webflow sends the
POST. -
If the receiver answers
200, the delivery is complete. -
Any other result is a failure: a non-200 status, a redirect (the
301and302responses that a trailing slash or a switch from HTTP to HTTPS produces), a TLS negotiation or validation failure, or a timeout. -
A failed delivery is retried up to three more times, at ten-minute intervals. After the third retry the request is marked failed.
-
If failures keep happening, Webflow deactivates the webhook and sends an email. Reactivating it, or asking about a deactivated webhook, goes through Webflow support: there is no API call to re-enable a webhook.
Consequences for design:
-
Answer fast. The timeout is not published as a number; the guide only says that Webflow expects a fast response. Verify, enqueue and answer in milliseconds, and do the work elsewhere.
-
Return
200, not201,202or204. The guide asks for HTTP200. A receiver that follows the usual REST habit of answering202 Acceptedfor queued work is, by the documented rule, reporting a failure, and will be retried. -
Never redirect. Register the exact final URL, including the scheme and any trailing slash, because a redirect fails the delivery.
-
Do not answer
200for a bad signature. A400for a failed verification (the status the guide’s samples use) triggers retries of a request that will never verify, which is harmless but noisy; a200would hide a configuration error. The retries are a feature for transient failures, so keep the endpoint’s real failures visible. -
Plan for duplicates. A retry after a timeout can deliver an event that your receiver already processed. At-least-once delivery is the contract, so handlers must be idempotent.
-
Plan for missed events. Three retries over thirty minutes do not cover a day-long outage, and a deactivated webhook delivers nothing. Run a periodic reconciliation (for example listing items modified since the last run) as the safety net for events that never arrived, as the sync pattern in Data API: CMS does.
-
Monitor. The
lastTriggeredfield of the Get call shows when a webhook last fired; alert when a busy webhook has been silent for longer than usual.
Source: Data API: Working with webhooks
Verifying signatures
Anybody who learns the URL of a receiver can send it a forged POST. Without verification, a forged form_submission can
fill the CRM with invented leads and a forged collection_item_published can make the site rebuild on demand. Signature
verification proves two things: the request was produced by Webflow (only Webflow and the owner of the secret can compute
the signature) and it is recent (the timestamp is part of the signed message, so an old capture cannot be replayed).
The algorithm
Webflow sends two headers:
-
x-webflow-timestamp: the send time. -
x-webflow-signature: the SHA-256 HMAC, hex-encoded.
The message that is signed is the timestamp, a colon and the raw request body, in the Python form from the guide:
f"{request_timestamp}:{request_body_json}". The key is the webhook’s secret (next section). The receiver recomputes the
HMAC and takes three steps, in this order:
-
Compute HMAC-SHA256 of the message with the secret and hex-encode it.
-
Compare it with the
x-webflow-signatureheader using a constant-time function:crypto.timingSafeEqualin Node.js andhmac.compare_digestin Python. An ordinary string comparison leaks, through its running time, how many leading characters matched. -
Check freshness: reject the request if the timestamp is older than five minutes. The guide’s code implements this as 300000 milliseconds.
Which secret signs which webhook
| How the webhook was created | Signing key |
|---|---|
Through a site token (a webhook created with the site token’s API access) after 2025-04-14 |
The webhook’s own secret, returned as |
Through an OAuth app (Data Client App) |
The app’s client secret. There is no per-webhook secret. |
In the Webflow dashboard |
None. The requests are not signed. |
Two practical rules follow. First, the creation response is the only moment at which a site-token webhook’s secretKey is
shown by the documented API, so the creation script must write it into the secret store immediately. Second, an app that
serves many sites uses one signing key (its client secret) for every webhook of every installation, so a leaked client
secret compromises all of them; rotate it in the app’s settings and redeploy the receiver.
The secretKey also tells you something at registration time: a response without it means the webhook was created through
an OAuth app and will be signed with the client secret. Branch on that in provisioning scripts instead of hard-coding the
expectation.
The raw body is the message
The signature covers the bytes Webflow sent. The commonest verification bug is therefore parsing the JSON first and signing
JSON.stringify of the result: key order, whitespace and Unicode escapes can differ from the original bytes, and the
digests no longer match. Capture the raw body before any parser touches it:
-
In Express, mount
express.raw({ type: "application/json" })on the webhook route, or pass averifycallback toexpress.jsonthat storesreq.rawBody. Do not putexpress.json()in front of the route. -
In Flask, call
request.get_data()(bytes) beforerequest.get_json(). -
Behind a reverse proxy or API gateway, make sure the body is forwarded unmodified (no re-encoding, no compression rewriting).
Timestamp units
The guide describes x-webflow-timestamp as Unix epoch time, while its sample code compares the value in milliseconds
against a 300000 ms window. The receivers below accept both: a numeric value above one trillion is treated as milliseconds,
anything else as seconds. Log the first few headers after registering a webhook and keep the normalization, so that a
future change of unit does not take the receiver down. The raw header text, exactly as received, is what goes into the
signed message; never reformat it.
Use the SDK when you can
The JavaScript SDK ships a verifier: webflowClient.webhooks.verifySignature takes the request headers, the stringified
body and the secret, and returns a boolean. It is the recommended method in the guide, and the manual implementation
below shows exactly what it does, which matters when the receiver is not written in JavaScript.
import { WebflowClient } from "webflow-api";
const webflow = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
// rawBody is the exact string received; headers is the request header object
const ok = await webflow.webhooks.verifySignature({
headers,
body: rawBody,
secret: process.env.WEBFLOW_WEBHOOK_SECRET!,
});
Source: Data API: Working with webhooks
The exact option names of the helper have changed between SDK majors; the call above is the shape in the guide, and the manual verifier in the next section has no SDK dependency, which makes it the safer choice for a receiver that must not change when the SDK is upgraded.
A Node.js receiver
The following receiver is complete and runnable with Node.js 22 or later. It verifies the signature manually, rejects stale requests, deduplicates events, answers immediately and processes the event after the response through an in-memory queue. The queue is deliberately minimal; the production section below replaces it.
{
"name": "fernbrook-webhooks",
"private": true,
"type": "module",
"engines": { "node": ">=22.13" },
"dependencies": {
"express": "5.3.0",
"webflow-api": "3.3.4"
}
}
Source: npm: webflow-api
// server.mjs
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.WEBFLOW_WEBHOOK_SECRET; // per-webhook secret, or the app client secret
const MAX_AGE_MS = 5 * 60 * 1000;
if (!SECRET) throw new Error("WEBFLOW_WEBHOOK_SECRET is not set");
/** Returns true when the request was signed by Webflow and is recent. */
function verify(headers, rawBody, now = Date.now()) {
const timestamp = headers["x-webflow-timestamp"];
const signature = headers["x-webflow-signature"];
if (typeof timestamp !== "string" || typeof signature !== "string") return false;
const sent = Number(timestamp);
if (!Number.isFinite(sent)) return false;
const sentMs = sent > 1e12 ? sent : sent * 1000; // accept seconds or milliseconds
if (Math.abs(now - sentMs) > MAX_AGE_MS) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}:`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// ---- idempotency and queue (in memory; see the production section) ----
const seen = new Map(); // key -> expiry
const TTL_MS = 24 * 60 * 60 * 1000;
const queue = [];
let draining = false;
function alreadySeen(key) {
const now = Date.now();
for (const [k, exp] of seen) if (exp < now) seen.delete(k);
if (seen.has(key)) return true;
seen.set(key, now + TTL_MS);
return false;
}
function enqueue(job) {
queue.push(job);
if (!draining) void drain();
}
async function drain() {
draining = true;
while (queue.length > 0) {
const job = queue.shift();
try {
await handle(job);
} catch (err) {
console.error("handler failed", job.key, err);
seen.delete(job.key); // allow a Webflow retry to be processed again
}
}
draining = false;
}
// ---- Fernbrook handlers ----
async function handleFormSubmission(payload) {
const { name, data, submittedAt, localeId } = payload;
if (name !== "Book a guided tour") return;
const visitor = {
fullName: data["Full name"],
email: data["Email"],
visitDate: data["Visit date"],
groupSize: Number(data["Group size"] ?? 1),
language: localeId ? "non-default locale" : "English",
};
// Replace with your mail or CRM client. Never log the full payload: it holds personal data.
console.log("tour request", { visitDate: visitor.visitDate, groupSize: visitor.groupSize, submittedAt });
}
async function handleItemPublished(payload) {
const { collectionId, id, fieldData } = payload;
if (collectionId !== process.env.JOURNAL_COLLECTION_ID) return;
const res = await fetch(process.env.REVALIDATE_URL, {
method: "POST",
headers: { "content-type": "application/json", "x-revalidate-token": process.env.REVALIDATE_TOKEN },
body: JSON.stringify({ path: `/journal/${fieldData.slug}`, itemId: id }),
});
if (!res.ok) throw new Error(`revalidate failed: ${res.status}`);
}
async function handleSitePublish(payload) {
console.log("site published", payload.publishedOn ?? "", payload.domains ?? []);
}
async function handle({ triggerType, payload }) {
switch (triggerType) {
case "form_submission":
return handleFormSubmission(payload);
case "collection_item_published":
return handleItemPublished(payload);
case "site_publish":
return handleSitePublish(payload);
default:
console.log("ignored trigger", triggerType);
}
}
// ---- HTTP ----
const app = express();
app.post("/webflow/:kind", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
const raw = req.body; // Buffer, because of express.raw
if (!Buffer.isBuffer(raw) || !verify(req.headers, raw)) {
return res.status(400).send("invalid signature");
}
let event;
try {
event = JSON.parse(raw.toString("utf8"));
} catch {
return res.status(400).send("invalid json");
}
const p = event.payload ?? {};
const key = [
event.triggerType,
p.id ?? p.formId ?? "",
p.lastPublished ?? p.lastUpdated ?? p.submittedAt ?? "",
].join("|");
if (!alreadySeen(key)) enqueue({ key, ...event });
return res.status(200).send("ok"); // exactly 200
});
app.get("/healthz", (_req, res) => res.status(200).send("ok"));
const port = Number(process.env.PORT ?? 8080);
app.listen(port, () => console.log(`listening on ${port}`));
Source: Data API: Working with webhooks
Points worth noting in this receiver:
-
express.rawis attached to the route, soreq.bodyis aBufferof the exact bytes. The signature is computed overtimestamp, a colon and those bytes, without ever decoding them to a string. -
The freshness window is symmetrical (
Math.abs), which also rejects a timestamp from the future produced by a replaying attacker or a skewed clock. Keep the server clock synchronized with NTP. -
timingSafeEqualthrows when the buffers differ in length, so the length check comes first. -
The idempotency key combines the trigger, an identifier and the event time. For a form submission,
formIdis the submission ID; for an item event,idis the item ID andlastPublisheddistinguishes the publishes of the same item. -
A failed handler removes its key from the
seenmap, so that a retry from Webflow, or a manual replay, is processed. -
The handler logs counts and dates, never the e-mail address or the free-text answers: form payloads are personal data.
Run it:
npm install
export WEBFLOW_WEBHOOK_SECRET="REPLACE_WITH_SECRET"
export JOURNAL_COLLECTION_ID="REPLACE_WITH_JOURNAL_COLLECTION_ID"
export REVALIDATE_URL="https://www.fernbrook-gardens.example/api/revalidate"
export REVALIDATE_TOKEN="REPLACE_WITH_TOKEN"
node server.mjs
During development, expose the port through a tunnelling tool or a request-inspection service. The guide’s own tutorial uses an inspection service such as webhook.site to see the headers and body of a first delivery.
A Python receiver
The Flask receiver below does the same work. It reads the raw bytes, verifies, deduplicates and hands the event to a background worker through a standard-library queue.
flask==3.1.0
webflow==2.0.0
gunicorn==23.0.0
Source: PyPI: webflow
# app.py
import hashlib
import hmac
import json
import os
import queue
import threading
import time
from flask import Flask, request
SECRET = os.environ["WEBFLOW_WEBHOOK_SECRET"].encode("utf-8")
MAX_AGE_SECONDS = 5 * 60
TTL_SECONDS = 24 * 60 * 60
app = Flask(__name__)
jobs: "queue.Queue[dict]" = queue.Queue()
seen: dict[str, float] = {}
seen_lock = threading.Lock()
def verify(timestamp: str | None, signature: str | None, raw_body: bytes) -> bool:
"""True when the request was signed by Webflow and is recent."""
if not timestamp or not signature:
return False
try:
sent = float(timestamp)
except ValueError:
return False
sent_seconds = sent / 1000 if sent > 1e12 else sent # accept seconds or milliseconds
if abs(time.time() - sent_seconds) > MAX_AGE_SECONDS:
return False
message = timestamp.encode("utf-8") + b":" + raw_body
digest = hmac.new(SECRET, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(digest, signature)
def already_seen(key: str) -> bool:
now = time.time()
with seen_lock:
for k in [k for k, exp in seen.items() if exp < now]:
del seen[k]
if key in seen:
return True
seen[key] = now + TTL_SECONDS
return False
def forget(key: str) -> None:
with seen_lock:
seen.pop(key, None)
def handle_form_submission(payload: dict) -> None:
if payload.get("name") != "Book a guided tour":
return
data = payload.get("data", {})
# Replace with your mail or CRM client. Do not log the whole payload: it is personal data.
print("tour request", data.get("Visit date"), data.get("Group size"), payload.get("submittedAt"))
def handle_item_published(payload: dict) -> None:
import urllib.request
if payload.get("collectionId") != os.environ["JOURNAL_COLLECTION_ID"]:
return
slug = payload.get("fieldData", {}).get("slug", "")
body = json.dumps({"path": f"/journal/{slug}", "itemId": payload.get("id")}).encode("utf-8")
req = urllib.request.Request(
os.environ["REVALIDATE_URL"],
data=body,
method="POST",
headers={
"content-type": "application/json",
"x-revalidate-token": os.environ["REVALIDATE_TOKEN"],
},
)
with urllib.request.urlopen(req, timeout=10) as res:
if res.status >= 300:
raise RuntimeError(f"revalidate failed: {res.status}")
def handle_site_publish(payload: dict) -> None:
print("site published", payload.get("publishedOn"), payload.get("domains"))
HANDLERS = {
"form_submission": handle_form_submission,
"collection_item_published": handle_item_published,
"site_publish": handle_site_publish,
}
def worker() -> None:
while True:
job = jobs.get()
try:
handler = HANDLERS.get(job["triggerType"])
if handler:
handler(job.get("payload", {}))
else:
print("ignored trigger", job["triggerType"])
except Exception as exc: # noqa: BLE001
print("handler failed", job["key"], exc)
forget(job["key"]) # let a retry be processed
finally:
jobs.task_done()
threading.Thread(target=worker, daemon=True).start()
@app.post("/webflow/<kind>")
def webflow_hook(kind: str):
raw = request.get_data() # bytes, before any JSON parsing
if not verify(
request.headers.get("x-webflow-timestamp"),
request.headers.get("x-webflow-signature"),
raw,
):
return "invalid signature", 400
try:
event = json.loads(raw)
except json.JSONDecodeError:
return "invalid json", 400
p = event.get("payload", {}) or {}
key = "|".join(
[
str(event.get("triggerType", "")),
str(p.get("id") or p.get("formId") or ""),
str(p.get("lastPublished") or p.get("lastUpdated") or p.get("submittedAt") or ""),
]
)
if not already_seen(key):
jobs.put({"key": key, **event})
return "ok", 200
@app.get("/healthz")
def healthz():
return "ok", 200
Source: Data API: Working with webhooks
Run it with one worker process so the in-memory structures are shared; the production section explains why that is a limit:
pip install -r requirements.txt
export WEBFLOW_WEBHOOK_SECRET="REPLACE_WITH_SECRET"
export JOURNAL_COLLECTION_ID="REPLACE_WITH_JOURNAL_COLLECTION_ID"
export REVALIDATE_URL="https://www.fernbrook-gardens.example/api/revalidate"
export REVALIDATE_TOKEN="REPLACE_WITH_TOKEN"
gunicorn --workers 1 --threads 4 --bind 0.0.0.0:8080 app:app
Source: Data API: Working with webhooks
A test for the verifier
A verifier is security code, so it deserves a test that signs a message the way Webflow does and checks that a tampered body, a wrong secret and an old timestamp are all rejected.
# test_verify.py
import hashlib
import hmac
import time
import app as receiver # the module above, with WEBFLOW_WEBHOOK_SECRET set to "test-secret"
def sign(secret: bytes, timestamp: str, body: bytes) -> str:
return hmac.new(secret, timestamp.encode() + b":" + body, hashlib.sha256).hexdigest()
def test_valid_request():
ts = str(int(time.time() * 1000))
body = b'{"triggerType":"site_publish","payload":{}}'
assert receiver.verify(ts, sign(b"test-secret", ts, body), body)
def test_tampered_body_is_rejected():
ts = str(int(time.time() * 1000))
sig = sign(b"test-secret", ts, b'{"a":1}')
assert not receiver.verify(ts, sig, b'{"a":2}')
def test_wrong_secret_is_rejected():
ts = str(int(time.time() * 1000))
body = b"{}"
assert not receiver.verify(ts, sign(b"other", ts, body), body)
def test_stale_timestamp_is_rejected():
ts = str(int((time.time() - 600) * 1000))
body = b"{}"
assert not receiver.verify(ts, sign(b"test-secret", ts, body), body)
Source: Data API: Working with webhooks
The Fernbrook handlers
Form submission: the guided-tour booking
The "Book a guided tour" form has the fields Full name, Email, Visit date, Group size and Language. The handler in both receivers does the following, and a production version adds the final step:
-
Ignore any other form (the filter normally prevents this; the check protects against a mis-registered webhook).
-
Validate the values: the e-mail looks like an e-mail, the group size is a positive integer below the garden’s limit and the date is in the future. The receiver must not trust the payload’s shape any more than it trusts a public form.
-
Create or update the visitor record in the booking system, keyed by the submission ID so a repeated event does not book twice.
-
Send the visitor a confirmation in the right language. The
localeIdfield tells which locale the form was submitted from; map the locale IDs of the Spanish and German locales (listed by the Sites API) to the matching mail template. -
Later: file uploads arrive as URLs in
dataforFormFileUploadInputfields; download and scan them before attaching them to anything.
Form submissions are also stored by Webflow and can be read with the forms endpoints (see Data API: Assets, Forms and Custom Code); the webhook is the push channel, the API is the safety net.
Item published: revalidating the Journal
The Next.js reader of the Journal (see
Content Delivery API and Headless) caches pages. The
collection_item_published handler checks that the item belongs to the Journal Collection, builds the path from
fieldData.slug and calls the reader’s revalidation endpoint with a shared token. Three details matter:
-
A publish of a localized item arrives with
cmsLocaleIdset. Map it to the locale prefix (/es/journal/{slug},/de/journal/{slug}) before revalidating, or the Spanish and German pages keep serving stale content. -
Unpublishing and deleting are separate triggers (
collection_item_unpublished,collection_item_deleted). Register them too, or removed articles stay in the reader’s cache until its time-to-live expires. -
Item events and site publishes are separate triggers. Listen to
site_publishas well, and revalidate the index pages when the site itself is published.
Idempotency and queueing in production
The in-memory structures in the samples are right for learning and wrong for production, for reasons that follow directly from the delivery contract.
-
Process restarts lose the
seenmap and the queue. An event acknowledged with200and then lost with the process is never retried, because Webflow considers it delivered. Persist before acknowledging. -
More than one instance means more than one
seenmap. A retry can reach a different instance than the original.
The robust shape is: verify, then write the raw event and its key to durable storage in one atomic operation, then answer
200.
| Concern | Approach |
|---|---|
Durable queue |
A message queue (a managed queue service, a Redis-backed job library or a database table) that the receiver writes to before answering. Keep the write under a few milliseconds. |
Idempotency key |
A unique constraint on the key from the samples. An insert that violates the constraint means a duplicate: still answer |
Worker retries |
Retry failed jobs with exponential backoff and a cap, and a dead-letter store for jobs that exhaust it. This is independent of Webflow’s own retries. |
Ordering |
Webhooks are not guaranteed to arrive in order. Use |
Payload trust |
For decisions with consequences (publishing, billing), treat the payload as a notification and fetch the authoritative object with the Data API. |
Replay |
Keep raw events for a few days so that a bug fix can be replayed. |
A minimal database table for the idempotency key and the job state:
CREATE TABLE webhook_events (
key TEXT PRIMARY KEY,
trigger TEXT NOT NULL,
body JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0
);
// inside the route, replacing alreadySeen and enqueue
const result = await db.query(
"INSERT INTO webhook_events (key, trigger, body) VALUES ($1, $2, $3) ON CONFLICT (key) DO NOTHING",
[key, event.triggerType, event],
);
// result.rowCount is 0 for a duplicate; either way answer 200
return res.status(200).send("ok");
Source: Data API: Working with webhooks
If the receiver runs on Webflow Cloud, on a serverless platform or on an edge worker, the same rules apply with the platform’s queue and database in place of these (see Webflow Cloud); the verification code is unchanged because it uses only HMAC and a clock.
Security checklist
-
Verify every request of an API webhook, and reject without a valid signature and a fresh timestamp.
-
Sign over the raw bytes, never over re-serialized JSON.
-
Compare in constant time and never log the secret or the computed digest.
-
Keep the secret in a secret store, not in the repository or the front end. Rotate the OAuth client secret if it leaks, and delete and recreate a site-token webhook to obtain a new
secretKey. -
Serve HTTPS only with a valid certificate and no redirects.
-
Limit the body size (one megabyte in the samples) and parse only after verification.
-
Treat all fields as untrusted input. Form values come from the public; escape them before putting them in e-mail HTML, SQL or a spreadsheet formula.
-
Minimize personal data in logs. Submissions contain names and e-mail addresses (see Forms).
-
Do not use dashboard webhooks for anything sensitive, because they cannot be verified.
-
Return the same response for every rejection so the endpoint does not reveal which check failed.
Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
Every request fails verification |
The body was parsed before verification; the secret is the wrong one (per-webhook secret versus OAuth client secret); a proxy re-encoded the body. |
Verification passes in tests, fails in production |
The timestamp unit or the clock: check the header’s unit, and synchronize the server clock. |
Webhook stops firing |
It was deactivated after repeated failures. Check the e-mail, fix the receiver, and contact Webflow support to reactivate. |
The same event arrives twice |
A retry after a slow or non-200 response. Deduplicate; answer faster. |
Retries never stop |
The endpoint answers |
No |
The webhook was created through an OAuth app (signed with the client secret) or in the dashboard (unsigned). |
Form webhook never fires |
The filter name no longer matches the form’s name, or the form was not published, or the form field names are not unique. |
Item webhook fires more often than expected |
Compare |
Localized publish does not refresh the right page |
|
|
The token reached the rate limit; honour |
Change history and related pages
-
Webhooks created with a site token after 2025-04-14 have their own secret key.
-
The branch triggers (
branch_created,branch_merged,branch_deleted) appear in the create endpoint’s enumeration in the 2026 reference, next to the comment trigger. -
The Data API v1 is retired; all endpoints on this page are v2 (see Product Changes and Deprecations).
Related pages in this section:
-
Data API Authentication and Scopes for the tokens and the
sites:read,sites:writeandforms:readscopes. -
Data API: CMS for the reconciliation sync.
-
Forms for form design and spam protection.
-
Content Delivery API and Headless for the revalidation target.
-
Testing, CI and DevOps for testing receivers in a pipeline.