How to fix SKU archiving with the Webflow API from a guarded Webflow Cloud admin route

How to fix SKU archiving with the Webflow API from a guarded Webflow Cloud admin route

Learn how to build a guarded Webflow Cloud route that archives products and takes single variants off sale.

How to fix SKU archiving with the Webflow API from a guarded Webflow Cloud admin route

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

Webflow's v2 API archives whole products rather than single SKUs, so a guarded Webflow Cloud route archives the product or sets the one variant's stock to zero.

Most SKU archiving starts as a quick script on someone's laptop, and sometimes it fails in confusing ways. The request goes through, but the variant stays on the storefront. Webflow's v2 Data API has no archive flag on a SKU. The Update SKU endpoint edits a variant's fields, like price, while the archive flag lives on the product in Update Product.

In this build, we move both fixes into a Next.js app on Webflow Cloud. A guarded Route Handler archives a whole product, or takes one variant off sale by setting its stock to zero, and an admin page lets your store team run either action without ever seeing the token.

What do you need to fix SKU archiving with the Webflow API in Webflow?

You need to cover five prerequisites:

  • Ecommerce site: A Webflow site on an Ecommerce plan, with a test product you can safely archive and restore.
  • Site token: With the ecommerce:read and ecommerce:write scopes. On a client site, have the site owner generate and hold it, and rotate it and the shared secret at handover.
  • IDs: The site ID from Site settings and the ID of the SKUs collection the inventory endpoint needs.
  • Next.js 15 and Node.js 22: The floors that Webflow Cloud's bring-your-own-app docs set for deploys.
  • Webflow Cloud project: Connected to the site, with one environment you can deploy to for testing before production.

With these prerequisites, you can proceed with the build.

5 steps to fix SKU archiving with the Webflow API in Webflow Cloud

The build is a Next.js app with one Route Handler behind a middleware gate, plus an admin page that calls it. The handler offers two actions because the API provides two different tools.

1. Create the Next.js app with npm

Scaffold the project with npm, because Webflow Cloud's bring-your-own-app docs say: "Currently, Webflow Cloud supports only the npm package manager." Pinning @15 keeps the scaffold on Next.js 15, where middleware.ts still runs on Webflow Cloud.

If you start from an existing repository that carries a pnpm-lock.yaml or yarn.lock, delete it and regenerate with npm install before the first deploy.

Run the scaffold from the folder where you keep projects:

npx create-next-app@15 sku-archiver --ts --app --use-npm --yes
cd sku-archiver
npm run dev

You now have a TypeScript App Router project with a package-lock.json and a dev server on localhost. Leave the generated config alone; Webflow Cloud's configuration page says "You don't need to add an adapter, a base path, or an output mode."

2. Store the token and IDs as environment variables

Put every value the route needs into environment variables, locally in .env.local and in your Webflow Cloud environment for deploys.

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

Create .env.local in the project root with these keys:

# Site token with ecommerce:read and ecommerce:write (mark as secret in Webflow Cloud)
WEBFLOW_SITE_TOKEN=

# The site ID and the SKUs collection ID
WEBFLOW_SITE_ID=
WEBFLOW_SKU_COLLECTION_ID=

# Shared secret your store team types into the admin page (mark as secret)
ADMIN_SHARED_SECRET=

# Mount path of the app, for client-side fetch calls; leave empty locally
NEXT_PUBLIC_BASE_PATH=

For deploys, add the same five variables in the Webflow Cloud environment, with NEXT_PUBLIC_BASE_PATH set to the environment's mount path and the token and shared secret marked secret. Webflow Cloud does not create NEXT_PUBLIC_BASE_PATH for you. Trigger a new deploy so the build picks the values up.

Find the SKUs collection ID in the site's collections list; Ecommerce sites carry separate Products and SKUs collections, and the inventory call returns a 404 if you use the Products collection ID instead.

3. Write the archive Route Handler

Create a POST handler with two actions. archive-product sends Update Product with isArchived: true and publishStatus: "live", so the product leaves the published store without waiting for a site publish.

retire-sku sends Update Item Inventory with a finite quantity of zero, which shows that single variant as out of stock while the rest of the product stays on sale. Leave export const runtime = 'edge' out of this file; the OpenNext Cloudflare adapter behind Webflow Cloud does not support it, and the build fails.

Save the following as app/api/skus/archive/route.ts:

// app/api/skus/archive/route.ts
import { NextResponse } from 'next/server';

type ArchiveRequest = { action?: string; productId?: string; skuId?: string };

const ID_PATTERN = /^[a-f0-9]{24}$/;

function safeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

async function webflow(path: string, method: string, body: unknown) {
  return fetch(`https://api.webflow.com/v2${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.WEBFLOW_SITE_TOKEN}`,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify(body),
  });
}

