How to fix Webflow inventory API issues on Webflow Cloud

How to fix Webflow inventory API errors with a secure Webflow Cloud route

Learn how to fix the SKU ID and scope errors behind Webflow's inventory API by proxying it through a gated Next.js route on Webflow Cloud.

How to fix Webflow inventory API errors with a secure Webflow Cloud route

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

Most inventory API errors trace back to a product ID sent where a SKU ID belongs, and a gated Webflow Cloud route keeps the token on the server while it fixes that.

Webflow's inventory endpoint reads and updates stock for one SKU at a time, and it fails in ways the error body rarely explains. A 404 usually means a product ID went where a SKU ID belongs. A 409 means the site does not have Ecommerce turned on, and calling the endpoint from the browser would expose your site token anyway.

In this article, we’ll set up a secure proxy route for the inventory API on Webflow Cloud.

What do you need to proxy the inventory API in Webflow?

You need five things:

  • Webflow site with Ecommerce: The inventory endpoint returns a 409 when Ecommerce is not enabled on the site, so check that first.
  • Webflow site token: Create it in Site settings under Apps & integrations with ecommerce:read, ecommerce:write and cms:read, and treat it as a secret from the start.
  • Node.js 22.13.0 or later: That is the Webflow CLI 2.x engine floor, which is stricter than the general Node.js 22 requirement for Webflow Cloud.
  • npm: Webflow Cloud supports only npm, so install and lock dependencies with npm.
  • SKU IDs: The endpoint path is /v2/collections/{sku_collection_id}/items/{sku_id}/inventory, so you need the SKU collection ID and the ID of each SKU you want to read or update.

The SKU IDs are where most builds go wrong, so step 1 finds them before any code exists.

6 steps to proxy the inventory API in Webflow Cloud

The build is one dynamic Route Handler that reads and updates stock for a SKU, a middleware.ts gate that keeps writes behind a shared secret, and a client component that calls the route through the mount path.

1. Find the SKU collection ID and your SKU IDs

Every product in Webflow Ecommerce has at least one SKU, and inventory belongs to the SKU, not the product. A product with sizes or colors has one SKU per variant, each with its own stock count.

The inventory endpoint takes the SKU's item ID and the ID of the SKUs collection, so a product ID copied from the Designer or the products list returns a 404 even when the token and path are right.

The products endpoint returns each product with its SKUs nested inside, and the collections list gives you the SKU collection ID. Export your site ID and token in a terminal, then run both requests:

curl -s "https://api.webflow.com/v2/sites/$SITE_ID/products?limit=100" \
  -H "Authorization: Bearer $WEBFLOW_SITE_TOKEN" \
  | jq '.items[] | {product: .product.fieldData.name, skus: [.skus[] | {id, name: .fieldData.name}]}'

curl -s "https://api.webflow.com/v2/sites/$SITE_ID/collections" \
  -H "Authorization: Bearer $WEBFLOW_SITE_TOKEN" \
  | jq '.collections[] | {id, displayName, slug}'

The first command lists every product with its SKU IDs. The second command shows the collection named SKUs, which holds the ID you need. If either request returns 409, turn on Ecommerce for the site before going further. You now have one collection ID and a SKU ID for each variant you plan to track.

2. Scaffold the Next.js app with npm and the Webflow CLI

Install the Webflow CLI and create the app with npm, since the platform fixes both the package manager and the Node version. The CLI package is @webflow/webflow-cli, and its 2.x releases require Node.js 22.13.0 or later, which is why the version check comes first.

Check your Node version, then install the CLI and scaffold the app:

node --version
npm install -g @webflow/webflow-cli
npx create-next-app@15 inventory-app
cd inventory-app

Pinning create-next-app@15 keeps the scaffold on the release where middleware.ts is the standard file name, which matters for the write gate in step 5. Pick the App Router when create-next-app asks, since Route Handlers live under app/, and decline the src/ directory option so every path in this build stays where it is shown.

Leave next.config alone: Webflow's configuration docs say you don't need to add an adapter, a base path, or an output mode. At the end of this step, you have a Next.js 15 project with a package-lock.json, the Webflow CLI on your path, and a Node version the CLI accepts.

3. Store the token and collection ID as environment variables

Put the token and the SKU collection ID in the Webflow Cloud environment that holds your mount path, not in the repository.

Webflow's environment variables page states that "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime," and secrets are redacted from build logs.

