Apps Overview and OAuth

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 the Webflow app model as it was on 2026-10-28, the date on which its facts were checked against the official documentation. An app is the unit of distribution of the Webflow developer platform: the thing that is registered in a workspace, that users install on their sites and that, optionally, ends up in the Marketplace. The examples use the JavaScript SDK webflow-api 3.3.4, the Webflow CLI @webflow/webflow-cli 2.15.0 (which needs Node 22.13 or later), the Python SDK webflow 2.0.0 and the typings package @webflow/designer-extension-typings 2.2.2. Plans are named by tier only. Registering an app is a Workspace feature that the developer documentation lists for the Team and Enterprise tiers, and a free Developer workspace is the recommended place to build and test; every example here runs against a site in such a workspace. The Developer Platform Overview explains how to obtain one.

The running example is Fernbrook Gardens, the fictitious botanical garden of this sub-section, whose site is published in English with Spanish and German locales. This page follows the garden’s agency, which builds the Fernbrook Content Dashboard: a backend that lists the garden’s sites, reads the Plants collection and updates items. It is a Data Client app, the simplest of the three app types, and it is also the one that needs OAuth. The OAuth protocol itself, the exact request parameters, the error table and the introspection and revocation endpoints are covered in Data API Authentication and Scopes and are not repeated here; this page is about the app that uses them.

What an app is, and what it is not

Most integrations do not need an app. A script that runs inside the garden’s own infrastructure and talks to the garden’s own site can use a site token, which an administrator generates in Site settings in a few clicks. An app becomes necessary when at least one of the following is true:

  • The code must act on sites that belong to other people, each of whom grants access through a consent screen.

  • The code needs a capability reserved for apps, such as the custom-code endpoints of the Data API or an overlay inside the Designer.

  • The integration is going to be offered to other workspaces, publicly or privately, through the Marketplace.

  • Access must be revocable by the user who granted it, independently of any administrator of the site.

Conversely, an app is more work than a token: it needs a registration, a redirect URI, a server that completes the OAuth exchange and a place to keep one token per installation. If none of the four conditions applies, the token is the better design, and the decision table in Data API Authentication and Scopes says so. Nothing in this page changes that advice.

An app is also not the same thing as an integration in the broader sense. The Marketplace lists apps, but the Integrations and Marketplace Apps page describes them from the point of view of a site owner who installs one, while this page describes them from the point of view of the developer who builds one.

The three building blocks

The getting-started page of the apps documentation defines three building blocks. They differ in where the code runs and which API it talks to, and each block corresponds to a capability that is ticked when the app is registered.

Building block Where it runs What it uses Typical purpose

Data Client

On a backend you host, outside the Designer

The Data API v2 through OAuth access tokens

Syncing data between Webflow and other platforms: CMS content to an external database, form submissions to a CRM, automated publishing

Designer Extension

Inside the Webflow Designer, in an iframe the Designer opens as an overlay

The Designer API, exposed to the extension as the webflow global

Enhancing the design workflow: new panels, automating design tasks, embedding content from other services

Hybrid app

Both: an iframe in the Designer and a backend

The Designer API in the iframe, plus the Data API on the backend, tied together by a user ID token

One solution that combines operations on the canvas with backend data operations

The registration page puts the same idea in terms of the form: an app can have either capability or both, and an app with both is called a Hybrid App. There is no separate "hybrid" checkbox; hybrid is simply the app that ticks both capabilities. The consequence is that the choice is not final. A Data Client can gain a Designer Extension capability later, which is how the Fernbrook Content Dashboard grows in the next pages of this sub-section.

Data Client

A Data Client is the OAuth half of the platform. Its code is an ordinary web service, written in any language, that sends the user to Webflow to authorize, receives a code on its redirect URI, exchanges that code for an access token and then calls https://api.webflow.com/v2. Because the code runs on infrastructure the developer controls, the client secret stays on the server and the app can run scheduled jobs, receive webhooks and talk to third-party systems in the same process. The Data API itself is covered across the data-api-* pages, beginning with Data API: Sites, Pages and Components and Data API: CMS.

Designer Extension

A Designer Extension is a small web application, built with ordinary HTML, CSS and JavaScript (or any framework that compiles to them), that the Designer shows in an iframe. It does not call the Data API and does not need OAuth: it talks to the open site through the Designer API. It is built, bundled and uploaded with the Webflow CLI, and its webflow.json file declares its size and its capabilities. It is the subject of Designer Extensions and the Designer API, and the CLI commands are in Webflow CLI.

Hybrid app

A hybrid app needs both halves to know who the user is. The iframe asks the Designer for a user ID token, sends it to the app’s backend, and the backend resolves it to a user and looks up the OAuth token it stored for that user. The sequence is drawn in Hybrid Apps. This page prepares the ground: the backend there is the Data Client built below.

The three app types at a glance

The diagram shows what each app type contains and which credentials flow where. The split between the Designer and the backend is the thing to remember: an extension talks to the Designer API without OAuth, and a Data Client never touches the canvas.

flowchart LR subgraph REG["Registered app (Workspace settings)"] direction TB CAP1["Capability: Data Client"] CAP2["Capability: Designer Extension"] end subgraph DC["Data Client app"] direction TB S1["Your backend server"] S2["OAuth authorization-code flow"] S3["Data API v2 calls with a bearer token"] S1 --> S2 --> S3 end subgraph DE["Designer Extension app"] direction TB E1["Bundle uploaded with the CLI"] E2["iframe inside the Designer"] E3["Designer API, no OAuth"] E1 --> E2 --> E3 end subgraph HY["Hybrid app"] direction TB H1["iframe in the Designer"] H2["User ID token"] H3["Backend resolves token, then uses stored OAuth token"] H1 --> H2 --> H3 end CAP1 --> DC CAP2 --> DE CAP1 --> HY CAP2 --> HY DC -->|"installed by users on sites"| MKT["Marketplace or private install"] DE -->|"versions uploaded by an admin"| MKT HY -->|"both halves ship together"| MKT

The two registration capabilities on the left are independent switches, and a Hybrid app is what results when both are on. The three right-hand groups are the shapes the finished product takes. Distribution is the same for every type: other users can install an app only after it has been published in the Marketplace, publicly or privately, which is covered in Publishing Apps and the Marketplace.

Choosing the app type for a job

Fernbrook need App type Reason

A dashboard lists the garden’s sites and edits Plants items from the agency’s server

Data Client

Backend work against the Data API, no canvas interaction

A panel in the Designer adds a Plant Label Kit of elements and styles to a page

Designer Extension

Works on the canvas through the Designer API

The label kit also fixes missing alt text on every image of the site in bulk

Hybrid app

The canvas half asks for the user, the backend half calls the Assets endpoints with the stored OAuth token

A nightly job pushes Plants from the garden’s inventory system into the CMS of the garden’s own site

No app; a site token

One site, one owner, no consent screen needed

A monitoring tool reads the workspace audit log

No app; a workspace token (Enterprise)

Workspace-wide, read-only, one scope

Registering an app in Workspace settings

Registration happens in the Webflow dashboard, not through an API. The prerequisites are strict, and the first three are the ones that surprise people the most.

  • Administrator rights in the workspace. The documentation states that only workspace administrators can create apps, view client secrets, upload bundles and modify app settings. A member with the Designer or Editor role cannot.

  • A verified email address and two-factor authentication on the account. The troubleshooting section of the page lists both.

  • A plan that supports app development. The page names the Team and Enterprise tiers.

  • Optionally a Developer workspace. It is free, and it is the place the documentation recommends for building and testing, so the production workspace does not accumulate half-finished apps. Webflow also notes it can be requested for features such as Enterprise-only APIs and Localization, which matters for the Spanish and German locales of the Fernbrook site.

The steps:

  1. Sign in and open the dashboard.

  2. Choose the workspace. For Fernbrook that is the agency’s development workspace, not the garden’s production workspace.

  3. Open Settings > Apps & Integrations > App Development and select Create an App.

  4. Fill in the app details and select Continue.

  5. Tick the capabilities. For a Data Client, configure OAuth: redirect URIs and scopes. Then select Create app.

The app details

Field Rule

Name

The public name of the app. It appears on the consent screen, so choose the name users will recognize, for example Fernbrook Content Dashboard

Description

A short summary of the purpose, at most 140 characters

Icon

An image that represents the app

Homepage URL

A valid HTTPS link to the website of the app

Restrict app installation to a specific site

An optional toggle, explained below. Off by default

The description limit is easy to exceed when the text is reused from a README, so write the 140-character version first and keep it with the app’s source for the Marketplace listing later.

Capabilities

Two switches, which can be combined. Designer Extension adds an overlay in the Designer that manipulates the site and the Designer through the Designer API. Data Client adds the ability to read and write site data and to connect to third-party infrastructure through OAuth with the Data API. The settings that appear after registration depend on the capabilities: a Designer Extension adds a Designer Extension URI (where the extension is served inside the Designer iframe, which is important for CORS), a version list and a Publish Extension Version action; a Data Client adds the OAuth settings described next. The Designer Extension side is detailed in Designer Extensions and the Designer API.

Client ID and client secret

When the app is created, it appears in Apps & Integrations > App Development, and its listing shows the Client ID and the Client Secret. The client ID is public: it appears in every authorize URL. The secret is a credential. The documentation gives four rules that this page repeats because they are the ones most often broken:

  • Never commit the client secret to version control.

  • Rotate it if it is exposed.

  • Keep it in an environment variable or a secret manager.

  • Apply proper CORS policies for the Designer Extension part of the app.

Redirect URIs

A redirect URI is the address on the app’s server to which Webflow sends the user’s browser after authorization, carrying the code and the state. It is the single most common source of registration errors, and the rules are short.

  • It must be a valid HTTPS URL.

  • It must match what was registered character for character. A trailing slash matters: /callback and /callback/ are different URIs. For localhost, the port number must match as well.

  • Tunnel hosts such as ngrok change on every restart, so the registered URI has to be updated each time the tunnel address changes.

  • Redirect URIs can be added or modified after registration, so a development URI and a production URI can coexist on the same app.

A reasonable pattern is to register one URI per environment and to keep the value in configuration, never in code:

Environment Redirect URI

Developer machine behind a local HTTPS proxy

https://localhost:3000/auth/webflow/callback

Staging

https://dashboard-staging.example.com/auth/webflow/callback

Production

https://dashboard.example.com/auth/webflow/callback

The URI that the app sends in the authorize link and the one it sends in the token exchange must both equal one of these registered values. If the authorize link includes redirect_uri, the token request has to include it too, unchanged.

Source: Data API: OAuth

Scopes

Scopes are the permissions of a Data Client. They are chosen twice: once in the app’s settings, where they define what the app can ever ask for, and once in the authorize URL, where they define what this particular installation asks for. The two are related by a rule that has consequences for design.

  • Webflow rejects an authorize URL that requests a scope not configured in the app settings.

  • Webflow does not check that the requested scopes are enough. An app that leaves one out is authorized without error, and the API calls that need it fail later with 403.

  • In the authorize URL the scope list is space-separated and URL-encoded: a space is %20 and a colon is %3A, so sites:read cms:read becomes sites%3Aread%20cms%3Aread. The SDK helpers encode this automatically.

Source: Data API: OAuth

The full list of scope names, and the way to read the scope each endpoint needs, is in Data API Authentication and Scopes. For the Fernbrook Content Dashboard the choice is small:

Scope Why the dashboard needs it

authorized_user:read

Show which agency editor connected the site, using the authorized-user endpoint

sites:read

List the sites the user chose to authorize, and read their locales (English, Spanish and German for the garden)

cms:read

Read collections and Plants items

cms:write

Update Plants items and publish them

Scopes are locked after publication

The registration page is explicit about the lifecycle: scopes are locked once the app is published in the Marketplace. Adding a scope afterwards is a change that needs review, and it follows a fixed order. Doing it out of order produces a scopes-mismatch error.

  1. Register a copy of the app to test the new scope without touching the production app.

  2. Submit an App Update that includes the new scopes, through the Marketplace submission form.

  3. After approval, confirm that the scopes appear in the app settings.

  4. Only then change the scope parameter of the OAuth URL. Users re-authorize to grant the new scope.

Two corollaries follow. First, installations made before the update do not gain the new scope, so the app must detect a missing scope and ask the user to reinstall, which the introspection check in the skeleton below does at the moment of connection and can repeat on every login. Second, the practical advice is to request the narrowest set from the beginning: a scope that is never needed costs a re-review nobody wants, but a scope that is missing costs a re-review too, so list the endpoints the app will call before registering.

An install link is the authorize URL given to a person who should connect the app. It is the entry point of the whole flow, and Webflow documents four places where it is used.

  • Generated by the SDK. WebflowClient.authorizeURL in the Node SDK and authorize_url in the Python SDK build the link from the client ID, the scopes, the redirect URI and an optional state.

  • Copied from the dashboard. In Apps & Integrations > App Development, the app’s entry has an Install button, and the link behind it is a ready-made install link for testing.

  • Supplied at Marketplace submission. The reviewers and the Marketplace listing use the link to let users install.

  • Placed on the developer’s own site. A "Connect to Webflow" button can point straight to the authorize URL.

Source: Data API: OAuth

The dashboard link is a convenient way to test a registration before any code exists: pasting it in a browser shows the consent screen with the app name, the scopes and the choice of sites, and a wrong redirect URI or scope is reported immediately. A link that works and a link that builds correctly in code are two different artifacts, so treat the dashboard link as a smoke test, not as the production flow, because the app’s own route is the one that creates and verifies a state value.

A generated link has the following shape; every value is a placeholder.

https://webflow.com/oauth/authorize
  ?client_id=REPLACE_WITH_CLIENT_ID
  &response_type=code
  &redirect_uri=https%3A%2F%2Fdashboard.example.com%2Fauth%2Fwebflow%2Fcallback
  &scope=authorized_user%3Aread%20sites%3Aread%20cms%3Aread%20cms%3Awrite
  &state=REPLACE_WITH_RANDOM_STATE

Source: Data API: OAuth

Before the consent screen appears, a new app is visible only to users of its own workspace. External users cannot install the app until it has been approved and published in the Marketplace, though testers from outside the workspace can be invited beforehand following the user-testing instructions of the documentation. For the Fernbrook agency that means a colleague in the same workspace can install the dashboard at once, and the garden’s own staff, who are in another workspace, cannot until publication or a private listing.

Restricting an app to a single site

On the app’s registration form, Restrict app installation to a specific site controls how many sites one authorization can cover.

Toggle Behavior

On

A user can authorize the app for only one site at a time. Each installation is a separate authorization and has a separate token

Off (default)

A user can authorize the app for several sites or for the entire workspace, and a single token reaches all of them

The OAuth documentation describes no URL parameter that restricts authorization: the choice of site (or workspace) happens on the consent screen, where the user selects what the app may access, and the toggle limits that selection to one site. The toggle is therefore a registration-time decision for the app, not something the authorize URL can ask for per request.

The choice shapes the data model more than the code:

  • With the toggle on, a token belongs to exactly one site, and the natural storage key is the site ID. A client of the agency who owns three Fernbrook-style sites authorizes the app three times and the app holds three tokens.

  • With the toggle off, one token can cover many sites, so the storage key is the authorization, and a site-to-token map is derived from the sites that introspection reports. When the user later revokes one site, the app finds out only when calls for that site fail.

An app that serves one client site at a time, such as a per-site analytics widget, should turn the toggle on: it follows least privilege, makes the consent screen clearer for the person authorizing and makes revocation affect one site only. A dashboard that deliberately spans a client’s whole portfolio should leave it off.

A Data Client skeleton

The Webflow documentation ships a starter repository for Data Clients, webflow-app-starter-v2, which uses Fastify as the server, the Level key-value database and the webflow-api SDK. It needs an app registered with the sites:read scope, a .env file with the client ID and secret, the redirect URI http://localhost:3000/auth and a click on Connect with Webflow to list the sites. It stores a single token under a single key, which the guide itself marks as for demonstration only, and recommends storing tokens per user or per workspace with encryption in production.

The skeleton below keeps the same two-route shape (start the authorization, complete it) but moves to the production concerns that the starter leaves out: HTTPS redirect, random state, a granted-scope check, a token store keyed by site ID and encrypted at rest. It uses Express rather than Fastify so that the dependencies match the other pages of this sub-section.

Project files

{
  "name": "fernbrook-content-dashboard",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22.13" },
  "scripts": { "start": "node --env-file=.env src/server.mjs" },
  "dependencies": {
    "express": "5.3.0",
    "express-session": "1.18.1",
    "webflow-api": "3.3.4"
  }
}

The .env file is created locally and never committed. The variable names are the ones the starter uses for the client ID, with an explicit name for the secret; the values below are placeholders.

# .env (listed in .gitignore, never committed)
WEBFLOW_CLIENT_ID=REPLACE_WITH_CLIENT_ID
WEBFLOW_CLIENT_SECRET=REPLACE_WITH_CLIENT_SECRET
WEBFLOW_REDIRECT_URI=https://localhost:3000/auth/webflow/callback
SESSION_SECRET=REPLACE_WITH_LONG_RANDOM_STRING
# 32 bytes encoded as 64 hexadecimal characters
TOKEN_ENCRYPTION_KEY=REPLACE_WITH_64_HEX_CHARACTERS

The token store

The store is the piece most worth isolating, because it is the part that moves from a Map to a database in production without changing the routes. It encrypts each token with AES-256-GCM using the key from the environment, and indexes by site ID, because that is the question the rest of the app asks: "which token do I use for this site?".

// src/token-store.mjs: per-site token storage, encrypted at rest
import crypto from "node:crypto";

const key = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY ?? "", "hex");
if (key.length !== 32) {
  throw new Error("TOKEN_ENCRYPTION_KEY must be 64 hexadecimal characters");
}

function seal(plain) {
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
  const body = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
  return Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64");
}

function open(sealed) {
  const raw = Buffer.from(sealed, "base64");
  const decipher = crypto.createDecipheriv("aes-256-gcm", key, raw.subarray(0, 12));
  decipher.setAuthTag(raw.subarray(12, 28));
  return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString("utf8");
}

// siteId -> { sealed, scope, userId, savedAt }
// In production this is a database table with a unique index on site_id.
const rows = new Map();

export const tokenStore = {
  save({ siteIds, token, scope, userId }) {
    const sealed = seal(token);
    for (const siteId of siteIds) {
      rows.set(siteId, { sealed, scope, userId, savedAt: new Date().toISOString() });
    }
  },
  get(siteId) {
    const row = rows.get(siteId);
    return row ? open(row.sealed) : undefined;
  },
  sitesFor(userId) {
    return [...rows].filter(([, row]) => row.userId === userId).map(([siteId]) => siteId);
  },
  remove(siteId) {
    rows.delete(siteId);
  },
};

The OAuth routes and a Data API call

The server has the two routes of the starter plus a data route that proves the stored token works for a given site. The OAuth steps are the ones the documentation lists: with no code, build the authorize URL and redirect; with a code, exchange it and store the token. The extra work is the introspection step, which turns the single token into a set of site IDs and a verified scope list before anything is stored.

// src/server.mjs: Data Client skeleton for the Fernbrook Content Dashboard
import crypto from "node:crypto";
import express from "express";
import session from "express-session";
import { WebflowClient } from "webflow-api";
import { tokenStore } from "./token-store.mjs";

const {
  WEBFLOW_CLIENT_ID: clientId,
  WEBFLOW_CLIENT_SECRET: clientSecret,
  WEBFLOW_REDIRECT_URI: redirectUri,
  SESSION_SECRET: sessionSecret,
} = process.env;

for (const [name, value] of Object.entries({ clientId, clientSecret, redirectUri, sessionSecret })) {
  if (!value) throw new Error(`Missing configuration value ${name}`);
}

// The narrowest set the dashboard really uses.
const SCOPES = ["authorized_user:read", "sites:read", "cms:read", "cms:write"];

const app = express();
app.use(
  session({
    secret: sessionSecret,
    resave: false,
    saveUninitialized: false,
    cookie: { httpOnly: true, secure: true, sameSite: "lax", maxAge: 30 * 60 * 1000 },
  }),
);

// 1. Start: build the install link and send the browser to Webflow.
app.get("/auth/webflow", (req, res) => {
  const state = crypto.randomBytes(32).toString("hex");
  req.session.oauthState = state;
  res.redirect(WebflowClient.authorizeURL({ clientId, redirectUri, scope: SCOPES, state }));
});

// 2. Callback: check state, exchange the code, verify the grant, store per site.
app.get("/auth/webflow/callback", async (req, res) => {
  const { code, state, error } = req.query;
  if (error) return res.status(400).send(`Authorization refused: ${error}`);
  if (typeof code !== "string" || typeof state !== "string") {
    return res.status(400).send("Missing code or state");
  }

  const expected = req.session.oauthState ?? "";
  delete req.session.oauthState;
  const a = Buffer.from(state);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(400).send("State does not match");
  }

  try {
    const accessToken = await WebflowClient.getAccessToken({
      clientId,
      clientSecret,
      code,
      redirectUri,
    });

    const { authorization } = await new WebflowClient({ accessToken }).token.introspect();
    const granted = (authorization?.scope ?? "").split(",");
    const missing = SCOPES.filter((scope) => !granted.includes(scope));
    if (missing.length > 0) {
      return res.status(403).send(`Reinstall the app: missing scopes ${missing.join(", ")}`);
    }

    const siteIds = authorization?.authorizedTo?.siteIds ?? [];
    const userId = authorization?.authorizedTo?.userIds?.[0];
    if (siteIds.length === 0 || !userId) {
      return res.status(403).send("No site was authorized. Choose at least one site.");
    }

    tokenStore.save({ siteIds, token: accessToken, scope: authorization.scope, userId });
    req.session.userId = userId;
    res.redirect("/sites");
  } catch (err) {
    console.error("Token exchange failed", err?.statusCode ?? "", err?.message);
    res.status(502).send("Could not complete the Webflow connection");
  }
});

// 3. Use: list the sites of the signed-in user, each through its own stored token.
app.get("/sites", async (req, res) => {
  const siteIds = tokenStore.sitesFor(req.session.userId);
  if (siteIds.length === 0) return res.redirect("/auth/webflow");

  const result = [];
  for (const siteId of siteIds) {
    const client = new WebflowClient({ accessToken: tokenStore.get(siteId) });
    try {
      const site = await client.sites.get(siteId);
      result.push({ id: site.id, name: site.displayName });
    } catch (err) {
      if (err?.statusCode === 401) {
        tokenStore.remove(siteId); // revoked or uninstalled: forget it
        continue;
      }
      throw err;
    }
  }
  res.json(result);
});

// 4. Use: read Plants items of one site.
app.get("/sites/:siteId/plants", async (req, res) => {
  const token = tokenStore.get(req.params.siteId);
  if (!token) return res.status(404).send("Site not connected");

  const client = new WebflowClient({ accessToken: token });
  const { collections } = await client.collections.list(req.params.siteId);
  const plants = collections?.find((c) => c.slug === "plants");
  if (!plants) return res.status(404).send("No Plants collection on this site");

  const { items } = await client.collections.items.listItems(plants.id, { limit: 20 });
  res.json((items ?? []).map((item) => ({ id: item.id, name: item.fieldData?.name })));
});

app.listen(3000, () => console.log("Fernbrook Content Dashboard on port 3000"));

Reading the skeleton:

  • The state is created per attempt and deleted on first use. The official guide shows the same state argument in authorizeURL; the comparison on the callback is the half of CSRF protection that the app must write itself.

  • The exchange happens on the server. The client secret never reaches the browser, and the callback sends redirect_uri again because the authorize link included it.

  • Introspection decides where the token is stored. The introspection response lists authorizedTo.siteIds, so the same code works whether the toggle "restrict app installation" is on (one ID) or off (several IDs, or all of a workspace). If the list is empty the install is useless and the callback says so.

  • A 401 means forget the token. The user may have removed the app from the site or revoked it; the next visit to /sites starts the flow again.

  • The Plants route uses a slug lookup because collection IDs differ on every site, and an agency app that works on several client sites cannot hard-code them. The collection endpoints are covered in Data API: CMS.

The same skeleton in Python

The Python SDK has the same helpers in webflow.oauth. This version uses Flask and the standard library, with cryptography for the encryption.

Flask==3.1.0
webflow==2.0.0
cryptography==44.0.0

Source: PyPI: webflow

# app.py: Data Client skeleton for the Fernbrook Content Dashboard
import hmac
import os
import secrets

from cryptography.fernet import Fernet
from flask import Flask, jsonify, redirect, request, session
from webflow.client import Webflow
from webflow.oauth import authorize_url, get_access_token

CLIENT_ID = os.environ["WEBFLOW_CLIENT_ID"]
CLIENT_SECRET = os.environ["WEBFLOW_CLIENT_SECRET"]
REDIRECT_URI = os.environ["WEBFLOW_REDIRECT_URI"]
SCOPES = ["authorized_user:read", "sites:read", "cms:read", "cms:write"]

# TOKEN_ENCRYPTION_KEY is a urlsafe base64 key made with Fernet.generate_key()
fernet = Fernet(os.environ["TOKEN_ENCRYPTION_KEY"])

app = Flask(__name__)
app.secret_key = os.environ["SESSION_SECRET"]
app.config.update(
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_SAMESITE="Lax",
)

# site_id -> {"sealed": bytes, "user_id": str}; a database table in production
ROWS: dict[str, dict] = {}


def save_token(site_ids, token, user_id):
    sealed = fernet.encrypt(token.encode())
    for site_id in site_ids:
        ROWS[site_id] = {"sealed": sealed, "user_id": user_id}


def load_token(site_id):
    row = ROWS.get(site_id)
    return fernet.decrypt(row["sealed"]).decode() if row else None


@app.get("/auth/webflow")
def start():
    state = secrets.token_hex(32)
    session["oauth_state"] = state
    return redirect(
        authorize_url(
            client_id=CLIENT_ID,
            redirect_uri=REDIRECT_URI,
            scope=SCOPES,
            state=state,
        )
    )


@app.get("/auth/webflow/callback")
def callback():
    if "error" in request.args:
        return f"Authorization refused: {request.args['error']}", 400

    code = request.args.get("code")
    expected = session.pop("oauth_state", "")
    if not code or not hmac.compare_digest(request.args.get("state", ""), expected):
        return "State does not match", 400

    token = get_access_token(
        client_id=CLIENT_ID,
        client_secret=CLIENT_SECRET,
        code=code,
        redirect_uri=REDIRECT_URI,
    )

    authorization = Webflow(access_token=token).token.introspect().authorization
    granted = (authorization.scope or "").split(",")
    missing = [scope for scope in SCOPES if scope not in granted]
    if missing:
        return f"Reinstall the app: missing scopes {', '.join(missing)}", 403

    site_ids = authorization.authorized_to.site_ids or []
    user_ids = authorization.authorized_to.user_ids or []
    if not site_ids or not user_ids:
        return "No site was authorized. Choose at least one site.", 403

    save_token(site_ids, token, user_ids[0])
    session["user_id"] = user_ids[0]
    return redirect("/sites")


@app.get("/sites")
def sites():
    user_id = session.get("user_id")
    mine = [s for s, row in ROWS.items() if row["user_id"] == user_id]
    if not mine:
        return redirect("/auth/webflow")

    result = []
    for site_id in mine:
        site = Webflow(access_token=load_token(site_id)).sites.get(site_id)
        result.append({"id": site.id, "name": site.display_name})
    return jsonify(result)

The Python version omits the data route and the 401 handling for brevity; they follow the Node structure, with the SDK reporting non-success responses as exceptions. The SDK classes are described in Data API: SDKs, Limits and Errors.

Token storage per site

The starter app saves one token under one key and calls itself a demonstration. A real app has to answer three questions that the demonstration skips: whose token is it, which sites does it reach and how is it protected?

What to key the storage on

Storage key Fits when Weakness

User ID

The app acts as one agency editor and a user connects once for several sites

One revoked site is not visible until a call fails; two editors can hold overlapping tokens for the same site

Site ID

The restrict-to-a-site toggle is on, or the app needs a single answer to "which token for this site?"

A token that covers several sites is stored several times, so rotation or revocation must update every row

Workspace ID

The user authorized the whole workspace and the app manages every site in it

New sites in the workspace need checking; a user may revoke site by site later

Authorization ID

The app wants one row per consent and a join table to sites

One more table; the best fit for a multi-tenant product

The skeleton uses the site ID because the question the app asks most often is "I am about to touch this site, which credential do I use?" For a larger product, an authorizations table (ID, encrypted token, scope, user ID, created and last-used times) joined to a site_authorizations table (authorization ID, site ID) answers both that question and "which sites does this consent cover?", and it keeps rotation to a single row. The introspection response carries the fields to populate both tables: authorization.id, scope, createdOn, lastUsed and the authorizedTo lists of site, workspace and user IDs.

How to protect it

  • Encrypt the token column with a key held outside the database, as the example does, and keep that key in a secret manager, not in the repository. The official guide asks to encrypt sensitive data and to use secure session management in production.

  • Never put the token in the cookie, the URL, the page or a log line. The browser holds a session identifier, nothing else.

  • Do not expose the token to a Designer Extension iframe. Even in a hybrid app, the iframe sends a user ID token to the backend and the backend uses the stored OAuth token; the OAuth token itself never travels to the browser.

  • Record when a token was last used so that unused authorizations can be reviewed, and remove the row on a 401.

  • Keep revocation in the interface. A Disconnect button calls the revoke endpoint documented in Data API Authentication and Scopes and deletes the row, so the user does not depend on the Webflow settings page to cut access.

  • Plan for loss. If the encryption key is lost, every stored token is lost. Users must reconnect, which is acceptable for an agency tool and must be a documented procedure.

The access-token documentation does not state a token lifetime or a refresh procedure, so the stored token should be treated as valid until a 401 says otherwise, and every call site has to handle that answer by starting the install flow again.

A table for production

A relational layout that fits the pattern above, in portable SQL:

CREATE TABLE webflow_authorizations (
  id              TEXT PRIMARY KEY,          -- authorization.id from introspection
  user_id         TEXT NOT NULL,
  token_sealed    BYTEA NOT NULL,            -- encrypted, never plain text
  scope           TEXT NOT NULL,             -- comma-separated, as reported
  created_on      TIMESTAMPTZ NOT NULL,
  last_used_at    TIMESTAMPTZ
);

CREATE TABLE webflow_authorized_sites (
  authorization_id TEXT NOT NULL REFERENCES webflow_authorizations (id) ON DELETE CASCADE,
  site_id          TEXT NOT NULL,
  PRIMARY KEY (authorization_id, site_id)
);

CREATE INDEX webflow_authorized_sites_site_idx ON webflow_authorized_sites (site_id);

With this layout, uninstalling an app for one client deletes one authorization and, through the cascade, every site row that referred to it.

From skeleton to the garden’s dashboard

