How to use the Webflow custom code API to push scripts to specific pages

Learn to register and apply scripts to Webflow pages with the Data API.

How to use the Webflow custom code API to push scripts to specific pages

Colin Lateano
Developer Evangelist
View author profile
Colin Lateano
Developer Evangelist
View author profile
Table of contents

Webflow's Custom Code API lets you apply scripts to specific pages without touching the Designer; it's useful for analytics tags, tracking pixels, or any snippet that needs to land on targeted pages at scale.

Webflow's Custom Code API lets you register scripts once at the site level and then apply them to specific pages programmatically, no Designer required. It's the right approach when you need to push tracking pixels, debugging utilities, or analytics tags to targeted pages at scale, or when you need those deployments scripted and repeatable.

This guide walks through the full workflow: both registration types, the GET-before-PUT pattern that prevents data loss, and the four failure modes that catch most developers on the first run.

What do you need to apply custom code via the Webflow Data API?

You need three things: a Webflow App with OAuth authorization, your site ID and target page IDs, and the script content itself.

Each of these three requirements has a specific constraint that matters before you write a line of code. The most consequential is the credential type. Using a site token is the single most common blocker here, and the error message won't tell you why.

Here's what each requirement looks like in practice.

A Webflow App with OAuth (not a site token)

Site tokens (the API keys generated under Site Settings > Apps & integrations > API access) do not grant access to the Custom Code endpoints. This is the single most common blocker developers hit.

Webflow token limitations are explicit: custom code requires a Webflow Data Client App authorized via OAuth, with custom_code:read and custom_code:write scopes. A site token on any call to these endpoints returns a 401.

If you've built with the CMS API before, you already know how site tokens work. Forget that pattern for this workflow. You need a full Webflow Data Client App. Webflow's developer docs walk through app creation, redirect URI configuration, and the OAuth token exchange. The access token produced by that flow authenticates every request below.

Once you have a token, set it as an environment variable:

export WEBFLOW_OAUTH_TOKEN=your_oauth_access_token_here

All requests in this guide use this token in the Authorization: Bearer header.

If you're building this into a Node.js application, pull it from process.env.WEBFLOW_OAUTH_TOKEN. The code examples below use the native fetch API, available in Node 18+. No additional dependencies required beyond the webflow-api npm package if you prefer the SDK wrapper.

Your site ID and the page IDs you want to target

The site ID appears in the Webflow Designer URL: webflow.com/dashboard/sites/[site-id]/....

Grab it from there or fetch it via the API:

const sitesResponse = await fetch("https://api.webflow.com/v2/sites", {
  headers: {
    Authorization: `Bearer ${process.env.WEBFLOW_OAUTH_TOKEN}`,
  },
});

const { sites } = await sitesResponse.json();
const siteId = sites[0].id;
console.log("Site ID:", siteId);

For page IDs, list pages on the site:

const pagesResponse = await fetch(
  `https://api.webflow.com/v2/sites/${siteId}/pages`,
  {
    headers: {
      Authorization: `Bearer ${process.env.WEBFLOW_OAUTH_TOKEN}`,
    },
  }
);

const { pages } = await pagesResponse.json();
pages.forEach((page: { title: string; id: string; slug: string }) => {
  console.log(`${page.title} (${page.slug}): ${page.id}`);
});

Build a map of page slugs to IDs. When applying scripts in bulk, you'll reference IDs frequently, and having the map in a variable saves repeated API calls.

Note: The Webflow CLI will add native support for listing pages from the command line in an upcoming release, removing the need for a one-off script like this. You can check the CLI command reference for the latest available commands.

The script content or hosted URL

For inline registration, prepare the raw JavaScript string. The 2,000-character limit applies only to the source code. If your snippet is close to or over that limit, host it externally and use the hosted registration path instead.

For hosted registration, you need the public URL where the script file lives and its SRI hash.

Generate the hash from the actual file content:

curl -s https://your-cdn.com/script.js | openssl dgst -sha256 -binary | openssl enc -base64

Prepend sha256- to the output. The full value goes in the integrityHash field: sha256-abc123.... If this hash doesn't match the file content at the hosted URL, registration fails with a 400.

Once these three elements are in place, the workflow runs in about five minutes. Here's how it goes.

5 steps to apply a script to a Webflow page via the Data API

The workflow has two distinct phases: site-level registration (once per script) and page-level application (once per page, per script). The apply step requires reading the current page state before writing to it. Skipping the read causes data loss.

Before starting, make sure you have your OAuth token set, your site ID handy, and the script content ready.

1. Register your script with the site

Registration is a one-time operation per script. Run it once per environment; after that, you reference the script by ID from any page-level call. The endpoint and request body differ slightly between inline and hosted registration.

For inline registration:

const registerResponse = await fetch(
  `https://api.webflow.com/v2/sites/${siteId}/registered_scripts/inline`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WEBFLOW_OAUTH_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      sourceCode: `console.log("Custom Code API loaded");`,
      displayName: "Page Debug Logger",
      version: "1.0.0",
      location: "footer",
      canCopy: true,
    }),
  }
);

const script = await registerResponse.json();
console.log("Script ID:", script.id);
// Output: Script ID: page_debug_logger

The location field controls where Webflow injects the script. "header" puts it inside <head>. "footer" puts it immediately before </body>. Set this based on when you need the script to execute: analytics and tag managers typically go in the header; DOM-manipulating scripts go in the footer.

The id field in the response is the auto-derived script ID you'll use for every page-level call. Log it to your console and keep it. You don't need to re-register to retrieve it later, but having it on hand skips a lookup step.

Hosted registration (when your script exceeds the inline limit)

Hosted registration points Webflow to a script file on your CDN rather than embedding the source code inline. Use this path when your snippet exceeds the 2,000-character limit, or when you want to update the script content without re-registering it.

The trade-off is the `integrityHash` field: you're responsible for generating and maintaining the SRI hash, and if it doesn't match the file at registration time, the call fails with a 400.

For hosted registration:

const registerHostedResponse = await fetch(
  `https://api.webflow.com/v2/sites/${siteId}/registered_scripts/hosted`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WEBFLOW_OAUTH_TOKEN}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      hostedLocation: "https://your-cdn.com/analytics.js",
      integrityHash: "sha256-abc123yourhashhere",
      displayName: "Custom Analytics",
      version: "1.0.0",
      location: "header",
      canCopy: false,
    }),
  }
);

const hostedScript = await registerHostedResponse.json();
console.log("Hosted Script ID:", hostedScript.id);

If you get a 409 on this call, a script with the same derived ID already exists on the site. Either increment the version, use the existing script ID directly in your page calls, or choose a different display name.

2. Confirm existing scripts on the site (optional but recommended)

Before working with page-level calls, a quick check of the full registered script list confirms the registration went through and shows all script IDs on the site. This is also how you audit before hitting the 800-script limit.

Run this:

const registeredResponse = await fetch(
  `https://api.webflow.com/v2/sites/${siteId}/registered_scripts`,
  {
    headers: {
      Authorization: `Bearer ${process.env.WEBFLOW_OAUTH_TOKEN}`,
    },
  }
);

const { registeredScripts } = await registeredResponse.json();
registeredScripts.forEach(
  (s: { id: string; version: string; location: string }) => {
    console.log(`${s.id} — v${s.version} — ${s.location}`);
  }
);

The response returns all scripts registered on the site, unfiltered by page. If you're working across multiple scripts, this gives you a clean reference before the page-level work starts.

3. Fetch the current scripts on the target page

This step is not optional. The PUT endpoint that applies scripts to a page replaces the entire list of scripts. If you skip the GET and PUT a single script, every script that was on that page before is gone.

I've debugged this mistake more than once. A developer new to the API skips the read, does a PUT with a single script, then spends minutes trying to figure out why all their other page scripts have stopped working. The API is doing exactly what it was told to do. Always read the current state before writing.

Fetch the current script list for the page:

const pageId = "your-target-page-id";

const currentResponse = await fetch(
  `https://api.webflow.com/v2/pages/${pageId}/custom_code`,
  {
    headers: {
      Authorization: `Bearer ${process.env.WEBFLOW_OAUTH_TOKEN}`,
    },
  }
);

const { scripts: currentScripts = [] } = await currentResponse.json();
console.log(`Found ${currentScripts.length} existing script(s) on this page:`);
currentScripts.forEach((s: { id: string; location: string }) => {
  console.log(`  - ${s.id} at ${s.location}`);
});

If the page has no custom code yet, the response returns an empty array. The merge step in Step 4 handles that case the same way, since the spread just produces an array with only your new script.

4. Check for duplicates, then merge your new script into the list

Before building the updated array, confirm the script isn't already applied. Sending a PUT with the same script ID listed twice causes the call to fail with a 409.

Here's the dedup check and merge:

const scriptId = "page_debug_logger";
const scriptLocation = "footer" as const;

const alreadyApplied = currentScripts.some(
  (s: { id: string }) => s.id === scriptId
);

if (alreadyApplied) {
  console.log(`Script "${scriptId}" is already on this page. Skipping.`);
  process.exit(0);
}

