Skip to main content
This is the reference page for how the Webflow integration works underneath: the OAuth model, the four gates every write passes, what is stored and what deliberately is not, how inbound deliveries are verified, and the crons that keep a connection honest. If you just want to connect a site, start with Connecting Webflow.
Everything here is grounded in the source: src/lib/webflow/ in hiveku_builder, the two dispatch routes at src/app/api/olympus/web/webflow-ops and src/app/api/builder/webflow/[projectId]/ops, the receiver at src/app/api/webhooks/webflow/[connectionId]/[urlToken], and the four src/app/api/cron/webflow-* routes. Routes, env vars and error codes are quoted verbatim.

Design principles at a glance

One registry, two front doors

110 named operations live in one registry. The Olympus API route, the Clerk-authed dashboard route, the MCP tools, the AI chat and the workflow nodes all dispatch through the same runner, so the gates cannot drift apart.

Tokens encrypted at rest

OAuth access tokens and pasted site tokens are AES-256-GCM enveloped under a Webflow-scoped key. They are decrypted in memory for the length of one call and never returned, logged or echoed.

Webflow stays the source of truth

Hiveku persists connections, sites, webhook subscriptions, an inbound event log and a short-lived CMS read cache. Pages, styles, components and content live in Webflow.

Honest about the API's limits

Where the Data API cannot do something, nothing in the stack pretends otherwise: the registry has no operation for it, the tool descriptions say so, and the chat refuses with a fixed script.

The OAuth model

Webflow’s OAuth is the plain authorization-code grant with no PKCE — the provider does not support it — so the HMAC-signed state value Hiveku mints, with a five-minute lifetime, is the only CSRF anchor. Access tokens do not expire and there is no refresh token; revocation is explicit.

Which app runs the flow

Unlike the Shopify integration, Webflow supports both a Hiveku-native app and bring-your-own. The OAuth client is resolved in this order:
  1. The account’s own registered Webflow Data Client app, when the connection names an oauth_app_id.
  2. The Hiveku-native app from env (HIVEKU_WEBFLOW_CLIENT_ID and HIVEKU_WEBFLOW_CLIENT_SECRET). Quick connect is only offered when this pair is configured.
  3. The account’s first registered Webflow oauth_apps row.
The user picks which sites to share on Webflow’s own consent screen. Hiveku collects nothing site-specific beforehand.

Three intents, one start route

What the callback does

1

Re-check the OAuth client

The app the flow started with must still belong to the calling account.
2

Exchange and introspect

Exchange the code, then read the granted scope and the plan rate limit (GET /token/introspect), the authorizing user (GET /token/authorized_by), and the shared sites with their domains and locales (GET /sites).
3

Encrypt and persist in one transaction

Encrypt the token, mint a per-connection webhook URL token (only its SHA-256 is stored), then write the connection, its sites and any project binding in a single transaction.
4

Read the row back, account-scoped

The new row is re-read under account scope and the connect fails only if it is invisible — an RLS gap looks exactly like that, and failing loudly beats a connection nobody can use.
5

Best-effort follow-ups

Bust the credential cache, register the receiver webhooks on every shared site, restore any project the token-health cron had dropped back to the native CMS, and index the bound project’s pages for Review. None of these can fail the connect.

Requested scopes

DEFAULT_WEBFLOW_SCOPES in src/lib/webflow/auth.ts is the single source of truth: the start route joins it space-separated for the authorize URL, and the registry’s scope gate compares each connection’s granted string against each operation’s needs. All 22:
Two scopes are deliberately absent. Any users:* scope is excluded because the Users and Access Groups endpoints no longer exist in the Data API, and requesting a scope the consent screen cannot grant fails the whole authorization. workspace_activity:read is excluded because that endpoint takes a workspace token this integration never holds.
site_activity:read was added after some connections were granted. Those answer 412 missing_scopes with reconnect copy. Reconnecting fixes the scope; the site itself may still answer 402 if the workspace is not Enterprise.
Webflow issued singular page:read and page:write to older grants, so the scope gate treats each as satisfying its pages:* twin and vice versa.

