Custom Store Products

Sell custom store products on Superwall paywalls with dashboard checkout or your own purchase controller, and sync revenue attribution and entitlements.

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. Versions before 4.19.0 require a PurchaseController. In 4.19.0 and later, paywalls can also run the checkout configured on the dashboard without a purchase controller.

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. Runs the dashboard-configured checkout, or calls your PurchaseController when your app handles checkout.
  5. Tracks the purchase result and refreshes entitlements after a successful purchase (SDK 4.19.0 and later).

Use Dashboard Checkout (4.19.0+)

Configure the custom store product and its checkout on the dashboard, then attach the product to your paywall. Configure Superwall without a PurchaseController:

Superwall.configure(apiKey: "MY_API_KEY")

The paywall runs its configured checkout and reports completion, cancellation, or failure to the SDK. Your billing server must report the purchase to Superwall so it can grant entitlements and record revenue. Completing checkout alone does not grant access.

For Superwall's hosted Stripe checkout and redemption flow, see Web Checkout.

Revenue Attribution (4.19.0+)

For checkout handled by your PurchaseController, pass product.customTransactionId to your billing system, for example as checkout metadata. Superwall sets this read-only String? before calling purchase(product:); it is nil for non-custom products. Have your server send the value back as custom_transaction_id with the custom store's purchase events. This associates revenue, including renewals, with the paywall that sold the product.

The SDK includes this value as the original transaction ID of transaction_complete. SDK conversion tracking alone does not report your billing provider's revenue; your server must report the purchase events.

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,
        customTransactionId: product.customTransactionId
      )

      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, pass product.customTransactionId through to your server, report cancellation and pending states distinctly when possible, and expose the active product identifiers that should unlock Superwall entitlements.

Keep Entitlements In Sync

In SDK 4.19.0 and later, Superwall refreshes entitlements in the background after a successful custom product purchase, including when your controller returns .purchased. Entitlements already reported by your server become available after this refresh. The purchase result and paywall dismissal do not wait for it.

If your server reports the purchase later, call refreshCustomerInfo() once reporting finishes:

let customerInfo = await Superwall.shared.refreshCustomerInfo()

Apps also refresh entitlements when returning to the foreground, including apps without web paywalls. This is not a substitute for reporting purchases from your server.

When Your App Owns Subscription State

If you use a PurchaseController, continue managing subscriptionStatus yourself. Superwall does not query your 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 dashboard checkout opens, or that your PurchaseController receives the product identifier and transaction ID.
  3. Complete, cancel, fail, and mark a purchase pending in your external billing test environment.
  4. Confirm server-reported entitlements appear after purchase; with a PurchaseController, also verify your app updates subscriptionStatus after purchase and restore.
  5. Confirm revenue is attributed when your server reports custom_transaction_id.
  6. 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