Four variables cover this build:

  • WEBFLOW_SITE_TOKEN: Mark it secret. The Route Handler sends it as a bearer token on every inventory call. I avoid the name WEBFLOW_API_TOKEN here because webflow auth login writes its own token under that name to .env, and mixing the two is an easy way to end up calling the API with the wrong scopes.
  • WEBFLOW_SKU_COLLECTION_ID: The SKUs collection ID from step 1.
  • INVENTORY_ROUTE_SECRET: Mark it secret. Middleware compares it against a request header before any write reaches the handler so that sync jobs can write and browsers cannot.
  • NEXT_PUBLIC_BASE_PATH: The environment's mount path, for example /inventory. Webflow Cloud does not create this variable, so add it yourself.

For next dev, put the first three in .env.local, ideally with a separate development token that has the same scopes. Leave NEXT_PUBLIC_BASE_PATH unset locally, because next dev serves the app at the root and an /inventory prefix would make the local fetch 404.

4. Write the inventory Route Handler

The handler validates the SKU ID from the URL and calls Webflow with the token from the environment. GET returns only the stock fields, and PATCH accepts a body that matches what the update endpoint allows: inventoryType is required, quantity sets an absolute count, and updateQuantity adds or subtracts from the current one.

Create app/api/inventory/[skuId]/route.ts:

// app/api/inventory/[skuId]/route.ts
const API = 'https://api.webflow.com/v2/collections';
const ID_PATTERN = /^[a-f0-9]{24}$/;

type Inventory = { id: string; quantity: number; inventoryType: 'finite' | 'infinite' };

function config() {
  const token = process.env.WEBFLOW_SITE_TOKEN;
  const collectionId = process.env.WEBFLOW_SKU_COLLECTION_ID;
  return token && collectionId ? { token, collectionId } : null;
}

async function callWebflow(skuId: string, init: RequestInit = {}) {
  const cfg = config();
  if (!cfg) {
    return Response.json({ error: 'Inventory route is not configured' }, { status: 500 });
  }
  const upstream = await fetch(`${API}/${cfg.collectionId}/items/${skuId}/inventory`, {
    ...init,
    headers: {
      Authorization: `Bearer ${cfg.token}`,
      'Content-Type': 'application/json',
      accept: 'application/json',
    },
  });
  const data = await upstream.json().catch(() => null);
  if (!upstream.ok) {
    return Response.json(
      { error: 'Webflow rejected the request', status: upstream.status, detail: data },
      { status: upstream.status, headers: retryHeader(upstream) },
    );
  }
  const { id, quantity, inventoryType } = data as Inventory;
  return Response.json({ id, quantity, inventoryType });
}

function retryHeader(res: Response): Record<string, string> {
  const retryAfter = res.headers.get('retry-after');
  return retryAfter ? { 'Retry-After': retryAfter } : {};
}

type Params = { params: Promise<{ skuId: string }> };

export async function GET(_request: Request, { params }: Params) {
  const { skuId } = await params;
  if (!ID_PATTERN.test(skuId)) {
    return Response.json({ error: 'Invalid SKU ID' }, { status: 400 });
  }
  return callWebflow(skuId);
}

export async function PATCH(request: Request, { params }: Params) {
  const { skuId } = await params;
  if (!ID_PATTERN.test(skuId)) {
    return Response.json({ error: 'Invalid SKU ID' }, { status: 400 });
  }

  const body = await request.json().catch(() => null);
  const inventoryType = body?.inventoryType;
  const hasQuantity = Number.isInteger(body?.quantity);
  const hasUpdate = Number.isInteger(body?.updateQuantity);

  if (inventoryType !== 'finite' && inventoryType !== 'infinite') {
    return Response.json({ error: 'inventoryType must be finite or infinite' }, { status: 400 });
  }
  if (inventoryType === 'finite' && hasQuantity === hasUpdate) {
    return Response.json(
      { error: 'Send exactly one of quantity or updateQuantity' },
      { status: 400 },
    );
  }

  const update =
    inventoryType === 'infinite'
      ? { inventoryType }
      : hasQuantity
        ? { inventoryType, quantity: body.quantity }
        : { inventoryType, updateQuantity: body.updateQuantity };

  return callWebflow(skuId, { method: 'PATCH', body: JSON.stringify(update) });
}

The handler requires exactly one of quantity or updateQuantity for finite stock, so a sync job cannot send an ambiguous update, and it drops both fields when the SKU is set to infinite.

