How to batch import Airtable rows to Webflow CMS with Zapier

How to batch import Airtable entries to the Webflow CMS with Zapier and Webflow Cloud

Learn how to build a Webflow Cloud route that validates Airtable rows from Zapier or Postman and creates them as draft CMS items.

How to batch import Airtable entries to the Webflow CMS with Zapier and Webflow Cloud

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

Airtable can stay the source of truth for CMS entries; a Webflow Cloud route fed by Zapier, and tested first in Postman, validates each ready row and writes it to the Collection as a draft.

Content that starts life in Airtable, such as a product catalog or a backlog of articles, eventually has to become CMS items, and copying rows by hand doesn't last. It also drifts: renaming a column in Airtable or a field in Webflow can break the mapping.

In this guide, we build a setup that gives the import one address that owns the mapping: a Next.js Route Handler on Webflow Cloud. Zapier and Postman post rows to it, and it writes each row to the Collection as a draft through the Data API, so Airtable stays the source of truth.

What do you need to batch import CMS entries with Zapier and Airtable in Webflow?

You need seven components for this import:

  • Webflow CMS collection: Define the structured CMS fields first because the route maps rows onto existing fields and sends no schema changes.
  • Next.js app: Create an npm-based Next.js app that meets the bring-your-own-app requirements, including the supported version floors and package manager.
  • Airtable base: Use one table with controlled column names and a view filtered to records that are ready to import.
  • Zapier account: Webhooks by Zapier is available on every plan, including Free, but the Zap here has three steps (trigger, webhook, Airtable update), and the Free plan allows only two, so plan on Professional or higher.
  • Postman: Use Postman or another HTTP client that can save POST requests and custom secret headers for repeated testing.
  • Shared secret: Create a long random string that Zapier and Postman send to the route as an authentication value.
  • Node and Webflow CLI: Install the required Node version and @webflow/webflow-cli after creating the npm-based application, then use the CLI to deploy it.

With these components ready, the build hinges on a stable Airtable field map, a protected route, and a tested deployment URL.

6 steps to batch import Airtable entries into the Webflow CMS in Webflow Cloud

Six steps to build a route that accepts Airtable rows from Zapier or Postman and creates them as draft CMS items, with a dry-run mode that shows the mapping before anything is written.

Start by controlling which Airtable rows enter the workflow, then build, deploy, test, automate, and run the import in appropriately sized batches.

1. Shape the Airtable table as the import source

Set the Airtable column names to match the CMS field slugs so the environment field map can use identical pairs. You can instead decide which column maps to which slug and write that decision down. For a blog collection, that might mean columns called name, slug, summary and body.

Add a checkbox column called Ready and a second checkbox column called Imported, then create a view filtered to rows where Ready is checked and Imported is unchecked. That view is the gate: Zapier polls it every 15 minutes on the Free plan and every 2 minutes on Professional, so a row enters the workflow shortly after someone ticks the box, not instantly.

After a successful import, you or a later Zap step sets Imported; the row drops out of the view, and a re-import becomes a deliberate act rather than an accident.

Review the source columns and their intended collection fields before they reach the route. If a source value needs a change, make that change explicit in Airtable or in the route instead of assuming two differently structured fields are interchangeable.

Filter the grid so only ticked, unimported rows remain. Every row in the filtered view is now one you intend to send through the import workflow, under column names that match the field map.

2. Create the import Route Handler in the Next.js app

Create app/api/import/route.ts to accept one row or an array of rows, check a shared secret in constant time, validate the configured field map, and map each row to the configured CMS field names. It then writes the mapped rows with Webflow's Create Items endpoint, POST /v2/collections/{collection_id}/items/insert, which Webflow recommends for new integrations.

That endpoint takes up to 100 items per request and creates them as drafts by default, so the route sends 100 at a time, one request after another, and waits out a 429 for as long as Retry-After says. Add ?dryRun=1 to a request to get the mapped rows back without writing anything. It omits the incompatible edge runtime export.

The secret comparison hashes both values and XORs the bytes instead of calling crypto.subtle.timingSafeEqual, because that Cloudflare extension exists on the deployed Workers runtime but not on Node's Web Crypto under next dev, and I want one code path that behaves the same in both places.

Ensuring timing consistency across local and deployed runtimes

A route built on timingSafeEqual passes after deployment and throws on your laptop, which makes local testing useless for the one check that guards the token. The field map comes from an environment variable rather than a constant, so changing the mapping does not require editing the handler.

Paste this into app/api/import/route.ts:

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

type Row = Record<string, unknown>;

const BATCH = 100; // Create Items accepts up to 100 items per request

type WriteResult =
  | { ok: true; created: number }
  | { ok: false; created: number; failedAtRow: number; status: number; error: unknown };

async function sha256(text: string): Promise<Uint8Array> {
  const data = new TextEncoder().encode(text);
  return new Uint8Array(await crypto.subtle.digest('SHA-256', data));
}

// Constant-time compare that runs identically under next dev and on Workers.
async function secretsMatch(presented: string | null, expected: string): Promise<boolean> {
  if (!presented) return false;
  const a = await sha256(presented);
  const b = await sha256(expected);
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
  return diff === 0;
}

function mapRow(row: Row, fieldMap: Record<string, string>): Row {
  const out: Row = {};
  for (const [airtableColumn, cmsField] of Object.entries(fieldMap)) {
    if (row[airtableColumn] !== undefined) out[cmsField] = row[airtableColumn];
  }
  return out;
}

// Writes mapped rows as draft items, 100 per request, one request at a time.
async function createItems(collectionId: string, token: string, rows: Row[]): Promise<WriteResult> {
  // skipInvalidFiles=false makes a bad image URL fail loudly instead of being dropped.
  const url = `https://api.webflow.com/v2/collections/${collectionId}/items/insert?skipInvalidFiles=false`;
  let created = 0;
  for (let i = 0; i < rows.length; ) {
    const chunk = rows.slice(i, i + BATCH);
    const res = await fetch(url, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      body: JSON.stringify({ items: chunk.map((fieldData) => ({ isDraft: true, fieldData })) }),
    });
    if (res.status === 429) {
      const wait = Number(res.headers.get('Retry-After') ?? '5');
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
      continue; // retry the same chunk
    }
    if (!res.ok) {
      const error = await res.json().catch(() => null);
      return { ok: false, created, failedAtRow: i, status: res.status, error };
    }
    created += chunk.length;
    i += BATCH;
  }
  return { ok: true, created };
}

export async function POST(request: Request) {
  const secret = process.env.IMPORT_SHARED_SECRET;
  const token = process.env.WEBFLOW_API_TOKEN;
  const collectionId = process.env.WEBFLOW_COLLECTION_ID;

  if (!secret || !token || !collectionId) {
    return NextResponse.json({ error: 'Import route is not configured' }, { status: 500 });
  }

  let parsedFieldMap: unknown;
  try {
    parsedFieldMap = JSON.parse(process.env.WEBFLOW_FIELD_MAP ?? '{}');
  } catch {
    return NextResponse.json({ error: 'WEBFLOW_FIELD_MAP is not valid JSON' }, { status: 500 });
  }

  if (
    parsedFieldMap === null ||
    typeof parsedFieldMap !== 'object' ||
    Array.isArray(parsedFieldMap) ||
    Object.entries(parsedFieldMap).length === 0 ||
    Object.entries(parsedFieldMap).some(
      ([airtableColumn, cmsField]) =>
        airtableColumn.length === 0 || typeof cmsField !== 'string' || cmsField.length === 0
    )
  ) {
    return NextResponse.json(
      { error: 'WEBFLOW_FIELD_MAP must be a non-empty JSON object with non-empty string mappings' },
      { status: 500 }
    );
  }

  const fieldMap = parsedFieldMap as Record<string, string>;

  if (!(await secretsMatch(request.headers.get('x-import-secret'), secret))) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

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

  const rows: Row[] = Array.isArray(body) ? (body as Row[]) : [body as Row];
  if (rows.some((r) => r === null || typeof r !== 'object' || Array.isArray(r))) {
    return NextResponse.json(
      { error: 'Each row must be a JSON object' },
      { status: 400 }
    );
  }

  const items = rows.map((row) => mapRow(row, fieldMap));

  // ?dryRun=1 checks authentication and mapping without writing anything.
  if (new URL(request.url).searchParams.get('dryRun') === '1') {
    return NextResponse.json({ dryRun: true, count: items.length, items });
  }

  const result = await createItems(collectionId, token, items);
  if (!result.ok) {
    return NextResponse.json(
      {
        created: result.created,
        failedAtRow: result.failedAtRow,
        webflowStatus: result.status,
        webflowError: result.error,
      },
      { status: 502 }
    );
  }
  return NextResponse.json({ created: result.created }, { status: 201 });
}