The site API token alternative

A customer can paste a Webflow site API token instead of running OAuth. The token is validated against Webflow before anything is stored — GET /token/introspect for the granted scope and plan limit, GET /sites for the reachable sites — must be at least 20 characters, and is never echoed back. A site token is a real but smaller connection: Exactly 15 operations carry requiresOAuth and refuse a site token: all nine custom-code operations, the three page custom-code operations, webhook_create, webhook_update and token_authorized_by. webhook_delete is deliberately not gated, so a site token can still tear down a registration it inherited.
Hiveku attempts webhook registration on a site-token connection anyway rather than assuming the refusal. When Webflow refuses, the connect answers webhooks.skipped = 'site_token_refused', returns the receiver URL once for the Settings panel, and seeds an inbox item with manual instructions. The URL is never stored outside that inbox item.

Token encryption at rest

1

Encryption

src/lib/webflow/crypto.ts wraps the generic envelope helper so a leak of the Webflow key cannot open Shopify tokens or voice SIP passwords, and the reverse. The stored format is an AES-256-GCM envelope (v1:iv:ct:tag) in webflow_connections.access_token_enc, holding either an OAuth access token or a pasted site token. Never plaintext.
2

Key configuration

WEBFLOW_TOKEN_ENCRYPTION_KEY, a 32-byte base64 value. The connect routes check it is configured up front, so a misconfigured deploy fails at connect time rather than at first use.
3

Rotation

Set WEBFLOW_TOKEN_ENCRYPTION_KEY_PREVIOUS to the old key and the primary to the new one. Decryption falls back automatically and each token re-wraps on its next write. The token-health cron also re-wraps envelopes as it sweeps, so rotation completes without anyone reconnecting. Remove the previous key once everything has been re-saved.
The per-connection webhook URL token is handled differently: only its SHA-256 is stored in a column (webhook_url_token_hash), and its only operational copy is the token segment of the registered endpoint_url.

What Hiveku stores, and what stays in Webflow

Rows Hiveku persists

What stays in Webflow

Pages, layout, classes, styles, interactions, components, the Designer’s own comment surface, form definitions, checkout and tax settings, and the content itself. Hiveku reads it through the API each time and caches only the CMS snapshots above (schema for 10 minutes, items for 60 seconds).

Resolving a project’s connection

Precedence is override_mode = 'override' (its connection and bound site), then the account-default connection, then null. disabled hides the account default for that project. Rows with disconnected_at set are never returned. Site selection on the resolved connection: the project’s bound site when a project id is given, else an explicitly requested site, else the connection’s single site, else null. Two deliberate rescues sit here — a requested site not on the default connection falls back to the account’s own row for it, and a project override whose bound site has moved to another connection follows the site’s live row rather than answering “site null” and 404-ing every operation. Tokens are decrypted in memory only and cached for 60 seconds keyed by account, project and site, busted on connect, disconnect and settings changes. A missing table or column latches to “no connection” for the life of the process rather than failing every caller.

The operation registry and its four gates

Every write passes four gates before Webflow is called, in this order.
The confirm set is small on purpose. The rule written into the registry is that it applies only to a write that cannot be undone through the API, or one that changes what every visitor sees at once — because “a gate everyone always satisfies has stopped being a gate”.
Exactly three operations are locale-gated: page_dom_update, component_content_update and component_properties_update. A site with no secondary locale cannot take DOM writes at all, and the error says so rather than naming an empty list. enterpriseOnly is advisory rather than a gate: Webflow answers those endpoints not_enterprise_plan_site and the client maps that to 402.

Write locks, audit and invalidation

