> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hiveku.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webflow for Developers

> The developer reference for Hiveku's Webflow integration: the OAuth model and scopes, token encryption at rest, what Hiveku stores versus what stays in Webflow, the inbound webhook receiver, the reliability crons, the MCP tool families, and the full error map.

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](/integrations/webflow/connecting).

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

## Design principles at a glance

<CardGroup cols={2}>
  <Card title="One registry, two front doors" icon="sitemap">
    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.
  </Card>

  <Card title="Tokens encrypted at rest" icon="lock">
    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.
  </Card>

  <Card title="Webflow stays the source of truth" icon="database">
    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.
  </Card>

  <Card title="Honest about the API's limits" icon="triangle-exclamation">
    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.
  </Card>
</CardGroup>

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

| Endpoint       | URL                                              |
| -------------- | ------------------------------------------------ |
| Authorize      | `https://webflow.com/oauth/authorize`            |
| Token exchange | `https://api.webflow.com/oauth/access_token`     |
| Revoke         | `https://webflow.com/oauth/revoke_authorization` |
| Data API v2    | `https://api.webflow.com/v2`                     |
| Beta namespace | `https://api.webflow.com/beta`                   |

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

| Intent                    | Result                                             |
| ------------------------- | -------------------------------------------------- |
| `webflow_account_connect` | A connection with `purpose = 'account_default'`    |
| `webflow_project_connect` | A project-scoped override; requires a `project_id` |
| `webflow_reconnect`       | Refreshes the token on an existing connection row  |

### What the callback does

<Steps>
  <Step title="Re-check the OAuth client">
    The app the flow started with must still belong to the calling account.
  </Step>

  <Step title="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`).
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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

| Scope pair                               | What it unlocks                                                                            |
| ---------------------------------------- | ------------------------------------------------------------------------------------------ |
| `sites:read` / `sites:write`             | Site metadata, domains, publishing, 301 redirects, Google tags and webhook registration    |
| `pages:read` / `pages:write`             | The page list, page metadata, page SEO and schema, and page DOM writes                     |
| `cms:read` / `cms:write`                 | Collections, fields and items                                                              |
| `assets:read` / `assets:write`           | The asset library, alt text and folders                                                    |
| `forms:read` / `forms:write`             | Forms and their submissions                                                                |
| `custom_code:read` / `custom_code:write` | Registered scripts and applied site code. Page custom code needs `pages:read` alongside it |
| `components:read` / `components:write`   | Component listing, content and properties                                                  |
| `ecommerce:read` / `ecommerce:write`     | Products, SKUs, orders and inventory. Several of these need `cms:read` alongside           |
| `comments:read` / `comments:write`       | Designer comment threads and replies                                                       |
| `site_config:read` / `site_config:write` | robots.txt, `/.well-known/` files and llms.txt                                             |
| `site_activity:read`                     | The Enterprise site activity log                                                           |
| `authorized_user:read`                   | The identity of the user who authorized the connection                                     |

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

