# 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

# Teleport

Continue a flow on the web from a route on: an iOS app hands the shopper to their browser, they pay with Stripe there, and come back to the app with access.

Teleport moves the rest of a flow to the web. When the flow reaches a teleporting route inside an iOS app, the shopper's browser opens on that route, on your project's web paywall domain, with the flow's answers carried along. They pay with Stripe there and come back to the app, which is credited automatically.

Turn it on in `config.ts`, with every default:

```ts
export default definePaywall({
  products: { card: "live:price_1ABC…:14days-free", annual: "com.acme.pro.annual" },
  teleport: true,
});
```

Then teleport a push from the router:

```tsx
router.push("checkout", { teleport: true });
```

Or name the route in the config, so a push to it and a Stripe purchase teleport without asking, and the teleport is prepared when the paywall opens:

```ts
teleport: { route: "checkout", fallbacks: { card: "annual" } },
```

`purchase("card")` is the same call as everywhere else.

The [`teleport` example](https://github.com/superwall/superwall/tree/main/examples/teleport) is a complete flow: one question in the app, then the plan in the browser.

## What happens

**In the app.** Three things teleport:

* **A push asked to.** `router.push(route, { teleport: true })` (or `replace`) opens the route in the browser instead of pushing it, and the paywall stays where it is.
* **A push to `teleport.route`.** The same, without the option.
* **A Stripe purchase.** `purchase("card")` opens `teleport.route` in the browser, or the route it was tapped on when there is none, with `card` as the product the shopper tapped. It resolves once the teleport settles: `completed` when they paid, `abandoned` when they cancelled or the checkout expired.

While the shopper is in the browser, the app shows Superwall's **teleport screen** over the paywall: "Finish in your browser", with Complete in Browser (opens it again), Continue In-App Instead (only with a [fallback](#options)), and Cancel. The app keeps checking the purchase, in the background too. Once it is paid, the SDK redeems it and the paywall dismisses (or stays, with `postPurchase: "stay"`).

A second tap while the browser is open reopens the same tab, switched to the product tapped this time, so the shopper never ends up with two checkouts. When they come back to the app, the screen goes and the purchase is checked once more behind a loader; if it is still unpaid after a few seconds it counts as abandoned. Cancel, closing the paywall, or paying in the app keeps the checkout watched for a few minutes, so a payment the shopper finishes anyway is still credited, and the next tap reopens the same tab.

**On the web.** The same paywall opens in the browser on the teleported route. Every [`useQueryState`](/docs/framework/web-funnels) value the flow had in the app is already set, so the web page picks up where the app left off, and `useTeleport().productReference` is the product the shopper tapped. From there, navigation is ordinary:

* `router.push` moves forward on the web;
* `router.back()` and `close()` on the teleported route return to the app;
* `router.canGoBack()` is `true` on the teleported route, so a back button stays on screen.

A Stripe purchase on the web page pays in the page, with Apple Pay or the card sheet (whatever `checkout` says, Apple Pay by default), never on the hosted page, and only Stripe products are sold there. Once it completes, the page says "You're all set" with Return to App, or opens the app at once, see [`completion`](#options). The app opens through its universal link, falling back to its URL scheme when the link doesn't open it.

The page sells one purchase per presentation. Opened again once paid, it shows "You're all set" instead of selling again; and when the app opened another checkout for the same presentation and that one got paid, the page switches to say so.

## Instant opening

Creating a teleport takes a round-trip, so it is done ahead of the tap. A paywall with `teleport.route` prepares that teleport when it opens: when the SDK presents it, never while it preloads in the background. The tap then opens the browser at once, carrying any answer that changed since. For a route you reach with `router.push(route, { teleport: true })`, prepare it yourself, as you would [prefetch a checkout](/docs/framework/web-checkout#prefetch-make-checkout-open-instantly):

```tsx
const { prefetch } = useTeleport();

React.useEffect(() => {
  prefetch("plans");
}, [prefetch]);
```

A prepared teleport stays usable for ten minutes, only for the route it was prepared for, and is made again when the trial offer changed since. A trial offer that changes while the browser is open moves the open checkout to it, in the same tab.

## Options

`teleport: true` takes every default. The object form changes what you name:

| Key                            | Default          | What it does                                                                                                                                                                                                                                                                                                                 |
| ------------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `route`                        | none             | The route that teleports by default: a push to it, a Stripe purchase, and the teleport prepared when the paywall opens. Without it, a Stripe purchase teleports the route it was tapped on, and only `{ teleport: true }` teleports a push.                                                                                  |
| `overlay`                      | `true`           | Show the teleport screen in the app. `false` keeps the paywall as it is, for a flow that leaves midway (an onboarding that continues on the web), or for your own screen built with `useTeleport()`. The purchase is watched either way.                                                                                     |
| `fallbacks`                    | none             | A store product the teleport screen offers in place of a Stripe one, keyed by the Stripe product: `{ card: "annual" }`. Both sides are typed against your products. Continue In-App Instead buys it through the App Store, and the Stripe `purchase()` resolves with that purchase. Shown only once the store has priced it. |
| `completion`                   | `"confirmation"` | After a web purchase: `"confirmation"` shows "You're all set" with Return to App, `"direct"` opens the app at once.                                                                                                                                                                                                          |
| `introductoryOfferEligibility` | `"automatic"`    | Whether the web checkout offers the trial: `"automatic"` when the App Store says the shopper is eligible, `"eligible"` always, `"ineligible"` never. The App Store's trial history doesn't bind a Stripe purchase, so this is separate from the paywall's own [`introductoryOfferEligibility`](/docs/framework/config).      |

## Your own teleport screen

The teleport screen and the "You're all set" screen are `TeleportScreen`, which the build mounts for any paywall with `teleport`. Set `overlay: false` and draw your own from `useTeleport()`. Its `status` turns `"waiting"` exactly when the built-in screen would go up (once the app is in the background, after a moment if it never is, or when the SDK asks just before it opens the browser), and stays `"waiting"` until the purchase settles, so your screen behaves like Superwall's. Once the shopper is back in the app, `returned` turns `true` while the purchase is checked one last time:

```tsx
import { usePurchase, useTeleport } from "superwall/hooks";

const teleport = useTeleport();
const { purchase } = usePurchase();
if (teleport.status !== "waiting" || teleport.returned) return null;
const fallback = teleport.fallbackReference;
return (
  <Sheet>
    <p>Finish your purchase in Safari.</p>
    <Button onPress={teleport.open}>Open Safari</Button>
    {fallback ? <Button onPress={() => purchase(fallback)}>Pay with Apple instead</Button> : null}
    <Button onPress={teleport.cancel}>Cancel</Button>
  </Sheet>
);
```

`status` is one of:

| `status`      | Where                                           | Carries                                                                             |
| ------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------- |
| `"idle"`      | anywhere                                        | nothing                                                                             |
| `"waiting"`   | in the app, while the shopper is in the browser | `productReference`, `fallbackReference`, `returned`                                 |
| `"arrived"`   | on the web page, before the purchase            | `productReference`                                                                  |
| `"completed"` | on the web page, once paid                      | `productReference`, `paidElsewhere` (another checkout of the presentation was paid) |

Buying the fallback (or any store product) while the teleport is in flight hands it over: the Stripe `purchase()` resolves with the in-app purchase. On the web page, `returnToApp()` goes back. To tell the teleport page from any other web page, read [`useDevice().host`](/docs/framework/variables#usedevice), which is `"web-teleport"` there.

To follow what happens, for example to count how often shoppers cancel, pass callbacks:

```tsx
const [cancels, setCancels] = React.useState(0);
useTeleport({
  onOpen: () => {},                          // the browser opened, the first time or again
  onReturn: () => {},                        // the shopper came back to the app mid-purchase
  onCancel: () => setCancels((n) => n + 1),  // the shopper tapped Cancel
  onComplete: () => {},                      // paid on the web, being redeemed
});
```

`onCancel` is the Cancel button only. A teleport that ends unpaid any other way, the shopper back without paying or the checkout expired, resolves its `purchase()` as `abandoned` without calling `onCancel`.

## Requirements and fallbacks

* **SuperwallKit 4.18.0 or later.** On an older SDK, and on Android, nothing teleports: the route is pushed in the app as usual, and a Stripe purchase opens Superwall's hosted checkout instead, see [Web checkout](/docs/framework/web-checkout#inside-an-ios-app).
* **A web paywall domain** on the project's Stripe app. Teleport is refused without one; the purchase resolves `failed`.
* The app's **URL scheme** (and its team and bundle id, for the universal link) on the dashboard, so the web page can open the app again. Without them the "You're all set" screen tells the shopper to go back on their own.
* `superwall dev` doesn't teleport: a preview has no API key. Push and open the paywall from the app to try it.

## Attribution

A teleported purchase is attributed to the paywall the app showed, exactly as with the editor's checkout page: the presentation the SDK reported, its placement and experiment, and the shopper's identity and device, all recorded when the teleport is created. Checkouts opened on the web page inherit them from the teleport.