# 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 and Subscription Status

Own your purchase logic with a PurchaseController.

> **Warning:** **Beta**The KMP SDK is in beta and its API may change between releases.

By default Superwall handles purchases and subscription status for you, and most apps should leave it that way. If you already have purchase logic (your own billing stack, or a provider like RevenueCat), you can take it over with a `PurchaseController`.

> **Note:** Passing a `PurchaseController` means **you** own subscription status. Superwall will not set it for
> you, so you must set `Superwall.subscriptionStatus` yourself after every purchase, restore, and app
> launch.

## The interface

One interface covers both stores. Each platform invokes only its own store's method, so you implement all three and only two ever run on a given device.

```kotlin
import com.superwall.sdk.kmp.PurchaseController
import com.superwall.sdk.kmp.models.results.PurchaseResult
import com.superwall.sdk.kmp.models.results.RestorationResult

class MyPurchaseController : PurchaseController {

    override suspend fun purchaseFromAppStore(productId: String): PurchaseResult {
        // Your StoreKit logic
        return PurchaseResult.Purchased
    }

    override suspend fun purchaseFromGooglePlay(
        productId: String,
        basePlanId: String?,
        offerId: String?,
    ): PurchaseResult {
        // Your Play Billing logic
        return PurchaseResult.Purchased
    }

    override suspend fun restorePurchases(): RestorationResult {
        // Your restore logic
        return RestorationResult.Restored
    }
}
```

All three are `suspend` functions, so you can do the real asynchronous work inline without callbacks.

> **Note:** `purchaseFromGooglePlay` takes `basePlanId` and `offerId` alongside the product id, because Play
> models subscriptions with base plans and offers. iOS has no equivalent, which is why the two entry
> points are separate rather than one method with platform-shaped arguments.

## Handling every case

`PurchaseResult` is a sealed interface, so handle all four:

| Result          | When                                                                                       |
| --------------- | ------------------------------------------------------------------------------------------ |
| `Purchased`     | The product was purchased                                                                  |
| `Cancelled`     | The user cancelled. StoreKit 2's `.userCancelled`, or RevenueCat's `userCancelled == true` |
| `Pending`       | Awaiting action. StoreKit 1's `.deferred`, or RevenueCat's `paymentPendingError`           |
| `Failed(error)` | Anything else                                                                              |

```kotlin
override suspend fun purchaseFromAppStore(productId: String): PurchaseResult {
    return try {
        when (val outcome = myStore.purchase(productId)) {
            is Success -> PurchaseResult.Purchased
            is UserCancelled -> PurchaseResult.Cancelled
            is Deferred -> PurchaseResult.Pending
            is Error -> PurchaseResult.Failed(outcome.message)
        }
    } catch (e: Exception) {
        PurchaseResult.Failed(e.message ?: "Unknown error")
    }
}
```

There are convenience factories if you prefer them: `PurchaseResult.purchased()`, `.cancelled()`, `.pending()`, `.failed(error)`.

`RestorationResult` has two cases, `Restored` and `Failed(error)`.

> **Warning:** `RestorationResult.Restored` means the restore completed **without errors**, not that the user has
> an active subscription. Set subscription status from the entitlements you actually resolved, not
> from the fact that restore succeeded.

## Wire it up

Pass the controller at configure time:

```kotlin
Superwall.configure(
    apiKey = "pk_your_api_key",
    purchaseController = MyPurchaseController(),
)
```

> **Note:** A second `configure` call will **not** install a different purchase controller, because repeat calls are
> a no-op. Set it on the first call.

## Keep subscription status current

This is the part that is easy to forget. After any purchase, restore, or launch-time entitlement check, tell Superwall what you found:

```kotlin
import com.superwall.sdk.kmp.models.entitlements.Entitlement
import com.superwall.sdk.kmp.models.entitlements.SubscriptionStatus

fun syncSubscriptionStatus(activeEntitlementIds: Set<String>) {
    Superwall.subscriptionStatus = if (activeEntitlementIds.isEmpty()) {
        SubscriptionStatus.Inactive
    } else {
        SubscriptionStatus.Active(
            activeEntitlementIds.map { Entitlement(id = it) }.toSet(),
        )
    }
}
```

`Entitlement` requires only an `id`; the remaining fields have defaults.

> **Warning:** Do not leave the status at `SubscriptionStatus.Unknown` once you know the answer. Gated paywalls
> and your own UI both branch on it.

## Consumables on Android

Play Billing requires consuming a purchase before the same product can be bought again:

```kotlin
val token = Superwall.consume(purchaseToken)
```

This is an **Android** operation. On iOS it echoes the token back unchanged, so it is safe to call from shared code without a platform check.

## Restoring

`Superwall.restorePurchases()` routes through your controller's `restorePurchases()` when one is configured, and through the native SDK otherwise:

```kotlin
when (val result = Superwall.restorePurchases()) {
    is RestorationResult.Restored -> println("Restored")
    is RestorationResult.Failed -> println("Failed: ${result.error}")
}
```

Failure stays in the return type and does not throw.

## Observing purchases you did not make

If you want Superwall to see transactions that happen outside of a paywall without taking over purchasing entirely, skip the controller and set an option instead:

```kotlin
Superwall.configure(
    apiKey = "pk_your_api_key",
    options = SuperwallOptions(shouldObservePurchases = true),
)
```