A file upload field gets an image into a form submission; a Zap and one small Webflow Cloud route carry it into a draft CMS item nobody re-uploads.
Most photo-driven sites had the same overlooked manual step. Visitors submit a picture through a form (say, a community gallery), the file lands in the site's form submissions, and a marketer downloads it, opens the CMS, creates an item, and uploads the same file again. The form and the CMS both work; a person handles the handoff.
In this build, a Zap starts from each Webflow form submission and posts the file URL to a Route Handler in a Webflow Cloud app. The route creates the CMS item with draft status. The site stays on the existing Webflow platform; the only new moving part is one short Route Handler deployed next to it.
What do you need to upload form images to the CMS with Zapier in Webflow?
You need to gather these accounts, tools, and project details before starting:
- CMS collection: A collection with name, plain text caption, and image fields, plus the collection ID the create-item URL needs.
- File upload form: A form on that site with a file upload field. Webflow's help center limits the field to the "Premium Site plan (or legacy Business Site plan), Ecommerce Plus Site plan, or Ecommerce Advanced Site plan."
- Webflow API token: A secret token with write access to CMS items for that site, stored only in Webflow Cloud's environment variables and your local
.env.local. - Zapier account: A Zap using Webflow's form-submission trigger and a Webhooks by Zapier POST action. Webhooks by Zapier is on every plan, and this is a two-step Zap, so the Free plan covers it.
- Node.js and npm: Install Node.js and npm on your machine. Webflow Cloud supports only the npm package manager, so
pnpmandyarncommands won't work here. - Git repository: A repository for the Next.js app you'll connect to Webflow Cloud, plus a chosen mount path (I use
/toolsfor utility apps like this).
With these pieces ready, configure the form first, then build the route that connects Zapier to the CMS.
6 steps to upload form images to the CMS with Zapier in Webflow Cloud
Zapier forwards the submission's existing file URL to one Next.js Route Handler on Webflow Cloud. The handler checks a shared secret before sending the CMS request.
The build moves from form configuration through route deployment, Zap mapping, and a final live submission test.
1. Add a file upload field to the Webflow form
Name every form field for easy recognition when Zapier exposes the submission data for mapping. In the Designer, add a File Upload element inside the form block, select it and in its element settings, set the Name to photo.
Do the same for a text input named name and a text area named caption, and set the form's own Name to photo-submission. Publish the site.
Then change one site setting, or nothing downstream works. Webflow's help center is explicit: "By default, only users logged into Webflow can access files uploaded through a form," and the fix is to turn off Restrict uploaded file access under Site settings > Apps & Integrations.
The same article adds: "You'll also need to turn this option off if you want to send your form file submissions to a different cloud storage provider using Zapier or another third-party integration."
File accessibility requirements
A Zap that hands the file URL to the CMS is exactly that case. With the restriction on, the CMS cannot fetch the image. Turning it off makes every uploaded file readable by anyone with its link, so do not reuse this form for anything sensitive, such as IDs or CVs.
Two size and type limits meet here. The form accepts files up to 10MB, and several document types, but the CMS API accepts images up to 4MB, so tell visitors the 4MB limit in the form's help text and expect PDFs to be rejected.
Field names matter more than labels here. Field names are what you'll recognize when mapping data in Zapier, and a form called "Form 3" with a field called "Field" is a mapping headache when the client asks why captions stopped arriving. I rename fields first to prevent that mapping problem.
Set the element's Name input to photo. Submit the form once with a test image. Check the site's form submissions for that entry; the uploaded file should be listed as a link, and that link is what the Zap will forward to your route.
2. Create the Next.js app with npm
Create the Next.js app with the default configuration generated by Next.js 15. Webflow Cloud's bring-your-own-app requirements list Next.js 15 or higher.
Run the scaffold from your terminal:
npx create-next-app@15 form-images --typescript --app --no-tailwind --eslint
cd form-images
You now have an App Router project with nothing in it that Webflow Cloud objects to. Resist the urge to configure it further: Webflow's bring-your-own-app page puts it as "No adapter, no base path, no wrangler.json." The mount path is injected at build time, and adding your own basePath works against that.
Run npm run dev. If the default Next.js page loads at localhost:3000, the project is ready for the route.
3. Write the Route Handler that creates the CMS item
Create app/api/zapier-image/route.ts and make the handler authenticate the request with a header Zapier will send before validating the payload. It then passes the image URL directly from the form submission in a Webflow CMS API request.
Paste this as the whole file:
// app/api/zapier-image/route.ts
import { NextResponse } from "next/server";
type ZapPayload = {
name?: string;
caption?: string;
imageUrl?: string;
};
// Compare over the fixed expected-secret length without an input-dependent
// early return. This implementation works in the deployed Workers runtime
// and under local `next dev`.
function sameSecret(provided: string, expected: string): boolean {
let diff = provided.length ^ expected.length;
for (let i = 0; i < expected.length; i++) {
diff |= (provided.charCodeAt(i) || 0) ^ expected.charCodeAt(i);
}
return diff === 0;
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
export async function POST(request: Request) {
const provided = request.headers.get("x-zapier-secret") ?? "";
const expected = process.env.ZAPIER_SHARED_SECRET ?? "";
if (!expected || !sameSecret(provided, expected)) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
let body: ZapPayload;
try {
body = (await request.json()) as ZapPayload;
} catch {
return NextResponse.json({ error: "body must be JSON" }, { status: 400 });
}
if (!body.name || !body.imageUrl) {
return NextResponse.json(
{ error: "name and imageUrl are required" },
{ status: 400 }
);
}
let imageUrl: URL;
try {
imageUrl = new URL(body.imageUrl);
} catch {
return NextResponse.json({ error: "imageUrl is not a URL" }, { status: 400 });
}
if (imageUrl.protocol !== "https:") {
return NextResponse.json({ error: "imageUrl must use https" }, { status: 400 });
}
const itemsUrl = process.env.WEBFLOW_ITEMS_URL;
const imageField = process.env.WEBFLOW_IMAGE_FIELD ?? "photo";
if (!itemsUrl || !process.env.WEBFLOW_API_TOKEN) {
return NextResponse.json({ error: "server not configured" }, { status: 500 });
}
// Without skipInvalidFiles=false, Webflow drops an image it cannot fetch and still returns 202.
const createUrl = new URL(itemsUrl);
createUrl.searchParams.set("skipInvalidFiles", "false");
const webflowRes = await fetch(createUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WEBFLOW_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
isDraft: true,
fieldData: {
name: body.name,
slug: `${slugify(body.name) || "submission"}-${Date.now()}`,
caption: body.caption ?? "",
[imageField]: { url: imageUrl.toString() },
},
}),
});
if (!webflowRes.ok) {
const detail = await webflowRes.text();
return NextResponse.json(
{ error: "webflow rejected the item", detail },
{ status: 502 }
);
}
return NextResponse.json({ ok: true }, { status: 201 });
}
isDraft: true keeps user-submitted images in review until an editor opens the draft, checks it, and publishes it. The slug gets a timestamp suffix so two visitors with the same name still produce two different slugs.
The secret comparison is written by hand because the implementation must work on the deployed Workers runtime and under next dev. The loop runs for the fixed length of the configured secret and records a supplied-length mismatch without returning early.
Security and rate limiting considerations
As written, the route authenticates requests only with the shared secret. It does not rate-limit requests, cap request volume or spending, verify a signed webhook body, check a timestamp, or reject replayed payloads.
The API token and shared secret remain server-only and use no NEXT_PUBLIC_ variables, so the snippet does not intentionally expose them to client code.
Anyone who obtains the header value can reuse it to create CMS items until you rotate the secret, so add rate limiting, an appropriate usage or spend cap, and signed, time-bound request verification before treating the public endpoint as resistant to abuse or replay.
Set WEBFLOW_ITEMS_URL to https://api.webflow.com/v2/collections/{collection_id}/items with your collection ID filled in.
The create-item reference documents this single-item shape and its 202 response; Webflow now points new integrations at the batch /items/insert endpoint instead, but this one remains supported and fits one submission per call, and the handler uses webflowRes.ok rather than requiring a particular upstream success status. Match caption and photo to your collection's field slugs.
For a local check, create .env.local with WEBFLOW_API_TOKEN, ZAPIER_SHARED_SECRET, WEBFLOW_ITEMS_URL, and WEBFLOW_IMAGE_FIELD. Also run export ZAPIER_SHARED_SECRET=your-secret-value in the terminal you'll send the request from because .env.local leaves your shell environment unchanged.
The example payload points at https://example.com/test.jpg; swap it for a real, publicly hosted image if you want the create to succeed.
Testing the endpoint locally
Post the payload from that terminal:
curl -i -X POST http://localhost:3000/api/zapier-image \
-H "Content-Type: application/json" \
-H "x-zapier-secret: $ZAPIER_SHARED_SECRET" \
-d '{"name":"Local test","caption":"from curl","imageUrl":"https://example.com/test.jpg"}'
An omitted header returns a 401. The route returns a 201 response with ok: true when all the preceding checks pass and Webflow has fetched the image.
That guarantee comes from skipInvalidFiles=false: by default, Webflow skips an image it cannot fetch, or one over 4MB, and still reports success, which would leave a draft with an empty photo field and a green check in Zapier. A 502 carries Webflow's own error text back to you.
4. Set environment variables and deploy to Webflow Cloud
Create a Webflow Cloud environment for the connected Git repository and set its mount path to /tools. In that environment's settings, add WEBFLOW_API_TOKEN and ZAPIER_SHARED_SECRET as secrets and WEBFLOW_ITEMS_URL and WEBFLOW_IMAGE_FIELD as plain variables before the first deploy.
Managing environment secrets
Generate the shared secret with something long and random (for example, the output of openssl rand -hex 32); it's the only thing standing between the public internet and your CMS write token.
Webflow's bring-your-own-app page confirms the behavior you want: "Both secret and non-secret environment variables are available to your application's build process ... and remain available to the deployed application at runtime." Secrets are redacted from build logs, but Webflow describes that redaction as "a safety mechanism, not a recommended secret-handling workflow," so never print the environment in a build step.
Once saved, the variable list masks both secrets. With the variables in place, push to the connected repository and let the first build run.
When the deploy finishes, the route is live at your site's domain plus /tools/api/zapier-image (the site's Webflow domain, or a custom domain on Premium and higher).
A bare POST to that address from your terminal should return a 401, as it did locally. That response proves the route is reachable at the mounted path and the secret check is active.
5. Build the Zap that posts each submission to the route
Create a two-step Zap from the test submission: Webflow's form submission trigger, then Webhooks by Zapier with the POST event. The webhook sends JSON to your route. In the trigger, choose your Webflow site and the photo-submission form, then test it.
Zapier should pull in the test submission from the photo-submission form; check that the photo field comes through as a file link, and that name and caption come through as text.
In the Webhooks by Zapier action, set the URL to your mounted route and the payload type to JSON. Add a header named x-zapier-secret whose value is the same string you stored as ZAPIER_SHARED_SECRET.
Map the body fields as follows:
| Route body key | Zapier source field | CMS destination |
|---|---|---|
name |
Form field name |
Name field |
caption |
Form field caption |
Plain text field caption |
imageUrl |
Form field photo (file URL) |
Image field photo |
| Route body key → Zapier source field → CMS destination |
|---|
name |
Form field name |
| Name field |
caption |
Form field caption |
Plain text field caption |
imageUrl |
Form field photo (file URL) |
Image field photo |
The route receives strings and passes the uploaded file's URL directly to the CMS. The handler therefore requires HTTPS. If the photo field arrives as something other than a URL in your test data, map a URL version of that field when Zapier lists one.
Test the action from the Zap editor. A 201 with ok: true in the response means the route accepted the payload and Webflow reported creating the item; a 401 means the header value and the stored secret differ (a trailing space is the usual culprit).
6. Submit a real image and check the draft CMS item
Turn the Zap on, then submit the live form with an actual photo and a distinctive name that is easy to find in both the Zap's run history and the CMS. Open the Zap's run history and check that the webhook step succeeded and returned the route's response. Then open the CMS panel in the Designer and select the collection to find the new item.
It should be a draft with the name and caption you typed. The image field should show the submitted photo.
The new item should appear in the Collection with a Draft badge.
Watch the slug too. Yours will read like distinctive-name-1726000000000. The timestamp makes it unique, and an editor can shorten it before publishing. Submit a second image with the same name to confirm the timestamp suffix keeps the second slug unique.
With two draft items in the collection, the automated pipeline handles the marketer's re-upload for them.
What causes Zapier image uploads to the Webflow CMS to fail?
Failures usually come from runtime configuration, mounted-route URLs, CMS field or file validation, and unsupported Node modules in the Workers environment, with each category producing a recognizable error.
Match the visible symptom to its cause, then apply the corresponding fix.
The Webflow Cloud build fails on the API route with an edge runtime error
Cause: The bring-your-own-app page still tells you to add the edge runtime directive to API routes. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter. OpenNext requires the standard Workers runtime path, while export const runtime = 'edge' targets the Next.js edge runtime and produces a broken build.
The docs page uses "edge" for two unrelated things: Cloudflare Workers as an edge platform (where your app already runs) and the Next.js runtime directive (an opt-in OpenNext can't satisfy), so following that sentence faithfully is what triggers the failure.
Fix: Delete the line from route.ts and any other route or page where you added it, then redeploy. The route executes on Cloudflare Workers with the standard runtime configuration.
Zapier's test returns 404 although the route answers under next dev
Cause: Locally, next dev serves your route at /api/zapier-image. The deployed route instead uses the mounted URL. A webhook URL copied from your local terminal drops the /tools segment, so Zapier posts to a path where the route isn't mounted.
The same mechanism bites any client-side code in the app: Webflow's docs note that "Client-side fetch calls must manually include the base path," because the browser's fetch is not rewritten for you.
Fix: Put the mount path into the Zap's webhook URL and retest the action. If you later add a front end to this app that calls the route from the browser, add a NEXT_PUBLIC_BASE_PATH variable set to the mount path (Webflow Cloud does not create it for you) and prefix every client fetch with it, rather than hardcoding /tools in components that will break the day the app is remounted.
Zapier's webhook step returns 502 with webflow rejected the item
Cause: The request passed the route's own checks, but the CMS API rejected the fieldData the route sent. Usually the caption key or the WEBFLOW_IMAGE_FIELD value doesn't match the collection's field slug, so Webflow receives a field the collection doesn't have.
Other common causes include a file Webflow won't accept, such as an image over 4MB or a PDF, or a file link that isn't publicly reachable because Restrict uploaded file access is still on. The route forwards Webflow's response body as detail, so the actual reason travels back to Zapier with the 502.
Fix: Open the Zap's run history and read the detail text in the webhook step's response. If it names a field, open the collection settings, copy the exact field slugs, and update caption in the route or WEBFLOW_IMAGE_FIELD in the Webflow Cloud environment, then redeploy.
If it points at the image, paste the photo URL into a private browser window; if the file doesn't open there, Webflow can't fetch it either.
After adding image downloads, the build log can't resolve node:fs
Cause: A natural extension of this build downloads the image to inspect or resize it, and a first attempt often writes it to a temp path. Webflow Cloud's Workers runtime pins a compatibility date earlier than the one node:fs requires, so the import fails. node:path is available, which makes the failure confusing because runtime support varies between Node module names.
Fix: Keep processing in memory. To inspect the image, fetch the URL and work on the response as an ArrayBuffer or a stream, then pass the original URL on to the CMS.
For a non-security checksum to detect duplicate uploads, use SHA-256 from crypto.subtle.digest, which runs both on Workers and under next dev; Cloudflare's MD5 extension works only on Workers and throws locally. Keep hashing out of anything that gates access; the shared secret check stays as it is.
What you can build next with Zapier and Webflow
Once a submitted image lands in a draft CMS item without a human re-uploading it, the same pattern covers most form-driven content, such as event entries that become listings and testimonials that need approval before they appear.
Zapier's Webflow integration supplies the trigger side, and a Webflow Cloud route in between adds validation and a guaranteed image in code you control. The same route-in-the-middle shape also sends form leads to HubSpot without Zapier at all.
Frequently asked questions
Can I skip Webflow Cloud and use Zapier's Webflow action to create the item directly?
Yes, when the collection needs no custom logic. Use Zapier's Webflow action because it exposes no public endpoint and has a smaller attack surface. Zapier's Create Item action even has a Draft setting. Choose Webflow Cloud when you need custom validation, collision-safe slugs, image or host checks, a hard failure when the image did not make it, or version-controlled behavior.
Does every submission use Zapier tasks?
Yes. Each submission triggers one Zap run, so Zapier usage grows with form volume. Your plan's task rules determine how many tasks that two-step run consumes. Review the Zap's run history to see every processed submission, then compare that record with the form's submission count and your plan's task allowance before traffic increases.
How do I rotate the shared secret?
Generate a new random value, update ZAPIER_SHARED_SECRET in the Webflow Cloud environment, and redeploy. Then paste the same value into the webhook step's x-zapier-secret header. The route returns 401 to submissions between deployment and the Zap update, so make both changes back-to-back. Review the run history immediately afterward to retry any submissions that arrived during rotation.
If I add rate limiting to the route, should I use middleware.ts or proxy.ts?
Use middleware.ts for this Webflow Cloud app. Webflow's framework-customization docs require Edge runtime middleware on the Workers runtime. In newer Next.js releases, proxy.ts runs on the Node runtime, so it does not satisfy that platform requirement. Keep rate-limiting logic in middleware.ts and test the deployed mounted route before relying on it.
Can a trusted internal form publish items without a draft review?
Yes, if the route can identify which form sent the payload. Have the Zap send the form name as an extra body key, then set isDraft to false only when that key matches a trusted staff form, such as one for posting event photos. Keep isDraft: true for public, anonymous forms so editors still review outside submissions before publication.





