# iOS Documentation # Changelog 4.15.1 [#4151] Enhancements [#enhancements] * Adds an `onCustomCallback` parameter to `getPaywall`. * `SuperwallOptions.localResources` now accepts UIImage's from xcasset files, e.g. `UIImage(named: "my-image")`. * Exposes abandoned transaction product params in audience filters. Fixes [#fixes] * Sanitizes email user attribute. 4.15.0 [#4150] Enhancements [#enhancements-1] * Adds support for custom store products. This allows you to purchase products that are on stores outside of the App Store using the `PurchaseController`. * Adds `formUnion` override when unioning sets of `Entitlement` objects. Fixes [#fixes-1] * Fixes issue where test mode products had trial price data missing. * Fixed computed period prices (`weeklyPrice`, `dailyPrice`, `monthlyPrice`, `yearlyPrice`) displaying incorrectly rounded values on StoreKit 2 in production. For example, a £4.99/week product could show as £5.00/week. This was caused by Apple's `priceFormatStyle` applying storefront-specific rounding to computed values. 4.14.2 [#4142] Enhancements [#enhancements-2] * Adds multipage paywall navigation tracking by tracking a `paywall_page_view` event, which contains information about the page view. 4.14.1 [#4141] Enhancements [#enhancements-3] * Localizes all alerts into 41 languages. * Makes sure to refresh free trial eligibility on every paywall open. Fixes [#fixes-2] * Makes `device.isSandbox` more reliable. * Fixes the web restore alert not showing the "Yes" action button and "Cancel" incorrectly triggering the restore action. * Fixes a rare issue where a user's subscription could remain active after a refund, preventing paywalls from being shown. * Fixes trial eligibility for Stripe paywalls and tracks `freeTrial_start`. * Fixes an issue where `transaction_complete` could be missing transaction information when a crossgrade occurred while using a purchase controller. * Fixes terminated webviews refreshing in a loop on low RAM devices. 4.14.0 [#4140] Enhancements [#enhancements-4] * Adds support for "Test Mode", which allows you to simulate in-app purchases without involving StoreKit. Test Mode can be enabled through the Superwall dashboard by marking specific users as test store users, or activates automatically when a bundle ID mismatch is detected. When active, a configuration modal lets you select starting entitlements and override free trial availability. Purchases are simulated with a UI that lets users complete, abandon, or fail transactions, with all purchase events firing normally for end-to-end paywall testing. * Adds prioritized campaign preloading. When a campaign is marked as prioritized in the dashboard, its paywalls are preloaded before all others. * Adds Stripe checkout message handling for `stripe_checkout_start`, `stripe_checkout_submit`, `stripe_checkout_complete`, `stripe_checkout_fail`, and `stripe_checkout_abandon`. * Adds SDK-side analytics tracking for Stripe checkout lifecycle events (`start`, `submit`, `complete`, `fail`) with `store` and `product_identifier` payload fields. Fixes [#fixes-3] * Fixes issue with compiling on Xcode 26.4 beta. * Fixes dashboard display of multiple active entitlements. The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. 4.13.0 [#4130] Enhancements [#enhancements-5] * Adds support for local images and videos in paywalls. * Schedules trial notifications after purchasing Stripe products. Fixes [#fixes-4] * Fixes race condition relating to the user ID when upgrading from v3 of the SDK to v4. * Fixes issue where the Superscript version hadn't been upgraded to 1.0.13 if installed via CocoaPods. 4.12.11 [#41211] Enhancements [#enhancements-6] * Adds `appstackId` as an `IntegrationAttribute`. 4.12.10 [#41210] Enhancements [#enhancements-7] * Adds native haptic feedback support for paywalls. Haptic types can be configured in the paywall editor and include light, medium, heavy, success, warning, error, and selection. * Adds `custom callback` action support allowing you to perform an async action and send the result back to the paywall. Fixes [#fixes-5] * Fixes issue where the `app_install` event was being cleared upon reset, which meant that this couldn't be used with `device.daysSince_app_install` after reset. 4.12.9 [#4129] Fixes [#fixes-6] * Updates Superscript version to 1.0.13. This fixes an issue with String and Int comparison. View the original Rust release changelog [here](https://github.com/superwall/superscript/releases/tag/1.0.13). * Fixes an issue where dismissing a modally presented paywall didn't fire `paywall_decline`. 4.12.8 [#4128] Enhancements [#enhancements-8] * Exposes the `introOfferToken` on `StoreProduct` so that those using a PurchaseController can take advantage of the introductory offer eligiblity override. Fixes [#fixes-7] * Stop logging `paywallWebviewLoad_timeout` events because they were confusing. * Only refreshes terminated webviews once to avoid infinite reloading loops on low RAM devices. 4.12.7 [#4127] Fixes [#fixes-8] * Fixes microphone permission request to prevent App Store Connect warnings. 4.12.6 [#4126] Enhancements [#enhancements-9] * Adds post purchase actions support. Fixes [#fixes-9] * Fixes a rare issue where TestFlight products could display in a different currency on the paywall than on Apple's payment sheet. 4.12.5 [#4125] Enhancements [#enhancements-10] * Adds microphone permission request support. Fixes [#fixes-10] * Fixes issue where the notification permission prompt would not appear if provisional notification permission was already granted. 4.12.4 [#4124] Enhancements [#enhancements-11] * Adds back in contacts and location permission requests but this time will not get flagged in App Store review if they're not being used. * Adds App Tracking Transparency permission request. 4.12.3 [#4123] Fixes [#fixes-11] * Removes contacts and location permission APIs to prevent App Store warnings. 4.12.2 [#4122] Fixes [#fixes-12] * Fixes issue building for Mac Catalyst. 4.12.1 [#4121] Enhancements [#enhancements-12] * Adds `redemptionInfo.paywallInfo.product` which contains information about the product that was purchased. This deprecates `redemptionInfo.paywallInfo.productIdentifier` in favor of `redemptionInfo.paywallInfo.product.identifer`. 4.12.0 [#4120] Enhancements [#enhancements-13] * Adds `paywallPreload_start` and `paywallPreload_complete` events. * Adds `request permission` action support allowing you to request notification, location, photos, contacts, and camera permissions from paywalls. * Improves drawer presentation style corner rounding by applying the device radius on bottom corners. Fixes [#fixes-13] * Updates Superscript version to 1.0.12. This fixes an issue with `appVersionPadded` comparison. View the original Rust release changelog [here](https://github.com/superwall/superscript/releases/tag/1.0.12). 4.11.2 [#4112] Fixes [#fixes-14] * Deprecates `device.isApplePayAvailable` and defaults it to `true`. This also removes the PassKit import, which was getting flagged for some developers in review. 4.11.1 [#4111] Fixes [#fixes-15] * Fixes issue where `isApplePayAvailable` being calculated off the main thread could cause a crash. * Fixes potential crashes in WebKit navigation delegate methods. 4.11.0 [#4110] Enhancements [#enhancements-14] * Adds the ability to override introductory offer eligibility via the paywall editor. * Adds dynamic notification support and scheduling. * Adds `refreshConfiguration()` to manually refresh the SDK configuration. This should only be used in wrapper SDKs in development for hot reloading. * Adds `offerType`, `subscriptionGroupId` and `store` to `SubscriptionTransaction` and `NonSubscriptionTransaction`. Fixes [#fixes-16] * Fixes an issue where not all product IDs belonging to `Entitlement`s in `CustomerInfo` were being included. 4.10.8 [#4108] Enhancements [#enhancements-15] * Adds support for `Set user attributes` action. * Adds new `SuperwallDelegate` method called `userAttributesDidChange` that notifies you when user attributes change from an external source. * Adds `firebaseInstallationId` as an `IntegrationAttribute`. Fixes [#fixes-17] * Fixes a crash caused by a race condition when accessing JSON dictionaries concurrently. * Fixes issue returning the `PurchaseResult` from `Superwall.shared.purchase(_:)` when using StoreKit 1 inside a `PurchaseController`. * Fixes `handleDeepLink` returning true for non-Superwall URLs when called before configuration completes. 4.10.6 [#4106] Fixes [#fixes-18] * Fixes issue that prevented the SDK from being built on old Xcode versions. 4.10.5 [#4105] Fixes [#fixes-19] * Updates `device.isApplePayAvailable` for more accurate filtering. Previously it returned true whenever the device supported Apple Pay, even if no card was added. It now returns true only when the device supports Apple Pay and the user has added a card. * Fixes issue where `didRedeemLink` might not get called if there's no paywall available to present an alert from. 4.10.4 [#4104] Fixes [#fixes-20] * Updates Superscript version to 1.0.10. This fixes an issue with namespacing in cocoapods. View the original Rust release changelog [here](https://github.com/superwall/superscript/releases/tag/1.0.10). * Fixes some issues building for visionOS. 4.10.3 [#4103] Fixes [#fixes-21] * Fixes issue where `Superwall.shared.confirmAllAssignments()` would be return an empty `Set` if config hadn't been retrieved. 4.10.1 [#4101] Fixes [#fixes-22] * Fixes issue where `willRedeemLink` might get called twice during the web checkout payment sheet flow. * Fixes issue where paywall might get dismissed prematurely during web checkout. * Fixes issue where the spinner on the paywall wasn't showing for a few seconds after the system closed the web checkout payment sheet due to a successful purchase. 4.10.0 [#4100] Enhancements [#enhancements-16] * Adds `CustomerInfo`. This contains the latest information about all of the customer's purchase and subscription data. This can be accessed via the published property `Superwall.shared.customerInfo`, via `Superwall.shared.getCustomerInfo()`, via the `AsyncStream` `customerInfoStream`, or via the delegate method `customerInfoDidChange(from:to:)`. This updates the `Entitlement` object to have more properties such as `startsAt` and `expiredAt`. These can be used in audience filters. * Adds `Superwall.shared.entitlements.byProductIds(_:)` to return a `Set` of `Entitlement` objects belonging to a given set of product identifiers. * Changes the `PurchaseController` examples to account for `CustomerInfo` changes. * Adds `transaction_abandon` capability to web checkout payment sheet. Fixes [#fixes-23] * Fixes issue after purchasing web products where localized strings weren't correct in SDK wrappers like Expo. # Cohorting in 3rd Party Tools :::ios ```swift Swift extension SuperwallService: SuperwallDelegate { func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) { if eventInfo.event.description == "trigger_fire" { MyAnalyticsService.shared.setUserAttributes([ "sw_experiment_\(eventInfo.event.params["experiment_id"])": true, "sw_variant_\(eventInfo.event.params["variant_id"])": true ]) } } } ``` ::: Once you've set this up, you can easily ask for all users who have an attribute `sw_experiment_1234` and breakdown by both variants to see how users in a Superwall experiment behave in other areas of your app. # Custom Paywall Analytics You can create customized analytics tracking for any paywall event by using custom placements. With them, you can get callbacks for actions such as interacting with an element on a paywall sent to your [Superwall delegate](/docs/sdk/guides/using-superwall-delegate). This can be useful for tracking how users interact with your paywall and how that affects their behavior in other areas of your app. For example, in the paywall below, perhaps you're interested in tracking when people switch the plan from "Standard" and "Pro": You could create a custom placement [tap behavior](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements#tap-behaviors) which fires when a segment is tapped: Then, you can listen for this placement and forward it to your analytics service: ```swift Swift extension SuperwallService: SuperwallDelegate { func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) { switch eventInfo.event { case let .customPlacement(name, params, paywallInfo): // Prints out didTapPro or didTapStandard print("\(name) - \(params) - \(paywallInfo)") MyAnalyticsService.shared.send(event: name, params: params) default: print("Default event: \(eventInfo.event.description)") } } } ``` For a walkthrough example, check out this [video on YouTube](https://youtu.be/4rM1rGRqDL0). # 3rd Party Analytics Hooking up Superwall events to 3rd party tools [#hooking-up-superwall-events-to-3rd-party-tools] SuperwallKit automatically tracks some internal events. You can [view the list of events here](/docs/sdk/guides/3rd-party-analytics/tracking-analytics). We encourage you to also track them in your own analytics by implementing the [Superwall delegate](/docs/sdk/guides/using-superwall-delegate). Using the `handleSuperwallEvent(withInfo:)` function, you can forward events to your analytics service: :::ios ```swift Swift extension SuperwallService: SuperwallDelegate { func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) { print("analytics event called", eventInfo.event.description) MyAnalyticsService.shared.track( event: eventInfo.event.description, params: eventInfo.params ) } } ``` ```swift Objective-C - (void)handleSuperwallEventWithInfo:(SWKSuperwallEventInfo *)info { NSLog(@"Analytics event called %@", info.event.description)); [[MyAnalyticsService shared] trackEvent:info.event.description params:info.params]; } ``` :::
You might also want to set user attribute to allow for [Cohorting in 3rd Party Tools](/docs/sdk/guides/3rd-party-analytics/cohorting-in-3rd-party-tools). Alternatively, if you want typed versions of all these events with associated values, you can access them via `eventInfo.event`: :::ios ```swift func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) { switch eventInfo.event { case .firstSeen: break case .appOpen: break case .appLaunch: break case .identityAlias: break case .appInstall: break case .sessionStart: break case .deviceAttributes(let attributes): break case .subscriptionStatusDidChange: break case .appClose: break case .deepLink(let url): break case .triggerFire(let placementName, let result): break case .paywallOpen(let paywallInfo): break case .paywallClose(let paywallInfo): break case .paywallDecline(let paywallInfo): break case .transactionStart(let product, let paywallInfo): break case .transactionFail(let error, let paywallInfo): break case .transactionAbandon(let product, let paywallInfo): break case .transactionComplete(let transaction, let product, let type, let paywallInfo): break case .subscriptionStart(let product, let paywallInfo): break case .freeTrialStart(let product, let paywallInfo): break case .transactionRestore(let restoreType, let paywallInfo): break case .transactionTimeout(let paywallInfo): break case .userAttributes(let atts): break case .nonRecurringProductPurchase(let product, let paywallInfo): break case .paywallResponseLoadStart(let triggeredPlacementName): break case .paywallResponseLoadNotFound(let triggeredPlacementName): break case .paywallResponseLoadFail(let triggeredPlacementName): break case .paywallResponseLoadComplete(let triggeredPlacementName, let paywallInfo): break case .paywallWebviewLoadStart(let paywallInfo): break case .paywallWebviewLoadFail(let paywallInfo): break case .paywallWebviewLoadComplete(let paywallInfo): break case .paywallWebviewLoadTimeout(let paywallInfo): break case .paywallWebviewLoadFallback(let paywallInfo): break case .paywallWebviewProcessTerminated(let paywallInfo): break case .paywallProductsLoadStart(let triggeredPlacementName, let paywallInfo): break case .paywallProductsLoadFail(let triggeredPlacementName, let paywallInfo): break case .paywallProductsLoadComplete(let triggeredPlacementName): break case .paywallProductsLoadRetry(let triggeredPlacementName, let paywallInfo, let attempt): break case .surveyResponse(let survey, let selectedOption, let customResponse, let paywallInfo): break case .paywallPresentationRequest(let status, let reason): break case .touchesBegan: break case .surveyClose: break case .reset: break case .restoreStart: break case .restoreFail(let message): break case .restoreComplete: break case .configRefresh: break case .customPlacement(let name, let params, let paywallInfo): break case .configAttributes: break case .confirmAllAssignments: break case .configFail: break case .adServicesTokenRequestStart: break case .adServicesTokenRequestFail(let error): break case .adServicesTokenRequestComplete(let token): break case .shimmerViewStart: break case .shimmerViewComplete: break } } ``` ::: Wanting to use events to see which product was purchased on a paywall? Check out this [doc](/docs/sdk/guides/advanced/viewing-purchased-products). # Superwall Events We encourage you to track them in your own analytics as described in [3rd Party Analytics](..). The following Superwall events can be used as placements to present paywalls: * `app_install` * `app_launch` * `deepLink_open` * `session_start` * `paywall_decline` * `transaction_fail` * `transaction_abandon` * `survey_response` For more info about how to use these, check out [how to add them using a Placement](/docs/dashboard/dashboard-campaigns/campaigns-placements#adding-a-placement). The full list of events is as follows: | **Event Name** | **Action** | **Parameters** | | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `adServicesTokenRequestComplete` | When the AdServices token request finishes. | `["token": String]` | | `adServicesTokenRequestFail` | When the AdServices token request fails. | `["error": Error]` | | `adServicesTokenRequestStart` | When the AdServices token request starts. | None | | `app_close` | Anytime the app leaves the foreground. | Same as `app_install` | | `app_install` | When the SDK is configured for the first time. | `["is_superwall": true, "app_session_id": String, "using_purchase_controller": Bool]` | | `app_launch` | When the app is launched from a cold start. | Same as `app_install` | | `app_open` | Anytime the app enters the foreground. | Same as `app_install` | | `configAttributes` | When the attributes affecting Superwall's configuration are set or changed. | None | | `configFail` | When the Superwall configuration fails to be retrieved. | None | | `configRefresh` | When the Superwall configuration is refreshed. | None | | `confirmAllAssignments` | When all experiment assignments are confirmed. | None | | `customPlacement` | When the user taps on an element in the paywall that has a `custom_placement` action. | `["name": String, "params": [String: Any], "paywallInfo": PaywallInfo]` | | [`deepLink_open`](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements#using-the-deeplink-open-event) | When a user opens the app via a deep link. | `["url": String, "path": String", "pathExtension": String, "lastPathComponent": String, "host": String, "query": String, "fragment": String]` + any query parameters in the deep link URL | | `device_attributes` | When device attributes are sent to the backend every session. | Includes `app_session_id`, `app_version`, `os_version`, `device_model`, `device_locale`, and various hardware/software details. | | `first_seen` | When the user is first seen in the app, regardless of login status. | Same as `app_install` | | `freeTrial_start` | When a user completes a transaction for a subscription product with an introductory offer. | Same as `subscription_start` | | `identityAlias` | When the user's identity aliases after calling `identify`. | None | | `nonRecurringProduct_purchase` | When the user purchases a non-recurring product. | Same as `subscription_start` | | `paywall_close` | When a paywall is closed (either manually or after a transaction succeeds). | \[“paywall\_webview\_load\_complete\_time”: String?, “paywall\_url”: String, “paywall\_response\_load\_start\_time”: String?, “paywall\_products\_load\_fail\_time”: String?, “secondary\_product\_id”: String, “feature\_gating”: Int, “paywall\_response\_load\_complete\_time”: String?, “is\_free\_trial\_available”: Bool, “is\_superwall”: true, “presented\_by”: String, “paywall\_name”: String, “paywall\_response\_load\_duration”: String?, “paywall\_identifier”: String, “paywall\_webview\_load\_start\_time”: String?, “paywall\_products\_load\_complete\_time”: String?, “paywall\_product\_ids”: String, “tertiary\_product\_id”: String, “paywall\_id”: String, “app\_session\_id”: String, “paywall\_products\_load\_start\_time”: String?, “primary\_product\_id”: String, “survey\_attached”: Bool, “survey\_presentation”: String?] | | [`paywall_decline`](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements#using-the-paywall-decline-event) | When a user manually dismisses a paywall. | Same as `paywall_close` | | `paywall_open` | When a paywall is opened. | Same as `paywall_close` | | `paywallPresentationRequest` | When something happened during the paywall presentation, whether a success or failure. | `[“source_event_name”: String, “status”: String, “is_superwall”: true, “app_session_id”: String, “pipeline_type”: String, “status_reason”: String]` | | `paywallProductsLoad_complete` | When the request to load a paywall's products completes. | Same as `paywallResponseLoad_start` | | `paywallProductsLoad_fail` | When the request to load a paywall's products fails. | Same as `paywallResponseLoad_start` | | `paywallProductsLoad_retry` | When the request to load a paywall's products fails and is being retried. | `["triggeredPlacementName": String?, "paywallInfo": PaywallInfo, "attempt": Int]` | | `paywallProductsLoad_start` | When the request to load a paywall's products starts. | Same as `paywallResponseLoad_start` | | `paywallResponseLoad_complete` | When a paywall request to Superwall's servers completes. | Same as `paywallResponseLoad_start` | | `paywallResponseLoad_fail` | When a paywall request to Superwall's servers fails. | Same as `paywallResponseLoad_start` | | `paywallResponseLoad_notFound` | When a paywall request returns a 404 error. | Same as `paywallResponseLoad_start` | | `paywallResponseLoad_start` | When a paywall request to Superwall's servers has started. | Same as `app_install` + `["is_triggered_from_event": Bool]` | | `paywallWebviewLoad_complete` | When a paywall's webpage completes loading. | Same as `paywall_close` | | `paywallWebviewLoad_fail` | When a paywall's webpage fails to load. | Same as `paywall_close` | | `paywallWebviewLoad_fallback` | When a paywall's webpage fails and loads a fallback version. | Same as `paywall_close` | | `paywallWebviewLoad_start` | When a paywall's webpage begins to load. | Same as `paywall_close` | | `paywallWebviewLoad_processTerminated` | When the paywall's web view content process terminates. | Same as `paywall_close` | | `reset` | When `Superwall.reset()` is called. | None | | `restoreComplete` | When a restore completes successfully. | None | | `restoreFail` | When a restore fails. | `["message": String]` | | `restoreStart` | When a restore is initiated. | None | | `session_start` | When the app is opened after at least 60 minutes since last `app_close`. | Same as `app_install` | | `shimmerViewComplete` | When the shimmer view stops showing. | None | | `shimmerViewStart` | When the shimmer view starts showing. | None | | `subscription_start` | When a user completes a transaction for a subscription product without an introductory offer. | \[“product\_period\_days”: String, “product\_price”: String, “presentation\_source\_type”: String?, “paywall\_response\_load\_complete\_time”: String?, “product\_language\_code”: String, “product\_trial\_period\_monthly\_price”: String, “paywall\_products\_load\_duration”: String?, “product\_currency\_symbol”: String, “is\_superwall”: true, “app\_session\_id”: String, “product\_period\_months”: String, “presented\_by\_event\_id”: String?, “product\_id”: String, “trigger\_session\_id”: String, “paywall\_webview\_load\_complete\_time”: String?, “paywall\_response\_load\_start\_time”: String?, “product\_raw\_trial\_period\_price”: String, “feature\_gating”: Int, “paywall\_id”: String, “product\_trial\_period\_daily\_price”: String, “product\_period\_years”: String, “presented\_by”: String, “product\_period”: String, “paywall\_url”: String, “paywall\_name”: String, “paywall\_identifier”: String, “paywall\_products\_load\_start\_time”: String?, “product\_trial\_period\_months”: String, “product\_currency\_code”: String, “product\_period\_weeks”: String, “product\_periodly”: String, “product\_trial\_period\_text”: String, “paywall\_webview\_load\_start\_time”: String?, “paywall\_products\_load\_complete\_time”: String?, “primary\_product\_id”: String, “product\_trial\_period\_yearly\_price”: String, “paywalljs\_version”: String?, “product\_trial\_period\_years”: String, “tertiary\_product\_id”: String, “paywall\_products\_load\_fail\_time”: String?, “product\_trial\_period\_end\_date”: String, “product\_weekly\_price”: String, “variant\_id”: String, “presented\_by\_event\_timestamp”: String?, “paywall\_response\_load\_duration”: String?, “secondary\_product\_id”: String, “product\_trial\_period\_days”: String, “product\_monthly\_price”: String, “paywall\_product\_ids”: String, “product\_locale”: String, “product\_daily\_price”: String, “product\_raw\_price”: String, “product\_yearly\_price”: String, “product\_trial\_period\_price”: String, “product\_localized\_period”: String, “product\_identifier”: String, “experiment\_id”: String, “is\_free\_trial\_available”: Bool, “product\_trial\_period\_weeks”: String, “paywall\_webview\_load\_duration”: String?, “product\_period\_alt”: String, “product\_trial\_period\_weekly\_price”: String, “presented\_by\_event\_name”: String?] | | `subscriptionStatus_didChange` | When a user's subscription status changes. | `["is_superwall": true, "app_session_id": String, "subscription_status": String]` | | `surveyClose` | When the user chooses to close a survey instead of responding. | None | | [`survey_response`](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements#using-the-survey-response-event) | When a user responds to a paywall survey. | `["survey_selected_option_title": String, "survey_custom_response": String, "survey_id": String, "survey_assignment_key": String, "survey_selected_option_id": String]` | | `touches_began` | When the user touches the app's UIWindow for the first time (if tracked by a campaign). | Same as `app_install` | | `transaction_abandon` | When the user cancels a transaction. | Same as `subscription_start` | | `transaction_complete` | When the user completes checkout and any product is purchased. | Same as subscription\_start + \[“web\_order\_line\_item\_id”: String, “app\_bundle\_id”: String, “config\_request\_id”: String, “state”: String, “subscription\_group\_id”: String, “is\_upgraded”: String, “expiration\_date”: String, “trigger\_session\_id”: String, “original\_transaction\_identifier”: String, “id”: String, “transaction\_date”: String, “is\_superwall”: true, “store\_transaction\_id”: String, “original\_transaction\_date”: String, “app\_session\_id”: String] | | `transaction_fail` | When the payment sheet fails to complete a transaction (ignores user cancellation). | Same as `subscription_start` + `["message": String]` | | `transaction_restore` | When the user successfully restores their purchases. | Same as `subscription_start` | | `transaction_start` | When the payment sheet is displayed to the user. | Same as `subscription_start` | | `transaction_timeout` | When the transaction takes longer than 5 seconds to display the payment sheet. | `["paywallInfo": PaywallInfo]` | | `trigger_fire` | When a registered placement triggers a paywall. | `[“trigger_name”: String, “trigger_session_id”: String, “variant_id”: String?, “experiment_id”: String?, “paywall_identifier”: String?, “result”: String, “unmatched_rule_”: “”]. unmatched_rule_ indicates why a rule (with a specfiic experiment id) didn’t match. It will only exist if the result is no_rule_match. Its outcome will either be OCCURRENCE, referring to the limit applied to a rule, or EXPRESSION.` | | `user_attributes` | When the user attributes are set. | `[“aliasId”: String, “seed”: Int, “app_session_id”: String, “applicationInstalledAt”: String, “is_superwall”: true, “application_installed_at”: String] + provided attributes` | # Advanced Purchasing Using a `PurchaseController` is only recommended for **advanced** use cases. By default, Superwall handles all subscription-related logic and purchasing operations for you out of the box. By default, Superwall handles basic subscription-related logic for you: 1. **Purchasing**: When the user initiates a checkout on a paywall. 2. **Restoring**: When the user restores previously purchased products. 3. **Subscription Status**: When the user's subscription status changes to active or expired (by checking the local receipt). However, if you want more control, you can pass in a `PurchaseController` when configuring the SDK via `configure(apiKey:purchaseController:options:)` and manually set `Superwall.shared.subscriptionStatus` to take over this responsibility. On iOS, starting in `4.15.0`, a `PurchaseController` is also how you handle custom products attached to Superwall paywalls. Those products are not purchased with StoreKit, so your controller must route them through your own billing system. See [Custom Store Products](/docs/ios/guides/custom-store-products) for the full iOS setup. Step 1: Creating a `PurchaseController` [#step-1-creating-a-purchasecontroller] A `PurchaseController` handles purchasing and restoring via protocol methods that you implement. :::ios ```swift Swift // MyPurchaseController.swift import SuperwallKit import StoreKit final class MyPurchaseController: PurchaseController { static let shared = MyPurchaseController() // 1 func purchase(product: StoreProduct) async -> PurchaseResult { // Use StoreKit, RevenueCat, or your own billing system to purchase... // If `product.sk1Product` and `product.sk2Product` are both nil, // this is a custom product that should be handled externally. // Send Superwall the result. return .purchased // .cancelled, .pending, .failed(Error) } func restorePurchases() async -> RestorationResult { // Use StoreKit or some other SDK to restore... // Send Superwall the result. return .restored // Or failed(error) } } ``` ```swift Objective-C @import SuperwallKit; @import StoreKit; // MyPurchaseController @interface MyPurchaseController: NSObject + (instancetype)sharedInstance; @end @implementation MyPurchaseController + (instancetype)sharedInstance { static MyPurchaseController *sharedInstance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ sharedInstance = [MyPurchaseController new]; }); return sharedInstance; } // 1 - (void)purchaseWithProduct:(SWKStoreProduct * _Nonnull)product completion:(void (^ _Nonnull)(enum SWKPurchaseResult, NSError * _Nullable))completion { // TODO // ---- // Purchase via StoreKit, RevenueCat, Qonversion or however // you like and return a valid SWKPurchaseResult // // Custom products introduced in 4.15.0 are not backed by StoreKit. // Handle those with your external billing system using // `product.productIdentifier`. completion(SWKPurchaseResultPurchased, nil); } // 2 - (void)restorePurchasesWithCompletion:(void (^ _Nonnull)(enum SWKRestorationResult, NSError * _Nullable))completion { // TODO // ---- // Restore purchases and return `SWKRestorationResultRestored` if successful. // Return an `NSError` if not. completion(SWKRestorationResultRestored, nil); } @end ```
```swift import StoreKit import SuperwallKit enum PurchaseControllerError: LocalizedError { case customProductNotHandled(productId: String) var errorDescription: String? { switch self { case .customProductNotHandled(let productId): return "Custom product \(productId) must be handled by your external billing system." } } } final class SWPurchaseController: PurchaseController { // MARK: Sync Subscription Status /// Makes sure that Superwall knows the customer's subscription status by /// changing `Superwall.shared.subscriptionStatus` func syncSubscriptionStatus() async { /// Every time the customer info changes, the subscription status should be updated. for await _ in Superwall.shared.customerInfoStream { var products: Set = [] for await verificationResult in Transaction.currentEntitlements { switch verificationResult { case .verified(let transaction): products.insert(transaction.productID) case .unverified: break } } let activeDeviceEntitlements = Superwall.shared.entitlements.byProductIds(products) let activeWebEntitlements = Superwall.shared.entitlements.web let allActiveEntitlements = activeDeviceEntitlements.union(activeWebEntitlements) await MainActor.run { Superwall.shared.subscriptionStatus = .active(allActiveEntitlements) } } } // MARK: Handle Purchases /// For App Store-backed products, delegate to `Superwall.shared.purchase(...)`. /// Custom products from Superwall paywalls must be handled in your /// external billing system using `product.productIdentifier`. func purchase(product: StoreProduct) async -> PurchaseResult { if product.sk1Product != nil || product.sk2Product != nil { return await Superwall.shared.purchase(product) } // Replace this with your own external billing implementation. return .failed( PurchaseControllerError.customProductNotHandled( productId: product.productIdentifier ) ) } // MARK: Handle Restores /// Makes a restore with Superwall and returns its result after syncing subscription status. /// This gets called when someone tries to restore purchases on one of your paywalls. func restorePurchases() async -> RestorationResult { let result = await Superwall.shared.restorePurchases() return result } } ``` For custom products in `4.15.0+`, `StoreProduct` does not contain an App Store purchase target. If `sk1Product` and `sk2Product` are unavailable, purchase the product in your own billing system using `product.productIdentifier`, then return `.purchased`, `.pending`, `.cancelled`, or `.failed(error)` from your controller. Do not call `Superwall.shared.purchase(product)` for that case. ::: Here’s what each method is responsible for: 1. Purchasing a given product. In here, enter your code that you use to purchase a product. Then, return the result of the purchase as a `PurchaseResult`. For Flutter, this is separated into purchasing from the App Store and Google Play. This is an enum that contains the following cases, all of which must be handled: 1. `.cancelled`: The purchase was cancelled. 2. `.purchased`: The product was purchased. 3. `.pending`: The purchase is pending/deferred and requires action from the developer. 4. `.failed(Error)`: The purchase failed for a reason other than the user cancelling or the payment pending. 2. Restoring purchases. Here, you restore purchases and return a `RestorationResult` indicating whether the restoration was successful or not. If it was, return `.restore`, or `failed` along with the error reason. Step 2: Configuring the SDK With Your `PurchaseController` [#step-2-configuring-the-sdk-with-your-purchasecontroller] Pass your purchase controller to the `configure(apiKey:purchaseController:options:)` method: :::ios ```swift UIKit // AppDelegate.swift import UIKit import SuperwallKit @main class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { Superwall.configure( apiKey: "MY_API_KEY", purchaseController: MyPurchaseController.shared // <- Handle purchases on your own ) return true } } ``` ```swift SwiftUI @main struct MyApp: App { init() { Superwall.configure( apiKey: "MY_API_KEY", purchaseController: MyPurchaseController.shared // <- Handle purchases on your own ) } var body: some Scene { WindowGroup { ContentView() } } } ``` ```swift Objective-C // AppDelegate.m @import SuperwallKit; @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // Override point for customization after application launch. [Superwall configureWithApiKey:@"MY_API_KEY" purchaseController:[MyPurchaseController sharedInstance] options:nil completion:nil]; return YES; } ``` ::: Step 3: Keeping `subscriptionStatus` Up-To-Date [#step-3-keeping-subscriptionstatus-up-to-date] You **must** set `Superwall.shared.subscriptionStatus` every time the user's subscription status changes, otherwise the SDK won't know who to show a paywall to. This is an enum that has three possible cases: 1. **`.unknown`**: This is the default value. In this state, paywalls will not show and their presentation will be ***automatically delayed*** until `subscriptionStatus` changes to a different value. 2. **`.active(let entitlements)`**: Indicates that the user has an active entitlement. Paywalls will not show in this state unless you remotely set the paywall to ignore subscription status. A user can have one or more active entitlement. 3. **`.inactive`**: Indicates that the user doesn't have an active entitlement. Paywalls can show in this state. Here's how you might do this: :::ios ```swift Swift import SuperwallKit func syncSubscriptionStatus() async { var purchasedProductIds: Set = [] // get all purchased product ids for await verificationResult in Transaction.currentEntitlements { switch verificationResult { case .verified(let transaction): purchasedProductIds.insert(transaction.productID) case .unverified: break } } // get entitlements from purchased product ids from Superwall let entitlements = Superwall.shared.entitlements.byProductIds(products) // set subscription status await MainActor.run { Superwall.shared.subscriptionStatus = .active(entitlements) } } ``` ```swift Objective-C @import SuperwallKit; // when a subscription is purchased, restored, validated, expired, etc... [myService setSubscriptionStatusDidChange:^{ if (user.hasActiveSubscription) { [Superwall sharedInstance] setActiveSubscriptionStatusWith:[NSSet setWithArray:@[myEntitlements]]]; } else { [[Superwall sharedInstance] setInactiveSubscriptionStatus]; } }]; ``` :::
`subscriptionStatus` is cached between app launches Listening for subscription status changes [#listening-for-subscription-status-changes] If you need a simple way to observe when a user's subscription status changes, on iOS you can use the `Publisher` for it. Here's an example: :::ios ```swift iOS subscribedCancellable = Superwall.shared.$subscriptionStatus .receive(on: DispatchQueue.main) .sink { [weak self] status in switch status { case .unknown: self?.subscriptionLabel.text = "Loading subscription status." case .active(let entitlements): self?.subscriptionLabel.text = "You currently have an active subscription: \(entitlements.map { $0.id }). Therefore, the paywall will not show unless feature gating is disabled." case .inactive: self?.subscriptionLabel.text = "You do not have an active subscription so the paywall will show when clicking the button." } } ``` ::: You can do similar tasks with the `SuperwallDelegate`, such as [viewing which product was purchased from a paywall](/docs/sdk/guides/3rd-party-analytics#using-events-to-see-purchased-products). Product Overrides [#product-overrides] Product overrides allow you to dynamically substitute products on paywalls without modifying the paywall design in the Superwall dashboard. When using a `PurchaseController`, you may want to override specific products shown on your paywalls. This is useful for: * A/B testing different subscription tiers * Showing region-specific products * Dynamically changing products based on user segments * Testing promotional pricing without modifying paywalls :::ios ```swift Swift // Configure product overrides when setting up Superwall let paywallOptions = PaywallOptions() paywallOptions.overrideProductsByName = [ "primary": "com.example.premium_monthly_promo", "secondary": "com.example.premium_annual_promo", "tertiary": "com.example.premium_lifetime" ] Superwall.configure( apiKey: "MY_API_KEY", purchaseController: MyPurchaseController.shared, options: paywallOptions ) ``` ```swift Objective-C // Configure product overrides when setting up Superwall SWKPaywallOptions *paywallOptions = [[SWKPaywallOptions alloc] init]; paywallOptions.overrideProductsByName = @{ @"primary": @"com.example.premium_monthly_promo", @"secondary": @"com.example.premium_annual_promo", @"tertiary": @"com.example.premium_lifetime" }; [Superwall configureWithApiKey:@"MY_API_KEY" purchaseController:[MyPurchaseController sharedInstance] options:paywallOptions completion:nil]; ``` ::: **How Product Overrides Work:** 1. Product names (e.g., "primary", "secondary") must match exactly as defined in the Superwall dashboard's Paywall Editor 2. The SDK substitutes the original product IDs with your override IDs before fetching from the App Store 3. The paywall maintains its visual design while showing the substituted products 4. Your `PurchaseController` will receive the overridden products when `purchase(product:)` is called Product overrides only affect the products shown on paywalls. They don't change your subscription logic or entitlement validation. # Custom callbacks Available from iOS SDK 4.12.10. Overview [#overview] Custom callbacks let a paywall request arbitrary actions from your app and receive results that determine which branch (`onSuccess` / `onFailure`) executes inside the paywall. Common use cases include validating user input, fetching data, or running business logic that lives outside the paywall. How it works [#how-it-works] 1. In the paywall editor, attach a **Custom callback** action to an element (button, form submit, etc.) and give it a name (e.g. `validate_email`). 2. When the user triggers that element the SDK calls your `onCustomCallback` handler with a `CustomCallback` object. 3. Your handler runs whatever logic is needed and returns a `CustomCallbackResult` — either `.success()` or `.failure()` — with optional data. 4. The paywall receives the result and executes the matching `onSuccess` or `onFailure` branch. Setting up the handler [#setting-up-the-handler] Register the handler on a `PaywallPresentationHandler` before calling `register`: ```swift let handler = PaywallPresentationHandler() handler.onCustomCallback { callback in switch callback.name { case "validate_email": let email = callback.variables?["email"] as? String if let email, isValidEmail(email) { return .success(data: ["validated": true]) } else { return .failure(data: ["error": "Invalid email"]) } default: return .failure() } } Superwall.shared.register(placement: "campaign_trigger", handler: handler) { // Feature launched } ``` CustomCallback [#customcallback] The `CustomCallback` struct is passed to your handler: CustomCallbackResult [#customcallbackresult] Return one of the following from your handler to signal the outcome: ```swift // Success — the paywall's onSuccess branch runs CustomCallbackResult.success(data: ["key": "value"]) // Failure — the paywall's onFailure branch runs CustomCallbackResult.failure(data: ["error": "Something went wrong"]) ``` Both `success()` and `failure()` accept an optional `data` dictionary whose values are sent back to the paywall and accessible as `callbacks..data.`. Callback behavior [#callback-behavior] When configuring the custom callback action in the paywall editor you can choose between two behaviors: * **Blocking** — the paywall waits for your handler to return before continuing the tap-action chain. Use this when the next step depends on the result (e.g. form validation). * **Non-blocking** — the paywall continues immediately. The `onSuccess` / `onFailure` handlers still fire when the result arrives, but subsequent actions in the chain do not wait. Accessing returned data in the paywall [#accessing-returned-data-in-the-paywall] Inside the paywall you can reference the returned data using the pattern `callbacks..data.`. For example, if the callback named `validate_email` returns `["validated": true]`, the paywall can access `callbacks.validate_email.data.validated`. # Custom Paywall Actions For example, adding a custom action called `help_center` to a button in your paywall gives you the opportunity to present a help center whenever that button is pressed. To set this up, implement `handleCustomPaywallAction(withName:)` in your `SuperwallDelegate`: :::ios ```swift Swift func handleCustomPaywallAction(withName name: String) { if name == "help_center" { HelpCenterManager.present() } } ``` ```swift Objective-C - (void)handleCustomPaywallActionWithName:(NSString *)name { if ([name isEqualToString:"help_center"]) { [HelpCenterManager present]; } } ``` :::
Remember to set `Superwall.shared.delegate`! For implementation details, see the [Superwall Delegate](/docs/sdk/guides/using-superwall-delegate) guide. # Purchasing Products Outside of a Paywall If you're using Superwall for revenue tracking, but want a hand with making purchases in your implementation, you can use our `purchase` methods: :::ios ```swift iOS // For StoreKit 1 private func purchase(_ product: SKProduct) async throws -> PurchaseResult { return await Superwall.shared.purchase(product) } // For StoreKit 2 private func purchase(_ product: StoreKit.Product) async throws -> PurchaseResult { return await Superwall.shared.purchase(product) } // Superwall's `StoreProduct` private func purchase(_ product: StoreProduct) async throws -> PurchaseResult { return await Superwall.shared.purchase(product) } ``` ::: For iOS, the `purchase()` method supports StoreKit 1, 2 and Superwall's abstraction over a product, `StoreProduct`. You can fetch the products you've added to Superwall via the `products(for:)` method. Similarly, in Android, you can fetch a product using a product identifier — and the first base plan will be selected: :::ios ```swift iOS private func fetchProducts(for identifiers: Set) async -> Set { return await Superwall.shared.products(for: identifiers) } ``` ::: If you already have your own product fetching code, simply pass the product representation to these methods. For example, in StoreKit 1 — an `SKProduct` instance, in StoreKit 2, `Product`, etc. Each `purchase()` implementation returns a `PurchaseResult`, which informs you of the transaction's resolution: * `.cancelled`: The purchase was cancelled. * `.purchased`: The product was purchased. * `.pending`: The purchase is pending/deferred and requires action from the developer. * `.failed(Error)`: The purchase failed for a reason other than the user cancelling or the payment pending. # Game Controller Support :::ios First, set the `SuperwallOption` `isGameControllerEnabled` to `true`: ```swift let options = SuperwallOptions() options.isGameControllerEnabled = true Superwall.configure(apiKey: "MY_API_KEY", options: options); ``` Forward events to your paywall by calling `gamepadValueChanged(gamepad:element:)` from your own gamepad's `valueChanged` handler: ```swift controller.extendedGamepad?.valueChangedHandler = { gamepad, element in // send values to Superwall Superwall.shared.gamepadValueChanged(gamepad: gamepad, element: element) // ... rest of your code } ``` ::: # Observer Mode If you wish to make purchases outside of Superwall's SDK and paywalls, you can use **observer mode** to report purchases that will appear in the Superwall dashboard, such as transactions: This is useful if you are using Superwall solely for revenue tracking, and you're making purchases using frameworks like StoreKit or Google Play Billing Library directly. Observer mode will also properly link user identifiers to transactions. To enable observer mode, set it using `SuperwallOptions` when configuring the SDK: :::ios ```swift iOS let options = SuperwallOptions() options.shouldObservePurchases = true Superwall.configure(apiKey: "your_api_key", options: options) ``` ::: There are a few things to keep in mind when using observer mode: 1. On iOS, if you're using StoreKit 2, then Superwall solely reports transaction completions. If you're using StoreKit 1, then Superwall will report transaction starts, abandons, and completions. 2. When using observer mode, you can't make purchases using our SDK — such as `Superwall.shared.purchase(aProduct)`. For more on setting up revenue tracking, check out this [doc](/docs/dashboard/dashboard-settings/overview-settings-revenue-tracking). # Retrieving and Presenting a Paywall Yourself If you want complete control over the paywall presentation process, you can use `getPaywall(forPlacement:params:paywallOverrides:delegate:)`. This returns the `UIViewController` subclass `PaywallViewController`, which you can then present however you like. Or, you can use a SwiftUI `View` via `PaywallView`. The following is code is how you'd mimic [register](/docs/sdk/quickstart/feature-gating): ```swift Swift final class MyViewController: UIViewController { private func presentPaywall() async { do { // 1 let paywallVc = try await Superwall.shared.getPaywall( forPlacement: "campaign_trigger", delegate: self ) self.present(paywallVc, animated: true) } catch let skippedReason as PaywallSkippedReason { // 2 switch skippedReason { case .holdout, .noAudienceMatch, .placementNotFound: break } } catch { // 3 print(error) } } private func launchFeature() { // Insert code to launch a feature that's behind your paywall. } } // 4 extension MyViewController: PaywallViewControllerDelegate { func paywall( _ paywall: PaywallViewController, didFinishWith result: PaywallResult, shouldDismiss: Bool ) { if shouldDismiss { paywall.dismiss(animated: true) } switch result { case .purchased, .restored: launchFeature() case .declined: let closeReason = paywall.info.closeReason let featureGating = paywall.info.featureGatingBehavior if closeReason != .forNextPaywall && featureGating == .nonGated { launchFeature() } } } } ``` ```swift Objective-C @interface MyViewController : UIViewController - (void)presentPaywall; @end @interface MyViewController () @end @implementation MyViewController - (void)presentPaywall { // 1 [[Superwall sharedInstance] getPaywallForEvent:@"campaign_trigger" params:nil paywallOverrides:nil delegate:self completion:^(SWKGetPaywallResult * _Nonnull result) { if (result.paywall != nil) { [self presentViewController:result.paywall animated:YES completion:nil]; } else if (result.skippedReason != SWKPaywallSkippedReasonNone) { switch (result.skippedReason) { // 2 case SWKPaywallSkippedReasonHoldout: case SWKPaywallSkippedReasonUserIsSubscribed: case SWKPaywallSkippedReasonEventNotFound: case SWKPaywallSkippedReasonNoRuleMatch: case SWKPaywallSkippedReasonNone: break; }; } else if (result.error) { // 3 NSLog(@"%@", result.error); } }]; } -(void)launchFeature { // Insert code to launch a feature that's behind your paywall. } // 4 - (void)paywall:(SWKPaywallViewController *)paywall didFinishWithResult:(enum SWKPaywallResult)result shouldDismiss:(BOOL)shouldDismiss { if (shouldDismiss) { [paywall dismissViewControllerAnimated:true completion:nil]; } SWKPaywallCloseReason closeReason; SWKFeatureGatingBehavior featureGating; switch (result) { case SWKPaywallResultPurchased: case SWKPaywallResultRestored: [self launchFeature]; break; case SWKPaywallResultDeclined: closeReason = paywall.info.closeReason; featureGating = paywall.info.featureGatingBehavior; if (closeReason != SWKPaywallCloseReasonForNextPaywall && featureGating == SWKFeatureGatingBehaviorNonGated) { [self launchFeature]; } break; } } @end ``` ```swift SwiftUI import SuperwallKit struct MyAwesomeApp: App { @State var store: AppStore = .init() init() { Superwall.configure(apiKey: "MyAPIKey") } var body: some Scene { WindowGroup { ContentView() .fullScreenCover(isPresented: $store.showPaywall) { // You can just use 'placement' at a minimum. The 'feature' // Closure fires if they convert PaywallView(placement: "a_placement", onSkippedView: { skip in switch skip { case .userIsSubscribed, .holdout(_), .noRuleMatch, .eventNotFound: MySkipView() } }, onErrorView: { error in MyErrorView() }, feature: { // User is subscribed as a result of the paywall purchase // Or they already were (which would happen in `onSkippedView`) }) } } } } ``` ```kotlin Kotlin // This is an example of how to use `getPaywall` to use a composable` import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView import com.superwall.sdk.Superwall import com.superwall.sdk.paywall.presentation.get_paywall.getPaywall import com.superwall.sdk.paywall.presentation.internal.request.PaywallOverrides import com.superwall.sdk.paywall.vc.PaywallView import com.superwall.sdk.paywall.vc.delegate.PaywallViewCallback @Composable fun PaywallComposable( event: String, params: Map? = null, paywallOverrides: PaywallOverrides? = null, callback: PaywallViewCallback, errorComposable: @Composable ((Throwable) -> Unit) = { error: Throwable -> // Default error composable Text(text = "No paywall to display") }, loadingComposable: @Composable (() -> Unit) = { // Default loading composable Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier.align(Alignment.Center), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { CircularProgressIndicator() } } } ) { val viewState = remember { mutableStateOf(null) } val errorState = remember { mutableStateOf(null) } val context = LocalContext.current LaunchedEffect(Unit) { PaywallBuilder(event) .params(params) .overrides(paywallOverrides) .delegate(delegate) .activity(context as Activity) .build() .fold(onSuccess = { viewState.value = it }, onFailure = { errorState.value = it }) } when { viewState.value != null -> { viewState.value?.let { viewToRender -> DisposableEffect(viewToRender) { viewToRender.onViewCreated() onDispose { viewToRender.beforeOnDestroy() viewToRender.encapsulatingActivity = null CoroutineScope(Dispatchers.Main).launch { viewToRender.destroyed() } } } AndroidView( factory = { context -> viewToRender } ) } } errorState.value != null -> { errorComposable(errorState.value!!) } else -> { loadingComposable() } } } ``` This does the following: 1. Gets the paywall view controller. 2. Handles the cases where the paywall was skipped. 3. Catches any presentation errors. 4. Implements the delegate. This is called when the user is finished with the paywall. First, it checks `shouldDismiss`. If this is true then is dismissed the paywall from view before launching any features. This may depend on the `result` depending on how you first presented your view. Then, it switches over the `result`. If the result is `purchased` or `restored` the feature can be launched. However, if the result is `declined`, it checks that the the `featureGating` property of `paywall.info` is `nonGated` and that the `closeReason` isn't `.forNextPaywall`. Best practices [#best-practices] 1. **Make sure to prevent a paywall from being accessed after a purchase has occurred**. If a user purchases from a paywall, it is your responsibility to make sure that the user can't access that paywall again. For example, if after successful purchase you decide to push a new view on to the navigation stack, you should make sure that the user can't go back to access the paywall. 2. **Make sure the paywall view controller deallocates before presenting it elsewhere**. If you have a paywall view controller presented somewhere and you try to present the same view controller elsewhere, you will get a crash. For example, you may have a paywall in a tab bar controller, and then you also try to present it modally. We plan on improving this, but currently it's your responsibility to ensure this doesn't happen. :::ios 3. **Listening for Loading State Changes**. If you have logic that depends on the progress of the paywall's loading state, you can use the delegate function `paywall(_:loadingStateDidChange:)`. Or, if you have an instance of a `PaywallViewController`, you can use the published property: ```swift let stateSub = paywall.$loadingState.sink { state in print(state) } ``` ::: # Request permissions from paywalls Overview [#overview] Use the **Request permission** action in the paywall editor when you want to gate features behind iOS permissions without sending users into your app settings flow. When the user taps the element, SuperwallKit presents the native prompt, reports the result back to the paywall so you can update the design, and emits analytics events you can forward through `SuperwallDelegate`. The **Request permission** action is rolling out to the paywall editor and is not visible in the dashboard just yet. We're shipping it very soon, so keep an eye on the changelog if you don't see it in your editor today. Add the action in the editor [#add-the-action-in-the-editor] 1. Open your paywall in the editor and select the button or element you want to wire up. 2. Set its action to **Request permission**. 3. Choose the permission to request. You can add multiple buttons if you need to prime more than one permission (for example, notification + camera). 4. Republish the paywall. No code changes are required beyond making sure the necessary Info.plist strings exist in your app. Supported permissions and Info.plist keys [#supported-permissions-and-infoplist-keys] | Editor option | `permission_type` sent from the paywall | Required Info.plist keys | Notes | | ------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | Notifications | `notification` | *None* | Uses `UNUserNotificationCenter` with alert, badge, and sound options. | | Location (When In Use) | `location` | `NSLocationWhenInUseUsageDescription` | Prompts for foreground access only. | | Location (Always) | `background_location` | `NSLocationWhenInUseUsageDescription`, `NSLocationAlwaysAndWhenInUseUsageDescription` | The SDK first ensures When-In-Use is granted, then escalates to Always. | | Photos | `read_images` | `NSPhotoLibraryUsageDescription` | Requests `.readWrite` access on iOS 14+. | | Contacts | `contacts` | `NSContactsUsageDescription` | Uses `CNContactStore.requestAccess`. | | Camera | `camera` | `NSCameraUsageDescription` | Uses `AVCaptureDevice.requestAccess`. | | Microphone | `microphone` | `NSMicrophoneUsageDescription` | Uses `AVAudioSession.requestRecordPermission()`. | | App Tracking Transparency | `tracking` | `NSUserTrackingUsageDescription` | iOS 14+ only. Uses `ATTrackingManager.requestTrackingAuthorization()`. | If a required Info.plist key is missing—or the platform does not support the permission, such as background location on visionOS—the action finishes with an `unsupported` status, and the delegate receives a `permissionDenied` event so you can log the misconfiguration. > **Note**: In iOS SDK 4.12.3, Contacts and Location permission requests were temporarily removed to prevent App Store warnings. If you need those, update to 4.12.4+. What the SDK tracks [#what-the-sdk-tracks] Each button tap generates three analytics events that flow through `handleSuperwallEvent(withInfo:)`: * `permission_requested` when the native dialog is about to appear. * `permission_granted` if the user allows access. * `permission_denied` if the user declines or the permission is unsupported on the current device. All three events include: ```json { "permission_name": "", "paywall_identifier": "" } ``` Use the associated `SuperwallEvent.permissionRequested`, `.permissionGranted`, and `.permissionDenied` cases to branch on outcomes: ```swift func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) { switch eventInfo.event { case .permissionRequested(let permission, let paywallId): Analytics.track("permission_requested", with: [ "permission": permission, "paywall_id": paywallId ]) case .permissionGranted(let permission, _): FeatureFlags.unlock(permission: permission) case .permissionDenied(let permission, _): Alerts.presentPermissionDeclinedCopy(for: permission) default: break } } ``` Status values returned to the paywall [#status-values-returned-to-the-paywall] The paywall receives a `permission_result` event with one of the following statuses so you can branch in your paywall logic (for example, swapping a button for a checklist item): * `granted` – The system reported success. * `denied` – The user denied the request or an earlier session already denied it. * `unsupported` – The permission is not available on the current device or the Info.plist copy block is missing. Because the permissions are requested from real user interaction, you can safely stack actions—for example, ask for notifications first and, on success, show a camera prompt that immediately appears inside the same paywall session. Troubleshooting [#troubleshooting] * See `unsupported`? Double-check the Info.plist keys in the table above and confirm the permission exists on the target OS (background location is not available on visionOS). * Nothing happens when you tap the button? Make sure the action is published as **Request permission** and that the app has been updated with the new paywall revision. * Want to show fallback copy after a denial? Configure `PaywallOptions.notificationPermissionsDenied` or handle the `permissionDenied` event in your delegate to display a Settings deep link. # Using the Presentation Handler You can provide a `PaywallPresentationHandler` to `register`, whose functions provide status updates for a paywall: * `onDismiss`: Called when the paywall is dismissed. Accepts a `PaywallInfo` object containing info about the dismissed paywall, and there is a `PaywallResult` informing you of any transaction. * `onPresent`: Called when the paywall did present. Accepts a `PaywallInfo` object containing info about the presented paywall. * `onError`: Called when an error occurred when trying to present a paywall. Accepts an `Error` indicating why the paywall could not present. * `onSkip`: Called when a paywall is skipped. Accepts a `PaywallSkippedReason` enum indicating why the paywall was skipped. * `onCustomCallback` *(Android 2.7.0+)*: Called when the paywall requests a custom callback. Accepts a `CustomCallback` containing the callback name and optional variables, and returns a `CustomCallbackResult` indicating success or failure with optional data to pass back to the paywall. ```swift Swift let handler = PaywallPresentationHandler() handler.onDismiss { paywallInfo, result in print("The paywall dismissed. PaywallInfo: \(paywallInfo). Result: \(result)") } handler.onPresent { paywallInfo in print("The paywall presented. PaywallInfo:", paywallInfo) } handler.onError { error in print("The paywall presentation failed with error \(error)") } handler.onSkip { reason in switch reason { case .holdout(let experiment): print("Paywall not shown because user is in a holdout group in Experiment: \(experiment.id)") case .noAudienceMatch: print("Paywall not shown because user doesn't match any audiences.") case .placementNotFound: print("Paywall not shown because this placement isn't part of a campaign.") } } Superwall.shared.register(placement: "campaign_trigger", handler: handler) { // Feature launched } ``` ```swift Objective-C SWKPaywallPresentationHandler *handler = [[SWKPaywallPresentationHandler alloc] init]; [handler onDismiss:^(SWKPaywallInfo * _Nonnull paywallInfo, enum SWKPaywallResult result, SWKStoreProduct * _Nullable product) { NSLog(@"The paywall presented. PaywallInfo: %@ - result: %ld", paywallInfo, (long)result); }]; [handler onPresent:^(SWKPaywallInfo * _Nonnull paywallInfo) { NSLog(@"The paywall presented. PaywallInfo: %@", paywallInfo); }]; [handler onError:^(NSError * _Nonnull error) { NSLog(@"The paywall presentation failed with error %@", error); }]; [handler onSkip:^(enum SWKPaywallSkippedReason reason) { switch (reason) { case SWKPaywallSkippedReasonUserIsSubscribed: NSLog(@"Paywall not shown because user is subscribed."); break; case SWKPaywallSkippedReasonHoldout: NSLog(@"Paywall not shown because user is in a holdout group."); break; case SWKPaywallSkippedReasonNoAudienceMatch: NSLog(@"Paywall not shown because user doesn't match any audiences."); break; case SWKPaywallSkippedReasonPlacementNotFound: NSLog(@"Paywall not shown because this placement isn't part of a campaign."); break; case SWKPaywallSkippedReasonNone: // The paywall wasn't skipped. break; } }]; [[Superwall sharedInstance] registerWithPlacement:@"campaign_trigger" params:nil handler:handler feature:^{ // Feature launched. }]; ``` ```kotlin Kotlin val handler = PaywallPresentationHandler() handler.onDismiss { paywallInfo, result -> println("The paywall dismissed. PaywallInfo: ${it}") } handler.onPresent { println("The paywall presented. PaywallInfo: ${it}") } handler.onError { println("The paywall errored. Error: ${it}") } handler.onSkip { when (it) { is PaywallSkippedReason.PlacementNotFound -> { println("The paywall was skipped because the placement was not found.") } is PaywallSkippedReason.Holdout -> { println("The paywall was skipped because the user is in a holdout group.") } is PaywallSkippedReason.NoAudienceMatch -> { println("The paywall was skipped because no audience matched.") } } } Superwall.instance.register(placement = "campaign_trigger", handler = handler) { // Feature launched } ``` ```dart Flutter PaywallPresentationHandler handler = PaywallPresentationHandler(); handler.onPresent((paywallInfo) async { String name = await paywallInfo.name; print("Handler (onPresent): $name"); }); handler.onDismiss((paywallInfo, paywallResult) async { String name = await paywallInfo.name; print("Handler (onDismiss): $name"); }); handler.onError((error) { print("Handler (onError): ${error}"); }); handler.onSkip((skipReason) async { String description = await skipReason.description; if (skipReason is PaywallSkippedReasonHoldout) { print("Handler (onSkip): $description"); final experiment = await skipReason.experiment; final experimentId = await experiment.id; print("Holdout with experiment: ${experimentId}"); } else if (skipReason is PaywallSkippedReasonNoAudienceMatch) { print("Handler (onSkip): $description"); } else if (skipReason is PaywallSkippedReasonPlacementNotFound) { print("Handler (onSkip): $description"); } else { print("Handler (onSkip): Unknown skip reason"); } }); Superwall.shared.registerPlacement("campaign_trigger", handler: handler, feature: () { // Feature launched }); ``` ```typescript React Native const handler = new PaywallPresentationHandler() handler.onPresent((paywallInfo) => { const name = paywallInfo.name console.log(`Handler (onPresent): ${name}`) }) handler.onDismiss((paywallInfo, paywallResult) => { const name = paywallInfo.name console.log(`Handler (onDismiss): ${name}`) }) handler.onError((error) => { console.log(`Handler (onError): ${error}`) }) handler.onSkip((skipReason) => { const description = skipReason.description if (skipReason instanceof PaywallSkippedReasonHoldout) { console.log(`Handler (onSkip): ${description}`) const experiment = skipReason.experiment const experimentId = experiment.id console.log(`Holdout with experiment: ${experimentId}`) } else if (skipReason instanceof PaywallSkippedReasonNoAudienceMatch) { console.log(`Handler (onSkip): ${description}`) } else if (skipReason instanceof PaywallSkippedReasonPlacementNotFound) { console.log(`Handler (onSkip): ${description}`) } else { console.log(`Handler (onSkip): Unknown skip reason`) } }) Superwall.shared.register({ placement: 'campaign_trigger', handler: handler, feature: () => { // Feature launched } }); ``` Wanting to see which product was just purchased from a paywall? Use `onDismiss` and the `result` parameter. Or, you can use the [SuperwallDelegate](/docs/sdk/guides/3rd-party-analytics#using-events-to-see-purchased-products). # Viewing Purchased Products When a paywall is presenting and a user converts, you can view the purchased products in several different ways. Use the `PaywallPresentationHandler` [#use-the-paywallpresentationhandler] Arguably the easiest of the options — simply pass in a presentation handler and check out the product within the `onDismiss` block. ```swift Swift let handler = PaywallPresentationHandler() handler.onDismiss { _, result in switch result { case .declined: print("No purchased occurred.") case .purchased(let product): print("Purchased \(product.productIdentifier)") case .restored: print("Restored purchases.") } } Superwall.shared.register(placement: "caffeineLogged", handler: handler) { logCaffeine() } ``` ```swift Objective-C SWKPaywallPresentationHandler *handler = [SWKPaywallPresentationHandler new]; [handler onDismiss:^(SWKPaywallInfo * _Nonnull info, enum SWKPaywallResult result, SWKStoreProduct * _Nullable product) { switch (result) { case SWKPaywallResultPurchased: NSLog(@"Purchased %@", product.productIdentifier); default: NSLog(@"Unhandled event."); } }]; [[Superwall sharedInstance] registerWithPlacement:@"caffeineLogged" params:@{} handler:handler feature:^{ [self logCaffeine]; }]; ``` ```kotlin Android val handler = PaywallPresentationHandler() handler.onDismiss { _, paywallResult -> when (paywallResult) { is PaywallResult.Purchased -> { // The user made a purchase! val purchasedProductId = paywallResult.productId println("User purchased product: $purchasedProductId") // ... do something with the purchased product ID ... } is PaywallResult.Declined -> { // The user declined to make a purchase. println("User declined to make a purchase.") // ... handle the declined case ... } is PaywallResult.Restored -> { // The user restored a purchase. println("User restored a purchase.") // ... handle the restored case ... } } } Superwall.instance.register(placement = "caffeineLogged", handler = handler) { logCaffeine() } ``` ```dart Flutter PaywallPresentationHandler handler = PaywallPresentationHandler(); handler.onDismiss((paywallInfo, paywallResult) async { String name = await paywallInfo.name; print("Handler (onDismiss): $name"); switch (paywallResult) { case PurchasedPaywallResult(productId: var id): // The user made a purchase! print('User purchased product: $id'); // ... do something with the purchased product ID ... break; case DeclinedPaywallResult(): // The user declined to make a purchase. print('User declined the paywall.'); // ... handle the declined case ... break; case RestoredPaywallResult(): // The user restored a purchase. print('User restored a previous purchase.'); // ... handle the restored case ... break; } }); Superwall.shared.registerPlacement( "caffeineLogged", handler: handler, feature: () { logCaffeine(); }); ``` ```typescript React Native import * as React from "react" import Superwall from "../../src" import { PaywallPresentationHandler, PaywallInfo } from "../../src" import type { PaywallResult } from "../../src/public/PaywallResult" const Home = () => { const navigation = useNavigation() const presentationHandler: PaywallPresentationHandler = { onDismiss: (handler: (info: PaywallInfo, result: PaywallResult) => void) => { handler = (info, result) => { console.log("Paywall dismissed with info:", info, "and result:", result) if (result.type === "purchased") { console.log("Product purchased with ID:", result.productId) } } }, onPresent: (handler: (info: PaywallInfo) => void) => { handler = (info) => { console.log("Paywall presented with info:", info) // Add logic for when the paywall is presented } }, onError: (handler: (error: string) => void) => { handler = (error) => { console.error("Error presenting paywall:", error) // Handle any errors that occur during presentation } }, onSkip: () => { console.log("Paywall presentation skipped") // Handle the case where the paywall presentation is skipped }, } const nonGated = () => { Superwall.shared.register({ placement: "non_gated", handler: presentationHandler, feature: () => { navigation.navigate("caffeineLogged", { value: "Go for caffeine logging", }) }); } return // Your view code here } ``` Use `SuperwallDelegate` [#use-superwalldelegate] Next, the [SuperwallDelegate](/docs/sdk/guides/using-superwall-delegate) offers up much more information, and can inform you of virtually any Superwall event that occurred: ```swift Swift class SWDelegate: SuperwallDelegate { func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) { switch eventInfo.event { case .transactionComplete(_, let product, _, _): print("Transaction complete: product: \(product.productIdentifier)") case .subscriptionStart(let product, _): print("Subscription start: product: \(product.productIdentifier)") case .freeTrialStart(let product, _): print("Free trial start: product: \(product.productIdentifier)") case .transactionRestore(_, _): print("Transaction restored") case .nonRecurringProductPurchase(let product, _): print("Consumable product purchased: \(product.id)") default: print("Unhandled event.") } } } @main struct Caffeine_PalApp: App { @State private var swDelegate: SWDelegate = .init() init() { Superwall.configure(apiKey: "my_api_key") Superwall.shared.delegate = swDelegate } var body: some Scene { WindowGroup { ContentView() } } } ``` ```swift Objective-C // SWDelegate.h... #import @import SuperwallKit; NS_ASSUME_NONNULL_BEGIN @interface SWDelegate : NSObject @end NS_ASSUME_NONNULL_END // SWDelegate.m... @implementation SWDelegate - (void)handleSuperwallEventWithInfo:(SWKSuperwallEventInfo *)eventInfo { switch(eventInfo.event) { case SWKSuperwallEventTransactionComplete: NSLog(@"Transaction complete: %@", eventInfo.params[@"primary_product_id"]); } } // In AppDelegate.m... #import "AppDelegate.h" #import "SWDelegate.h" @import SuperwallKit; @interface AppDelegate () @property (strong, nonatomic) SWDelegate *delegate; @end @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // Override point for customization after application launch. self.delegate = [SWDelegate new]; [Superwall configureWithApiKey:@"my_api_key"]; [Superwall sharedInstance].delegate = self.delegate; return YES; } ``` ```kotlin Android class SWDelegate : SuperwallDelegate { override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) { when (eventInfo.event) { is SuperwallPlacement.TransactionComplete -> { val transaction = (eventInfo.event as SuperwallPlacement.TransactionComplete).transaction val product = (eventInfo.event as SuperwallPlacement.TransactionComplete).product val paywallInfo = (eventInfo.event as SuperwallPlacement.TransactionComplete).paywallInfo println("Transaction Complete: $transaction, Product: $product, Paywall Info: $paywallInfo") } else -> { // Handle other cases } } } } class MyApplication : Application() { override fun onCreate() { super.onCreate() Superwall.configure(this, "my_api_key") Superwall.instance.delegate = SWDelegate() } } ``` ```dart Flutter import 'dart:io'; import 'package:flutter/material.dart'; import 'package:superwallkit_flutter/superwallkit_flutter.dart'; class _MyAppState extends State implements SuperwallDelegate { final logging = Logging(); @override void initState() { super.initState(); configureSuperwall(useRevenueCat); } Future configureSuperwall(bool useRevenueCat) async { try { final apiKey = Platform.isIOS ? 'ios_api_project_key' : 'android_api_project_key'; final logging = Logging(); logging.level = LogLevel.warn; logging.scopes = {LogScope.all}; final options = SuperwallOptions(); options.paywalls.shouldPreload = false; options.logging = logging; Superwall.configure(apiKey, purchaseController: null, options: options, completion: () { logging.info('Executing Superwall configure completion block'); }); Superwall.shared.setDelegate(this); } catch (e) { // Handle any errors that occur during configuration logging.error('Failed to configure Superwall:', e); } } @override Future handleSuperwallEvent(SuperwallEventInfo eventInfo) async { switch (eventInfo.event.type) { case PlacementType.transactionComplete: final product = eventInfo.params?['product']; logging.info('Transaction complete event received with product: $product'); // Add any additional logic you need to handle the transaction complete event break; // Handle other events if necessary default: logging.info('Unhandled event type: ${eventInfo.event.type}'); break; } } } ``` ```typescript React Native import { PaywallInfo, SubscriptionStatus, SuperwallDelegate, SuperwallPlacementInfo, PlacementType, } from '../../src'; export class MySuperwallDelegate extends SuperwallDelegate { handleSuperwallPlacement(placementInfo: SuperwallPlacementInfo) { console.log('Handling Superwall placement:', placementInfo); switch (placementInfo.placement.type) { case PlacementType.transactionComplete: const product = placementInfo.params?.["product"]; if (product) { console.log(`Product: ${product}`); } else { console.log("Product not found in params."); } break; default: break; } } } export default function App() { const delegate = new MySuperwallDelegate(); React.useEffect(() => { const setupSuperwall = async () => { const apiKey = Platform.OS === 'ios' ? 'ios_api_project_key' : 'android_api_project_key'; Superwall.configure({ apiKey: apiKey, }); Superwall.shared.setDelegate(delegate); }; } } ``` Use a purchase controller [#use-a-purchase-controller] If you are controlling the purchasing pipeline yourself via a [purchase controller](/docs/sdk/guides/advanced-configuration), then naturally the purchased product is available: ```swift Swift final class MyPurchaseController: PurchaseController { func purchase(product: StoreProduct) async -> PurchaseResult { print("Kicking off purchase of \(product.productIdentifier)") do { let result = try await MyPurchaseLogic.purchase(product: product) return .purchased // .cancelled, .pending, .failed(Error) } catch { return .failed(error) } } // 2 func restorePurchases() async -> RestorationResult { print("Restoring purchases") return .restored // false } } @main struct Caffeine_PalApp: App { private let pc: MyPurchaseController = .init() init() { Superwall.configure(apiKey: "my_api_key", purchaseController: pc) } var body: some Scene { WindowGroup { ContentView() } } } ``` ```swift Objective-C // In MyPurchaseController.h... #import @import SuperwallKit; @import StoreKit; NS_ASSUME_NONNULL_BEGIN @interface MyPurchaseController : NSObject + (instancetype)sharedInstance; @end NS_ASSUME_NONNULL_END // In MyPurchaseController.m... #import "MyPurchaseController.h" @implementation MyPurchaseController + (instancetype)sharedInstance { static MyPurchaseController *sharedInstance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ sharedInstance = [MyPurchaseController new]; }); return sharedInstance; } - (void)purchaseWithProduct:(SWKStoreProduct * _Nonnull)product completion:(void (^ _Nonnull)(enum SWKPurchaseResult, NSError * _Nullable))completion { NSLog(@"Kicking off purchase of %@", product.productIdentifier); // Do purchase logic here completion(SWKPurchaseResultPurchased, nil); } - (void)restorePurchasesWithCompletion:(void (^ _Nonnull)(enum SWKRestorationResult, NSError * _Nullable))completion { // Do restore logic here completion(SWKRestorationResultRestored, nil); } @end // In AppDelegate.m... #import "AppDelegate.h" #import "MyPurchaseController.h" @import SuperwallKit; @interface AppDelegate () @end @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // Override point for customization after application launch. [Superwall configureWithApiKey:@"my_api_key" purchaseController:[MyPurchaseController sharedInstance] options:nil completion:^{ }]; return YES; } ``` ```kotlin Android class MyPurchaseController(val context: Context): PurchaseController { override suspend fun purchase( activity: Activity, productDetails: ProductDetails, basePlanId: String?, offerId: String? ): PurchaseResult { println("Kicking off purchase of $basePlanId") return PurchaseResult.Purchased() } override suspend fun restorePurchases(): RestorationResult { TODO("Not yet implemented") } } class MyApplication : Application() { override fun onCreate() { super.onCreate() Superwall.configure(this, "my_api_key", purchaseController = MyPurchaseController(this)) } } ``` ```dart Flutter class MyPurchaseController extends PurchaseController { // 1 @override Future purchaseFromAppStore(String productId) async { print('Attempting to purchase product with ID: $productId'); // Do purchase logic return PurchaseResult.purchased; } @override Future purchaseFromGooglePlay( String productId, String? basePlanId, String? offerId ) async { print('Attempting to purchase product with ID: $productId and basePlanId: $basePlanId'); // Do purchase logic return PurchaseResult.purchased; } @override Future restorePurchases() async { // Do resture logic } } ``` ```typescript React Native export class MyPurchaseController extends PurchaseController { // 1 async purchaseFromAppStore(productId: string): Promise { console.log("Kicking off purchase of ", productId) // Purchase logic return await this._purchaseStoreProduct(storeProduct) } async purchaseFromGooglePlay( productId: string, basePlanId?: string, offerId?: string ): Promise { console.log("Kicking off purchase of ", productId, " base plan ID", basePlanId) // Purchase logic return await this._purchaseStoreProduct(storeProduct) } // 2 async restorePurchases(): Promise { // TODO // ---- // Restore purchases and return true if successful. } } ``` SwiftUI - Use `PaywallView` [#swiftui---use-paywallview] The `PaywallView` allows you to show a paywall by sending it a placement. It also has a dismiss handler where the purchased product will be vended: ```swift @main struct Caffeine_PalApp: App { @State private var presentPaywall: Bool = false init() { Superwall.configure(apiKey: "my_api_key") } var body: some Scene { WindowGroup { Button("Log") { presentPaywall.toggle() } .sheet(isPresented: $presentPaywall) { PaywallView(placement: "caffeineLogged", params: nil, paywallOverrides: nil) { info, result in switch result { case .declined: print("No purchased occurred.") case .purchased(let product): print("Purchased \(product.productIdentifier)") case .restored: print("Restored purchases.") } } feature: { print("Converted") presentPaywall.toggle() } } } } } ``` # App Store Privacy Labels When submitting your app for review, you'll need to fill out an App Store Privacy label. When using the Superwall SDK, there are a few choices you may need to consider. App Store Privacy Labels [#app-store-privacy-labels] Privacy disclosures in regards to how data is processed or otherwise used are required when submitting an app for review on the App Store. When using the Superwall SDK, there are a few options you'll need to select to comply with this requirement. **At a minimum, you'll need to select "Purchases":** When you select "Purchases", you'll need to scroll down finish setup. When you do, there are two options you'll need to select: 1. Analytics 2. App Functionality Identifying Users [#identifying-users] How you proceed with the next prompt depends on how you are identifying users. If you *are* identifying users via their email or any other means, disclose that here. Note that the Superwall SDK does not do this. Finally, Superwall does not track purchase history of users for advertising purposes — so you can choose "No" here (unless you're using other SDKs which do this, or you're performing any purchase history tracking for advertising purposes on your own ): In terms of the Superwall SDK, that's all you need to choose. But again, remember that your privacy label could look different depending on how you process data, how other SDKs are used and more. Collected Data [#collected-data] Here is a detailed list of anything that might be collected in the Superwall SDK: | Property | Description | | ----------------------------- | --------------------------------------------------------------- | | `publicApiKey` | The API key for accessing the public API. | | `platform` | The operating system of the device (e.g., iOS, Android). | | `appUserId` | A unique identifier for the app user. | | `aliases` | List of aliases associated with the app user. | | `vendorId` | The vendor ID of the device. | | `appVersion` | The version of the app. | | `osVersion` | The operating system version running on the device. | | `deviceModel` | The model of the device (e.g., iPhone or Android device model). | | `deviceLocale` | The current locale set on the device. | | `preferredLocale` | The preferred locale of the user. | | `deviceLanguageCode` | The language code of the device's system language. | | `preferredLanguageCode` | The preferred language code set by the user. | | `regionCode` | The region code set on the device. | | `preferredRegionCode` | The preferred region code of the user. | | `deviceCurrencyCode` | The currency code for transactions on the device. | | `deviceCurrencySymbol` | The currency symbol based on the device’s settings. | | `interfaceType` | The type of user interface (e.g., vision, ipad, etc). | | `timezoneOffset` | The device’s current timezone offset in minutes. | | `radioType` | The network radio type (e.g., WiFi, Cellular). | | `interfaceStyle` | The interface style (e.g., light or dark mode). | | `isLowPowerModeEnabled` | Indicates whether low power mode is enabled. | | `bundleId` | The bundle identifier of the app. | | `appInstallDate` | The date the app was installed. | | `isMac` | A boolean indicating if the device is a Mac. | | `daysSinceInstall` | The number of days since the app was installed. | | `minutesSinceInstall` | The number of minutes since the app was installed. | | `daysSinceLastPaywallView` | The number of days since the last paywall view. | | `minutesSinceLastPaywallView` | The number of minutes since the last paywall view. | | `totalPaywallViews` | The total number of paywall views. | | `utcDate` | The current UTC date. | | `localDate` | The local date of the device. | | `utcTime` | The current UTC time. | | `localTime` | The local time on the device. | | `utcDateTime` | The UTC date and time combined. | | `localDateTime` | The local date and time combined. | | `isSandbox` | Indicates if the app is running in a sandbox environment. | | `subscriptionStatus` | The subscription status of the app user. | | `isFirstAppOpen` | Boolean indicating if it is the user’s first app open. | | `sdkVersion` | The current version of the SDK. | | `sdkVersionPadded` | The padded version of the SDK (e.g. 001.002.003-beta.001). | | `appBuildString` | The app’s build string identifier. | | `appBuildStringNumber` | The numeric value of the app’s build number. | | `interfaceStyleMode` | The current interface style mode (e.g., dark, light). | | `ipRegion` | The region derived from the device's IP address. | | `ipRegionCode` | The region code derived from the device's IP. | | `ipCountry` | The country derived from the device's IP address. | | `ipCity` | The city derived from the device's IP address. | | `ipContinent` | The continent derived from the device's IP address. | | `ipTimezone` | The timezone derived from the device's IP address. | | `capabilities` | A string indicating any Superwall-SDK specific capabilities. | | `capabilitiesConfig` | A JSON configuration of the above capabilities. | | `platformWrapper` | The platform wrapper (e.g., React Native). | | `platformWrapperVersion` | The version of the platform wrapper. | # Advanced Configuration Logging [#logging] Logging is enabled by default in the SDK and is controlled by two properties: `level` and `scopes`. `level` determines the minimum log level to print to the console. There are five types of log level: 1. **debug**: Prints all logs from the SDK to the console. Useful for debugging your app if something isn't working as expected. 2. **info**: Prints errors, warnings, and useful information from the SDK to the console. 3. **warn**: Prints errors and warnings from the SDK to the console. 4. **error**: Only prints errors from the SDK to the console. 5. **none**: Turns off all logs. The SDK defaults to `info`. `scopes` defines the scope of logs to print to the console. For example, you might only care about logs relating to `paywallPresentation` and `paywallTransactions`. This defaults to `.all`. Check out [LogScope](https://sdk.superwall.me/documentation/superwallkit/logscope) for all possible cases. You set these properties like this: :::ios ```swift Swift let options = SuperwallOptions() options.logging.level = .warn options.logging.scopes = [.paywallPresentation, .paywallTransactions] Superwall.configure(apiKey:"MY_API_KEY", options: options); // Or you can set: Superwall.shared.logLevel = .warn ``` ```swift Objective-C SWKSuperwallOptions *options = [[SWKSuperwallOptions alloc] init]; options.logging.level = SWKLogLevelWarn; [Superwall configureWithApiKey:@"pk_e6bd9bd73182afb33e95ffdf997b9df74a45e1b5b46ed9c9" purchaseController:nil options:options completion:nil ]; [Superwall sharedInstance].logLevel = SWKLogLevelWarn; ``` ::: Preloading Paywalls [#preloading-paywalls] Paywalls are preloaded by default when the app is launched from a cold start. The paywalls that are preloaded are determined by the list of placements that result in a paywall for the user when [registered](/docs/sdk/quickstart/feature-gating). Preloading is smart, only preloading paywalls that belong to audiences that could be matched. Paywalls are cached by default, which means after they load once, they don't need to be reloaded from the network unless you make a change to them on the dashboard. However, if you have a lot of paywalls, preloading may increase network usage of your app on first load of the paywalls and result in slower loading times overall. To make an onboarding or first-launch paywall load before the rest of your campaigns, prioritize the campaign from the dashboard with [Priority Placements](/docs/dashboard/dashboard-campaigns/campaigns-placements-prioritized). Use the SDK methods below when you need to disable automatic preloading or manually preload specific placements. You can turn off preloading by setting `shouldPreload` to `false`: :::ios ```swift Swift let options = SuperwallOptions() options.paywalls.shouldPreload = false Superwall.configure(apiKey: "MY_API_KEY", options: options) ``` ```swift Objective-C SWKSuperwallOptions *options = [[SWKSuperwallOptions alloc] init]; options.paywalls.shouldPreload = false; [Superwall configureWithApiKey:@"MY_API_KEY" purchaseController:nil options:options completion:nil ]; ``` ::: Then, if you'd like to preload paywalls for specific placements you can use `preloadPaywalls(forPlacements:)`: :::ios ```swift Swift Superwall.shared.preloadPaywalls(forPlacements: ["campaign_trigger"]); ``` ```swift Objective-C NSMutableSet *eventNames = [NSMutableSet set]; [eventNames addObject:@"campaign_trigger"]; [[Superwall sharedInstance] preloadPaywallsForPlacements:placementNames]; ``` ::: If you'd like to preload all paywalls you can use `preloadAllPaywalls()`: :::ios ```swift Swift Superwall.shared.preloadAllPaywalls() ``` ```swift Objective-C [[Superwall sharedInstance] preloadAllPaywalls]; ``` ::: Note: These methods will not reload any paywalls that have already been preloaded. External Data Collection [#external-data-collection] By default, Superwall sends all registered events and properties back to the Superwall servers. However, if you have privacy concerns, you can stop this by setting `isExternalDataCollectionEnabled` to `false`: :::ios ```swift Swift let options = SuperwallOptions() options.isExternalDataCollectionEnabled = false Superwall.configure(apiKey: "MY_API_KEY", options: options) ``` ```swift Objective-C SWKSuperwallOptions *options = [[SWKSuperwallOptions alloc] init]; options.isExternalDataCollectionEnabled = false; [Superwall configureWithApiKey:@"MY_API_KEY" purchaseController:nil options:options completion:nil]; ``` ::: Disabling this will not affect your ability to create triggers based on properties. Automatically Dismissing the Paywall [#automatically-dismissing-the-paywall] By default, Superwall automatically dismisses the paywall when a product is purchased or restored. You can disable this by setting `automaticallyDismiss` to `false`: :::ios ```swift Swift let options = SuperwallOptions() options.paywalls.automaticallyDismiss = false Superwall.configure(apiKey: "MY_API_KEY", options: options) ``` ```swift Objective-C SWKSuperwallOptions *options = [[SWKSuperwallOptions alloc] init]; options.automaticallyDismiss = false; [Superwall configureWithApiKey:@"MY_API_KEY" purchaseController:nil options:options completion:^{}]; ``` ::: To manually dismiss the paywall , call `Superwall.shared.dismiss()`. Custom Restore Failure Message [#custom-restore-failure-message] You can set the title, message and close button title for the alert that appears after a restoration failure: :::ios ```swift Swift let options = SuperwallOptions() options.paywalls.restoreFailed.title = "My Title" options.paywalls.restoreFailed.message = "My message" options.paywalls.restoreFailed.closeButtonTitle = "Close" Superwall.configure(apiKey: "MY_API_KEY", options: options) ``` ```swift Objective-C SWKSuperwallOptions *options = [[SWKSuperwallOptions alloc] init]; options.paywalls.restoreFailed.title = @"My Title"; options.paywalls.restoreFailed.message = @"My message"; options.paywalls.restoreFailed.closeButtonTitle = @"Close"; [Superwall configureWithApiKey:@"MY_API_KEY" purchaseController:nil options:options completion:nil]; ``` ::: Haptic Feedback [#haptic-feedback] On iOS, the paywall uses haptic feedback by default after a user purchases or restores a product, opens a URL from the paywall, or closes the paywall. To disable this, set the `isHapticFeedbackEnabled` `PaywallOption` to false: :::ios ```swift Swift let options = SuperwallOptions() options.paywalls.isHapticFeedbackEnabled = false Superwall.configure(apiKey: "MY_API_KEY", options: options) ``` ```swift Objective-C SWKSuperwallOptions *options = [[SWKSuperwallOptions alloc] init]; options.isHapticFeedbackEnabled = false; [Superwall configureWithApiKey:@"MY_API_KEY" purchaseController:nil options:options completion:^{}]; ``` ::: Note: Android does not use haptic feedback. Transaction Background View [#transaction-background-view] During a transaction, we add a `UIActivityIndicator` behind the view to indicate a loading status. However, you can remove this by setting the `transactionBackgroundView` to `nil`: :::ios ```swift Swift let options = SuperwallOptions() options.paywalls.transactionBackgroundView = nil Superwall.configure(apiKey: "MY_API_KEY", options: options) ``` ```swift Objective-C SWKSuperwallOptions *options = [[SWKSuperwallOptions alloc] init]; options.paywalls.transactionBackgroundView = SWKTransactionBackgroundViewNone; [Superwall configureWithApiKey:@"MY_API_KEY" purchaseController:nil options:options completion:nil ]; ``` ::: Purchase Failure Alert [#purchase-failure-alert] When a purchase fails, we automatically present an alert with the error message. If you'd like to show your own alert after failure, set the `shouldShowPurchaseFailureAlert` `PaywallOption` to `false`: :::ios ```swift Swift let options = SuperwallOptions() options.paywalls.shouldShowPurchaseFailureAlert = false Superwall.configure(apiKey: "MY_API_KEY", options: options) ``` ```swift Objective-C SWKSuperwallOptions *options = [[SWKSuperwallOptions alloc] init]; options.paywalls.shouldShowPurchaseFailureAlert = false; [Superwall configureWithApiKey:@"MY_API_KEY" purchaseController:nil options:options completion:nil ]; ``` ::: Web Purchase Confirmation Alert [#web-purchase-confirmation-alert] When a user completes a purchase via web checkout (app2web flow), you can control whether to show a confirmation alert. By default, this is set to `false` to prevent duplicate alerts. Set `shouldShowWebPurchaseConfirmationAlert` to `true` if you want to show the native confirmation alert: :::ios ```swift Swift let options = SuperwallOptions() options.paywalls.shouldShowWebPurchaseConfirmationAlert = true Superwall.configure(apiKey: "MY_API_KEY", options: options) ``` ```swift Objective-C SWKSuperwallOptions *options = [[SWKSuperwallOptions alloc] init]; options.paywalls.shouldShowWebPurchaseConfirmationAlert = true; [Superwall configureWithApiKey:@"MY_API_KEY" purchaseController:nil options:options completion:nil ]; ``` ::: Locale Identifier [#locale-identifier] When evaluating rules, the device locale identifier is set to `autoupdatingCurrent`. However, you can override this if you want to test a specific locale: :::ios ```swift Swift let options = SuperwallOptions() options.localeIdentifier = "en_GB" Superwall.configure(apiKey: "MY_API_KEY", options: options) // Or you can set: Superwall.shared.localeIdentifier = "en_GB" // To revert to default: Superwall.shared.localeIdentifier = nil ``` ```swift Objective-C SWKSuperwallOptions *options = [[SWKSuperwallOptions alloc] init]; options.localeIdentifier = @"en_GB"; [Superwall configureWithApiKey:@"MY_API_KEY" purchaseController:nil options:options completion:^{}]; // Or you can set: [Superwall sharedInstance].localeIdentifier = "en_GB" // To revert to default: [Superwall sharedInstance].localeIdentifier = nil ``` ::: For a list of locales that are available on iOS, take a look at [this list](https://gist.github.com/jacobbubu/1836273). You can also preview your paywall in different locales using [In-App Previews](/docs/sdk/quickstart/in-app-paywall-previews). Game Controller [#game-controller] If you're using a game controller, you can enable this in `SuperwallOptions` too. Check out our [Game Controller Support](/docs/sdk/guides/advanced/game-controller-support) article. Take a look at [SuperwallOptions](https://sdk.superwall.me/documentation/superwallkit/superwalloptions) in our SDK reference for more info. # Consumable Products Use consumable products when a purchase should grant a quantity that can be used up, such as credits, coins, boosts, or tokens. This guide assumes purchases are made from Superwall paywalls and that you are not using a `PurchaseController`. Consumable products are one-time purchases that users can buy repeatedly, such as credits, tokens, boosts, or packs. Non-consumable products are also one-time purchases, but they grant permanent access, such as a lifetime unlock. Superwall uses entitlements to decide whether a user has ongoing access. Because consumables are meant to be used up, they should usually not grant entitlements. Your app should listen for the purchase, grant the consumable benefit in your own system, and treat Superwall's purchase history as a record of what happened. Dashboard Setup [#dashboard-setup] 1. Create the consumable in App Store Connect. 2. Add the product in Superwall from **Products**. 3. Use the App Store product identifier. 4. Set **Period** to **None (Lifetime / Consumable)**. 5. Leave **Entitlements** empty. 6. Add the product to any paywall that should sell it. Do not attach an entitlement to a consumable unless the purchase should also unlock ongoing access. If a consumable has no entitlement, buying it does not make the user's subscription status active. Include Consumables In Purchase History [#include-consumables-in-purchase-history] Apple excludes consumable purchases from App Store purchase history unless you opt in. Add `SKIncludeConsumableInAppPurchaseHistory` to your app's `Info.plist` as a Boolean set to `YES`. ```xml Info.plist SKIncludeConsumableInAppPurchaseHistory ``` When this key is present and set to `YES`, Superwall uses StoreKit 2 on iOS 18 and later. On earlier iOS versions, the SDK falls back to StoreKit 1 for purchase history support. Grant The Consumable Benefit [#grant-the-consumable-benefit] Superwall does not maintain balances for consumables. Grant credits, tokens, or other benefits from your app or backend after the `transactionComplete` event. Make this operation idempotent so retries do not double-credit the user. ```swift Swift import SuperwallKit final class SWDelegate: SuperwallDelegate { func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) { guard case let .transactionComplete(transaction, product, _, _) = eventInfo.event else { return } guard product.productIdentifier == "com.example.credits_100" else { return } Task { await ConsumablesService.shared.grantCredits( count: 100, productId: product.productIdentifier, transactionId: transaction?.storeTransactionId ) } } } Superwall.shared.delegate = SWDelegate() ``` ```swift Objective-C #import @interface SWDelegate : NSObject @end @implementation SWDelegate - (void)handleSuperwallEventWithInfo:(SWKSuperwallEventInfo *)eventInfo { if (eventInfo.event != SWKSuperwallEventTransactionComplete) { return; } NSString *productId = eventInfo.params[@"primary_product_id"]; if (![productId isEqualToString:@"com.example.credits_100"]) { return; } NSString *transactionId = eventInfo.params[@"store_transaction_id"]; [[ConsumablesService shared] grantCredits:100 productId:productId transactionId:transactionId]; } @end [Superwall sharedInstance].delegate = [SWDelegate new]; ``` Read Purchase History [#read-purchase-history] Consumable and non-consumable purchases appear in `customerInfo.nonSubscriptions`. Use `isConsumable` to distinguish consumables from lifetime purchases. ```swift Swift let customerInfo = Superwall.shared.customerInfo let consumables = customerInfo.nonSubscriptions.filter { $0.isConsumable } for purchase in consumables { print("Consumable purchased: \(purchase.productId)") } ``` ```swift Objective-C SWKCustomerInfo *customerInfo = [Superwall sharedInstance].customerInfo; for (SWKNonSubscriptionTransaction *purchase in customerInfo.nonSubscriptions) { if (purchase.isConsumable) { NSLog(@"Consumable purchased: %@", purchase.productId); } } ``` # Custom Store Products 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`](/docs/ios/sdk-reference/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 [#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](/docs/ios/guides/web-checkout). Custom Store Products are for purchase flows that your app handles from `PurchaseController`. Add a PurchaseController [#add-a-purchasecontroller] Pass a `PurchaseController` when configuring Superwall: ```swift Swift 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`. ```swift Swift 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 [#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: ```swift Swift let activeProductIds: Set = ["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 [#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 [#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 [#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](/docs/ios/guides/advanced-configuration). # Making Purchases Making purchases of a consumable, non-consumable or subscription product in Superwall takes only one line. You can use this whether or not you are using Superwall's paywalls: ```swift let result = await Superwall.shared.purchase(product) ``` This method takes a `StoreProduct` and returns a `PurchaseResult` so you can take action on the result. Here's an example from our demo app, [Caffeine Pal](https://github.com/superwall/CaffeinePal/blob/using-superwall-sdk/Caffeine%20Pal/Store%20and%20Models/CaffeineStore.swift#L121): `Superwall.shared.purchase(product)` is for StoreKit-backed products. For Custom Store Products on a paywall, handle the purchase in your [`PurchaseController`](/docs/ios/sdk-reference/PurchaseController) using `product.productIdentifier`. See [Custom Store Products](/docs/ios/guides/custom-store-products). ```swift func purchase(_ product: StoreProduct) async throws { let result = await Superwall.shared.purchase(product) switch result { case .cancelled: throw CaffeinePalStoreFrontError.cancelled case .purchased: // In `handleSuperwallEvent` delegate method, we'll check if an espresso recipe was // Purchased and if it was, we'll add it to the purchased drinks set. print("Purchased product \(product.productIdentifier)") case .pending: throw CaffeinePalStoreFrontError.pending case .failed(let error): throw error } } ``` For the SDK reference, check out this [page](/docs/ios/guides/advanced/direct-purchasing). The flow looks like this: 1. Fetch your products. 2. Call `purchase` on any of them. 3. Respond to the result. Here's an example: Fetch products [#fetch-products] A `StoreProduct` can be fetched using its corresponding identifier from App Store Connect or a [StoreKit Configuration File](/docs/ios/guides/testing-purchases). Custom Store Products are loaded from paywalls and are not fetched with `products(for:)`. For example, `subscription.caffeinePalPro.monthly` here: That product could be fetched like so: ```swift let caffeineSub = await Superwall.shared.products(for: Set(["subscription.caffeinePalPro.monthly"])) ``` Call purchase [#call-purchase] Now, simply call `purchase`: ```swift let result = await Superwall.shared.purchase(caffeineSub) ``` Respond to result [#respond-to-result] Finally, respond to the result: ```swift switch result { case .cancelled: // user cancelled the purchase flow case .purchased: // Purchase completed case .pending: // Purchase in flight case .failed(let error): // Couldn't purchase, check out the error } ``` There are a number of additional ways to respond to a purchase outside of this `result`, depending on how the product was purchased (for example, within a paywall). For examples, see this [doc](/docs/ios/guides/advanced/viewing-purchased-products). # Article-Style Paywalls: Inline with Additional Plans Article-style paywalls let you keep readers in the flow of a long-form page while still prompting for upgrade options. You can place an inline paywall inside a scroll view, then present a second, full-screen paywall when users tap “see more plans.” This pattern is common in paid media and magazine apps: a portion of the article is readable, the rest is blurred or gated, and a footer paywall offers an inline purchase with a “see more plans” option that opens a full-screen paywall. Check out this working example:
This guide will show you how to build this example by explaining the APIs involved, and then a full code sample. There’s also a live working example in [CaffeinePal](https://github.com/superwall/CaffeinePal/tree/using-superwall-sdk). Look at the `RecipesView` to see it in action. Key APIs [#key-apis] Use `getPaywall()` to fetch a paywall you can embed inline, and configure it with a placement so you can control which paywall variant shows from the dashboard. For more on presenting paywalls in custom presentations, check out our [blog post](https://superwall.com/blog/custom-paywall-presentation-in-ios-with-the-superwall-sdk/). For the second paywall, trigger a custom action from the inline paywall and call `getPaywall()` again to present the full-screen option. You're responsible for removing embedded paywall views when users move on. Reusing the same `PaywallViewController` or `PaywallView` instance elsewhere can cause a crash. For UIKit, avoid mixing `register()` and `getPaywall()` when you embed paywalls. Presenting a second paywall [#presenting-a-second-paywall] To get the inline paywall to trigger a second, full-screen paywall, create a custom action in the paywall editor in the embedded paywall. In this example, a custom action called "showFromLine" is triggered from the "or, view all plans" button: Then, respond to that action in your [`SuperwallDelegate`](/docs/ios/sdk-reference/SuperwallDelegate) to retrieve the second paywall and present it. In the code below, our second paywall is normally triggered via the `showAllPlansPaywall` placement that was setup in the Superwall dashboard within a campaign: ```swift extension MyAppLogic: SuperwallDelegate, PaywallViewControllerDelegate { // Custom action comes in func handleCustomPaywallAction(withName name: String) { if name == "showFromInline" { Task { await presentAllPlansPaywall() } } } // MARK: PaywallViewControllerDelegate func paywall( _ paywall: PaywallViewController, didFinishWith result: PaywallResult, shouldDismiss: Bool ) { if shouldDismiss { paywall.dismiss(animated: true) } } func paywall( _ paywall: PaywallViewController, loadingStateDidChange loadingState: PaywallLoadingState ) { // Handle loading state changes if needed } // MARK: Custom Paywall Presentation private func presentAllPlansPaywall() async { do { let paywallViewController = try await Superwall.shared.getPaywall( forPlacement: "showAllPlansPaywall", delegate: self ) guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootViewController = windowScene.windows.first?.rootViewController else { return } var topController = rootViewController while let presented = topController.presentedViewController { topController = presented } topController.present(paywallViewController, animated: true) } catch let reason as PaywallSkippedReason { print("Paywall skipped: \(reason)") } catch { print("Error presenting paywall: \(error)") } } } ``` This keeps the inline paywall embedded while you intentionally present the next paywall. The entire flow looks like this: **In your dashboard** 1. Have a paywall setup for your "footer" or bottom paywall. 2. Add a custom action to it to present a second paywall over it. 3. Make sure both paywalls are active in a campaign, and remember the placements used to trigger them **In your code** 1. Use `getPaywall` and `PaywallView` to embed the first paywall in your scrollview. 2. Users can purchase from there, or tap another button to present a second paywall. 3. Handle a custom action fired from a "View all plans" or similar button in a `SuperwallDelegate`. 4. Use `PaywallViewControllerDelegate` to manage presentation of the second one. Here's some code to model your approach, showing the first paywall as either an overlay at the bottom or inline with scrolled content: ```swift enum PaywallEmbedMode { case overlay case inline } struct ArticlePaywallDemoView: View { let mode: PaywallEmbedMode let placement: String = "getPaywallTest" var body: some View { ScrollView { VStack(alignment: .leading) { Text("How to embed a Superwall paywall alongside your own content") .font(.title) Text("By Superwall").font(.caption) Text("...article content...") .padding(.vertical, 16) if mode == .inline { paywallContent } } .padding() .overlay(alignment: .bottom) { if mode == .overlay { paywallContent } } } } private var paywallContent: some View { PaywallView(placement: placement) .frame(maxWidth: .infinity) .frame(height: 300) } } ``` If you need to remove the paywall, remove the `PaywallView` from the view hierarchy and recreate it when you need to show it again. # Experimental Flags Experimental flags in Superwall's SDK allow you to opt into features that are safe for production but are still being refined. These features may undergo naming changes or internal restructuring in future SDK versions. We expose them behind flags to give you early access while preserving flexibility for ongoing development. These flags are configured via the `SuperwallOptions` struct: ```swift let options = SuperwallOptions() options.enableExperimentalDeviceVariables = true Superwall.configure(apiKey: "my_api_key", options: options) ``` Available experimental flags [#available-experimental-flags] When these flags are enabled and the user runs your app, these values become available in campaign filters. Currently, these include: **Latest Subscription Period Type (String)**: Represents whether the user is in a trial, promotional, or a similar phase. Possible values include: * `trial` * `code` * `subscription` * `promotional` * `winback` * `revoked` Represented as `latestSubscriptionPeriodType` in campaign filters. **Latest Subscription State (String)**: Represents what *state* the actual subscription is in. Possible values include: * `inGracePeriod` * `subscribed` * `expired` * `inBillingRetryPeriod` * `revoked` Represented as `latestSubscriptionState` in campaign filters. **Latest Subscription Will Auto Renew (Bool)**: If the user is set to renew or not. Either `true` or `false` Represented as `latestSubscriptionWillAutoRenew` in campaign filters. Detecting users who've cancelled an active trial [#detecting-users-whove-cancelled-an-active-trial] One common use case for these flags is detecting users who've cancelled an active trial. In that case, the filter in the campaign would check for `latestSubscriptionWillAutoRenew` to be `false` and `latestSubscriptionPeriodType` to be `trial`. # Handling Deep Links When your app receives a deep link, you might be tempted to write a switch statement that maps each URL to a specific placement and calls `register`. This works, but it means every time you add a new link or change which paywall shows, you have to ship an app update. A better approach is to pass the URL to `handleDeepLink` and let Superwall's [`deepLink_open`](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements#deeplink_open) standard placement handle the rest. The SDK extracts the URL's path, query parameters, and other components, then fires `deepLink_open` as a placement. You write campaign rules on the dashboard to decide which paywall to show, which means there is no app update required. The problem [#the-problem] Here's a common pattern where deep link routing is hardcoded in the app: :::ios ```swift func handleURL(_ url: URL) { let placement: String? = switch url.path { case "/promo": "promoPlacement" case "/onboarding": "onboardingPlacement" case "/upgrade": "upgradePlacement" case "/special-offer": "specialOfferPlacement" default: nil } if let placement { Superwall.shared.register(placement: placement) } } ``` ::: Every new URL path means a code change, a build, and an app store review. If you want to change which paywall shows for `/promo`, that's another update too. The solution: `handleDeepLink` + campaign rules [#the-solution-handledeeplink--campaign-rules] Instead, pass the URL to `handleDeepLink`. The SDK fires the `deepLink_open` standard placement with all of the URL's components as parameters. Then, on the Superwall dashboard, you create campaign rules that match on those parameters to decide what to show. :::ios ```swift func handleURL(_ url: URL) { Superwall.handleDeepLink(url) } ``` ::: That's it on the app side. The routing logic lives on the dashboard. Setting up campaign rules [#setting-up-campaign-rules] Once `handleDeepLink` is wired up, the `deepLink_open` placement fires every time a deep link arrives. The URL's path, host, query parameters, and other components are available as parameters you can match against in your campaign's audience filters. On the Superwall dashboard, create a new [campaign](/docs/dashboard/dashboard-campaigns/campaigns) — for example, "Deep Link Paywalls". In your campaign, [add a placement](/docs/dashboard/dashboard-campaigns/campaigns-placements#adding-a-placement) and select `deepLink_open` from the standard placements list. Edit the default audience and add filters that match the URL components you care about. For example, if your deep link is `myapp://promo?offer=summer`: * Set `params.path` **is** `promo` to match the path. * Set `params.offer` **is** `summer` to match the query parameter. See [`deepLink_open` parameters](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements#deeplink_open) for the full list of available fields. Click **Paywalls** at the top of the campaign and choose which paywall to present when the filters match. Now when a user opens `myapp://promo?offer=summer`, the SDK fires `deepLink_open`, the campaign rule matches, and the paywall shows. That's all without touching your app code. To add a new deep link path or change which paywall it shows, just update the campaign on the dashboard. Multiple deep link routes [#multiple-deep-link-routes] You can handle several deep link patterns from a single campaign by adding multiple audiences, each with its own filters and paywalls. For example: | Deep link | Filter | Paywall | | ----------------------------- | -------------------------------------------------------- | --------------- | | `myapp://promo?offer=summer` | `params.path` is `promo` AND `params.offer` is `summer` | Summer Sale | | `myapp://promo?offer=newyear` | `params.path` is `promo` AND `params.offer` is `newyear` | New Year Offer | | `myapp://upgrade` | `params.path` is `upgrade` | Upgrade Paywall | Each audience evaluates independently. When you need to add a new route, create a new audience on the dashboard — no app update needed. Prerequisites [#prerequisites] To use `handleDeepLink`, your app needs deep link handling set up first. If you haven't done that yet, follow the setup guide: :::ios * [Deep link setup](/docs/sdk/quickstart/in-app-paywall-previews) ::: Related deep link guides [#related-deep-link-guides] :::ios * [Deep Link Setup](/docs/sdk/quickstart/in-app-paywall-previews) — Configure URL schemes, universal links, and wire `handleDeepLink` into your app so Superwall can respond to incoming links. * [Using Superwall Deep Links](/docs/sdk/guides/superwall-deep-links) — Trigger paywalls or custom in-app behavior using Superwall-hosted URLs at `*.superwall.app/app-link/...`. ::: # Overriding Introductory Offer Eligibility Overview [#overview] Starting with iOS SDK 4.11.0, you can override the default introductory offer eligibility logic to control when users see free trials and intro offers on your paywalls. This allows you to show intro offers to returning users (as "promo offers") or to prevent them from appearing entirely. This feature is configured entirely through the Paywall Editor in the Superwall Dashboard. No code changes are required in your app. Requirements [#requirements] * **iOS SDK:** Version 4.11.0 or later * **Platform:** iOS 16+ only (App Store products) * **Xcode Version:** 16.3+ * You must have set up the [App Store Connect API](/docs/dashboard/dashboard-settings/overview-settings-revenue-tracking#app-store-connect-api) and the [In App Purchase Configuration](/docs/dashboard/dashboard-settings/overview-settings-revenue-tracking#in-app-purchase-configuration). If you're using a `PurchaseController` [#if-youre-using-a-purchasecontroller] `PurchaseController` support for intro offer eligibility override was added in SDK version 4.12.8. You'll need to add the JWS token that we generate for the product in a `PurchaseOption` that you pass to StoreKit when you purchase: ```swift Swift func purchase(product: StoreProduct) async -> PurchaseResult { guard let sk2Product = product.sk2Product else { return .cancelled } var options: Set = [] // Grab the introOfferToken if let introOfferToken = product.introOfferToken { // Add it as a PurchaseOption options.insert(.introductoryOfferEligibility(compactJWS: introOfferToken.token)) } // Pass it in to the StoreKit purchase function let result = try await sk2Product.purchase(options: options) // etc... } ``` How It Works [#how-it-works] By default, Superwall uses Apple's StoreKit to determine if a user is eligible for an introductory offer. Apple's rules state that users can only claim an introductory offer once per subscription group. With this feature, you can override this behavior to: * **Show intro offers to returning users** who have already used a trial (useful for win-back campaigns) * **Hide intro offers entirely** even if users are eligible * **Use the default behavior** (let StoreKit decide) Configuration [#configuration] 1. Open your paywall in the Paywall Editor 2. Go to the **Products** menu in the left sidebar 3. Select an option from the **"Introductory Offer Eligibility"** dropdown 4. Publish your paywall Options [#options] **Automatic (Default)** Uses Apple's default eligibility rules **Always Eligible** Allows users to see and claim intro offers, even if they've used one before **Always Ineligible** Prevents users from seeing intro offers # Local Resources Local resources let your paywalls load bundled assets directly from the device instead of fetching them over the network. This is useful for hero images, onboarding videos, and other media that should appear immediately even when the connection is slow. :::ios Local resources require **iOS SDK v4.13.0+**. ::: Registering local resources [#registering-local-resources] Choose a stable resource ID for each asset you want to serve locally. That same ID is what you'll select in the [paywall editor](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-local-resources) when configuring image or video components. :::ios On iOS, local resources are configured on `SuperwallOptions.localResources` before calling [`configure()`](/docs/sdk/sdk-reference/configure). ```swift Swift let options = SuperwallOptions() options.localResources = [ "hero-image": Bundle.main.url(forResource: "hero", withExtension: "png")!, "logo": UIImage(named: "Logo")!, "onboarding-video": Bundle.main.url(forResource: "welcome", withExtension: "mp4")! ] Superwall.configure( apiKey: "pk_your_api_key", options: options ) ``` Set `localResources` before calling `configure()`. Resources added later will not be available to paywalls that already loaded. ::: Supported source types [#supported-source-types] :::ios iOS maps each resource ID to a local file URL: | Type | Use for | | ----- | ------------------------------------------------------------------------- | | `URL` | Files in your app bundle or sandbox that can be loaded directly from disk | ::: Choosing resource IDs [#choosing-resource-ids] Resource IDs are the contract between your app and the paywall editor. A few guidelines: * Use stable, descriptive names like `"hero-image"` and `"onboarding-video"`. * Keep the casing consistent. `"Hero-Image"` and `"hero-image"` are different IDs. * If you rename an ID, update any paywalls that reference it. Referencing local resources in a paywall [#referencing-local-resources-in-a-paywall] In the paywall editor, set a local resource on an image or video component and select the resource ID you registered in the SDK. You can still provide a remote URL as a fallback. Under the hood, paywalls load these resources through `swlocal://` URLs. For example: ```html ``` If the SDK cannot resolve a local resource, the paywall can fall back to the remote URL configured in the editor. Debugging [#debugging] If a resource ID does not appear in the editor or fails to load: * Make sure the app is running a compatible SDK version. * Confirm the resource ID in your paywall exactly matches the key you registered in the SDK. * Open a paywall on a test device after configuring local resources so the editor can discover recently used IDs. * Keep a remote fallback URL on critical media so older builds still render correctly. Related [#related] * [iOS `localResources`](/docs/sdk/sdk-reference/localResources): SDK reference for the iOS property. * [Android `localResources`](/docs/sdk/sdk-reference/localResources): SDK reference for the Android property. * [Paywall Editor: Local Resources](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-local-resources): How to assign local resource IDs in the dashboard. # Migrating from v2 to v3 - iOS Note that the minimum deployment target has changed for v3 from iOS 11 to iOS 13 This is so that we can use newer APIs internally and externally. Migration steps [#migration-steps] 1\. Update Swift Package Manager dependency (if needed) [#1-update-swift-package-manager-dependency-if-needed] Our GitHub URL has changed. Although you can keep using the old one, its best if you replace it with the newer one. If you're using Swift Package Manager to handle dependencies: * Select your project from the **Project Navigator**, select your project under **Project** and click **Package Dependencies**. * Remove the old dependency for `paywall-ios`. * Click **+** and search for our new url [https://github.com/superwall/Superwall-iOS](https://github.com/superwall/Superwall-iOS) in the search bar. * Set the **Dependency Rule** to **Up to Next Major Version** with the lower bound set to **3.0.0**. * Make sure your project name is selected in **Add to Project**. * Then, **Add Package**. Sometimes Xcode keeps the old framework reference around by accident, so select your target in Xcode, then go to Build Phases, and ensure that your target’s Link Binary with Libraries section references SuperwallKit, and remove the reference to Paywall if it was still there. If you have any Xcode issues during building you might need to clean the build folder by going to **Product** > **Clean Build Folder** and then restart Xcode. 1.1 Update CocoaPods dependency (if needed) [#11-update-cocoapods-dependency-if-needed] If instead you're using CocoaPods to manage dependencies, in your Podfile update the reference to the Pod from `Paywall` to `SuperwallKit` then run `pod install`: | Before | After | | ------------------------- | ------------------------------ | | pod 'Paywall', '\< 3.0.0' | pod 'SuperwallKit', '\< 4.0.0' | 1.2 Update Framework References [#12-update-framework-references] Since our framework is now called `SuperwallKit`, you'll now need to explicitly import `SuperwallKit` instead of `Paywall` throughout your code: Swift [#swift] | Before | After | | -------------- | ------------------- | | import Paywall | import SuperwallKit | Objective-C [#objective-c] | Before | After | | ---------------- | --------------------- | | @import Paywall; | @import SuperwallKit; | 2\. Update code references [#2-update-code-references] In some cases, you should be able to update references using the automatic renaming suggestions that Xcode provides. For other cases where this hasn't been possible, you'll need to run through this list to manually update your code. 2.1 Update references to `Paywall.foo` to `Superwall.shared.foo` [#21-update-references-to-paywallfoo-to-superwallsharedfoo] You'll see errors saying `Cannot find 'Paywall' in scope`. This is because the main class for interacting with our API is now called `Superwall`. All variables and functions (apart from configure) are now instance functions. This means you'll need to use the shared instance `Superwall.shared`. 2.2 Triggering is now registering [#22-triggering-is-now-registering] Previously you'd use `Paywall.track(...)` to implicitly trigger a paywall, and `Paywall.trigger(...)` to explicitly trigger a paywall. This was confusing as they essentially did the same thing. `Paywall.track` provided completion blocks for what happened on the paywall when really you needed to know what to do next. We wanted to make this simpler so at the heart of this release is `Superwall.shared.register(event:params:handler:feature:)`. This allows you to register an event to access a feature that may or may not be paywalled later in time. It also allows you to choose whether the user can access the feature even if they don't make a purchase. You can read our docs on [how register works](/docs/ios/quickstart/feature-gating) to learn more. Given the low cost nature of how register works, we strongly recommend wrapping all core functionality in a register `feature` block in order to remotely configure which features you want to gate – without an app update. For SwiftUI apps, we have removed the `.triggerPaywall` view modifier in favor of this register function. **2.3 Rename `PaywallDelegate` to `SuperwallDelegate`** [#23-rename-paywalldelegate-to-superwalldelegate] The following method has changed: | Before | After | | ----------------------------------------------------------------------- | --------------------------------------------------------------------- | | func trackAnalyticsEvent(withName name: String, params: \[String: Any]) | func handleSuperwallEventInfo(withInfo eventInfo: SuperwallEventInfo) | This has a `SuperwallEventInfo` parameter. This has a `params` dictionary and an `event` enum whose cases contain associated values. Note that the methods for handling subscription-related logic no longer exist inside `SuperwallDelegate`, as discussed in the next section. 2.4 Handling subscription-related logic [#24-handling-subscription-related-logic] SuperwallKit now handles all subscription-related logic by default making integration super easy. We track the user's subscription status for you and expose the published property `Superwall.shared.subscriptionStatus`. This means that if you were previously using StoreKit you can simply delete that code and let SuperwallKit handle it. However, if you're using RevenueCat or still want to keep control over subscription-related logic, you'll need to conform to the `PurchaseController` protocol. This is a protocol that handles purchasing and restoring, much like the `PaywallDelegate` did in v2.x of the SDK. You set the purchase controller when you configure the SDK. You can read more about that in our [Purchases and Subscription Status](/docs/ios/guides/advanced-configuration) guide. The following methods were previously in the `PaywallDelegate` but are now in the `PurchaseController` and have changed slightly: Purchasing [#purchasing] | Before | After | | --------------------------------- | --------------------------------------------------------- | | func purchase(product: SKProduct) | func purchase(product: SKProduct) async -> PurchaseResult | Here, you purchase the product but then return the result of the purchase as a `PurchaseResult` enum case. Make sure you handle all cases of `PurchaseResult`. Restoring [#restoring] | Before | After | | ----------------------------------------------------------- | -------------------------------------------------- | | func restorePurchases(completion: @escaping (Bool) -> Void) | func restorePurchases() async -> RestorationResult | This has changed to an async function that returns the result of restoring a purchase. If you need help converting between completion blocks and async, [check out this article](https://wwdcbysundell.com/2021/wrapping-completion-handlers-into-async-apis/). Subscription Status [#subscription-status] | Before | After | | ------------------------------- | ------------------------------------------------------------ | | func isUserSubscribed() -> Bool | Superwall.shared.subscriptionStatus = .active (or .inactive) | `isUserSubscribed()` has been removed in favor of a `subscriptionStatus` variable which you **must** set every time the user's subscription status changes. On first app install this starts off as `.unknown` until you determine the user's subscription status and set it to `.active` when they have an active subscription, or `.inactive` when they don't. Paywalls will not show until the user's subscription status is set. You can [check out our docs](/docs/ios/guides/advanced-configuration) for detailed info about implementing the `PurchaseController`. 2.5 Rename `PaywallOptions` to `SuperwallOptions` [#25-rename-paywalloptions-to-superwalloptions] This now clearly defines which of the options are explicit to paywalls vs other configuration options within the SDK. 2.6 Configuring and Identity management [#26-configuring-and-identity-management] When configuring the API, you now no longer provide a userId or delegate. | Before | After | | --------------------------------------------- | ----------------------------------------------------------- | | configure(apiKey\:userId\:delegate\:options:) | configure(apiKey\:purchaseController\:options\:completion:) | To use the optional delegate, set `Superwall.shared.delegate`. To identify a user, use `Superwall.shared.identify(userId:options:)`. You can [read more](/docs/ios/quickstart/user-management) about identity management in our docs. 3\. Check out the full change log [#3-check-out-the-full-change-log] You can view this on [our GitHub page](https://github.com/superwall/Superwall-iOS/blob/master/CHANGELOG.md). 4\. Check out our updated example apps [#4-check-out-our-updated-example-apps] All of our example apps have been updated to use the latest SDK. We have created a dedicated app that shows you how to integrate Superwall with RevenueCat. In addition, we have added an Objective-C app. 5\. Read our docs and view the updated iOS SDK documentation [#5-read-our-docs-and-view-the-updated-ios-sdk-documentation] Visit the links in the sidebar or [click here to go to the iOS SDK docs](https://sdk.superwall.me/documentation/superwallkit/). # Migrating from v3 to v4 - iOS Migration steps [#migration-steps] 1\. Update code references [#1-update-code-references] 1.1 Rename references from `event` to `placement` [#11-rename-references-from-event-to-placement] In some cases, you should be able to update references using the automatic renaming suggestions that Xcode provides. For other cases where this hasn't been possible, you'll need to run through this list to manually update your code. | Before | After | | ------------------------------------- | ----------------------------------------- | | func register(event:) | func register(placement:) | | func preloadPaywalls(forEvents:) | func preloadPaywalls(forPlacements:) | | func getPaywall(forEvent:) | func getPaywall(forPlacement:) | | func getPresentationResult(forEvent:) | func getPresentationResult(forPlacement:) | | TriggerResult.eventNotFound | TriggerResult.placementNotFound | 1.2 Update PurchaseController method [#12-update-purchasecontroller-method] The following has been changed in the `PurchaseController`: | Before | After | | --------------------------------------------------------- | ------------------------------------------------------------ | | func purchase(product: SKProduct) async -> PurchaseResult | func purchase(product: StoreProduct) async -> PurchaseResult | This provides a `StoreProduct` object, which contains information about the product to be purchased. 2\. StoreKit 2 [#2-storekit-2] The SDK defaults to using StoreKit 2 for users who are on iOS 15+. However, you can choose to stay on StoreKit 1 by setting the `SuperwallOption` `storeKitVersion` to `.storeKit1`. There are a few caveats to this however. In the following scenarios, the SDK will choose StoreKit 1 automatically: 1. If you're using Objective-C and using a `PurchaseController`. 2. If you're using Objective-C and observing purchases by setting the `SuperwallOption` `shouldObservePurchases` to `true`. 3. If you have set the key `SKIncludeConsumableInAppPurchaseHistory` to `true` in your info.plist, the SDK will use StoreKit 1 for everyone who isn't on iOS 18+. If you're using Objective-C and using `purchase(_:)` you must manually set the `SuperwallOption` `storeKitVersion` to `.storeKit1`. If you're using a `PurchaseController`, you access the StoreKit 2 product to purchase using `product.sk2Product` and the StoreKit 1 product `product.sk1Product` if you're using StoreKit 1. You should take the above scenarios into account when choosing which product to purchase. 3\. Getting the purchased product [#3-getting-the-purchased-product] The `onDismiss` block of the `PaywallPresentationHandler` now accepts both a `PaywallInfo` object and a `PaywallResult` object. This allows you to easily access the purchased product from the result when the paywall dismisses. 4\. Entitlements [#4-entitlements] The `subscriptionStatus` has been changed to accept a set of `Entitlement` objects. This allows you to give access to entitlements based on products purchased. For example, in your app you might have Bronze, Silver, and Gold subscription tiers, i.e. entitlements, which entitle a user to access a certain set of features within your app. Every subscription product must be associated with one or more entitlements, which is controlled via the dashboard. Superwall will already have associated all your products with a default entitlement. If you don't use more than one entitlement tier within your app and you only use subscription products, you don't need to do anything extra. However, if you use one-time purchases or multiple entitlements, you should review your products and their entitlements. In general, consumables should not be associated with an entitlement, whereas non-consumables should be. Check your products [here](https://superwall.com/applications/\:app/products/v2). If you're using a `PurchaseController`, you'll need to set the `entitlements.status` instead of the `subscriptionStatus`: | Before | After | | --------------------------------------------- | ---------------------------------------------------------------- | | Superwall.shared.subscriptionStatus = .active | Superwall.shared.subscriptionStatus = .active(Set(entitlements)) | You can get the `StoreProducts` and their associated entitlements from Superwall by calling the method `products(for:)`. Here is an example of how you'd sync your subscription status with Superwall using these methods: ```swift Swift func syncSubscriptionStatus() async { var products: Set = [] for await verificationResult in Transaction.currentEntitlements { switch verificationResult { case .verified(let transaction): products.insert(transaction.productID) case .unverified: break } } let storeProducts = await Superwall.shared.products(for: products) let entitlements = Set(storeProducts.flatMap { $0.entitlements }) await MainActor.run { Superwall.shared.subscriptionStatus = .active(entitlements) } } ``` ```swift RevenueCat func syncSubscriptionStatus() { assert(Purchases.isConfigured, "You must configure RevenueCat before calling this method.") Task { for await customerInfo in Purchases.shared.customerInfoStream { // Gets called whenever new CustomerInfo is available let superwallEntitlements = customerInfo.entitlements.activeInCurrentEnvironment.keys.map { Entitlement(id: $0) } await MainActor.run { [superwallEntitlements] in if superwallEntitlements.isEmpty { Superwall.shared.subscriptionStatus = .inactive } else { Superwall.shared.subscriptionStatus = .active(Set(superwallEntitlements)) } } } } } ``` You can listen to the published property `Superwall.shared.subscriptionStatus` to be notified when the subscriptionStatus changes. Or you can use the `SuperwallDelegate` method `subscriptionStatusDidChange(from:to:)`, which replaces `subscriptionStatusDidChange(to:)`. 5\. Paywall Presentation Condition [#5-paywall-presentation-condition] In the Paywall Editor you can choose whether to always present a paywall or ask the SDK to check the user subscription before presenting a paywall. For users on v4 of the SDK, this is replaced with a check on the entitlements within the audience filter. As you migrate your users from v3 to v4 of the SDK, you'll need to make sure you set both the entitlements check and the paywall presentation condition in the paywall editor. 6\. Check out the full change log [#6-check-out-the-full-change-log] You can view this on [our GitHub page](https://github.com/superwall/Superwall-iOS/blob/master/CHANGELOG.md). 7\. Check out our updated example apps [#7-check-out-our-updated-example-apps] All of our example apps have been updated to use the latest SDK. We now only have two apps: Basic and Advanced. Basic shows you the basic integration of Superwall without needing a purchase controller or multiple entitlements. Advanced shows you how to use entitlements within your app as well as optionally using a purchase controller with StoreKit or RevenueCat. 8\. Read our docs and view the updated iOS SDK documentation [#8-read-our-docs-and-view-the-updated-ios-sdk-documentation] Visit the links in the sidebar or [click here to go to the iOS SDK docs](https://sdk.superwall.me/documentation/superwallkit/). # Using Superwall Deep Links A Superwall Deep Link is a URL hosted at `https://.superwall.app/app-link/...` that opens your app to trigger a paywall as configured on the Superwall dashboard, or custom in-app behavior via the Superwall delegate. Prerequisites [#prerequisites] :::ios 1. Set up [deep link handling](/docs/sdk/quickstart/in-app-paywall-previews) ::: 2. Create a [Web Checkout app](/docs/web-checkout/web-checkout-creating-an-app), even if you do not plan to charge through Web Checkout, this provisions the `*.superwall.app` domain that powers Superwall Deep Links. Handling incoming links [#handling-incoming-links] * Always call `handleDeepLink` first. It returns `true` when the SDK recognizes the URL and plans to take over presentation, or `false` when you should continue routing inside your own app. * When a recognized link arrives before `Superwall.configure(...)` finishes, the SDK caches it and replays it immediately after configuration completes, so it is safe to forward links during cold launch. * If the return value is `false`, continue with your normal router—those links are not associated with any Superwall experience. :::ios ```swift func application( _ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { let handled = Superwall.handleDeepLink(url) if handled { return true } return routeInternally(url) } ``` ::: Link formats and campaigns [#link-formats-and-campaigns] Deep link URLs are hosted at `https://.superwall.app/app-link/...`, you can have anything after the `/app-link/` path, including query parameters. These values will be availble to you in audience filters on the Superwall dashboard, or in the `handleSuperwallDeepLink` delegate method. :::ios ```swift final class PaywallDelegate: SuperwallDelegate { func handleSuperwallDeepLink( _ url: URL, pathComponents: [String], queryParameters: [String: String] ) { guard let head = pathComponents.first else { return } switch head { case "campaign": if pathComponents.count > 1 { routeToCampaignDetail(id: pathComponents[1]) } default: break } } } ``` ::: Keep your own routing logic in place for non-Superwall URLs and for any additional behaviors you want to stack on top of Superwall's default presentation flow. Related deep link guides [#related-deep-link-guides] :::ios * [Deep Link Setup](/docs/sdk/quickstart/in-app-paywall-previews) — Configure URL schemes, universal links, and wire `handleDeepLink` into your app so Superwall can respond to incoming links. * [Handling Deep Links](/docs/sdk/guides/handling-deep-links) — Use `handleDeepLink` with the `deepLink_open` standard placement and dashboard campaign rules to present paywalls from your own deep links, without hardcoding routing logic. ::: # Test Mode Test mode lets you simulate in-app purchases without involving StoreKit or any external purchase controller. When active, all purchases are faked and product data is retrieved from the Superwall dashboard. This makes it easy to test your entire paywall flow end-to-end, including purchase, restore, and entitlement changes, without needing a StoreKit configuration file or sandbox account. How it works [#how-it-works] When test mode is active: * **Product data comes from the dashboard** instead of StoreKit, so you don't need a StoreKit configuration file or App Store Connect products set up. * **Purchases are simulated.** Instead of the system payment sheet, a test mode drawer appears letting you choose to complete, abandon, or fail the transaction. All purchase events fire normally, so your analytics and delegate callbacks work as expected. * **Restores are simulated.** A restore drawer lets you pick which entitlements to restore. * **A configuration modal** appears on launch showing your User ID, purchase controller status, device and user attributes, free trial override, and starting entitlements. You can use this to configure the test session before interacting with your paywalls. * **All events route to sandbox**, so test mode activity won't affect your production data. Activating test mode [#activating-test-mode] There are two ways to activate test mode: 1\. From the dashboard [#1-from-the-dashboard] Mark specific users as **test store users** in the Superwall dashboard. When the SDK detects that the current user's ID matches a test store user from your config, test mode activates automatically. This is the most common approach. 2\. From the SDK [#2-from-the-sdk] Set `testModeBehavior` on `SuperwallOptions` before calling `configure`: ```swift let options = SuperwallOptions() options.testModeBehavior = .always Superwall.configure(apiKey: "your-api-key", options: options) ``` The available behaviors are: | Behavior | Description | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `.automatic` | **(Default)** Activates when the current user is marked as a test store user in the dashboard, or when the app's bundle ID doesn't match the one configured in the dashboard. Never activates during UI tests. | | `.whenEnabledForUser` | Activates only when the current user is marked as a test store user in the dashboard. Ignores bundle ID mismatches. | | `.always` | Always activates test mode, regardless of dashboard configuration. Useful during local development. | | `.never` | Never activates test mode, regardless of configuration. | The configuration modal [#the-configuration-modal] When test mode activates, a modal appears before you interact with any paywalls. It displays: * **User ID:** Your current user ID, with a link to view the user in the dashboard. * **Purchase Controller:** Whether you've provided a custom purchase controller. * **Device Attributes:** Tap to view all device-level attributes the SDK is tracking. * **User Attributes:** Tap to view all user-level attributes. * **Free Trial Override:** Override free trial availability for all products. Choose **Use Default** (respects the product's actual trial status), **Force Available**, or **Force Unavailable**. * **Starting Entitlements:** If you have entitlements configured, you can set each one to **Active** or **Inactive** before dismissing the modal. This lets you test how your paywalls behave for users with different entitlement states. Tap **OK** to dismiss the modal and begin testing. Your selections persist across sessions. Tap **Reset to Defaults** to clear all overrides. Simulating purchases [#simulating-purchases] When you tap a purchase button on a paywall while test mode is active, a drawer appears instead of the system payment sheet: The drawer shows the product details and these options: * **Purchase:** Simulates a successful purchase. The product's entitlements are activated and your subscription status updates accordingly. * **Failure:** Simulates a purchase failure. All standard Superwall events (`transaction_start`, `transaction_complete`, `transaction_abandon`, `transaction_fail`, etc.) fire as they normally would, so you can verify your analytics and delegate callbacks. Simulating restores [#simulating-restores] When a restore is triggered while test mode is active, a drawer appears letting you select which entitlements to restore and in what state. This is useful for testing how your app handles different restore scenarios. When to use test mode vs. StoreKit testing [#when-to-use-test-mode-vs-storekit-testing] | | Test mode | StoreKit testing | | ---------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | | **Setup** | No StoreKit config file needed | Requires a StoreKit configuration file in Xcode | | **Products** | Pulled from the Superwall dashboard | Must exist in the StoreKit config or App Store Connect | | **Transactions** | Simulated via UI drawer | Real StoreKit transactions in a sandbox | | **Best for** | End-to-end paywall flow testing, verifying entitlement gating, testing without App Store Connect setup | Testing real StoreKit behavior, receipt validation, subscription lifecycle | Test mode is ideal for quickly validating your paywall presentation, purchase flows, and entitlement gating without any StoreKit setup. For testing actual StoreKit behavior, use [StoreKit testing in Xcode](/docs/ios/guides/testing-purchases) instead. # Setting up StoreKit testing StoreKit testing in Xcode is a local test environment for testing in-app purchases without requiring a connection to App Store servers. Set up in-app purchases in a local StoreKit configuration file in your Xcode project, or create a synced StoreKit configuration file in Xcode from your in-app purchase settings in App Store Connect. After you enable the configuration file, the test environment uses this local data on your paywalls when your app calls StoreKit APIs. Add a StoreKit Configuration File [#add-a-storekit-configuration-file] Go to **File ▸ New ▸ File...** in the menu bar , select **StoreKit Configuration File** and hit **Next**: Give it the name **Products**. For a configuration file synced with an app on App Store Connect, select the checkbox, specify your team and app in the drop-down menus that appear, then click **Next**. For a local configuration, leave the checkbox unselected, then click **Next**. Save the file in the top-level folder of your project. You don't need to add it to your target. Create a New Scheme for StoreKit Testing [#create-a-new-scheme-for-storekit-testing] It's best practice to create a new scheme in Xcode to be used for StoreKit testing. This allows you to separate out staging and production environments. Click the scheme in the scheme menu and click **Manage Schemes...**: If you haven't already got a Staging scheme, select your current scheme and click **Duplicate**: In the scheme editor, add the StoreKit Configuration file to your scheme by clicking on **Run** in the side bar, selecting the **Options** tab and choosing your configuration file in **StoreKit Configuration**. Then, click **Close**: You can rename your scheme to **MyAppName (Staging)**. Setting up the StoreKit Configuration File [#setting-up-the-storekit-configuration-file] If you've chosen to sync your configuration file with the App Store, your apps will automatically be loaded into your StoreKit Configuration file. When you add new products, just sync again. If you're using a local configuration, open **Products.storekit**, click the **+** button at the bottom and create a new product. In this tutorial, we'll create an auto-renewable subscription: Enter a name for a new subscription group and click **Done**. The subscription group name should match one that is set up for your app in App Store Connect, but it's not a requirement. That means you can test your subscription groups and products in the simulator and then create the products in App Store Connect later: Configure the subscription as needed by filling in the **Reference Name**, **Product ID**, **Price**, **Subscription Duration**, and optionally an **Introductory Offer**. Again, this product doesn't have to exist in App Store Connect for you to test purchasing in the simulator. Here is a sample configuration: Repeat this for all of your products. When configuring a paywall, the product ID you enter here must match the product ID on the paywall. You're now all set! Testing purchases with Transaction Manager [#testing-purchases-with-transaction-manager] Once you've set up your StoreKit configuration file, you can leverage Xcode's Transaction Manager. Find it under **Debug -> StoreKit -> Manage Transactions...**: Use this to quickly test purchasing your products. Once you make a purchase, you can open Transaction Manager to delete it, refund it, request parental approval and much more. Most commonly, you'll probably delete the transaction to reset your subscription state: This makes everything a little faster, saving you the trouble of having to delete and reinstall your app to test these states. If you'd like to see a video over how to use it, check this one out: