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

GET/POST /sites/{site_id}/assets, GET/PATCH/DELETE /assets/{asset_id}, GET/POST /sites/{site_id}/asset_folders, GET /asset_folders/{asset_folder_id}

assets:read, assets:write

Forms

GET /sites/{site_id}/forms, GET /forms/{form_id}, GET /sites/{site_id}/forms/{form_id}/submissions, GET /sites/{site_id}/form_submissions, GET/PATCH/DELETE /sites/{site_id}/form_submissions/{form_submission_id}

forms:read, forms:write

Custom code

GET /sites/{site_id}/registered_scripts, POST /sites/{site_id}/registered_scripts/hosted, POST /sites/{site_id}/registered_scripts/inline, GET/PUT/DELETE /sites/{site_id}/custom_code, GET/PUT/DELETE /pages/{page_id}/custom_code, GET /sites/{site_id}/custom_code/blocks

custom_code:read, custom_code:write, plus the site and page scopes

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.

  • fileName includes 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:read for the list and get endpoints, assets:write for create, update and delete. A site token or an OAuth token can call them.

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.

sequenceDiagram participant S as Script participant W as Webflow Data API participant B as S3 bucket S->>S: Read file and compute MD5 hash S->>W: POST /sites/{site_id}/assets with fileName, fileHash, parentFolder W-->>S: id, hostedUrl, uploadUrl, uploadDetails S->>B: POST uploadUrl as multipart form with uploadDetails fields then the file B-->>S: 201 Created (or the status in success_action_status) S->>W: PATCH /assets/{id} with altText and displayName W-->>S: Asset with variants
  1. 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.

  2. Register. POST /v2/sites/{site_id}/assets with a JSON body of fileName, fileHash and, optionally, parentFolder (the ID of an asset folder). The response (status 200) contains id, contentType, parentFolder, hostedUrl (the public link, available as soon as the upload completes), assetUrl (the S3 location), originalFileName, createdOn, lastUpdated, uploadUrl and uploadDetails.

  3. Upload. uploadDetails holds 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-type and Cache-Control. Send them all as multipart/form-data fields to uploadUrl, and add the file as the last field, named file. S3 ignores form fields that come after the file field, so the order matters.

  4. Describe. Updating the asset’s alt text and display name is a separate PATCH call.

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"));

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"))

Four pitfalls cause most failed uploads:

  • Sending the Webflow Authorization header to S3. The presigned Policy and X-Amz-Signature already 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 uploadDetails for 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);

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 has id, displayName, parentFolder (null at the root), assets (an array of asset IDs), siteId, createdOn and lastUpdated.

  • GET /v2/asset_folders/{asset_folder_id} returns one folder.

  • POST /v2/sites/{site_id}/asset_folders (assets:write) creates a folder from displayName and an optional parentFolder ID.

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)

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.

Reading submissions

Two list endpoints exist and they return the same submission objects:

  • GET /v2/sites/{site_id}/forms/{form_id}/submissions lists the submissions of one form ID.

  • GET /v2/sites/{site_id}/form_submissions lists the submissions of a whole site and accepts an elementId query parameter, the formElementId from 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.

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`);
# 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:read and custom_code:write. The registration and site endpoints need custom_code:write; the page endpoints need custom_code:write and the page scopes.

  • The scripts an App has applied belong to that App. GET /sites/{site_id}/custom_code returns "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

displayName

Required. A user-facing name, 1 to 50 alphanumeric characters. The response id is derived from it ("human readable id"), for example a display name Cookie banner yields an ID such as cookie_banner

version

Required. A semantic version such as 1.0.0. Each name and version pair is unique

hostedLocation (hosted)

Required. The URI of the script on your own server or CDN

integrityHash (hosted)

Required. The Subresource Integrity hash of the file

sourceCode (inline)

Required. The JavaScript, without <script> tags. Webflow uploads it and serves it from its own CDN, so an inline script also ends up with a hostedLocation

canCopy

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.

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:

  1. GET the current list for the site or page.

  2. Remove an entry with the same id if you are changing its version, then add the new entry.

  3. PUT the 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:write can 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 assets commands

Scriptable, repeatable and checked by the MD5 hash

Add alt text in Spanish and German

PATCH /assets/{id} with localeId

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 forms:read and forms:write

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}/assets with the MD5 fileHash) then a multipart POST of the uploadDetails fields plus the file, last, to the S3 uploadUrl. 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 elementId to 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.