# 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 (private beta) 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).