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.

sequenceDiagram autonumber participant V as Visitor or editor participant W as Webflow participant R as Fernbrook receiver participant Q as Queue participant H as Worker V->>W: Submits a form or publishes an item W->>W: Builds the JSON body and signs it W->>R: POST with x-webflow-timestamp and x-webflow-signature R->>R: HMAC-SHA256 over timestamp, colon and raw body R->>R: Compare in constant time, check age below 5 minutes alt signature or age invalid R-->>W: 400 Bad Request Note over W: Non-200 is a failure and is retried else valid R->>Q: Enqueue event with idempotency key R-->>W: 200 OK within the timeout Q->>H: Deliver event H->>H: Skip when the key was seen, otherwise handle end opt receiver answered non-200, redirected, timed out or had a TLS error W->>R: Retry up to 3 more times, 10 minutes apart end

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

POST with a JSON body. The body is an object with a triggerType string and a payload object whose shape depends on the trigger.

Content-Type

application/json

x-webflow-timestamp

When the webhook was sent. The guide describes it as a Unix epoch time (see the units note in the verification section).

x-webflow-signature

The request signature: a hex-encoded SHA-256 HMAC, covered in detail below.

Success

HTTP status 200 and nothing else. Any other outcome is a failure.

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

form_submission

A visitor submits a form on the published site. Payload: form name, site ID, submitted data, schema, timestamp, form ID.

Site and pages

site_publish

The site is published. Payload: site ID, the published timestamp, the domains published to and the user who published.

page_created, page_metadata_updated, page_deleted

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

ecomm_new_order

A customer places an order. Payload: order ID, status, customer information, shipping and the purchased items.

ecomm_order_changed

An order changes (status, comments, fulfilment details).

ecomm_inventory_changed

Inventory of an item or SKU changes: item ID, quantity and inventory type.

CMS items

collection_item_created

An item is created, including as a draft. Payload: item ID, site ID, Workspace ID, Collection ID, creation date and draft status.

collection_item_changed

An item is updated.

collection_item_deleted

An item is deleted.

collection_item_published

An item is published (see Publishing and Scheduling).

collection_item_unpublished

An item is unpublished.

Comments

comment_created

A comment is created on the site. Payload: thread ID, comment ID, type, author, content, resolution state and mentioned users.

Branches

branch_created, branch_merged, branch_deleted

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

name

The name of the form. This is what the filter of a form webhook matches.

siteId

The site the form belongs to.

data

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.

schema

One entry per field: fieldName, fieldType (FormTextInput, FormTextarea, FormCheckboxInput, FormRadioInput or FormFileUploadInput) and fieldElementId.

submittedAt

The submission time as an ISO 8601 string.

id

The event ID.

formId

The ID of the form submission.

formElementId

The unique ID of the Form element; may be null.

localeId

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 POST to /v2/sites/{site_id}/webhooks with a site token or the access token of a Data Client App

Signed, with x-webflow-timestamp and x-webflow-signature

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" }
  }'

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}`);
}

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)

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.name inside 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

GET /v2/sites/{site_id}/webhooks

sites:read

Returns the webhooks registered by apps for the site, with pagination (limit, offset, total).

Get

GET /v2/webhooks/{webhook_id}

sites:read

Returns one registration, including lastTriggered.

Delete

DELETE /v2/webhooks/{webhook_id}

sites:read

Returns 204 with no content.

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"

Limits

Criterion Limit

Webhooks per triggerType per site

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.

  1. Webflow sends the POST.

  2. If the receiver answers 200, the delivery is complete.

  3. Any other result is a failure: a non-200 status, a redirect (the 301 and 302 responses that a trailing slash or a switch from HTTP to HTTPS produces), a TLS negotiation or validation failure, or a timeout.

  4. A failed delivery is retried up to three more times, at ten-minute intervals. After the third retry the request is marked failed.

  5. 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, not 201, 202 or 204. The guide asks for HTTP 200. A receiver that follows the usual REST habit of answering 202 Accepted for 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 200 for a bad signature. A 400 for a failed verification (the status the guide’s samples use) triggers retries of a request that will never verify, which is harmless but noisy; a 200 would 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 lastTriggered field of the Get call shows when a webhook last fired; alert when a busy webhook has been silent for longer than usual.

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:

  1. Compute HMAC-SHA256 of the message with the secret and hex-encode it.

  2. Compare it with the x-webflow-signature header using a constant-time function: crypto.timingSafeEqual in Node.js and hmac.compare_digest in Python. An ordinary string comparison leaks, through its running time, how many leading characters matched.

  3. 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 secretKey when the webhook is created. Each webhook has its own secret; store it securely.

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 a verify callback to express.json that stores req.rawBody. Do not put express.json() in front of the route.

  • In Flask, call request.get_data() (bytes) before request.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!,
});

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"
  }
}
// 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}`));

Points worth noting in this receiver:

  • express.raw is attached to the route, so req.body is a Buffer of the exact bytes. The signature is computed over timestamp, 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.

  • timingSafeEqual throws 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, formId is the submission ID; for an item event, id is the item ID and lastPublished distinguishes the publishes of the same item.

  • A failed handler removes its key from the seen map, 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

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

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)

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:

  1. Ignore any other form (the filter normally prevents this; the check protects against a mis-registered webhook).

  2. 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.

  3. Create or update the visitor record in the booking system, keyed by the submission ID so a repeated event does not book twice.

  4. Send the visitor a confirmation in the right language. The localeId field 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.

  5. Later: file uploads arrive as URLs in data for FormFileUploadInput fields; 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 cmsLocaleId set. 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_publish as well, and revalidate the index pages when the site itself is published.

Site publish: an audit line

The site_publish handler records the timestamp, the domains and the user. For a garden with a designer, an editor and a marketing manager, that log answers the recurring question of who published the site and when.

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 seen map and the queue. An event acknowledged with 200 and then lost with the process is never retried, because Webflow considers it delivered. Persist before acknowledging.

  • More than one instance means more than one seen map. 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 200, do not enqueue again.

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 lastUpdated or lastPublished to ignore an older state that arrives after a newer one, or re-read the item from the Data API in the worker and act on the current state.

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");

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

  1. Verify every request of an API webhook, and reject without a valid signature and a fresh timestamp.

  2. Sign over the raw bytes, never over re-serialized JSON.

  3. Compare in constant time and never log the secret or the computed digest.

  4. 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.

  5. Serve HTTPS only with a valid certificate and no redirects.

  6. Limit the body size (one megabyte in the samples) and parse only after verification.

  7. 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.

  8. Minimize personal data in logs. Submissions contain names and e-mail addresses (see Forms).

  9. Do not use dashboard webhooks for anything sensitive, because they cannot be verified.

  10. 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 201, 202 or 204, or redirects. Answer exactly 200.

No secretKey in the creation response

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 lastUpdated and lastPublished with the previous values if the handler must act only on real changes.

Localized publish does not refresh the right page

cmsLocaleId was ignored; map it to the locale path.

429 when registering webhooks

The token reached the rate limit; honour X-RateLimit-Remaining and back off.

  • 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: