Hybrid Apps
|
This content was generated with the assistance of AI and should be verified against the official Webflow documentation before being relied on in production. This sub-section’s Webflow bibliography lists the reference material consulted while preparing these pages. |
This page describes Webflow hybrid apps as they were on 2026-10-28, the date on which its facts were checked against the
official documentation. A hybrid app is one app registration that has two capabilities at once: a Designer Extension,
the interface that runs inside the Designer, and a Data Client, the server-side OAuth client that calls the Data API.
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) and the type package @webflow/designer-extension-typings 2.2.2. Registering an app is a Workspace
feature that the developer documentation lists for the Team and Enterprise tiers; plans are named by tier only, and every
example runs against a site in a free Developer workspace. The
Developer Platform Overview explains how to create that workspace.
The running example is Fernbrook Gardens, the fictitious botanical garden of the whole sub-section, whose site has Spanish and German locales next to English. The Plant Label Kit extension of Designer Extensions and the Designer API could only do what the Designer API allows inside one open canvas. This page gives it a backend, so that it can fix the alt text of every image of the site in one pass, in all three locales, with the permissions of the user who is looking at the Designer.
What a hybrid app is
The two capabilities have different strengths and different limits.
| Capability | Runs | Can do |
|---|---|---|
Designer Extension |
In an iframe inside the Designer, as the signed-in user |
Read and change the open canvas through the Designer API: elements, styles, variables, components, the selection and notifications. It cannot call the Data API with a credential of its own, and it must never hold a client secret |
Data Client |
On your server |
Call the Data API |
Source: Apps: Hybrid Apps
The documentation presents the combination as one comprehensive experience: the app’s interface lives inside the Designer, and the Data Client manages site data, assets and more. The problem the combination creates is identity. The extension knows which Designer user is using it; the server holds an OAuth token for some user who once installed the app. Nothing says that the person at the keyboard is that user. Webflow’s answer is the ID token, a short-lived signed statement of identity that the extension asks the Designer for and the server resolves through the Data API.
The identity flow
The flow has five steps. The diagram shows them for the Plant Label Kit.
In words:
-
The extension asks for an ID token.
webflow.getIdToken()returns a promise of a string, a JSON Web Token (JWT) that identifies the current user of the extension. The token stays valid for 15 minutes; after that the extension asks for a new one. -
The extension sends the token to your backend over HTTPS, as the body or an
Authorizationheader of a request of your own design. The token is proof of identity, not a Data API credential. -
The backend resolves the token. The documentation says not to trust the token on its own: send it to the Resolve ID Token endpoint,
POST https://api.webflow.com/beta/token/resolve, which decodes and verifies it and answers with the user’s details. -
The backend finds the stored OAuth token of that user, obtained earlier through the authorization-code flow of Data API Authentication and Scopes. The
idandsiteIdthat the resolve call returned are the lookup keys. -
The backend calls the Data API with that stored token and returns only a summary to the extension.
Source: Designer API: getIdToken
The two calls in detail
getIdToken() is part of the Designer API object webflow:
webflow.getIdToken(): Promise<string>;
Source: Designer API: getIdToken
The resolve endpoint is documented in the Data API v2 beta reference:
| Item | Value |
|---|---|
Method and URL |
|
Content type |
|
Authorization |
|
Required scope |
|
Request body |
A JSON object with the optional string field |
200 response |
A JSON object whose fields are all optional: |
Errors |
|
Source: Data API: Resolve ID Token
Two details deserve attention. The scope authorized_user:read is the same one that
GET /v2/token/authorized_by needs, so an app that never asked for it cannot resolve tokens, and the failure shows up
as 403. And the authorization of the resolve call is an app token: the extension’s token only goes in the body.
Because the endpoint belongs to the beta API, its shape can change; the
Product Changes and Deprecations page tracks such changes.
The extension side
The Plant Label Kit already has a panel with a button. The new button asks the Designer for the token, posts it to the
backend and reports the result with webflow.notify. The @webflow/designer-extension-typings package gives the global
webflow object its types:
{
"name": "plant-label-kit",
"private": true,
"type": "module",
"scripts": {
"dev": "webflow extension serve",
"build": "webflow extension bundle"
},
"devDependencies": {
"@webflow/designer-extension-typings": "2.2.2",
"@webflow/webflow-cli": "2.15.0",
"typescript": "7.0.2"
}
}
Source: Apps: Webflow CLI reference
The extension code is small, because the backend does the work. The backend address comes from one constant, so the same bundle can point to a local server during development and to the real host in production:
// src/fix-alt-text.ts: runs inside the Designer iframe
const BACKEND_URL = "https://localhost:8443"; // replaced by the production host in the bundle
export async function fixAltText(): Promise<void> {
// The token is valid for 15 minutes, so ask for a fresh one on every click.
const idToken = await webflow.getIdToken();
const response = await fetch(`${BACKEND_URL}/api/fix-alt-text`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idToken }),
});
if (response.status === 401) {
await webflow.notify({
type: "Error",
message: "Connect the Fernbrook backend to Webflow first",
});
return;
}
if (!response.ok) {
await webflow.notify({ type: "Error", message: "The alt text pass failed" });
return;
}
const summary: { changed: number; skipped: number } = await response.json();
await webflow.notify({
type: "Success",
message: `Alt text updated on ${summary.changed} images (${summary.skipped} skipped)`,
});
}
document
.getElementById("fix-alt-text")
?.addEventListener("click", () => void fixAltText());
Source: Designer API: getIdToken
The extension never sees the OAuth token and never needs the client secret. A browser request from the iframe to the backend is cross-origin, so the backend must allow the extension’s origin with CORS; allow exactly that origin, as the browser reports it, rather than a wildcard.
The backend side
The backend has three jobs: resolve the ID token, find the stored OAuth token and run the pass. The Data Client half
(authorize URL, callback, token exchange and a token vault) is the Express application of
Data API Authentication and Scopes; the code below adds one
route to it and reuses its tokenVault. The scopes of the app for this feature are authorized_user:read (to resolve),
sites:read, assets:read and assets:write.
// fix-alt-text.mjs: one extra route for the Express server of the Content Dashboard
import express from "express";
const API = "https://api.webflow.com";
async function webflow(path, accessToken, init = {}) {
const response = await fetch(`${API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"Content-Type": "application/json",
...init.headers,
},
});
if (!response.ok) {
const error = new Error(`Webflow ${response.status} on ${path}`);
error.status = response.status;
throw error;
}
return response.json();
}
export function altTextRoute({ tokenVault, siteLocales }) {
const router = express.Router();
router.post("/api/fix-alt-text", express.json(), async (req, res) => {
const { idToken } = req.body ?? {};
if (typeof idToken !== "string" || idToken.length === 0) {
return res.status(400).json({ error: "idToken is required" });
}
// 1. Resolve the Designer user. The bearer token is the app's stored token.
let user;
try {
user = await webflow("/beta/token/resolve", process.env.WEBFLOW_SERVICE_TOKEN, {
method: "POST",
body: JSON.stringify({ idToken }),
});
} catch (err) {
return res.status(err.status === 400 ? 400 : 401).json({ error: "invalid ID token" });
}
// 2. Find the OAuth token stored for this user. No token: send them to connect.
const accessToken = tokenVault.get(user.id);
if (!accessToken || !user.siteId) {
return res.status(401).json({ error: "not connected", connect: "/auth/webflow" });
}
// 3. Run the pass with the stored token, never with the ID token.
const result = await fixSiteAltText(user.siteId, accessToken, siteLocales);
res.json(result);
});
return router;
}
Source: Data API: Resolve ID Token
The line WEBFLOW_SERVICE_TOKEN stands for whichever Data Client token the server uses to authorize the resolve call; the
reference only states that it must come from a Data Client app and carry authorized_user:read. Keep it in the secret
manager like any other token, as explained in
Data API Authentication and Scopes. The siteId returned by
the resolve call is the site the Designer is open on, so the backend never needs the browser to say which site to touch.
Bulk-fixing alt text
The pass lists the assets of the site, 100 at a time, and for each image without alt text writes one derived from the
file name, in every locale. Webflow stores asset alt text per locale; the update endpoint accepts a localeId query
parameter and an altText body field:
| Endpoint | Detail |
|---|---|
|
Scope |
|
Scope |
Source: Data API: Update Asset
The Fernbrook rule is deliberately conservative. It never overwrites alt text that a person wrote, it skips anything that is not an image, and the Spanish and German text comes from a translation table that the garden’s editors maintain, falling back to the English text only when no translation exists:
// fixSiteAltText: continues fix-alt-text.mjs
const PAGE_SIZE = 100;
// Editor-maintained translations, keyed by the file name without extension.
const TRANSLATIONS = {
"japanese-maple": {
en: "Japanese maple in autumn colour",
es: "Arce japones en colores de otono",
de: "Japanischer Faecherahorn in Herbstfarben",
},
};
function describe(asset, language) {
const key = asset.originalFileName.replace(/\.[^.]+$/, "").toLowerCase();
const entry = TRANSLATIONS[key];
if (entry) return entry[language] ?? entry.en;
// Fallback: turn "rose-garden-path.jpg" into "Rose garden path".
const words = key.replace(/[-_]+/g, " ").trim();
return words.charAt(0).toUpperCase() + words.slice(1);
}
export async function fixSiteAltText(siteId, accessToken, siteLocales) {
let offset = 0;
let total = Infinity;
let changed = 0;
let skipped = 0;
while (offset < total) {
const page = await webflow(
`/v2/sites/${siteId}/assets?limit=${PAGE_SIZE}&offset=${offset}`,
accessToken,
);
total = page.pagination?.total ?? 0;
offset += PAGE_SIZE;
for (const asset of page.assets ?? []) {
const isImage = asset.contentType?.startsWith("image/");
if (!isImage || asset.altText) {
skipped += 1;
continue;
}
// siteLocales: [{ language: "en", id: "REPLACE_WITH_LOCALE_ID" }, ...]
for (const locale of siteLocales) {
await webflow(`/v2/assets/${asset.id}?localeId=${locale.id}`, accessToken, {
method: "PATCH",
body: JSON.stringify({ altText: describe(asset, locale.language) }),
});
}
changed += 1;
}
}
return { changed, skipped };
}
Source: Data API: List Assets
Points to notice:
-
The loop follows
pagination.total, so a site with more than 100 assets is handled without a second request design. -
offsetpagination over a list that the loop itself edits is safe here because the loop changes alt text and never adds or removes assets. -
One
PATCHper image and locale is three calls per image. The Data API rate limit is per key, so a large library needs the backoff described in Data API: SDKs, Limits and Errors. -
The code only fills gaps. A reviewer can always see which images changed because those are the ones whose
lastUpdatedmoved; alt text quality still needs an editor, and the guidance in Accessibility on writing useful alt text applies. -
The locales come from the site, not from a constant: the Spanish and German
localeIdvalues are listed by the localization endpoints of Data API: E-commerce and Localization.
Source: Data API: Update Asset
Launch contexts
An extension can be opened in more than one way, and behaviour that depends on how it was opened is read with
webflow.getLaunchContext(). It returns a promise of a LaunchContext object or null, and the object has two fields.
| Field | Meaning |
|---|---|
|
How the extension was launched: |
|
Extra launch information: |
Source: Designer API: getLaunchContext
The Plant Label Kit uses it to decide what to show first. Opened from the Apps panel it shows the whole panel; opened for an image intent it goes straight to the alt text step:
async function chooseStartView(): Promise<void> {
const context = await webflow.getLaunchContext();
if (context === null || context.type === "AppsPanel") {
showView("home");
return;
}
if (context.type === "AppIntent" && typeof context.value === "object" && context.value?.image) {
showView("alt-text");
return;
}
showView("home");
}
Source: Designer API: getLaunchContext
The method needs the canDEAccessCanvas ability, which works in any locale, branch, workflow and site mode. The
appIntents and appConnections that make the other launch types possible are declared in webflow.json, together
with size, publicDir and the featureFlags object; the file is covered in
Designer Extensions and the Designer API.
Deep links
A hybrid app often starts outside the Designer: an agency’s own dashboard, a welcome email or a Marketplace listing. A deep link opens the Designer of a site with the app already running. Its form is:
https://<site short name>.design.webflow.com?app=<client id>
Source: Apps: Deep linking
The site short name is the subdomain-sized identifier of the site, which the Designer API and the Data API’s Get Site
endpoint return. The app parameter is the app’s client ID, shown under Apps & Integrations in the app’s settings of
the dashboard. For a site whose short name is fernbrook-gardens and an app whose client ID is REPLACE_WITH_CLIENT_ID
the link is https://fernbrook-gardens.design.webflow.com?app=REPLACE_WITH_CLIENT_ID.
The behaviour depends on the installation:
-
The app is installed on the site. The Designer opens with the extension running.
-
The app is not installed. The Designer opens normally without the extension, and the parameter has no effect.
That second case is the reason the Fernbrook dashboard should confirm the installation before it shows a link. The
introspection response of GET /v2/token/introspect lists the siteIds the token reaches, so a dashboard that holds a
token for the site knows the app is installed there. The documentation describes the deep link as a way to smooth the
hand-off from a third-party experience into the Designer Extension; it describes no token exchange, so the identity step
is still the ID token flow of this page.
Local development
The Designer loads an extension in development from a local server, so the edit-reload loop does not involve uploading bundles. The CLI scaffolds a project with a template and serves it:
npm install --global @webflow/webflow-cli@2.15.0
webflow extension list # the templates available to init
webflow extension init plant-label-kit react # project name, then the template
cd plant-label-kit
npm install
npm run dev # serves the extension on port 1337
webflow extension serve takes --port to use a port other than 1337. To try the extension, install the app on a test
site of the Developer workspace (Workspace settings > Apps & Integrations > Develop, the app’s menu, Install), open
the site in the Designer, press E to open the Apps panel and choose Launch development app; refresh the page if the
app does not appear. The same page lists Node.js 16.20 as the minimum for that tutorial; the CLI release pinned on this
page needs Node 22.13 or later, so use the newer version.
A hybrid app adds a second server to that picture, and three rules keep the loop working:
-
Run the backend over HTTPS. The redirect URI of a Data Client must be HTTPS and must match the registered value exactly, including the port. Serve the backend with a local certificate or register a tunnel URL; tunnel hosts that change on every restart must be registered again each time.
-
Point the extension at the local backend through the single
BACKEND_URLconstant, and allow the extension’s origin in the backend’s CORS configuration. -
Keep one app registration. The development app and the production app should be separate registrations in the Developer workspace, so the development client ID, secret and redirect URIs never mix with the published ones.
# backend/.env (not committed)
WEBFLOW_CLIENT_ID=REPLACE_WITH_CLIENT_ID
WEBFLOW_CLIENT_SECRET=REPLACE_WITH_CLIENT_SECRET
WEBFLOW_REDIRECT_URI=https://localhost:8443/auth/webflow/callback
WEBFLOW_SERVICE_TOKEN=REPLACE_WITH_APP_TOKEN
SESSION_SECRET=REPLACE_WITH_LONG_RANDOM_STRING
Source: Apps: Register an app
The first-run experience
A user who opens the extension for the first time has no stored token, so the backend answers 401 with a connect path.
The extension should open that path in a new browser tab, because the authorization page cannot run inside the Designer’s
iframe, and then poll or ask the user to click again once the callback has stored the token. A deep link is the return
trip: the callback page of the backend can end with a button to the Designer deep link of the site, so the user lands
back in the extension with the connection complete.
| Situation | What the backend sees | What to do |
|---|---|---|
First use, no stored token |
Resolve succeeds, vault has no entry |
|
Token revoked by the user |
The Data API answers |
Drop the stored token and ask to reconnect |
ID token older than 15 minutes |
Resolve fails |
Ask the extension for a new token and retry once |
Scope added by an app update |
Data API calls answer |
Ask the user to re-authorize; existing installs do not gain scopes |
Source: Data API: OAuth
Security checklist
-
The ID token proves identity for 15 minutes; it is not a Data API credential and must never be used as a bearer token against
api.webflow.com/v2. -
Always resolve the token on the server. The extension’s claim of who the user is carries no weight until
POST /beta/token/resolvehas accepted the token. -
Key the token vault by the resolved
id, not by anything the browser sends. -
Never return an OAuth token, client secret or stored credential to the iframe; return summaries.
-
Request only the scopes the feature uses, here
authorized_user:read,sites:read,assets:readandassets:write. -
Do not log ID tokens or the resolved email address.
-
Restrict CORS to the extension’s origin and require HTTPS on every route.
Pitfalls
-
Calling
https://api.webflow.com/v2/token/resolveinstead of thebetapath. -
Authorizing the resolve call with the ID token instead of an app token that carries
authorized_user:read. -
Caching an ID token across clicks and meeting the 15-minute expiry.
-
Assuming the resolved user is the user who installed the app: the vault may have no entry for them.
-
Forgetting that a deep link to a site without the app installed opens a plain Designer.
-
Registering the development redirect URI without the port, or with a trailing slash that the app does not send.
-
Writing alt text over text an editor wrote; the pass above only fills gaps.
Summary
-
A hybrid app is one registration with a Designer Extension (canvas, inside the Designer) and a Data Client (Data API, on a server).
-
webflow.getIdToken()returns a JWT valid for 15 minutes; the backend resolves it withPOST https://api.webflow.com/beta/token/resolve, an app bearer token withauthorized_user:readand a bodyidToken, and getsid,email,firstName,lastNameandsiteId. -
The backend then calls the Data API with the OAuth token it stored for that user; the extension never holds it.
-
webflow.getLaunchContext()reportsAppsPanel,AppConnectionorAppIntent; deep links have the formhttps://<site short name>.design.webflow.com?app=<client id>and do nothing if the app is not installed. -
Local development serves the extension on port 1337 and needs an HTTPS backend with a registered redirect URI.
-
The Plant Label Kit backend lists assets 100 at a time and fills missing alt text per locale with
PATCH /v2/assets/{asset_id}?localeId=....