// Merge: keep all existing scripts, append the new one
const updatedScripts = [
  ...currentScripts,
  {
    id: scriptId,
    location: scriptLocation,
    attributes: {}, // optional: pass key-value pairs as HTML attributes on the script tag
  },
];

The attributes field is a key-value object that maps to HTML attributes on the rendered <script> tag. Passing { "data-page": "home", async: "true" } renders as <script data-page="home" async="true">. Leave it as an empty object if you don't need custom attributes.

The location value on the page-level apply call can differ from what you set at registration. Registration sets a default, but a page-level application lets you override it on a per-page basis. Most workflows set it consistently to the same value as registration.

5. PUT the updated script list on the page

With the merged array ready, write it to the page:

const applyResponse = await fetch(
  `https://api.webflow.com/v2/pages/${pageId}/custom_code`,
  {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${process.env.WEBFLOW_OAUTH_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ scripts: updatedScripts }),
  }
);

if (applyResponse.ok) {
  console.log("Scripts applied successfully.");
} else {
  const error = await applyResponse.json();
  console.error("Apply failed:", error);
}

The PUT call replaces the full script list on the page with whatever scripts you pass in. Because you fetched the existing list in Step 3 and merged it in Step 4, all previous scripts stay in place, and your new script is appended.

To verify: GET the page scripts again after the PUT and confirm your script ID appears in the response array. The entire round-trip (register, read, merge, write) takes under a second per page. For bulk operations across dozens of pages, wrap Steps 3–5 in a loop over your page IDs.

Bulk apply across multiple pages:

const targetPageIds = ["page-id-1", "page-id-2", "page-id-3"];

for (const pid of targetPageIds) {
  const res = await fetch(`https://api.webflow.com/v2/pages/${pid}/custom_code`, {
    headers: { Authorization: `Bearer ${process.env.WEBFLOW_OAUTH_TOKEN}`},
  });
  const { scripts: existing = [] } = await res.json();

  if (existing.some((s: { id: string }) => s.id === scriptId)) {
    console.log(`Page ${pid}: already has script, skipping`);
    continue;
  }

  await fetch(`https://api.webflow.com/v2/pages/${pid}/custom_code`, {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${process.env.WEBFLOW_OAUTH_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ scripts: [...existing, { id: scriptId, location: "footer" }] }),
  });

  console.log(`Page ${pid}: script applied`);
}

On Starter and Basic plans, the rate limit is 60 requests per minute per API key. On CMS, eCommerce, and Business plans, it's 120. For large sites, add a small delay between iterations if you're applying to hundreds of pages back-to-back.

A await new Promise(r => setTimeout(r, 600)) between each loop iteration keeps you well under the limit.

What causes Webflow custom code API calls to fail?

Most failures stem from one of four causes: the wrong credential type, a destructive PUT without a prior GET, SRI hash problems, or a script ID collision.

Here's how to identify and fix each.

401 on every custom code request: site token used instead of OAuth

The Custom Code API returns a 401 when you authenticate with a site token. This is the number-one mistake developers make when they first try to use these endpoints. The error message from the API is generic and doesn't explicitly say "wrong credential type". It just returns unauthorized.

Check your request headers. If Authorization: Bearer is followed by a token you generated from Site Settings > Apps & integrations > API access, that's the issue. Site tokens work fine for the CMS API and most other Webflow API endpoints, but they explicitly don't cover custom code.

Swap to the OAuth access token from your Webflow App, and the 401 goes away.

Scripts disappearing from a page after a PUT

A PUT that replaces only some of the page's scripts, while silently removing the rest, always means the existing list wasn't fetched before the PUT, or was fetched but not included in the request body.

The safest pattern is a reusable function that always reads before writing:

async function addScriptToPage(
  pageId: string,
  scriptId: string,
  location: "header" | "footer"
) {
  const headers = {
    Authorization: `Bearer ${process.env.WEBFLOW_OAUTH_TOKEN}`,
    "Content-Type": "application/json",
  };

  // Always read first
  const getRes = await fetch(`https://api.webflow.com/v2/pages/${pageId}/custom_code`, {
    headers,
  });
  const { scripts: existing = [] } = await getRes.json();

  // Skip if already applied
  if (existing.some((s: { id: string }) => s.id === scriptId)) return;

  // Merge and write
  await fetch(`https://api.webflow.com/v2/pages/${pageId}/custom_code`, {
    method: "PUT",
    headers,
    body: JSON.stringify({
      scripts: [...existing, { id: scriptId, location }],
    }),
  });
}

