# 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 `<html>`. 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
  └ <Layout>          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
        └ <Page />
```

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 `<html>` and beats a stylesheet's `:root` rule; use one or the other per edge.

### What the framework stamps on `<html>`

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.