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:
- Loads the product metadata from Superwall instead of StoreKit.
- Makes the product available to paywall variables such as
products.primary.price,products.selected.period, and trial variables. - Checks trial eligibility using the product's entitlements and the user's entitlement history.
- Calls your
PurchaseControllerwhen the user starts the purchase. - 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
subscriptionStatuscurrent before presenting paywalls. - Return
.pendingwhen the external checkout requires more user action. - Return
.cancelledwhen 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
sk1Productorsk2Productfor a custom product. Useproduct.productIdentifier. - Do not wait until the next app launch to update
subscriptionStatusafter purchase.
Testing
Test the full flow on a paywall that contains your Custom Store Product:
- Confirm the product's price and trial copy render on the paywall.
- Tap the product and verify your
PurchaseControllerreceives the product identifier. - Complete, cancel, fail, and mark a purchase pending in your external billing test environment.
- Confirm your app updates
subscriptionStatusafter purchase and restore. - 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?
Advanced Purchasing
If you need fine-grain control over the purchasing pipeline, use a purchase controller to manually handle purchases and subscription status.
Advanced Configuration
When configuring the SDK you can pass in options that configure Superwall, the paywall presentation, and its appearance.