# 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

# Customer Center

Give users a native screen to manage subscriptions, restore purchases, request refunds, and contact support.

The Customer Center is a self-service screen for managing purchases. Present it in whichever way fits your app:

## Tab

```swift Anywhere
// Works from UIKit and SwiftUI alike. Presents over your
// top-most view controller, so there's nothing to wire up.
Superwall.shared.presentCustomerCenter()
```

## Tab

```swift UIKit
// Or present the view controller yourself, which lets you
// push it onto a navigation stack you own.
let customerCenter = CustomerCenterViewController()
present(customerCenter, animated: true)
```

## Tab

```swift SwiftUI
// Or use the modifier to present it as a sheet.
struct SettingsView: View {
  @State private var showsCustomerCenter = false

  var body: some View {
    Button("Manage Subscription") {
      showsCustomerCenter = true
    }
    .presentSuperwallCustomerCenter(isPresented: $showsCustomerCenter)
  }
}
```

Users can view their subscriptions and purchases, restore purchases, cancel or manage a subscription, request a refund, change plans, contact support, and open any subscription for its details. It works for App Store purchases and [web checkout](/docs/ios/guides/web-checkout) purchases alike, and every string ships localized in 41 locales.

![The Customer Center management screen with a Pro purchase marked Active, a Restore purchases action, a See all purchases link, and account details](https://superwall.com/docs/images/customer_center_management.jpg)

> **Note:** The Customer Center requires &#x2A;*iOS 15+** and SDK version 4.18.0 or later. The SDK's deployment target is unchanged, so you can ship it in apps that support older iOS versions. The presenting APIs are marked `@available(iOS 15.0, *)`, so if your deployment target is below iOS 15, wrap those calls in an availability check and annotate any type that conforms to `CustomerCenterDelegate`. `CustomerCenterConfiguration` and the other configuration types are not gated, so you can build your configuration unconditionally.

It works with zero configuration. The management screen lists each subscription as a row that opens its own detail screen, alongside Restore purchases and account details. An active entitlement with no transaction behind it also opens a detail screen. One-time purchases appear as cards without one. Change plan, Request a refund, and Cancel subscription (with a cancellation survey) each act on one subscription, so they live on that subscription's screen rather than the management screen. The only row that needs configuration is **Contact support**, which stays hidden until you set a support email.

## Presenting the Customer Center

### From anywhere

Call `presentCustomerCenter` on the shared instance. It presents over the top-most view controller:

```swift
Superwall.shared.presentCustomerCenter()
```

You can pass a configuration, a presenting view controller, a delegate, and a dismiss handler:

```swift
Superwall.shared.presentCustomerCenter(
  configuration: nil,      // overrides SuperwallOptions.customerCenter for this presentation
  from: nil,               // presents from the top-most view controller when nil
  delegate: myDelegate,    // retained for the duration of the presentation
  onDismiss: {
    print("Customer Center dismissed")
  }
)
```

Only one Customer Center can be presented at a time. Calling `presentCustomerCenter` while one is already on screen does nothing. To dismiss it programmatically:

```swift
Superwall.shared.dismissCustomerCenter()
```

### From SwiftUI

The `presentSuperwallCustomerCenter` modifier presents it as a sheet, and takes an optional configuration and dismiss handler:

```swift
.presentSuperwallCustomerCenter(
  isPresented: $showsCustomerCenter,
  configuration: nil,   // overrides SuperwallOptions.customerCenter for this presentation
  onDismiss: {
    print("Customer Center dismissed")
  }
)
```

Or embed `CustomerCenterView` directly in your own navigation stack. Pass `usesExistingNavigation` so it renders into your stack instead of wrapping itself in its own:

```swift
NavigationStack {
  CustomerCenterView(navigationOptions: .init(usesExistingNavigation: true))
}
```

### From UIKit

`CustomerCenterViewController` gives you direct control. Present it modally:

```swift
let customerCenter = CustomerCenterViewController(delegate: myDelegate)
present(customerCenter, animated: true)
```

Or push it onto your own navigation controller, which is what you want when the Customer Center is a row in your settings screen:

```swift
let customerCenter = CustomerCenterViewController(
  presentationStyle: .pushed,
  delegate: myDelegate
)
navigationController?.pushViewController(customerCenter, animated: true)
```

A pushed Customer Center renders into your navigation bar and leaves it alone: your title, your back button, your appearance, your swipe-to-go-back. It adds no close button, since your stack already provides the way back. Its own screen, the detail for a subscription, is pushed onto your stack as a further view controller, so it behaves like any other screen you pushed yourself.

### From Objective-C

```objc
[Superwall.sharedInstance presentCustomerCenterWithConfiguration:nil
                                                            from:nil
                                                        delegate:myDelegate
                                                       onDismiss:nil];
```

## Customizing the Customer Center

Configuration lives in code via [`SuperwallOptions`](/docs/ios/sdk-reference/SuperwallOptions). Set it before calling `configure`:

```swift
let options = SuperwallOptions()

let cancelSurvey = CustomerCenterConfiguration.FeedbackSurvey(
  id: "cancel_survey",
  title: "Why are you cancelling?",
  options: [
    .init(id: "too_expensive", title: "Too expensive"),
    .init(id: "dont_use", title: "Don't use it enough"),
    .init(id: "bought_by_mistake", title: "Bought by mistake")
  ]
)

options.customerCenter = CustomerCenterConfiguration(
  managementScreen: .init(
    paths: [
      .init(id: "restore", type: .restore),
      .init(id: "change_plan", type: .changePlan()),
      .init(id: "refund", type: .refund()),
      .init(id: "manage_subscription", type: .manageSubscription, survey: cancelSurvey),
      .init(id: "faq", type: .url(URL(string: "https://mycompany.com/faq")!, title: "FAQ", openMethod: .inApp)),
      .init(id: "contact_support", type: .contactSupport)
    ]
  ),
  noPurchasesScreen: .init(
    paths: [.init(id: "restore", type: .restore)]
  ),
  support: .init(email: "support@mycompany.com")
)

Superwall.configure(apiKey: "MY_API_KEY", options: options)
```

You can also pass a `CustomerCenterConfiguration` directly to any presentation call to override the options value for that presentation only. The resolution order is: the per-call argument, otherwise `SuperwallOptions.customerCenter`, which itself starts out as `CustomerCenterConfiguration.default`.

> **Tip:** A configuration you supply **replaces** the default rather than merging with it, so the screen shows exactly what you list and nothing else. Start from `CustomerCenterConfiguration.default` and adjust it if you'd rather keep the defaults.

There are two screens to configure. The **management screen** shows when the user has at least one subscription or purchase, and the **no purchases screen** shows when they have none. Each screen has an optional title, an optional subtitle, and an ordered list of paths. Leave a title as `nil` to use the localized default.

### Paths

A path is an action row. Every path is optional and reorderable, and each has a stable `id` reported as `path_id` in [events](#events). Delegate callbacks receive the `CustomerCenterAction` rather than the path id.

| Path type                   | What it does                                                                                                                                                               |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.restore`                  | Restores purchases, with a progress overlay and a result alert.                                                                                                            |
| `.manageSubscription`       | Opens Apple's manage subscriptions sheet for App Store subscriptions, or your web management page for web subscriptions. The default row is labeled "Cancel subscription". |
| `.refund(window:)`          | Opens Apple's refund request sheet. Pass `window` (seconds since purchase) to only offer refunds within a time limit.                                                      |
| `.changePlan(productIds:)`  | Opens Apple's subscription store for the subscription's group so the user can switch plans. Pass `productIds` to offer a subset of the group. Requires iOS 17+.            |
| `.contactSupport`           | Opens a pre-filled support email. Hidden unless `support.email` is set.                                                                                                    |
| `.url(_:title:openMethod:)` | Opens any URL, either `.inApp` (Safari view) or `.external` (leaves the app). `title` is required: every other path type has a localized default name, and a URL has none. |
| `.custom(identifier:)`      | Does nothing itself. Handle it yourself in the delegate or the `onCustomerCenterAction` modifier.                                                                          |

`.refund`, `.changePlan`, and `.manageSubscription` act on a single subscription, so they appear on a subscription's detail screen rather than the management screen. Paths only appear when they make sense for the user's purchases, and you don't have to gate any of this yourself. `.refund` and `.changePlan` show only for App Store subscriptions, never for family-shared ones, and `.changePlan` is hidden below iOS 17. `.refund` is additionally hidden during a free trial, on an already-refunded purchase, and for free products. `.manageSubscription` for an App Store subscription shows only while it's active and set to renew.

When no path applies to a purchase, its detail screen shows a line of text instead of an action list. A subscription the same user bought through your Android app reads "Manage this subscription through Google Play." One that's still active through any other store the SDK can't manage reads "Manage this subscription where you bought it." Anything else reads "There's nothing to manage for this purchase."

### Surveys

Attach a `FeedbackSurvey` to any path and it's shown before the action runs, except when the path resolves to a web management page, where the survey is skipped. The default configuration attaches a cancellation survey to the manage subscription path:

![The Customer Center cancellation survey sheet asking &#x22;Why are you cancelling?&#x22; with the options Too expensive, Don](https://superwall.com/docs/images/customer_center_survey.jpg)

Survey answers are reported through the [`customerCenter_surveyResponse` event](#events), the delegate, and the `onCustomerCenterSurveyResponse` modifier, so you can act on them however you like. The built-in option ids (`too_expensive`, `dont_use`, `bought_by_mistake`) come pre-localized; custom options use the titles you provide.

### Support options

`support` controls the contact path and a few related behaviors:

```swift
options.customerCenter.support = .init(
  email: "support@mycompany.com",   // enables the Contact support path
  latestAppVersion: nil,            // set to control the update banner yourself
  shouldWarnToUpdate: true,         // shows an update banner for outdated installs
  checksAppStoreForUpdates: true,   // looks up the published version automatically
  webManagementURL: nil             // overrides the web subscription management page
)
```

The support email opens the user's mail app with a message pre-filled with diagnostics: their user ID, app version, OS version, device model, SDK version, active entitlements, and whether they're in the sandbox. If no mail app is available, the Customer Center shows the address so the user can still reach you.

### Prompting users to update

The Customer Center can show a banner asking the user to update your app, which is useful when someone contacts support about a bug you've already fixed. By default it looks your app up on the App Store, compares against the installed version, and shows the banner only when the installed version is older. The lookup result is cached for 24 hours, and the check is skipped entirely on TestFlight, sandbox, and simulator builds, whose versions are normally ahead of the App Store.

Set `latestAppVersion` to skip the lookup and control the version yourself:

```swift
options.customerCenter.support = .init(
  email: "support@mycompany.com",
  latestAppVersion: "2.1.0"
)
```

> **Warning:** If you ship a release using [phased release](https://developer.apple.com/help/app-store-connect/update-your-app/release-a-version-update-in-phases/), the lookup sees the new version as soon as it goes live, so the banner can appear for users your rollout hasn't reached yet. Phased-release builds can be downloaded manually at any time, so tapping **Update** will get them the new version, which may drive adoption faster than your rollout schedule intends. To keep the banner on your own schedule, set `latestAppVersion`, or set `checksAppStoreForUpdates` to `false`.

### Appearance

By default the Customer Center uses system colors and matches light and dark mode automatically. Set an accent color with a light and dark hex pair to tint the screen's controls:

```swift
options.customerCenter.appearance = .init(
  accent: .init(light: "#0A84FF", dark: "#64B5FF")
)
```

### Other options

```swift
options.customerCenter.showsAccountDetails = true             // user ID and original download date
options.customerCenter.warnsAboutDuplicateSubscriptions = true // App Store + web double-subscription banner
```

## Web checkout purchases

Subscriptions purchased through [web checkout](/docs/ios/guides/web-checkout) appear in the Customer Center alongside App Store purchases, labeled with the store they came from: Stripe and Paddle purchases show a "Web" badge, and Superwall-store purchases show "Superwall". For all of them, the manage subscription path sends the user to their web management page instead of Apple's sheet. The SDK derives that page automatically for Superwall-hosted checkouts; set `support.webManagementURL` to point somewhere else. If no management page can be resolved, the row stays visible and tells the customer to use the link in their emailed receipt.

When the user is actively subscribed both on the web and through the App Store, the Customer Center shows a banner warning them they may be paying twice, so they can cancel one. Disable this with `warnsAboutDuplicateSubscriptions = false`.

## Reacting to what users do

### The delegate

Implement `CustomerCenterDelegate` (or `SWKCustomerCenterDelegate` from Objective-C) to observe and gate Customer Center behavior. Every method has a default implementation, so implement only what you need:

```swift
final class MyCustomerCenterDelegate: CustomerCenterDelegate {
  func customerCenter(shouldRestorePurchases resume: @escaping (Bool) -> Void) {
    // Gate restores, e.g. require sign-in first. Call resume(false) to cancel.
    resume(true)
  }

  func customerCenter(didSelect action: CustomerCenterAction, for purchase: SubscriptionTransaction?) {
    if case .custom(let identifier) = action {
      // Handle your .custom paths here.
      print("Custom path tapped: \(identifier)")
    }
  }

  func customerCenter(didCompleteSurvey surveyId: String, optionId: String, for action: CustomerCenterAction) {
    print("Survey \(surveyId) answered with \(optionId)")
  }

  func customerCenter(didCompleteRefundRequestFor productId: String, status: CustomerCenterRefundStatus) {
    print("Refund request for \(productId): \(status)")
  }

  func customerCenterDidDismiss() {
    print("Customer Center dismissed")
  }
}
```

The Customer Center does not retain your delegate. Keep a strong reference to it yourself, or pass it to `Superwall.shared.presentCustomerCenter(delegate:)`, which retains it while the Customer Center is presented.

### SwiftUI modifiers

In SwiftUI, use modifiers instead of a delegate. Attach them to the view that presents the Customer Center, or to an embedded `CustomerCenterView`:

```swift
CustomerCenterView()
  .onCustomerCenterShouldRestore { resume in resume(true) }
  .onCustomerCenterAction { action, purchase in print(action) }
  .onCustomerCenterSurveyResponse { surveyId, optionId, action in print(surveyId, optionId) }
  .onCustomerCenterRefundRequest { productId, status in print(productId, status) }
  .onCustomerCenterDismiss { print("dismissed") }
```

## Events

The Customer Center tracks five [`SuperwallEvent`](/docs/ios/sdk-reference/SuperwallEvent) cases. Observe them via the [Superwall delegate](/docs/ios/guides/using-superwall-delegate) to forward them to your analytics, and see them in your Superwall dashboard like any other event:

| Event                           | Fires when                        | Parameters                                                                                   |
| ------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------- |
| `customerCenter_open`           | The Customer Center is presented. | `screen` (`management` or `no_purchases`), `presentation` (`sheet`, `pushed`, or `embedded`) |
| `customerCenter_close`          | The Customer Center is dismissed. |                                                                                              |
| `customerCenter_action`         | The user taps a path.             | `action`, `path_id`, `product_id`, plus `url` or `custom_identifier` when relevant           |
| `customerCenter_surveyResponse` | The user answers a survey.        | `survey_id`, `option_id`, `action`, `path_id`, `product_id`                                  |
| `customerCenter_refundRequest`  | A refund request finishes.        | `product_id`, `status` (`success`, `user_cancelled`, or `error`)                             |

## Localization

All Customer Center strings ship localized in 41 locales, including regional variants such as `en_GB`, `pt_BR`, and `zh_Hant`. The locale follows the device, or [`SuperwallOptions.localeIdentifier`](/docs/ios/sdk-reference/SuperwallOptions) when you've set it, including dates. Any titles you set in the configuration are used verbatim, so localize custom titles yourself.

## Limitations

* Requires iOS 15+, and the change plan path requires iOS 17+.
* A purchase whose product has no display name is titled with the entitlement it unlocks, or left untitled. It is never titled with the product identifier. Today that applies to every web purchase, because the product catalog does not return a name yet. An App Store product has one once it's localized in App Store Connect. Until then StoreKit reports an empty name, which is handled the same way.
* Promotional and win-back offers are not yet supported as a path type.
* The Customer Center is configured in code today. Remote configuration from the Superwall dashboard is coming.

## FAQ

## Can I present the Customer Center modally or in a navigation stack?

Both, from either UI framework.**UIKit, modally:**```swift
let customerCenter = CustomerCenterViewController()
present(customerCenter, animated: true)
```**UIKit, pushed onto your navigation stack:**```swift
let customerCenter = CustomerCenterViewController(presentationStyle: .pushed)
navigationController?.pushViewController(customerCenter, animated: true)
```**SwiftUI, modally:**```swift
Button("Manage Subscription") {
  showsCustomerCenter = true  // @State private var showsCustomerCenter = false
}
.presentSuperwallCustomerCenter(isPresented: $showsCustomerCenter)
```**SwiftUI, pushed onto your navigation stack:**```swift
NavigationLink(
  destination: CustomerCenterView(
    navigationOptions: .init(usesExistingNavigation: true, showsCloseButton: false)
  )
) {
  Text("Manage Subscription")
}
```Pushed, it renders into your navigation bar and leaves it alone, so turn off the close button and let your stack's back button do the work. See [Presenting the Customer Center](#presenting-the-customer-center).

## Can I customize the cancellation survey questions?

Yes. Attach a `FeedbackSurvey` with your own question and options to any path. See [Surveys](#surveys).

## Where do survey responses show up?

They reach your delegate (or the `onCustomerCenterSurveyResponse` modifier), and fire the `customerCenter_surveyResponse` event with the survey id, the option chosen, and the product. See [Events](#events).

## Do web purchases show up in the Customer Center?

Yes, alongside App Store purchases, badged with the store they came from ("Web" for Stripe and Paddle, "Superwall" for the Superwall store). Managing one opens your web management page rather than Apple's sheet. See [Web checkout purchases](#web-checkout-purchases).

## Which purchases show up in the Customer Center?

From Superwall's [`CustomerInfo`](/docs/ios/sdk-reference/customerInfo), the same data behind `Superwall.shared.customerInfo`. That covers StoreKit subscriptions and one-time purchases, purchases made through [web checkout](/docs/ios/guides/web-checkout), and any active entitlement Superwall knows about, including ones granted outside a store. Names and prices come from StoreKit, falling back to your Superwall product catalog for web products StoreKit can't resolve. Repeat renewals of the same subscription collapse into a single row.

## Can I change which options appear?

Yes. Every path is optional and reorderable, and you can retitle any of them. See [Customizing the Customer Center](#customizing-the-customer-center).

## What happens when someone subscribes in more than one subscription group?

Both subscriptions appear, one row each. Every subscription opens its own screen, whether there is one or several, and Change plan, Request a refund, and Cancel subscription live there, acting only on that subscription and opening only its group. See [Paths](#paths).

## Why isn't Contact support showing?

It stays hidden until you set a support email. See [Support options](#support-options).

## Why isn't Request a refund showing?

It lives on a subscription's own screen, so open the subscription from the management screen first. It needs an App Store purchase that isn't family-shared, and it's hidden during a free trial, on a purchase that was already refunded, and for free products. If you set a `window` on the path, it's also hidden once that long has passed since the purchase. See [Paths](#paths).

## Why isn't Change plan showing?

It lives on a subscription's own screen, so open the subscription from the management screen first. It needs iOS 17+ and an active, auto-renewing App Store subscription that belongs to a subscription group. It's also hidden for family-shared subscriptions. See [Paths](#paths).

## Why doesn't the update banner appear while I'm testing?

The App Store version check is skipped on TestFlight, sandbox, and simulator builds, whose versions are usually ahead of what's published. Set `latestAppVersion` to see the banner while testing. See [Prompting users to update](#prompting-users-to-update).

## Can I use test mode to try the Customer Center?

Not usefully. A [test mode](/docs/ios/guides/test-mode) purchase grants entitlements without creating a transaction behind them, so the screen shows a single card titled with the entitlement id, no price, and a status of "Active". Cancel subscription, Change plan, and Request a refund all need a subscription transaction to act on, so none of them appear. Test mode also replaces customer info wholesale, so real purchases are hidden while it is active. Use a StoreKit configuration file, a sandbox account, or a redeemed web purchase instead. See [Test mode and the Customer Center](/docs/ios/guides/test-mode#test-mode-and-the-customer-center).