A successful write returns 201 with {"created": n}. If Webflow rejects a batch, the route stops and returns 502 with how many items were created, failedAtRow (the first row of the rejected batch), and Webflow's own error, whose details name the offending field.

Everything before failedAtRow is already in the Collection, so resume from that row rather than resending the whole array. skipInvalidFiles=false is set deliberately: Webflow's default silently drops an image it cannot fetch and still reports success.

Security considerations for shared secret authentication

The shared-secret header authenticates callers, and the secret stays in a server-side variable without a NEXT_PUBLIC_ prefix, so keep it out of any browser code. Anyone holding it can write to the Collection, and the route has no rate limit or replay protection: resending the same rows creates duplicate drafts.

Two things contain the damage. Every item arrives as a draft, so nothing reaches the live site without an editor publishing it, and the Imported checkbox keeps the Zap from sending a row twice. Rotate the secret whenever someone who had it leaves the project.

The route can now authenticate incoming requests, validate their JSON structure, map Airtable rows to CMS field names and create them as draft items.

3. Configure the environment variables and deploy to Webflow Cloud

Configure the shared secret, field map, API token and Collection ID in the Webflow Cloud environment, then deploy the app to the project and mount the path connected to your site. Keep the secret server-side and format the field map as a single-line JSON object.

Use these configuration details to connect the deployed route to its callers:

  • IMPORT_SHARED_SECRET: Store the long random string that authenticates requests from Zapier and Postman as a secret environment variable.
  • x-import-secret header: Send the same secret value from each client under this exact request header name.
  • WEBFLOW_FIELD_MAP: Map Airtable columns to CMS fields with JSON such as {"name":"name","slug":"slug","summary":"summary","body":"body"}.
  • WEBFLOW_API_TOKEN: A Site API token with the CMS:write scope, stored as a secret.
  • WEBFLOW_COLLECTION_ID: The ID of the Collection the rows go into.
  • Deployment route URL: Record the domain, mount path, and /api/import path that Zapier and Postman must call.

Together, these settings provide the route's authentication value, mapping rules, write access, target Collection and deployed address.

Malformed JSON in the field map returns a clear 500, as does a value that is not a non-empty object of non-empty string mappings. A mistyped column name can still pass those structural checks, so it drops that field without an error; you catch it in the validation response.

Install the Webflow CLI, then deploy to the Webflow Cloud project and mount path linked to the site. Write down the full route URL, including the mount path, so requests from Zapier and Postman reach the deployed handler.

Once the deployment finishes, the environment lists all four variables and the route answers at a URL of the form https://your-domain/your-mount-path/api/import.

4. Prove the route with Postman before Zapier touches it

Create a POST request in Postman to the deployed route URL with ?dryRun=1 on the end, add a header named x-import-secret with your secret, and set the body to raw JSON. A readable response lets you catch mapping mistakes before they enter the automated workflow.

Use a body that mirrors one Airtable row by column name:

{
  "name": "Postman test entry",
  "slug": "postman-test-entry",
  "summary": "A row sent by hand to check the field map.",
  "body": "<p>Body content from Airtable.</p>"
}

