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
Tokens encrypted at rest
Webflow stays the source of truth
Honest about the API's limits
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:- The account’s own registered Webflow Data Client app, when the connection names an
oauth_app_id. - The Hiveku-native app from env (
HIVEKU_WEBFLOW_CLIENT_IDandHIVEKU_WEBFLOW_CLIENT_SECRET). Quick connect is only offered when this pair is configured. - The account’s first registered Webflow
oauth_appsrow.
Three intents, one start route
What the callback does
Re-check the OAuth client
Exchange and introspect
GET /token/introspect), the authorizing user (GET /token/authorized_by), and the shared sites with their domains and locales (GET /sites).Encrypt and persist in one transaction
Read the row back, account-scoped
Best-effort follow-ups
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:
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.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:
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.
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
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.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.Rotation
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.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 isoverride_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.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 answers409 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 oncode, never on the message text.
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
GET ?action=<read action>&… and POST { action, … }.
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
EveryPOST 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_unpublishandcms_item_unpublish_bulknever 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, a5xxor a409 write_lockedoutcome 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. Other4xxoutcomes stay cached.
POST carrying an Idempotency-Key header; without the header nothing is claimed.
Audit actor
A successful write on the Olympus route is audited asolympus:<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 onPOST /api/webhooks/webflow/[connectionId]/[urlToken]. The route is public in middleware; the URL token and, for OAuth connections, the HMAC signature are the auth.
Read the raw body
JSON.parse and JSON.stringify round trip.Look up the connection
404 with nothing written.Check the URL token
401 with nothing written.Verify the signature (OAuth only)
<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'.Ignore what cannot be routed
200 ignored. A 4xx would only buy three retries of the same useless body.Deduplicate on the event row
(account, site, event id) unique. A replay answers 200 replay: true with no fan-out.Fan out, then answer 200
last_received_at best-effort, the fan-out runs and never throws, and the answer is 200.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
How the submission id is chosen
How the submission id is chosen
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.Where it lands
Where it lands
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.What fires it onward
What fires it onward
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.Historical submissions
Historical submissions
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.A known limitation
A known limitation
/. 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.
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 authenticatedAuthorization: Bearer ${CRON_SECRET}, fail-closed and constant-time.
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.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, namedwebflow_<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: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.
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 themnot_enterprise_plan_site and the client maps that to 402.
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_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 callhttps://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.
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.
Pinning per request rather than globally is deliberate: a beta endpoint changing shape or being withdrawn takes down nine operations, not all 110.