Custom Store Products

Sell products from non-App Store billing systems on iOS paywalls using a PurchaseController.

Custom Store Products let an iOS paywall sell products that are not backed by StoreKit. Use them when the checkout is owned by your app, your server, Stripe, a web flow, or another billing system, but you still want the product to appear on a Superwall paywall with product variables, trial eligibility, and purchase tracking.

Custom Store Products require iOS SDK 4.15.0 or later and a PurchaseController. If your app does not configure a purchase controller, custom product purchases will fail because there is no App Store product for Superwall to purchase.

How It Works

When a paywall contains a Custom Store Product, Superwall:

  1. Loads the product metadata from Superwall instead of StoreKit.
  2. Makes the product available to paywall variables such as products.primary.price, products.selected.period, and trial variables.
  3. Checks trial eligibility using the product's entitlements and the user's entitlement history.
  4. Calls your PurchaseController when the user starts the purchase.
  5. Tracks the purchase result that your controller returns.

Your app is responsible for the actual checkout and entitlement state. After a successful external purchase, update Superwall.shared.subscriptionStatus so Superwall knows whether the user should keep seeing paywalls.

If you want Superwall's hosted Stripe web checkout and redemption flow, use Web Checkout. Custom Store Products are for purchase flows that your app handles from PurchaseController.

Add a PurchaseController

Pass a PurchaseController when configuring Superwall:

let purchaseController = CustomStorePurchaseController()

Superwall.configure(
  apiKey: "MY_API_KEY",
  purchaseController: purchaseController
)

Inside purchase(product:), StoreKit-backed products contain either sk1Product or sk2Product. Custom Store Products do not, so route them to your external billing system using product.productIdentifier.

import SuperwallKit

final class CustomStorePurchaseController: PurchaseController {
  func purchase(product: StoreProduct) async -> PurchaseResult {
    if hasStoreKitProduct(product) {
      return await Superwall.shared.purchase(product)
    }

    do {
      let result = try await BillingClient.shared.purchase(
        productIdentifier: product.productIdentifier
      )

      switch result {
      case .purchased:
        await syncSubscriptionStatus()
        return .purchased
      case .pending:
        return .pending
      case .cancelled:
        return .cancelled
      }
    } catch {
      return .failed(error)
    }
  }

  func restorePurchases() async -> RestorationResult {
    do {
      try await BillingClient.shared.restorePurchases()
      await syncSubscriptionStatus()
      return .restored
    } catch {
      return .failed(error)
    }
  }

  private func hasStoreKitProduct(_ product: StoreProduct) -> Bool {
    if product.sk1Product != nil {
      return true
    }

    if #available(iOS 15.0, *), product.sk2Product != nil {
      return true
    }

    return false
  }

  private func syncSubscriptionStatus() async {
    let activeProductIds = await BillingClient.shared.activeProductIdentifiers()
    let entitlements = Superwall.shared.entitlements.byProductIds(activeProductIds)

    await MainActor.run {
      Superwall.shared.subscriptionStatus = entitlements.isEmpty
        ? .inactive
        : .active(entitlements)
    }
  }
}

Replace BillingClient with your own billing implementation. It should start checkout for product.productIdentifier, report cancellation and pending states distinctly when possible, and expose the active product identifiers that should unlock Superwall entitlements.

Keep Entitlements In Sync

Superwall decides whether a user is active from subscriptionStatus, not from the external payment provider directly. When your billing system says the user has access, map the active product identifiers back to Superwall entitlements and set the status:

let activeProductIds: Set<String> = ["pro_monthly_external"]
let entitlements = Superwall.shared.entitlements.byProductIds(activeProductIds)

Superwall.shared.subscriptionStatus = entitlements.isEmpty
  ? .inactive
  : .active(entitlements)

Call this after purchase, after restore, on app launch, and whenever your billing provider reports a subscription or entitlement change.

Make sure the product identifier returned by your billing system matches the product identifier configured in Superwall. If the identifier does not match, entitlements.byProductIds(_:) will not find the entitlement and the user can remain inactive after purchase.

Trial Eligibility

Custom Store Products can use the same trial variables as App Store products. Superwall checks the custom product's trial metadata and associated entitlements, then looks at the user's entitlement history to avoid showing a trial to someone who has already had access.

For best results:

  • Attach at least one entitlement to each custom subscription product.
  • Keep subscriptionStatus current before presenting paywalls.
  • Return .pending when the external checkout requires more user action.
  • Return .cancelled when the user intentionally exits checkout.

If customer information has not loaded yet, Superwall avoids treating the user as eligible for a custom-product trial.

What Not To Do

  • Do not call Superwall.shared.purchase(product) for a custom product. That helper is for StoreKit-backed products.
  • Do not fetch custom products with Superwall.shared.products(for:); that method fetches App Store products.
  • Do not rely on sk1Product or sk2Product for a custom product. Use product.productIdentifier.
  • Do not wait until the next app launch to update subscriptionStatus after purchase.

Testing

Test the full flow on a paywall that contains your Custom Store Product:

  1. Confirm the product's price and trial copy render on the paywall.
  2. Tap the product and verify your PurchaseController receives the product identifier.
  3. Complete, cancel, fail, and mark a purchase pending in your external billing test environment.
  4. Confirm your app updates subscriptionStatus after purchase and restore.
  5. Confirm users who have already held the entitlement do not see a custom-product trial as available.

For general purchase-controller setup, see Advanced Purchasing.

How is this guide?

On this page