The response includes "dryRun": true, "count": 1, and the mapped row inside items. Read the keys in the mapped item: every one should be the intended CMS field name, and any Airtable column missing from the field map should be absent. If a key is wrong, correct WEBFLOW_FIELD_MAP in the environment and resend.

Send a request with a wrong secret too and confirm you get a 401 from this route. Save the Postman request so you can reuse it whenever the field map changes. Then remove ?dryRun=1, send one disposable row, and confirm it appears in the Collection as a draft.

Once you've confirmed the mapping and the write, delete the disposable item. You now have a saved request that verifies the deployed route's authentication and field mapping before Zapier begins sending Airtable records.

5. Connect Zapier from the Airtable view to the route

Configure an Airtable "New Record" trigger, which fires for records "when first added to a selected view", and point it at the Ready view. For the action, add "Webhooks by Zapier" with the event "POST".

Set the URL to the route, set Payload Type to JSON, and add one Data row per Airtable column, keyed by the column name, with the matching trigger field as its value. Finish with a Headers row named x-import-secret that holds the shared secret.

Payload Type must be json. The route calls request.json() and answers 400 to a form-encoded body. Keep the Data keys identical to the column names in WEBFLOW_FIELD_MAP; a typo in a body key drops that field from the mapped output.

Test the Zap against a real record from the view. Then add a third step, Airtable's Update Record action, that ticks Imported on the same record, and set it to run only when the webhook step returned created, so a rejected row stays in the view for correction.

The editor should now show the trigger, the JSON body and the completed secret header. The Zap history now logs each checked box in Airtable as one run, with the route's response attached and the row marked Imported once its item exists.

6. Run the batch and choose the right lane for its size

Select the Airtable rows you want to process by checking Ready, then watch the Zap history fill. Each selected record triggers one run, and each run posts one row to the route.

For a large one-time load, the Postman lane can be cheaper and faster; I use it for the first validation pass and leave Zapier for the trickle afterward.

Using the Postman array lane for initial high-volume imports

Export the Airtable table to CSV, convert it to a JSON array on your own machine (a spreadsheet tool or a few lines of scripting will do), and paste the whole array into the Postman body. Keep the CSV outside the route and send its converted JSON through the same request path used for validation.

The route already accepts an array, so one Postman request can validate the whole table, and the response returns a count so you can confirm the row total. Split very large arrays into a few requests rather than one; a smaller batch finishes sooner, and its mapped list is easier to inspect.

Because request bounds, rate controls, and replay protection are not yet implemented, use this array lane only for controlled validation.

Whichever lane you use, the items arrive as drafts. Review them in the Collection and publish when they are right. A single Postman request can carry a few hundred rows, since the route writes 100 per call and a request has 20 seconds; for thousands, split the array.

Apply the imported update row by row. Rejected or unprocessed rows remain available for correction and another run.

What causes a Zapier to Webflow CMS import to fail?

These imports usually fail because of an incompatible runtime export, a deployed secret mismatch, an oversized batch, or a row Webflow's validation rejects.

Use the visible symptom to separate Webflow Cloud configuration problems from mapping behavior and failures in the live CMS operation.

Webflow Cloud build fails on an API route copied from the bring-your-own-app page

Cause: The copied route retains the incompatible runtime export. The bring-your-own-app page uses one word for two concepts: Cloudflare Workers is an edge platform, and the docs describe hosting as "the Edge runtime," but the Next.js edge runtime export is a separate compile target.

Fix: Delete the export from every route and layout in the app and rebuild. The Route Handler runs on Workers without it. If you have a middleware.ts as well, keep the name: Node.js runtime middleware is not supported on Workers, and renaming it to proxy.ts, as newer Next.js guidance suggests, produces a file that runs on the Node runtime and cannot opt into Edge, so it will not run at all.

Check every route and layout, not just the import file, because one remaining incompatible export can still stop the application build before the handler receives a request.