Treat this as the only path for applying scripts. Never construct the scripts array from scratch without first reading the current state.

409 Conflict on script registration

A 409 on the registration endpoint means the displayName you provided generates a script ID that already exists on the site. Because IDs are derived from display names and are immutable, you can't overwrite an existing registration.

Your options are to use the existing script ID directly (no need to re-register the same script), change the displayName to something that generates a different ID, or increment the version field. Note that incrementing the version creates a separate registration. It doesn't update the existing one. The old registration stays on the site.

If you're unsure which IDs are already registered, fetch the site's list of registered scripts before attempting a new registration. That list shows every ID currently in use.

SRI hash errors on hosted script registration

If registration returns a 400 and you're using hosted registration, the integrityHash is either malformed or doesn't match the file content. The format must be exactly sha256-[base64-encoded hash] with no spaces or newlines in the hash string.

Regenerate the hash from the live file:

curl -s https://your-cdn.com/script.js | openssl dgst -sha256 -binary | openssl enc -base64 -A

The -A flag on the base64 command outputs the hash on a single line. Copy the output and prepend sha256- before putting it in integrityHash. If the file at hostedLocation changes after registration, re-register with the new hash and an incremented version string.

Webflow validates the hash at registration time; subsequent changes to the hosted file won't automatically cause failures, but they will break integrity verification in browsers.

Build your site’s automation layer

This guide covered registering scripts at both the inline and hosted levels, reading page state before writing to it, and patterns that prevent the most common data-loss failures.

The same GET-merge-PUT pattern applies to site-level custom code as well; the endpoint changes (`/v2/sites/{siteId}/custom_code`), but the read-before-write logic is identical. If you need to push a script across every page in a single call rather than page-by-page, the site-level custom code endpoint is the next step.

Explore developer documentation for capabilities beyond what these endpoints expose (site-wide script management, app webhooks and OAuth app architecture).

Frequently asked questions

Can I use a site token for the Custom Code API?

No. Site tokens are explicitly excluded from the Custom Code endpoints. Any request using a site token returns a 401. You need a Webflow Data Client App with OAuth authorization and custom_code:read / custom_code:write scopes.

What happens if I PUT an empty scripts array?

It removes all custom code from that page. The PUT endpoint treats the scripts array as the complete desired state. Passing an empty array is equivalent to clearing every script from the page. This is expected behavior, not a bug.

Is there a rate limit on Custom Code API calls?

Yes. Limits are per API key: 60 requests per minute on Starter and Basic plans, 120 per minute on CMS, eCommerce, and Business plans. See Webflow pricing for current plan details. When applying scripts across hundreds of pages in a loop, add a brief delay between calls to stay under the limit.

Can I update a registered script's source code after registration?

No. Registrations are immutable. To update an inline script's code or a hosted script's URL, register a new version with the same display name and an incremented version string. Then update any page-level references to the new version if needed. Webflow tracks registrations by ID and version separately.

How do I remove a script from one page without clearing all scripts?

GET the current scripts for the page, filter out the script you want to remove, then PUT the filtered array back. The PUT with the remaining scripts removes only the excluded one and leaves everything else intact. The same GET-merge-PUT pattern applies for both adding and removing.


Last Updated
May 8, 2026
Category

Related articles

How to prevent page scroll when a modal is open in Webflow + the iOS Safari fix
How to prevent page scroll when a modal is open in Webflow + the iOS Safari fix

How to prevent page scroll when a modal is open in Webflow + the iOS Safari fix

How to prevent page scroll when a modal is open in Webflow + the iOS Safari fix

Development
By
Colin Lateano
,
,
Read article
How to add styled tooltips to Webflow without using jQuery
How to add styled tooltips to Webflow without using jQuery

How to add styled tooltips to Webflow without using jQuery

How to add styled tooltips to Webflow without using jQuery

Development
By
Colin Lateano
,
,
Read article
How to add a Calendly popup modal to Webflow and keep visitors on-site
How to add a Calendly popup modal to Webflow and keep visitors on-site

How to add a Calendly popup modal to Webflow and keep visitors on-site

How to add a Calendly popup modal to Webflow and keep visitors on-site

Development
By
Colin Lateano
,
,
Read article
How do you add a GDPR-compliant cookie consent banner to Webflow using Cookiebot?
How do you add a GDPR-compliant cookie consent banner to Webflow using Cookiebot?

How do you add a GDPR-compliant cookie consent banner to Webflow using Cookiebot?

How do you add a GDPR-compliant cookie consent banner to Webflow using Cookiebot?

Development
By
Colin Lateano
,
,
Read article

verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.