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:
- 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.
- Runs the dashboard-configured checkout, or calls your
PurchaseControllerwhen your app handles checkout. - Tracks the purchase result and refreshes entitlements after a successful purchase (SDK
4.19.0and 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
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 dashboard checkout opens, or that your
PurchaseControllerreceives the product identifier and transaction ID. - Complete, cancel, fail, and mark a purchase pending in your external billing test environment.
- Confirm server-reported entitlements appear after purchase; with a
PurchaseController, also verify your app updatessubscriptionStatusafter purchase and restore. - Confirm revenue is attributed when your server reports
custom_transaction_id. - 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.