Checkout
How a web funnel sells — the checkout action on its own screen, the Stripe gate on publish, and what the runner and the server do when the visitor pays.
A web funnel sells on the page, through your connected Stripe account. A
native funnel sells through the app store instead: checkout is a web-only
action (packages/shared/src/funnel/checkout.ts:22-23), and the publish
route demands a Stripe account that can take a card only of a web funnel
with a checkout (apps/api/src/routes/dashboard/funnels.ts:678-686).
The button and the list
A sale needs two things on the same screen: a packageList naming the
funnel's offering, and a button whose action is checkout. The checkout
buys what a list on its own screen selects — a checkout with no package
list on its page is the CHECKOUT_WITHOUT_PACKAGE_LIST blocker
(packages/shared/src/funnel/checkout.ts:147-149,
packages/shared/src/funnel/checkout.ts:168-175). A funnel sells one
offering: the lists must name exactly one between them
(packages/shared/src/funnel/checkout.ts:129-132).
A paywall screen alone does not count — it shows an offer and has never
had a purchase path (packages/shared/src/funnel/checkout.ts:138-140). The
full set of selling blockers, with the codes publish refuses with, lives on
Publishing.
The checkout action waits on the payment, so nothing may follow it. See
the checkout row in Routing.
The inline wallet step
A checkout action opens the payment on its own screen. The
expressCheckout block instead mounts the wallet buttons — Apple Pay /
Google Pay — inline, on the selling page itself
(packages/shared/src/funnel/blocks-schema.ts:286). Like a checkout, it
buys what the page's packageList selected, through the same endpoint,
so the same rule applies: a block with no list on its own page is the
EXPRESS_WITHOUT_PACKAGE_LIST blocker
(packages/shared/src/funnel/checkout.ts:53). And like a checkout, it
sells on its own: a web funnel with an express block but no checkout
action still publishes (packages/shared/src/funnel/checkout.ts:135).
The block carries its own fallback: when no wallet can run here, the
runner draws the block's children instead
(packages/funnel-renderer/src/blocks/express.tsx:49) — usually a
button whose action is checkout, so the page sells either way. Two
things decide which half shows. The wallet needs an email, and the
intent endpoint refuses to create without one, so until an earlier page
has collected an address the fallback draws; and Stripe itself reports
whether a wallet exists on the device, so a desktop browser with none
also gets the fallback. The intent is one per page, shared by every
express block on it
(apps/dashboard/src/runner/express-host.tsx:106).
In the builder, the canvas draws the block as the wallet's own logo
button above its fallback — Apple Pay in an iOS frame, G Pay in an
Android one — editor-only mocks; the buyer still gets Stripe's buttons
(packages/funnel-renderer/src/blocks/express.tsx:153). And whenever
the selected page (or block) sells, the right panel opens with a
Payments section: green while the project's Stripe connection is live,
a red "No Stripe connection" error with a Connect button for the owner
when it is missing
(apps/dashboard/src/components/funnel-builder/payments-section.tsx:84).
How the runner takes the payment
The visitor never leaves the page. The runner posts to
POST /funnel-sessions/:sessionId/payment-intent with the picked package,
the page the buyer is on, and an email
(apps/api/src/routes/public/funnel-payment.ts:65-69,
apps/api/src/routes/public/funnel-payment.ts:742-755):
{
"package_identifier": "pro_monthly",
"page_id": "paywall-1",
"email": "buyer@example.com"
}The server charges only a session that is still in_progress
(apps/api/src/routes/public/funnel-payment.ts:757-761). When the project
cannot take a card it answers 409 STRIPE_NOT_CONNECTED
(apps/api/src/routes/public/funnel-payment.ts:763-768) — the same code
that gates publish. The named package must be the one the session's machine
recorded as selected, never merely the one the request names
(apps/api/src/routes/public/funnel-payment.ts:770-775); the offering is
resolved through the checkout page the buyer is actually on
(apps/api/src/routes/public/funnel-payment.ts:121-136), and any amount
in the request is ignored — the price comes from the Stripe Price on the
connected account (apps/api/src/routes/public/funnel-payment.ts:39-44).
The intent endpoint answers with a client_secret, the matching
publishable_key, and the stripe_account
(apps/api/src/routes/public/funnel-payment.ts:1185-1190). The runner
confirms through that connected account — card form and Express Checkout
(Apple Pay / Google Pay) share one code path, with redirect: "if_required" keeping the buyer on the page
(apps/dashboard/src/runner/payment-step.tsx:7-16). After Stripe says the
money moved, the runner still has to ask the server to settle: it posts to
POST /funnel-sessions/:sessionId/confirm, which asks Stripe — never the
browser — whether the payment settled, retrying the transient answer with
backoff (apps/api/src/routes/public/funnel-payment.ts:1303-1341,
apps/dashboard/src/runner/payment-step.tsx:156-180).
What a paid session emits
Settling runs one shared service, whether the browser's /confirm or the
Connect webhook (for the buyer who closed the tab) gets there first
(apps/api/src/routes/public/funnel-payment.ts:1246-1255). It marks the
purchase paid, moves the session to paid, mints the claim token, and —
inside the same transaction — emits funnel.session.paid and
funnel.claim_token.issued
(apps/api/src/services/funnel/complete-purchase.ts:391-397,
apps/api/src/services/funnel/complete-purchase.ts:460-473). The paid
session events are part of the public webhook set
(packages/shared/src/integrations.ts:21-27).
The plaintext claim token is what carries the purchase into the app. How it gets there is Claiming the Result.