<Warning>
  `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.
</Warning>

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:

|                                                | OAuth connection                  | Site API token                                                 |
| ---------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
| CMS, pages, SEO, assets, ecommerce, publishing | Yes                               | Yes                                                            |
| Google tags                                    | Yes                               | Yes                                                            |
| Custom code and registered scripts             | Yes                               | **No** — `412 oauth_required`                                  |
| Page custom code                               | Yes                               | **No** — `412 oauth_required`                                  |
| Registering webhooks                           | Yes                               | **No** — Webflow refuses; connect answers `site_token_refused` |
| Inbound delivery verification                  | HMAC signature plus the URL token | The URL token alone                                            |
| The authorizing user's identity                | Recorded                          | Not available                                                  |

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.

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

## Token encryption at rest

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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

| Table                           | What it holds                                                                                                                                                                                                                          | Notes                                                                                                                                                                                                     |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `webflow_connections`           | The encrypted token, granted `scope`, `authorized_user_email`, `workspace_id`, `rate_limit_per_minute`, `webhook_url_token_hash`, and the lifecycle stamps `last_introspected_at`, `installed_at`, `disconnected_at`, `last_synced_at` | `auth_kind` is `'oauth'` or `'site_token'`. `rate_limit_per_minute` is 60 or 120 depending on the Webflow plan, null until the first introspection                                                        |
| `webflow_sites`                 | One row per shared site: the Webflow site id, display and short name, custom domains, the locale object, `last_published_at`, `last_publish_requested_at`                                                                              | Unique per `(account_id, site_id)` on purpose — an agency and its client may both bind one site                                                                                                           |
| `project_webflow_settings`      | The per-project binding: `override_mode` (`inherit` / `override` / `disabled`), the connection, and the bound site id                                                                                                                  | `project_id` is unique. `webflow_site_id` is **deliberately not a foreign key**: the OAuth callback replaces `webflow_sites` rows on reconnect, and a SetNull cascade would silently unbind every project |
| `webflow_webhook_subscriptions` | One row per registered trigger per site: `trigger_type`, `endpoint_url`, an optional Webflow `filter`, and `last_received_at`                                                                                                          | `webhook_id` is unique. A row is deleted only after Webflow confirmed the delete                                                                                                                          |
| `webflow_events`                | The inbound delivery log: `trigger_type`, the raw `payload`, and whether the HMAC `verified`                                                                                                                                           | Unique on `(account_id, site_id, event_id)` so a redelivery never runs the side effects twice. **No foreign keys on purpose** — the log must survive a disconnect so a missed-delivery audit still works  |
| `webflow_cms_snapshots`         | A short-lived read cache: `kind = 'schema'` for the collection list and field definitions, `kind = 'items'` for one collection's items                                                                                                 | Invalidated, not deleted, by inbound collection webhooks, then refetched on the next read                                                                                                                 |

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

| Gate          | Refuses with                    | When                                                                                            |
| ------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- |
| **Scope**     | `412 missing_scopes`            | The connection's granted set does not hold a scope the operation names. The fix is a reconnect  |
| **Confirm**   | `412 confirm_required`          | A confirm-flagged operation was called without `confirm: true`. Nineteen operations are flagged |
| **Auth kind** | `412 oauth_required`            | One of the 15 OAuth-only operations was called on a site-token connection                       |
| **Locale**    | `412 secondary_locale_required` | A locale-gated operation was called without a `localeId` from the site's **secondary** locales  |

<Note>
  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".
</Note>

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.

| Status | Code                                                           | Meaning                                                   |
| ------ | -------------------------------------------------------------- | --------------------------------------------------------- |
| `401`  | `webflow_auth`                                                 | Webflow rejected the stored token                         |
| `402`  | `not_enterprise_plan_site`                                     | An Enterprise-only endpoint on a non-Enterprise workspace |
| `404`  | `collection_not_found` and siblings                            | The target is not on the bound site                       |
| `409`  | `ecommerce_not_enabled`, `forms_require_republish`, `conflict` | Webflow answered a coded conflict                         |
| `409`  | `write_locked`                                                 | The per-target lock was not acquired. Safe to retry       |
| `409`  | `write_unconfirmed`                                            | The lock window closed mid-handler. Re-read first         |
| `412`  | `secondary_locale_required`                                    | A DOM write needs a secondary locale                      |
| `412`  | `oauth_required`                                               | A site token cannot call this endpoint                    |
| `412`  | `missing_scopes`                                               | The connection never granted the scope                    |
| `412`  | `confirm_required`                                             | A confirm-gated operation without the confirmation        |
| `422`  | `webflow_validation`                                           | Webflow refused the payload                               |
| `424`  | `asset_upload_incomplete`, `snippet_apply_failed`              | A composite failed after its first step. Re-run to finish |
| `429`  | `rate_limited`, `publish_cooldown`                             | With `retry_after_seconds`                                |
| `502`  | `webflow_upstream`                                             | A Webflow 5xx or a network failure                        |
| `504`  | `webflow_timeout`                                              | No answer inside the request timeout                      |

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

| Route                                  | Auth                               | Used by                                               |
| -------------------------------------- | ---------------------------------- | ----------------------------------------------------- |
| `/api/olympus/web/webflow-ops`         | `Authorization: Bearer olp_…`      | MCP tools, the department agents, the workflow engine |
| `/api/builder/webflow/[projectId]/ops` | Clerk session, or `x-agent-secret` | The dashboard panels and the Python coder-agent rail  |

Both take `GET ?action=<read action>&…` and `POST { action, … }`.

<Warning>
  **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.
</Warning>

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.

<Steps>
  <Step title="Read the raw body">
    Byte-verbatim. The HMAC does not survive a `JSON.parse` and `JSON.stringify` round trip.
  </Step>

  <Step title="Look up the connection">
    Unknown, disconnected, or no stored token hash returns `404` with nothing written.
  </Step>

  <Step title="Check the URL token">
    SHA-256, constant-time compare. A mismatch returns `401` with nothing written.
  </Step>

  <Step title="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'`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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`.
  </Step>
</Steps>

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

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

<AccordionGroup>
  <Accordion title="How the submission id is chosen" icon="fingerprint">
    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.
  </Accordion>

  <Accordion title="Where it lands" icon="inbox">
    `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.
  </Accordion>

  <Accordion title="What fires it onward" icon="bolt">
    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.
  </Accordion>

  <Accordion title="Historical submissions" icon="clock-rotate-left">
    `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`.
  </Accordion>

  <Accordion title="A known limitation" icon="circle-info">
    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.
  </Accordion>
