` | Limit to these paywalls or funnels; repeatable or comma-separated. |
| `--platform ` | Limit to these platforms (`ios`, `android`, `web`); repeatable or comma-separated. |
| `--rename =` | Declare a directory rename so CI can resolve it. |
| `-m ` | Recorded on the source commit, it only lands when the source actually changed. |
A push refuses, before anything is written, when:
* a selected paywall has diagnostics (publishing is immutable; fix first), but a directory missing `app/index.tsx` or `config.ts` isn't a paywall yet, so it's skipped silently rather than blocking; `superwall dev` is where you see it,
* a product in `config.ts` doesn't exist on the dashboard (every variable on it would be undefined on device),
* a directory rename is unresolved (below),
* the project pushes to several platforms and a selected paywall's `config.ts` has no `platforms` field, see [Several platforms](/docs/framework/push-and-promote#several-platforms).
The first push binds each paywall, creating it on Superwall if needed, and records the binding in `superwall.lock`, per platform; commit that file. After that, push always updates the same paywalls; no IDs ever appear in your code.
Every push also snapshots your `superwall/` source, so the code each version was built from is recoverable. `.env`, `node_modules/`, `.git/`, and anything `superwall/.gitignore` lists never leave the machine.
### Renames
Renaming a paywall directory is detected, never guessed. Interactively, push asks whether the unfamiliar directory is a rename (keeping the live paywall attached) or a new paywall. In CI, declare it, anything unresolved stops the push rather than creating a duplicate:
```sh
superwall push --rename plus-upgrade=pro-upgrade
```
Deleting a paywall directory never blocks a push. The dashboard paywall keeps serving, and restoring the directory re-binds it.
## `superwall promote`
Points production at a pushed version. Promote never rebuilds, it only moves the live pointer.
| Flag | What it does |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `--id ` | Limit to one paywall or funnel. |
| `--platform ` | Limit to one platform. |
| `--version`, `-v ` | Pick a specific version (with a single `--id`, and `--platform` when that paywall is on several platforms), which is also the **rollback**. |
```sh
superwall promote --id plus-upgrade --version 5
# → Rolled back version 7 → 5
```
## `superwall publish`
Push + promote in one step. Takes `-m `, `--id`, `--platform`, and `--rename`. Also warns about other paywalls that are pushed-but-not-live, so nothing ships half-forgotten.
## Before pushing: create the products
A push refuses if a `config.ts` names a product the dashboard doesn't have. The fix is a command away. The same CLI writes products directly:
```sh
superwall entitlements list --json # grab the NUMERIC entitlement id
superwall products create pro_3999_year \
--project --app \
--name "Annual" --price 39.99 --period year \
--trial-days 7 --entitlement --json
```
* `--entitlement` takes the **numeric id** (`55688`), not the identifier (`pro`), the identifier fails with a decode error.
* Pass `--project` explicitly when your account has several, or the command errors with "Multiple projects found".
* `--price` is major units (`39.99`); `--period` is `day|week|month|year`; `--trial-days` sets the intro offer.
* `--dry-run` confirms the target before writing anything.
## Before you push
* **Superwall for Agents has to be enabled** on each Superwall app you push to, otherwise the push stops with "Superwall for Agents is in private beta and isn't enabled for this app yet." It's enabled on our side, not in the CLI; contact [support@superwall.com](mailto\:support@superwall.com) for access.
* **One broken surface blocks the whole push.** A leftover scaffold aimed at a nonexistent product stops everything, push what you built with repeated `--id` flags instead of touching unrelated directories.
For the full error-message-to-fix table, see [Troubleshooting](/docs/framework/troubleshooting).
# 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
# Configuration
Everything definePaywall accepts, products, background, transitions, trial reminders, and the behavior settings that shape a paywall.
Every paywall declares itself in a required `config.ts`: its dashboard name, its products, and any behavior settings. This file is the whole truth for the paywall. Nothing is inherited from anywhere else.
```ts
import { definePaywall } from "superwall/config";
export default definePaywall({
name: "Plus — Annual, 3-day trial",
products: {
monthly: "pro_999_month",
annual: "pro_5999_year",
},
});
```
TypeScript is the only validation, so keep the object literal inline. That's what lets the compiler catch typos. If you want to share settings between paywalls, export a plain object and spread it:
```ts
// superwall/components/shared-config.ts
export const shared = { transition: "slide", background: "#0d0f12" } as const;
// paywalls/pro/config.ts
export default definePaywall({ ...shared, name: "Pro", products: { … } });
```
## Options
| Key | Type | Default | What it does |
| ------------------------------ | ------------------------------------------------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | `string` | directory name, title-cased | The label shown in the dashboard. The directory stays the identifier. |
| `platforms` | `("ios" \| "android" \| "web")[]` | the project's only platform | Which platforms this paywall is pushed to, see [below](#platforms). Required once the project pushes to several. |
| `products` | `Record` | — | Product slots by reference, optionally one id per platform, see [below](#products). |
| `transition` | `"push" \| "slide" \| "fade" \| "shift" \| "none"` or custom | `"push"` | Default page transition, see [Transitions](/docs/framework/transitions). |
| `queryState` | `boolean` | on when `checkout` is set | Keep the route stack and every `useQueryState` value in the page URL, so the flow resumes from any link. Never applies on a native host, see [Pages & navigation](/docs/framework/navigation). |
| `checkout` | mode or `{ mode, prefetch?, managedPayments? }` | — | Sell on the web. Omit for native-only, see [Web checkout](/docs/framework/web-checkout). |
| `background` | `string` or `{ light, dark? }` | — | Background color painted behind the paywall while it loads and as the page background, see [below](#background). |
| `presentation` | `{ style, drawer?, popup? }` | `{ style: "fullscreen" }` | How the SDK presents the paywall, see [below](#presentation). |
| `insets` | `"safe-area" \| "none" \| number \| string \| { top?, right?, bottom?, left? }` | `"safe-area"` | What the paywall is padded with at each screen edge, see [below](#insets). |
| `featureGating` | `"gated" \| "nonGated"` | `"nonGated"` | Whether the feature behind the placement is gated on a purchase. |
| `onDeviceCacheEnabled` | `boolean` | `true` | Cache the paywall on device for instant presentation. |
| `gameControllerEnabled` | `boolean` | `false` | Forward game controller input to the paywall. |
| `introductoryOfferEligibility` | `"automatic" \| "eligible" \| "ineligible"` | `"automatic"` | How the SDK decides trial eligibility for this paywall's products. `"automatic"` asks the store; the other two force the answer, useful to preview or ship one variant regardless of the shopper's history. Native only: on the web a trial is whatever the Stripe price's offer says. See [Trials](/docs/framework/trials). |
| `postPurchase` | `"dismiss" \| "stay" \| "redeem" \| { redirect }` | `"dismiss"` | What happens once a purchase completes. `"redeem"` and `{ redirect }` are web-only. See [below](#post-purchase). A `purchase()` call can override it per button. |
| `notifications` | `{ trialReminder }` | — | Trial-reminder notification, see [below](#trial-reminder-notifications). |
| `localization` | `{ defaultLocale, messages? }` | `"en"` | Fallback locale; file-based catalogs need no config, see [Localization](/docs/framework/localization). |
| `scrollEnabled` | `boolean` | `true` | Whether the paywall scrolls. |
| `allowedHosts` | `string[]` | — | Hosts this surface may call over the network, as bare hostnames. Superwall and Stripe are always reachable; anything else is refused until it is named here, see [below](#allowed-hosts). |
> **Note:** A paywall built with the framework has no dashboard editor, so `config.ts` is the only place its settings live. Presentation, feature gating, caching, scrolling, game controller, web checkout destination, and background all travel with the pushed version and are applied on promote, which also means `promote --version` rolls them back with the code. Whatever a config omits resets to the default in the table.
> **Note:** There is deliberately **no identifier field**. The directory path is the paywall's identity, and the dashboard binding lives in `superwall.lock`, never in this file. See [Project structure](/docs/framework/project-structure).
## Allowed hosts
A published paywall runs under a Content-Security-Policy Superwall enforces when it serves the page. By default it may talk to Superwall and Stripe and nothing else, so a `fetch` to your own API is refused by the browser before it reaches the network — no request is sent, and the only sign is a CSP error in the console.
Name the hosts you call and they are permitted:
```ts title="superwall/funnels/onboarding/config.ts"
export default definePaywall({
name: "Onboarding",
allowedHosts: ["api.example.com"],
});
```
Write bare hostnames — `https` is implied and required. A pasted `https://api.example.com` is accepted and normalizes to the same thing. An explicit port is kept (`api.example.com:8443`).
Not accepted: `http://`, wildcards like `*.example.com`, paths, and anything that is not a hostname. Every entry is https, with no exception — a served policy should never be able to carry a plaintext origin. Each one goes into that policy verbatim, so it has to be a host you actually call rather than a pattern. `superwall push` fails and names the entry it could not read, so a typo stops the push instead of becoming a blocked request in production.
### A local API
You do not need to name one. **`superwall dev` applies no policy at all**, so a `fetch` to `http://localhost:3000` works there whether or not it appears in `allowedHosts`.
`localhost` and `127.0.0.1` are rejected outright, and the push says so. The reason is arithmetic rather than policy: a pushed paywall runs on a shopper's device, where `localhost` is *their* machine, not yours — so the entry could only widen the policy for everyone in exchange for a request that cannot succeed. To reach a local API from a **pushed** build, put it behind an https tunnel (ngrok, cloudflared) and name that hostname.
> **Note:** The flip side is that a missing host never fails in dev, only after deploy. Add the host in the same change that writes the `fetch`, not when you notice it failing.
> **Note:** A surface may name up to 20 hosts. If you need more, the work usually belongs behind one backend of your own — and every entry widens the policy for every shopper who sees the paywall.
The list travels with the pushed version, like every other setting here, so `promote --version` rolls it back with the code and two versions can permit different hosts.
> **Callout:** This is not a formality. The policy also permits `unsafe-eval`, which the bundler output needs, so the set of reachable hosts is what keeps a published paywall from fetching and running code nobody reviewed. Name the host you call, not a wildcard that happens to cover it.
## Post-purchase
`postPurchase` is one setting for both platforms; each host reads the part it can act on.
| Value | Native | Web |
| --------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"dismiss"` (default) | SDK dismisses the paywall. | The host does what the placement says: grant access and close in a Web SDK overlay, redeem on a hosted page. |
| `"stay"` | Nothing is dismissed. | The paywall keeps control; finish the purchase with [`useCheckoutRedemption()`](/docs/framework/hooks#usecheckoutredemption) — a success screen, a "Continue to app" button. |
| `"redeem"` | Same as `"dismiss"`. | Send the shopper to the redemption page, which opens the app with their code. |
| `{ redirect: url }` | Same as `"dismiss"`. | Send the shopper to `url` with their redemption code attached. |
Without a `checkout`, or with `platforms` that leave out `"web"`, the type narrows to `"dismiss" | "stay"` — there is nowhere to redeem or redirect to. A `purchase(ref, { postPurchase })` call overrides it for that button.
## Platforms
A project can push to several platforms: an iOS app, its Android counterpart, and a web app, all in the same Superwall project. `platforms` says which of them this paywall is for:
```ts
// paywalls/plus-upgrade/config.ts: one directory, shipped to both stores
export default definePaywall({ platforms: ["ios", "android"], products: { … } });
// paywalls/web-upgrade/config.ts: the web app only
export default definePaywall({ platforms: ["web"], checkout: "sheet", products: { … } });
```
The values are `ios`, `android`, and `web`, and the array is typed, so anything else is a compile error. The project binds one Superwall app per platform in `superwall.lock`, and every platform listed gets its own dashboard paywall, pushed and promoted independently: a shared directory is one codebase with one live version per platform.
On a single platform the field is optional. Once the project pushes to more than one, every paywall has to say; a push refuses a silent one rather than guess. There is no project-wide default on purpose (`config.ts` is the whole truth for its paywall); to mirror a set everywhere, share it like any other setting, `export const everywhere = { platforms: ["ios", "android", "web"] } as const`, and spread it.
A reference stays the same on every platform, but the store product behind it usually doesn't. Give such a slot one id per platform (`annual: { ios: "…", android: "…" }`, [below](#products)); each platform's build and version carry their own. Binding a platform to an actual app happens on the first push, see [Push, promote & publish](/docs/framework/push-and-promote#several-platforms).
## Presentation
How the native SDK shows the paywall. `style` is one of `fullscreen` (default), `modal`, `push`, `noAnimation`, `drawer`, or `popup`; the last two take geometry, as percentages of the screen and a corner radius in points:
```ts
presentation: { style: "drawer", drawer: { height: 60, cornerRadius: 24 } }
presentation: { style: "popup", popup: { width: 80, height: 60, cornerRadius: 15 } }
```
Geometry is optional and defaults to a 70% drawer or an 80% × 60% popup with a 15pt radius, the same defaults the editor uses. A zero or negative dimension fails the build. Web builds ignore presentation; the browser is the page.
The style also shapes the paywall's [insets](#insets): a modal or drawer starts below the status bar, so the top inset is 0 there, and a popup is inset by the SDK on every side. The style is stamped on `` as `data-sw-presentation` for CSS that wants to know.
## Insets
What the paywall is padded with at each screen edge. The default, `"safe-area"`, keeps every layout and page clear of the status bar, cutout and home indicator on the device the paywall is running on, and clear of nothing on the web:
```ts
insets: "safe-area" // default — the device's safe area, floored per platform and presentation
insets: "none" // flush to every edge: a full-bleed paywall
insets: 24 // pixels, all four edges
insets: "1.5rem" // any CSS length
insets: { top: "none", bottom: "safe-area" } // per edge; an omitted edge stays "safe-area"
```
Insets never shrink the scroll area: each page scrolls edge to edge and carries the inset as its scroll padding, so content passes under the bars and rests clear of them. Turn an edge off only for something that must rest under the bar. A full-bleed design (`"none"`, or `top: "none"` for a hero whose top edge is the screen edge) keeps its close button out of the bar with `--sw-safe-area-inset-top`, which the framework maintains whatever `insets` says. How the safe area is resolved, and the variables behind it, are in [Styling](/docs/framework/styling#insets). Insets are a page-level concern and are not part of the version's settings; the SDK is not involved.
## Background
Set the color that sits behind the paywall. The string shorthand sets it for light mode; the object form adds a dark-mode color:
```ts
background: "#0d0f12"
// or
background: { light: "#ffffff", dark: "#0d0f12" }
```
Colors are 6- or 8-digit hex (`#RRGGBB` or `#RRGGBBAA`). Shorthand like `#fff`, named colors, and `rgb()` are rejected at push.
It does two things from one value: native SDKs paint it behind the webview and derive the loading spinner from it, and the web document uses it as the page background, so the color a shopper sees while the paywall loads matches the color it settles on. It takes effect on the next `superwall publish`; when absent, paywalls fall back to the platform default.
## Products
Products are **slots**. The key is the reference your code uses; the value is the store identifier:
```ts
products: {
annual: "pro_5999_year", // shorthand
monthly: { productId: "pro_999_month" }, // same thing
yearly: { ios: "pro_5999_year", android: "pro_5999_year_play" }, // one id per platform
}
```
A per-platform slot must name every platform in `platforms`; a push refuses one that can't resolve on a platform it ships to. Your components read them by reference (`getProduct("annual")`, `purchase("annual")`), and the references are typed, so a typo is a compile error. Web/Stripe products put the Stripe price inside the identifier using the `{test|live}:price_…:{offer}` format.
Product **data** (price, period, trial) never appears in this file. It's store-owned and arrives at runtime; a reference the dashboard has no product for renders undefined variables and blocks publishing. The full story, including how to read product variables safely, is in [Products](/docs/framework/products).
## Trial reminder notifications
Declare a local notification and the SDK schedules it when a trial actually starts. The paywall doesn't need to be open when it fires:
```ts
notifications: {
trialReminder: {
title: "Your trial ends tomorrow",
body: "Keep Pro, or cancel in Settings — no charge either way.",
beforeTrialEndDays: 1, // default 1
},
},
```
`title`, `subtitle`, and `body` accept message keys (resolved through `t()`) or literal copy. For full control, pass a function instead. It receives `{ trialEndDate, product, t, locale }` and returns `{ title, body, delayMs }`, or `null` to skip the notification entirely. The trial reminders example shows both forms, see [Examples](/docs/framework/examples).
## Experimenting without rebuilds
Variables are never declared in this file: everything the paywall reads (`useVariables()`, product variables, trial eligibility) is supplied by your app and the store at runtime, and the studio can override all of it live while you preview. Write your paywall to read variables defensively and every one of them becomes experimentable from the dashboard, no rebuild required. See [Variables & personalization](/docs/framework/variables).
# 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
# Examples
Complete, standalone example projects, each teaching exactly one idea, from a minimal paywall to trials, funnels, and Rive animations.
Every example is a complete, standalone framework project teaching exactly **one idea**. Styling is deliberately plain so the mechanism is the thing you read. When what you're building matches one, read it before writing code. Each `README.md` explains the idea, and every example runs as-is.
## Use an example
```bash
superwall create --example multi-page # scaffold it as a new project
superwall dev # open it in the studio
```
All examples are public at [github.com/superwall/superwall/tree/main/examples](https://github.com/superwall/superwall/tree/main/examples), browse them there, or copy a directory anywhere for a working `superwall dev`.
## Fundamentals
| Example | The one idea | Reach for it when |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------- |
| [`minimal`](https://github.com/superwall/superwall/tree/main/examples/minimal) | One page, one product, purchase. A paywall is a React component, not a template | Starting anything |
| [`product-selection`](https://github.com/superwall/superwall/tree/main/examples/product-selection) | Selection state is ordinary React. The framework has no "selected plan" concept | Multiple plans, price rows |
`minimal` shows the canonical price guard (render the price only when it exists, with bare copy as the fallback). `product-selection` adds a typed plan union, `haptics.selection()` on choice vs `haptics.light()` on the CTA, a real radiogroup with `aria-checked`, store-formatted `price` and `monthlyPrice` side by side, and a designed unpriced state.
## Navigation
| Example | The one idea |
| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| [`multi-page`](https://github.com/superwall/superwall/tree/main/examples/multi-page) | `router.push`/`back`, the page stack, and chrome in `layout.tsx` that reads router state |
| [`transitions`](https://github.com/superwall/superwall/tree/main/examples/transitions) | All five built-ins plus a custom `zoom`, proof a transition is just CSS on two attributes |
| [`onboarding-quiz`](https://github.com/superwall/superwall/tree/main/examples/onboarding-quiz) | Answers decide where you land; the router carries no state (a plain module outside React does) |
`onboarding-quiz`'s terminal page defends every read, a replayed page never crashes on a missing answer, and hardcodes step labels per page, because a branching flow's depth is not its step number.
## Purchases
| Example | The one idea |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`purchase-states`](https://github.com/superwall/superwall/tree/main/examples/purchase-states) | The two channels: what your `purchase()` call resolves vs what the SDK reports on its own |
| [`trial-eligibility`](https://github.com/superwall/superwall/tree/main/examples/trial-eligibility) | Two paywalls in one, chosen by the store, every string forks and both states read as intentional |
| [`abandonment-offer`](https://github.com/superwall/superwall/tree/main/examples/abandonment-offer) | `purchase()` resolving `abandoned` is a signal only this paywall can act on, a last-chance offer on a custom `sheet` transition |
| [`trial-reminders`](https://github.com/superwall/superwall/tree/main/examples/trial-reminders) | A local notification declared in config, scheduled by the SDK when the trial starts |
| [`web-funnel`](https://github.com/superwall/superwall/tree/main/examples/web-funnel) | Selling on the web: steps as pages on the `shift` transition, answers kept in the URL with `useQueryState`, then `checkout: "sheet"`, `purchase()` unchanged |
`purchase-states` is the only example showing the full haptic vocabulary (`success()` / `error()` keyed to outcomes). `abandonment-offer` holds the deepest CSS lesson of the set: the scrim behind its sheet reuses the framework's timing variables, so one number drives both the page dim and the backdrop, see [Transitions](/docs/framework/transitions).
## The host
| Example | The one idea |
| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| [`personalization`](https://github.com/superwall/superwall/tree/main/examples/personalization) | `useVariables()`, device, user, placement params, and guarding every read |
| [`permissions`](https://github.com/superwall/superwall/tree/main/examples/permissions) | `requestPermission` (asks the OS) vs `requestCallback` (asks *your app*); a denial is an outcome, not an error |
## Look and feel
| Example | The one idea |
| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| [`custom-fonts`](https://github.com/superwall/superwall/tree/main/examples/custom-fonts) | A typeface from a file in your project, relative-path `@font-face`, subset to latin |
| [`with-tailwind`](https://github.com/superwall/superwall/tree/main/examples/with-tailwind) | Tailwind v4 with zero framework config, and the `dark:` variant redefined onto the SDK's class |
| [`with-motion`](https://github.com/superwall/superwall/tree/main/examples/with-motion) | In-page animation gated on presentation, plus a price count-up gated on the value existing |
| [`with-rive`](https://github.com/superwall/superwall/tree/main/examples/with-rive) | Interactive vector animation, `.riv` as a hosted asset, the WASM engine bundled |
| [`orientation`](https://github.com/superwall/superwall/tree/main/examples/orientation) | `useDevice().orientation`, landscape is a two-column reflow, not a shrunken portrait |
## Localization
| Example | The one idea |
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| [`localization`](https://github.com/superwall/superwall/tree/main/examples/localization) | Four locales by filename, shared + paywall-local catalogs, guarded `{price}` interpolation, no language picker on device |
# 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
# Hooks Reference
Every hook the framework provides, signatures, what each returns, and the semantics that matter.
Everything a paywall reads or triggers comes through hooks. One concern each. There is deliberately no kitchen-sink hook.
```tsx
import {
useProducts, usePurchase, useDiscount, useActions, useCheckoutRedemption, useHaptics,
useTranslation, useIntroductoryOffer, useDevice, useUser, useVariables, useColorScheme,
useSuperwallEvent, useSuperwallSnapshot, useSuperwallSession,
type ProductReference,
} from "superwall/hooks";
import { useRouter, useIsFocused } from "superwall/navigation";
```
## `useProducts()`
```tsx
const { products, getProduct } = useProducts();
const annual = getProduct("annual"); // typed reference — typos are compile errors
```
Products, keyed by the reference declared in `config.ts`, each carrying store-owned `variables` (`price`, `period`, `trialPeriodDays`, …). A declared reference always exists, but its variables may not have arrived, guard every read and design the empty state. The full variable list and reading rules are in [Products](/docs/framework/products).
## `usePurchase()`
```tsx
const { purchase, prefetch, isPurchasing, transaction, failure } = usePurchase();
const result = await purchase("annual");
// { status: "completed" | "abandoned" | "failed" } — never throws for flow outcomes
```
The whole purchase flow (outcomes, the no-loading-state rule, web checkout, and `prefetch`) is in [Purchases](/docs/framework/purchases).
## `useDiscount(options?)`
```tsx
const { redeem, clear, applied, isRedeeming, getDiscountedProduct } = useDiscount({
onDropped: (code) => toast(`${code} is no longer valid`),
});
const result = await redeem(code); // { valid: true, … } | { valid: false, reason }
const annual = getDiscountedProduct("annual"); // discounted prices, or undefined
```
Stripe promotion codes for web checkout. A redeemed code rides on every checkout session for the products it applies to. `applied.discount` is Stripe's coupon under Stripe's own field names (`percentOff`, `amountOff`, `duration`, `durationInMonths`), and each product carries only what had to be computed: `amount`, `discountedAmount` and `savings` in minor units, plus `price`, `discountedPrice` and `savingsPrice` formatted for the locale. Only meaningful with `checkout: "sheet"` or `"applePay"`. [Web checkout](/docs/framework/web-checkout) has the flow.
## `useActions()`
```tsx
const { close, restore, openUrl, requestPermission, requestCallback } = useActions();
```
Also on the object: `openExternalUrl`, `openDeepLink`, `customPlacement`, `requestStoreReview`, `setUserAttributes`. Everything a paywall asks its host to do. [Actions](/docs/framework/actions) has the full table, the permission types, and the callback pattern.
## `useCheckoutRedemption()`
```tsx
const { completed, redemptionUrl, appLink, redeem, redirect, finish, openApp } = useCheckoutRedemption();
```
Finishes a web checkout the paywall chose to keep with [`postPurchase: "stay"`](/docs/framework/config#post-purchase). `completed` is false until such a purchase lands; then render your own success screen and call `redeem()` when the shopper taps continue, `openApp()` to jump straight into the installed app through `appLink` (show that button only when `appLink` is set), `redirect(url)` to send them somewhere of yours, or `finish()` to do whatever the paywall's stored behavior says. On any other `postPurchase` the host finishes the purchase itself and `completed` stays false. Only meaningful with web checkout.
## `useHaptics()`
```tsx
const haptics = useHaptics();
haptics.light(); // navigation, CTAs
haptics.selection(); // changing a choice
haptics.success(); // purchase landed
```
Also available: `medium`, `heavy`, `warning`, `error`. Fire one on every meaningful tap, iOS produces no feedback of its own inside a paywall. No-ops where haptics are unavailable, so call them unconditionally.
## `useTranslation()`
```tsx
const { t, locale, setLocale, locales } = useTranslation();
t("paywall.cta", { price });
```
Localized copy from `messages/.ts` catalogs, the catalog system, fallback rules, and interpolation are in [Localization](/docs/framework/localization).
## `useIntroductoryOffer()`
```tsx
const { eligible } = useIntroductoryOffer(); // boolean | undefined — the store decides
```
Introductory-offer eligibility, the same signal the visual editor calls `hasIntroductoryOffer`. `undefined` until the SDK reports, so gate trial-only UI on `eligible === true`. Splits the paywall into eligible and ineligible versions, both must read as intentional. See [Free trials](/docs/framework/trials).
## `useVariables()`
```tsx
const { device, user, params } = useVariables();
```
Everything the app and SDK told this paywall about the presentation: the SDK-filled `device` record, `user` attributes your app set, and the placement's `params`. All three are host-filled, so guard every read. The records, fields, and guarding doctrine are in [Variables & personalization](/docs/framework/variables).
## `useUser()`
```tsx
const user = useUser();
```
Shorthand for `useVariables().user` when the device and params records aren't needed.
## `useDevice()`
```tsx
const { orientation, hostPlatform, deviceModel } = useDevice();
```
The same device record as `useVariables().device`, plus `orientation` (`"portrait" | "landscape"`), measured in the page, so it updates the moment the device turns, and `hostPlatform` (`"ios" | "android" | "web" | undefined`), the SDK showing the paywall, normalized and known from the first render. Branch platform-specific UI on `hostPlatform`, never on the raw `platform` string. See [Variables & personalization](/docs/framework/variables).
## `useLocalResource(id, fallback)`
```tsx
import hero from "@/assets/hero.mp4";
const src = useLocalResource("hero-video", hero);
```
The URL to load for a resource the host app bundled: `swlocal://hero-video` when the app registered that id (`SuperwallOptions.localResources` on iOS), otherwise `fallback`. The same code works on the web, in the studio, and on SDKs without local resources. `useLocalResources()` is the underlying record: `{ ids, has(id), resolve(id, fallback) }`. See [Assets](/docs/framework/assets#local-resources).
## `useColorScheme()`
```tsx
const scheme = useColorScheme(); // "light" | "dark"
```
Rarely needed: the framework already keeps a `dark`/`light` class on `` from what the device reports, so style with plain CSS (`:root.dark { … }`). Reach for the hook only when you need the scheme in JavaScript. Never use `@media (prefers-color-scheme: dark)` as the mechanism, see [Styling](/docs/framework/styling).
## `useSuperwallEvent(name, handler)`
```tsx
useSuperwallEvent("transaction_complete", () => haptics.success());
```
Typed SDK events, subscribed for the component's lifetime; an inline arrow handler is fine. The event list and when each fires: [Lifecycle & events](/docs/framework/lifecycle). For anything a dedicated hook covers (products, trial, variables), use the hook. It cannot miss data that arrived before your component subscribed.
## `useSuperwallSnapshot()`
```tsx
const snapshot = useSuperwallSnapshot();
const opened = snapshot.paywall !== undefined;
```
The whole runtime state as one subscribed object. Its most common use is gating entry animations on presentation, paywalls are preloaded hidden, and `snapshot.paywall` flips when the paywall is actually shown ([Lifecycle & events](/docs/framework/lifecycle)). It also carries `experiment` (the A/B assignment), `locale`, and the current purchase and transaction state.
## `useSuperwallSession()`
```tsx
const session = useSuperwallSession();
session.postMessage({ event_name: "custom", data: "…" });
```
The full session for advanced work: the few methods no hook surfaces (raw protocol messaging, `accept`) and use outside React components. If you're reaching for it for products, purchases, actions, user attributes, or events, use the dedicated hook instead.
## `useRouter()`
```tsx
import { useRouter } from "superwall/navigation";
const router = useRouter();
router.push("plans");
router.replace("terms");
router.back();
router.canGoBack();
router.dismiss(2);
router.dismissAll();
router.dismissTo("goals");
router.name; // current page
router.depth; // pages underneath (index = 0)
```
The stack router for multi-page flows, expo-router's API, method for method. Page names autocomplete and reject typos via the generated `superwall.d.ts`. [Pages & navigation](/docs/framework/navigation) covers the stack model, state between pages, and shared chrome.
## `useIsFocused()`
```tsx
import { useIsFocused } from "superwall/navigation";
const focused = useIsFocused();
```
Whether this page is on top of the stack. Pages you navigate away from stay alive. A covered page can't be clicked or focused, and `useIsFocused()` tells it so, so it can pause video or timers. See [Pages & navigation](/docs/framework/navigation).
## `useQueryState(key, parser?)`
```tsx
import { parseAsStringEnum, useQueryState } from "superwall/navigation";
const [goal, setGoal] = useQueryState("goal", parseAsStringEnum(["focus", "habit"]));
const [name, setName] = useQueryState("name"); // string | null
setGoal("focus"); // ?goal=focus
setGoal(null); // key removed
```
`useState` whose value lives in the page URL, so a flow resumes from any link: after a reload, in the OS browser after an in-app one, or back from hosted checkout. On a surface with web checkout the URL is kept automatically, and **every answer in a web funnel goes through this hook** (single choice, multi choice, inputs, the selected plan), never `useState`; see [Web Funnels](/docs/framework/web-funnels). On a native host the same hook is plain state shared across pages. The API is nuqs's: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsStringEnum`, `parseAsArrayOf`, `createParser`, plus `.withDefault()` and `.withOptions({ history, clearOnDefault })`. See [Pages & navigation](/docs/framework/navigation).
## `ProductReference`
```tsx
import { type ProductReference } from "superwall/hooks";
const [selected, setSelected] = React.useState("annual");
```
The union of product references declared in your `config.ts`, the type behind `getProduct`, `purchase`, and `prefetch`. Use it for selection state so an invalid reference is a compile error.
# 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
# Host Protocol
The messages a paywall exchanges with whoever is showing it — the SDK on a device, the studio locally, the dashboard in a browser — and what each side promises.
A paywall is a web page that talks to a **host** over `postMessage`. On a device the host is the native SDK. In `superwall dev` and in the dashboard it's the studio. The paywall cannot tell them apart, and that is the point: nothing about previewing is compiled into a published paywall.
You don't write any of this by hand — `superwall/hooks` and `superwall/navigation` speak it for you. Read this page when you're debugging what a paywall asked for, or building something that hosts one.
## The envelope
Every message travels JSON-stringified in the same envelope, in both directions:
```json
{
"version": 1,
"direction": "from_paywall",
"payload": { "events": [{ "event_name": "purchase", "product": "primary" }] }
}
```
`direction` is `from_paywall` for what the paywall sends and `to_paywall` for what the host answers. Batches are normal — one envelope carries several events.
> **Note:** The wire is **trusted for forward compatibility**: a message a paywall has never heard of reaches it anyway rather than taking the whole batch down. Old app binaries with old SDKs load new paywall bundles indefinitely, so the contract is additive — messages are added, never repurposed.
## The handshake
The paywall sends `ping` **once**, as soon as its runtime boots. The host replies with everything a first paint needs, in one batch:
| Reply | Carries |
| ------------------------------- | ------------------------------------------------------------------------------------- |
| `template_variables` | device, user and placement params, plus every product's variables |
| `products` | the product list, keyed by reference |
| `experiment` | experiment, variant and campaign ids |
| `template_substitutions_prefix` | `freeTrial` when the shopper is eligible for an introductory offer, otherwise nothing |
| `paywall_open` | the paywall is now visible — this is the presentation signal |
Because that ping is sent once and never repeated, **the host has to be listening before the paywall's document loads** — the studio attaches to an iframe on mount, before its content window has booted, for exactly this reason. A host that attaches after the ping never hears it, and the paywall waits forever with no variables.
See [Lifecycle & Events](/docs/framework/lifecycle) for why `paywall_open`, not mount, is what entry animations gate on.
## What the paywall asks for, and what comes back
| The paywall sends | The host does | The paywall hears |
| ------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `purchase` | buys through the store, or asks you in the studio | `transaction_start`, then `transaction_complete` (+ `freeTrial_start`), `transaction_abandon` or `transaction_fail` |
| `restore` | restores purchases | `restore_start`, then `transaction_restore` + `restore_complete`, or `restore_fail` |
| `close` | dismisses the paywall | `paywall_close` |
| `open_url`, `open_url_external`, `open_deep_link` | opens it in an in-app browser, the OS browser, the payment sheet, or as a deep link | — |
| `request_permission` | prompts the OS | `permission_result` |
| `request_callback` | runs your app's registered handler | `callback_result` |
| `custom`, `custom_placement` | hands the payload to your app | — |
| `user_attribute_updated` | merges the attributes into the user variables it serves | — |
| `schedule_notification` | schedules a local notification | — |
| `initiate_web_checkout`, `stripe_checkout_*` | drives web checkout | see [Web Checkout](/docs/framework/web-checkout) |
A host answers what it can and ignores the rest — an unrecognised request is dropped, never fatal.
> **Note:** Several replies are deliberately payload-free. `restore_fail` carries no reason, so "nothing to restore" and a store error are one outcome to a paywall — the SDK composes an explanation for the first case but sends it to its own logs, not over the wire. `transaction_restore` likewise names no product. Anything a paywall renders has to come from a message that actually carries it.
## Variables arrive as patches, not snapshots
After the handshake the host sends only what changed. Switching the studio's theme sends one `template_variables` carrying the new `interfaceStyle`, not the whole bag again. That is why a paywall must **guard every read** rather than assume a variable is present: on a device the SDK can still deliver a product's price after first paint.
```tsx
const { annual } = useProducts();
const price = annual?.variables.price ?? "";
```
## Hosting a paywall yourself
`@superwall/host` is the SDK's half, packaged:
```ts
import {
createHostState,
createWindowHost,
diffHostState,
silentDelegate,
} from "@superwall/host";
const { host, detach } = createWindowHost({
target: iframe.contentWindow,
state: createHostState({ paywallId: "208540", paywallIdentifier: "pro", products }),
delegate: silentDelegate(),
});
host.update(diffHostState(previous, next));
```
The `delegate` decides every outcome a device would decide — purchase, restore, permission, callback — which is exactly what the studio turns into the prompts you answer. `diffHostState` produces the patch; the host applies it with `mergeHostState`, and a round-trip test in that package keeps the two honest.
In React, `@superwall/studio` ships that wiring as one component:
```tsx
import { HostedIframe, silentDelegate } from "@superwall/studio";
;
```
It attaches on mount — before the iframe's document has booted, which is what makes the single `ping` land — and sends every later `hostState` change as a patch. The dashboard's paywall previews are built on it, so a preview and a device now answer the runtime identically.
> **Note:** A host that isn't a device shouldn't answer like one. The dashboard's preview delegate returns `abandoned` for a purchase and ignores `openUrl`: it has to answer, or the paywall's button spins forever, but it must not report a transaction that never happened or navigate the page hosting it.
# 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
# Superwall Framework
Build paywalls, onboarding funnels, and web checkout flows as React mini-apps, in your repo, with your tools, shipped without an app update.
> **Warning:** The framework ships on the CLI's `next` channel (`SUPERWALL_CHANNEL=next`) and pushing needs Superwall for Agents enabled on your app. Nothing here is final: commands, config keys, and hooks can still change between releases. To get access, contact [support@superwall.com](mailto\:support@superwall.com).
Superwall paywalls are normally built in the visual editor. The framework is the other way to build them: as React code, in your own repo, reviewed and versioned like the rest of your app.
Everything after the build is unchanged. Paywalls are still delivered by the native SDKs, still shown through placements and campaigns, still experimented on from the dashboard, and still updated without an app release. The framework changes how a paywall is **authored**, not how it reaches your users.
"Paywall" is the dashboard's word for it, but the same project holds any screen you would rather change after shipping than release for: onboarding and quizzes, update-required and force-upgrade screens, rate-us and notification prompts, announcements, offers and win-backs. Each is a surface behind a placement, edited in code, previewed in the studio, and live on the next app open.
## What you write
A paywall is a small React app in a `superwall/` directory:
```ts
superwall/paywalls/pro/
├── config.ts definePaywall({ name, products: { annual: "pro_5999_year" } })
├── app/ pages: index.tsx, plans.tsx, layout.tsx
├── components/ everything that is not a page
└── messages/en.ts localized strings, discovered by filename
```
`config.ts` declares the paywall's name and which products it sells. `app/` holds the pages. Everything else is ordinary React: your components, your CSS, your dependencies. Four hooks connect it to Superwall, with [`useProducts()`](/docs/framework/hooks#useproducts) for live store prices, [`usePurchase()`](/docs/framework/hooks#usepurchase) to make the sale, [`useActions()`](/docs/framework/hooks#useactions) to close or restore, and [`useHaptics()`](/docs/framework/hooks#usehaptics) for the tap.
You preview locally with `superwall dev`, which opens a studio with device frames, light and dark toggles, locale switching, and outcomes you pick for every purchase, restore and permission. `superwall push` then seals an immutable version and `superwall promote` points production at it. Users get the new paywall on next open, with no app review.
## Why write a paywall in code
If you are comfortable in React you could build the UI yourself in an afternoon. What takes longer is everything around it, and that is the part the framework gives you:
* **Prices you cannot get wrong.** Products are declared as references like `annual: "pro_5999_year"`. Price, period, and trial terms come from the App Store, Google Play, or Stripe at runtime, already localized and formatted. A reference your dashboard has no product for blocks the push outright, so a paywall cannot ship with a stale or hardcoded price.
* **Purchases, restores, and trial eligibility, handled.** The parts that are boring to write, easy to get subtly wrong, and expensive when you do.
* **Shipping without an app release.** A hand-rolled paywall is stuck behind your release train. A pushed one is live on next open, and rolling back is one `promote`.
* **Campaigns and experiments still apply.** A code paywall is A/B tested from the dashboard like any other, so you keep the measurement.
* **Multi-step flows are natural.** An onboarding quiz or web funnel is one paywall whose steps are pages on a stack router. No network between steps and no loading spinners.
* **It lives in your repo.** PRs, code review, CI, your component library, your design tokens. Use Tailwind, Motion, Rive, or plain CSS; the framework has no opinion.
## Which should I use
| Build in the framework when | Build in the visual editor when |
| ------------------------------------------------------ | ------------------------------------------ |
| Engineers own the paywall | Design or marketing iterate on it directly |
| You want it in git, in PRs, in CI | You want changes without a developer |
| The flow is multi-step, or branches on user answers | The paywall is a single screen |
| You are reusing your app's components or design system | You are starting from a template |
| You need custom animation or interaction | The built-in components cover it |
Both ship the same way, and an app can use both. The framework trades the no-code loop for code-level control.
Not an engineer? An AI agent can write the code for you while you describe the paywall in plain English. See [Build paywalls with an AI agent](/docs/framework/build-with-an-agent).
> **Note:** Pushing needs Superwall for Agents enabled on each Superwall app you push to. A push tells you if it isn't; contact [support@superwall.com](mailto\:support@superwall.com) for access.
## How it fits together
| Piece | What it does |
| ----------------- | ---------------------------------------------------------------------------------- |
| `superwall` (npm) | The framework: `definePaywall`, hooks, navigation, the build |
| `superwall` (CLI) | `create`, `dev`, `push`, `promote`, `publish` |
| The studio | Local preview at `localhost:6100`, devices, themes, locales, outcomes you pick |
| The dashboard | Where pushed paywalls, versions, and products live; campaigns decide who sees what |
| The native SDKs | Present your paywall in-app, deliver product data, run purchases |
Your app keeps presenting paywalls exactly as it does today, through placements and campaigns. The framework changes how paywalls are *built*, not how they're *shown*.
## Start here
## Quickstart
/docs/framework/quickstart
Scaffold a project, preview your first paywall in the studio, and ship it.
## Project structure
/docs/framework/project-structure
How a `superwall/` directory is laid out and which files to commit.
## Pages & navigation
/docs/framework/navigation
Build multi-page flows with file-based routes and a stack router.
## Purchases
/docs/framework/purchases
Declare products, read live prices, and handle every purchase outcome.
## Go deeper
* **[Configuration](/docs/framework/config)**, everything `definePaywall` accepts, from presentation style to trial reminders.
* **[Web checkout](/docs/framework/web-checkout)**, sell the same paywall on the web with one config key.
* **[Variables & personalization](/docs/framework/variables)**, react to user attributes, device state, and placement parameters.
* **[Localization](/docs/framework/localization)**, one file per locale, picked automatically from the device.
* **[Examples](/docs/framework/examples)**, complete standalone projects, each teaching one idea.
# 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
# Lifecycle & Events
What a paywall knows and when, the preload rule that shapes every entry animation, and the SDK events you can react to.
The SDK **preloads paywalls hidden** before showing them. Your components mount long before anyone is looking, so a mount-timed animation (a `useEffect` on mount, Motion's `initial`/`animate` firing on mount, a CSS animation on load) has already finished by the time the paywall appears. This one fact shapes every entry animation you'll write.
## Gate entry animations on presentation, never mount
The presentation signal is `useSuperwallSnapshot().paywall`. It flips from `undefined` when the paywall is actually shown:
```tsx
import { useSuperwallSnapshot } from "superwall/hooks";
const opened = useSuperwallSnapshot().paywall !== undefined;
```
Unlike an event listener added in an effect, the snapshot cannot miss the moment. It reads current state rather than waiting to be told.
Value-driven animations gate on **both** conditions. A price count-up starts when `opened && raw !== undefined`, never when the store delivers the price (it would play while hidden), and never on a missing value (it would land on a made-up figure):
```tsx
const rawPrice = Number(annual?.variables.rawPrice);
const raw = Number.isFinite(rawPrice) ? rawPrice : undefined;
React.useEffect(() => {
if (opened && raw !== undefined) {
const controls = animate(price, raw, { duration: 0.9, ease: "circOut" });
return () => controls.stop();
}
}, [opened, raw]);
```
The with-motion [example](/docs/framework/examples) is the reference for both patterns. The ownership rule that goes with them: animation libraries animate *inside* a page, moving *between* pages is the router's job, so spamming navigation can never fight your component animations. See [Transitions](/docs/framework/transitions).
## Events you can react to
```tsx
import { useSuperwallEvent } from "superwall/hooks";
useSuperwallEvent("transaction_complete", () => haptics.success());
useSuperwallEvent("freeTrial_start", () => { /* trial began */ });
```
| Event | Fires when |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `paywall_open` | The paywall is presented (or re-presented). Prefer `snapshot.paywall` for anything render-driving. |
| `transaction_complete` | A purchase succeeded, whoever started it. A restore does **not** fire this event; see [Purchases](/docs/framework/purchases#restore). |
| `transaction_abandon` | The store sheet was closed. |
| `transaction_fail`, `transaction_timeout` | The store reported a failed or timed-out purchase. `purchase()` resolves `failed` with `reason: "sdk"`. **iOS only**: the Android SDK does not send these. |
| `transaction_start` | The store sheet is about to open. **iOS and Android.** |
| `freeTrial_start` | A trial actually began. Also triggers the configured [trial reminder](/docs/framework/trials). |
| `restore_start`, `restore_complete`, `restore_fail` | The restore lifecycle, also on `snapshot.restore`. **iOS** sends all three; **Android** sends only `restore_fail`. |
| `paywall_close` | The host dismissed the paywall. |
| `experiment` | The experiment assignment arrived (`experimentId`, `variantId`, `campaignId`). |
| `back_button_input` | Android hardware back. Only sent when the dashboard reroutes it to the paywall, which a framework paywall cannot enable, so in practice it never arrives and the SDK dismisses on its own. |
| `game_controller_input` | Controller input, needs `gameControllerEnabled: true` in [config](/docs/framework/config). |
| `event_tracking_behavior` | The app's analytics opt-out level (`all`, `superwallOnly`, `none`), also on `snapshot.eventTrackingBehavior`. |
| `message` | Every incoming SDK message, the debugging firehose. |
Subscriptions last the component's lifetime; an inline arrow handler is fine.
> **Note:** For products, variables, and introductory-offer eligibility, use the dedicated hooks instead of events. They read current state and cannot miss data that arrived before your component subscribed. Data arrives progressively after open (paywall id → products → variables → eligibility → experiment), which is one more reason every read is guarded.
## Dark mode
The device decides; the framework maintains a `dark`/`light` class on ``. Style with plain CSS and write no wiring:
```css
:root { --bg: #fdfef6; --fg: #0c0b0a; }
:root.dark { --bg: #1c1b19; --fg: #fdfef6; }
```
Don't use `@media (prefers-color-scheme: dark)` as the mechanism, it cannot see what the device reports and ignores the studio's theme toggle. The class is the mechanism. [Styling](/docs/framework/styling) has the full treatment, including Tailwind.
## Dev vs device
The same paywall is hosted by the studio in `superwall dev` and by the SDK on device, over the same protocol. You pick purchase outcomes in one and the store does in the other, product variables come from your dashboard in one and from the store on the other, and numeric **product** variables arrive as **strings** on device (device numerics like `daysSinceInstall` stay numbers). The full comparison table is in [The studio](/docs/framework/studio).
## The platform stylesheet
Published paywalls receive a small Superwall-owned stylesheet at serve time, platform-wide behavior like scroll control. Previews apply the same one, so local and published render identically. Set `SUPERWALL_RUNTIME_URL` in the project `.env` only if you need previews to use a local build of that platform layer.
# 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
# Localization
Ship a paywall in multiple languages by adding one file per locale, no registration, no wiring.
Ship a paywall in multiple languages by adding one file per locale. The filename is the locale, and the device picks which one renders, no registration, no wiring.
## Add locales
Message catalogs live in `messages/` directories, at the same two levels as components and assets:
```ts
superwall/
├── messages/ shared by every paywall
│ ├── en.ts
│ └── de.ts
└── paywalls/pro/
└── messages/ this paywall's own
├── en.ts
└── fr.ts
```
Each file default-exports a nested object:
```ts
// paywalls/pro/messages/fr.ts
export default {
paywall: {
title: "Passez à Pro",
cta: "S'abonner · {price}",
perMonth: "{price} par mois, facturé annuellement",
},
} as const;
```
A paywall's own catalog layers over the shared one, it overrides the keys it names and inherits the rest. A locale can exist in either layer or both.
If your fallback language isn't English, set it in `config.ts`:
```ts
localization: { defaultLocale: "en" },
```
## Use the strings: `useTranslation()`
```tsx
const { t, locale, setLocale, locales } = useTranslation();
{t("paywall.title")}
```
* **`t(key, values?)`**, the translated string for the active locale. Interpolation is `{name}` in the catalog with `t(key, { name: value })` at the call site.
* **`locale`**, the active locale, resolved from the device. Resolution is specific-to-general: `pt-BR` matches a `pt-BR` catalog first, then `pt`, then the default locale.
* **`setLocale(locale)`**, override the device; `setLocale(undefined)` returns to auto-detection. This is for previews and tests. On device, the system setting is the truth.
* **`locales`**: every locale that has a catalog.
## How fallbacks behave
* A key missing from the active locale falls back to the default locale **per key**. A partial translation stays usable while it's being finished.
* An unknown key renders as itself, so `t()` never breaks. The flip side: **key typos are invisible at runtime**. Nothing throws, and the key just shows up on screen. Check your copy in [the studio](/docs/framework/studio) with its locale switcher.
* Guard interpolations on the value existing, with a bare-key fallback, as in the CTA above. Never render "Subscribe · undefined".
## The rules
* **Never put a price in a catalog.** Prices are localized by the store, the SDK delivers the right currency and format for the user's region. Interpolate them: `"Subscribe · {price}"`. See [Products](/docs/framework/products).
* **No language picker on device.** The locale is the person's system setting; preview other locales with the studio's locale switcher.
* **Copy expands.** German runs long, size nothing to fit English.
* Product `period` and `periodly` variables ("yearly" → "jährlich") localize automatically in 44 locales, independent of your catalogs.
* **Start with a catalog even in one language.** Every user-facing string lives in `messages/en.ts` and reaches the page through `t()` from the first commit, `aria-label`s and button copy included. A second locale is then one new file and no code change, copy is reviewable in one place, and nothing has to be hunted out of JSX later. The `superwall create` scaffold is set up this way.
> **Note:** There is no plural engine, no ICU, no `_one`/`_other` suffixes. Write around plurals, or fork on the count yourself.
The localization [example](/docs/framework/examples) shows four locales, both catalog layers, and guarded interpolation.
# 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
# Migrating from the editor
Rebuild a paywall made in Superwall's visual editor as a framework paywall: what the CLI scaffolds, what an agent rebuilds, how you review it against the original, and how the switch happens.
A paywall built in the visual editor becomes a framework paywall by being rebuilt as code: the same design, products, pages and actions, written as routes, hooks and your own CSS. The CLI does the part that is deterministic, a coding agent rebuilds the pages, you review the result next to the original in the studio, and the original keeps serving until a campaign points at the new one.
## Start from the dashboard paywall
In your app's root (or an empty directory), with the paywall's id from the dashboard URL:
```bash
superwall create --from 65920
```
This reads the paywall and its served document, then scaffolds a project with:
* `paywalls//config.ts` carrying the name, platform, product slots, presentation style and geometry, feature gating, scroll, game controller and cache settings, and the background color, all as the paywall has them today. Nothing under `app/` is migrated; it is the starter, to be replaced.
* `paywalls//MIGRATION.md`, the brief: the source URL, the dashboard screenshot, a product table, and an inventory of the pages, drawers, elements, actions, product variables and locales the source uses.
* An `origins` entry in `superwall.lock`, which is what puts **Original** in the studio's Compare menu.
* The project connected to the same app, so the new paywall lands beside the old one.
`--from` needs a login and cannot be combined with `--example` or `--no-connect`. A paywall that is already built with the framework is refused; pull its source from the dashboard instead.
> **Note:** Template paywalls have no real products: their slots show `"missing"` in `config.ts`, and a push refuses them. Point each slot at a real product (or create one with `superwall products create`) before pushing.
## Rebuild the pages
The rebuild is agent work with you in the loop, and the CLI offers it as soon as the scaffold is done:
```
◇ Rebuild the pages now?
› With Claude Code runs here, headless; you review in the studio after
Copy the prompt paste it into Claude Code, Codex, Cursor, or another agent
Not now superwall migrate 65920 picks this up later
```
The same choice is one command away at any time, and it scaffolds first if the paywall isn't in the project yet:
```bash
superwall migrate 65920 # in the app root, or inside superwall/
superwall migrate 65920 --skill # print the prompt instead, once it is scaffolded
```
Running it with your agent hands it the brief and the rebuild playbook from the `superwall-framework` skill (`references/migrate-from-editor.md`), headless, in the `superwall/` directory, with a fixed order of work: confirm the inventory against the editor's document store embedded in the served page, lift the design tokens, one route per page with shared chrome in `layout.tsx`, every action as a hook call, every state and locale rebuilt, `typecheck` green. It cannot push, promote or touch campaigns. The mapping it follows (a navigation element's children become routes, a drawer becomes a sheet, a tap action becomes a hook call, Liquid product templates become `useProducts()` reads) is the `superwall-framework` skill's `references/migrate-from-editor.md`, which the agent reads as well.
Copying the prompt gives you the same instructions without the headless protocol, for an agent you drive yourself, which is the way to keep the inventory step conversational: ask for the inventory first and approve it before any code is written.
## Review against the original
When the agent finishes, the CLI offers to open the studio, and opens it straight onto the rebuilt paywall with **Original** in the second pane: the dashboard paywall served exactly as it is today, beside your local build. The same view is in the toolbar's Compare menu at any time, and a link with `?compare=origin` lands on it. Walk every page on a phone with a Dynamic Island, an iPhone SE, a Pixel and an iPad, in both color schemes, and compare.
It is your code now. Anything that doesn't match is a tweak to ask your agent for, or to make yourself, under `paywalls//`; the studio reloads as you save.
The Original pane is view-only. It does not receive the studio's theme, locale, device or product variables, so it renders in light mode with the editor's example prices. Your side does receive them, which is how you check the dark scheme and the real prices the original never showed.
## Switch when you are ready
`superwall push` creates a **new** dashboard paywall and binds it in `superwall.lock`; the editor paywall is untouched and keeps serving. To go live, point the campaign's variant at the new paywall in the dashboard. Rolling back is pointing it at the old one again. Nothing about a migration promotes on its own, and an agent following the playbook pushes only when asked and never touches campaigns.
Delete `MIGRATION.md` once you are satisfied. The `origins` entry in the lock stays, so Compare › Original keeps working for later edits.
## What does not carry over
* The original cannot be converted in place; a framework paywall is always a new dashboard paywall.
* Editor-only behavior with no framework equivalent, such as surveys attached to the paywall, is not carried over. The playbook names each such item in its hand-off rather than approximating it.
* The editor's example prices. The framework never invents a price; every product variable is guarded and the unpriced state is designed, as everywhere else in the framework.
# 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
# Migrating native screens
Turn a SwiftUI, UIKit, Jetpack Compose, Android Views, React Native or Flutter screen into a Superwall surface the app can change without a release: what the CLI reads from the source, what an agent rebuilds, how you review it against a simulator screenshot, and how the app is wired afterwards.
Some screens are worth changing without an app release: the update-required screen you need on the day you cannot ship, an onboarding step, a rate-us or notifications prompt, an announcement, an offer. Each of those can be a framework surface shown through a placement, like a paywall. `superwall migrate --screen` takes the native screen you have today and does the deterministic half of that move; a coding agent rebuilds the pages; you review the result in the studio next to a screenshot of the original; then you wire the app.
## Start from the screen
In your app's root, with the file that draws the screen:
```bash
superwall migrate --screen App/UpdateRequiredView.swift
```
Leave the path off and the CLI scans the app for likely candidates (files named or shaped like update, onboarding, paywall, rate-us, permission or announcement screens) and asks which one. SwiftUI and UIKit (`.swift`), Jetpack Compose and Android Views (`.kt`), React Native (`.tsx`) and Flutter (`.dart`) files are read.
The scan reads one file and writes, into the project it finds (or a new `superwall/` directory):
* `paywalls//config.ts` with the screen's name and, if the source referenced store product identifiers, one product slot per identifier. Confirm those exist on the dashboard; a push refuses unknown products.
* `paywalls//MIGRATION.md`, the brief: the source path and framework, every user-visible string with its dynamic values, the image and font assets and system symbols it uses, its links, and each action it performs mapped to the framework hook that replaces it (an App Store link becomes `useActions().openUrl`, a review request `requestStoreReview`, a notification prompt `requestPermission("notification")`, a dismiss `close`).
* An `origins` entry in `superwall.lock` naming the source file, so `superwall migrate --screen` can pick the rebuild up later.
The scan is a starting inventory, not a parser of your app. For a UIKit controller it also reads the view, view model and storyboard scene it references; for an Android Activity or Fragment it reads the layout XML and resolves `R.string` and `@string/` references from `strings.xml`. Anything else that lives in a view model or a theme file is left to the agent, which follows the source's imports.
## Capture the original
The studio cannot render a native screen, so the comparison uses a screenshot. With the screen open in a booted iOS simulator, the CLI offers to capture it; pass one yourself with `--screenshot path.png` (an Android emulator capture works the same way). It is saved as `paywalls//original.png`, and the studio's Compare menu offers it as **Original**, opening on it directly when the CLI starts the studio for you. Without a screenshot there is no Original entry; keep the app running in the simulator beside the studio instead.
## Rebuild the pages
After the scaffold the CLI asks whether to run the rebuild now with the coding agent it finds (Claude Code or Codex), copy the prompt for an agent you drive yourself, or stop.
Running it with your agent hands it the brief and the rebuild playbook from the `superwall-framework` skill (`references/migrate-from-native.md`) plus its `native/` file for the source framework, headless, in the `superwall/` directory. The order of work is fixed: read the source and everything it imports, lift the design tokens, rebuild each state as a page with the framework's hooks, every string through `messages/en.ts` and `t()`, assets copied into `assets/`, `typecheck` green. It writes only inside `paywalls//`: it never edits the app, never adds `register()` calls, and cannot push, promote or touch campaigns.
`superwall migrate --screen --skill` prints that prompt to stdout for a screen already in the project.
## Review
```bash
superwall dev
```
Open the screen, pick **Compare › Original**, and check every state in both schemes and on the devices the app supports. Anything that does not match is a normal edit in `paywalls//`; the studio reloads live. `MIGRATION.md` is yours to delete once you are done with it.
## Wire the app
Push when it is right, then make the app present the surface where it used to present the native screen. The brief names the placement, derived from the screen's name:
```swift
// before
if needsUpdate { present(UpdateRequiredViewController(), animated: true) }
// after
if needsUpdate { Superwall.shared.register(placement: "update_required") }
```
Registering a placement is the same in every SDK; the feature-gating pages for [iOS](/docs/ios/quickstart/feature-gating), [Android](/docs/android/quickstart/feature-gating), [Expo and React Native](/docs/expo/quickstart/feature-gating) and [Flutter](/docs/flutter/quickstart/feature-gating) are the reference, and the `superwall` skill's `workflows/placements/` playbook (`superwall integrate --skill` prints it) is what your agent follows. Create a campaign for that placement on the dashboard and point it at the pushed surface; `superwall integrate` and the `superwall` skill's `workflows/dashboard/` playbook do this. A screen the user must not dismiss is a gated surface: the campaign's paywall is set to gated, and the `feature` closure only runs when the user has been let through, so keep the app's own hard block until the surface is live. Delete the native screen once the campaign is verified on a device.
The framework file the agent read (`references/native/ios.md`, `android.md`, `react-native.md` or `flutter.md` in the `superwall-framework` skill) has the same table for every source construct and every wiring pattern, so ask it to do the wiring with you.
## What does not migrate
* Anything the screen did after itself (routing, refreshing data): the surface closes and the app's placement handler continues.
* Native-only pieces: a share sheet, a camera view, a settings deep link the SDK cannot open. The agent lists them as not carried over.
* Data the screen fetched itself. Pass it as placement params, or set it as user attributes before `register()`, and read it in the surface with `useVariables()`.
See also [Migrating from the editor](/docs/framework/migrate-from-editor) for the same workflow starting from a dashboard paywall.
# 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
# Pages & Navigation
Build multi-page paywalls, onboardings, and funnels with file-based pages and a stack router, no network between steps, no loading spinners.
Multi-page paywalls, onboarding quizzes, and funnels are built from file-based pages and a stack router. Moving between pages never touches the network. The whole flow ships together, so there's no page load, no spinner, and no screen that never arrives.
## Add pages
Every `.tsx` file in `app/` is a page; directories nest the name:
```ts
app/
├── index.tsx "index" — every flow starts here
├── plans.tsx "plans"
├── layout.tsx wraps every page (the one reserved name)
└── goals/
├── index.tsx "goals"
└── setup.tsx "goals/setup"
```
File names are lowercase-kebab, and each page default-exports a component. Components that aren't pages go in `components/`, not `app/`. A stray file there is a warning in dev and blocks a push.
> **Note:** Only the top-level `layout.tsx` is special. A nested `goals/layout.tsx` would become a page named `goals/layout`. There are no nested layouts.
## Navigate
```tsx
import { useRouter } from "superwall/navigation";
const router = useRouter();
router.push("goals/setup"); // forward
router.push("plans", { transition: "fade" }); // with a transition
router.replace("terms"); // swap the current page
router.back(); // one step back
router.canGoBack(); // anything to go back to?
router.dismiss(2); // back two steps
router.dismissAll(); // back to the first page
router.dismissTo("goals"); // unwind to it (replaces current if not in the stack)
router.name; // current page
router.depth; // pages underneath (index = 0)
```
If you've used expo-router, this is the same shape, minus `navigate`/`setParams`, plus `name` and `depth`. Page names autocomplete and reject typos, thanks to the generated `superwall.d.ts`, one more reason to [commit it](/docs/framework/project-structure).
A few rules make navigation feel right:
* **Closing the paywall is `useActions().close()`**, not navigation. The stack is for moving within the flow; closing hands control back to your app. See [Actions](/docs/framework/actions).
* **Pages you navigate away from stay alive.** Going back restores a page exactly as it was left, scroll position and state included. A covered page can't be clicked or focused; `useIsFocused()` tells a page it's covered so it can pause video or timers.
* **There is no declared page order.** Any page can push any page, which is exactly what makes branching flows possible.
* **Page views are tracked for you.** Every navigation reports analytics automatically; there's nothing to instrument.
## Pass state between pages
Navigation carries no params, on purpose. Cross-page state has two homes:
**`layout.tsx`** stays mounted for the whole flow, React state or context there is visible to every page:
```tsx
export default function Layout({ children }: PropsWithChildren) {
return {children}
;
}
```
**A plain module** works even after the collecting page is gone, the quiz pattern, from the onboarding quiz example (see [Examples](/docs/framework/examples)):
```ts
// components/answers.ts
export const answers: { goal?: Goal; level?: Level } = {};
```
```tsx
const choose = (value: Goal) => {
haptics.selection();
answers.goal = value;
router.push("level");
};
```
Guard every read on the destination, `answers.goal ? PLAN[answers.goal] : undefined`, so a revisited page never crashes on a missing answer.
**The URL**, for flows on the web, and on a web funnel this is not one option among three. It is the rule. Neither home above survives a reload, and an in-app browser (Instagram, TikTok) hands only the link to Safari when someone taps "open in browser". Its storage stays behind. So a surface with [web checkout](/docs/framework/web-checkout) keeps its state in the page URL: the route stack goes in automatically, and **every answer, selection and input** is kept with `useQueryState`, never `useState`:
```tsx
import { parseAsStringEnum, useQueryState } from "superwall/navigation";
const [goal, setGoal] = useQueryState("goal", parseAsStringEnum(["focus", "habit"]));
setGoal("focus"); // ?goal=focus — and the plan page reads the same hook
router.push("plan");
```
The API is [nuqs](https://nuqs.dev)'s, so the parsers read the same: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsStringEnum`, `parseAsArrayOf`, `createParser`, each with `.withDefault()` and `.withOptions({ history, clearOnDefault })`. Any link then resumes the flow on the same step with the same answers, after a reload, in the OS browser, or back from hosted checkout, and the browser's back button is `router.back()`.
[Web Funnels](/docs/framework/web-funnels) has the full treatment, multi choice, inputs, branching, the URL budget. Three rules keep it honest:
* **Only what a page asks for is persisted.** The framework never decides what an answer is. Keys starting with `sw_`, plus `platform` and `transport`, are reserved, the hook throws on them. The route stack is one of those reserved keys, `sw_nav` — a comma-joined list of route names (`?sw_nav=offer`), validated against the surface's routes. **It decides where a surface opens even when `queryState` is off**, so a link, a QR code or a screenshot job can enter a flow at any page; with `queryState` off it is read once and never written back, so navigation from there on is plain state.
* **Mind the URL budget.** About 2 kB is safe across every app and share sheet, keep keys short and values enumerable, and keep anything personal out of a URL.
* **The same code runs natively.** In an SDK webview there is no URL bar, so `useQueryState` is plain state shared across pages. The flow reads identically everywhere. `definePaywall({ queryState: true | false })` overrides the checkout default on web builds; a native host forces it off regardless.
## Shared chrome
Put back buttons, step counters, and the close button in `layout.tsx`, and drive them from router state so they can never drift from the stack:
```tsx
const router = useRouter();
{router.canGoBack()
?
: } /* placeholder keeps the layout stable */
{router.depth + 1} of 3
```
> **Note:** `depth + 1` works as a step counter only in linear flows. In a branching flow, a page's depth isn't its step number. Label steps per page instead.
The framework wraps the layout in a flex column that already fills the viewport inside the paywall's [insets](/docs/framework/styling#insets), so a layout's shell is a flex child, not a full-height box:
```css
.shell { display: flex; flex: 1 1 auto; flex-direction: column; }
```
The pages fill whatever the shell leaves them. A shell that sets `min-height: 100dvh` overflows the wrapper by the insets and scrolls; a layout that isn't a flex column can size the pages explicitly with `--sw-routes-height` on `:root` (default `auto`). `--sw-background` is needed on every paywall, layout or not; see [Styling](/docs/framework/styling#background-color).
Position overlay chrome absolutely *over* the pages rather than as a bar above them. Each page paints its own background, so a bar of its own shows as a seam during transitions. The layout is already inside the paywall's [insets](/docs/framework/styling#insets) and positions against the framework's content box, so `position: absolute; top: 4px` is below the status bar with no positioned ancestor of your own; give every page enough top padding to start below the chrome.
## A funnel is one paywall, not several
Multi-step flows (onboarding quizzes, web funnels) are **one paywall whose steps are pages**, not a chain of separate paywalls. Every step is a `router.push` in the same flow, so there's no load between steps and nothing to re-fetch. The structure is identical (`config.ts` plus `app/` pages plus `layout.tsx`), and funnels live in `superwall/funnels//` with exactly the same shape.
The web funnel example is the reference: question steps kept in the URL, a typed plan selector, then `purchase(reference)` at the end, with [web checkout](/docs/framework/web-checkout) taking payment in the same flow. Funnels usually want `transition: "shift"` in `config.ts`, see [Transitions](/docs/framework/transitions).
## Where transitions and animation fit
How pages move (the built-in transitions, custom ones, and bottom sheets) is covered in [Transitions](/docs/framework/transitions). Animation *inside* a page (Motion, CSS) is yours; moving *between* pages stays the router's job. Keeping that line means spamming navigation can never fight your component animations. And entry animations gate on presentation, never mount. See [Lifecycle & events](/docs/framework/lifecycle).
Assets for upcoming pages preload automatically while the user is on the current page, see [Assets](/docs/framework/assets).
# 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
# Products
Declare product slots in config.ts, read live store data through useProducts, and follow the three rules that keep prices honest.
Products connect your paywall to the things it sells. You declare them once in `config.ts`, and everything about them (price, period, trial) arrives from the store at runtime, localized and formatted for each user. You never hardcode a price.
## Declare products
Products are **slots**. The key is the reference your code uses; the value is the store identifier:
```ts
import { definePaywall } from "superwall/config";
export default definePaywall({
name: "Pro",
products: {
monthly: "pro_999_month",
annual: "pro_5999_year",
},
});
```
The shorthand string and the object form mean the same thing:
```ts
products: {
annual: "pro_5999_year", // shorthand
monthly: { productId: "pro_999_month" }, // same thing
},
```
Your code only ever speaks in references (`getProduct("annual")`, `purchase("annual")`), so swapping the underlying store product is a one-line config change.
### One reference, a product per platform
A paywall shipped to several platforms usually sells a different store product on each: the App Store and Google Play never share an id. Keep the reference and give the slot one identifier per platform:
```ts
export default definePaywall({
platforms: ["ios", "android", "web"],
products: {
monthly: "pro_999_month", // the same id on every platform
annual: {
ios: "pro_5999_year",
android: "pro_5999_year_play",
web: "live:price_1ABC…:7days-free",
},
},
});
```
`purchase("annual")` is the same call everywhere. Each platform's build resolves the slot to that platform's identifier (the per-platform map is rewritten out of the bundle, so an Android snapshot doesn't even contain the App Store id), and its version is pushed with the matching store (Play for Android). An Android paywall never names an App Store product, not even in the moment before the SDK delivers product data. A per-platform slot has to name every platform the paywall ships to, a push refuses `annual has no product id for web` rather than publish a slot that could never resolve. See [Configuration](/docs/framework/config#platforms).
### Web and Stripe products
Web paywalls sell through Stripe, and the Stripe price lives inside the identifier, no separate mapping. The format is `{environment}:{priceId}:{offer}`, where `{environment}` is exactly `test` or `live`:
```ts
products: {
monthly: "live:price_1ABC…:7days-free",
},
```
A paywall can declare both kinds side by side, store products for native, Stripe products for the web. See [Web checkout](/docs/framework/web-checkout) for how the same `purchase()` call sells on both.
### Product data never appears in the file
Price, period, and trial are store-owned and arrive at runtime, the same way they do for every Superwall paywall: the host loads the products, sends them with their data, and only then shows the paywall. On a device the host is the SDK; in a preview it is the studio, which reads your dashboard's products and sends nothing for a slot the dashboard cannot resolve. Until a host delivers them, `useProducts()` returns no products at all — the framework never invents one, in a preview or on a device. `superwall push` refuses to publish a reference the dashboard has no product for. Example identifiers in scaffolds and examples are placeholders to repoint at your own products.
## Read product data
```tsx
import { useProducts } from "superwall/hooks";
const { getProduct } = useProducts();
const annual = getProduct("annual");
annual?.variables.price // "$59.99" — formatted for the user's region
annual?.variables.monthlyPrice // "$5.00" — the store's own math
annual?.variables.trialPeriodDays
```
References are typed against your config, so a typo in `getProduct("anual")` is a compile error, not a runtime surprise.
The variables you'll reach for, all optional:
| Group | Variables |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Price | `price`, `rawPrice`, `currencyCode`, `currencySymbol` |
| Period | `period` ("year"), `periodAlt`, `localizedPeriod`, `periodly` ("yearly"), `periodDays`, `periodWeeks`, `periodMonths`, `periodYears` |
| Per-interval price | `dailyPrice`, `weeklyPrice`, `monthlyPrice`, `yearlyPrice` |
| Trial | `trialPeriodDays`, `trialPeriodWeeks`, `trialPeriodMonths`, `trialPeriodYears`, `trialPeriodPrice`, `rawTrialPeriodPrice`, `trialPeriodText` ("7-day"), `trialPeriodEndDate` ("Jul 23, 2026"), per-interval trial prices |
| Locale | `locale`, `languageCode` |
| State | `identifier`, `isSubscribed` |
`period` and `periodly` arrive pre-localized to the device locale, "yearly" becomes "jährlich" on a German device, with no work on your side.
## The three rules
Three habits keep product data honest.
### 1\. Guard every read and design the unpriced state
A reference only exists once a host has delivered it, and its variables may still be missing: the SDK can send a product before its data, a dashboard sample may lack a field, and a slot the studio cannot resolve carries example values. Degrade the copy; never invent a number:
```tsx
{annual?.variables.price ? `Subscribe · ${annual.variables.price}` : "Subscribe"}
```
The unpriced state isn't an error state. Your paywall will render it, so design it to read as intentional.
### 2\. `Number()` before arithmetic
Numeric-looking variables arrive as **strings** on device (`"59.99"`, `"7"`), while a dashboard sample in the studio may carry numbers. A `typeof x === "number"` check can pass in a preview and silently fail on a real phone, treating every product as trial-less:
```tsx
const days = Number(annual?.variables.trialPeriodDays);
const trialDays = Number.isFinite(days) ? days : 0;
```
### 3\. Display formatted, compute raw
Use `price` and `monthlyPrice` for copy. They're formatted by the store for the user's region and currency. Use `rawPrice` when you need to compute or animate. Never derive a displayed price the store already provides: your division will disagree with the store's own math somewhere in the world.
## Selection state is ordinary React
The framework has no "selected plan" concept, selection is your state, typed against the config:
```tsx
import { type ProductReference } from "superwall/hooks";
const [selected, setSelected] = React.useState("annual");
```
The `product-selection` [example](/docs/framework/examples) shows the full pattern: a typed plan union, `haptics.selection()` on choice, real `role="radiogroup"` semantics, and a designed unpriced state.
## Create the products on the dashboard
A push refuses if `config.ts` names a product the dashboard doesn't have, Stripe identifiers included. Store products can be created straight from the CLI; Stripe products are imported into the dashboard from Stripe instead, and the flags below don't apply to them.
```bash
superwall products create pro_5999_year \
--name "Annual" --price 59.99 --period year \
--trial-days 7 --entitlement
```
See the [CLI reference](/docs/framework/cli) for the full flags. Once the products exist, continue to [Purchases](/docs/framework/purchases).
# 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
# Project Structure
How a superwall/ directory is laid out, the two files the CLI manages, and the rules that keep a project portable.
Everything Superwall-related in your app lives in one `superwall/` directory, or the repo root, if you keep paywalls in a dedicated repo. It's a self-contained npm project: clone it, install, run `superwall dev`, and it works. Your host app needs no npm setup of its own.
## Layout
```ts
superwall/
├── package.json depends on `superwall`, react, react-dom
├── tsconfig.json
├── superwall.d.ts generated — commit, never edit
├── superwall.lock dashboard bindings per platform, commit
├── .gitignore
├── components/ components shared across paywalls
├── messages/ shared string catalogs (en.ts, de.ts, …)
├── assets/ shared images, video, fonts
├── paywalls// one directory per paywall
│ ├── config.ts required — definePaywall({ name, products })
│ ├── app/ pages — index.tsx (required), layout.tsx, more pages
│ ├── components/ this paywall's own components
│ ├── messages/ this paywall's own strings
│ └── assets/ this paywall's own assets
└── funnels// same shape, for funnels
```
`components/`, `messages/`, and `assets/` work at both levels: shared at the root, local inside a paywall. `@/…` imports resolve from the `superwall/` root:
```ts
import { Button } from "@/components/Button";
```
## The rules
A few conventions keep every project buildable, portable, and understandable at a glance:
* **`app/` holds pages and nothing else.** Every `.tsx` file in `app/` is a page, lowercase-kebab filename, default-exported component. `layout.tsx` at the top level is the one reserved name; stylesheets may sit beside pages. Anything else belongs in `components/`. A stray file in `app/` is a warning in dev and blocks a push.
* **Every paywall starts at `app/index.tsx`** and must have a `config.ts`.
* **The directory name is the identifier.** It's the URL in dev and the dashboard binding on push, lowercase-kebab. The `name` in `config.ts` is only the human-readable label shown in the dashboard.
* **No build tooling.** No vite config, no `index.html`, no entry point. The framework owns the build end to end.
* **Never name the package `"superwall"`** in `package.json`. That would shadow the framework import. `superwall create` names it after your app.
Commands work from your app root or from inside `superwall/` alike, and a globally installed `superwall` always defers to the project's own installed version, so everyone on the team builds with the version the project pins.
## Two files the CLI manages: commit both
### `superwall.d.ts`
Regenerated on every `dev` and `push`. It's what makes `router.push("plans")` autocomplete and reject typos, gives `getProduct` and `purchase` their typed product references, and makes asset imports typecheck. Never edit it; never delete it.
### `superwall.lock`
Binds each paywall directory to its paywall on the dashboard, per platform, and records which Superwall app the project pushes to on each platform. Committing it is what makes every machine, and CI, push to the same paywalls. Nothing about the dashboard ever appears in `config.ts`; the lock file is the only place bindings live.
```json
{
"version": 1,
"apps": { "ios": "1001", "android": "1002", "web": "1003" },
"paywalls": {
"plus-upgrade": {
"ios": { "paywallId": "2001", "snapshot": "…" },
"android": { "paywallId": "2002", "snapshot": "…" }
},
"web-upgrade": { "web": { "paywallId": "2003" } }
},
"funnels": {},
"origins": {
"paywall/plus-upgrade": { "paywallId": "1500", "url": "https://…/store/document/…" }
}
}
```
`origins` appears only for a surface that was rebuilt from something: a dashboard paywall (`superwall create --from`, recording its id and the URL it serves) or a native screen (`superwall migrate --screen`, recording the source path, its framework and the screenshot captured as the original). It is what the studio's **Compare › Original** shows. It is never a binding; pushes go to `paywalls`. See [Migrating from the editor](/docs/framework/migrate-from-editor) and [Migrating native screens](/docs/framework/migrate-native-screens).
`apps` maps each platform (`ios`, `android`, `web`) to the Superwall app the project pushes to there, one app per platform, and a paywall's `config.ts` says which platforms it ships to with `platforms: [...]`. One directory can ship to several platforms, each with its own dashboard paywall and versions. A project on a single platform never needs to mention it. See [Configuration](/docs/framework/config#platforms) and [Push, promote & publish](/docs/framework/push-and-promote#several-platforms).
Renaming a paywall directory is safe: the next `push` notices and asks whether it's a rename (keeping the live paywall attached) or a brand-new paywall. In CI, declare it with `--rename old=new`. See [Push, promote & publish](/docs/framework/push-and-promote).
## Keep imports inside the project
Import from within `superwall/` or from packages listed in its `package.json`. An import that reaches outside, say `../../src/theme`, still builds on your machine, but the pushed source can no longer be rebuilt anywhere else, so the push warns and names each offender, and the version is recorded as non-portable.
Copy shared code into `superwall/components/` instead. Duplication here is deliberate: it's what keeps the project self-contained.
## `.env`
`superwall/.env` (with your app root's `.env` as a fallback) holds project credentials, `SUPERWALL_API_KEY` for CI pushes. It's gitignored and never leaves your machine: source pushes exclude `.env*`, `node_modules/`, `.superwall/`, `.git/`, and anything `superwall/.gitignore` lists.
## Funnels
Multi-step flows (onboarding quizzes, web funnels) use exactly the same layout as paywalls and live under `superwall/funnels//`. A funnel is one surface whose steps are pages, not a chain of separate paywalls. See [Pages & navigation](/docs/framework/navigation).
# 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
# Purchases
Make the sale with usePurchase, handle completed, abandoned, and failed outcomes, restore purchases, and react to transactions from anywhere.
A purchase is one call: pass a product reference, await the result, react to what happened. The SDK owns the store sheet, the payment, and the receipt.
```tsx
import { usePurchase, useHaptics } from "superwall/hooks";
const { purchase } = usePurchase();
const haptics = useHaptics();
```
## The three outcomes
`purchase()` resolves, it never throws for flow outcomes:
| Status | Meaning | Respond by |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `completed` | The sale went through | `haptics.success()`; the SDK dismisses the paywall if configured |
| `abandoned` | The user closed the store sheet | Treat as an ordinary outcome, most people who open a sheet close it: show a last-chance offer, or nothing |
| `failed` | No transaction happened. On a store purchase `reason` is `"sdk"` (the store reported a failure, or the SDK timed the purchase out) or `"superseded"` (a newer purchase for the same product reference, or a paywall re-open); web checkout failures carry no `reason` | Usually nothing; `haptics.error()` at most |
**Platform note.** A declined card or store error reaches the paywall as `transaction_fail` or `transaction_timeout` and resolves `purchase()` with `reason: "sdk"`. The SDK owns that timing; the paywall runs no timer of its own, so a store sheet left open for five minutes is still a purchase in progress, not a failure.
* **iOS**: reported by the SDK.
* **Android**: not reported. The Android SDK sends no failure message, so a failed purchase leaves the promise pending and `isPurchasing` `true` until the user abandons or completes. Design the buy button so that state is survivable (never a spinner that blocks the sheet), and do not gate irreversible UI on `failed` there.
### Reacting to abandoned
`abandoned` is what your `purchase()` call resolves with when the user closes the store sheet; the same decline also arrives as a `transaction_abandon` event. Either makes a good hook for a last-chance offer:
```tsx
const result = await purchase(selected);
if (result.status === "abandoned") {
router.push("offer", { transition: "sheet" }); // "sheet" is a transition you define in CSS
}
```
The `abandonment-offer` [example](/docs/framework/examples) shows the full pattern.
## Options
```tsx
purchase(reference, { postPurchase?, stripeMetadata? })
```
`postPurchase` overrides the config's [`postPurchase`](/docs/framework/config#post-purchase) for this one button: `"stay"` keeps the paywall up after the sale, `"dismiss"` (the default) lets the SDK dismiss it, and on the web `"redeem"` or `{ redirect: url }` send the shopper on. `stripeMetadata` is string key/values written onto the Stripe subscription once a web purchase completes; store purchases ignore it. See [Web checkout](/docs/framework/web-checkout#custom-metadata-on-the-sale).
## The two channels
Your `purchase()` call is one channel. The SDK reporting on its own is the other, it reports what happened, whether or not this paywall started it: a purchase completing, a trial beginning, a sheet being abandoned.
```tsx
// this paywall's own attempt
const result = await purchase("annual");
// anything the SDK reports, whoever started it
useSuperwallEvent("transaction_complete", () => haptics.success());
useSuperwallEvent("transaction_abandon", () => {});
useSuperwallEvent("freeTrial_start", () => {});
```
Drive *this paywall's* flow from the awaited result; use events for side effects that should fire on any transaction, however it started. The `purchase-states` [example](/docs/framework/examples) shows both channels side by side, with [`useHaptics()`](/docs/framework/hooks#usehaptics) keyed to each outcome.
See [Lifecycle & events](/docs/framework/lifecycle) for the full event list.
## Restore
```tsx
import { useActions, useHaptics } from "superwall/hooks";
const { restore } = useActions();
const haptics = useHaptics();
```
`restore()` resolves once the SDK has finished:
| Result | Means |
| ------------------------ | ---------------------------------------------------------- |
| `{ status: "restored" }` | Something was restored **and** entitlements are now active |
| `{ status: "failed" }` | The restore failed, or there was nothing to restore |
> **Note:** Those two failures are one outcome on purpose, because the SDK can't tell them apart to the paywall. It composes a message for the "nothing to restore" case — *"the restoration result is `restored` but there are no active entitlements"* — but that text goes to its own logs and native alert, never over the protocol. Write copy that covers both, like "We couldn't find a purchase to restore."
The same lifecycle also lands on `useSuperwallSnapshot().restore` as `started`, `completed` or `failed`, and as the `restore_start`, `restore_complete` and `restore_fail` events, if you'd rather render progress than await.
* **iOS**: reports all three.
* **Android**: reports only `restore_fail`.
Every store paywall should offer restore, App Review expects it.
## Selling beyond the App Store
Trials, who's eligible, what to show each side, have their own page: [Free trials](/docs/framework/trials). And a single config key sells the same paywall on the web through Stripe, with `purchase()` unchanged: [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
# Push, Promote & Publish
Ship paywalls with git semantics: push seals an immutable version, promote points production at it, publish does both.
Shipping has git semantics on purpose: **push saves, promote ships.** Every push mints a sealed, immutable version; nothing your users see changes until promote points production at it.
```bash
superwall push # build + version. Production untouched.
superwall promote # point production at the latest push
superwall publish # push + promote in one step
superwall publish -m "Q3 test" # record why
```
The scaffolded project mirrors these as package scripts (`dev`, `push`, `promote`, `ship`).
> **Note:** Pushing needs Superwall for Agents enabled on each Superwall app you push to. If a push says it isn't enabled for an app yet, contact [support@superwall.com](mailto\:support@superwall.com) for access.
## `superwall push`
Builds every paywall, versions the changed ones, and leaves production alone. Re-running with nothing changed is a no-op.
| Flag | What it does |
| ---------------------- | ---------------------------------------------------------------------------- |
| `--id ` | Limit the push to one paywall (repeatable) |
| `--platform ` | Limit the push to one platform (repeatable), see [below](#several-platforms) |
| `--rename =` | Declare a directory rename (see below) |
| `-m ` | Record why this version exists |
The **first push binds** each paywall, creating it on Superwall if needed, and records the binding in `superwall.lock`. Commit that file: it's what makes every machine and CI push to the same paywalls. After that, push always updates the same paywall; no IDs ever appear in your code.
A push refuses, before anything is written, when:
* **A selected paywall has diagnostics.** Publishing is immutable; fix the named problems first. They're the same warnings `superwall dev` prints.
* **A product in `config.ts` doesn't exist on the dashboard.** Every variable on it would be undefined on device. Create the products first, see [Products](/docs/framework/products) and the [CLI reference](/docs/framework/cli).
* **A directory rename is unresolved** (below).
## Renames
Renaming a paywall directory is detected, never guessed. Interactively, push asks:
```
? `pro-upgrade` is not in superwall.lock. Is it a new paywall, or renamed?
› Renamed from plus-upgrade paywall 208540
Create a new paywall
```
Choosing the rename keeps the live paywall attached to the new directory. In CI there's no one to ask, so declare it, anything unresolved stops the push rather than silently creating a duplicate:
```bash
superwall push --rename plus-upgrade=pro-upgrade
```
Deleting a paywall directory never blocks a push: the dashboard paywall keeps serving, and restoring the directory re-binds it.
Every pushed version is previewable in the dashboard: opening the paywall there opens the [hosted studio](/docs/framework/studio#in-the-dashboard) on the live version, with the others one switch away.
## Several platforms
One project can push to several platforms (an iOS app, an Android app, a web app) as long as the apps belong to the same Superwall project. Each paywall's `config.ts` lists the platforms it's for:
```ts
export default definePaywall({ platforms: ["ios", "android"], products: { … } });
```
`superwall.lock` binds one Superwall app per platform under `apps`. The first push that meets a new platform binds it: when your account has exactly one app of that platform, it's picked for you; when it has several, push asks; when it has none, push says so. In CI there's no one to ask, so bind it once interactively, or add the line yourself, `"web": ""`, with ids from `superwall apps list`.
A paywall pushed to two platforms becomes two dashboard paywalls, each with its own versions, and the output names them:
```
plus-upgrade · ios
Linked to paywall 208551
Pushed version 4 (3f2a9c1)
plus-upgrade · android
Linked to paywall 208560
Pushed version 2 (8b71e04)
```
`--platform ` narrows push, promote, and publish to one platform (the bound app's id works too), and `promote --version` needs it when the paywall is on several:
```bash
superwall push --platform android
superwall promote --id plus-upgrade --platform ios --version 3
```
Until a project is bound on a second platform, `platforms` is optional and everything pushes to the one app. Once it is, every paywall has to say which platforms it's for; a push stops on a silent one rather than create a paywall on the wrong app.
## Source snapshots
Every push also snapshots your `superwall/` source to Superwall, so the exact code each version was built from is recoverable. The `-m "why"` note is recorded on that source commit, it only lands when the source actually changed.
What never leaves your machine: `.env` files, `node_modules/`, `.superwall/`, `.git/`, and anything `superwall/.gitignore` lists.
> **Warning:** If any import reaches outside the project directory, the push warns and records the version as non-portable. The pushed source can't be rebuilt elsewhere. The warning names the first five offenders, then counts the rest. Either copy the shared code into `superwall/components/`, or install it from a package registry. See [Project structure](/docs/framework/project-structure).
## `superwall promote`
Points production at a pushed version. Promote never rebuilds. It only moves the live pointer, so it's instant, and rollback is the same move in reverse:
```bash
superwall promote # latest push, every paywall
superwall promote --id plus-upgrade # just one
superwall promote --id plus-upgrade --version 5
# → Rolled back version 7 → 5
superwall promote --id plus-upgrade --platform ios --version 5 # a paywall on several platforms
```
`--version`/`-v` (with a single `--id`, plus `--platform` when that paywall is on several platforms) picks a specific version. Pinning forward and rolling back are the same operation.
## `superwall publish`
Push + promote in one step. It also warns about other paywalls that are pushed-but-not-live, so nothing ships half-forgotten.
`push` and `publish` both require git. The source snapshot is part of each.
## CI
Interactive machines authenticate once with `superwall login`. In CI, set `SUPERWALL_API_KEY` (an `sk_…` key) in the environment. `superwall/.env` works locally and is gitignored. `dev` needs no login at all.
A typical CI ship step:
```bash
superwall push --rename old=new -m "$COMMIT_MESSAGE" # renames declared, reason recorded
superwall promote
```
Because `superwall.lock` is committed, CI pushes to exactly the same paywalls as every developer machine.
# 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
# Quickstart
Scaffold a Superwall Framework project, preview your first paywall in the studio, and ship it to production.
This guide takes you from nothing to a live paywall: scaffold a project inside your app, preview it locally, and push it to Superwall.
## Before you start
You'll need:
* **Node 20.12+** (or Bun) and **git**.
* A **Superwall account** with an application that has Superwall for Agents enabled. A push tells you if it isn't, and [support@superwall.com](mailto\:support@superwall.com) can turn it on.
* The **Superwall CLI**:
## Tab
```bash bun
bun add -g superwall
```
## Tab
```bash npm
npm install -g superwall
```
* **`SUPERWALL_CHANNEL=next`** in your shell. The CLI's `next` channel is where experimental and beta features live, and the framework is on it; without the variable the CLI has no `create`. Add `export SUPERWALL_CHANNEL=next` to your shell profile (for example `~/.zshrc`) so every later command, yours or your agent's, stays on the channel. See [Channel](/docs/framework/cli#channel).
## Create the project
From the root of your app's repo:```bash
superwall create
```This scaffolds a self-contained `superwall/` directory, its own `package.json`, a starter paywall, and everything wired up, then connects it to your Superwall app and installs dependencies. Your app itself needs no npm setup.To skip the app picker, pass the app's id: `superwall create --app `. The **Copy Prompt** button on the dashboard's Paywalls page hands your coding agent exactly that command for the app you're looking at.To start from a working pattern instead, scaffold any [example](/docs/framework/examples). Each is a complete project:```bash
superwall create --example multi-page
```
## Preview in the studio
```bash
superwall dev
```This opens the studio at `http://localhost:6100`: every paywall as a card with a live preview, and an editor per paywall with a device-frame view at exact logical size. Switch devices, toggle light and dark, rotate, change locales, and buy: the studio hosts the paywall the way the SDK does and asks *you* to pick each purchase outcome, so you can test every branch of your flow. See [The studio](/docs/framework/studio) for the full tour.Edits hot-reload as you save. Warnings about project problems (a stray file in `app/`, a duplicate route) appear here too. They're the same checks that block a push, so fix them as they come up.
## Make it yours
Open `superwall/paywalls//` and edit. A paywall is ordinary React:```tsx
// app/index.tsx
import { useProducts, usePurchase, useActions, useHaptics } from "superwall/hooks";
export default function Paywall() {
const { getProduct } = useProducts();
const { purchase } = usePurchase();
const { close } = useActions();
const haptics = useHaptics();
const annual = getProduct("annual");
return (
Go Pro
);
}
```One thing to know from day one: **product data arrives at runtime.** Prices come from the store on a device and from your dashboard in the studio, and either host can deliver a product before its data, so guard every read rather than assuming a number is there. See [Products](/docs/framework/products).
## Point at real products
`config.ts` declares product **slots**. The key is the name your code uses; the value is the store identifier:```ts
import { definePaywall } from "superwall/config";
export default definePaywall({
name: "Pro — Annual",
products: {
annual: "pro_5999_year",
},
});
```The identifiers must exist as products on your Superwall dashboard. A push refuses otherwise. Create them in the dashboard, or from the CLI with `superwall products create`. See [Products](/docs/framework/products).
## Push, then promote
```bash
superwall push # build + seal an immutable version — production untouched
superwall promote # point production at the latest push
```Push saves, promote ships: the same split as git push and a deploy. `superwall publish` does both in one step. The first push binds each paywall to your dashboard and records the binding in `superwall.lock`; commit that file so every machine and CI push to the same paywalls. See [Push, promote & publish](/docs/framework/push-and-promote).
## Show it in your app
Nothing changes on the app side: add the paywall to a campaign in the dashboard, and your existing `register` / placement calls present it. If you're new to Superwall, follow your platform's quickstart, [iOS](/docs/ios), [Android](/docs/android), [Expo](/docs/expo), or [Flutter](/docs/flutter), to get the SDK configured and a placement registered.
## Where to next
## Project structure
/docs/framework/project-structure
The full directory layout, the two generated files, and what to commit.
## Pages & navigation
/docs/framework/navigation
Turn one page into a multi-step flow.
## Purchases
/docs/framework/purchases
Handle completed, abandoned, and failed, and why the buy button never shows a spinner.
## Examples
/docs/framework/examples
Complete projects for product selection, onboarding quizzes, trials, and more.
# 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
# The Studio
Preview every paywall locally with superwall dev: devices, themes, locales, live variables, and purchase, restore, permission and callback outcomes you choose.
`superwall dev` hosts the studio at `http://localhost:6100`: every paywall in your project as a card with a live miniature, and an editor per paywall with a device-frame preview at exact logical size. It's where you check everything you can't check in code.
```bash
superwall dev # the current project
superwall dev examples/* # several projects at once
```
`dev` needs no login, regenerates `superwall.d.ts` first (so route and product types are always current), and takes `--port`/`-p` (default 6100, moving to the next free port) and `--host`.
> **Tip:** Project problems, a stray file in `app/`, a duplicate route, print as warnings in dev. They're the same checks that block a push, so fix them as they appear rather than discovering them at ship time.
## The studio is a host
On a device the native SDK hosts your paywall: it answers the paywall's handshake with the products, the device and user data, and the introductory-offer eligibility, then replies to every purchase, restore, permission and callback request. The studio does exactly the same job, with the same messages in the same order, over the same protocol the visual editor has always used. Your paywall cannot tell the studio from a device, and nothing about previewing is compiled into a published paywall.
The only difference is who decides the outcomes. On a device the store and the OS do. In the studio, you do.
## What you can check
* **Devices**, iPhone SE through iPad Pro, plus Pixel, Galaxy and Desktop, and a responsive mode you resize by dragging any edge or corner of the frame (or by typing a width and height), from 200px to 3000px a side; the canvas holds still while you drag. Switching devices also changes what the paywall sees as platform, model, and OS version, so platform-conditional code is testable too, and the [insets](/docs/framework/styling#insets) move with it: a preview reports no `env()` safe area, so the framework's floors are what position chrome here, keyed on the same attributes a device sets. The Android presets reserve the navigation bar below the paywall the way the SDK keeps its webview above it, and the Desktop preset presents itself to a native paywall as a Mac host, which has no safe area at all.
* **The canvas**. The stage pans and zooms like a design canvas: scroll to pan, pinch or hold ⌘ while scrolling to zoom around the cursor, drag the empty stage to move, middle-drag anywhere including over a frame, or hold Space for the hand tool. A left drag inside a paywall always belongs to the paywall. Over a local preview the wheel belongs to the paywall whenever the page under the cursor could want it: anything that scrolls or overflows, a handler that consumed the event, or overscroll containment. The canvas pans only when none of that applies; over a published snapshot the browser hands the wheel to the snapshot, so use Space there. The zoom group in the toolbar steps in and out, and clicking the percentage fits everything back into view; ⌘0 fits, ⌘1 is actual size, ⌘+ and ⌘− step. Frames keep their real logical size at every zoom, so a paywall's viewport never changes.
* **Presentation**. `presentation` in `config.ts` shapes the frame the way the SDK will: a modal rises as a sheet on phones and centres on tablets, a drawer and a popup take the height, width and corner radius you declared, and the paywall's own viewport is that sheet, not the screen behind it.
* **Web paywalls**. A paywall whose only platform is web (from `platforms` in `config.ts`, or from the app it is bound to in `superwall.lock` once pushed) is shown as a bare browser viewport, sized to what the browser leaves once its status bar and toolbars are taken, with no island or home indicator, and reports `web` as its platform. The bars themselves are not drawn: check where they overlap on a real device.
* **Light and dark**. The studio's theme toggle changes the `interfaceStyle` the paywall is told, which drives the same `dark` class the framework stamps on a device. Check both, always.
* **Locale**, switch languages to proof every catalog. The paywall is told a new device locale, exactly as if the phone had changed language. See [Localization](/docs/framework/localization).
* **Rotation**, portrait and landscape, live. See `useDevice().orientation` in the [hooks reference](/docs/framework/hooks).
* **Introductory offer eligibility**. A toggle that flips the store's answer, so both versions of a trial paywall are one click apart. See [Free trials](/docs/framework/trials).
* **Compare**, split the stage between your local build and any pushed version of the paywall (the live one is marked), or another paywall in the project, and both frames scale together as the window changes. Versions come from the dashboard, so this needs a login and a pushed paywall; when they can't be read, the menu says why. A paywall created with `superwall create --from` also offers **Original**: the dashboard paywall it was rebuilt from, served as it is today. That pane is view-only, so it shows the editor's own example prices and does not follow the theme, locale or device variables you set on your side. A surface rebuilt from a native screen with `superwall migrate --screen` offers **Original** too, as the simulator screenshot captured during the migration. Opening a paywall's studio URL with `?compare=origin` lands on that view directly, which is what `superwall migrate` does when it opens the studio for you. See [Migrating from the editor](/docs/framework/migrate-from-editor) and [Migrating native screens](/docs/framework/migrate-native-screens).
* **Variables**, edit user attributes, device properties, placement params, and per-product variables live in the Variables panel. Values are seeded from your app's real sample data and products when you're logged in; without a login the panel falls back to the same defaults the dashboard shows an app with no traffic. Every edit is sent to the paywall as new variables; nothing reloads. See [Variables & personalization](/docs/framework/variables).
## Outcomes you choose
Purchases, restores, permission prompts and callbacks all resolve from the host. In the studio each one prompts **you** to pick the outcome, so both branches of every flow are testable. Abandon or fail your own purchase to check those paths; deny your own permission request to check the fallback copy; report nothing to restore. The paywall receives the same messages the SDK would send for that outcome.
Actions the paywall sends to the host (`close()`, `openUrl()`) surface as toasts as they happen, so you can confirm one reached the host. Haptics, page views and the purchase request itself are deliberately silent; the prompt covers it.
## Products in previews
Product data is read from your dashboard. A slot the dashboard has no product for gets **no variables at all** — the same thing a device sees when the store has nothing for that identifier — so `variables.price` is undefined and your own fallback renders. The studio never invents a price, because a confident number for a product it couldn't read is worse than none. Push still refuses a reference the dashboard cannot resolve, see [Products](/docs/framework/products).
> **Note:** This is why guarding every read matters: ``monthly?.variables.price ? `Subscribe · ${monthly.variables.price}` : "Subscribe"`` renders cleanly whether or not prices resolved. Sign in with `superwall login` and point the project at a connected app to see real ones.
## Dev vs device
| | `superwall dev` | Real device |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Product variables | Delivered by the studio from your dashboard; an unresolved slot gets none | Delivered by the SDK from the store |
| `purchase()` | You pick completed, abandoned or failed | Real store |
| `restore()` | You pick restored or nothing to restore | Real store |
| `close()`, `openUrl()` | Toast in the studio (haptics are silent) | Acted on by the host |
| Permissions / callbacks | Studio prompts you | OS prompt / your app's code |
| Numeric **product** variables | Whatever the dashboard sample carries | **Strings**, `Number()` before arithmetic |
| Presentation (`paywall_open`) | Immediate | After preload, when actually shown |
| Web checkout sheet | Not mounted: a preview has no API key, so a Stripe product resolves through the studio's purchase prompt | Works |
| `presentation`, `background`, `featureGating`, `scrollEnabled` | Read from `config.ts` | Live on device with iOS SDK ≥ 4.17.0; older SDKs and Android keep the **last promoted** values until you promote again |
| `onDeviceCacheEnabled` | Ignored: the preview reloads on every edit | Ignored in dev mode for the same reason, honoured normally otherwise |
### Settings on a device in dev mode
`superwall dev` serves your paywalls to the SDK's dev mode, and the manifest it
serves carries the same settings block `superwall push` stamps, computed once in
the framework so the device and the dashboard can't disagree. So changing
`presentation.style` from `fullscreen` to `drawer` in `config.ts` shows up on
the next presentation, with no push.
Each setting stands alone: one your `config.ts` doesn't mention is inherited from
the published paywall, so a paywall you've never pushed presents from its own
config and a bound one keeps whatever the dashboard configures that `config.ts`
has no word for — intro-offer eligibility, surveys, computed properties, and the
loading delay.
Two exceptions, both deliberate. `onDeviceCacheEnabled` is ignored in dev mode: a
dev server reloads the page on every edit, so an enabled cache could serve a stale
copy of your local page. `notifications` always come from your local `config.ts`,
never the dashboard's copy.
An SDK that predates the settings block ignores it and keeps presenting the last
promoted version's settings while running your local code, which is worth knowing
if a `drawer` looks fullscreen on device: check the SDK version before checking
your config. The Android SDK does not read it yet, tracked with the rest of the
Android gaps. `gameControllerEnabled` is an app-level SDK option rather than a
paywall setting, and `checkout` only steers the web build, so neither changes
anything on device.
## Preview on a real device
**Preview** shows a QR code that opens the paywall on a phone, and what it points at depends on where the studio is running.
Locally it carries a `superwall_dev` link to this machine's dev server, so the phone renders the code you are editing. Four things have to be true, and the SDK checks all of them:
* your app registers a URL scheme, which `superwall dev` finds in your `Info.plist`, `app.json` or `AndroidManifest.xml`, or reads from the app's dashboard record
* the SDK is in dev mode — `options.devServer = .url(…)` on a physical device, `.default` only reaches a simulator
* the build is a sandbox one; a production build refuses and says so
* the dev server address is loopback, `.local`, or a private network address
The QR carries the **network** address `superwall dev` prints, not the one your browser is on, so scanning works while you browse `localhost`. Both devices have to be on the same wifi, and iOS asks once for local network permission.
In the dashboard the QR carries a dashboard URL instead, which mints a short-lived token and hands the device to the SDK's debugger — so it shows the **live** paywall, through the normal paywall pipeline with real products.
> **Note:** App Transport Security blocks plain `http`, so an app cannot reach a dev server until its `Info.plist` allows local networking. `superwall dev` detects this and offers to add the two keys for you. Keep them out of release builds: the SDK refuses a dev server outside a sandbox build anyway, so they buy nothing in production.
## Download a screenshot
**Download screenshot** saves the image Superwall captured of the paywall. Captures run against the published document, so the button stays disabled — and says why — until a version is live. Push alone is not enough; promote is what makes a document a screenshot can be taken of.
You get the **1290 x 2796** render, which is the 6.7"/6.9" App Store screenshot size Apple accepts for a product page and for the attachments a subscription's review needs — so the file uploads to App Store Connect as-is, with no resizing. It is a genuine render at that viewport rather than an upscale of the phone-sized capture, so a paywall that reflows wider is shown reflowing.
## In the dashboard
Opening a code-first paywall from the dashboard opens this same studio, hosted in the dashboard, over what Superwall serves: the live snapshot by default, with every pushed version one switch away, and the same devices, themes, locales, variables and outcome prompts as `superwall dev`. Product, user, and device data come from your app's dashboard records. It is a preview, not an editor: the source stays in your project, and shipping still goes through `superwall push`, `promote`, and `publish`.
A published snapshot carries no preview code. Opened with `?transport=editor` it talks to whatever window embeds it, which is how the studio, the dashboard's preview frame and the screenshot service all host it. Opened by an SDK it talks to the SDK.
## The Push, Promote, and Publish buttons
The studio has buttons for the same operations as the CLI, good for quick iteration. Either way, pushing needs Superwall for Agents enabled on the app; if a push comes back refused, contact [support@superwall.com](mailto\:support@superwall.com) for access. For actually shipping, prefer the CLI: the buttons skip the diagnostics gate and the dashboard product check, can't resolve renames, and take no `-m` note. See [Push, promote & publish](/docs/framework/push-and-promote).
# 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
# Styling
How styling works in a paywall, plain CSS, the color-scheme class, the background color, insets and safe areas, and the platform stylesheet Superwall applies at serve time.
A paywall is styled with ordinary CSS. The framework ships no component library, no theme, and no opinions about how your paywall should look. Your stylesheet is the whole story.
What the framework does provide is a small set of mechanisms your CSS can rely on: a color-scheme class that follows the device, a background color that reaches the native SDK, insets that know which screen and which presentation the paywall is in, and a platform stylesheet applied at serve time.
## Plain CSS
Import a stylesheet from a route and write whatever you like:
```tsx
import "./theme.css";
```
Tailwind works, CSS modules work, and a single hand-written `theme.css` works. Nothing is injected into your styles, and nothing of yours is overridden.
## Color scheme
The device decides light or dark, and the framework maintains a `dark`/`light` class on ``. Style off that class and write no wiring:
```css
:root { --bg: #fdfef6; --fg: #0c0b0a; }
:root.dark { --bg: #1c1b19; --fg: #fdfef6; }
```
> **Warning:** Don't use `@media (prefers-color-scheme: dark)` as the mechanism. The media query reads the *browser's* setting; the class reflects what the device reports through the SDK, which is the real interface style, and it's what the studio's theme toggle drives. A paywall styled on the media query looks right on your machine and wrong on the device.
Using Tailwind? Point the `dark:` variant at the class so it follows the SDK:
```css
@custom-variant dark (&:where(.dark, .dark *));
```
Read the current scheme in JavaScript with [`useColorScheme()`](/docs/framework/hooks#usecolorscheme).
## Background color
`background` in [config](/docs/framework/config#background) sets the paywall's background, in light and dark:
```ts
background: { light: "#ffffff", dark: "#0d0f12" }
```
One value covers both sides of the load: it paints the page background, and it's sent to the native SDK, which paints the same color behind the webview and derives its loading spinner from it. Set it to whatever your page background is, and a native paywall has no flash of a different color while it loads. On the web the document paints the light color until React mounts, so a dark-mode visitor to a web funnel can still see one.
### Pages need `--sw-background` too
`background` covers the load: the document before React mounts, and the native color behind the webview. It does **not** paint the pages themselves. Every route is opaque so that a page sliding in never shows the stack through it, and each one paints `--sw-background`, which defaults to the system `Canvas` color.
Set both, to the same color, or your configured background is covered by system white or black on every route:
```css
:root {
--bg: #fdfef6;
--sw-background: var(--bg); /* what the pages paint */
}
:root.dark { --bg: #1c1b19; }
```
Every [example project](/docs/framework/examples) and the `superwall create` scaffold set it this way, next to `--sw-page-inset-bottom: 0px`, which is there because their layout ends in a "Built with" footer below the pages (see [how the paywall is laid out](#how-the-paywall-is-laid-out)); remove that line along with the footer. Set `--sw-background: transparent` only when the paywall is meant to show what is behind it.
## Insets
A paywall is inset from the screen edges by default. Everything a layout or a page renders starts clear of the status bar, the camera cutout, the home indicator and, on a rotated phone, the cutout's side, without a line of CSS: the framework pads the element that wraps the whole paywall by the safe area, so a page writes `padding: 16px 20px` and a footer at the bottom of a layout sits above the home indicator on its own. Pages still scroll edge to edge: each page's scroll container reaches back out under the insets and carries them as its scroll padding, the way a native scroll view runs under the bars with a content inset, so content passes beneath the status bar and the home indicator and comes to rest clear of them.
`insets` in [config](/docs/framework/config#insets) chooses what that padding is:
```ts
insets: "safe-area" // the default
insets: "none" // flush to every edge — a full-bleed paywall
insets: 24 // pixels, all four edges
insets: { top: "none", bottom: "safe-area" } // per edge; an omitted edge stays "safe-area"
```
Whatever it says, the framework keeps the safe area itself available as `--sw-safe-area-inset-top|right|bottom|left`, for the one kind of element that means to sit over the bars: chrome over a bleed, positioned absolutely in the layout.
```css
/* a close button in layout.tsx over a hero that runs under the status bar (insets: { top: "none" }) */
.close { position: absolute; top: calc(var(--sw-safe-area-inset-top) + 8px); right: 16px; }
/* a CTA pinned over scrolling content, when insets are "none" at the bottom */
.footer { position: sticky; bottom: 0; padding-bottom: calc(var(--sw-safe-area-inset-bottom) + 12px); }
```
Chrome inside the inset area needs none of this: a close button in `layout.tsx` is `position: absolute; top: 4px; right: 16px`, and it is already below the bar, because the framework's content box is the containing block layout chrome positions against. Turn an edge off only for something that must rest under the bar, such as a hero whose top edge is the screen edge; never to fix scrolling or a cut-off footer, since pages already scroll under the insets.
### How the safe area is resolved
The safe area comes from two sources:
* **The host's own `env(safe-area-inset-*)`**, wherever the webview reports one. On an iOS device that is the exact value for the phone in hand.
* **A floor for the screen the host said it is**, wherever it reports nothing. Previews report 0, so does Android WebView, and so do some webview contexts, which is exactly where a bare `env()` puts a close button in the status bar. The floor is a minimum for the class of device, so the two are combined with `max()` and a real value is never shrunk.
The floors follow what the SDK tells the paywall about the device and what `config.ts` says about the presentation:
| Host | Top | Bottom | Sides |
| ---------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------- | ----------------------- |
| iPhone with a Dynamic Island | 59px | 34px | 0 |
| iPhone with a notch | 44px | 34px | 0 |
| iPhone with a home button | 20px | 0 | 0 |
| iPhone, landscape | 0 | 21px | 59px / 44px, both sides |
| iPad | 24px | 20px | 0 |
| Android | 24px | 0, the SDK keeps the webview above the navigation bar | 0 |
| `presentation.style` `modal` or `drawer` | 0, the sheet starts below the status bar | as above on a phone, 0 on iPad where the sheet floats | 0 |
| `presentation.style` `popup` | 0 | 0 | 0 |
| Web | 0, the browser owns its chrome; `env()` still applies in a standalone web app | 0 | 0 |
### How the paywall is laid out
The router renders this around your layout and pages:
```
[data-sw-root] flex column, min-height: 100dvh, padding: the insets, paints --sw-background
└ [data-sw-content] position: relative, flex column — what absolute chrome positions against
└ your layout.tsx, a flex child
└ [data-sw-routes] the page stack, flex: 1 1 auto; negative margins pull it back out under the insets
└ [data-sw-route] one per page: position: absolute; inset: 0; padding: the insets; overflow: auto — the edge-to-edge scroll container
└
```
What follows from it:
* **Nothing of yours is viewport-sized.** The root is. A layout's shell is `flex: 1 1 auto`, not `min-height: 100dvh`; a shell that insists on the full viewport height overflows the insets and scrolls. A page fills its route with `min-height: 100%`.
* **Pages scroll; the document doesn't.** The scroll container is the page's route, so `window.scrollTo` does nothing; scroll the route or use `scrollIntoView`. Each page keeps its own scroll position when covered.
* **`position: absolute` in a layout resolves against the content box**, which starts after the insets: `top: 4px` is 4px below the status bar with no positioned ancestor of your own.
* **`position: sticky; bottom: 0`** as a page's last child pins a CTA above the home indicator, because sticky honours the scroll container's padding, while content scrolls under it to the screen edge. `top: 0` sticks a header below the status bar the same way.
* **A layout footer below the pages** (a flex child after `{children}`) needs `:root { --sw-page-inset-bottom: 0px; }` so the pages stop at it instead of reaching under it.
* **`position: fixed` has no place in a paywall.** It resolves against the viewport, ignoring the insets, and during a page transition the moving page becomes its containing block, so fixed chrome inside a page rides along and snaps when the animation ends. Chrome goes in the layout (absolute) or in the page (sticky).
### The variables
`insets` in config is the front door; underneath it are three layers of CSS variables, each yours to redefine on `:root` when a stylesheet needs more than the config can say. The framework's own declarations have the specificity of a bare `:root` and land before your stylesheet, so yours always win:
| Variable | What it is |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--sw-inset-*` | What the paywall is padded with. `insets` in config writes these; the default is the safe area. |
| `--sw-page-inset-*` | How far each page's scroll container reaches back under the insets, and its scroll padding. Equal to `--sw-inset-*`; set an edge to `0px` when the layout puts something of its own between the pages and that edge. |
| `--sw-bleed`, `--sw-page-bleed` | The four negative insets in `inset:` order, for an overlay that must cover the bars: `position: absolute; inset: var(--sw-bleed)` in a layout, `var(--sw-page-bleed)` inside a page's positioned wrapper. |
| `--sw-safe-area-inset-*` | The safe area: `max()` of the host's `env()` and the floor. What chrome over a bleed reads. The same names the visual editor's paywalls use. |
| `--sw-safe-area-floor-*` | The floor for the host the paywall is in, from the table above. Raise one to teach the framework about a device while keeping `env()` live. |
```css
:root[data-sw-platform="android"] { --sw-inset-top: 32px; } /* one platform's padding */
:root { --sw-safe-area-floor-top: 62px; } /* a taller floor, env() still live */
:root { --sw-inset-left: max(var(--sw-safe-area-inset-left), 20px); } /* a gutter that grows in landscape */
```
Overrides go on `:root`, not on a descendant: the variables are resolved on the root element, so a floor set on `.shell` would not reach `--sw-inset-top`. A value set by `insets` in config is written inline on `` and beats a stylesheet's `:root` rule; use one or the other per edge.
### What the framework stamps on ``
The floors key on attributes the framework maintains on the root element, and your CSS can key on the same ones:
| Attribute | Values | From |
| ---------------------- | ---------------------------------------- | ---------------------------------------------------------------- |
| `data-sw-platform` | `ios`, `android`, `web` | the device the host reports, live |
| `data-sw-idiom` | `phone`, `tablet`, `desktop` | the device the host reports, live |
| `data-sw-cutout` | `none`, `notch`, `island` | the iPhone model the host reports, live; absent on anything else |
| `data-sw-presentation` | `fullscreen`, `modal`, `drawer`, `popup` | `presentation.style` in `config.ts`, at build |
An attribute the host has said nothing about is absent rather than guessed, and the studio sets them the way a device would, so switching devices there moves the insets with it.
```css
:root[data-sw-platform="android"] .cta { border-radius: 999px; }
:root[data-sw-idiom="tablet"] .page { max-width: 34rem; }
```
## Scrolling
Scroll behavior belongs to the platform. `scrollEnabled` in [config](/docs/framework/config) turns page scrolling on or off, and the platform stylesheet implements it, so let the page itself scroll rather than building nested scroll containers.
## Fonts
The system font stack is what makes a webview read as native. When a design calls for brand type, a relative-path `@font-face` is the entire setup. See [custom fonts in Assets](/docs/framework/assets#custom-fonts).
## The platform stylesheet
Published paywalls receive a small Superwall-owned stylesheet at serve time, carrying platform-wide behavior like scroll control. It lands *before* your styles in the cascade, so your ordinary declarations win. A few platform rules are `!important`, `box-sizing` and `cursor` among them, and need `!important` of your own to override. Previews apply the same stylesheet, which is what makes local and published render identically.
Set `SUPERWALL_RUNTIME_URL` in the project `.env` only if you need previews to use a local build of that platform layer.
## Check it in the studio
The [studio](/docs/framework/studio) renders a paywall at device sizes with the controls a device would supply, a theme toggle for both color schemes, presets from phone through desktop (or any width in responsive mode), and a trial-eligibility toggle. It's the fastest way to see a stylesheet behave under conditions your browser won't reproduce on its own.
# 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
# Transitions
Built-in page transitions, where to set them, and how to define your own with nothing but a name and CSS.
Navigation animates by default. The framework ships five built-in transitions, lets you set them at three levels, and makes custom ones a matter of naming an animation and styling four CSS phases, no registration, no JavaScript.
## Built-ins
* **`push`**, iOS-style: the new page slides in from the right while the one behind shifts back and dims. The default.
* **`slide`**, both pages travel: the new one slides in from the right as the current one slides out to the left.
* **`fade`**, a crossfade, one layer at a time.
* **`shift`**, the funnel step: the new page fades in as it drifts the last 44px into place, and the page it replaces is simply gone. One layer moves at a time, so a long flow never reads as a stack. Set it once in `config.ts` for onboarding quizzes and web funnels.
* **`none`**, instant.
## Set them at three levels
The call site wins, then the page, then the surface:
```tsx
router.push("plans", { transition: "none" }); // one navigation
export const transition = "fade"; // one page (top of its file)
export default definePaywall({ transition: "slide" }); // whole surface
```
Going forward uses the *incoming* page's transition; going back uses the *leaving* one's, so a page always leaves the way it arrived.
## Tune the built-ins
A handful of CSS variables adjust timing and feel without replacing anything. Set them on `[data-sw-route]`, not `:root`. The router writes `--sw-transition` inline on the routes container, and an inline declaration shadows `:root`:
```css
[data-sw-route] {
--sw-transition: 500ms; /* duration */
--sw-ease: linear(…); /* a spring; cubic-bezier(0.28, 0.4, 0.08, 1) is the fallback */
--sw-stack-dim: 0.925; /* how much the page behind dims (the default) */
--sw-transition-fade: 350ms; --sw-ease-fade: cubic-bezier(0.4, 0, 0.2, 1);
--sw-transition-shift: 420ms; --sw-ease-shift: cubic-bezier(0.2, 0, 0, 1);
}
```
All motion respects `prefers-reduced-motion` automatically, with reduced motion on, the router settles instantly.
## Custom transitions
A transition is just a name plus CSS. Name it anywhere a transition goes, then style the four phases:
```tsx
export const transition = "zoom";
```
```css
@media (prefers-reduced-motion: no-preference) {
[data-sw-transition="zoom"][data-sw-phase] {
animation-duration: 420ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-sw-transition="zoom"][data-sw-phase="enter"] { animation-name: zoom-enter }
[data-sw-transition="zoom"][data-sw-phase="recede"] { animation-name: zoom-recede }
[data-sw-transition="zoom"][data-sw-phase="leave"] { animation-name: zoom-leave }
[data-sw-transition="zoom"][data-sw-phase="return"] { animation-name: zoom-return }
}
@keyframes zoom-enter { from { transform: var(--sw-from-transform, scale(0.85)); opacity: 0 } }
@keyframes zoom-recede { to { opacity: 0; transform: scale(1.15) } }
@keyframes zoom-leave { to { opacity: 0; transform: scale(0.85) } }
@keyframes zoom-return { from { opacity: 0; transform: scale(1.15) } }
```
The four phases cover both directions of travel:
| Phase | The page is… |
| -------- | ----------------------------------- |
| `enter` | arriving on top |
| `recede` | being covered as you go forward |
| `leave` | dropping off the top as you go back |
| `return` | coming forward again as you go back |
### Rules for custom transitions
* **Always start `from` at `var(--sw-from-transform, )`**, and `--sw-from-filter` for filters. The router fills these with a page's live position when a navigation interrupts an animation, so a spammed button picks the page up where it stands instead of snapping.
* **Wrap in `prefers-reduced-motion: no-preference`.** With reduced motion on, the router settles instantly and your animation never runs.
* **Duration comes from your CSS.** The page stays mounted as long as its animation runs, capped at 5s (and falling back to 500ms when nothing measurable is declared); don't declare a duration anywhere else.
* **Omit phases you don't want.** They don't animate. That's how `fade` crossfades one layer at a time.
## Bottom sheets over the flow
For a modal-feeling page, a last-chance offer, say, define a `sheet` transition: the page slides up while the one behind scales back and dims. Darken the container behind it in the same motion by reusing the framework's timing variables:
```css
:root { --dim: 0.85; }
[data-sw-routes] {
transition: background-color var(--sw-transition, 500ms)
var(--sw-ease, cubic-bezier(0.28, 0.4, 0.08, 1));
}
[data-sw-routes]:has([data-sw-transition="sheet"][data-sw-phase]) {
background-color: color-mix(in srgb, var(--bg) calc(var(--dim) * 100%), #000);
}
```
One `--dim` number drives both the page's `brightness()` and the backdrop, so they always match. Dismissing the sheet is `router.back()`, the page leaves the way it came. The abandonment offer example has the full recipe, see [Examples](/docs/framework/examples).
## Transitions vs. in-page animation
Animation libraries (Motion, plain CSS) animate *inside* a page. Moving *between* pages stays the router's job. Keep that line and spamming navigation can never fight your component animations, and remember that entry animations gate on presentation, never mount. See [Lifecycle & events](/docs/framework/lifecycle).
# 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
# Free Trials
Fork your paywall on trial eligibility the store reports, pull trial terms from product variables, and remind users before a trial ends.
The store decides who gets a trial, not you, and not the user's claim. Someone who used their trial two years ago and reinstalled is ineligible, and only the store knows. `useIntroductoryOffer()` is that signal, and it splits your paywall into two versions that must **both** read as intentional.
## Read eligibility
```tsx
import { useIntroductoryOffer } from "superwall/hooks";
const { eligible } = useIntroductoryOffer(); // boolean | undefined
```
`eligible` is `undefined` until the SDK reports, so gate trial-only UI on `eligible === true`, never on "not false."
## Two paywalls in one
Fork every user-facing string, including the CTA. A returning customer sees the ineligible copy, and it cannot read like a mistake:
```tsx
const { eligible } = useIntroductoryOffer();
const days = annual?.variables.trialPeriodDays;
{eligible ? "Start free" : "Go Pro"}
{eligible
? days
? `${days} days free, then ${annual?.variables.price ?? "the annual price"}.`
: "Your trial is on the house."
: "You have used your trial. Subscribe to keep going."}
```
Two details in that snippet are deliberate:
* **The fallbacks nest.** Eligible-but-days-unknown gets its own sentence. The data may not have arrived yet, and "undefined days free" is never acceptable copy.
* **The ineligible side is written, not defaulted.** "You have used your trial" tells a returning customer the paywall knows who they are.
## Trial terms come from the product
Trial length, price, and end date are variables on the product, `trialPeriodDays`, `trialPeriodPrice`, `trialPeriodEndDate`, `trialPeriodText`, never values in your files. They follow the same rules as every product read: guard them, and `Number()` before arithmetic. See [Products](/docs/framework/products).
## Test both sides
The studio has an introductory-offer toggle: flip it and check every string on both sides. See [The studio](/docs/framework/studio). At runtime the store decides eligibility — but you can override it per paywall with [`introductoryOfferEligibility`](/docs/framework/config) in `config.ts`: `"eligible"` or `"ineligible"` forces the answer for this paywall's products, `"automatic"` (the default) asks the store. Use it to ship the one variant regardless of the shopper's history.
The `trial-eligibility` [example](/docs/framework/examples) is the reference: every string forks, and both states read as designed.
## Trial reminder notifications
Declare a local notification in `config.ts` and the SDK schedules it when a trial **actually starts**. The paywall doesn't need to be open when it fires:
```ts
notifications: {
trialReminder: {
title: "Your trial ends tomorrow",
body: "Keep Pro, or cancel in Settings — no charge either way.",
beforeTrialEndDays: 1, // default 1
},
},
```
`title`, `subtitle`, and `body` accept message keys (resolved through `t()`, see [Localization](/docs/framework/localization)) or literal copy.
For full control, pass a function instead. It receives `{ trialEndDate, productIdentifier, product, t, locale }` and returns `{ title, subtitle?, body, delayMs }`, or `null` to skip the notification entirely:
```ts
notifications: {
trialReminder: ({ trialEndDate, t }) => ({
title: t("reminder.title"),
body: t("reminder.body"),
delayMs: trialEndDate.getTime() - Date.now() - 24 * 60 * 60 * 1000,
}),
},
```
`delayMs` is measured from now, and a value of `0` or less is skipped rather than fired immediately, compute it from `trialEndDate`, which is always supplied.
The `trial-reminders` [example](/docs/framework/examples) shows both forms.
> **Tip:** Users warned before the charge cancel calmly instead of charging back, and the ones who stay chose to stay.
# 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
# Troubleshooting
Common CLI errors and runtime surprises, what each one means and how to fix it.
The most common failures, in two groups: errors the CLI prints, and runtime behavior that surprises people the first time.
## CLI errors
### `Not a superwall project`
The CLI couldn't find a project from where you ran it. Run commands from your app root or from inside `superwall/`, and check that the project's `package.json` depends on `superwall`. See [Project structure](/docs/framework/project-structure).
### `…package.json is named "superwall"`
Your project's `package.json` has `"name": "superwall"`, which shadows the framework import. Nothing in the project can `import` from `superwall` anymore. Rename the package; `superwall create` names it after your app for exactly this reason.
### `No superwall framework found`
The project exists but its dependencies aren't installed, or `superwall` isn't among them. Run `bun add superwall` (or `npm install superwall`) inside the project directory.
### `This product / These N products do not exist on Superwall`
A `config.ts` names a product identifier the dashboard has no product for. The push refuses because every variable on that product would be undefined on device. Either fix the identifier, or create the products, from the dashboard, or with `superwall products create` from the CLI. See [Products](/docs/framework/products) and the [CLI reference](/docs/framework/cli).
### `Superwall for Agents is in private beta and isn't enabled for this app yet`
Pushing needs Superwall for Agents enabled on the Superwall app you're pushing to, per app, so a project on several platforms needs it on each one. Nothing in the CLI can turn it on; contact [support@superwall.com](mailto\:support@superwall.com) for access.
### `Multiple projects found. Pass --project .`
Your account has several Superwall projects, and the command can't guess which one you mean. Add `--project ` (and usually `--app `) to the command.
### Diagnostics block the push
Publishing is immutable, so a paywall with diagnostics, a stray non-page file in `app/`, a duplicate route, refuses to push. The message names each file and where it belongs. These are the same warnings `superwall dev` prints, so you'll usually have seen them before push time.
A directory missing `app/index.tsx` or `config.ts` is the exception: it isn't a paywall yet, so push skips it silently instead of failing. If a paywall you expected didn't ship, check that both files exist. `superwall dev` lists what it found.
### Rename ambiguity in CI
A renamed paywall directory can't be resolved interactively in CI, so the push stops rather than creating a duplicate. Add the `--rename old=new` flag the error prints. See [Push, promote & publish](/docs/framework/push-and-promote).
### `x doesn't say which platforms it's for`
The project pushes to several platforms, and this paywall's `config.ts` has no `platforms` field. Add `platforms: ["ios", "android"]` (or whichever apply). A push never guesses which app a paywall belongs to. See [Configuration](/docs/framework/config#platforms).
### `` `annual` has no product id for web ``
The paywall ships to a platform (here `web`) that its per-platform product slot doesn't name. Add an id for that platform (`annual: { ios: "…", android: "…", web: "…" }`) or remove the platform from `platforms`. See [Products](/docs/framework/products#one-reference-a-product-per-platform).
### `Several iOS apps in your account` / `no Web app to bind`
A `config.ts` names a platform `superwall.lock` doesn't bind yet, and your account has either several apps of that platform or none. Run `superwall push` interactively once to choose, add `"web": ""` under `apps` in `superwall.lock` yourself (`superwall apps list --json` shows ids), or create the app (`superwall apps create --platform web`). See [Several platforms](/docs/framework/push-and-promote#several-platforms).
### `This project has no app for "…"`
`--platform` takes `ios`, `android`, or `web`: one the project already binds in `superwall.lock` (or that app's id); the message lists the ones it has.
### `paywall x has never been pushed` (promote)
Promote only moves the live pointer between pushed versions. There's nothing to point at yet. Push first.
### `superwall publish requires git`
The source snapshot is part of every push, so `push` and `publish` both need git, the message names `publish` whichever you ran. Install git.
### `Pushing paywalls needs a Superwall account`
Run `superwall login` once interactively, or set `SUPERWALL_API_KEY` (an `sk_…` key) in CI. `superwall dev` needs no login.
## Runtime surprises
### Prices are example values in dev
The studio reads products from your dashboard when `superwall dev` is logged in. A slot the dashboard has no product for gets no variables, so prices render as whatever your own fallback shows — the studio will not stand in an invented price. Run `superwall login` if you haven't, then create the product on the dashboard or repoint the slot; push refuses until you do. Every read stays guarded and the unpriced state stays designed, because the SDK can still deliver a product before its data. The reading rules are in [Products](/docs/framework/products).
### A number comparison works in dev but not on device
Numeric-looking variables are **strings on a real device** (`"59.99"`, `"7"`), while a dashboard sample in the studio may carry numbers. A `typeof x === "number"` check silently fails on every phone. Coerce with `Number()` before arithmetic or comparison ([Products](/docs/framework/products)).
### My entry animation already finished when the paywall appears
The SDK preloads paywalls hidden, so components mount long before anyone is looking, a mount-timed animation plays to an empty room. Gate entry animations on presentation, not mount. See [Lifecycle & events](/docs/framework/lifecycle).
### Dark mode looks right on my machine, wrong on device
The mechanism is the `dark` class the framework maintains on ``, not `prefers-color-scheme`. A media query can't see what the device reports and ignores the studio's theme toggle. Style off the class, as shown in [Styling](/docs/framework/styling).
### My `fetch` to my own API never arrives
The server never saw it — the browser refused to send it. A published paywall runs under a Content-Security-Policy that permits Superwall and Stripe and nothing else, so a call to your own backend is blocked before any network activity: no DNS lookup, no request, nothing in your access logs. The only sign is a console error, `Refused to connect because it violates the document's Content Security Policy`, and the `fetch` promise rejecting with a bare `TypeError: Failed to fetch`.
Name the host in `allowedHosts` and push again:
```ts title="superwall/funnels/onboarding/config.ts"
export default definePaywall({
allowedHosts: ["api.example.com"],
});
```
Two things make this hard to spot. A rejected `fetch` looks exactly like a network failure, so a `catch` written for offline handling swallows it. And if the call only happens on a page shoppers reach near the end of a flow, nothing fails until then. See [Allowed hosts](/docs/framework/config#allowed-hosts).
### My link does nothing
Inside a webview, an `` 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.