Payment and delivery are two different events on two different systems. Everything that goes wrong in a digital store happens in the gap between them.
Selling a digital product is the one kind of commerce where the whole transaction can finish in a browser tab. Nothing ships, nothing is picked from a shelf, and the buyer expects the file within seconds of paying.
That speed is also what makes it easy to get wrong. Payment happens on Stripe, delivery happens on your side, and neither system knows whether the other finished.
This guide builds that bridge properly: Webflow for the storefront, Stripe for payment, and a Route Handler on Webflow Cloud that releases the product only after Stripe confirms the money is real.
What do you need to sell digital products with Stripe and Webflow?
A Webflow site for the storefront, a Stripe account with a product and price, and somewhere server-side to run two Route Handlers. The server-side is not optional, because a browser cannot be trusted to confirm its own payment.
Here is what to have open before you start:
- A Webflow site with the pages that will sell the product
- A Stripe account in test mode, with at least one product and price created
- Your Stripe secret key and, later, a webhook signing secret that starts with
whsec_
- A Webflow Cloud project running a Next.js app, plus Node.js 22.13.0 or higher locally, the floor set by Webflow CLI 2.x
Webflow Cloud is available from the free Starter site plan up, though mounting the app to a custom domain requires Premium or higher. Before any of that matters, though, you need to decide which Stripe product you're using.
Does a digital marketplace need Stripe Connect?
It depends entirely on whose money it is. If every sale belongs to you, plain Checkout is the whole answer. The moment somebody else is owed a share of a sale, or another business is collecting payments from its own customers through your product, you are running a platform, and Stripe has a separate product for that.
Stripe is explicit about where the line sits. Connect exists for a business that "manages payments and moves money between multiple parties", and its marketplace path is described as collecting payments from customers and automatically paying out a portion to sellers. Nothing in plain Checkout does that.
Match your situation to the row that describes it:
The word marketplace does a lot of unearned work in project briefs and picking wrong here is expensive because Connect changes onboarding, compliance and payout handling rather than just an API call.
The build below covers the first row, which is the shape most single-seller digital product stores take.
5 steps to build a digital product store on Webflow Cloud
The build has two Route Handlers and a rule: the browser starts a payment, and only Stripe can say it finished. Everything that hands a product over hangs off that second handler.
The steps below go in order, because each one produces something the next needs:
1. Create the product and price in Stripe
Start in the Stripe Dashboard rather than in code, because the price object you create here is the thing your storefront will reference by ID. Create the product, add a one-time price, and copy the price ID that begins with price_.
Keep the money in Stripe rather than in your Webflow CMS. It is tempting to store the amount as a CMS field so the design team can edit it, but a price that exists in two places will eventually disagree with itself, and the copy shoppers see won't match what they are charged. Reference the Stripe price ID from the CMS instead, and let Stripe own the number.
Finish this step with a price ID you can paste and a product that appears in test mode.
2. Build the Checkout Session handler
The storefront never talks to Stripe directly. It posts a price ID to your Route Handler, which creates a Checkout Session using the secret key and returns a URL to redirect to.
The indirection exists because of the secret key. Anything in a client component ships to the browser, so the key has to stay on the server side of the request, which is exactly what a Route Handler gives you.
Create the handler at app/api/checkout/route.ts:
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
export async function POST(request: NextRequest) {
const { priceId } = (await request.json()) as { priceId: string }
const secret = process.env.STRIPE_SECRET_KEY
const origin = request.headers.get('origin')
if (!secret || !priceId || !origin) {
return NextResponse.json({ error: 'Missing configuration' }, { status: 400 })
}
const stripe = new Stripe(secret, { maxNetworkRetries: 2 })
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
// No download link here. Nothing is delivered until payment settles.
success_url: `${origin}/thank-you?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/pricing`,
})
return NextResponse.json({ url: session.url })
}
Note what is not in there: no download link, no access grant, no CMS write. The session is a request to be paid, not a payment. Deploy this and clicking your buy button should land you on a Stripe-hosted checkout page with the right product and price showing.
3. Verify the Stripe webhook on the Workers runtime
This step separates a store that works from one that leaks product. Stripe tells you a payment succeeded by posting to your endpoint, and anyone else on the internet can post to that endpoint too. The signature is what distinguishes them.
Webflow Cloud runs on Cloudflare Workers, and that runtime detail decides how you verify. On the Workers build of the Stripe library, a Web Crypto provider is selected by default rather than the Node one, and that provider only does asynchronous work. The synchronous constructEvent therefore throws SubtleCryptoProvider cannot be used in a synchronous context rather than verifying anything. Stripe's own Cloudflare Worker template uses the async form, and so should you.
Add the handler at app/api/stripe-webhook/route.ts:
// app/api/stripe-webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
export async function POST(request: NextRequest) {
const signature = request.headers.get('stripe-signature')
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
maxNetworkRetries: 2,
})
if (!signature) {
return NextResponse.json({ error: 'Unsigned request' }, { status: 400 })
}
// Read the body as text. Parsing it first changes the bytes Stripe signed.
const body = await request.text()
let event: Stripe.Event
try {
// constructEventAsync, never constructEvent: the Web Crypto provider
// that Workers uses cannot run in a synchronous context.
event = await stripe.webhooks.constructEventAsync(
body,
signature,
webhookSecret
)
} catch (err) {
const message = err instanceof Error ? err.message : 'Invalid signature'
return NextResponse.json({ error: message }, { status: 400 })
}
if (
event.type === 'checkout.session.completed' ||
event.type === 'checkout.session.async_payment_succeeded'
) {
const session = event.data.object as Stripe.Checkout.Session
// Re-read the session from the API. The copy embedded in the event
// can say payment_status: 'unpaid' for delayed methods like ACH.
const fresh = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items'],
})
if (fresh.payment_status !== 'unpaid') {
// Must tolerate being called twice with the same session ID.
await fulfillCheckout(fresh)
}
}
return NextResponse.json({ received: true })
}
Two details carry more weight than they look. The body is read with request.text(), because Stripe signs the exact bytes it sent and any reparsing of that payload invalidates the signature. And the handler stays small, because Stripe retries when a 2xx is slow and Checkout additionally waits up to 10 seconds for your response before redirecting the buyer, so a slow handler both stalls the customer and risks a duplicate delivery. A correct deployment rejects a forged POST with a 400 and accepts a Stripe test event with a 200.
4. Deliver the product only after the webhook fires
The webhook must drive fulfillment, and the reason isn't what people assume. Stripe's fulfillment guidance says you cannot rely on the landing page because customers aren't guaranteed to reach it: someone can pay successfully and lose their connection before the page loads.
Stripe also recommends calling the same fulfillment function from the landing page, since webhooks can be delayed and a present customer should be served immediately. Treat that as a second caller of one idempotent function rather than an alternative to the webhook, which is why the function has to tolerate running twice for the same session.
Where the file itself lives depends on its size and sensitivity. Webflow Cloud object storage keeps it beside the app, and buckets are private, so the file is accessed through your route rather than a public URL. The pattern that holds up is for that route to check entitlement on every request and return a short-lived link, because a stable URL is one forwarded email away from becoming a free product.
Record the entitlement, then generate the link on request. When this step is right, a completed test payment produces a download that works for the buyer and a bare URL that does nothing for anyone else.
5. Record the sale in the Webflow CMS
Writing the purchase back into a collection lets the rest of the site behave like it knows what happened: a customer page listing what somebody owns, a counter on the product, or a private page opened by the purchase.
Use the create item endpoint, and remember that fieldData keys are the field slugs from your collection, not the labels shown in the Designer. Items created this way are staged, so they exist in the CMS without appearing on the live site until published, which is usually what you want for order records.
Handle a 429 by retrying rather than dropping the write, since the Data API enforces a per-minute request limit that varies by site plan. A working setup leaves one CMS item per completed test purchase, with no duplicates when Stripe retries the event.
What causes Stripe checkout to fail on Webflow Cloud? Tips to troubleshoot
Four failures account for most of what goes wrong here, and three of them are silent: the build breaking on a runtime directive, signature verification refusing every event, duplicate fulfillment from retried webhooks, and a secret that never made it to the deployed environment.
Work through them in the order below, since each one masks the next:
The build fails after adding the Route Handlers
Cause: An export const runtime = 'edge' directive in one of the route files. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support the Next.js edge runtime, so the directive fails the build rather than optimizing anything.
Fix: Delete the line and redeploy. Route Handlers already run on the Workers runtime without it. This one is worth checking first whenever a Webflow Cloud build breaks immediately after new API routes appear, because the error text points at the bundler rather than at the directive that caused it, and Webflow's own bring-your-own-app page still tells Next.js readers to add it.
Every webhook fails signature verification
Cause: Either the body was parsed before it was hashed, or the synchronous verification call was used. Both produce the same symptom of a 400 on every event, including Stripe's own test sends.
Fix: Read the payload with request.text() and pass that string straight to constructEventAsync. If the error text mentions a synchronous context, you are still calling constructEvent somewhere. If it mentions a timestamp instead, check the clock rather than the code, since Stripe's libraries reject events outside a five-minute tolerance by default and a badly skewed environment will fail every send. Worth testing this with a real Stripe test event rather than a hand-rolled POST, because a request you construct yourself will fail verification for the honest reason that it is not signed, which tells you nothing about whether the handler is correct.
Buyers receive the product twice
Cause: Stripe retries a webhook it does not get a prompt success response for, so slow fulfillment inside the handler produces repeat deliveries and duplicate CMS records for a single purchase.
Fix: Acknowledge the event before doing anything slow, and make the fulfillment itself idempotent by keying it on the Checkout Session ID. Store that ID with the order record and check for it before granting access, so a replayed event finds the work already done and exits. Idempotency is the durable fix here, and speed alone only narrows the window rather than closing it. This is easy to miss in testing because a healthy handler rarely gets retried, so force the case: return a 500 from the handler once on purpose, let Stripe retry, and confirm the second delivery finds the work already done.
It works locally and fails after deploying
Cause: The keys exist in your local environment file and not in the deployed environment, or they were added after the last deploy and no build has run since.
Fix: Add both the secret key and the webhook signing secret in your Webflow Cloud environment variables, mark them as secrets, and redeploy. They are available to the build and to the deployed app at runtime, and secret values are redacted from build logs. Check you are not mixing a test-mode key with a live-mode webhook secret, which fails in a way that reads like a code problem rather than a configuration one.
What you can build next with Stripe and Webflow
Once payment and fulfillment are separate steps you control, the store stops being a single transaction and starts being an account: purchase history, license keys, re-download links, and subscription upgrades all hang off the same webhook.
If you want the checkout without the Webflow Cloud app, our Stripe Checkout guide covers the lighter setup. To put purchases behind a login so buyers can return to what they own, the authentication and payments guide adds the account layer on the same runtime. For the connection routes that need no code at all, see the Webflow and Stripe integration.
For deeper customization beyond what those routes cover, Webflow's developer docs go into Route Handlers, object storage and the rest of the Webflow Cloud runtime.
Frequently asked questions
Can I sell digital products without Webflow Cloud?
Yes, with Stripe Payment Links or a hosted checkout embedded in a Webflow page. You give up server-side fulfillment, so delivery relies on the success page rather than a verified payment event. That works for low-value files and is risky for anything worth pirating.
Why not deliver the file on the success page?
Because a buyer can pay and never load it, the page isn't a reliable signal. Stripe also recommends calling your fulfillment function from the landing page for speed, but as a second call to the same idempotent function rather than the only trigger.
Do I need Stripe Connect for a marketplace?
Only if somebody other than you is owed part of each sale. Connect moves money between multiple parties and pays sellers out automatically. If every sale belongs to your business, plain Checkout is the right tool, and Connect adds onboarding you do not need.
Why does signature verification fail on Webflow Cloud specifically?
The runtime. Webflow Cloud runs on Cloudflare Workers, where Stripe's Web Crypto provider is asynchronous only. Calling the synchronous verification method throws a synchronous context error, so the async variant is required, not optional.
How do I stop a download link being shared?
Issue short-lived links tied to a specific purchase rather than exposing a permanent file path. Record the entitlement at fulfillment time and generate the link on request, so you control expiry and revocation.