Validating the SKU ID as a 24-character hex string means nothing a caller sends can change the upstream path. Webflow's status codes pass through unchanged, along with any Retry-After header on a 429, so a sync job can back off by the value Webflow sends.

Leave export const runtime = 'edge' out of this file. The bring-your-own-app page still suggests it for API routes, but it targets the Next.js edge runtime, which the OpenNext Cloudflare adapter behind Webflow Cloud does not support, and the build fails. The route already runs on Cloudflare Workers without it.

Run npm run dev and request /api/inventory/<sku-id> with one of your SKU IDs. A JSON body with quantity and inventoryType means the token and both IDs line up.

5. Gate writes with middleware.ts, never proxy.ts

Add a middleware.ts file at the project root that lets reads through and demands a shared secret on every other method. The file name matters on this platform.

Webflow Cloud's framework customization docs state, "Node.js runtime middleware isn't supported. Only Edge runtime middleware works on the Workers runtime." Next.js 16 renames middleware to proxy, but proxy runs on the Node runtime and cannot opt into Edge, so a renamed file never runs here.

The gate compares SHA-256 digests of both values, hiding the secret's length and working the same in next dev and on Workers. That avoids crypto.subtle.timingSafeEqual, which exists on the deployed Workers runtime but not under local next dev.

Create middleware.ts next to package.json:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

