# 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

# Using the Presentation Handler

You can provide a `PaywallPresentationHandler` to `register`, whose functions provide status updates for a paywall:

* `onDismiss`: Called when the paywall is dismissed. Accepts a `PaywallInfo` object containing info about the dismissed paywall, and there is a `PaywallResult` informing you of any transaction.
* `onPresent`: Called when the paywall did present. Accepts a `PaywallInfo` object containing info about the presented paywall.
* `onError`: Called when an error occurred when trying to present a paywall. Accepts an `Error` indicating why the paywall could not present.
* `onSkip`: Called when a paywall is skipped. Accepts a `PaywallSkippedReason` enum indicating why the paywall was skipped.
* `onCustomCallback&#x60; &#x2A;(Android 2.7.0+)*: Called when the paywall requests a custom callback. Accepts a `CustomCallback` containing the callback name and optional variables, and returns a `CustomCallbackResult` indicating success or failure with optional data to pass back to the paywall.

## Tab

```swift Swift
let handler = PaywallPresentationHandler()
handler.onDismiss { paywallInfo, result in
  print("The paywall dismissed. PaywallInfo: \(paywallInfo). Result: \(result)")
}
handler.onPresent { paywallInfo in
  print("The paywall presented. PaywallInfo:", paywallInfo)
}
handler.onError { error in
  print("The paywall presentation failed with error \(error)")
}
handler.onSkip { reason in
  switch reason {
  case .holdout(let experiment):
    print("Paywall not shown because user is in a holdout group in Experiment: \(experiment.id)")
  case .noAudienceMatch:
    print("Paywall not shown because user doesn't match any audiences.")
  case .placementNotFound:
    print("Paywall not shown because this placement isn't part of a campaign.")
  }
}

Superwall.shared.register(placement: "campaign_trigger", handler: handler) {
  // Feature launched
}
```

## Tab

```swift Objective-C
SWKPaywallPresentationHandler *handler = [[SWKPaywallPresentationHandler alloc] init];

[handler onDismiss:^(SWKPaywallInfo * _Nonnull paywallInfo,
                         enum SWKPaywallResult result,
                         SWKStoreProduct * _Nullable product) {
  NSLog(@"The paywall presented. PaywallInfo: %@ - result: %ld", paywallInfo, (long)result);
}];

[handler onPresent:^(SWKPaywallInfo * _Nonnull paywallInfo) {
  NSLog(@"The paywall presented. PaywallInfo: %@", paywallInfo);
}];

[handler onError:^(NSError * _Nonnull error) {
  NSLog(@"The paywall presentation failed with error %@", error);
}];

[handler onSkip:^(enum SWKPaywallSkippedReason reason) {
  switch (reason) {
    case SWKPaywallSkippedReasonUserIsSubscribed:
      NSLog(@"Paywall not shown because user is subscribed.");
      break;
    case SWKPaywallSkippedReasonHoldout:
      NSLog(@"Paywall not shown because user is in a holdout group.");
      break;
    case SWKPaywallSkippedReasonNoAudienceMatch:
      NSLog(@"Paywall not shown because user doesn't match any audiences.");
      break;
    case SWKPaywallSkippedReasonPlacementNotFound:
      NSLog(@"Paywall not shown because this placement isn't part of a campaign.");
      break;
    case SWKPaywallSkippedReasonNone:
      // The paywall wasn't skipped.
      break;
  }
}];

[[Superwall sharedInstance] registerWithPlacement:@"campaign_trigger" params:nil handler:handler feature:^{
  // Feature launched.
}];
```

## Tab

```kotlin Kotlin
val handler = PaywallPresentationHandler()
handler.onDismiss { paywallInfo, result ->
  println("The paywall dismissed. PaywallInfo: ${it}")
}
handler.onPresent {
  println("The paywall presented. PaywallInfo: ${it}")
}
handler.onError {
  println("The paywall errored. Error: ${it}")
}
handler.onSkip {
  when (it) {
    is PaywallSkippedReason.PlacementNotFound -> {
      println("The paywall was skipped because the placement was not found.")
    }
    is PaywallSkippedReason.Holdout -> {
      println("The paywall was skipped because the user is in a holdout group.")
    }
    is PaywallSkippedReason.NoAudienceMatch -> {
      println("The paywall was skipped because no audience matched.")
    }
  }
}