A single-target write (one item, one page) runs inside a 90-second Prisma transaction that holds one pooled connection, so two agents editing the same item serialize instead of racing. Bulk creates, uploads, site publishes, composites, and any handler that issues its own Prisma query must take no lock — the first group can outlive the transaction after Webflow has already applied the write, and the second would wait for a second pooled connection while holding one, which is a pool-wide deadlock under load. A lock that is never acquired answers 409 write_locked (safe to retry). One whose window closes after the handler started answers 409 write_unconfirmed (re-read before retrying). After a successful write the runner writes one audit_logs row named webflow.<action>, fire-and-forget, and marks the CMS snapshots stale for the collection or project touched. Handlers never audit themselves.

The error map

Every error carries the HTTP status and a stable machine code, so the Olympus route, its Clerk twin, the panels, the MCP tools and the workflow nodes all map one failure the same way. Branch on code, never on the message text. Every error derived from a Webflow response also carries upstreamStatus, the raw status Webflow answered, so a handler can tell an upstream 404 (“nothing applied yet”) from a 400 without parsing text. Errors raised by Hiveku’s own gates leave it undefined.

The two dispatch routes

Both take GET ?action=<read action>&… and POST { action, … }.
Reads are GET and writes are POST, deliberately. The MCP layer classifies a tool read-versus-write purely by its mapping’s HTTP method, so a read served over POST vanishes from tools/list for exactly the read-only keys that should have it. A read sent as POST, or a write as GET, answers 400 wrong_verb rather than running.
On the Olympus route project_id is optional, because an account can hold a Webflow connection with no Hiveku project bound to it; site_id then names the target, and an explicit site_id must belong to the calling account’s sites.

Idempotency

Every POST claims an idempotency slot, so an MCP retry after a timeout replays the cached response instead of creating the CMS item twice. Two deliberate exceptions:
  • site_publish, cms_item_publish, cms_item_unpublish and cms_item_unpublish_bulk never claim a slot. The MCP proxy derives the key from the body, so an identical publish an hour later would otherwise replay last hour’s receipt instead of publishing again.
  • A 429, a 5xx or a 409 write_locked outcome forgets the claim rather than caching it. Those are Webflow’s cooldowns and outages and the advisory lock’s own timeout, and caching that “failure” would make the retry fail for an hour after the cause was gone. Other 4xx outcomes stay cached.
The Clerk twin mirrors this for a POST carrying an Idempotency-Key header; without the header nothing is claimed.

Audit actor

A successful write on the Olympus route is audited as olympus:<key name>, so audit_logs names the MCP key that ran it. The one exception: a caller presenting the Olympus service key itself may name the actor through the X-Olympus-Actor-Label header — the workflow engine sends workflow:<workflowId>:<runId> so the activity feed shows which run wrote, not just “the service key”. The header is honoured only for the service key, under a constant-time compare, and only when it matches that reserved shape.

The inbound webhook receiver

All deliveries for one connection land on POST /api/webhooks/webflow/[connectionId]/[urlToken]. The route is public in middleware; the URL token and, for OAuth connections, the HMAC signature are the auth.
1

Read the raw body

Byte-verbatim. The HMAC does not survive a JSON.parse and JSON.stringify round trip.
2

Look up the connection

Unknown, disconnected, or no stored token hash returns 404 with nothing written.
3

Check the URL token

SHA-256, constant-time compare. A mismatch returns 401 with nothing written.
4

Verify the signature (OAuth only)

HMAC-SHA256, hex, over the string <timestamp>:<rawBody> keyed with the app’s client secret, with a five-minute maximum clock skew. A mismatch returns 401 plus at most 50 bounded unverified rows per connection per hour. A site-token connection has no signature, so the URL token is the whole check and the delivery is recorded as verification: 'url_token'.
5

Ignore what cannot be routed

An unparseable envelope, or a site that is not on this connection, answers 200 ignored. A 4xx would only buy three retries of the same useless body.
6

Deduplicate on the event row

A row is written on the (account, site, event id) unique. A replay answers 200 replay: true with no fan-out.
7

Fan out, then answer 200

Subscription rows are stamped last_received_at best-effort, the fan-out runs and never throws, and the answer is 200.
There is exactly one non-200: a form_submission whose ledger write failed. The event row is deleted and the answer is 503 ledger_unavailable, so Webflow redelivers instead of every retry reading replay: true and dropping the lead. Webflow retries three times at ten-minute intervals on any non-2xx, then stops.
The body and the signature value are never logged, and step details are never returned to Webflow — they may carry a database error message.

