Rovenue SDK
FunnelsWeb

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.

On this page