Web Checkout

Sell the same paywall on the web with one config key, Stripe payment in a sheet, Apple Pay, or a hosted checkout page, with purchase() unchanged.

One config key sells the same paywall on the web:

checkout: "sheet",

Native store products ignore it. Drop the same paywall into your iOS app and a store reference buys through the App Store. Your components don't change, and neither does purchase().

One exception: in external mode on a plain web page, the page navigates away to the hosted checkout page, so purchase() never resolves. Call it, but don't await it or branch on its result. Inside an app the paywall stays put and purchase() resolves normally, see Hosted checkout.

This page covers the framework side: config, modes, and prefetching. Stripe keys, web apps, products, and campaigns are set up in the dashboard, see the Web Checkout section for that half.

Modes

ModeThe purchaseUse when
sheetStripe checkout in a sheet over the paywall, nobody leaves mid-flowThe default choice for the web
applePayStraight to Apple Pay where available, sheet as fallbackApple-Pay-heavy audiences
externalSuperwall's hosted checkout page, then backYou want zero payment UI in the paywall

Only sheet and applePay add payment UI to the paywall; external adds nothing. Stripe's own scripts load at runtime from js.stripe.com rather than being bundled.

An SDK too old to host checkout inside the paywall (below iOS 4.10.8) falls back to external on its own, whichever mode you configured.

Hosted checkout

In external mode purchase() hands the checkout page to whoever is showing the paywall.

Inside an app, the SDK opens it, in the payment sheet on iOS, in a browser elsewhere, and the paywall stays alive underneath. The framework then asks the backend every two seconds whether that checkout finished, for up to two minutes, and purchase() resolves completed as soon as it does, or abandoned if the shopper never finishes. On an SDK too old to redeem the purchase itself (below iOS 4.14.0) the framework also hands over the redemption link, so entitlements land in the app either way.

On a plain web page there is no app to open anything, so the page navigates to the checkout URL itself and purchase() never resolves, the browser has already left.

Products

Web paywalls sell Stripe products, declared with the price inside the identifier, {environment}:{priceId}:{offer}, where {environment} is exactly test or live:

products: {
  monthly: "live:price_1ABC…:7days-free",
},

A paywall can declare store and Stripe products side by side. See Products.

The purchase, unchanged

With sheet or applePay and a Stripe product, the same purchase() call opens the payment sheet in-page, a brief loading overlay covers the session creation unless it was prefetched. The outcomes map exactly as they do natively:

  • completed, payment succeeded
  • abandoned, the shopper closed the sheet
  • failed, a payment or session error

The web sheet does not set isPurchasing, react to the awaited result, which is the right pattern everywhere anyway. A web paywall also typically drops the close button and restore link its native sibling carries: there's no host app to close back to.

Prefetch: make the sheet open instantly

Creating a checkout session takes a network round-trip. Prefetching does it before the tap, so the sheet opens with nothing to wait for.

Automatic: a sheet paywall warms one Stripe product on load; applePay warms every Stripe product on the paywall, up to ten. Steer it in config:

checkout: { mode: "sheet", prefetch: "pro" }   // which product warms first
checkout: { mode: "sheet", prefetch: false }   // disable auto-prefetch

On selection, do this whenever there's a product selector. With sheet, only one plan is warmed; prefetch the selected one so whichever plan is on screen opens instantly:

import { usePurchase, type ProductReference } from "superwall/hooks";

const { purchase, prefetch } = usePurchase();
const [reference, setReference] = React.useState<ProductReference>("monthly");

React.useEffect(() => {
  prefetch(reference);
}, [prefetch, reference]);

prefetch is safe to call unconditionally. It's a no-op for store products, for paywalls without web checkout, and for already-warm sessions (sessions stay warm for about ten minutes). It's a hint; never await it.

The shopper's email

A checkout session is made for one shopper. When user.email is set, it becomes the Stripe session's customer_email: the sheet shows it and does not let it be changed, and the purchase, the receipt and the redemption email all go to it. When user.stripe_customer_id is set, the session is pinned to that customer. Both are read when the session is created, the same as the editor's paywalls.

A funnel usually asks for the email on one of its pages, after the prefetch on mount already made a session without it. Set it with setUserAttributes the moment you have it; the framework then drops every cached session and prefetches again, so by the time the plan page is on screen the sheet opens with the email already in place:

const { setUserAttributes } = useActions();

const onContinue = () => {
  setUserAttributes({ email });
  router.push("plans");
};

Anything the host set before the paywall opened (Superwall.shared.setUserAttributes(["email": …]) in the app, or identify) is already there, nothing to do. A sheet that is already open keeps its session.

Promotion codes

useDiscount redeems a Stripe promotion code and re-prices the paywall's Stripe products. The code then rides on every checkout session created afterwards for the products it applies to, so the sheet and the Apple Pay sheet both quote and charge the discounted price.

