# Framework Documentation # 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 # Actions Close the paywall, restore purchases, open links, request OS permissions, and call back into your app, everything a paywall asks its host to do. A paywall runs inside your app, and some things only the host can do: dismiss the paywall, open a link, prompt for a permission, run your app's code. All of it goes through `useActions()`: ```tsx import { useActions } from "superwall/hooks"; const { close, restore, openUrl, requestPermission, requestCallback } = useActions(); ``` ## The actions | Action | Use it for | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `close()` | Closing the paywall. The X button. Closing is not navigation. | | `restore()` | Restore purchases. Resolves `{ status: "restored" }` or `{ status: "failed" }` — the SDK reports "nothing to restore" as a failure like any other. See [Purchases](/docs/framework/purchases#restore). | | `openUrl(url)` | Terms, privacy, any link. **Always this, never ``.** | | `openExternalUrl(url)` | Open in the system browser instead of in-app. | | `openDeepLink(link)` | Deep link into the app. | | `customPlacement(name, params?)` | Fire a Superwall placement, which can present another paywall. | | `requestPermission(type)` | OS permission prompt. Resolves `"granted" \| "denied" \| "unsupported"`. | | `requestCallback(name, options?)` | Run **your app's** code and await its answer. Resolves `{ status: "success" \| "failure", data? }`. | | `requestStoreReview("in-app" \| "external")` | Store review prompt. | | `setUserAttributes({ key: value })` | Set attributes on the user, an email a funnel page captured, a plan they picked. The host SDK merges and persists them (the same as `Superwall.shared.setUserAttributes`), and the paywall's own `useUser()` sees them at once. Values are sent as strings. A `user.email` also becomes the email on every web checkout session from then on, see [Web checkout](/docs/framework/web-checkout#the-shoppers-email). | > **Warning:** Links go through `openUrl`, never an ``. Inside a webview, an anchor either does nothing or navigates the paywall away from itself. `openUrl` hands the URL to the host so it opens the way the platform expects. Closing works the same way: the paywall lives on a navigation stack of its own pages, but *leaving* the paywall isn't a navigation. It's `close()`. See [Pages & navigation](/docs/framework/navigation). ## Permissions ```tsx const status = await requestPermission("notification"); // "granted" | "denied" | "unsupported" ``` Permission types: `notification`, `camera`, `microphone`, `location`, `background_location`, `contacts`, `read_images`, `read_video` (Android only), `tracking`. ## Callbacks: ask your app a question A callback runs code *in your app* and hands the answer back to the paywall, anything the paywall cannot know on its own: does this account exist, is this referral code valid, what did the user pick during signup. ```tsx const result = await requestCallback<{ exists: boolean }>("checkAccount"); if (result.status === "success" && result.data?.exists) { router.push("welcome-back"); } ``` Type the answer with a claim, as above. The generic is your statement of what the app returns. ### Permission vs callback A **permission** asks the OS; a **callback** asks your app. Both resolve from code the paywall does not control, which shapes how you use them: * **Show something while they run.** The OS prompt or your app's code takes as long as it takes. * **Treat a denial as an ordinary outcome**, not an error. A user who declines notifications is still a user, design the path that continues without. ## In development In `superwall dev`, the studio is the host. Every action reaches it over the same protocol the SDK uses: `close()` and the URL actions surface as toasts, and permission, callback, purchase and restore requests prompt **you** to pick the outcome, which the studio then reports back with the messages the SDK would send. That makes both branches of every flow testable before a device ever sees it. See [The studio](/docs/framework/studio), and [Host Protocol](/docs/framework/host-protocol) for the messages underneath. > **Tip:** The permissions example shows `requestPermission` and `requestCallback` side by side, with a denial treated as an outcome rather than an error. See [Examples](/docs/framework/examples). # 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 # Assets Add images, video, audio, fonts, and animations to a paywall by importing files. The build handles optimization, hosting, and caching. Add media to a paywall by importing files from an `assets/` directory. The build handles optimization, hosting, and caching; there is nothing to configure and no upload step. ## Where assets live Assets follow the same two-level pattern as components and messages: shared at the project root, local inside a paywall. ```ts superwall/ ├── assets/ shared across every paywall └── paywalls/pro/ └── assets/ this paywall's own ``` > **Warning:** Every asset belongs in an `assets/` directory, `superwall/assets/` for shared files, `superwall/paywalls//assets/` for one paywall's own. If a large asset lives anywhere else, the build fails and names the file. ## Use an image Import the file and use it like any URL: ```tsx import hero from "@/assets/hero.jpg"; // shared: superwall/assets/ import badge from "../assets/badge.png"; // this paywall's own ``` CSS `url()` works the same way. Imports typecheck because of the generated `superwall.d.ts`, one more reason to [commit it](/docs/framework/project-structure). Supported out of the box: | Kind | Formats | | -------------- | --------------------------------------------------------- | | Images | `png` `jpg` `jpeg` `webp` `avif` `gif` `svg` `ico` `apng` | | Video | `mp4` `webm` `mov` `m4v` | | Audio | `mp3` `m4a` `aac` `wav` `ogg` | | Fonts | `woff2` `woff` `ttf` `otf` | | Animation & 3D | `lottie` `riv` `glb` | `?url`, `?raw`, and `?inline` import suffixes work too, as do CSS modules. ## How hosting works You never choose where an asset is served from. The build decides, and nothing about your code changes either way: * **Video, audio, and fonts** are always served from Superwall's CDN, whatever their size. Video streams properly instead of being carried by the paywall, and one upload is reused across every version of every paywall. * **Images** embed in the paywall when small and move to the CDN when large. ```tsx import promo from "../assets/promo.mp4"; ` either does nothing or navigates the paywall away from itself. Open links through `useActions().openUrl` instead. See [Actions](/docs/framework/actions). ### Controls sit in the status bar / under the home indicator The paywall is inset from the edges by default, so this happens for one of two reasons: `insets` in `config.ts` turned that off for the edge in question, or the control is `position: fixed` and positioned from a bare `env()`, which is 0 in previews, in Android WebView and in some webview contexts. Fixed chrome positions from `var(--sw-safe-area-inset-top)` and friends: the safe area where the host reports one, a floor for the device and presentation where it doesn't. Chrome inside the layout should not be fixed at all. See [Insets](/docs/framework/styling#insets). ### The payment sheet doesn't open in dev By design. A preview has no API key, so a Stripe product resolves through the studio's purchase prompt instead of the payment sheet, which lets you test the flow and copy without creating checkout sessions. Push and open the live URL to verify the checkout itself. See [Web checkout](/docs/framework/web-checkout). # 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 # Variables & Personalization React to user attributes, device state, and placement parameters, and write paywalls the dashboard can experiment on without a rebuild. Everything your app and the SDK tell a paywall about the presentation arrives through `useVariables()`: who the user is, what device they're on, and what the placement was called with. Read these defensively and a single paywall can greet a returning user by name, adapt to platform, or react to any parameter your app passes, all without a rebuild. ## `useVariables()` ```tsx import { useVariables } from "superwall/hooks"; const { device, user, params } = useVariables(); ``` Three records, three sources: * **`device`**, filled in by the SDK: `platform`, `deviceModel`, `osVersion`, `appVersion`, `deviceLocale`, `regionCode`, `deviceCurrencyCode`, `subscriptionStatus`, `activeEntitlements`, `daysSinceInstall`, `totalPaywallViews`, and more. * **`user`**, whatever your app set via `setUserAttributes` (`user.firstName`, `user.plan`, …). * **`params`**, whatever the placement was called with (`params.event_name` is the placement's name; `$`-prefixed keys are SDK-set, and anything the app passed alongside comes through unprefixed). ```tsx const name = typeof user.firstName === "string" ? user.firstName : undefined;