export async function POST(request: Request) {
  // Checked here as well as in middleware, so the route fails closed on its own.
  const expected = process.env.ADMIN_SHARED_SECRET ?? '';
  if (!expected || !safeEqual(request.headers.get('x-admin-secret') ?? '', expected)) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const siteId = process.env.WEBFLOW_SITE_ID;
  const skuCollectionId = process.env.WEBFLOW_SKU_COLLECTION_ID;
  if (!process.env.WEBFLOW_SITE_TOKEN || !siteId || !skuCollectionId) {
    return NextResponse.json({ error: 'Route is not configured' }, { status: 500 });
  }

  let body: ArchiveRequest;
  try {
    body = (await request.json()) as ArchiveRequest;
  } catch {
    return NextResponse.json({ error: 'Request body must be JSON' }, { status: 400 });
  }

  let upstream: Response;
  if (body.action === 'archive-product' && ID_PATTERN.test(body.productId ?? '')) {
    upstream = await webflow(`/sites/${siteId}/products/${body.productId}`, 'PATCH', {
      publishStatus: 'live',
      product: { isArchived: true },
    });
  } else if (body.action === 'retire-sku' && ID_PATTERN.test(body.skuId ?? '')) {
    upstream = await webflow(
      `/collections/${skuCollectionId}/items/${body.skuId}/inventory`,
      'PATCH',
      { inventoryType: 'finite', quantity: 0 },
    );
  } else {
    return NextResponse.json(
      { error: 'Send action archive-product with productId, or retire-sku with skuId' },
      { status: 400 },
    );
  }

  const detail = await upstream.text();
  if (!upstream.ok) {
    console.error('Webflow API rejected the request', upstream.status, detail);
    return NextResponse.json(
      { error: 'Webflow API rejected the request', status: upstream.status, detail },
      { status: 502 },
    );
  }

  return NextResponse.json({ done: true, action: body.action });
}

Bad input returns 400, and missing configuration returns 500. A Webflow API rejection returns 502 with the upstream status and body, so the person at the admin page can tell "my input was wrong" from "the API said no" without opening logs. Validating both IDs as 24-character hex strings means nothing typed into the form can change the upstream path.

4. Guard the route with middleware.ts

Add a middleware.ts file so requests without the shared secret never reach the handler. Webflow Cloud's framework customization page says, "Node.js runtime middleware isn't supported.

Only Edge runtime middleware works on the Workers runtime." Next.js 16 renames middleware to proxy, which runs on the Node runtime, so keep this file named middleware.ts.

Add this file at the project root, next to package.json:

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

export function middleware(request: NextRequest) {
  const expected = process.env.ADMIN_SHARED_SECRET;
  if (!expected || request.headers.get('x-admin-secret') !== expected) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  return NextResponse.next();
}

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

Middleware is the first gate, and the handler repeats the check with a comparison that doesn't stop at the first mismatch.

Vercel's postmortem on a 2025 middleware bypass advises against making middleware the only protection, and the handler's own check means a renamed or skipped middleware file still leaves the route locked. A request without the header comes back 401 from either layer.

5. Call the route from an admin page with the base path

Build a small client page where your store team picks an action and enters the ID along with the shared secret.

Prefix every client-side fetch with NEXT_PUBLIC_BASE_PATH, because the bring-your-own-app docs are explicit that "Client-side fetch calls must manually include the base path to correctly reach your endpoints." A fetch to /api/skus/archive without the prefix goes to the root of your domain instead of your app.

Replace the contents of app/page.tsx with this component:

// app/page.tsx
'use client';

import { useState, type FormEvent } from 'react';

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

export default function ArchivePage() {
  const [action, setAction] = useState('archive-product');
  const [id, setId] = useState('');
  const [secret, setSecret] = useState('');
  const [result, setResult] = useState('');

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setResult('Working...');
    const payload = action === 'archive-product' ? { action, productId: id } : { action, skuId: id };

    try {
      const response = await fetch(`${basePath}/api/skus/archive`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'x-admin-secret': secret },
        body: JSON.stringify(payload),
      });
      const data = await response.json();
      setResult(`${response.status}: ${JSON.stringify(data, null, 2)}`);
    } catch {
      setResult('The response was not JSON. Check NEXT_PUBLIC_BASE_PATH.');
    }
  }

  return (
    <main style={{ maxWidth: 480, margin: '40px auto', fontFamily: 'sans-serif' }}>
      <h1>Archive products and SKUs</h1>
      <form onSubmit={handleSubmit} style={{ display: 'grid', gap: 12 }}>
        <select value={action} onChange={(e) => setAction(e.target.value)}>
          <option value="archive-product">Archive a whole product</option>
          <option value="retire-sku">Take one SKU off sale</option>
        </select>
        <input
          placeholder={action === 'archive-product' ? 'Product ID' : 'SKU ID'}
          value={id}
          onChange={(e) => setId(e.target.value)}
          required
        />
        <input
          type="password"
          placeholder="Admin secret"
          value={secret}
          onChange={(e) => setSecret(e.target.value)}
          required
        />
        <button type="submit">Run</button>
      </form>
      <pre style={{ whiteSpace: 'pre-wrap' }}>{result}</pre>
    </main>
  );
}