The skeleton is a Data Client. To make it the Fernbrook Content Dashboard of the following pages, the agency adds the remaining pieces in this order.

  1. Registration. A development app in the agency workspace, the four scopes above, the toggle left off because the agency manages several client sites, and one redirect URI per environment.

  2. Install and test. The dashboard link from the Install button, used once, then the application’s own /auth/webflow route used from then on.

  3. Locales. For the garden’s Spanish and German variants the dashboard reads the site’s locales through the Data API and treats each secondary locale item as a separate piece of content; the localization endpoints are in Data API: Ecommerce and Localization.

  4. Webhooks. A subscription tells the dashboard when a Plants item changes, so it can refresh its cache without polling; see Webhooks.

  5. Designer half. A Designer Extension capability turns the app into a hybrid app, described in Hybrid Apps.

  6. Publication. Private listing for the agency’s clients first, Marketplace later, covered in Publishing Apps and the Marketplace.

Setting up the tooling

A Data Client does not need the Webflow CLI: it is a web service and ships like one. The CLI becomes necessary the moment the app gains a Designer Extension, because the extension is initialized, served locally and bundled with it. The pinned versions for the later pages are:

# Node 22.13 or later is required by the CLI
node --version

npm install --global @webflow/webflow-cli@2.15.0
webflow --version

# Typings for TypeScript extension code
npm install --save-dev @webflow/designer-extension-typings@2.2.2

Installing the CLI globally is a convenience for a developer machine. In a repository that others will clone, a local dev dependency pinned to 2.15.0 and run through npx makes the version part of the project, which matters because the extension’s webflow.json and the CLI’s behavior change between versions. The commands are in Webflow CLI.

A comparison with older books and tutorials

Older tutorials describe Webflow integrations as Zapier-style automations, as custom scripts pasted in the site’s custom code settings or as calls made with a long-lived key. The app model changes three assumptions that those sources make:

  • There is a consent screen. Access is granted by a person who chooses sites, and that person can withdraw it, so an integration written as if the credential were permanent is wrong.

  • Scopes are part of the product. They are reviewed, locked on publication and visible to the user at install time.

  • The Designer is extensible. A task that older tutorials solve with copy and paste in the style panel may now be a panel inside the Designer, built as a Designer Extension.

The list of what changed and when is in Where Older Books and Tutorials Are Outdated.

Pitfalls checklist

  • Creating the app in the production workspace instead of a development one, then being unable to remove it cleanly.

  • Registering the redirect URI as /callback and sending /callback/, or forgetting the port of a localhost URI.

  • Leaving a tunnel URL in the registration after the tunnel restarted.

  • Requesting a scope in the authorize URL that is not configured in the app settings: Webflow rejects the link.

  • Leaving a needed scope out of the authorize URL: authorization succeeds and calls fail with 403.

  • Changing the authorize URL scope before the app update that adds it is approved: scopes-mismatch error.

  • Assuming an existing installation gains a new scope after an update: it does not, and the user must reinstall.

  • Using one token for every client site of an agency instead of one stored authorization per consent.

  • Storing the token in a cookie, in local storage, in the URL or in a log.

  • Omitting redirect_uri from the token exchange when the authorize link had it.

  • Letting the 15-minute authorization code expire before the exchange.

  • Treating the dashboard Install link as the production flow instead of the app’s own route that creates and checks state.

  • Handing the OAuth token to a Designer Extension iframe instead of resolving a user ID token on the backend.

  • Expecting an external user to install the app before it has been published, publicly or privately, in the Marketplace.

  • Committing the client secret, or sending it from a browser.

Summary

  • An app is registered in Settings > Apps & Integrations > App Development of a workspace, by a workspace administrator with a verified email and two-factor authentication, on a plan that supports app development.

  • There are three building blocks: a Data Client (backend, OAuth, Data API), a Designer Extension (iframe in the Designer, Designer API) and a Hybrid app (both, tied by a user ID token). Capabilities are chosen at registration and can be combined.

  • The registration form takes a name, a description of at most 140 characters, an icon, an HTTPS homepage and the optional Restrict app installation to a specific site toggle; the app listing then shows the client ID and secret.

  • Redirect URIs are HTTPS, exact to the character (slash and port included) and can be edited after registration.

  • Scopes are declared in the app and requested in the authorize URL; Webflow rejects extras but does not check sufficiency; they are locked after publication and changed only through an approved App Update.

  • Install links come from the SDK, from the dashboard Install button, from the Marketplace submission or from the app’s own site; a new app can be installed only inside its workspace until it is published.

  • A production Data Client verifies state, exchanges the code on the server, checks the granted scopes with introspection and stores an encrypted token keyed by the authorized site IDs.

  • The token documentation states no lifetime or refresh, so every API call handles 401 by restarting the flow.