Zapier and Postman get 401 from the deployed URL, but the same request succeeds against localhost

Cause: The route compares the header against IMPORT_SHARED_SECRET from the deployed environment, and that value does not match the one in your local .env file. This handler returns 401 when its comparison fails and 500 when the variable is missing entirely. Hence, a 401 with the expected header usually means the deployed value differs from the local one, for example, because a trailing newline was pasted into the dashboard field.

Fix: Open the Webflow Cloud environment, re-enter the secret with no surrounding whitespace, and redeploy: a changed value reaches the app only after a deploy. Then send the saved Postman request to the deployed route to verify it. Variables are set per environment; confirm all four exist in the one your URL targets.

If the 401 persists, temporarily have the route return whether the x-import-secret header arrived at all, never its value. A missing header points to the Zap's Headers row; a present header points to a value mismatch.

Large batches time out or fail partway through

Cause: The route writes 100 items per call, one call after another, and every call counts against the Data API's rate limit of 60 requests a minute on Starter and Basic and 120 on CMS, Ecommerce and Business. A very long array can outlast the platform's 20-second request timeout, especially once Retry-After waits start.

Fix: Split the array into requests of a few hundred rows and send each only after the previous one returns. If a request fails partway, its response gives created and failedAtRow: every row before failedAtRow is already in the Collection, so resend from that row rather than the whole array.

Keep the Zap lane for trickle imports, since it sends one row per run and never approaches either limit.

The route returns 502 with a Webflow validation error

Cause: Webflow rejected a batch, usually because a required field is missing or a slug is already in use. The mapping can be correct, and the values still invalid. The route stops at the first rejected batch and returns Webflow's error, whose details array names the field that failed.

Fix: Read webflowError.details in the 502 response. Each entry names a field, such as items[20].fieldData.slug, where the index counts from the start of the failed batch, so add failedAtRow to find the Airtable row. Correct that row in Airtable and resend from failedAtRow.

For a slug already in use, change the slug in Airtable or delete the existing item first, then run the ?dryRun=1 request to confirm the mapping before writing again.

What you can build next with Zapier, Airtable, and the Webflow CMS

Explore the Webflow Zapier integration to build more automated workflows, or keep Airtable as a live source instead of an import with Airtable as a database.

For deeper customization beyond this import route, Webflow's developer docs cover the Data API.

Frequently asked questions

Can Zapier call the Webflow CMS API directly instead of going through a Webflow Cloud route?

Yes, natively: Zapier's Webflow integration has Create Item and Create Live Item actions that need no code. Use them unless you need reference or multi-reference fields, which Webflow's help center says Zapier cannot map, or you want the validation and batching this route adds.

How do I rotate the shared secret without breaking the Zap?

Rotate it while no Airtable rows are marked Ready. You should update IMPORT_SHARED_SECRET in Webflow Cloud and the Zap's x-import-secret header together, redeploy, then send the saved Postman request with the new value. Any run that lands between those changes receives a 401, so pause incoming work until the test succeeds.

Can I build the route in Astro or Vite instead of Next.js?

Astro, yes; Vite, not as documented, because Webflow describes no server endpoints for Vite apps. In an Astro 6 or 7 app, the mapping and write logic port unchanged, but read the environment from locals.runtime.env rather than process.env. The rest of the handler carries over: it still compares the secret with the SHA-256 XOR routine, maps each row and writes the batches to /items/insert. Only your framework's request and response wrappers need to change.

Can the route update existing items instead of creating new ones?

Yes, with a second call. The Data API updates items with PATCH /v2/collections/{collection_id}/items/{item_id}, so the row needs the Webflow item ID, which the create response returns, and you can write back to Airtable in the same Update Record step.

Can I rerun a row without creating a duplicate?

Not with create alone: each successful run makes a new draft, so the Imported checkbox is the duplicate guard. Tick it only after the route returns created, and resend only rows that stayed unticked. Resending a row that already went through creates a second draft, which an editor can delete before publishing.


Last Updated
October 2, 2026
Category

Related articles


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.