const { redeem, applied, getDiscountedProduct } = useDiscount({
  onDropped: () => toast("That code is no longer valid"),
});

const result = await redeem(code);
if (!result.valid) setError(result.reason);

const annual = getDiscountedProduct("annual");
const price = annual?.discountedPrice ?? getProduct("annual")?.variables.price;

Redeeming clears the warmed sessions and warms them again with the code, so the price a shopper sees and the price Stripe charges cannot drift. A code that the backend later refuses at checkout is dropped paywall-wide and onDropped fires. clear() forgets it.

The hook hands back Stripe's own vocabulary rather than a sentence, because a sentence would ship English into every locale. applied.discount is the coupon as Stripe names it — percentOff, amountOff, currency, duration, durationInMonths — and each product carries only what had to be computed:

On a discounted productWhat it is
amount, discountedAmount, savingsThe price before, the price with the code, and the difference, in minor units (1999 is $19.99)
price, discountedPrice, savingsPriceThe same three, formatted for the paywall's locale
appliesWhether the code lowers this product's price at all
const { applied, getDiscountedProduct } = useDiscount();
const annual = getDiscountedProduct("annual");

// "15% off forever", "$5 off for 3 months" — your words, your locale
const off = applied?.discount.percentOff
  ? t("percentOff", { percent: applied.discount.percentOff })
  : t("amountOff", { amount: annual?.savingsPrice });

On older SDKs. Payment inside the paywall needs a native SDK from 4.10.8; below that the same purchase() sends the shopper to Superwall's hosted checkout page instead, and prefetching is skipped. Below 4.14.0 the SDK does not redeem a completed web purchase itself, so the paywall asks the backend for a redemption link and hands it to the SDK as a deep link. Both are automatic and match what paywall.js does.

The host can redeem for the paywall. A host that offers its own code field sends redeem_discount; the paywall validates, prices and answers with discount_redemption_result (valid, and either appliedProductCount or a reason). An empty code clears whatever is applied. Codes are handled in the order they arrive, so a slow answer for an earlier code cannot overwrite a later one.

After the purchase

By default the host finishes a web purchase itself: grant access and close inside a Web SDK overlay, redeem on a hosted page. Change that with postPurchase, in the config or per purchase() call:

  • "redeem" sends the shopper to the redemption page (opens the app with their code), { redirect: url } sends them to a URL of yours with the code attached.
  • "stay" hands the completed purchase back to the paywall. Show your own success screen, then finish it with useCheckoutRedemption():
const { completed, redeem, redirect } = useCheckoutRedemption();
if (completed) return <Success onContinue={redeem} />;

Custom metadata on the sale

Pass stripeMetadata to purchase() to write key/values onto the Stripe subscription once the purchase completes — a quiz session id, the plan chosen, anything you want to see in Stripe and in your webhooks. Values are strings; store purchases ignore it (there is no Stripe object to write to).

purchase("annual", { stripeMetadata: { quiz_session: sessionId, plan: "annual" } });

Managed payments

checkout: { mode: "sheet", managedPayments: true } forces Superwall's managed payments on for this paywall, false forces it off. Omit it and the account setting applies. Stripe only, which is the only thing checkout drives.

The sheet is not yours to style

It takes no colors, fonts, or spacing from the page around it, and there's no prop to change that. This is deliberate: payment UI that borrows the paywall's design stops looking like payment UI, and the payment step is the one place a shopper is entitled to see something they recognize. Safe areas, scroll locking, and Escape handling (never mid-payment) are handled for you.

The sheet reads two things from around it, neither of them a prop: it lays out edge to edge over a paywall whose presentation.style is fullscreen, push or noAnimation and as a bottom sheet over a modal one, and it insets itself for the device the SDK reports. On the web, where there is no device model, it uses the notch-era default.

What a web purchase resolves with

purchase() resolves the same three statuses everywhere. A web purchase carries the product it was for and nothing else: there is no store transaction and no failure reason, because no store was involved. Branch on status, as Purchases does, rather than reading transaction.

Verify on a pushed version

superwall dev previews the flow and the copy, but it does not open the payment sheet: a preview has no API key, so purchase() on a Stripe product resolves through the studio's purchase prompt like any other product. Push and open the live URL to verify the checkout itself, see Push, promote & publish.

Two things gate that push: the Stripe product must already be imported into your Superwall dashboard (push validates every declared identifier, Stripe ones included), and the application needs Superwall for Agents enabled (private beta), see the CLI reference.

A full web funnel

The web-funnel example is the reference: question steps as pages, a typed plan selector with on-selection prefetch, then purchase(reference), the whole flow in one paywall. Because checkout is set, the flow's step and answers live in the page URL, so it resumes from any link, in Safari after an in-app browser, or back from hosted checkout. Keep every answer in useQueryState. Web Funnels is the guide.

How is this guide?

On this page