# 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: &#x2A;*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, &#x2A;*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");
<input value={name ?? ""} onChange={(event) => 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. &#x2A;*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.