The eleven registered triggers

site_publish, collection_item_created, collection_item_changed, collection_item_deleted, collection_item_published, collection_item_unpublished, page_created, page_deleted, page_metadata_updated, form_submission (unfiltered, so every form routes) and comment_created. Webflow caps registrations at 75 per trigger per site, and a second (trigger, url) pair is a duplicate delivery, so every registration lists first and adopts a match rather than creating a second. Re-registration is create-first and delete-last per site: a failure part-way leaves duplicates, which the receiver deduplicates by event id, never a gap. A delivery fans out to the projects bound to that site — capped at 50 — as either an external binding (a Webflow-hosted site whose pages are indexed) or a cms_only binding (a Hiveku-hosted project whose CMS is Webflow: snapshots invalidate, pages are never indexed).

A form submission, end to end

The id is payload.id, unless that equals payload.formElementId — which is a form id, not a submission id — in which case it is a SHA-256 of the form name and the submission timestamp. No id, no name and no timestamp is a permanent condition: it is reported, not retried.
recordFormSubmission writes into the Hiveku Forms ledger with source: 'webflow', the form label from the payload, the payload fields, and the account-scoped idempotency key webflow:<accountId>:<submissionId>. The raw payload also keeps the Webflow site, event and connection ids.
The ledger fires the form_submitted workflow trigger itself. The Webflow branch deliberately does not fire workflows a second time. The ledger’s own path also creates or updates the CRM contact, and a new, non-spam row queues its notification fire-and-forget with a sweeper behind it.
POST /api/builder/webflow/[projectId]/forms/backfill imports what Webflow already holds, under the same idempotency key as the receiver, so running it twice — or after the receiver already routed the same submission — converges on one row. Historical rows start no workflow and send no notification. The default limit is 200 and the cap is 1000. A later page hitting the rate limit answers partial: true with retry_after_seconds; the first page hitting it answers 429.
The Webflow form payload carries no page path, so every routed submission lands with a page path of /. Only the backfill can match a form to its page, because it reads the form list and the page list alongside.

Form routing shapes

GET and POST /api/builder/webflow/[projectId]/forms/routing switch routing on and off. Two shapes exist and must never overlap: one site-wide unfiltered form_submission registration, or one registration per form name. Turning site-wide on deletes every per-form registration; a per-form switch under a live site-wide hook answers 409 form_routing_site_wide. A site token answers 412 oauth_required before Webflow is called. Registration serializes on a per-site advisory lock, the same scope webhook_delete holds.
A site switched to per-form routing never gets the site-wide hook back from a reconnect or a reconcile. The effective trigger set drops form_submission whenever a filtered row exists, and re-registration replays the prior shape exactly.

Re-registering webhooks

POST /api/builder/webflow/connections/[id]/reregister-webhooks re-registers Hiveku’s receiver webhooks for every site of a connection at the current endpoint URL. It is the recovery path the health inbox items and the Webhooks panel link to, and it is how a connection that predates registration gets its webhooks without a reconnect. Auth is admin or owner, the same gate as disconnect, because a registration affects every project on the connection. It answers 404 when the connection is not the account’s, 409 when it is disconnected, and 412 oauth_required for a site token.

Reliability crons

Four sweeps, all authenticated Authorization: Bearer ${CRON_SECRET}, fail-closed and constant-time.
Disconnects in the token-health cron are capped per run. A Webflow-side auth incident then reports the rest as disconnect_cap instead of moving every customer’s content source unattended, and the inbox item keeps the reverted project ids so a bad run stays restorable.
Every one of the four carries a wall-clock budget and answers partial: true when it runs out, so a large fleet degrades into more runs rather than a timeout.

MCP tools