{name ? `Welcome back, ${name}` : "Go Pro"}

{device.platform ?? "—"} ``` ## Guard every read All three records are filled in by the host, your paywall controls none of them, so every read needs a fallback: * For **`device`** fields, `?? "—"` (or any sensible default) suffices. The SDK guarantees the shape, just not that a value has arrived yet. * For **`user`** and **`params`**, the host controls the *type* too, so check it before using it: `typeof params.event_name === "string"`. An attribute your app sets as a number today might be a string tomorrow, and the paywall must not crash either way. > **Warning:** `device.isSandbox` is a string, not a boolean. Compare it as one. While previewing, every one of these values is editable live in the studio's **Variables** panel: user attributes, device properties, placement params, and per-product variables, all seeded from your app's real sample data. Change a value and watch the paywall react. See [The studio](/docs/framework/studio). ## `useUser()` Shorthand for when you only need the user record: ```tsx import { useUser } from "superwall/hooks"; const user = useUser(); ``` Identical to `useVariables().user`, reach for it when the device and params records aren't needed. ## `useDevice()` The same device record as `useVariables().device`, plus **`orientation`**: ```tsx import { useDevice } from "superwall/hooks"; const { orientation, platform, deviceModel } = useDevice(); ``` `orientation` is `"portrait" | "landscape"`, measured in the page itself, it updates the moment the device turns, so you can build layouts that answer to rotation. The orientation example reflows to a two-column grid in landscape rather than shrinking the portrait layout; see [Examples](/docs/framework/examples). `hostPlatform` names the SDK showing the paywall: `"ios"` (an iPad app on a Mac too), `"android"`, `"web"` for the Web SDK on your own site, or `undefined` for a host that hasn't said, such as the studio's device-less preview. It is normalized, and it is known from the first render: until the host's device record arrives it reads the platform the host stamped on the paywall URL. Branch on it for anything one platform must never show, and show neither variant while it is `undefined`: ```tsx const { hostPlatform } = useDevice(); const native = hostPlatform === "ios" || hostPlatform === "android"; {native ? : null} {hostPlatform === "web" ? : null} ``` The raw `platform` field is the host's own string (`"iOS"`, `"macOS"`, `"Web"`) and is empty until the device record arrives, so don't compare against it. ## Built to be experimented on Notice what's missing: variables are never *declared* in code. What the paywall reads, user attributes, device state, placement params, product variables, trial eligibility, is supplied by the app and the store at runtime, and the studio overrides all of it live while previewing. Write every read defensively, guarded, typed, with a designed fallback, and every one of those values becomes a knob the dashboard can turn without a rebuild. A paywall that renders sensibly for any combination of inputs can be A/B tested freely. > **Tip:** The personalization example shows the full doctrine in one project: `?? "—"` for SDK-guaranteed device fields, `typeof` checks for host-controlled user and params reads, and designed fallbacks for every string. See [Examples](/docs/framework/examples). # 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. ## Inside the Web SDK The same paywall runs inside the [Web SDK](/docs/web) 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 | 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. A native 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. The Web SDK always hosts it. ## 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("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 ; ``` ## 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, 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. # 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 Funnels Quizzes and checkout funnels on the web: one paywall whose steps are pages, every answer kept in the URL so the flow survives any browser hand-off, and payment at the end. A web funnel is a paywall with `checkout` set: a few question pages, a plan, then `purchase()`. It is served as a normal web page, and a web page has one problem a native paywall never has: **the person may change browsers halfway through.** A link opened from Instagram or TikTok runs in that app's in-app browser; tapping "Open in Safari" (or being sent there to pay with Apple Pay) hands over the URL and nothing else. `localStorage`, cookies, React state, all of it stays behind. Hosted checkout comes back to a URL too, and a reload starts from scratch. So a web funnel keeps its state in the URL. The router does its half automatically; your half is one rule. ## The rule: every answer is `useQueryState` On a web funnel, **never hold an answer in `useState`, layout context, or a module.** Single choice, multi choice, text input, the selected plan, anything the person entered lives in `useQueryState`, so any URL resumes the flow on the same step with the same answers. ```tsx import { parseAsArrayOf, parseAsStringEnum, useQueryState } from "superwall/navigation"; const GOALS = ["focus", "habit", "catch-up"] as const; const goalParser = parseAsStringEnum(GOALS); // single choice — set, then move on const [goal, setGoal] = useQueryState("goal", goalParser); const choose = (value: (typeof GOALS)[number]) => { haptics.selection(); setGoal(value); router.push("interests"); }; ``` ```tsx // multi choice — an array of enum ids, toggled in place const [interests, setInterests] = useQueryState( "interests", parseAsArrayOf(parseAsStringEnum(["reading", "writing", "speaking"])).withDefault([]), ); const toggle = (id: Interest) => setInterests((current) => current.includes(id) ? current.filter((one) => one !== id) : [...current, id], ); ``` ```tsx // text input — a plain string; replaces are coalesced, so typing is safe const [name, setName] = useQueryState("name"); setName(event.target.value || null)} /> ``` ```tsx // the selected plan, typed against config const [plan, setPlan] = useQueryState("plan", parseAsStringEnum(["monthly", "annual"]).withDefault("annual")); ``` Every later page reads the same hook: the plan page shows `goal`, the summary page lists `interests`, and `purchase(plan)` uses the selection, all with no context and no prop drilling. Guard reads on pages someone might land on directly: `goal ? COPY[goal] : COPY.default`. The API is [nuqs](https://nuqs.dev)'s, so its parsers read the same: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsStringEnum`, `parseAsArrayOf`, `createParser`, each with `.withDefault()` (removes `null` from the type and clears the key when the value equals the default) and `.withOptions({ history, clearOnDefault })`. Junk in the URL parses to the default. Full signatures are in [Hooks](/docs/framework/hooks#usequerystatekey-parser). > **Note:** The same hook on a native host, where there is no URL bar, is plain state shared across pages. A funnel written this way runs unchanged natively; only where the state is kept differs. ## What the router does on its own With `checkout` set, `queryState` defaults to on and the route stack is mirrored into one reserved param: ``` https://yourapp.superwall.app/funnel?sw_nav=index,goal,interests&goal=habit&interests=reading,speaking ``` * `router.push` adds a browser history entry; back, replace and dismiss rewrite in place. **The browser's back button is `router.back()`**, including Android's hardware back. * Any URL rebuilds the stack it names, with no animation and one `entry` page view. A route that no longer exists starts the flow over at `index`. * Writes are coalesced so a text input can't trip Safari's history rate limit, and anything pending is flushed the moment the page is hidden, the instant before a hand-off or the jump to hosted checkout. * Foreign params (`utm_*`, attribution) are left untouched, and survive the whole flow. `definePaywall({ queryState: false })` turns it off for a checkout surface; `queryState: true` turns it on for a web surface without checkout. See [Config](/docs/framework/config). ## Branch on the answers Branching reads the same state, so a branch taken before a hand-off is the branch resumed after it: ```tsx const [goal] = useQueryState("goal", goalParser); const next = () => router.push(goal === "catch-up" ? "backlog" : "interests"); ``` Because the stack is in the URL as the routes actually visited, back always retraces the branch taken. ## Keep the URL small and clean About 2 kB is safe across every app and share sheet, and a question flow of twenty short answers is well under 500 bytes if you follow three habits: * **Enumerate.** Store ids (`"habit"`), never labels (`"Build a habit"`). `parseAsStringEnum` gives you validation for free. * **Short keys, cleared defaults.** `goal`, not `selectedGoalOption`; leave `clearOnDefault` on so untouched answers cost nothing. * **Nothing personal.** URLs end up in referrer and analytics logs. An email or a name belongs in the checkout sheet's own fields, not in the query string. Keys starting with `sw_`, plus `platform` and `transport`, are reserved, the hook throws on them. ## Move like a funnel Set the funnel transition once; `shift` fades each step in as it drifts into place and drops the previous step outright, so a long flow never reads as a growing stack: ```ts export default definePaywall({ name: "Onboarding", transition: "shift", checkout: { mode: "sheet", prefetch: "annual" }, products: { monthly: "live:price_…:no-trial", annual: "live:price_…:7days-free" }, }); ``` Then the plan page prefetches the selected product and `purchase(plan)` opens the sheet, see [Web Checkout](/docs/framework/web-checkout). ## Checklist * `checkout` set in `config.ts`; `transition: "shift"` * every answer, selection and input is `useQueryState`, no `useState` for anything the person entered * enum ids, short keys, defaults cleared, nothing personal * later pages guard their reads, so a direct link never crashes * test it: answer two questions, copy the studio's iframe URL into a new tab, and you should land on the same step with the same answers The `web-funnel` [example](/docs/framework/examples) is the reference.