async function sha256Hex(value: string): Promise<string> {
  const data = new TextEncoder().encode(value);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return Array.from(new Uint8Array(digest))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

export async function middleware(request: NextRequest) {
  if (request.method === 'GET') {
    return NextResponse.next();
  }

  const expected = process.env.INVENTORY_ROUTE_SECRET;
  const provided = request.headers.get('x-inventory-secret') ?? '';

  if (!expected) {
    return NextResponse.json({ error: 'Route secret is not set' }, { status: 500 });
  }

  const [a, b] = await Promise.all([sha256Hex(expected), sha256Hex(provided)]);
  if (a !== b) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/api/inventory/:path*',
};

A sync job that sends x-inventory-secret can now PATCH stock, while a browser can only read. The GET stays open because a storefront needs to show stock, and each read costs one call against the token's rate limit of 120 requests per minute on Ecommerce sites.

If a busy product page would exceed that, cache reads for a few seconds in Webflow Cloud's Key Value storage rather than through Cache-Control, which Webflow Cloud replaces on every response.

A PATCH without the header now returns 401 both locally and after deploy, and a GET returns the stock as before.

6. Call the route from the client with the base path

Prefix every browser-side fetch with the mount path, because Webflow Cloud does not rewrite client requests for you. The bring-your-own-app page puts it plainly: "Client-side fetch calls must manually include the base path to correctly reach your endpoints." Server code never sees this problem, which is why the bug surfaces only once a component runs in the browser.

A minimal stock badge reads the route from the browser:

// app/components/StockBadge.tsx
'use client';

import { useEffect, useState } from 'react';

const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';

export function StockBadge({ skuId }: { skuId: string }) {
  const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading');
  const [stock, setStock] = useState<{ quantity: number; inventoryType: string } | null>(null);

  useEffect(() => {
    fetch(`${basePath}/api/inventory/${skuId}`)
      .then(async (res) => {
        if (!res.ok) throw new Error(String(res.status));
        setStock(await res.json());
        setState('ok');
      })
      .catch(() => setState('error'));
  }, [skuId]);

  if (state === 'loading') return <span>Checking stock</span>;
  if (state === 'error') return <span>Stock unavailable</span>;
  if (stock?.inventoryType === 'infinite') return <span>In stock</span>;
  return <span>{stock && stock.quantity > 0 ? `${stock.quantity} in stock` : 'Sold out'}</span>;
}

Render <StockBadge skuId="..." /> from app/page.tsx with one of your SKU IDs, then deploy with the Webflow CLI to the environment that holds the mount path; the deploy command and project linking prompts are on the bring-your-own-app page.

Once the deploy finishes, open the mount path on the site and the badge moves from "Checking stock" to the live count.

What causes Webflow inventory API calls to fail?

Most inventory failures come from the IDs and scopes the endpoint expects; the rest come from Webflow Cloud's build and routing rules. Match the symptom to its cause below.

The inventory route returns 404 or 409 from Webflow

Cause: A 404 most often means a product ID was used where the endpoint needs a SKU ID, or the collection ID belongs to the Products collection instead of SKUs. A 409 means the site does not have Ecommerce enabled, so the endpoint refuses every call.

Fix: Rerun the two requests from step 1 and copy the SKU ID from the skus array, not the product's own ID, and the collection ID from the collection named SKUs. Update WEBFLOW_SKU_COLLECTION_ID if it was wrong and redeploy. For a 409, enable Ecommerce on the site; no code change clears it.

The inventory route returns 401, 403 or 429 from Webflow

Cause: A 401 or 403 means the token is missing a scope. Reads need ecommerce:read and writes need ecommerce:write, so a token that reads fine can still fail on PATCH. A deployed 401 can also mean a development token, or the CLI's own WEBFLOW_API_TOKEN, was pasted into the production secret.

A 429 means the token used up its per-minute allowance, which is easy to hit when every page view reads stock.

Fix: Regenerate the site token with both Ecommerce scopes, update WEBFLOW_SITE_TOKEN and redeploy. For a 429, have sync jobs wait for the Retry-After value the route passes through before retrying, and cache storefront reads in Key Value storage.

If the route instead returns its own 500 with "Inventory route is not configured", a variable is missing from the Webflow Cloud environment.

The Webflow Cloud build fails on the inventory route

Cause: The route declares export const runtime = 'edge', which the OpenNext Cloudflare adapter does not support. The bring-your-own-app page describes Webflow Cloud as deploying "using the Edge runtime", meaning the Cloudflare Workers platform, and then tells you to add the directive to API routes, so a developer reading the first sentence concludes the second is required.

The other common cause is a node:fs import, often added to cache the last payload; Workers only provides it from compatibility date 2025-09-01, which is later than the date in Webflow Cloud's example configuration.

Fix: Delete the runtime export from every route and redeploy; nothing replaces it, because the route already runs on Workers. For a node:fs failure, remove the import and keep the handler stateless, with Key Value storage for anything that must persist between requests.

The stock badge 404s in the browser but works from the server

Cause: The client-side fetch asked for /api/inventory/... at the site root, while the app is mounted at a path such as /inventory, so the request misses the app and returns the Webflow site's own 404 page. A JSON parse error on an HTML body is the usual first clue.

Fix: Confirm NEXT_PUBLIC_BASE_PATH is set in the Webflow Cloud environment to the exact mount path, with a leading slash and no trailing slash, and that every client fetch builds its URL from it. Rebuild after changing it, since NEXT_PUBLIC_ values are baked in at build time.

The write gate stops working after a Next.js 16 upgrade

Cause: Someone upgraded to Next.js 16 and renamed middleware.ts to proxy.ts. The renamed file runs on Node, which Webflow Cloud does not support for middleware, so PATCH requests reach the handler with no secret check.

Fix: Rename the file back to middleware.ts and keep the middleware export name, whatever the Next.js version. Send one unauthenticated PATCH against the deployed route and expect a 401 before calling it fixed.

What you can build next with the inventory API and Webflow

I'd add an ecomm_inventory_changed webhook next, so stock changes reach the app as they happen instead of the storefront polling for them. From there, a handler can join each SKU's stock to its product name and price from the products endpoint, which turns the badge into a full availability panel.

For deeper customization beyond inventory reads and writes, Webflow's developer docs cover the rest of the Ecommerce API.

Frequently asked questions

Should a sync job send quantity or updateQuantity?

Send quantity when the job holds the true count, such as a nightly sync from a warehouse system, because it overwrites whatever Webflow has. Send updateQuantity when the job reacts to a single event, such as a return adding one unit, because it adjusts the current count without reading it first. The route accepts one or the other per request, never both.

How many environment variables can one Webflow Cloud environment hold?

Webflow's limits page caps one environment at 100 variables, and this build uses four of them. Secret and plain variables both count toward that cap.

Does the same inventory handler work in an Astro or Vite app?

The approach does; the files do not. Webflow Cloud also accepts Astro and Vite apps, so port the route and the secret check into that framework's server route. Astro 6 and 7 require Node.js 22.12 or later, a lower floor than the 22.13.0 the Webflow CLI needs.

How should I rotate the site token?

Generate a new token with the same scopes, update WEBFLOW_SITE_TOKEN in the Webflow Cloud environment, and redeploy so the route picks up the new value. Send one GET to the deployed route and confirm a 200, then revoke the old token. Revoking last keeps the route working through the switch.


Last Updated
October 19, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com 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.