Data API: Assets, Forms and Custom Code
|
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 Data API v2 as it was on 2026-10-28, the date on which its facts were checked against the
official developer documentation and the API reference. It covers three resource families that belong together because
each is "everything around the CMS": the files that CMS items and pages point to (assets), the data that visitors send
in (forms) and the scripts that a site loads (custom code). The examples are written for a site in a Developer
workspace, so they run without a paid plan, and they use placeholder identifiers and environment variables that you must
replace. The JavaScript examples use webflow-api 3.3.4 on Node 22 or later, and the Python examples use the requests
package (the Python SDK is the webflow package, current version 2.0.0, which wraps the same endpoints). Plans are named
by tier only, never by price. Tokens, scopes and OAuth are explained in
Data API: Authentication and Scopes, SDK behaviour, rate limits
and the error format in Data API: SDKs, Limits and Errors, and
the Designer side of the same features in Images and Assets,
Forms and Custom Code in the Head,
Footer and Embeds.
The running example is Fernbrook Gardens, a fictitious botanical garden with Plants, Garden zones, Events and Journal
Collections and a site published in Spanish and German as well as English. Three jobs on this page come from it: a
migration script that uploads two hundred plant photographs into a Plants asset folder before the CMS items that
reference them are created; a nightly job that copies the "Plan your visit" form submissions into the garden’s own
visitor database and then deletes them; and a small application that installs a cookie-banner script on the site without
anybody pasting code into Site settings.
The map of this page
| Family | Endpoints (all under https://api.webflow.com/v2) |
Scopes |
|---|---|---|
Assets |
|
|
Forms |
|
|
Custom code |
|
|
Source: Data API: Upload asset
Every list endpoint on this page paginates with offset and limit, and limit is at most 100. Every response carries
a pagination object with limit, offset and total. A 429 status means the token’s per-minute budget is spent; the
Retry-After header says how long to wait, and the JavaScript SDK retries with exponential backoff on its own.
Assets
An asset is a file stored by Webflow and served from its CDN: an image in the Assets panel, the file behind a CMS image or file field, a PDF, a Lottie animation. The Data API manages the file records and the folders that organise them. The Designer side (the Assets panel, alt text, responsive variants) is in Images and Assets; this section is the programmatic half.
Limits and supported types
The Working with Assets guide lists the rules that apply to an API upload exactly as they apply to the Designer:
-
Images must not exceed 4 MB, documents 10 MB.
-
Images: PNG, JPEG, GIF, SVG, WebP and AVIF. Documents: PDF, DOC and DOCX, XLS and XLSX, PPT and PPTX, TXT, CSV, ODT, ODS and ODP. Lottie: JSON and dotLottie.
-
fileNameincludes the extension and must be shorter than 100 characters. -
Uploaded files are public. Search engines may not index a file that no public page links to, but anyone who has the URL can download it, so a private document must never be uploaded as an asset.
-
Scopes:
assets:readfor the list and get endpoints,assets:writefor create, update and delete. A site token or an OAuth token can call them.
Source: Data API: Working with Assets
The upload flow
An upload is two requests because the bytes do not travel through the API server. The first request registers the asset and returns instructions; the second sends the file to Amazon S3 with a presigned POST.
-
Hash the file. Compute the MD5 of the file’s binary contents and send it as
fileHash. Webflow uses the hash for data integrity and to avoid storing the same file twice; when the hash matches a file that already exists, no redundant copy is kept. -
Register.
POST /v2/sites/{site_id}/assetswith a JSON body offileName,fileHashand, optionally,parentFolder(the ID of an asset folder). The response (status 200) containsid,contentType,parentFolder,hostedUrl(the public link, available as soon as the upload completes),assetUrl(the S3 location),originalFileName,createdOn,lastUpdated,uploadUrlanduploadDetails. -
Upload.
uploadDetailsholds the form fields of an S3 presigned POST:acl,bucket,X-Amz-Algorithm,X-Amz-Credential,X-Amz-Date,key,Policy,X-Amz-Signature,success_action_status,content-typeandCache-Control. Send them all asmultipart/form-datafields touploadUrl, and add the file as the last field, namedfile. S3 ignores form fields that come after the file field, so the order matters. -
Describe. Updating the asset’s alt text and display name is a separate
PATCHcall.
Source: Data API: Upload asset
The following Node script uploads one Fernbrook photograph into the Plants folder, creating the folder first when it
does not exist. It needs WEBFLOW_API_TOKEN (a site token with assets:read and assets:write) and
WEBFLOW_SITE_ID in the environment.
// upload-asset.mjs -- npm install webflow-api@3.3.4
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { WebflowClient } from "webflow-api";
const client = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN });
const siteId = process.env.WEBFLOW_SITE_ID; // REPLACE_WITH_SITE_ID in your environment
const MIME = { ".webp": "image/webp", ".jpg": "image/jpeg", ".png": "image/png", ".pdf": "application/pdf" };
async function ensureFolder(name) {
const { assetFolders = [] } = await client.assets.listFolders(siteId);
const found = assetFolders.find((f) => f.displayName === name && !f.parentFolder);
if (found) return found.id;
const created = await client.assets.createFolder(siteId, { displayName: name });
return created.id;
}
export async function uploadAsset(path, folderId, altText) {
const bytes = await readFile(path);
const fileName = basename(path);
if (fileName.length >= 100) throw new Error("fileName must be shorter than 100 characters");
// 1. Register the asset. The MD5 hash lets Webflow detect duplicates.
const fileHash = createHash("md5").update(bytes).digest("hex");
const asset = await client.assets.create(siteId, { fileName, fileHash, parentFolder: folderId });
// 2. Send the file to S3 with the presigned POST fields. The file field goes last.
const form = new FormData();
for (const [name, value] of Object.entries(asset.uploadDetails ?? {})) {
form.append(name, String(value));
}
const ext = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
form.append("file", new Blob([bytes], { type: MIME[ext] ?? "application/octet-stream" }), fileName);
const response = await fetch(asset.uploadUrl, { method: "POST", body: form });
if (!response.ok) throw new Error(`S3 upload failed: ${response.status} ${await response.text()}`);
// 3. Add the description that the Designer and the CMS image fields will show.
if (altText) await client.assets.update(asset.id, { altText });
return { id: asset.id, url: asset.hostedUrl };
}
const folderId = await ensureFolder("Plants");
console.log(await uploadAsset("./photos/lavender.webp", folderId, "Lavender in flower on a terraced bed"));
Source: Data API: Upload asset
The same flow in Python, with requests, is shorter because requests builds the multipart body itself. The file goes
into files, the presigned fields into data, and requests writes the form fields before the file part.
# upload_asset.py -- pip install requests
import hashlib
import os
import pathlib
import requests
API = "https://api.webflow.com/v2"
HEADERS = {"Authorization": f"Bearer {os.environ['WEBFLOW_API_TOKEN']}"}
SITE_ID = os.environ["WEBFLOW_SITE_ID"] # REPLACE_WITH_SITE_ID in your environment
def upload_asset(path: str, folder_id: str | None = None, alt_text: str | None = None) -> dict:
p = pathlib.Path(path)
data = p.read_bytes()
body = {"fileName": p.name, "fileHash": hashlib.md5(data).hexdigest()}
if folder_id:
body["parentFolder"] = folder_id
# 1. Register the asset.
r = requests.post(f"{API}/sites/{SITE_ID}/assets", json=body, headers=HEADERS, timeout=30)
r.raise_for_status()
asset = r.json()
# 2. POST the presigned fields plus the file to S3 (no Webflow Authorization header here).
s3 = requests.post(
asset["uploadUrl"],
data=asset["uploadDetails"],
files={"file": (p.name, data)},
timeout=120,
)
s3.raise_for_status()
# 3. Describe the asset.
if alt_text:
requests.patch(
f"{API}/assets/{asset['id']}", json={"altText": alt_text}, headers=HEADERS, timeout=30
).raise_for_status()
return {"id": asset["id"], "url": asset["hostedUrl"]}
if __name__ == "__main__":
print(upload_asset("photos/lavender.webp", alt_text="Lavender in flower on a terraced bed"))
Source: Data API: Upload asset
Four pitfalls cause most failed uploads:
-
Sending the Webflow
Authorizationheader to S3. The presignedPolicyandX-Amz-Signaturealready authorise the request. An extra bearer header is not needed and puts the token in a request to a third-party host. -
Hashing the wrong bytes. The MD5 must be computed over the same bytes that are uploaded. A script that re-encodes the image between hashing and uploading produces a mismatch.
-
A short-lived policy. The presigned POST is meant to be used immediately. Do not store
uploadDetailsfor later; register the asset again when you are ready to upload. -
Going over the limits. Check the size before step 1. A 5 MB hero image fails the 4 MB image limit; resize it first (see Performance Optimization).
Listing, updating and deleting assets
GET /v2/sites/{site_id}/assets lists a site’s assets. It accepts offset, limit (at most 100), localeId and
folderId. The folderId filter returns the assets in that folder and in all of its descendant folders, and must be a
24-character hexadecimal ObjectId. Each asset has id, contentType, size in bytes, siteId, hostedUrl,
originalFileName, displayName, createdOn, lastUpdated, altText (nullable), folderId (present in list
responses; null for an asset at the root) and variants. A variant is one of the responsive renditions that Webflow
generates for raster images, with its own hostedUrl, format, width, height, quality and an error field that
reports a validation problem when a variant could not be produced.
GET /v2/assets/{asset_id} returns one asset. PATCH /v2/assets/{asset_id} changes displayName (not localizable) and
altText; with the localeId query parameter the alt text is written for a secondary locale, which is how the Spanish
and German alt texts of Fernbrook’s images are filled (see
Data API: E-commerce and Localization for the locale IDs).
DELETE /v2/assets/{asset_id} removes the asset, requires assets:write and cannot be undone through the API.
// List every asset in the Plants folder (and its sub-folders) that has no alt text yet.
import { WebflowClient } from "webflow-api";
const client = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN });
const siteId = process.env.WEBFLOW_SITE_ID;
const plantsFolderId = process.env.PLANTS_FOLDER_ID; // from the folder list
let offset = 0;
const missing = [];
for (;;) {
const page = await client.assets.list(siteId, { folderId: plantsFolderId, limit: 100, offset });
const assets = page.assets ?? [];
missing.push(...assets.filter((a) => !a.altText).map((a) => ({ id: a.id, name: a.displayName })));
offset += assets.length;
if (assets.length === 0 || offset >= (page.pagination?.total ?? 0)) break;
}
console.table(missing);
Source: Data API: List assets
A delete is the one asset operation with consequences outside the API: CMS items, pages and components that reference the file keep a reference to a file that no longer exists, so the image disappears on the next publish. Search the references first, in the Designer’s Assets panel or by listing items, and prefer replacing the file over deleting it.
Asset folders
Folders organise the Assets panel and nest. Three endpoints exist, and there is no update or delete endpoint for a folder:
-
GET /v2/sites/{site_id}/asset_folders(assets:read) lists the folders. Each hasid,displayName,parentFolder(null at the root),assets(an array of asset IDs),siteId,createdOnandlastUpdated. -
GET /v2/asset_folders/{asset_folder_id}returns one folder. -
POST /v2/sites/{site_id}/asset_folders(assets:write) creates a folder fromdisplayNameand an optionalparentFolderID.
Because folders cannot be renamed or removed through the API, a script that creates a folder should look the folder up by
name first, as ensureFolder does above; otherwise every run creates another folder with the same name. Folder names are
not unique, so the lookup should also compare parentFolder.
# Create Plants/Perennials unless it already exists.
import os
import requests
API = "https://api.webflow.com/v2"
HEADERS = {"Authorization": f"Bearer {os.environ['WEBFLOW_API_TOKEN']}"}
SITE_ID = os.environ["WEBFLOW_SITE_ID"]
def ensure_folder(name: str, parent: str | None = None) -> str:
r = requests.get(f"{API}/sites/{SITE_ID}/asset_folders", headers=HEADERS, timeout=30)
r.raise_for_status()
for folder in r.json().get("assetFolders", []):
if folder["displayName"] == name and folder.get("parentFolder") == parent:
return folder["id"]
body = {"displayName": name, **({"parentFolder": parent} if parent else {})}
r = requests.post(f"{API}/sites/{SITE_ID}/asset_folders", json=body, headers=HEADERS, timeout=30)
r.raise_for_status()
return r.json()["id"]
plants = ensure_folder("Plants")
perennials = ensure_folder("Perennials", plants)
print(plants, perennials)
Source: Data API: Create asset folder
Assets and CMS image fields
A CMS image field stores an object with the asset’s fileId, url and alt. To fill one, upload the asset first, then
send its hostedUrl (and optionally alt) in the item’s fieldData; Webflow resolves the URL to the asset. The order is
the reason the migration for Fernbrook uploads all photographs before it creates the Plants items. Field value formats are
covered in Data API: The CMS, and the CMS field types in
Collections and Field Types.
Custom fonts
The Data API has no endpoint for fonts. The endpoint families in the v2 reference are assets, asset folders, forms,
custom code and so on, and fonts appear in none of them. Custom fonts are uploaded in the Designer’s site settings, in
the Fonts section, and are not listed by the assets endpoints, which only know about files in the Assets panel. A font
file hosted by the site’s own assets can still be referenced from custom CSS, but the font then does not appear in the
Designer’s font pickers. A pipeline that must install fonts has two options: upload them once by hand in the settings and
keep the font files in the code repository, or serve them from a CDN and load them with a @font-face rule in a
registered stylesheet. The Designer workflow, licences and variable fonts are in
Typography and Fonts.
Forms
A Webflow form that uses the default action stores each submission against the site. The Data API reads those stored submissions and the form definitions; it does not create submissions (visitors do that through the published form) and it does not edit a form’s fields, which belong to the page in the Designer. What a form is, where submissions are stored, spam protection and the plan limits are in Forms. To react in real time instead of polling, subscribe to the form submission event described in Webhooks.
Listing forms and reading their schema
GET /v2/sites/{site_id}/forms (scope forms:read) returns forms and pagination. Each form has id, displayName,
createdOn, lastUpdated, siteId, siteDomainId, workspaceId, pageId, pageName, a nullable formElementId,
a fields object keyed by field ID and responseSettings. A field has a displayName, a type (Plain, Email,
Password, Phone or Number), a nullable placeholder and userVisible. responseSettings carries redirectUrl,
redirectMethod, redirectAction and sendEmailConfirmation. GET /v2/forms/{form_id} returns the schema of one
form.
The formElementId is the stable handle of a form. When a form lives inside a component, every instance of the component
produces its own form with its own id, but all instances share the formElementId of the form in the component
definition. That is the key to use when you want all submissions of "the" contact form regardless of the page (see
Components, Props, Slots and Variants).
A request fails with 409 when the site must be republished before the forms endpoints can be used: the error code is
forms_require_republish. Publish the site once from the Designer or with the publish endpoint (see
Publishing and Scheduling) and retry.
Source: Data API: List forms
Reading submissions
Two list endpoints exist and they return the same submission objects:
-
GET /v2/sites/{site_id}/forms/{form_id}/submissionslists the submissions of one form ID. -
GET /v2/sites/{site_id}/form_submissionslists the submissions of a whole site and accepts anelementIdquery parameter, theformElementIdfrom the form list, to narrow them to one form including every component instance.
Both accept offset and limit (at most 100) and need forms:read. A submission has id, displayName (the form’s
name on the site), siteId, workspaceId, dateSubmitted, formResponse (an object keyed by the field names, holding
the submitted values) and localeId, which is null for primary-locale submissions and for sites without localization. A
submission from the Spanish version of the site therefore carries the Spanish locale ID, which lets a back-office route
replies in the visitor’s language.
GET /v2/sites/{site_id}/form_submissions/{form_submission_id} returns one submission.
Updating hidden fields
PATCH /v2/sites/{site_id}/form_submissions/{form_submission_id} (scope forms:write) updates hidden fields: the body
is {"formSubmissionData": {"<hidden field name>": "<value>"}}, and every name must be a hidden field that already exists
in the form schema. Visible fields that the visitor filled in cannot be edited. The practical use is a small workflow
state kept with the submission itself, for example a hidden status field that a staff app sets to answered, so that
the Designer’s submission list shows who handled which enquiry.
Deleting submissions
DELETE /v2/sites/{site_id}/form_submissions/{form_submission_id} (scope forms:write) removes a submission and answers
204 without a body. Deleting is the way to honour an erasure request and to keep personal data from piling up: the
nightly Fernbrook job copies a submission into its own database, confirms the copy and then deletes the original.
Like the update endpoint, it can answer 409 until the site has been republished.
Source: Data API: Delete form submission
A complete export-and-delete job
The job pages through every submission of the "Plan your visit" form, writes each to a JSON Lines file and deletes the
ones that were written. It uses the plain fetch API for the delete call so that the example does not depend on the SDK
method signature for it.
// export-submissions.mjs -- Node 22, npm install webflow-api@3.3.4
import { appendFileSync } from "node:fs";
import { WebflowClient } from "webflow-api";
const token = process.env.WEBFLOW_API_TOKEN; // site token with forms:read and forms:write
const siteId = process.env.WEBFLOW_SITE_ID;
const client = new WebflowClient({ accessToken: token });
const { forms = [] } = await client.forms.list(siteId, { limit: 100 });
const visit = forms.find((f) => f.displayName === "Plan your visit");
if (!visit) throw new Error("Form not found; check the form name in the Designer");
let offset = 0;
const exported = [];
for (;;) {
const page = await client.sites.forms.listSubmissionsBySite(siteId, {
elementId: visit.formElementId ?? undefined,
limit: 100,
offset,
});
const batch = page.formSubmissions ?? [];
for (const s of batch) {
appendFileSync("submissions.jsonl", JSON.stringify(s) + "\n");
exported.push(s.id);
}
offset += batch.length;
if (batch.length === 0 || offset >= (page.pagination?.total ?? 0)) break;
}
// Delete only after the whole export succeeded. Deleting while paging would shift the offsets.
for (const id of exported) {
const res = await fetch(`https://api.webflow.com/v2/sites/${siteId}/form_submissions/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 60) * 1000));
continue; // this id is retried on the next run
}
if (res.status !== 204) console.error("delete failed", id, res.status);
}
console.log(`exported ${exported.length} submissions`);
Source: Data API: Delete form submission
# export_submissions.py -- the same job with requests
import json
import os
import time
import requests
API = "https://api.webflow.com/v2"
HEADERS = {"Authorization": f"Bearer {os.environ['WEBFLOW_API_TOKEN']}"}
SITE_ID = os.environ["WEBFLOW_SITE_ID"]
forms = requests.get(f"{API}/sites/{SITE_ID}/forms", headers=HEADERS, timeout=30).json()["forms"]
visit = next(f for f in forms if f["displayName"] == "Plan your visit")
exported, offset = [], 0
with open("submissions.jsonl", "a", encoding="utf-8") as out:
while True:
params = {"limit": 100, "offset": offset}
if visit.get("formElementId"):
params["elementId"] = visit["formElementId"]
r = requests.get(f"{API}/sites/{SITE_ID}/form_submissions", headers=HEADERS, params=params, timeout=30)
r.raise_for_status()
body = r.json()
batch = body.get("formSubmissions", [])
for s in batch:
out.write(json.dumps(s, ensure_ascii=False) + "\n")
exported.append(s["id"])
offset += len(batch)
if not batch or offset >= body["pagination"]["total"]:
break
for sid in exported:
r = requests.delete(f"{API}/sites/{SITE_ID}/form_submissions/{sid}", headers=HEADERS, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "60")))
elif r.status_code != 204:
print("delete failed", sid, r.status_code)
print(f"exported {len(exported)} submissions")
Handle the data with care. Submissions contain personal data, so store the export encrypted, restrict who can read it and
delete the original on the schedule your privacy notice promises (see the privacy section of
Forms). A site token with forms:write can delete every submission of the site, so create
a dedicated token for this job and keep it out of source control.
Custom code
The Custom Code API lets an application add JavaScript to a site or to a single page without anybody editing Site settings. It works in two stages: first a script is registered on the site, then the registered script is applied to the site or to a page. A script that is registered but not applied does nothing.
The OAuth-only rule
"Only Webflow Apps with OAuth tokens can call the custom code API endpoints, not clients with site or Workspace tokens." This is stated in the Working with Custom Code guide, and the reference for the site and page endpoints repeats that the call needs a bearer token obtained from the OAuth code grant flow. The consequences:
-
A script that installs custom code must be a Data Client App with an OAuth flow (see Apps Overview and OAuth and Data API: Authentication and Scopes); the site token you used for assets and forms returns an authorisation error here.
-
The guide lists the scopes
sites:read,sites:write,pages:read,pages:write,custom_code:readandcustom_code:write. The registration and site endpoints needcustom_code:write; the page endpoints needcustom_code:writeand the page scopes. -
The scripts an App has applied belong to that App.
GET /sites/{site_id}/custom_codereturns "all scripts applied to a site by the App", so one App never sees or overwrites the scripts of another. The Site settings code box in the Designer is a separate place, described in Custom Code in the Head, Footer and Embeds, and it is not touched by the API.
Registering scripts
POST /v2/sites/{site_id}/registered_scripts/hosted registers a script that you host yourself, and
POST /v2/sites/{site_id}/registered_scripts/inline registers source code that Webflow hosts. A site holds at most 800
registered scripts, and a registration made after that limit fails.
| Field | Meaning |
|---|---|
|
Required. A user-facing name, 1 to 50 alphanumeric characters. The response |
|
Required. A semantic version such as |
|
Required. The URI of the script on your own server or CDN |
|
Required. The Subresource Integrity hash of the file |
|
Required. The JavaScript, without |
|
Optional, false by default. Whether the script is copied when the site is duplicated or transferred |
The inline script size limit needs a caution. The reference page for the inline endpoint says that inline scripts are limited to 2,000 characters, while the guide mentions 10,000 characters. Keep inline code under 2,000 characters, and use a hosted script for anything larger.
Source: Data API: Register inline script
The response status is 201 and returns id, canCopy, displayName, hostedLocation, integrityHash, createdOn,
lastUpdated and version. GET /v2/sites/{site_id}/registered_scripts lists all of them.
Subresource Integrity
A hosted script must carry an SRI hash so that the browser can refuse a file that differs from the registered one. The
guide computes it by fetching the script and hashing the response with SHA-256 in SRI format (sha256- followed by the
base64 digest). Webflow compares the hash with the file’s content, so the hosted file must not change after the hash is
computed; a deployment that rewrites the file (for example a CDN that minifies it on the fly) breaks the integrity check
and the script is blocked.
import { createHash } from "node:crypto";
// Returns an SRI string such as "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
export async function sriFor(url) {
const body = Buffer.from(await (await fetch(url)).arrayBuffer());
return "sha256-" + createHash("sha256").update(body).digest("base64");
}
Versions are immutable
Registered scripts cannot be overwritten: "you cannot overwrite scripts at this time". A new release is a new
registration with a higher version, and applying it replaces the old version in the site’s list. The old registration
stays on the site, counting toward the 800 limit, so a release pipeline should bump the version on every change, never
reuse one, and avoid registering a version for every commit. The version in the apply call must match a registered
version.
Applying scripts: the replace-all PUT
PUT /v2/sites/{site_id}/custom_code (scope custom_code:write) applies registered scripts to a site and
PUT /v2/pages/{page_id}/custom_code does the same for one page. The body is a list:
{
"scripts": [
{
"id": "cookie_banner",
"location": "footer",
"version": "1.0.0",
"attributes": { "data-region": "eu" }
}
]
}
Each entry has id (the registered script’s ID), location (header for inside <head> or footer for just before
</body>), version and optional attributes, key and value pairs that Webflow writes onto the script tag (a way to pass
a configuration to the script without editing it).
The call is replace-all. The reference says: "If you have multiple scripts your App needs to apply or maintain on a site, ensure they are always included in the request body." To remove a script, send the list without it. Sending only the new script silently removes everything your App applied earlier. The safe pattern is read, append, write:
-
GETthe current list for the site or page. -
Remove an entry with the same
idif you are changing its version, then add the new entry. -
PUTthe whole list back.
Two requests that run at the same time can still overwrite each other (the second read does not see the first write), so serialise installs for one site in your App, for example with a queue per site.
// install-cookie-banner.mjs -- run inside the App's OAuth callback or a job that holds the user's access token
import { WebflowClient } from "webflow-api";
import { sriFor } from "./sri.mjs";
const accessToken = process.env.WEBFLOW_OAUTH_ACCESS_TOKEN; // from the OAuth code exchange
const siteId = process.env.WEBFLOW_SITE_ID;
const client = new WebflowClient({ accessToken });
const hostedLocation = "https://cdn.fernbrook-gardens.example/cookie-banner-1.0.0.js";
// 1. Register the hosted script (displayName + version must be new).
const registered = await client.scripts.registerHosted(siteId, {
displayName: "Cookie banner",
version: "1.0.0",
hostedLocation,
integrityHash: await sriFor(hostedLocation),
canCopy: true,
});
// 2. Read what the App already applied, then append (the apply call replaces the list).
let current = [];
try {
const existing = await client.sites.scripts.getCustomCode(siteId);
current = existing.scripts ?? [];
} catch (err) {
if (err.statusCode !== 404) throw err; // a site with nothing applied yet
}
const scripts = [
...current.filter((s) => s.id !== registered.id),
{ id: registered.id, location: "footer", version: registered.version, attributes: { "data-region": "eu" } },
];
// 3. Write the full list back.
await client.sites.scripts.upsertCustomCode(siteId, { scripts });
// 4. Custom code is staged: nothing is live until the site is published.
console.log("Applied. Publish the site to make the banner live.");
# install_cookie_banner.py -- the same read-append-write with requests
import os
import requests
API = "https://api.webflow.com/v2"
HEADERS = {"Authorization": f"Bearer {os.environ['WEBFLOW_OAUTH_ACCESS_TOKEN']}"}
SITE_ID = os.environ["WEBFLOW_SITE_ID"]
# Register an inline script (Webflow hosts it). Keep it under 2,000 characters.
reg = requests.post(
f"{API}/sites/{SITE_ID}/registered_scripts/inline",
headers=HEADERS,
json={
"displayName": "Garden analytics hook",
"version": "1.0.0",
"sourceCode": "window.fernbrook = { ready: true };",
"canCopy": True,
},
timeout=30,
)
reg.raise_for_status()
script = reg.json()
# Read, append, write. The PUT replaces the whole list for this App.
current = requests.get(f"{API}/sites/{SITE_ID}/custom_code", headers=HEADERS, timeout=30)
scripts = current.json().get("scripts", []) if current.ok else []
scripts = [s for s in scripts if s["id"] != script["id"]]
scripts.append({"id": script["id"], "location": "header", "version": script["version"]})
put = requests.put(f"{API}/sites/{SITE_ID}/custom_code", headers=HEADERS, json={"scripts": scripts}, timeout=30)
put.raise_for_status()
print(put.json())
Page-level scripts and inspecting what is applied
The page endpoints mirror the site endpoints: GET, PUT and DELETE on /v2/pages/{page_id}/custom_code. A script
that only the Events page needs, such as a ticket-widget loader, is applied to that page’s ID so that it does not load on
every page of the site (see Performance Optimization). The same replace-all
rule applies, with the page’s own list.
DELETE /v2/sites/{site_id}/custom_code removes every script the App applied to the site and answers 204. It does not
unregister them: the entries stay in the registered list. To remove a single script, use the PUT without it.
GET /v2/sites/{site_id}/custom_code/blocks (scope custom_code:read) lists every block of applied code on the site and
on its pages, with siteId, pageId (null for site-level), type (site or page), scripts, createdOn and
lastUpdated, paged with offset and limit. It is the way to audit where a script is in use before changing it.
Staged until published
Applying, updating or removing scripts changes staged custom code. The guide states that the changes "only take effect when the site is published". Publishing pushes every staged change live, not only yours, so an App should ask the user before it publishes (see Publishing and Scheduling for the publish endpoint and its one-per-minute limit). Custom code does not run in the Designer’s canvas, only on the published site and in preview mode; an App that serves its own scripts must allow the preview origin, which the "Supporting custom code in preview mode" guide describes.
Security and lifecycle
-
Pin versions and hashes. A hosted script with SRI cannot be altered after registration without the browser refusing it. This protects the site against a compromised CDN account. Do not register a URL that points to "latest".
-
Keep secrets out of scripts. Registered code is public JavaScript, delivered to every visitor. Never place an API token or an internal URL in it.
-
Least privilege. Request only the scopes the App uses.
custom_code:writecan change what runs in the visitor’s browser on every page, which is as powerful as editing the site’s source. Review it as you would a deployment key. -
Clean up on uninstall. The guide advises keeping the scopes that let the App remove what it applied when a customer uninstalls it, and publishing each affected site so that the removal takes effect.
-
Deliver code through the API, not by pasting. Pasted code cannot be versioned or removed by the App, and it runs twice if the App also applies the same script. For Marketplace Apps, scripts are part of the reviewed experience, so a change means a new version and an App update (see Publishing Apps and the Marketplace).
Choosing the right tool
| Task | Tool | Why |
|---|---|---|
Upload many photographs once |
Data API assets, or the CLI |
Scriptable, repeatable and checked by the MD5 hash |
Add alt text in Spanish and German |
|
The alt text is localizable, the display name is not |
Install a font |
Designer, site settings |
No Data API endpoint |
Mirror form submissions elsewhere in real time |
Form submission webhook |
Push instead of polling; see Webhooks |
Back-fill or purge stored submissions |
Forms endpoints with |
The only bulk read and delete path |
Add a tracking script by hand, once |
Site settings code box |
No App or OAuth flow is needed |
Install or update a script from an App |
Custom code API with OAuth |
Versioned, removable, scoped to the App |
Summary
-
An asset upload is register (
POST /sites/{site_id}/assetswith the MD5fileHash) then a multipart POST of theuploadDetailsfields plus the file, last, to the S3uploadUrl. Images are limited to 4 MB, documents to 10 MB. -
Asset folders can be listed, read and created but not renamed or deleted through the API, and fonts have no endpoint.
-
Forms endpoints read form schemas and submissions, update hidden fields and delete submissions; use
elementIdto collect every instance of a form that lives in a component, and expect a 409 until the site is republished. -
Custom code needs an OAuth App token. Register a script (hosted with an SRI hash, or inline), then apply it with the replace-all
PUT: always read, append and write the whole list. -
Applied code is staged until the site is published, and registered versions are immutable.