# 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();

<button
  onClick={async () => {
    haptics.light();
    const result = await purchase("annual");
    if (result.status === "completed") haptics.success();
  }}
>
  Subscribe
</button>
```

## 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();

<button
  onClick={async () => {
    haptics.light();
    const result = await restore();
    if (result.status === "failed") setMessage("We couldn't find a purchase to restore.");
  }}
>
  Restore purchases
</button>
```

`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 — &#x2A;"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).