Superwall.instance.register(placement = "campaign_trigger", handler = handler) {
    // Feature launched
}
```

## Tab

```dart Flutter
PaywallPresentationHandler handler = PaywallPresentationHandler();
handler.onPresent((paywallInfo) async {
  String name = await paywallInfo.name;
  print("Handler (onPresent): $name");
});
handler.onDismiss((paywallInfo, paywallResult) async {
  String name = await paywallInfo.name;
  print("Handler (onDismiss): $name");
});
handler.onError((error) {
  print("Handler (onError): ${error}");
});
handler.onSkip((skipReason) async {
  String description = await skipReason.description;

  if (skipReason is PaywallSkippedReasonHoldout) {
    print("Handler (onSkip): $description");

    final experiment = await skipReason.experiment;
    final experimentId = await experiment.id;
    print("Holdout with experiment: ${experimentId}");
  } else if (skipReason is PaywallSkippedReasonNoAudienceMatch) {
    print("Handler (onSkip): $description");
  } else if (skipReason is PaywallSkippedReasonPlacementNotFound) {
    print("Handler (onSkip): $description");
  } else {
    print("Handler (onSkip): Unknown skip reason");
  }
});

Superwall.shared.registerPlacement("campaign_trigger", handler: handler, feature: () {
  // Feature launched
});
```

## Tab

```typescript React Native
const handler = new PaywallPresentationHandler()
handler.onPresent((paywallInfo) => {
  const name = paywallInfo.name
  console.log(`Handler (onPresent): ${name}`)
})
handler.onDismiss((paywallInfo, paywallResult) => {
  const name = paywallInfo.name
  console.log(`Handler (onDismiss): ${name}`)
})
handler.onError((error) => {
  console.log(`Handler (onError): ${error}`)
})
handler.onSkip((skipReason) => {
  const description = skipReason.description

  if (skipReason instanceof PaywallSkippedReasonHoldout) {
    console.log(`Handler (onSkip): ${description}`)
    const experiment = skipReason.experiment
    const experimentId = experiment.id
    console.log(`Holdout with experiment: ${experimentId}`)
  } else if (skipReason instanceof PaywallSkippedReasonNoAudienceMatch) {
    console.log(`Handler (onSkip): ${description}`)
  } else if (skipReason instanceof PaywallSkippedReasonPlacementNotFound) {
    console.log(`Handler (onSkip): ${description}`)
  } else {
    console.log(`Handler (onSkip): Unknown skip reason`)
  }
})

Superwall.shared.register({
  placement: 'campaign_trigger',
  handler: handler,
  feature: () => {
    // Feature launched
  }
});
```

:::expo
```tsx React Native
import { usePlacement } from "expo-superwall";
import { Button } from "react-native";

function PaywallButton() {
  const { registerPlacement } = usePlacement({
    onPresent: (paywallInfo) => {
      console.log(`Handler (onPresent): ${paywallInfo.name}`);
    },
    onDismiss: (paywallInfo, paywallResult) => {
      console.log(`Handler (onDismiss): ${paywallInfo.name}`);
      // Check the result to see if user purchased
      console.log(`Result:`, paywallResult);
    },
    onError: (error) => {
      console.log(`Handler (onError): ${error}`);
    },
    onSkip: (skipReason) => {
      console.log(`Handler (onSkip):`, skipReason);
    },
  });

  const handlePress = async () => {
    await registerPlacement({
      placement: 'campaign_trigger',
      feature: () => {
        // Feature launched
        console.log("Feature unlocked!");
      },
    });
  };

  return <Button onPress={handlePress} title="Show Paywall" />;
}
```
:::

> **Tip:** Wanting to see which product was just purchased from a paywall? Use `onDismiss` and the `result`
> parameter. Or, you can use the
> [SuperwallDelegate](/docs/sdk/guides/3rd-party-analytics#using-events-to-see-purchased-products).