</AccordionGroup>

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

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

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

| Route                                     | Cadence                    | What it watches                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| ----------------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/cron/webflow-token-health`          | Daily                      | One probe per active connection (`/token/introspect` for OAuth, which also resets Webflow's inactivity clock and refreshes scope and plan limit; `GET /sites/{id}` for site tokens). Only **two consecutive 401s** mark a connection disconnected, drop its projects back to the native CMS and seed an urgent "Reconnect Webflow" inbox item. A 403 is a scope or plan answer and 429 or 5xx is not evidence, so those are reported and never acted on. Also refreshes stale site rows and re-wraps token envelopes after a key rotation |
| `/api/cron/webflow-webhook-health`        | Hourly, **detection only** | Two signals per connection installed more than 72 hours ago: more than 20 unverified deliveries in 24 hours (a rotated secret, a hand-added hook, or probing), and registrations present with zero verified events in 72 hours — for which it reads Webflow's own webhook list and lets the managed hooks' `lastTriggered` decide between a dead endpoint and a quiet site. It never re-registers anything                                                                                                                                |
| `/api/cron/webflow-webhook-url-reconcile` | Daily                      | After a `NEXT_PUBLIC_APP_URL` change every registration still points at the old receiver and deliveries 404 silently. Rebuilds the URL from the stored token segment and replaces every registration, create-first and delete-last per site                                                                                                                                                                                                                                                                                               |
| `/api/cron/webflow-events-retention`      | Daily                      | A bounded purge of the inbound log: verified rows older than **90 days**, unverified rows older than **7 days**. Nothing else ever deletes a `webflow_events` row                                                                                                                                                                                                                                                                                                                                                                         |

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

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](/integrations/claude-code-plugin) for the plugin, or [LLM Connectors](/integrations/llm-connectors) for the generic MCP connector.

## Rate limits and the publish cooldown

Webflow's published limits, per API key:

| Plan                        | Requests per minute |
| --------------------------- | ------------------- |
| Starter and Basic           | 60                  |
| CMS, eCommerce and Business | 120                 |
| Enterprise                  | Custom              |

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.

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

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

| Limit                                            | Value                                   |
| ------------------------------------------------ | --------------------------------------- |
| Data API page size                               | 100 per page                            |
| Items paged for one collection                   | 2000                                    |
| Pages listed                                     | The first 2000                          |
| CMS item bulk create                             | 500                                     |
| CMS item bulk publish, update, delete, unpublish | 100                                     |
| DOM text write                                   | 1000 nodes                              |
| JSON-LD per page                                 | 60 KB raw, nesting depth 32, 5000 nodes |
| Page schema read by query                        | 100 pages                               |
| Page schema bulk write                           | 25 pages                                |
| Page metadata bulk write                         | 100 pages                               |
| Slug scan for `page_query`                       | At most 500 pages over 5 requests       |
| Google tags per site                             | 25                                      |
| Webhooks per trigger per site                    | 75                                      |
| Webhook signature clock skew                     | 5 minutes                               |
| Comment latency                                  | Up to five minutes to appear            |

<Note>
  `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.
