PurchaseController

A protocol for handling Superwall's subscription-related logic with your own purchase implementation.

This protocol is not required. By default, Superwall handles all subscription-related logic automatically.

When implementing PurchaseController, you must manually update subscriptionStatus whenever the user's entitlements change.

Purpose

Use this protocol only if you want complete control over purchase handling, such as when using RevenueCat, another third-party purchase framework, or your own external billing flow.

Signature

public protocol PurchaseController: AnyObject {
  @MainActor
  func purchase(product: StoreProduct) async -> PurchaseResult
  
  @MainActor
  func restorePurchases() async -> RestorationResult
}

Parameters

Prop

Type

Returns / State

  • purchase() returns a PurchaseResult (.purchased, .failed(Error), or .cancelled)
  • restorePurchases() returns a RestorationResult (.restored or .failed(Error?))

When using a PurchaseController, you must also manage subscriptionStatus yourself.

Handling Products

  • For App Store-backed products, use product.sk1Product or product.sk2Product, or pass the product into your existing purchase SDK.
  • For custom products introduced in 4.15.0, Superwall will call your purchase controller with a StoreProduct that has no StoreKit backing product. In that case, use product.productIdentifier in your external billing system and return the matching PurchaseResult.
  • Do not call Superwall.shared.purchase(product) for custom products. That helper is for StoreKit-backed purchases only.

Monthly billing plans

iOS SDK 4.16.0 adds support for annual App Store subscriptions that are billed monthly. If you let Superwall handle purchases, no code changes are required. Superwall passes the selected billing plan to StoreKit automatically.

If your PurchaseController calls StoreKit 2 directly, pass the billing plan from StoreProduct into the StoreKit purchase options when it is available:

func purchase(product: StoreProduct) async -> PurchaseResult {
  guard let sk2Product = product.sk2Product else {
    return .cancelled
  }

  var options: Set<StoreKit.Product.PurchaseOption> = []

  if let token = product.introOfferToken {
    options.insert(.introductoryOfferEligibility(compactJWS: token.token))
  }

  if #available(iOS 26.4, macOS 26.4, tvOS 26.4, watchOS 26.4, visionOS 26.4, *),
    let billingPlanType = product.billingPlanType {
    let storeKitBillingPlan: StoreKit.Product.SubscriptionInfo.BillingPlanType

    switch billingPlanType {
    case .upFront:
      storeKitBillingPlan = .upFront
    case .monthly:
      storeKitBillingPlan = .monthly
    }

    options.insert(.billingPlanType(storeKitBillingPlan))
  }

  do {
    let result = try await sk2Product.purchase(options: options)

    switch result {
    case .success(.verified(let transaction)):
      await transaction.finish()
      return .purchased
    case .success(.unverified(_, let error)):
      return .failed(error)
    case .pending:
      return .pending
    case .userCancelled:
      return .cancelled
    @unknown default:
      return .cancelled
    }
  } catch {
    return .failed(error)
  }
}

product.billingPlanType is the billing plan that will actually be applied on the current device. It is nil when no plan is configured or when StoreKit cannot honor the selected plan, so purchase code can omit the option and let Apple use the default billing plan.

For a complete guide, see Custom Store Products.

Usage

For implementation examples and detailed guidance, see Using RevenueCat.

This is commonly used with RevenueCat, StoreKit 2, or other third-party purchase frameworks where you want to maintain your existing purchase logic.

How is this guide?

On this page