The MCP server exposes 110 tools, one per registry operation, named webflow_<action>. All of them map to /api/olympus/web/webflow-ops, which resolves the calling account’s effective site connection. The access token never leaves the builder. One tool per operation rather than a single webflow_ops dispatcher, for three reasons: every MCP call writes an audit row carrying the tool name, and a dispatcher would record “webflow_ops” for a page read and a site publish alike; the scope middleware can refuse writes on a read-only key per operation; and each description can name its own failure mode. The file is generated from each operation’s schema, and a test fails when the tools and the builder registry disagree. The families mirror the operation families: webflow_site_*, webflow_page_*, webflow_cms_*, webflow_asset_*, webflow_script_*, webflow_site_customcode_*, webflow_hiveku_snippet_*, webflow_google_tag_*, webflow_form_*, webflow_comment_*, webflow_product_*, webflow_order_*, webflow_sku_*, webflow_inventory_*, webflow_redirect_*, webflow_robots_*, webflow_wellknown_*, webflow_llms_txt_*, webflow_webhook_*, webflow_token_*, and webflow_activity_log_list. Department chats reach the same tools. Passing a project_id when you talk to a department pins it to that site, which is what makes the webflow_* tools act on it; omit it for account-level work. See Hiveku for Claude Code for the plugin, or LLM Connectors for the generic MCP connector.

Rate limits and the publish cooldown

Webflow’s published limits, per API key: Exceeding the limit returns 429 with a Retry-After header, typically 60 seconds. Hiveku gives each connection an in-process token bucket sized to the advertised X-RateLimit-Limit, so a burst of parallel tool calls is refused locally with a coded 429 instead of burning the remote budget.
A remote 429 is retried once, on GET only, after Retry-After. A write that hit the limit may already have been applied, and replaying it is exactly the double-write the idempotency layer exists to prevent. A request that times out is a 504 webflow_timeout and is never retried on a read or a write, because it may have reached Webflow.
Publishing has its own limit: Webflow allows one successful publish per minute per site. Hiveku enforces it locally with a claimed publish window, so a second publish inside 60 seconds answers 429 publish_cooldown with retry_after_seconds rather than being sent and rejected.

Other hard numbers worth designing around

page_query scans client-side. An empty result under capped: true means “not in the pages scanned”, never “no such page” — treat the two differently in your own code.

Enterprise-gated families

Fourteen operations need a Webflow Enterprise workspace. Webflow answers them not_enterprise_plan_site and the client maps that to 402. The dashboard rail carries no Enterprise marker, because the plan is not known to the client; each page has its own 402 state instead, and every one of them links to the same destination — the site’s publishing settings in the Webflow dashboard, where a customer on any plan manages 301 redirects by hand.
Ecommerce is different: it is gated by the site, not the plan tier. Webflow answers ecommerce_not_enabled, and the fix is turning Ecommerce on in the Designer. Forms are different again: Webflow will not list a site’s forms until it has been published once, which surfaces as forms_require_republish.

The beta namespace

Nine operations call https://api.webflow.com/beta rather than v2. The base is passed per request as apiBase: 'beta'; there is no global flip, and both bases draw on the same per-connection token bucket. Two more use beta conditionally: component_content_get and page_dom_get, but only when with_bindings is passed, which is what makes them return the collection and field each property is bound to.
The beta page endpoints are also the reason page titles, SEO and JSON-LD are editable on a single-locale site. The v2 DOM route demands a secondary locale id; the beta page metadata and schema endpoints write the primary locale by omitting the locale entirely. The gap Webflow’s documentation describes is page body text, not page metadata.
Pinning per request rather than globally is deliberate: a beta endpoint changing shape or being withdrawn takes down nine operations, not all 110.

Required environment variables

Set these on the builder web service unless noted.

What’s next

The Webflow AI chat

The chat mode, what it refuses and why, and how staged changes reach visitors.

Webflow automations

The trigger nodes, the 110 action nodes, and a worked recipe.

Connecting Webflow

OAuth and the site token, account default versus project override, and what gets stored.

Shopify for developers

The sibling integration, which shares the envelope-crypto and reliability-cron patterns.