</Note>

## Enterprise-gated families

Fourteen operations need a Webflow Enterprise workspace. Webflow answers them `not_enterprise_plan_site` and the client maps that to `402`.

| Area                  | Operations                                                               |
| --------------------- | ------------------------------------------------------------------------ |
| 301 redirects         | `redirect_list`, `redirect_create`, `redirect_update`, `redirect_delete` |
| robots.txt            | `robots_get`, `robots_replace`, `robots_update`, `robots_delete`         |
| llms.txt              | `llms_txt_get`, `llms_txt_set`, `llms_txt_delete`                        |
| `/.well-known/` files | `wellknown_create`, `wellknown_delete`                                   |
| Site activity log     | `activity_log_list`                                                      |

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.

<Note>
  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`.
</Note>

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

| Module              | Operations                                                                                                                      |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Page SEO and schema | `page_schema_get`, `page_schema_set`, `page_schema_query`, `page_schema_update_bulk`, `page_metadata_update_bulk`, `page_query` |
| llms.txt            | `llms_txt_get`, `llms_txt_set`, `llms_txt_delete`                                                                               |

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.

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

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.

| Env var                                 | Required for                                         | Notes                                                                                                                        |
| --------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `WEBFLOW_TOKEN_ENCRYPTION_KEY`          | Encrypting and decrypting every stored Webflow token | 32-byte base64. **Lose it and every connection's tokens are unrecoverable.** The connect routes refuse to run without it     |
| `WEBFLOW_TOKEN_ENCRYPTION_KEY_PREVIOUS` | Key rotation only                                    | The old key. Decrypt falls back to it and tokens re-wrap on next write. Remove once fully rotated                            |
| `HIVEKU_WEBFLOW_CLIENT_ID`              | The Hiveku-native OAuth app                          | Without the pair, Quick connect is not offered and accounts must register their own Webflow Data Client app                  |
| `HIVEKU_WEBFLOW_CLIENT_SECRET`          | The Hiveku-native OAuth app                          | Also the key that verifies inbound webhook signatures for connections made with it                                           |
| `NEXT_PUBLIC_APP_URL`                   | The OAuth callback and the receiver URL              | Change it and existing registrations point at the dead URL until the URL-reconcile cron runs. The receiver URL is HTTPS only |
| `CRON_SECRET`                           | Authenticating the four reliability crons            | Must match on both the cron worker and the builder                                                                           |

## What's next

<CardGroup cols={2}>
  <Card title="The Webflow AI chat" icon="comments" href="/integrations/webflow/ai-chat">
    The chat mode, what it refuses and why, and how staged changes reach visitors.
  </Card>

  <Card title="Webflow automations" icon="diagram-project" href="/integrations/webflow/automations">
    The trigger nodes, the 110 action nodes, and a worked recipe.
  </Card>

  <Card title="Connecting Webflow" icon="plug" href="/integrations/webflow/connecting">
    OAuth and the site token, account default versus project override, and what gets stored.
  </Card>

  <Card title="Shopify for developers" icon="store" href="/integrations/shopify/developers">
    The sibling integration, which shares the envelope-crypto and reliability-cron patterns.
  </Card>
</CardGroup>
