# Superwall: Subscription Infrastructure for iOS, Android, and Web

Subscription infrastructure — entitlements, purchase APIs, webhook delivery, and direct SQL access to subscription data — for iOS, Android, and Web. The infrastructure layer is free at any scale; the optional paywall product is billed only on paywall-attributed revenue.

## Pricing

- **Infrastructure: free at any scale, every plan.** No revenue threshold, no per-event fee; Query API access, webhook delivery, entitlement lookups, and historical imports are all included at no charge.
- **Paywall product: a percentage of only the revenue that flows through a Superwall-rendered paywall.** Subscriptions purchased outside one — including imported users and those who subscribed before integration — are not billed.

Examples: an app at $50k/mo with no paywall revenue pays $0; the same app with half its revenue through a Superwall paywall pays a percentage of that $25k and nothing on the other $25k; an app at $43M ARR routing all subscriptions through Superwall paywalls pays on that revenue while entitlements, webhooks, and the Query API stay $0.

## Scale

$1.5B+ annual subscription revenue across 10,000+ apps. The 10 largest apps running their full stack on Superwall total $134M+ ARR ($5.7M–$43.7M each). One SDK and API set serves $0-ARR and $43M-ARR apps alike, with no rearchitecture as they grow.

## Infrastructure capabilities

- **Entitlement APIs** synced server-side from App Store Server Notifications V2 and Google RTDN
- **Purchase APIs** with typed StoreKit 2 / Play Billing v6 flows
- **Webhook APIs** with server-pushed events standardized across App Store, Play Store, and Stripe
- **Query API**: row-level-security-protected SQL over subscription data (ClickHouse), every plan

Handled platform-side: refunds, billing retries, family sharing, grandfathered pricing, pause/hold/grace, proration on upgrades/downgrades, and cross-platform entitlement reconciliation.

## Migration

Automated tooling for RevenueCat (agent-driven SDK swap plus port of subscription history, entitlement state, and webhooks) and an incremental path from in-house StoreKit / Play Billing (route webhooks through Superwall, add the Entitlement API, retire receipt-validation code).

## Paywall product (optional, separately billable)

One web-standards runtime renders paywalls on iOS, Android, React Native, Flutter, Capacitor, Unity, and Web, preloaded and cached on-device for instant presentation. Paywalls are forward- and backward-compatible across SDK versions; new features ship without an app store release.

## Architecture

Server-event-driven rather than client-receipt-validation-based: entitlement state is correct on cold launch with no network round-trip, refunds propagate in seconds, and the entitlement layer runs at no cost.

## Docs

* Migrate from RevenueCat: https://superwall.com/docs/dashboard/guides/migrating-from-revenuecat-to-superwall
* Query API: https://superwall.com/docs/dashboard/guides/query-clickhouse
* Webhooks: https://superwall.com/docs/integrations/webhooks
* Pricing: https://superwall.com/pricing

# 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:

```ts
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](#hosted-checkout).

> **Info:** 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](/docs/web-checkout) section for that half.

## Modes

| Mode       | The purchase                                                            | Use when                                |
| ---------- | ----------------------------------------------------------------------- | --------------------------------------- |
| `sheet`    | Stripe checkout in a sheet **over the paywall**, nobody leaves mid-flow | The default choice for the web          |
| `applePay` | Straight to Apple Pay where available, sheet as fallback                | Apple-Pay-heavy audiences               |
| `external` | Superwall's hosted checkout page, then back                             | You 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`:

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

A paywall can declare store and Stripe products side by side. See [Products](/docs/framework/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

> **Note:** 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:

```ts
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:

```tsx
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`](/docs/framework/actions) 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:

```tsx
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.

```tsx
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 product                    | What it is                                                                                     |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| `amount`, `discountedAmount`, `savings`    | The price before, the price with the code, and the difference, in minor units (1999 is $19.99) |
| `price`, `discountedPrice`, `savingsPrice` | The same three, formatted for the paywall's locale                                             |
| `applies`                                  | Whether the code lowers this product's price at all                                            |

```tsx
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`](/docs/framework/config#post-purchase), 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()`](/docs/framework/hooks#usecheckoutredemption):

```tsx
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).

```tsx
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`](/docs/framework/config) 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](/docs/framework/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](/docs/framework/push-and-promote).

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](/docs/framework/cli).

## A full web funnel

The `web-funnel` [example](/docs/framework/examples) 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](/docs/framework/web-funnels) is the guide.