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.

Inside the Web SDK

The same paywall runs inside the Web SDK on your own site. Push it to your web app (platforms: ["ios", "web"]), put it behind a placement, and register() it from the page; the SDK frames it, inline in an element of yours or as an overlay. The framework speaks the SDK's bridge, so nothing in the paywall changes:

  • useDevice().hostPlatform is "web" from the first render, so web-only and native-only UI never show on the wrong host.
  • The Web SDK hosts the paywall the way the web paywall app hosts editor paywalls, so the paywall does no more than it does inside the native SDKs. The SDK prices the Stripe products from Superwall's paywall API and sends them in (getProduct() answers exactly as it does in the app), reports the paywall's events, and finishes a purchase.
  • sheet and applePay take payment in the paywall. A completed purchase binds the entitlement to the identified user, the SDK finishes the checkout, and the page's register() resolves purchased. There is no redemption step: a user signed in to your app under the same appUserId sees the entitlement there too.
  • close() dismisses it and register() resolves declined.

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.

A native SDK too old to host checkout inside the paywall (below 4.10.8) falls back to external on its own, whichever mode you configured. The Web SDK always hosts it.

Per purchase

checkout is the paywall's default. One purchase() call can ask for another mode, a button that always opens the sheet on a paywall whose default is applePay:

purchase("annual", { checkout: "sheet" });
prefetch("annual", { checkout: "sheet" }); // warm what that button opens

It overrides the default and nothing else: inside an iOS app the purchase still opens the hosted page, an SDK too old to host payment still falls back to it, and sheet or applePay only open where the config ships the payment UI (checkout set to sheet or applePay). On a paywall configured external, or with no checkout, they open the hosted page.

Inside an iOS app

A Stripe product on a paywall shown in an iOS app is bought on the web, outside the app: purchase() always opens Superwall's hosted checkout, whichever mode you configured. Its session is warmed when the paywall opens, so the browser comes up at once, and a second tap on the same product reopens the same checkout. That holds even with no checkout key at all: a Stripe product is never handed to the App Store. With teleport set and SuperwallKit 4.18 or later, the purchase opens your own paywall's checkout route in the browser instead of the hosted page. Store products on the same paywall still buy natively, so one paywall can sell Stripe in one storefront and the App Store in another. The price, period and trial the paywall shows for the Stripe product come from your dashboard, baked in at push, see Products.

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 checkout open instantly

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

Automatic: when the paywall opens, it warms one Stripe product, for the sheet or the hosted page alike; applePay warms every Stripe product on the paywall, up to ten. Inside an app that means when the SDK presents the paywall, never while it preloads in the background; on a web page, once it loads. Each product keeps its own session, so a second tap on the same product opens the same checkout again (sessions stay warm for about ten minutes), and a completed purchase forgets them all. 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, without a host's public API key, and for already-warm sessions. 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 useUserAttribute 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 [, setEmail] = useUserAttribute("email");

const onContinue = () => {
  setEmail(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 that is the session prefetching warms. 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.

One thing gates that push: the Stripe product must already be imported into your Superwall dashboard, since push validates every declared identifier, Stripe ones included. 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