The user types the secret in and sends it as a header, so it never ships in the JavaScript bundle. Redeploy the environment, open the app at its mount path, and archive your test product.

A 200 with done: true means Webflow accepted the change, and because the request sets publishStatus to live, the product disappears from the published store without a site publish.

What causes Webflow API SKU archiving to fail?

Most failures come from the endpoint chosen for the job; the rest come from configuration or Webflow Cloud's build and routing rules.

The SKU update succeeds, but the variant stays on the storefront

Cause: The request went to Update SKU or to the CMS items endpoint with an archive field, expecting it to archive the variant. In the v2 API, archiving exists only at the product level, so a SKU update changes the fields it documents and nothing else. Updating a SKU also switches the product's type to Advanced, which surprises teams who only meant to archive it.

Fix: Use retire-sku to show the single variant as out of stock, or archive-product when the whole product should leave the store. Out of stock is the closest the API comes to retiring one variant while its siblings stay on sale; to remove the variant itself, edit the product's variant options in Webflow.

The admin page returns 500 or 502 instead of done: true

Cause: A 500 means a variable from step 2 is missing in the environment the app runs in. A 502 means Webflow refused the request, and the handler copies the upstream status into the response. An upstream 401 or 403 points to the token's scopes, a 404 points to a wrong ID, and a 409 means the site does not have Ecommerce enabled.

Fix: For a 500, add the missing variable to the Webflow Cloud environment you deploy to, or to .env.local locally, and redeploy. For a 502, read the status and details first. Regenerate the token with both Ecommerce scopes for a 401 or 403, and copy the IDs again from Webflow for a 404.

The Webflow Cloud build fails on a Route Handler that ran locally

Cause: The word "edge" means two different things on the same documentation page. Webflow Cloud runs on Cloudflare Workers, an edge platform, and the docs describe that as deploying "using the Edge runtime." The page then tells you to add export const runtime = 'edge', a separate Next.js runtime target that the OpenNext Cloudflare adapter does not support.

Fix: Delete the directive from every Route Handler and page; your code already runs on Workers without it. Search the project for runtime = before each deploy, because snippets copied from older tutorials often carry the line along.

The admin page shows a 404 or HTML error only after deployment

Cause: The client fetch is going to /api/skus/archive at the root of your domain instead of under the app's mount path. Locally, the app sits at the root, so the bug stays hidden until the deployed app lives at something like /sku-admin. The response is your Webflow site's 404 page, so response.json() throws on HTML.

Fix: Set NEXT_PUBLIC_BASE_PATH to the exact mount path in your Webflow Cloud environment and prefix every client fetch with it. Redeploy after changing the variable, since Next.js inlines public values during the build.

What you can build next with the Webflow API and Webflow Cloud

For variant stock, the inventory API guide shows how to read and update counts per SKU.

For every product and SKU field you can change, Webflow's developer docs document the full product object.

Frequently asked questions

Can the same route unarchive a product?

Yes, with one more action. Send the same Update Product request with isArchived: false and publishStatus: "live". Keep it as a separate action behind the same middleware matcher, so the archive button can never restore a product by accident.

Does archiving a product need a site publish?

Not with this route. Update Product changes only the staged product unless publishStatus is live, which this route sets. If you drop that field, the product stays on the live store until the next site publish.

Can the route read a CSV of product IDs from the file system?

No. Webflow Cloud's Workers runtime has no file system access, as its Node.js compatibility page notes, so code that reads a CSV from the project folder will not run. Accept the list in the request body and parse it in memory instead.

Who should hold the API token and shared secret on a client project?

Have the site owner generate the site token so the store keeps control of it. Your agency stores it only as a secret in the Webflow Cloud environment, never in code or chat. At handover, the owner rotates the token and the shared secret by updating both values in the environment and redeploying.


Last Updated
October 24, 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

Lancez-vous gratuitement

Essayez Webflow aussi longtemps que vous le souhaitez avec notre forfait Starter gratuit. Souscrivez à un forfait Site payant pour publier, héberger et débloquer des fonctionnalités supplémentaires.

Get started — it’s free
Voir la démo

Essayez Webflow aussi longtemps que vous le souhaitez avec notre forfait Starter gratuit. Souscrivez à un forfait Site payant pour publier, héberger et débloquer des fonctionnalités supplémentaires.