A marketplace only earns its keep when every vendor gets paid the instant a customer checks out, and Webflow Cloud gives you the server-side home to split that payment, record the order, and keep the whole flow inside your own code.
A single-store checkout takes one payment into one account. A marketplace must take a payment and route most of it to the vendor who made the sale, while keeping a platform fee for the business owner. That split has to happen on a server your customers never see.
Webflow Cloud runs your app on Cloudflare Workers, which gives you a place to store your Stripe keys, communicate with Stripe Connect, and read from and write to a database, all at the edge. The storefront can stay in Webflow, but the money and the data move through code you control.
In this guide, we build that infra in six steps, from a database schema and a Workers-compatible Stripe client through vendor onboarding, split payments, and a signed webhook that confirms every sale.
What do you need to build a multi-vendor marketplace in Webflow Cloud?
You need a Webflow Cloud Next.js app, a Stripe account with Connect enabled, and a SQLite database binding for your marketplace data. Stripe Connect is required, since it lets a single payment pay out to multiple vendors.
Here is the full list of what you need:
- A Webflow Cloud project running a Next.js app, deployed or in local dev
- A Stripe account with Connect enabled in the dashboard
- Your Stripe secret key and a webhook signing secret
- A SQLite (D1) database binding declared in your
wrangler.json - Node.js 22 or higher locally
Vendors need nothing more than an email to get started. Stripe Connect handles onboarding, collects their bank and identity details, and returns an account ID that you store for the vendor.
If you already have sign-in wired up with Auth0 on Webflow Cloud, that same session tells you which vendor is onboarding and which customer is buying.
6 steps to build a multi-vendor marketplace in Webflow Cloud
The build moves in the order money moves: set up the data, connect Stripe, onboard a vendor, list what they sell, take a split payment, then confirm it.
Here is the sequence I follow on every Webflow Cloud marketplace:
- Model vendors, products, and orders in SQLite
- Build a Stripe client that runs on the Workers runtime
- Onboard vendors with Stripe Connect
- List each vendor's products from the database
- Take a customer payment and split it with the vendor
- Confirm the paid order from a Stripe webhook
Every step runs in a Route Handler, so your Stripe secret key and database binding stay server-side and never reach the browser.
1. Model vendors, products, and orders in SQLite
A marketplace is a relationship among three things:
- The vendors who sell
- The products they list
- The orders that connect buyers to vendors
Webflow Cloud gives you a SQLite database binding for exactly this kind of structured, relational data, and it is the source of truth that the rest of the guide reads from.
Declare the database in wrangler.json at the root of your app:
{
"d1_databases": [
{
"binding": "DB",
"database_name": "marketplace",
"database_id": "1234",
"migrations_dir": "./migrations"
}
]
}
Leave database_id as a placeholder. Webflow Cloud generates the real value and injects it on deploy, so you don't need to look it up in advance. Migrations in migrations_dir are applied automatically on deploy.
Creating a migration
Create a migration in ./migrations that defines the three tables:
-- migrations/0001_init.sql
CREATE TABLE vendors (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
stripe_account_id TEXT,
onboarded INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE products (
id TEXT PRIMARY KEY,
vendor_id TEXT NOT NULL REFERENCES vendors(id),
title TEXT NOT NULL,
price_cents INTEGER NOT NULL,
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE orders (
id TEXT PRIMARY KEY,
product_id TEXT NOT NULL REFERENCES products(id),
vendor_id TEXT NOT NULL REFERENCES vendors(id),
amount_cents INTEGER NOT NULL,
platform_fee_cents INTEGER NOT NULL,
-- UNIQUE is what makes the webhook safe to retry. Stripe can
-- deliver the same event more than once, and without this each
-- delivery writes another paid order for the same payment.
stripe_payment_intent TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending'
);
The stripe_account_id column joins your database to Stripe: it is empty until a vendor onboards, and it is the account every payment later routes money to.
Creating a database helper
Keep a small helper that hands you the binding, and never read it at the top of a module, because the binding only exists inside a request:
// lib/db.ts
import { getCloudflareContext } from '@opennextjs/cloudflare'
import type { D1Database } from '@cloudflare/workers-types'
// Grab the DB binding at request time. Calling this at module scope would
// run before Webflow Cloud has attached the binding to the request context.
export function db(): D1Database {
const { env } = getCloudflareContext()
return env.DB as D1Database
}
With the schema migrated and the helper in place, every later handler is a query away from your marketplace data.
2. Build a Stripe client that runs on the Workers runtime
This step used to catch everybody, and it is worth knowing what changed. Stripe's Node library once defaulted to an HTTP transport built on node:https, which the Workers runtime does not provide, so the first Stripe call failed before it reached Stripe, and you had to pass httpClient: Stripe.createFetchHttpClient() by hand.
Current versions handle this themselves. The stripe package ships a dedicated worker build, selected automatically through the worker and workerd export conditions that the Workers runtime sets, and in that build the default HTTP client is already the fetch client.
The same is true of crypto: the worker build's default provider is SubtleCrypto.
So the wrapper below sets no transport options at all:
// lib/stripe.ts
import Stripe from 'stripe'
// On the Workers runtime the `worker` export condition resolves the
// package to its worker build, whose defaults are already the fetch
// HTTP client and the SubtleCrypto provider. No httpClient override.
export function stripe(): Stripe {
return new Stripe(process.env.STRIPE_SECRET_KEY!)
}
If you are pinned to an older major, or you inherit a codebase that passes httpClient: Stripe.createFetchHttpClient(), leave it: it is redundant on current versions rather than harmful. Don't add it just because a tutorial says the SDK cannot work otherwise; that is no longer true.
Wrapping the client in one utility is still worth doing, for the ordinary reason that it gives you a single place to read configuration. Reading STRIPE_SECRET_KEY from process.env keeps the secret server-side; Webflow Cloud makes environment variables available to your build and to the deployed app at runtime, so the key never ships to the browser.
Store it as a Secret in your environment's dashboard so it stays encrypted and masked.
3. Onboard vendors with Stripe Connect
Before Stripe can pay a vendor, it needs to know who they are and where their money goes. Stripe Connect handles that with a connected account plus a hosted onboarding flow, so you never touch a vendor's bank details yourself.
You create a connected account for the vendor, store its ID, and send a one-time link for them to complete onboarding in Stripe.
Note how the account is created, because this is the part most tutorials still get wrong. Stripe's type: 'express' parameter is deprecated; the account's behavior is now configured through controller properties, which set who pays fees, who bears losses, and which dashboard the vendor gets.
Asking for stripe_dashboard.type: 'express' gives you what "an Express account" used to mean, without the deprecated parameter.
Create app/api/vendors/onboard/route.ts:
// app/api/vendors/onboard/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
export async function POST(request: NextRequest) {
const { vendorId } = (await request.json()) as { vendorId: string }
const sql = db()
const s = stripe()
// Reuse an existing connected account if the vendor already started.
const vendor = await sql
.prepare('SELECT stripe_account_id FROM vendors WHERE id = ?')
.bind(vendorId)
.first<{ stripe_account_id: string | null }>()
let accountId = vendor?.stripe_account_id ?? null
// Otherwise create a connected account and save its ID against the vendor.
if (!accountId) {
const account = await s.accounts.create({
// `type: 'express'` is deprecated. Configure the same behaviour
// through controller properties instead.
controller: {
fees: { payer: 'application' },
losses: { payments: 'application' },
stripe_dashboard: { type: 'express' },
},
// `transfers` is the capability that makes a destination charge
// legal. Without it the split payment has nowhere to go.
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
})
accountId = account.id
await sql
.prepare('UPDATE vendors SET stripe_account_id = ? WHERE id = ?')
.bind(accountId, vendorId)
.run()
}
// Return a single-use onboarding link the vendor opens on Stripe.
const link = await s.accountLinks.create({
account: accountId,
refresh_url: `${process.env.APP_URL}/vendor/onboarding/refresh`,
return_url: `${process.env.APP_URL}/vendor/dashboard`,
type: 'account_onboarding',
})
return NextResponse.json({ url: link.url })
}
Redirect the vendor to the returned url. When they return to your return_url, don't treat that as proof of anything. Returning to your site only means the flow was entered and exited properly, not that Stripe collected everything or that the account can take money. The account.updated webhook in step six is what flips onboarded to true, and step five refuses to charge until it has.
Account links are single-use and short-lived, so the handler mints a new one each time rather than storing it.
4. List each vendor's products from the database
With vendors in the database, the storefront can show what each one sells straight from your own data, so listings never drift from what is actually for sale. Because the read runs in a Route Handler, the browser receives only the fields you choose to return.
Create app/api/vendors/[id]/products/route.ts:
// app/api/vendors/[id]/products/route.ts
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
// Join through vendors so an un-onboarded vendor's products never
// appear. Listing something nobody can pay for is worse than
// listing nothing.
const { results } = await db()
.prepare(
`SELECT p.id, p.title, p.price_cents
FROM products p
JOIN vendors v ON v.id = p.vendor_id
WHERE p.vendor_id = ? AND p.active = 1 AND v.onboarded = 1`
)
.bind(id)
.all()
return NextResponse.json({ products: results })
}
The active = 1 filter keeps drafts private without any frontend logic, the same instinct you want everywhere money is involved: decide on the server, ship only the result. Product images are a good candidate for responsive, optimized images, so a grid of large covers doesn't slow the page.
Note the params promise: on Next.js 15, which Webflow Cloud runs, dynamic route params are awaited.
5. Take a customer payment and split it with the vendor
This step makes it a marketplace. When a customer buys a product, you create a Stripe destination charge: the buyer pays your platform account, Stripe forwards the balance to the vendor's connected account, and application_fee_amount routes your platform's cut back to you, all in one payment.
Create app/api/checkout/route.ts:
// app/api/checkout/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
// The platform keeps 10% of every sale, in basis points.
const PLATFORM_FEE_BPS = 1000
export async function POST(request: NextRequest) {
const { productId } = (await request.json()) as { productId: string }
// Look up the product and its vendor's connected account server-side.
const row = await db()
.prepare(
`SELECT p.id AS product_id, p.price_cents,
v.id AS vendor_id, v.stripe_account_id, v.onboarded
FROM products p
JOIN vendors v ON v.id = p.vendor_id
WHERE p.id = ? AND p.active = 1`
)
.bind(productId)
.first<{
product_id: string
price_cents: number
vendor_id: string
stripe_account_id: string | null
onboarded: number
}>()
// Gate on status, not presence. stripe_account_id is set the moment
// the account is created, long before it can accept a charge.
if (!row?.stripe_account_id || row.onboarded !== 1) {
return NextResponse.json({ error: 'Vendor not payable' }, { status: 409 })
}
const fee = Math.round((row.price_cents * PLATFORM_FEE_BPS) / 10_000)
const intent = await stripe().paymentIntents.create({
amount: row.price_cents,
currency: 'usd',
application_fee_amount: fee,
transfer_data: { destination: row.stripe_account_id },
metadata: { product_id: row.product_id, vendor_id: row.vendor_id },
})
return NextResponse.json({ clientSecret: intent.client_secret })
}
Return the client_secret to your checkout page and confirm it with Stripe.js in the browser using the Payment Element, which is the same client-side confirmation you would use for a single-store build. (This is a different flow from Stripe Checkout, the hosted page, which has no client-secret confirmation step of this kind.)
One caveat that bites marketplaces specifically: if your platform and the vendor's account are in different regions, Stripe requires you to name the connected account as the settlement merchant with on_behalf_of on the PaymentIntent.
Cross-border transfers are supported between the US, Canada, the UK, the EEA and Switzerland; outside those, the platform and vendor must share a region. Worth knowing before your first international vendor, not after.
The amount and the fee are both computed on the server from the database price, never from a number the browser sends, so a tampered request cannot change what the vendor charges or what you keep. The metadata fields carry the product and vendor through to the webhook, where the order is finally recorded.
6. Confirm the paid order from a Stripe webhook
A client_secret returned to the browser is a promise, not proof. The payment can still fail, and the customer can close the tab before your code hears the result. Stripe webhooks close that gap: Stripe calls your endpoint when the payment actually succeeds, and that call is what you trust to write the order.
Create app/api/webhooks/stripe/route.ts:
// app/api/webhooks/stripe/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import Stripe from 'stripe'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
export async function POST(request: NextRequest) {
const body = await request.text()
const signature = request.headers.get('stripe-signature') ?? ''
let event: Stripe.Event
try {
// constructEventAsync, not constructEvent: verification runs on
// SubtleCrypto, which is asynchronous, so the synchronous method
// cannot be used here whatever else you configure. The worker
// build already defaults to the SubtleCrypto provider, so there
// is no provider argument to pass.
event = await stripe().webhooks.constructEventAsync(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
if (event.type === 'payment_intent.succeeded') {
const pi = event.data.object as Stripe.PaymentIntent
// Not every payment_intent.succeeded came from this checkout.
// A Dashboard payment or another feature arrives with no
// metadata, and binding undefined into a NOT NULL column throws,
// which Stripe then retries on its backoff schedule forever.
if (!pi.metadata.product_id || !pi.metadata.vendor_id) {
return NextResponse.json({ received: true })
}
// Stripe is the source of truth for the sale, not the browser.
// OR IGNORE plus the UNIQUE constraint makes a duplicate delivery
// a no-op rather than a second order.
await db()
.prepare(
`INSERT OR IGNORE INTO orders
(id, product_id, vendor_id, amount_cents,
platform_fee_cents, stripe_payment_intent, status)
VALUES (?, ?, ?, ?, ?, ?, 'paid')`
)
.bind(
crypto.randomUUID(),
pi.metadata.product_id,
pi.metadata.vendor_id,
pi.amount_received,
pi.application_fee_amount ?? 0,
pi.id
)
.run()
}
// The event that makes a vendor sellable. charges_enabled is the
// field to trust, not the vendor having returned to your site.
if (event.type === 'account.updated') {
const account = event.data.object as Stripe.Account
if (account.charges_enabled) {
await db()
.prepare('UPDATE vendors SET onboarded = 1 WHERE stripe_account_id = ?')
.bind(account.id)
.run()
}
}
return NextResponse.json({ received: true })
}
Two details make this reliable on Webflow Cloud. Verify the signature with constructEventAsync rather than the synchronous constructEvent because, on this runtime, verification goes through the Web Crypto API, which is asynchronous. This is the one Stripe adjustment the Workers runtime still genuinely requires.
And the body is read with a request.text() before anything else touches it, since the signature is computed over the exact raw payload. Point Stripe at this URL in the dashboard and subscribe it to exactly two events: payment_intent.succeeded and account.updated.
Subscribe to only what you handle, since every extra event type is traffic your endpoint has to reason about and reject cleanly
The account.updated branch is what closes the loop from step three. Stripe sends it as a vendor's verification progresses, and charges_enabled turning true is the moment they can actually be paid, which is what step five gates on.
What breaks a multi-vendor marketplace in Webflow Cloud?
Most failures trace back to a handful of predictable causes:
- A database binding read at module load instead of inside a request
- A payment sent to a vendor whose onboarding is not finished
- A webhook verified with the synchronous method or a parsed body
- The Stripe client resolving to its Node build rather than its worker build
Each one tends to fail with a generic error, so the symptoms below map them back to a cause.
The first Stripe call throws or times out after deploy
This is the classic symptom of the SDK resolving to its Node build rather than its worker build, so the client tries a node:https transport the runtime does not implement. The call fails before it reaches Stripe, usually as a connection or module error with no Stripe status code attached.
On current versions, the worker build is selected automatically, so start by checking your stripe version rather than reaching for the old workaround.
If you are on a recent release and still seeing this, the worker export condition is not reaching the resolver that bundles your handler, and passing httpClient: Stripe.createFetchHttpClient() explicitly is the escape hatch that ends the argument.
The webhook returns 400, or every order is missing
The signature check is failing, so the handler rejects real events. On the Workers runtime, this almost always means one of two things: you called the synchronous constructEvent instead of constructEventAsync, or you read and re-serialized the JSON body before verifying it.
Verify with await stripe().webhooks.constructEventAsync(...) and read the raw body with request.text() rather than request.json(). Once the body has been through request.json(), the exact bytes Stripe signed are gone, and no amount of re-stringifying reconstructs them.
Confirm STRIPE_WEBHOOK_SECRET matches the signing secret shown for that exact endpoint in the Stripe dashboard, since each endpoint has its own.
The database binding is undefined
getCloudflareContext() was called at the top level of a module, before Webflow Cloud attached the binding to the request. The binding reads as undefined, and the first query throws an exception.
Call getCloudflareContext() inside the function that handles the request, the way the db() helper does, never at module scope. Confirm the binding name in your code matches the binding value in wrangler.json, DB in this guide, and redeploy after changing wrangler.json so the new binding is picked up.
A payment fails because the vendor is not able to receive funds
The destination charge points to a connected account that hasn't finished onboarding, so Stripe refuses to route funds to it. This is why a vendor who just clicked through onboarding can still fail their first sale.
Gate on the vendor's status rather than their presence, which is what the onboarded column in step one exists for. stripe_account_id is populated the moment you create the account, so checking it proves only that the vendor started.
The listing query in step four and the guard in step five both require onboarded = 1, and the account.updated branch in step six is the only thing that sets it when Stripe reports charges_enabled.
Wrapping the payment and webhook handlers in Sentry error tracking surfaces these Stripe errors with a stack trace the first time they happen instead of as a silent failed sale.
Extend your marketplace across Webflow Cloud
The six steps above cover the core loop: model, connect, onboard, list, split, and confirm. Once that loop is solid, you can extend the marketplace with the same building blocks, without new infrastructure.
A vendor dashboard is the most common next build. With orders already in SQLite, you can total each vendor's sales and fees and render a real earnings view, the kind of authenticated, data-driven screen covered in our real-time dashboard guide.
As the catalog grows, you can sync products into the Webflow CMS so the storefront renders as native, styled Webflow pages instead of being fetched at request time. And if your reporting outgrows what SQLite comfortably handles, reach for Neon Postgres.
If your catalog is single-seller rather than multi-vendor, you do not need Connect at all, and our guide to selling digital products covers that simpler build with Checkout Sessions.
Explore Webflow and Stripe to see how the payment layer connects to the rest of your site.
Frequently asked questions
Do I need a database, or can I use the Webflow CMS for a marketplace?
You need a database for the transactional data. The Webflow CMS is a great fit for the storefront and the vendor and product content you want styled and indexed, but orders, connected-account IDs, and payout records belong in the SQLite binding, where they can be written from a webhook and queried on the fly. Many marketplaces run both: the CMS for display and SQLite for the money.
Which Stripe Connect account type should vendors use, Express or Standard?
Think in terms of controller properties rather than account types, because that is how Stripe models it now. The type parameter is deprecated, and what used to be "an Express account" is now controller.stripe_dashboard.type: 'express' with fees and losses assigned to your platform, which is what this guide uses.
That configuration usually fits a marketplace: Stripe handles onboarding and gives vendors a lightweight dashboard, while your platform controls the payment flow and fees. Vendors who want their own full Stripe account and dashboard map to the standard dashboard type instead. Both work with destination charges, so the payment code is unchanged either way.
Does the Stripe SDK work on Webflow Cloud's Workers runtime?
Yes, and on current versions it needs no transport configuration. The package ships a worker build that the runtime selects automatically, and that build already defaults to the fetch HTTP client and the SubtleCrypto provider.
One thing does still change: verify webhooks with constructEventAsync rather than the synchronous constructEvent. That is not about defaults; SubtleCrypto is asynchronous by nature, so the synchronous method cannot use it, no matter your configuration.
How do vendors get paid, and how do they see their earnings?
With destination charges, Stripe automatically transfers each sale's balance to the vendor's connected account, minus your application_fee_amount, and pays out according to the account's schedule. Vendors can see balances and payouts through the Stripe-hosted Express dashboard, which you expose by generating a login link with accounts.createLoginLink, and you can render your own earnings summary from the orders table.
Can I build the storefront in the Webflow Designer instead of Next.js?
Yes. The Designer can own everything the customer sees, with your Webflow Cloud app handling onboarding, checkout, and webhooks via Route Handlers for the front end. Because a Webflow Cloud app mounts at a base path such as /app, build request URLs from your basePath rather than hard-coding a leading slash, so calls resolve against the right root.




