# Superwall Documentation
# External Paywall Creation
The legacy editor is deprecated. Please visit the docs covering our new
[editor](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-overview).
The following is only necessary if you're looking to build your own paywall website outside of Superwall. We strongly recommend using the paywall editor or templates instead.
Data Tags [#data-tags]
Data tags are specific attributes attached to elements, like text, links, or buttons, on the paywall webpage.\
These are recognised by a special script called [Paywall.js](/docs/advanced-paywall-creation#paywalljs) that interprets user feedback and transmits information to and from the SDK. These are built in to the templates we've created and you don't need to worry about them unless you're building your own paywall webpage. When you configure your paywall webpage on the Superwall dashboard, the dashboard instantly recognizes the data tags on your website. From there you can edit the text that appears in the tagged elements.
There are seven different types of data tag:
| Name | Value | Purpose |
| ----------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data-pw-var | The reference name of the element, e.g. "Title". | This indicates to Superwall that the element should be remotely configurable. The Superwall dashboard will create an editable text field for this element. The reference name of that text field will be equal to the value you provide. |
| data-pw-close | Add anything as a value. It's ignored. | Closes the paywall. |
| data-pw-restore | Add anything as a value. It's ignored. | For restoring purchases, if you bought on a different device. |
| data-pw-purchase | Either primary, secondary, or tertiary. | This relates to your primary, secondary, or tertiary product set up in the Superwall dashboard respectively. When the tagged element is tapped, it tells the SDK to initialize a purchase of the specified product. |
| data-pw-open-url | A valid url. E.g.` .` | Normal href links do not work in a paywall. However, attaching this tag to an element opens the provided url. |
| data-pw-open-deep-link | A valid deep link url. E.g. fb://profile/33138223345. | Opens a deeplink from a button on a paywall. |
| data-pw-custom | A name for a custom action | When the tagged element is tapped, the SDK calls its delegate method handleCustomPaywallAction(withName:). The value you provide is passed to the function, from which you can perform custom logic. Check out [Custom Paywall Buttons](/docs/sdk/guides/advanced/custom-paywall-actions) for more. |
| data-pw-skip-inner-html | true | This prevents an element's text from being edited. It tells Paywall.js to keep looking into the element's children for more data-pw-var tags. This is useful when you want to hide a whole section or edit the style of a container, rather than edit its text. |
Here are a few examples of what data tags look like in the webpage HTML:
```html HTML
```
Paywall.js [#paywalljs]
Paywall.js is a javascript utility that recognizes data tags and acts as the interface between the Superwall client SDK and the webpage presented to the user. It does a few fancy tricks to make the HTML feel native, but it's main purpose is to interpret user feedback and transmit information to and from the SDK. In addition, when you configure a paywall on the Superwall dashboard, the dashboard instantly recognizes the data tags you’ve provided. From there, you can edit the text of all tagged items.
To get your webpage to be operable with Superwall, you need to add the Paywall.js script to the header of your site.
We recommend using [Webflow](https://webflow.com). To add custom scripts to a Webflow webpage you need to have a paid Webflow account. However, our [clonable Webflow project](https://webflow.com/website/45-Paywall-Elements?ref=showcase-search\&searchValue=superwall) already includes this script. Therefore, we recommend using the cloned project as the basis of your paywalls. In this project, we've also removed the Webflow watermark that is attached to all free Webflow webpages.
If you'd like to add this script yourself, open the **Pages panel**, select the **cog wheel** next to the page name, then scroll down to the **Custom Code** section. In the **Inside head tag** section, add the following code:
```html HTML
```
Do not remove async
Paywall.js is available to Superwall customers subject to the terms of the subscription agreement
between Superwall and the customer, and is not available as an open-source project.
Game Controller [#game-controller]
Paywall.js now supports game controller input so users can make purchases while maintaining the native game controller experience.
Basic Setup [#basic-setup]
To attach an on-screen button to a game controller button, simply add the `data-pw-gc-button` tag to the element. The value of this tag must match the [unmappedLocalizedName](https://developer.apple.com/documentation/gamecontroller/gccontrollerelement/3681941-unmappedlocalizedname) of the game controller button in iOS.
For example, the following button would purchase the primary product if a user pressed the "A" button on their game controller.
```html HTML
Purchase
```
Styling [#styling]
To allow users to style buttons, we will add and remove classes during the button press event. When the game controller input becomes depressed, we add `pw-gc-press`. When the button is let up, we will remove that class. Many users will use these classes to slightly shirk the button on press and restore it on press end.
```css CSS
.pw-gc-press {
opacity: 0.9;
transition: all .2s ease-in-out;
transform: scale(0.93);
}
```
Advanced [#advanced]
Some users may want to take other actions based on the game controller input so we will automatically forward all game controller events to a function on the window if it is defined.
```typescript TypeScript
type DirectionalInput = {
controller_element: string
directional: true
x: number
y: number
}
type NonDirectionalInput = {
controller_element: string
directional: false
value: number
}
export type Input =
| DirectionalInput
| NonDirectionalInput
window.onGameControllerInput = (data: Input) => {
// Do anything you want with the input!
}
```
# Changelog
2.7.14 [#2714]
Fixes [#fixes]
* Fixes test mode products not being loaded properly in PW
* Improve exponential backoff retry for Play Service unavailable
* Fix Custom Info date serialization issues
2.7.13 [#2713]
Fixes [#fixes-1]
* Fix `device.appVersionPadded` and `device.sdkVersionPadded` emitting non-ASCII digits on devices whose default locale uses a non-Latin numbering system (e.g. `ar-EG`, `fa-IR`, `bn-BD`), which caused audience-rule version comparisons to misbucket affected users.
* Ensures timeout applies to HttpUrlConnection for enrichment and subscription API's
* Remove unnecessary sync access causing ANR lock in React Native
2.7.12 [#2712]
Enhancements [#enhancements]
* Add customer info to paywall info and tracked events
* Add stripe/paddle intro offer eligibility
Fixes [#fixes-2]
* Fix dismiss animation for bottom sheets and modals on newer Samsung devices
2.7.11 [#2711]
Enhancements [#enhancements-1]
* Improved startup performance and reduced blockage of caller threads
* Add `PaywallOptions.preloadDeviceOverrides` - this allows you to override `shouldPreload` for different device tiers, i.e. disable it for low-end devices. For more details, see KDoc.
Fixes [#fixes-3]
* Reduce CookieManager ANR potential
* Fix concurrency issues with double onFinished calls being invoked
2.7.10 [#2710]
Potentially impactful changes [#potentially-impactful-changes]
* Billing errors on register are now wrapped in `SubscriptionStatusTimeout` errors
* This is used to clarify that the status is timing out due to billing errors. If you are depending on the `NoPaywallView` to distinguish between these, ensure you are checking for the proper status.
Enhancements [#enhancements-2]
* Adds onboarding analytics
* Adds prioritized preloading support
* Improved error handling
Fixes [#fixes-4]
* Prevents paywalls from dismissing on return from deep link
* Fixes deadlock in `SerialTaskManager`
* Fix issues with bottom sheet on certain Samsung devices
2.7.9 [#279]
Fixes [#fixes-5]
* Fix review dialog closing paywall
2.7.8 [#278]
Fixes [#fixes-6]
* Fix serialization issue with R8 and dates
* Fix scope cancellation for test mode
2.7.7 [#277]
Enhancements [#enhancements-3]
* Adds support for local resource loading in paywalls via `Superwall.instance.localResources`
Fixes [#fixes-7]
* Fix issues with stripe period types failing to deserialize
2.7.6 [#276]
Fixes [#fixes-8]
* Fix concurrency issue with early paywall displays and product loading
* Improve edge case handling in billing
* Improve paywall timeout cases and failAt stamping
* Fix issue with param templating in re-presentation
* Fix race issue with test mode
2.7.5 [#275]
Enhancements [#enhancements-4]
* Add appstack integration attribute identifier
Fixes [#fixes-9]
* Ensure test mode does not interfere with expo
* Ensure isActive is properly returned and not calculated via expiration date
* Fix potential memory leak when webview crashes
* Ensure O(n) cleanup doesn't run multiple times
2.7.4 [#274]
Enhancements [#enhancements-5]
* Adds support for "Test Mode", which allows you to simulate in-app purchases without involving Google Play. Test Mode can be enabled through the Superwall dashboard by marking specific users as test store users, or activates automatically when a an application ID mismatch is detected or behavior is set to `ALWAYS`. 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.
* Improved logging in web entitlement and entitlement redeeming for easier debugging
* Adds active entitlements to subscription status change event
* Ensures purchases are always queried with connected clients and retried in background
* Adds `expirationDate`, `subscriptionGroupId` and `offerId` to `StoreTransactionType`
Fixes [#fixes-10]
* Improve rounding to match editor's pricing consistently
2.7.3 [#273]
Enhancements [#enhancements-6]
* Adds state and params to `PaywallInfo` for `PaywallClose` events
2.7.2 [#272]
Fixes [#fixes-11]
* Fixes issue with `enableExperimentalDeviceVariables` option causing subscription status sync to fail
2.7.1 [#271]
Enhancements [#enhancements-7]
* Adds improved constructors for `SuperwallOptions`,`PaywallOptions` and `PaywallPresentationHandler`, allowing a DSL like usage,i.e. ` PaywallPresentationHandler { onPresent { ... } }`
* Adds haptic feedback action support
Fixes [#fixes-12]
* Ensures poster is always visible on video preview
* Fix bug with loading not dismissing post purchase if user remains in paywall
* Fix warning about old edge-to-edge API usages
* Fix shimmerview theme warnings and memory leak
2.7.0 [#270]
Enhancements [#enhancements-8]
* Enables paywall post-purchase action execution instead of dismissing
* Enables triggering custom callback requests from paywall
* Adds a new method to PaywallPresentationHandler called onCustomCallback that allows user to handle custom callback requests
* Adds retrieving of paywall state inside paywall info
* Adds support for new one time purchases with purchase options and offers
* Update Superscript to version 1.0.13, find more in the [Superscript changelog](https://github.com/superwall/superscript/releases/tag/1.0.13)
Deprecations [#deprecations]
* Deprecated `paywallWebviewLoad_timeout` - this event was causing confusion due to its naming, leading to it being deprecated
Fixes [#fixes-13]
* Fixes late initialization authorization issue for Stripe checkouts
* Improves how Shimmer duration is measured
* Fixes wrong redemption type being displayed due to integration attributes
2.6.8 [#268]
Enhancements [#enhancements-9]
* Adds microphone permission
Fixes [#fixes-14]
* Fixes error when redeeming external purchases
2.6.7 [#267]
Enhancements [#enhancements-10]
* Adds permission granting and callbacks to/from paywalls
* Adds `PaywallPreloadStart` and `PaywallPreloadComplete` events
Fixes [#fixes-15]
* Fix handling of deep links when paywall is detached
* Enables permission granting from paywall and callbacks
* Fix crash when handling drawer style paywalls with 100% height
2.6.6 [#266]
Enhancements [#enhancements-11]
* Add dynamic notification support and scheduling enabling deeper personalization of notifications
* Provides a `CustomerInfo` class API, allowing you to observe the customer's purchases and subscription lifecycle via `Superwall.instance.customerInfo` flow
* Provides a new delegate method to observe customer info changes - `fun customerInfoDidChange(from: CustomerInfo, to: CustomerInfo)`
* Provides a `CustomerInfoDidChange` event to track customer info changes
* Overrides `Collection.plus`and `.toSet` methods to ensure our merging methods are used.
* Provides a `userAttributesDidChange(newAttributes: Map)` method in Superwall Delegate to track external (i.e. paywall) attribute changesg
* Allows triggering a `transaction_abandon` offer on a `paywall_decline` offer and vice-versa, whereas previously it would trigger a presentation error.
* Add a `Superwall.teardown` method and `Superwall.instance.refreshConfiguration()` for development with hot-reload based frameworks
⚠️ Warning ⚠️ [#️-warning-️]
If you are using a Purchase Controller and web2app or app2web purchases, you will have to update your purchase controller
to listen to `Superwall.instance.customerInfo` which will provide you with the relevant web entitlements and call
`setSubscriptionStatus` accordingly.
2.6.5 [#265]
Dependencies [#dependencies]
* Reverts `androidx.lifecycle:lifecycle-runtime-ktx` to 2.8.4 to ensure old Compose BOM compatiblity
Enhancements [#enhancements-12]
* Improves error messaging in play store errors
Fixes [#fixes-16]
* Fixes edge case bug with wrong entitlement being matched in cases where product ID's match and base plans differentiate by suffix only
* Fixes issue with composable paywall state updates not firing in onAttach
2.6.4 [#264]
Enhancements [#enhancements-13]
* Improves error and timeout handling
* Hardens paywall recreation in case of render process crash
2.6.3 [#263]
Enhancements [#enhancements-14]
* Adds `productIdentifier` to RedemptionResult's `PaywallInfo` object
Fixes [#fixes-17]
* Fixes nested scrolling issue in Modal webviews
* Removes node removal for `com.google.android.gms.permission.AD_ID` from Manifest
* Ensures remote entitlements in the background refresh without feature flags
2.6.2 [#262]
Enhancements [#enhancements-15]
* Adds `Superwall.instance.consume(purchaseToken)` method to help easily consume in-app purchases
Fixes [#fixes-18]
* Fixes issue with deeplink params not being handled properly in some cases
* Fixes issue with Drawer and Modal displays on Android 14 Samsung devices
* Fixes selection issue with some OTP, ensures after consuming the Status is synced
2.6.1 [#261]
Enhancements [#enhancements-16]
* Enables Stripe and Paddle checkout via in-app payment sheets
* Improves product handling and redemption for Stripe and Paddle
Fixes [#fixes-19]
* Fixes issue with Google's Play Billing library auto-reconnection
2.6.0 ⚠️ [Deprecated] [#260-️-deprecated]
Notes [#notes]
* This version is deprecated due to discovery of an issue in Play Billing library which could cause runtime issues
* Please use version 2.6.1
2.5.8 [#258]
Fixes [#fixes-20]
* Fix lifetime purchase entitlements not being discovered in some cases on purchase
* Fix potential ANR issues where some animations would end up looping over on main thread
* Fix webview client not behaving properly when using a resetted paywall
2.6.0-alpha [#260-alpha]
* Add app2web support, allowing users to purchase Stripe or Paddle products without leaving your app
* Add `PaymentSheet` purchase type enabling quick bottom sheet purchases
* Add support for Android app links purchase redeeming
2.5.7 [#257]
Fixes [#fixes-21]
* Fix `demandScore` and `demandTier` getting removed from some events
* Fixes paywall navigation resetting after backgrounding
* Removes webview flags which can cause off-screen render issues
2.5.6 [#256]
Enhancements [#enhancements-17]
* Add support for rerouting back button if enabled in paywall settings
* Handled by `SuperwallOptions.PaywallOptions.onBackPressed`, which enables you to consume the back press or let the SDK consume it
* Add support for redeeming web entitlements with Paddle
Fixes [#fixes-22]
* Fix binary .so file regression to ensure 16kb page size compatibility
* Fix potential issue with paywall not dismissing due to paywall\_decline concurrency issue
2.5.5 [#255]
Enhancements [#enhancements-18]
* Expose `signature` on `StoreTransaction`
Fixes [#fixes-23]
* ⚠️ Important - in the recent versions we have added usage of Google AppSetId and AdID to enable automatic attribution with Google's ad networks.
* Due to issues with Google's detection of the usage, they have been removed temporarily and will be added back once the issue is resolved.
* As an alternative, you can set those attribution identifiers using `AttributionProvider.GOOGLE_APP_SET | AttributionProvider.GOOGLE_ADS`
2.5.4 [#254]
Enhancements [#enhancements-19]
* `PaywallComposable` now reapplies theme on system change and `PaywallView` now exposes an `onThemeChanged()` method
* `DeepLink` event now exposes query params
2.5.3 [#253]
Enhancements [#enhancements-20]
* Adds ability to specify a custom height and corner radius for the drawer presentation style.
* Adds ability to display a `Popup` presentation style
* Adds ability to request reviews from paywall actions, including:
* Value `device.reviewRequestCount` that returns total request counts
* Method `device.reviewRequestsInHour|Day|Week|Year|reviewRequestsSinceInstall` computed methods for granular targeting
* Adds `Superwall.instance.setIntegrationAttributes` method enabling you to set integration identifiers for the users from different platforms (Adjust, Mixpanel, Meta, etc.)
* Adds `Superwall.instance.showAlert` method enabling you to easily show an alert over the current paywall
* Adds `PaywallOptions.timeoutAfter` to easily control timeout of paywalls when not using fallback loading
* Adds `Superwall.instance.setIntegrationAttributes` and `Superwall.instance.integrationAttributes` to set integration identifiers (i.e. Mixpanel, Appsflyer, etc)
* Adds user attributes to `TransactionComplete` and `PaywallOpen` events
* Adds device ID to device attributes
Fixes [#fixes-24]
* Fixes memory leak issues in `PaywallBuilder` would keep the paywall view alive in some cases
* Fixes issue where some events would be missing properties
2.5.1 [#251]
Enhancements [#enhancements-21]
* Improves subscribed user experience in cases with slow configuration
2.5.0 [#250]
Enhancements [#enhancements-22]
* Updates Google Play billing library to v8. Unfortunately, Google has broken backwards compatibility with previous versions, so if you're using the standalone library too ensure it is compatible with v8.
* Adds kotlin version to device variables
Fixes [#fixes-25]
* Reduces noisy logging when product is missing an offer
* Ensures that getting experimental properties works for consumable products
2.4.1 [#241]
Fixes [#fixes-26]
* Google Play Billing integration with newer libraries (such as RC 9.\*)
* Reduces noisy logging when product is missing an offer
* Ensures that getting experimental properties works for consumable products
2.4.0 [#240]
Enhancements [#enhancements-23]
* Increases superscript version to 1.0.2 with improved type and null safety
2.3.3 [#233]
Fixes [#fixes-27]
* Ensure properties are always properly serialized
2.3.2 [#232]
Enhancements [#enhancements-24]
* Adds new properties to count placement occurrences in specific time: `placementsInHours`, `placementsInDay`, `placementsInWeek`, `placementsInMonth`, `placementsSinceInstall`
Fixes [#fixes-28]
* Fixes an issue where a redemption could succeed but throw an error
* Fixes an issue with receipt manager when there is no purchases and experimental properties are enabled
2.3.1 [#231]
Fixes [#fixes-29]
* Fixes an issue where entitlements would not be reset on time
* Ensures `redeem` is only done on initial config not on config refresh
Enhancements [#enhancements-25]
* Adds `overrideProductsByName` property to allow globally overriding products on paywalls. This property accepts a map of product names to product identifiers (strings). Local overrides provided via `PaywallOverrides` take precedence over global overrides.
* Adds `ProductOverride` sealed class to provide flexible product override handling with support for both product IDs and `StoreProduct` objects.
2.3.0 [#230]
Enhancements [#enhancements-26]
* Deprecated `Superwall.instance.handleDeepLink` in favor of static `Superwall.handleDeepLink` to ensure links received before `configure` completion are handled properly
* Adds `externalAcountId`, provided to Google Play billing upon purchase as a SHA256 of the userId or the userId itself if `passIdentifiersToPlayStore` option is provided.
* Adds a `SuperwallOption` named `enableExperimentalDeviceVariables`. When set to true, this enables additional device-level variables: `latestSubscriptionPeriodType`, `latestSubscriptionState`, and `latestSubscriptionWillAutoRenew`. These properties provide information about the most recent Google Play subscription on the device and can be used in audience filters. Note that due to their experimental nature, they are subject to change in future updates.
* Update `com.android.billingclient` to version 7.1.1 to align with Google's latest requirements
Fixes [#fixes-30]
* Fixes issues with paywall destruction when activity performs a hot reload (i.e. during update)
* Fixes issue where the feature block would be triggered on non-gated paywalls when the app is minimised
2.2.3 [#223]
Fixes [#fixes-31]
* Fix potential issue with device enrichment and attributes synchronisation causing a lock
2.2.2 [#222]
Enhancements [#enhancements-27]
* Adds `demandScore` and `demandTier` to device attributes using an off-device advanced machine learning model. A user is assigned these based on a variety of factors to determine whether they're more or less likely to convert and can be used within audience filters.
* Adds `deviceTier` to the device attributes using an on-device scoring system to place the device in a tier based on it's capabilities. This can be used in audience filters to display different paywalls based on the user's device capabilities. The value can be one of the following values `ultraLow`, `low`, `mid`, `high`, `ultra_high`, `unknown`. NOTE: This property is still experimental
Fix [#fix]
* Fixes potential issue in `web2app` with subscription being overriden by the polling due to extra API calls
2.2.0 [#220]
Enhancements [#enhancements-28]
* Updates binaries to work on 16kb page sizes
2.1.2 [#212]
Fixes [#fixes-32]
* Fix issue with deep link referrer throwing a DeadObjectException
2.1.1 [#211]
Enhancements [#enhancements-29]
* Add optimisticLoading paywall option that hides the shimmer when HTML is loaded
* Prevent stopping the paywall handler listening with onDismiss when reason is None
* Improve PaywallBuilder API for non-kotlin and non-coroutine users
* Expose `deviceAttributes()` function to retrieve session device attributes
2.1.0 [#210]
Enhancements [#enhancements-30]
* Updates kotlin version to 2.0.21
* Updates `compileSDK` to 35
* Adds web checkout and redemption support
* Adds SuperwallDelegate methods `willRedeemLink` and `didRedeemLink`
Fixes [#fixes-33]
* Removes lock while reading cache that could cause ANR on the main thread
* Fixes issue where experiment and variant ID would be missing due to concurrency issues
2.1.0-beta.1 [#210-beta1]
Enhancements [#enhancements-31]
* Updates kotlin version to 2.0.21
* Updates `compileSDK` to 35
* Adds web checkout and redemption support
* Adds SuperwallDelegate methods `willRedeemLink` and `didRedeemLink`
2.0.8 [#208]
Fixes [#fixes-34]
* Fixes the serialization issue with Kotlin 2.0
2.0.7 [#207]
Enhancements [#enhancements-32]
* Improves how errors are handled when loading, improving the UX and reloading in real failure cases
* Added `device.subscriptionStatus` to the device object
Fixes [#fixes-35]
* Fixes an issue where users of kotlin 2.0 would experience a `NoAudienceMatch` when evaluating rules
2.0.6 [#206]
Fixes [#fixes-36]
* Fix potential crash while setting render priority
2.0.5 [#205]
Fixes [#fixes-37]
* Fix issue with `original_transaction_id` missing when using a `PurchaseController`
2.0.4 [#204]
Enhancements [#enhancements-33]
* Provide overloads for Java interop
* Provide utility functions for Java interop with `PurchaseController`
2.0.3 [#203]
Enhancements [#enhancements-34]
* Renames `SuperwallPlacement` back to `SuperwallEvent`
2.0.2 [#202]
Fixes [#fixes-38]
* Fixes issue with `NoAudienceMatch` appearing on some devices and issues with certain campaign rules
2.0.1 [#201]
Enhancements [#enhancements-35]
* Changes back to `handleSuperwallEvent` naming with a deprecation notice and a typealias for previous methods
Fixes [#fixes-39]
* Removes extra failure logging when displaying alerts
* Finds nearest activity instead of relying just on Context in `PaywallComposable`
* Improves cleanup in `PaywallComposable`
2.0.0 [#200]
Our 2.0.0 release brings some major and minor changes to both our API's and core features. For more information, please look at our [migration docs](https://superwall.com/docs/migrating-to-v2-android)
Enhancements [#enhancements-36]
* Adds `PaywallBuilder` class as an alternative to existing `getPaywallView` method. This provides a cleaner API and an ability to change purchase loading bar and shimmer view.
* Ensure safety of static webview calls that are known to fail randomly due to Webview's internal issues
* Adds `purchase` method to `Superwall` you can use to purchase products without having to resort on paywalls. To purchase a product you can pass it in one of the following objects:
* Google Play's `ProductDetails`
* Superwall's `StoreProduct` object
* Or a string containing the product identifier, i.e. `Superwall.instance.purchase("product_id:base_plan:offer")`
* Adds `restorePurchases` method to `Superwall` you can use to handle restoring purchases
* Adds `getProducts` method to `Superwall` you can use to retrieve a list of `ProductDetails` given the product ID, i.e. i.e. `Superwall.instance.purchase("product_id:base_plan:offer")`
* Adds support for observing purchases done outside of Superwall paywalls. You can now observe purchases done outside of Superwall paywalls by setting the `shouldObservePurchases` option to true and either:
* Manually by calling `Superwall.instance.observe(PurchasingObserverState)` or utility methods `Superwall.instance.observePurchaseStart/observePurchaseError/observePurchaseResult`
* Automatically by replacing `launchBillingFlow` with `launchBillingFlowWithSuperwall`. This will automatically observe purchases done outside of Superwall paywalls.
* Adds consumer proguard rules to enable consumer minification
* `Superwall.instance` now provides blocking or callback based version of multiple calls, suffixed with `*Sync`
* Improves preloading performance and reduces impact on the main thread
* Reduces minSDK to 22
Breaking Changes [#breaking-changes]
* `SuperwallPaywallActivity` and `PaywallView` have been moved into `com.superwall.sdk.paywall.view` package from `com.superwall.sdk.paywall.vc` package.
* Removes `PaywallComposable` and Jetpack Compose support from the main SDK artifact in favor of `Superwall-Compose` module for Jetpack Compose support:
* You can find it at `com.superwall.sdk:superwall-compose:2.0.0-alpha`
* Usage remains the same as before, but now you need to include the `superwall-compose` module in your project.
* Removed methods previously marked as Deprecated
* `SubscriptionStatus.Active` now takes in a set of `Entitlements`, while `Inactive` and `Active` have been turned into objects.
* `Superwall.instance.register` now uses `placement` instead of `event` as the argument name
* `preloadPaywalls` now uses `placementNames` instead of `eventNames` as the argument name
* `PaywallPresentationHandler.onDismiss` now has two arguments, `PaywallInfo` and `PaywallResult`
* `PaywallComposable` now uses `placement` argument instead of `event`
* `TriggerResult.NoRuleMatch` and `TriggerResult.EventNotFound` have been renamed to `TriggerResult.NoAudienceMatch` and `TriggerResult.PlacementNotFound`
* `PresentationResult.NoRuleMatch` and `PresentationResult.EventNotFound` have been renamed to `PresentationResult.NoAudienceMatch` and `PresentationResult.PlacementNotFound`
* `SuperwallEvent` has been renamed to `SuperwallPlacement`, belonging properties with `eventName` have been renamed to `placementName`
* `SuperwallEventInfo` has been renamed to `SuperwallPlacementInfo`
* `ComputedPropertyRequest.eventName` has been renamed to `ComputedPropertyRequest.placementName`
* `Superwall.instance.events` has been renamed to `Superwall.instance.placements`
* `LogScope.events` has been renamed to `LogScope.placements`
* `PaywallPresentationRequestStatusReason.EventNotFound` has been renamed to `PaywallPresentationRequestStatusReason.PlacementNotFound`
* `PaywallSkippedReason.EventNotFound` has been renamed to `PaywallSkippedReason.PlacementNotFound`
* `SuperwallDelegate.handleSuperwallEvent` method has been renamed to `SuperwallDelegate.handleSuperwallPlacement`
* Removed `PurchaseResult.Restored`
2.0.0-beta.5 [#200-beta5]
Breaking changes [#breaking-changes-1]
* `Superwall.instance.register` now uses `placement` instead of `event` as the argument name
* `preloadPaywalls` now uses `placementNames` instead of `eventNames` as the argument name
* Superwall's `PaywallPresentationHandler.onDismiss` now has two arguments, `PaywallInfo` and `PaywallResult`
* `PaywallComposable` now uses `placement` argument instead of `event`
* Remove `PurchaseResult.Restored`
2.0.0-beta.4 [#200-beta4]
Breaking changes [#breaking-changes-2]
* `SuperwallEvents.entitlementStatusDidChange` has been renamed to `SuperwallEvents.subscriptionStatusDidChange`
* `SuperwallDelegate.entitlementStatusDidChange` has been renamed to `SuperwallEvents.entitlementStatusDidChange`
* `TriggerResult.NoRuleMatch` and `TriggerResult.EventNotFound` have been renamed to `TriggerResult.NoAudienceMatch` and `TriggerResult.PlacementNotFound`
* `PresentationResult.NoRuleMatch` and `PresentationResult.EventNotFound` have been renamed to `PresentationResult.NoAudienceMatch` and `PresentationResult.PlacementNotFound`
2.0.0-beta.3 [#200-beta3]
Breaking changes [#breaking-changes-3]
* `SuperwallEvent` has been renamed to `SuperwallPlacement`, belonging properties with `eventName` have been renamed to `placementName`
* `SuperwallEventInfo` has been renamed to `SuperwallPlacementInfo`
* `ComputedPropertyRequest.eventName` has been renamed to `ComputedPropertyRequest.placementName`
* `Superwall.instance.events` has been renamed to `Superwall.instance.placements`
* `LogScope.events` has been renamed to `LogScope.placements`
* `PaywallPresentationRequestStatusReason.EventNotFound` has been renamed to `PaywallPresentationRequestStatusReason.PlacementNotFound`
* `PaywallSkippedReason.EventNotFound` has been renamed to `PaywallSkippedReason.PlacementNotFound`
* `SuperwallDelegate.handleSuperwallEvent` method has been renamed to `SuperwallDelegate.handleSuperwallPlacement`
2.0.0-beta.2 [#200-beta2]
Breaking Changes [#breaking-changes-4]
* API Changes:
* Migration of `setEntitlementStatus` to `setSubscriptionStatus`
* Exposing `Superwall.instance.entitlementsStatus`
* Migration of `SuperwallDelegate.entitlementStatusDidChange` to `SuperwallDelegate.subscriptionStatusDidChange`
2.0.0-beta.1 [#200-beta1]
Enhancements [#enhancements-37]
* Add `PaywallBuilder` class as an alternative to existing `getPaywallView` method. This provides a cleaner API and an ability to change purchase loading bar and shimmer view.
* Add callback versions of new 2.0 methods
* Ensure safety of static webview calls that are known to fail randomly due to Webview's internal issues
2.0.0-Alpha.1 [#200-alpha1]
Breaking Changes [#breaking-changes-5]
* `SuperwallPaywallActivity` and `PaywallView` have been moved into `com.superwall.sdk.paywall.view` package from `com.superwall.sdk.paywall.vc` package.
* Removes `PaywallComposable` and Jetpack Compose support from the main SDK artifact in favor of `Superwall-Compose` module for Jetpack Compose support:
* You can find it at `com.superwall.sdk:superwall-compose:2.0.0-alpha`
* Usage remains the same as before, but now you need to include the `superwall-compose` module in your project.
* Removed methods previously marked as Deprecated
* Removes `SubscriptionStatus`, together with belonging update methods and `subscriptionStatusDidChange` callback.
* These are replaced with `EntitlementStatus` and `entitlementStatusDidChange` callback. You can find more details on this migration in our docs.
Enhancements [#enhancements-38]
* Adds `purchase` method to `Superwall` you can use to purchase products without having to resort on paywalls. To purchase a product you can pass it in one of the following objects:
* Google Play's `ProductDetails`
* Superwall's `StoreProduct` object
* Or a string containing the product identifier, i.e. `Superwall.instance.purchase("product_id:base_plan:offer")`
* Adds `restorePurchases` method to `Superwall` you can use to handle restoring purchases
* Adds `getProducts` method to `Superwall` you can use to retrieve a list of `ProductDetails` given the product ID, i.e. i.e. `Superwall.instance.purchase("product_id:base_plan:offer")`
* Adds support for observing purchases done outside of Superwall paywalls. You can now observe purchases done outside of Superwall paywalls by setting the `shouldObservePurchases` option to true and either:
* Manually by calling `Superwall.instance.observe(PurchasingObserverState)` or utility methods `Superwall.instance.observePurchaseStart/observePurchaseError/observePurchaseResult`
* Automatically by replacing `launchBillingFlow` with `launchBillingFlowWithSuperwall`. This will automatically observe purchases done outside of Superwall paywalls.
* Adds consumer proguard rules to enable consumer minification
* Reduces minSDK to 22
* Adds `purchaseToken` to purchase events
* `Superwall.instance` now provides blocking or callback based version of multiple calls, suffixed with `*Sync`
* Improves preloading performance and reduces impact on the main thread
1.5.5 [#155]
Fixes [#fixes-40]
* Fixes potential distribution issues for variant selection in edge cases
* Fixes potential memory leaks of paywall calling activity
1.5.4 [#154]
Fixes [#fixes-41]
* Fixes issue when a paywall would dismiss with `ForNextPaywall` but next paywall could not be shown due to triggers or limits. Now it resolves into the proper dismiss status.
1.5.3 [#153]
Enhancements [#enhancements-39]
* Add `purchaseToken` to TransactionComplete
1.5.2 [#152]
Fixes [#fixes-42]
* Fix chromium crashes caused by race conditions in webview's implementation
1.5.1 [#151]
Enhancements [#enhancements-40]
* Updates superscript dependencies to reduce minSDK version
Fixes [#fixes-43]
* Adds consumer proguard rules to avoid minifying JNA classes during minification
1.5.0 [#150]
Enhancements [#enhancements-41]
* Adds `shimmerView_start` and `shimmerView_complete` events to track the loading of the shimmer animation.
* Makes `hasFreeTrial` match iOS SDK behavior by returning `true` for both free trials and non-free introductory offers
* Adds `Superwall.instance.events` - A SharedFlow instance emitting all Superwall events as `SuperwallEventInfo`. This can be used as an alternative to a delegate for listening to events.
* Adds a new shimmer animation
* Adds support for SuperScript expression evaluator
Fixes [#fixes-44]
* Fixes concurrency issues with subscriptions triggered in Cordova apps
1.5.0-beta.2 [#150-beta2]
Enhancements [#enhancements-42]
* Adds `shimmerView_start` and `shimmerView_complete` events to track the loading of the shimmer animation.
* Makes `hasFreeTrial` match iOS SDK behavior by returning `true` for both free trials and non-free introductory offers
1.5.0-beta.1 [#150-beta1]
Enhancements [#enhancements-43]
* Adds `Superwall.instance.events` - A SharedFlow instance emitting all Superwall events as `SuperwallEventInfo`. This can be used as an alternative to a delegate for listening to events.
* Adds a new shimmer animation
* Adds support for SuperScript expression evaluator
1.4.1 [#141]
Enhancements [#enhancements-44]
* Adds `appVersionPadded` attribute
Fixes [#fixes-45]
* Fixes issue where `PaywallPresentationHandler.onError` would be skipped in case of `BillingError`s
1.4.0 [#140]
Enhancements [#enhancements-45]
* Improves paywall loading and preloading performance
* Reduces impact of preloading on render performance
* Updates methods to return `kotlin.Result` instead of relying on throwing exceptions
* This introduces some minor breaking changes:
* `configure` completion block now provides a `Result` that can be used to check for success or failure
* `handleDeepLink` now returns a `Result`
* `getAssignments` now returns a `Result>`
* `confirmAllAssignments` now returns a `Result>`
* `getPresentationResult` now returns a `Result`
* `getPaywallComponents` now returns a `Result`
* Removes Gson dependency
* Adds `isScrollEnabled` flag to enable remote control of Paywall scrollability
* Adds `PaywallResourceLoadFail` event to enable tracking of failed resources in Paywall
* Improves bottom navigation bar color handling
Fixes [#fixes-46]
* Fixes issue where paywalls without fallback would fail to load and missing resource would cause a failure event
* Fixes issue with `trialPeriodDays` rounding to the higher value instead of lower, i.e. where `P4W2D` would return 28 days instead of 30, it now returns 30.
* Fixes issue with system navigation bar not respecting paywall color
* Fixes issues with cursor allocation in Room transaction
* Improves handling of chromium render process crash
1.4.0-beta.3 [#140-beta3]
* Fixes issue where paywalls without fallback would fail to load and missing resource would cause a failure event
1.4.0-beta.2 [#140-beta2]
Enhancements [#enhancements-46]
* Removes Gson dependency
* Adds `isScrollEnabled` flag to enable remote controll of Paywall scrollability
Fixes [#fixes-47]
* Fixes issue with `trialPeriodDays` rounding to the higher value instead of lower, i.e. where `P4W2D` would return 28 days instead of 30, it now returns 30.
* Fixes issue with system navigation bar not respecting paywall color
* Reduces impact of preloading on render performance
* Fixes issues with cursor allocation in Room transaction
1.4.0-beta.1 [#140-beta1]
* Updates methods to return `kotlin.Result` instead of relying on throwing exceptions
* This introduces some minor breaking changes:
* `configure` completion block now provides a `Result` that can be used to check for success or failure
* `handleDeepLink` now returns a `Result`
* `getAssignments` now returns a `Result>`
* `confirmAllAssignments` now returns a `Result>`
* `getPresentationResult` now returns a `Result`
* `getPaywallComponents` now returns a `Result`
1.3.1 [#131]
Fixes [#fixes-48]
* Fixes issue when destroying activities during sleep would cause a background crash
* Fixes issue when using Superwall with some SDK's would cause a crash (i.e. Smartlook SDK)
1.3.0 [#130]
Enhancements [#enhancements-47]
* The existing `getPaywall` method has been deprecated and renamed to `getPaywallOrThrow`. The new `getPaywall` method now returns a `kotlin.Result` instead of throwing an exception.
* Adds a new option to `SuperwallOptions` - `passIdentifiersToPlayStore` which allows you to pass the user's identifiers (from `Superwall.instance.identify(userId: String, ...)`) to the Play Store when making a purchase. Note: When passing in identifiers to use with the play store, please make sure to follow their \[guidelines]\([https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedAccountId(java.lang.String)](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedAccountId\(java.lang.String\)).
* Adds `Superwall.instance.confirmAllAssignments()`, which confirms assignments for all placements and returns an array of all confirmed experiment assignments. Note that the assignments may be different when a placement is registered due to changes in user, placement, or device parameters used in audience filters.
Fixes [#fixes-49]
* Fixes issues with Paywall sometimes not displaying when returning from background
* Fixes issue with SDK crashing when WebView is not available
* Fixes issue with `SuperwallPaywallActivity` NPE
* Update visibility of internal `getPaywall` methods to `internal` to prevent misuse
1.2.9 [#129]
Fixes [#fixes-50]
* Fixes issues with `MODAL` presentation style and scrolling containers
* Fixes issues with `FULLSCREEN` presentation style rendering behind navigation
1.2.8 [#128]
Fixes [#fixes-51]
* Fixes issues with Paywall presentation styles not being properly passed
1.2.7 [#127]
Enhancements [#enhancements-48]
* Exposes current configuration status via `Superwall.instance.configurationStatus`
Fixes [#fixes-52]
* Fixes issues with Paywall previews not loading
1.2.6 [#126]
Fixes [#fixes-53]
* Fixes issue where the paywall would not show in some cases when using `minutes_since`
* Fixes issue with wrong URL being logged when a paywall fails to load
1.2.5 [#125]
Enhancements [#enhancements-49]
* Adds a `Modifier` to `PaywallComposable` to allow for more control
* Adds a `PaywallView.setup(...)` method to allow for easy setup when using `PaywallView` directly
* Adds support for `MODAL` presentation style
Fixes [#fixes-54]
* Fixes issue with displaying `PaywallComposable`
* Resolves issue where users would get `UninitializedPropertyAccessException` when calling `Superwall.instance`
1.2.4 [#124]
Enhancements [#enhancements-50]
* For users who are not able to upgrade their AGP or Gradle versions, we have added a new artifact `superwall-android-agp-7` which keeps compatibility.
Enhancements [#enhancements-51]
* Fixes issue with decoding custom placements from paywalls.
1.2.3 [#123]
Enhancements [#enhancements-52]
* Expose `placementName`, `paywallInfo` and `params` on a `custom_placement` event
1.2.2 [#122]
Enhancements [#enhancements-53]
* Adds support for multiple paywall URLs, in case one CDN provider fails.
* `ActivityEncapsulatable` now uses a WeakReference instead of a reference
* SW-2900: Adds Superwall.instance.localeIdentifier as a convenience variable that you can use to dynamically update the locale used for evaluating rules and getting localized paywalls.
* SW-2919: Adds a `custom_placement` event that you can attach to any element in the paywall with a dictionary of parameters. When the element is tapped, the event will be tracked. The name of the placement can be used to trigger a paywall and its params used in audience filters.
* Adds support for bottom sheet presentation style (DRAWER), no animation style and default animation.
* Adds `build_id` and `cache_key` to `PaywallInfo`.
* SW-2917: Tracks a `config_attributes` event after calling `Superwall.configure`, which contains info about the configuration of the SDK. This gets tracked whenever you set the delegate.
* Adds in device attributes tracking after setting the interface style override.
* To comply with new Google Play Billing requirements we now avoid setting empty `offerToken` for one-time purchases
1.2.1 [#121]
Enhancements [#enhancements-54]
* Adds the ability for the SDK to refresh the Superwall configuration every session start, subject to a feature flag.
* Tracks a `config_refresh` Superwall event when the configuration is refreshed.
* SW-2890: Adds `capabilities` to device attributes. This is a comma-separated list of capabilities the SDK has that you can target in audience filters. This release adds the `paywall_event_receiver` capability. This indicates that the paywall can receive transaction from the SDK.
* SW-2902: Adds `abandoned_product_id` to a `transaction_abandon` event to use in audience filters. You can use this to show a paywall if a user abandons the transaction for a specific product.
1.2.0 [#120]
Enhancements [#enhancements-55]
* Adds DSL methods for configuring the SDK. You can now use a configuration block:
```kotlin
fun Application.configureSuperwall(
apiKey: String,
configure: SuperwallBuilder.() -> Unit,
)
```
This allows you to configure the SDK in a more idiomatic way:
```kotlin
configureSuperwall(CONSTANT_API_KEY){
options {
logging {
level = LogLevel.debug
}
paywalls {
shouldPreload = false
}
}
}
```
Deprecations [#deprecations-1]
This release includes multiple deprecations that will be removed in upcoming versions.
Most are internal and will not affect the public API, those that will are marked as such and a simple migration
path is provided. The notable ones in the public API are as follows:
* Deprecated `DebugViewControllerActivity` in favor of `DebugViewActivity`
* Deprecated `PaywallViewController` in favor of `PaywallView`
* Deprecated belonging methods:
* `viewWillAppear` in favor of `beforeViewCreated`
* `viewDidAppear` in favor of `onViewCreated`
* `viewWillDisappear` in favor of `beforeOnDestroy`
* `viewDidDisappear` in favor of `destroyed`
* `presentAlert` in favor of `showAlert`
* Deprecated `PaywallViewControllerDelegate` in favor of `PaywallViewCallback`
* Deprecated belonging methods:
* `didFinish` in favor of `onFinished`
* Deprecated `PaywallViewControllerEventDelegate` in favor of `PaywallViewEventCallback`
* Users might also note deprecation of `PaywallWebEvent.OpenedUrlInSafari` in favor of `PaywallWebEvent.OpenedUrlInChrome`
* `didFinish` in favor of `onFinished`
* In `Superwall`, the following methods were deprecated:
* `Superwall.paywallViewController` in favor of `Superwall.paywallView`
* `Superwall.eventDidOccur` argument `paywallViewController` in favor of `paywallView`
* `Superwall.dismiss` in favor of \`Superwall.presentPaywallView
* `Superwall.presentPaywallViewController` in favor of `Superwall.presentPaywallView`
* Deprecated `Paywallmanager.getPaywallViewController` in favor of `PaywallManager.getPaywallView`
* Deprecated `DebugManager.viewController` in favor of `DebugManager.view`
* Deprecated `DebugViewController` in favor of `DebugView`
* Deprecated `LogScope.debugViewController` in favor of `LogScope.debugView`
* Deprecated `PaywallPresentationRequestStatus.NoPaywallViewController` in favor of `NoPaywallView`
1.1.9 [#119]
Deprecations [#deprecations-2]
* Deprecated configuration method `Superwall.configure(applicationContext: Context, ...)` in favor of `Superwall.configure(applicationContext: Application, ...)` to enforce type safety. The rest of the method signature remains the same.
Fixes [#fixes-55]
* SW-2878: and it's related leaks. The `PaywallViewController` was not being properly detached when activity was stopped, causing memory leaks.
* SW-2872: Fixes issue where `deviceAttributes` event and fetching would not await for IP geo to complete.
* Fixes issues on tablet devices where the paywall would close after rotation/configuration change.
1.1.8 [#118]
Enhancements [#enhancements-56]
* SW-2859: Adds error message to `paywallWebviewLoad_fail`.
* SW-2866: Logs error when trying to purchase a product that has failed to load.
* SW-2869: Add `Reset` event to track when `Superwall.instance.reset` is called.
* SW-2867: Prevents Geo api from being called when app is in the background
* SW-2431: Improves coroutine scope usages & threading limits
* Toolchain and dependency updates
Fixes [#fixes-56]
* SW-2863: Fixed a `NullPointerException` some users on Android 12 & 13 would experience when calling `configure`.
1.1.7 [#117]
Enhancements [#enhancements-57]
* SW-2805: Exposes a `presentation` property on the `PaywallInfo` object. This contains information about the presentation of the paywall.
* SW-2855: Adds `restore_start`, `restore_complete`, and `restore_fail` events.
Fixes [#fixes-57]
* SW-2854: Fixed issue where abandoning the transaction by pressing back would prevent the user from restarting the transaction.
1.1.6 [#116]
Enhancements [#enhancements-58]
* SW-2833: Adds support for dark mode paywall background color.
* Adds ability to target devices based on their IP address location. Use `device.ipRegion`,
`device.ipRegionCode`, `device.ipCountry`, `device.ipCity`, `device.ipContinent`, or `device.ipTimezone`.
* Adds `event_name` to the event params for use with audience filters.
Fixes [#fixes-58]
* Fixes issue with products whose labels weren't primary/secondary/tertiary.
1.1.5 [#115]
Fixes [#fixes-59]
* Fixes thread safety crash when multiple threads attempted to initialize the `JavaScriptSandbox`
internally.
1.1.3 [#113]
Enhancements [#enhancements-59]
* Tracks an `identity_alias` event whenever identify is called to alias Superwall's anonymous ID with a
developer provided id.
* Adds `setInterfaceStyle(interfaceStyle:)` which can be used to override the system interface style.
* Adds `device.interfaceStyleMode` to the device template, which can be `automatic` or `manual` if
overriding the interface style.
Fixes [#fixes-60]
* Uses `JavascriptSandbox` when available for filter expression evaluation on a background thread
instead of running code on the main thread in a webview.
* Fixes crash where the loading spinner inside the `PaywallViewController` was being updated outside
the main thread.
1.1.2 [#112]
Enhancements [#enhancements-60]
* Updates build.gradle configuration which means we can now upload the SDK to maven central. You no
longer need to specify our custom repo in your build.gradle to get our SDK and therefore installation
should be easier.
Fixes [#fixes-61]
* Fixes `ConcurrentModificationException` crash that sometimes happened when identifying a user.
* Fixes crash on purchasing a free trial when using `getPaywall`.
1.1.1 [#111]
Fixes [#fixes-62]
* Fixes an issue loading products with offers.
1.1.0 [#110]
Enhancements [#enhancements-61]
* SW-2768: Adds `device.regionCode` and `device.preferredRegionCode`, which returns the `regionCode`
of the locale. For example, if a locale is `en_GB`, the `regionCode` will be `GB`. You can use this
in the filters of your campaign.
* Adds support for unlimited products in a paywall.
* SW-2785: Adds internal feature flag to disable verbose events like `paywallResponseLoad_start`.
Fixes [#fixes-63]
* SW-2732: User attributes weren't being sent on app open until identify was called. Now they are
sent every time there's a new session.
* SW-2733: Fixes issue where the spinner would still show on a paywall if a user had previously
purchased on it.
* SW-2744: Fixes issue where using the back button to dismiss a paywall presented via `getPaywall`
would call `didFinish` in the `PaywallViewControllerDelegate` with the incorrect values.
* Fixes issue where an invalid paywall background color would prevent the paywall from opening. If
this happens, it will now default to white.
* SW-2748: Exposes `viewWillAppear`, `viewDidAppear`, `viewWillDisappear` and `viewDidDisappear`
methods of `PaywallViewController` which you must call when using `getPaywall`.
* Stops `Superwall.configure` from being called multiple times.
* `getPresentationResult` now confirms assignments for holdouts.
* Gracefully handles unknown local notification types if new ones are added in the future.
* SW-2761: Fixes issue where "other" responses in paywall surveys weren't showing in the dashboard.
1.0.2 [#102]
Fixes [#fixes-64]
* Prevents a paywall from opening in a separate activity inside a task manager when using `taskAffinity`
within your app.
1.0.1 [#101]
Fixes [#fixes-65]
* Fixes serialization of `feature_gating` in `SuperwallEvents`.
* Changes the product loading so that if preloading is enabled, it makes one API request to get all
products available in paywalls. This results in fewer API requests. Also, it adds retry logic on failure.
If billing isn't available on device, the `onError` handler will be called.
1.0.0 [#100]
Breaking Changes [#breaking-changes-6]
* Changes the import path for the `LogScope`, and `LogLevel`.
Fixes [#fixes-66]
* Fixes rare thread-safety crash when sending events back to Superwall's servers.
* Calls the `onError` presentation handler block when there's no activity to present a paywall on.
* Fixes issue where the wrong product may be presented to purchase if a free trial had already been
used and you were letting Superwall handle purchases.
* Fixes `IllegalStateException` on Samsung devices when letting Superwall handle purchases.
* Keeps the text zoom of paywalls to 100% rather than respecting the accessibility settings text zoom,
which caused unexpected UI issues.
* Fixes rare `UninitializedPropertyAccessException` crash caused by a threading issue.
* Fixes crash when the user has disabled the Android System WebView.
1.0.0-alpha.45 [#100-alpha45]
Fixes [#fixes-67]
* Fixes issue where the `paywallProductsLoad_fail` event wasn't correctly being logged. This is a
"soft fail", meaning that even though it gets logged, your paywall will still show. The error message
with the event has been updated to include all product subscription IDs that are failing to be retrieved.
1.0.0-alpha.44 [#100-alpha44]
Fixes [#fixes-68]
* Fixes rare issue where paywall preloading was preventing paywalls from showing.
1.0.0-alpha.43 [#100-alpha43]
Enhancements [#enhancements-62]
* Adds `handleLog` to the `SuperwallDelegate`.
1.0.0-alpha.42 [#100-alpha42]
Fixes [#fixes-69]
* Makes sure client apps use our proguard file.
1.0.0-alpha.41 [#100-alpha41]
Fixes [#fixes-70]
* Removes need for `SCHEDULED_EXACT_ALARM` permission in manifest.
1.0.0-alpha.40 [#100-alpha40]
Fixes [#fixes-71]
* Fixes issue presenting paywalls to users who had their device language set to Russian, Polish or
Czech.
1.0.0-alpha.39 [#100-alpha39]
Fixes [#fixes-72]
* Adds missing `presentationSourceType` to `PaywallInfo`.
* Fixes issue where the status bar color was always dark regardless of paywall color.
* Adds `TypeToken` to proguard rules to prevent r8 from 'optimizing' our code and causing a crash.
1.0.0-alpha.38 [#100-alpha38]
Enhancements [#enhancements-63]
* SW-2682: Adds `Superwall.instance.latestPaywallInfo`, which you can use to get the `PaywallInfo` of
the most recently presented view controller.
* SW-2683: Adds `Superwall.instance.isLoggedIn`, which you can use to check if the user is logged in.
Fixes [#fixes-73]
* Removes use of `USE_EXACT_ALARM` permission that was getting apps rejected.
* Fixes issue with scheduling notifications. The paywall wasn't waiting to schedule notifications
before dismissal so the permissions wasn't always showing.
1.0.0-alpha.37 [#100-alpha37]
Enhancements [#enhancements-64]
* SW-2684: Adds error logging if the `currentActivity` is `null` when trying to present a paywall.
Fixes [#fixes-74]
* Fixes bug where paywalls might not present on first app install.
* Fixes bug where all `paywallResponseLoad_` events were being counted as `paywallResponseLoad_start`.
* Adds ProGuard rule to prevent `DefaultLifecycleObserver` from being removed.
1.0.0-alpha.36 [#100-alpha36]
Enhancements [#enhancements-65]
* Adds `X-Subscription-Status` header to all requests.
* Caches the last `subscriptionStatus`.
* Adds `subscriptionStatus_didChange` event that is fired whenever the subscription status changes.
* Calls the delegate method `subscriptionStatusDidChange` whenever the subscription status changes.
* SW-2676: Adds a completion block to the `configure` method.
Fixes [#fixes-75]
* Fixes issue where the main thread was blocked when accessing some properties internally.
* SW-2679: Fixes issue where the `subscription_start` event was being fired even if a non-recurring product was
purchased.
1.0.0-alpha.35 [#100-alpha35]
Fixes [#fixes-76]
* Fixes issue where `transaction_complete` events were being rejected by the server.
1.0.0-alpha.34 [#100-alpha34]
Breaking Changes [#breaking-changes-7]
* Changes `Superwall.instance.getUserAttributes()` to `Superwall.instance.userAttributes`.
* `SuperwallOptions.logging.logLevel` is now non-optional. Set it to `LogLevel.none` to prevent
logs from being printed to the console.
Enhancements [#enhancements-66]
* SW-2663: Adds `preloadAllPaywalls()` and `preloadPaywalls(eventNames:)` to be able to manually
preload paywalls.
* SW-2665: Adds `Superwall.instance.userId` so you can access the current user's id.
* SW-2668: Adds `preferredLocale` and `preferredLanguageLocale` to the device attributes for use in rules.
* Adds `Superwall.instance.logLevel` as a convenience variable to set and get the log level.
Fixes [#fixes-77]
* SW-2664: Fixes race condition between resetting and presenting paywalls.
1.0.0-alpha.33 [#100-alpha33]
Fixes [#fixes-78]
* Fixes issue where a user would be asked to enable notifications even if there weren't any attached
to the paywall.
1.0.0-alpha.32 [#100-alpha32]
Enhancements [#enhancements-67]
* SW-2214: Adds ability to use free trial notifications with a paywall.
* Adds `cancelAllScheduledNotifications()` to cancel any scheduled free trial notifications.
* SW-2640: Adds `computedPropertyRequests` to `PaywallInfo`.
* SW-2641: Makes `closeReason` in `PaywallInfo` non-optional.
Fixes [#fixes-79]
* Fixes issue where thrown exceptions weren't always being caught.
1.0.0-alpha.31 [#100-alpha31]
Enhancements [#enhancements-68]
* SW-2638: Adds `Restored` to `PurchaseResult`.
* SW-2644: Adds `RestoreType` to `SuperwallEvent.TransactionRestore`.
* SW-2643: Makes `storePayment` non-optional for a `StoreTransaction`.
* SW-2642: Adds `productIdentifier` to `StorePayment`.
Fixes [#fixes-80]
* SW-2635: Fixes crash that sometimes occurred if an app was trying to get Superwall's paywall
configuration in the background.
1.0.0-alpha.30 [#100-alpha30]
Enhancements [#enhancements-69]
* SW-2154: The SDK now includes a paywall debugger, meaning you can scan the QR code of any paywall in the
editor to preview it on device. You can change its localization, view product attributes, and view
the paywall with or without a trial.
Fixes [#fixes-81]
* More bug fixes relating to the loading of products.
1.0.0-alpha.29 [#100-alpha29]
Fixes [#fixes-82]
* SW-2631: Fixes issue where paywalls weren't showing if the products within them had a base plan or
offer ID set.
1.0.0-alpha.28 [#100-alpha28]
Fixes [#fixes-83]
* SW-2615: Fixes crash on Android versions \< 8 when accessing the Android 8+ only class Period.
* SW-2616: Fixes crash where the `PaywallViewController` was sometimes being added to a new parent view before
being removed from it's existing parent view.
1.0.0-alpha.27 [#100-alpha27]
Breaking Changes [#breaking-changes-8]
* \#SW-2218: Changes the `PurchaseController` purchase function to `purchase(activity:productDetails:basePlanId:offerId:)`.
This adds support for purchasing offers and base plans.
Enhancements [#enhancements-70]
* \#SW-2600: Backport device attributes
* Adds support for localized paywalls.
* Paywalls are only preloaded if their associated rules can match.
* Adds status bar to full screen paywalls.
Fixes [#fixes-84]
* Fixes issue where holdouts were still matching even if the limit set for their corresponding rules were exceeded.
* \#SW-2584: Fixes issue where prices with non-terminating decimals were causing products to fail to load.
1.0.0-alpha.26 [#100-alpha26]
Fixes [#fixes-85]
* Additional fixes to make Google billing more robust.
* Fixes an issue that causes `transaction_complete` events to fail.
1.0.0-alpha.25 [#100-alpha25]
Fixes [#fixes-86]
* Fixes [Google Billing Crash on Samsung devices](https://community.revenuecat.com/sdks-51/how-to-fix-crash-too-many-bind-requests-999-for-service-intent-inappbillingservice-3317).
1.0.0-alpha.24 [#100-alpha24]
Fixes [#fixes-87]
* Fixes an issue that could cause "n/a" to be displayed on a paywall in place of the proper subscription period string.
1.0.0-alpha.23 [#100-alpha23]
Fixes [#fixes-88]
* Fixes an issue where calling `identify` right after `configure` would hang b/c network requests need to access the user id to add to headers.
* Fixes a potential crash when loading data from disk
1.0.0-alpha.22 [#100-alpha22]
Fixes [#fixes-89]
* Fixes threading issue
1.0.0-alpha.21 [#100-alpha21]
Fixes [#fixes-90]
* Changes Activity to AppCompatActivity
1.0.0-alpha.20 [#100-alpha20]
Enhancements [#enhancements-71]
Fixes [#fixes-91]
* Fixes `app_open` race condition
* Prevents calling purchase twice
* Disables interactivity during purchase
1.0.0-alpha.19 [#100-alpha19]
Fixes [#fixes-92]
* Fixes `app_launch` event not triggering
1.0.0-alpha.18 [#100-alpha18]
Enhancements [#enhancements-72]
* Adds the ability to provide an `ActivityProvider` when configuring the SDK. This is an interface
containing the function `getCurrentActivity()`, which Superwall will use to present paywalls from.
You would typically conform an `ActivityLifecycleTracker` to this interface.
Fixes [#fixes-93]
* Fixes a crash when storing a `List` to user attributes and if that List or a Map had a null value.
1.0.0-alpha.17 [#100-alpha17]
Enhancements [#enhancements-73]
* Adds automatic purchase controller
* Improves memory handling for webviews
* Hides the loading indicator on a paywall if transactionBackgroundView is set to NONE
1.0.0-alpha.14 [#100-alpha14]
Enhancements [#enhancements-74]
* Adds `trigger_session_id` to Superwall Events.
* Resets the scroll position of the paywall on close.
Fixes [#fixes-94]
* Fixes issue where an invalid currency code on a device would crash the app when trying to retrieve products.
1.0.0-alpha.13 [#100-alpha13]
Fixes [#fixes-95]
* Fixes concurrency issues when setting and retrieving values like the appUserId and seed.
1.0.0-alpha.11 [#100-alpha11]
Fixes [#fixes-96]
* Can now use both non-recurring products and subscription products in paywalls.
* Fixes a crash issue that was caused by a lazy variable being accessed before it was initialized.
# Cohorting in 3rd Party Tools
:::android
```kotlin Kotlin
override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
when(eventInfo.event) {
is SuperwallEvent.TriggerFire -> {
MyAnalyticsService.shared.setUserAttributes(
mapOf(
"sw_experiment_${eventInfo.params.get("experiment_id").toString()}" to true,
"sw_variant_${eventInfo.params.get("variant_id").toString()}" to true
)
)
}
else -> {}
}
}
```
:::
:::flutter
```dart Flutter
@override
void handleSuperwallEvent(SuperwallEventInfo eventInfo) async {
final experimentId = eventInfo.params?['experiment_id'];
final variantId = eventInfo.params?['variant_id'];
switch (eventInfo.event.type) {
case EventType.triggerFire:
MyAnalyticsService.shared.setUserAttributes({
"sw_experiment_$experimentId": true,
"sw_variant_$variantId": true
});
break;
default:
break;
}
}
```
:::
:::expo
```typescript React Native
handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
const experimentId = eventInfo.params?['experiment_id']
const variantId = eventInfo.params?['variant_id']
if (!experimentId || !variantId) {
return
}
switch (eventInfo.event.type) {
case EventType.triggerFire:
MyAnalyticsService.shared.setUserAttributes({
`sw_experiment_${experimentId}`: true,
`sw_variant_${variantId}`: true
});
break;
default:
break;
}
}
```
:::
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:
:::android
```kotlin
override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
println("analytics event: ${eventInfo.event.rawName}")
MyAnalytics.shared.track(eventInfo.event.rawName, eventInfo.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`:
:::android
```kotlin
override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
when(eventInfo.event) {
is SuperwallPlacement.AppClose -> TODO()
is SuperwallPlacement.AppInstall -> TODO()
is SuperwallPlacement.AppLaunch -> TODO()
is SuperwallPlacement.AppOpen -> TODO()
is SuperwallPlacement.DeepLink -> TODO()
is SuperwallPlacement.FirstSeen -> TODO()
is SuperwallPlacement.FreeTrialStart -> TODO()
is SuperwallPlacement.NonRecurringProductPurchase -> TODO()
is SuperwallPlacement.PaywallClose -> TODO()
is SuperwallPlacement.PaywallDecline -> TODO()
is SuperwallPlacement.PaywallOpen -> TODO()
is SuperwallPlacement.PaywallPresentationRequest -> TODO()
is SuperwallPlacement.PaywallProductsLoadComplete -> TODO()
is SuperwallPlacement.PaywallProductsLoadFail -> TODO()
is SuperwallPlacement.PaywallProductsLoadStart -> TODO()
is SuperwallPlacement.PaywallResponseLoadComplete -> TODO()
is SuperwallPlacement.PaywallResponseLoadFail -> TODO()
is SuperwallPlacement.PaywallResponseLoadNotFound -> TODO()
is SuperwallPlacement.PaywallResponseLoadStart -> TODO()
is SuperwallPlacement.PaywallWebviewLoadComplete -> TODO()
is SuperwallPlacement.PaywallWebviewLoadFail -> TODO()
is SuperwallPlacement.PaywallWebviewLoadStart -> TODO()
is SuperwallPlacement.PaywallWebviewLoadTimeout -> TODO()
is SuperwallPlacement.SessionStart -> TODO()
is SuperwallPlacement.SubscriptionStart -> TODO()
is SuperwallPlacement.SubscriptionStatusDidChange -> TODO()
is SuperwallPlacement.SurveyClose -> TODO()
is SuperwallPlacement.SurveyResponse -> TODO()
is SuperwallPlacement.TransactionAbandon -> TODO()
is SuperwallPlacement.TransactionComplete -> TODO()
is SuperwallPlacement.TransactionFail -> TODO()
is SuperwallPlacement.TransactionRestore -> TODO()
is SuperwallPlacement.TransactionStart -> TODO()
is SuperwallPlacement.TransactionTimeout -> TODO()
is SuperwallPlacement.TriggerFire -> TODO()
is SuperwallPlacement.UserAttributes -> TODO()
}
}
```
:::
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.
:::android
```kotlin Kotlin
// MyPurchaseController.kt
class MyPurchaseController(val context: Context): PurchaseController {
// 1
override suspend fun purchase(
activity: Activity,
productDetails: ProductDetails,
basePlanId: String?,
offerId: String?
): PurchaseResult {
// TODO
// ----
// Purchase via GoogleBilling, RevenueCat, Qonversion or however
// you like and return a valid PurchaseResult
return PurchaseResult.Purchased()
}
// 2
override suspend fun restorePurchases(): RestorationResult {
// TODO
// ----
// Restore purchases and return true if successful.
return RestorationResult.Success()
}
}
```
:::
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:
:::android
```kotlin Kotlin
// MainApplication.kt
class MainApplication : android.app.Application(), SuperwallDelegate {
override fun onCreate() {
super.onCreate()
Superwall.configure(this, "MY_API_KEY", MyPurchaseController(this))
// OR using the DSL
configureSuperwall("MY_API_KEY") {
purchaseController = MyPurchaseController(this@MainApplication)
}
}
}
```
:::
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:
:::android
```kotlin Kotlin
// When a subscription is purchased, restored, validated, expired, etc...
myService.subscriptionStatusDidChange {
if (it.hasActiveSubscription) {
Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Active(entitlements))
} else {
Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Inactive(entitlements))
}
}
```
:::
`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:
:::android
```kotlin Kotlin
Superwall.instance.subscriptionStatus.collect { status: SubscriptionStatus ->
// React to changes
}
```
:::
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
**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 Android SDK 2.7.0.
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`:
```kotlin
val handler = PaywallPresentationHandler()
handler.onCustomCallback { callback ->
when (callback.name) {
"validate_email" -> {
val email = callback.variables?.get("email") as? String
if (isValidEmail(email)) {
CustomCallbackResult.success(mapOf("validated" to true))
} else {
CustomCallbackResult.failure(mapOf("error" to "Invalid email"))
}
}
else -> CustomCallbackResult.failure()
}
}
Superwall.instance.register(placement = "campaign_trigger", handler = handler) {
// Feature launched
}
```
CustomCallback [#customcallback]
The `CustomCallback` data class is passed to your handler:
CustomCallbackResult [#customcallbackresult]
Return one of the following from your handler to signal the outcome:
```kotlin
// Success — the paywall's onSuccess branch runs
CustomCallbackResult.success(data = mapOf("key" to "value"))
// Failure — the paywall's onFailure branch runs
CustomCallbackResult.failure(data = mapOf("error" to "Something went wrong"))
```
Both `success()` and `failure()` accept an optional `data` map 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 `mapOf("validated" to 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`:
:::android
```kotlin
override fun handleCustomPaywallAction(name: String) {
if (name == "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:
:::android
```kotlin Android
// Purchase product with first available baseplan
Superwall.instance.purchase("my_product_id")
// Purchase product with base plan and cheapest/free offer
Superwall.instance.purchase("my_product_id:base_plan:sw-auto")
// Purchase product with a specified offer
Superwall.instance.purchase("my_product_id:base_plan:offer")
// Purchase product with no offer
Superwall.instance.purchase("my_product_id:base_plan:sw-none")
```
:::
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:
:::android
```kotlin Android
val products = Superwall.instance.getProducts("id1", "id2")
```
:::
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
:::android
First set the `SuperwallOption` `isGameControllerEnabled` to `true`:
```kotlin
Superwall.instance.options.isGameControllerEnabled = true
```
Then Superwall will automatically listen for gamepad events and forward them to your paywall!
:::
:::flutter
First set the `SuperwallOption` `isGameControllerEnabled` to `true`:
```dart
Superwall.instance.options.isGameControllerEnabled = true
```
Then Superwall will automatically listen for gamepad events and forward them to your paywall!
:::
:::expo
First set the `SuperwallOption` `isGameControllerEnabled` to `true`:
```typescript
Superwall.instance.options.isGameControllerEnabled = true
```
Then Superwall will automatically listen for gamepad events and forward them to your paywall!
:::
# 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:
:::android
```kotlin Android (Automatic)
val options = SuperwallOptions()
options.shouldObservePurchases = true
Superwall.configure(this, "your_api_key", options = options)
// In your purchase methods, replace Google's billing code with Superwall's proxy
val productDetailsParams =
SuperwallBillingFlowParams.ProductDetailsParams
.newBuilder()
.setOfferToken("test_offer_token")
.setProductDetails(mockProductDetails)
.build()
val params =
SuperwallBillingFlowParams
.newBuilder()
.setIsOfferPersonalized(true)
.setObfuscatedAccountId(...)
.setObfuscatedProfileId(...)
.setProductDetailsParamsList(listOf(productDetailsParams))
.build()
billingClient.launchBillingFlowWithSuperwall()
```
```kotlin Android (Manual)
val options = SuperwallOptions()
options.shouldObservePurchases = true
Superwall.configure(this, "your_api_key", options = options)
// In your purchase methods, begin by tracking the purchase start
Superwall.instance.observePurchaseStart(productDetails)
// On succesful purchase
Superwall.instance.observePurchaseResult(billingResult, purchases)
// On purchase error
Superwall.instance.observePurchaseError(productDetails, error)
```
:::
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.
# Request permissions from paywalls
Overview [#overview]
Use the **Request permission** action in the paywall editor when you want to gate features behind Android permissions without bouncing users back to native screens. When the user taps the element, the SDK:
* Presents the corresponding Android system dialog.
* Emits analytics events (`permission_requested`, `permission_granted`, `permission_denied`).
* Sends the result back to the paywall so you can branch the UI (for example, swap a checklist item for a success state).
Add the action in the editor [#add-the-action-in-the-editor]
1. Open your paywall, select the button (or any element) that should prompt the permission, and set its action to **Request permission**.
2. Choose the permission you want to request. You can wire multiple buttons if you need to prime several permissions in a single flow.
3. Republish the paywall. No extra SDK configuration is required beyond having the proper `AndroidManifest.xml` entries.
Declare the permissions in `AndroidManifest.xml` [#declare-the-permissions-in-androidmanifestxml]
| Editor option | `permission_type` sent from the paywall | Required manifest entries | Notes |
| --------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| Notifications | `notification` | `` (API 33+) | Devices below Android 13 do not require a runtime permission; the SDK reports `granted` immediately. |
| Location (Foreground) | `location` | `` | Also covers coarse location because FINE implies COARSE. |
| Location (Background) | `background_location` | Foreground entry above **and** `` (API 29+) | The SDK first ensures foreground access, then escalates to background. |
| Photos / Images | `read_images` | `` (API 33+) or `READ_EXTERNAL_STORAGE` for older OS versions | Automatically picks the right permission at runtime. |
| Videos | `read_video` | `` (API 33+) or `READ_EXTERNAL_STORAGE` pre-33 | |
| Contacts | `contacts` | `` | |
| Camera | `camera` | `` | |
| Microphone | `microphone` | `` | Added in 2.6.8. |
If a manifest entry is missing—or the permission is unsupported on the current OS level—the SDK responds with an `unsupported` status so you can show fallback copy.
Analytics and delegate callbacks [#analytics-and-delegate-callbacks]
Forward the new events through `SuperwallDelegate.handleSuperwallEvent` to keep your analytics platform and feature flags in sync:
```kotlin
override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
when (val event = eventInfo.event) {
is SuperwallEvent.PermissionRequested -> {
analytics.track("permission_requested", mapOf(
"permission" to event.permissionName,
"paywall_id" to event.paywallIdentifier
))
}
is SuperwallEvent.PermissionGranted -> {
FeatureFlags.unlock(event.permissionName)
}
is SuperwallEvent.PermissionDenied -> {
Alerts.showPermissionDeclinedSheet(event.permissionName)
}
else -> Unit
}
}
```
You can also log the newer [`customerInfoDidChange`](/docs/android/sdk-reference/SuperwallDelegate#customerinfodidchangefrom-customerinfo-to-customerinfo) callback if the permission subsequently unlocks new paywalls that grant entitlements.
Status values returned to the paywall [#status-values-returned-to-the-paywall]
The paywall receives a `permission_result` web event with:
* `granted` – The system dialog reported success (or no dialog was needed).
* `denied` – The user denied the request or previously denied it.
* `unsupported` – The platform or manifest doesn't allow the requested permission.
Use Liquid or custom Javascript inside the paywall to branch on these statuses—for example, replace a “Grant notification access” button with a checkmark when the result equals `granted`.
Troubleshooting [#troubleshooting]
* Seeing `unsupported`? Double-check the manifest entries above and confirm the permission exists on the device's API level (for example, notification permissions only apply on Android 13+).
* Nothing happens when you tap the button? Ensure the action is set to **Request permission** in the released paywall version.
* Want to provide next steps after a denial? Listen for `PermissionDenied` in your delegate to deep-link users into Settings or show educational copy.
# Device Tier Targeting
The `deviceTier` variable allows you to create targeted audiences based on device performance capabilities. This helps optimize paywall experiences by showing resource-appropriate content to different device types. You can reference this in campaign filters, dynamic values, or in paywall text via the `device.deviceTier` variable.
Device tier targeting is available starting in Android SDK version `2.2.3`. Make sure you're using this version or later to access this feature.
How device tier works [#how-device-tier-works]
Device tier classification is based on several hardware factors:
* CPU performance
* Available RAM
* 4K/2K codec support
* Display quality
This automatic classification helps you deliver paywalls that perform well across the full spectrum of Android devices.
Matching device ranges [#matching-device-ranges]
When creating device tier filters, you can use `contains` or `equals` for narrower matching:
* **`contains`** - Broader matching that includes partial matches. For example, `deviceTier contains high` matches both `high` and `ultra_high` devices.
* **`equals`** - Exact matching for precise targeting. For example, `deviceTier equals high` matches only `high` tier devices, not `ultra_high`.
Use `contains` when you want to target a range of similar device capabilities, and `equals` when you need precise control over which specific tier to target.
Device tier values [#device-tier-values]
The `device.deviceTier` attribute returns one of these values:
* **`ultraLow`** - Entry-level devices with limited resources.
* **`low`** - Budget devices with basic performance capabilities.
* **`mid`** - Mid-range devices with moderate performance.
* **`high`** - Premium devices with strong performance.
* **`ultra_high`** - Flagship devices with top-tier specifications.
* **`unknown`** - Device tier couldn't be determined.
Creating device tier audiences [#creating-device-tier-audiences]
To target users by device tier, create an audience using the `device.deviceTier` attribute:
1. Navigate to **Campaigns** in your dashboard.
2. Click on the campaign you want to target.
3. Edit, or create, a new audience.
4. Add a filter where `device.deviceTier` contains your target tier(s).
5. Save your audience.
You can target multiple tiers in a single audience. For example, use
`deviceTier contains LOW`
to target both
`ultraLow`
and
`low`
tier devices.
Optimizing for lower-end devices [#optimizing-for-lower-end-devices]
Create lightweight paywalls for devices that may struggle with resource-intensive content:
```
device.deviceTier contains ultraLow OR device.deviceTier contains low
```
Show these users paywalls with:
* Static images instead of videos.
* Compressed media files.
* Simplified animations.
# 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()
}
}
}
}
}
```
# 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:
:::android
```kotlin
val options = SuperwallOptions()
options.logging.level = LogLevel.warn
options.logging.scopes = EnumSet.of(LogScope.paywallPresentation, LogScope.paywallTransactions)
Superwall.configure(
this,
apiKey = "MY_API_KEY",
options = options
)
// Or you can set:
Superwall.instance.logLevel = LogLevel.warn
// Or use the configuration DSL
configureSuperwall("MY_API_KEY") {
options {
logging {
level = LogLevel.warn
scopes = EnumSet.of(LogScope.paywallPresentation, LogScope.paywallTransactions)
}
}
}
```
:::
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`:
:::android
```kotlin
val options = SuperwallOptions()
options.paywalls.shouldPreload = false
Superwall.configure(
this,
apiKey = "MY_API_KEY",
options = options
)
// Or using the configuration DSL
configureSuperwall("MY_API_KEY") {
options {
paywalls {
shouldPreload = false
}
}
}
```
:::
Then, if you'd like to preload paywalls for specific placements you can use `preloadPaywalls(forPlacements:)`:
:::android
```kotlin
val eventNames = setOf("campaign_trigger")
Superwall.instance.preloadPaywalls(eventNames)
```
:::
If you'd like to preload all paywalls you can use `preloadAllPaywalls()`:
:::android
```kotlin
Superwall.instance.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`:
:::android
```kotlin
val options = SuperwallOptions()
options.isExternalDataCollectionEnabled = false
Superwall.configure(
this,
apiKey = "MY_API_KEY",
options = options
)
// Or using the configuration DSL
configureSuperwall("MY_API_KEY") {
options {
isExternalDataCollectionEnabled = false
}
}
```
:::
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`:
:::android
```kotlin
val options = SuperwallOptions()
options.paywalls.automaticallyDismiss = false
Superwall.configure(
this,
apiKey = "MY_API_KEY",
options = options
)
// Or using the configuration DSL
configureSuperwall("MY_API_KEY") {
options {
paywalls {
automaticallyDismiss = false
}
}
}
```
:::
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:
:::android
```kotlin
val options = SuperwallOptions()
options.paywalls.restoreFailed.title = "My Title"
options.paywalls.restoreFailed.message = "My message"
options.paywalls.restoreFailed.closeButtonTitle = "Close"
Superwall.configure(
this,
apiKey = "MY_API_KEY",
options = options
)
// Or using the configuration DSL
configureSuperwall("MY_API_KEY") {
options {
paywalls {
restoreFailed.title = "My Title"
restoreFailed.message = "My message"
restoreFailed.closeButtonTitle = "Close"
}
}
}
```
:::
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:
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`:
:::android
```kotlin
val options = SuperwallOptions()
options.paywalls.transactionBackgroundView = null
Superwall.configure(
this,
apiKey = "MY_API_KEY",
options = options
)
// Or using the configuration DSL
configureSuperwall("MY_API_KEY") {
options {
paywalls {
transactionBackgroundView = null
}
}
}
```
:::
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`:
:::android
```kotlin
val options = SuperwallOptions()
options.paywalls.shouldShowPurchaseFailureAlert = false
Superwall.configure(
this,
apiKey = "MY_API_KEY",
options = options
)
// Or using the configuration DSL
configureSuperwall("MY_API_KEY") {
options {
paywalls {
shouldShowPurchaseFailureAlert = false
}
}
}
```
:::
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:
:::android
```kotlin
val options = SuperwallOptions()
options.paywalls.shouldShowWebPurchaseConfirmationAlert = true
Superwall.configure(
this,
apiKey = "MY_API_KEY",
options = options
)
// Or using the configuration DSL
configureSuperwall("MY_API_KEY") {
options {
paywalls {
shouldShowWebPurchaseConfirmationAlert = true
}
}
}
```
:::
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:
:::android
```kotlin
val options = SuperwallOptions()
options.localeIdentifier = "en_GB"
Superwall.configure(
this,
apiKey = "MY_API_KEY",
options = options
)
// Or using the configuration DSL
configureSuperwall("MY_API_KEY") {
options {
localeIdentifier = "en_GB"
}
}
```
:::
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 then consume the Google Play purchase token so the item can be purchased again.
Dashboard Setup [#dashboard-setup]
1. Create the product as an in-app product in Google Play Console.
2. Add the product in Superwall from **Products**.
3. Use the Google Play product ID.
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.
Consume The Purchase Token [#consume-the-purchase-token]
Google Play in-app products must be consumed after you grant the benefit. If you do not consume the purchase token, the user may not be able to buy that same consumable again.
Use the `purchaseToken` from the `TransactionComplete` event, grant the benefit, then call `Superwall.instance.consume(purchaseToken)`.
`Superwall.instance.consume(purchaseToken)` is available in Android SDK 2.6.2 and later.
```kotlin Kotlin
import androidx.lifecycle.lifecycleScope
import com.superwall.sdk.Superwall
import com.superwall.sdk.analytics.superwall.SuperwallEvent
import com.superwall.sdk.analytics.superwall.SuperwallEventInfo
import com.superwall.sdk.delegate.SuperwallDelegate
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity(), SuperwallDelegate {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Superwall.instance.delegate = this
}
override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
val event = eventInfo.event
if (event !is SuperwallEvent.TransactionComplete) {
return
}
if (event.product.productIdentifier != "coins_100") {
return
}
val purchaseToken = event.transaction?.purchaseToken ?: return
lifecycleScope.launch {
ConsumablesService.grantCoins(
count = 100,
productId = event.product.productIdentifier,
purchaseToken = purchaseToken,
)
Superwall.instance.consume(purchaseToken)
.onFailure { error ->
// Retry consumption after confirming the benefit was granted.
println("Failed to consume purchase: ${error.message}")
}
}
}
}
```
```java Java
import com.superwall.sdk.Superwall;
import com.superwall.sdk.analytics.superwall.SuperwallEvent;
import com.superwall.sdk.analytics.superwall.SuperwallEventInfo;
import com.superwall.sdk.delegate.SuperwallDelegateJava;
import com.superwall.sdk.store.abstractions.transactions.StoreTransactionType;
import kotlin.Unit;
public class MainActivity extends AppCompatActivity implements SuperwallDelegateJava {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Superwall.getInstance().setJavaDelegate(this);
}
@Override
public void handleSuperwallEvent(SuperwallEventInfo eventInfo) {
if (!(eventInfo.getEvent() instanceof SuperwallEvent.TransactionComplete)) {
return;
}
SuperwallEvent.TransactionComplete event =
(SuperwallEvent.TransactionComplete) eventInfo.getEvent();
if (!event.getProduct().getProductIdentifier().equals("coins_100")) {
return;
}
StoreTransactionType transaction = event.getTransaction();
if (transaction == null) {
return;
}
String purchaseToken = transaction.getPurchaseToken();
ConsumablesService.grantCoins(100, event.getProduct().getProductIdentifier(), purchaseToken);
Superwall.getInstance().consume(purchaseToken, result -> {
// Check the Result in your app and retry if consumption fails.
return Unit.INSTANCE;
});
}
}
```
Grant the benefit before consuming the token. If your grant call fails, leave the purchase unconsumed and retry after your app confirms the user received the benefit.
Read Purchase History [#read-purchase-history]
Consumable and non-consumable purchases appear in `customerInfo.nonSubscriptions`. Use `isConsumable` to distinguish consumables from lifetime purchases.
```kotlin Kotlin
val customerInfo = Superwall.instance.getCustomerInfo()
val consumables = customerInfo.nonSubscriptions.filter { it.isConsumable }
for (purchase in consumables) {
println("Consumable purchased: ${purchase.productId}")
}
```
```java Java
CustomerInfo customerInfo = Superwall.getInstance().getCustomerInfo();
for (NonSubscriptionTransaction purchase : customerInfo.getNonSubscriptions()) {
if (purchase.isConsumable()) {
System.out.println("Consumable purchased: " + purchase.getProductId());
}
}
```
# Experimental Flags
Support for Experimental Flags in Android is not yet available.
# 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:
:::android
```kotlin
fun handleUrl(url: Uri) {
val placement = when (url.path) {
"/promo" -> "promoPlacement"
"/onboarding" -> "onboardingPlacement"
"/upgrade" -> "upgradePlacement"
"/special-offer" -> "specialOfferPlacement"
else -> null
}
placement?.let {
Superwall.instance.register(placement = it)
}
}
```
:::
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.
:::android
```kotlin
fun handleUrl(url: Uri) {
Superwall.instance.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:
:::android
* [Deep link setup](/docs/sdk/quickstart/in-app-paywall-previews)
:::
Related deep link guides [#related-deep-link-guides]
:::android
* [Deep Link Setup](/docs/sdk/quickstart/in-app-paywall-previews) — Configure URL schemes and wire `handleDeepLink` into your app so Superwall can respond to incoming links.
:::
# 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.
:::android
Local resources require **Android SDK v2.7.7+**.
:::
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.
:::android
On Android, local resources are configured on `Superwall.instance.localResources` after calling [`configure()`](/docs/sdk/sdk-reference/configure), and before presenting paywalls that depend on those assets.
```kotlin Kotlin
import android.app.Application
import android.net.Uri
import com.superwall.sdk.Superwall
import com.superwall.sdk.paywall.view.webview.PaywallResource
import java.io.File
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
Superwall.configure(
application = this,
apiKey = "pk_your_api_key",
)
Superwall.instance.localResources = mapOf(
"hero-image" to PaywallResource.FromResources(R.drawable.hero),
"onboarding-video" to PaywallResource.FromUri(
Uri.fromFile(File(filesDir, "welcome.mp4"))
),
"background-animation" to PaywallResource.FromResources(R.raw.paywall_bg)
)
}
}
```
Set `localResources` before presenting paywalls that depend on those assets. Updating the map later
only affects paywalls loaded after the change.
:::
Supported source types [#supported-source-types]
:::android
Android supports two local resource source types:
| Type | Use for |
| -------------------------------------- | --------------------------------------------------------------------------------- |
| `PaywallResource.FromResources(resId)` | Assets packaged in `res/drawable`, `res/raw`, and other Android resource folders |
| `PaywallResource.FromUri(uri)` | Files addressed by a `Uri`, such as files in app storage or content provider URLs |
:::
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 v1 to v2 - Android
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 most cases, the updates are simple and consist of renaming `events` to `placements` where necessary, for others, you'll need to run through this list to manually update your code.
| Before | After |
| ------------------------------------ | ---------------------------------------- |
| fun register(event:) | fun register(placement:) |
| fun preloadPaywalls(forEvents:) | fun preloadPaywalls(forPlacements:) |
| fun getPaywall(forEvent:) | fun getPaywall(forPlacement:) |
| fun getPresentationResult(forEvent:) | fun getPresentationResult(forPlacement:) |
| TriggerResult.EventNotFound | TriggerResult.PlacementNotFound |
| TriggerResult.NoRuleMatch | TriggerResult.NoAudienceMatch |
1.2 If using Compose and Paywall Composable [#12-if-using-compose-and-paywall-composable]
The `PaywallComposable` has been removed from the main Superwall SDK and moved into an optional `superwall-compose` library.
To use it, besides including the main superwall library, you'll now need to also include a `superwall-compose` artifact
```groovy gradle
implementation "com.superwall.sdk:superwall-compose:2.6.5"
```
2\. Replacing getPaywall with PaywallBuilder [#2-replacing-getpaywall-with-paywallbuilder]
The default method for retrieving a paywall to display it yourself is changing to use the Builder pattern, allowing you more flexibility
in both retrieving and displaying paywalls. The builder allows for improved customisation of your paywall experience, allowing you to pass
in both custom Shimmer and Loading views by implementing proper interfaces.
Usage example:
```kotlin Android
val paywallView = PaywallBuilder("placement_name")
.params(mapOf("key" to "value"))
.overrides(PaywallOverrides())
.delegate(mySuperwallDelegate)
.shimmerView(MyShimmerView(context))
.purchaseLoadingView(MyPurchaseLoadingView(context))
.activity(activity)
.build()
```
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` with the `subscriptionStatus`:
| Before | After |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Superwall.shared.setSubscriptionStatus(SubscriptionStatus.ACTIVE) | Superwall.shared.setSubscriptionStatus(SubscriptionStatus.Active(entitlements)) |
You can get the `ProductDetails` 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:
```kotlin With Play Billing
suspend fun syncSubscriptionStatus() {
// We await for configuration to be set so our entitlements are available
Superwall.instance.configurationStateListener.first { it is ConfigurationStatus.Configured }
// Query purchases from your BillingClient
val subscriptionPurchases = queryPurchasesOfType(BillingClient.ProductType.SUBS)
val inAppPurchases = queryPurchasesOfType(BillingClient.ProductType.INAPP)
val allPurchases = subscriptionPurchases + inAppPurchases
val hasActivePurchaseOrSubscription =
allPurchases.any { it.purchaseState == Purchase.PurchaseState.PURCHASED }
val status: SubscriptionStatus =
if (hasActivePurchaseOrSubscription) {
subscriptionPurchases
.flatMap {
// Extract the productId
it.products
}.toSet() // Ensure uniqueness
.flatMap {
// Receive entitlements
val res = entitlementsInfo.byProductId(it)
res
}.toSet()
.let { entitlements ->
if (entitlements.isNotEmpty()) {
SubscriptionStatus.Active(entitlements)
} else {
SubscriptionStatus.Inactive
}
}
} else {
SubscriptionStatus.Inactive
}
Superwall.instance.setSubscriptionStatus(status)
}
```
```kotlin with RevenueCat
fun syncSubscriptionStatus() {
Purchases.sharedInstance.getCustomerInfoWith {
if (hasAnyActiveEntitlements(it)) {
setSubscriptionStatus(
SubscriptionStatus.Active(
it.entitlements.active
.map {
Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL)
}.toSet(),
),
)
} else {
setSubscriptionStatus(SubscriptionStatus.Inactive)
}
}
}
```
You can listen to the flowable property `Superwall.instance.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 v2 of the SDK, this is replaced with a check on the entitlements within the audience filter. As you migrate your users from v1 to v2 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-Android/blob/develop/CHANGELOG.md).
7\. Check out our updated example apps [#7-check-out-our-updated-example-apps]
All of our [example apps](https://github.com/superwall/Superwall-Android/tree/develop/example) 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 has multiple flavors, showing you how to use entitlements within your app as well as optionally using a purchase controller with Play Billing or RevenueCat.
# Test Mode
Test mode lets you simulate in-app purchases without involving Google Play Billing 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 Google Play sandbox account or test tracks.
How it works [#how-it-works]
When test mode is active:
* **Product data comes from the dashboard** instead of Google Play, so you don't need products set up in Google Play Console.
* **Purchases are simulated.** Instead of the Google Play purchase flow, a test mode drawer appears letting you choose to complete, abandon, or fail the transaction. 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 and in what state.
* **A configuration modal** appears on launch showing why test mode activated, 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.
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`:
```kotlin
val options = SuperwallOptions().apply {
testModeBehavior = TestModeBehavior.ALWAYS
}
Superwall.configure(this, "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 application ID doesn't match the one configured in the dashboard. Never activates when JUnit or Espresso is detected on the classpath. |
| `WHEN_ENABLED_FOR_USER` | Activates only when the current user is marked as a test store user in the dashboard. Ignores application 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:
* **Reason:** Why test mode activated (e.g., application ID mismatch, matched test store user, or always-on via options).
* **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's state before dismissing the modal. Available states include **Subscribed**, **In Grace Period**, **Billing Retry**, **Expired**, **Revoked**, and **Inactive**. For active states you can also set the offer type (**None**, **Trial**, or **Promotional**). This lets you test how your paywalls behave for users with different entitlement states.
Tap **Continue** to dismiss the modal and begin testing. Your selections persist across sessions.
Simulating purchases [#simulating-purchases]
When you tap a purchase button on a paywall while test mode is active, a drawer appears instead of the Google Play purchase flow.
The drawer shows the product details (identifier, price, period, free trial availability, and entitlements) along with these options:
* **Confirm Purchase** (or **Start Free Trial** if the product has a free trial): Simulates a successful purchase. The product's entitlements are activated and your subscription status updates accordingly.
* **Abandon:** Closes the drawer without completing the purchase.
* **Fail:** Simulates a purchase failure.
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. Google Play testing [#when-to-use-test-mode-vs-google-play-testing]
| | Test mode | Google Play testing |
| ---------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| **Setup** | No Google Play Console setup needed | Requires products in Google Play Console and license testers configured |
| **Products** | Pulled from the Superwall dashboard | Must exist in Google Play Console |
| **Transactions** | Simulated via UI drawer | Real Google Play Billing transactions in a test environment |
| **Best for** | End-to-end paywall flow testing, verifying entitlement gating, testing without Google Play Console setup | Testing real billing behavior, receipt validation, subscription lifecycle |
Test mode is ideal for quickly validating your paywall presentation, purchase flows, and entitlement gating without any Google Play setup. For testing actual Google Play Billing behavior, use Google Play's license testing and test tracks instead.
# Using RevenueCat
Not using RevenueCat? No problem! Superwall works out of the box without any additional SDKs.
You only need to use a
`PurchaseController`
if you want end-to-end control of the purchasing pipeline. The recommended way to use RevenueCat with Superwall is by putting it in observer mode.
You can integrate RevenueCat with Superwall using several approaches:
1. [**Using a purchase controller:**](#using-a-purchase-controller) Use this route if you want to maintain control over purchasing logic and code.
2. [**Using PurchasesAreCompletedBy:**](#using-purchasesarecompletedby) Here, you don't use a purchase controller and you tell RevenueCat that purchases are completed by your app using StoreKit. In this mode, RevenueCat will observe the purchases that the Superwall SDK makes. For more info [see here](https://www.revenuecat.com/docs/migrating-to-revenuecat/sdk-or-not/finishing-transactions).
Using a purchase controller [#using-a-purchase-controller]
1\. Create a `PurchaseController` [#1-create-a-purchasecontroller]
Create a new file called `RCPurchaseController`, then copy and paste the following:
:::android
```kotlin
package com.superwall.superapp
import android.app.Activity
import android.content.Context
import com.android.billingclient.api.ProductDetails
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.LogLevel
import com.revenuecat.purchases.ProductType
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration
import com.revenuecat.purchases.PurchasesError
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.getCustomerInfoWith
import com.revenuecat.purchases.interfaces.GetStoreProductsCallback
import com.revenuecat.purchases.interfaces.PurchaseCallback
import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback
import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener
import com.revenuecat.purchases.models.StoreProduct
import com.revenuecat.purchases.models.StoreTransaction
import com.revenuecat.purchases.models.SubscriptionOption
import com.revenuecat.purchases.models.googleProduct
import com.revenuecat.purchases.purchaseWith
import com.superwall.sdk.Superwall
import com.superwall.sdk.delegate.PurchaseResult
import com.superwall.sdk.delegate.RestorationResult
import com.superwall.sdk.delegate.subscription_controller.PurchaseController
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.entitlements.SubscriptionStatus
import kotlinx.coroutines.CompletableDeferred
suspend fun Purchases.awaitProducts(productIds: List): List {
val deferred = CompletableDeferred>()
getProducts(
productIds,
object : GetStoreProductsCallback {
override fun onReceived(storeProducts: List) {
deferred.complete(storeProducts)
}
override fun onError(error: PurchasesError) {
deferred.completeExceptionally(Exception(error.message))
}
},
)
return deferred.await()
}
interface PurchaseCompletion {
var storeTransaction: StoreTransaction
var customerInfo: CustomerInfo
}
// Create a custom exception class that wraps PurchasesError
private class PurchasesException(
val purchasesError: PurchasesError,
) : Exception(purchasesError.toString())
suspend fun Purchases.awaitPurchase(
activity: Activity,
storeProduct: StoreProduct,
): PurchaseCompletion {
val deferred = CompletableDeferred()
purchase(
PurchaseParams.Builder(activity, storeProduct).build(),
object : PurchaseCallback {
override fun onCompleted(
storeTransaction: StoreTransaction,
customerInfo: CustomerInfo,
) {
deferred.complete(
object : PurchaseCompletion {
override var storeTransaction: StoreTransaction = storeTransaction
override var customerInfo: CustomerInfo = customerInfo
},
)
}
override fun onError(
error: PurchasesError,
p1: Boolean,
) {
deferred.completeExceptionally(PurchasesException(error))
}
},
)
return deferred.await()
}
suspend fun Purchases.awaitRestoration(): CustomerInfo {
val deferred = CompletableDeferred()
restorePurchases(
object : ReceiveCustomerInfoCallback {
override fun onReceived(purchaserInfo: CustomerInfo) {
deferred.complete(purchaserInfo)
}
override fun onError(error: PurchasesError) {
deferred.completeExceptionally(error as Throwable)
}
},
)
return deferred.await()
}
class RevenueCatPurchaseController(
val context: Context,
) : PurchaseController,
UpdatedCustomerInfoListener {
init {
Purchases.logLevel = LogLevel.DEBUG
Purchases.configure(
PurchasesConfiguration
.Builder(
context,
"android_rc_key",
).build(),
)
// Make sure we get the updates
Purchases.sharedInstance.updatedCustomerInfoListener = this
}
fun syncSubscriptionStatus() {
// Refetch the customer info on load
Purchases.sharedInstance.getCustomerInfoWith {
if (hasAnyActiveEntitlements(it)) {
setSubscriptionStatus(
SubscriptionStatus.Active(
it.entitlements.active
.map {
Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL)
}.toSet(),
),
)
} else {
setSubscriptionStatus(SubscriptionStatus.Inactive)
}
}
}
/**
* Callback for rc customer updated info
*/
override fun onReceived(customerInfo: CustomerInfo) {
if (hasAnyActiveEntitlements(customerInfo)) {
setSubscriptionStatus(
SubscriptionStatus.Active(
customerInfo.entitlements.active
.map {
Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL)
}.toSet(),
),
)
} else {
setSubscriptionStatus(SubscriptionStatus.Inactive)
}
}
/**
* Initiate a purchase
*/
override suspend fun purchase(
activity: Activity,
productDetails: ProductDetails,
basePlanId: String?,
offerId: String?,
): PurchaseResult {
// Find products matching productId from RevenueCat
val products = Purchases.sharedInstance.awaitProducts(listOf(productDetails.productId))
// Choose the product which matches the given base plan.
// If no base plan set, select first product or fail.
val product =
products.firstOrNull { it.googleProduct?.basePlanId == basePlanId }
?: products.firstOrNull()
?: return PurchaseResult.Failed("Product not found")
return when (product.type) {
ProductType.SUBS, ProductType.UNKNOWN ->
handleSubscription(
activity,
product,
basePlanId,
offerId,
)
ProductType.INAPP -> handleInAppPurchase(activity, product)
}
}
private fun buildSubscriptionOptionId(
basePlanId: String?,
offerId: String?,
): String =
buildString {
basePlanId?.let { append("$it") }
offerId?.let { append(":$it") }
}
private suspend fun handleSubscription(
activity: Activity,
storeProduct: StoreProduct,
basePlanId: String?,
offerId: String?,
): PurchaseResult {
storeProduct.subscriptionOptions?.let { subscriptionOptions ->
// If subscription option exists, concatenate base + offer ID.
val subscriptionOptionId = buildSubscriptionOptionId(basePlanId, offerId)
// Find first subscription option that matches the subscription option ID or default
// to letting revenuecat choose.
val subscriptionOption =
subscriptionOptions.firstOrNull { it.id == subscriptionOptionId }
?: subscriptionOptions.defaultOffer
// Purchase subscription option, otherwise fail.
if (subscriptionOption != null) {
return purchaseSubscription(activity, subscriptionOption)
}
}
return PurchaseResult.Failed("Valid subscription option not found for product.")
}
private suspend fun purchaseSubscription(
activity: Activity,
subscriptionOption: SubscriptionOption,
): PurchaseResult {
val deferred = CompletableDeferred()
Purchases.sharedInstance.purchaseWith(
PurchaseParams.Builder(activity, subscriptionOption).build(),
onError = { error, userCancelled ->
deferred.complete(
if (userCancelled) {
PurchaseResult.Cancelled()
} else {
PurchaseResult.Failed(
error.message,
)
},
)
},
onSuccess = { _, _ ->
deferred.complete(PurchaseResult.Purchased())
},
)
return deferred.await()
}
private suspend fun handleInAppPurchase(
activity: Activity,
storeProduct: StoreProduct,
): PurchaseResult =
try {
Purchases.sharedInstance.awaitPurchase(activity, storeProduct)
PurchaseResult.Purchased()
} catch (e: PurchasesException) {
when (e.purchasesError.code) {
PurchasesErrorCode.PurchaseCancelledError -> PurchaseResult.Cancelled()
else ->
PurchaseResult.Failed(
e.message ?: "Purchase failed due to an unknown error",
)
}
}
/**
* Restore purchases
*/
override suspend fun restorePurchases(): RestorationResult {
try {
if (hasAnyActiveEntitlements(Purchases.sharedInstance.awaitRestoration())) {
return RestorationResult.Restored()
} else {
return RestorationResult.Failed(Exception("No active entitlements"))
}
} catch (e: Throwable) {
return RestorationResult.Failed(e)
}
}
/**
* Check if the customer has any active entitlements
*/
private fun hasAnyActiveEntitlements(customerInfo: CustomerInfo): Boolean {
val entitlements =
customerInfo.entitlements.active.values
.map { it.identifier }
return entitlements.isNotEmpty()
}
private fun setSubscriptionStatus(subscriptionStatus: SubscriptionStatus) {
if (Superwall.initialized) {
Superwall.instance.setSubscriptionStatus(subscriptionStatus)
}
}
}
```
:::
As discussed in [Purchases and Subscription Status](/docs/sdk/guides/advanced-configuration), this `CustomPurchaseControllerProvider` is responsible for handling the subscription-related logic using the modern hooks-based approach.
2\. Configure Superwall (Continued) [#2-configure-superwall-continued]
The example above shows the complete setup. The `CustomPurchaseControllerProvider` wraps your `SuperwallProvider` and handles all purchase and restore logic through RevenueCat.
For more advanced implementations, see the [example app](https://github.com/superwall/expo-superwall/tree/main/example).
**Legacy Approach**: If you're migrating from the old SDK or need the class-based purchase controller, you can use `expo-superwall/compat`. However, we recommend using the modern `CustomPurchaseControllerProvider` approach shown above.
Removed Legacy Code Section [#removed-legacy-code-section]
The following section contains the legacy class-based approach. Skip to the next section for the modern configuration.
As discussed in [Purchases and Subscription Status](/docs/sdk/guides/advanced-configuration), this `PurchaseController` is responsible for handling the subscription-related logic. Take a few moments to look through the code to understand how it does this.
2\. Configure Superwall [#2-configure-superwall]
Initialize an instance of `RCPurchaseController` and pass it in to `Superwall.configure(apiKey:purchaseController)`:
:::android
```kotlin
val purchaseController = RCPurchaseController(this)
Superwall.configure(
this,
"MY_API_KEY",
purchaseController
)
// Make sure we sync the subscription status
// on first load and whenever it changes
purchaseController.syncSubscriptionStatus()
```
:::
3\. Sync the subscription status [#3-sync-the-subscription-status]
Then, call `purchaseController.syncSubscriptionStatus()` to keep Superwall's subscription status up to date with RevenueCat.
That's it! Check out our sample app for working examples:
:::android
* [Android](https://github.com/superwall/Superwall-Android/tree/develop/example/app/src/revenuecat)
:::
Using PurchasesAreCompletedBy [#using-purchasesarecompletedby]
If you're using RevenueCat's [PurchasesAreCompletedBy](https://www.revenuecat.com/docs/migrating-to-revenuecat/sdk-or-not/finishing-transactions), you don't need to create a purchase controller. Register your placements, present a paywall — and Superwall will take care of completing any purchase the user starts. However, there are a few things to note if you use this setup:
1. Here, you aren't using RevenueCat's [entitlements](https://www.revenuecat.com/docs/getting-started/entitlements#entitlements) as a source of truth. If your app is multiplatform, you'll need to consider how to link up pro features or purchased products for users.
2. If you require custom logic when purchases occur, then you'll want to add a purchase controller. In that case, Superwall handles purchasing flows and RevenueCat will still observe transactions to power their analytics and charts.
3. Be sure that user identifiers are set the same way across Superwall and RevenueCat.
For more information on observer mode, visit [RevenueCat's docs](https://www.revenuecat.com/docs/migrating-to-revenuecat/sdk-or-not/finishing-transactions).
# Using the Superwall Delegate
Use Superwall's delegate to extend our SDK's functionality across several surface areas by assigning to the `delegate` property:
:::android
```kotlin
class SWDelegate : SuperwallDelegate {
// Implement delegate methods here
}
// When configuring the SDK...
Superwall.instance.delegate = SWDelegate()
```
:::
Some common use cases for using the Superwall delegate include:
* **Custom actions:** [Respond to custom tap actions from a paywall.](/docs/sdk/guides/advanced/custom-paywall-actions#custom-paywall-actions)
* **Respond to purchases:** [See which product was purchased from the presented paywall.](/docs/sdk/guides/advanced/viewing-purchased-products)
* **Analytics:** [Forward events from Superwall to your own analytics.](/docs/sdk/guides/3rd-party-analytics)
Below are some commonly used implementations when using the delegate.
Superwall Events [#superwall-events]
Most of what occurs in Superwall can be viewed using the delegate method to respond to events:
:::android
```kotlin
class SWDelegate : SuperwallDelegate {
override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
// Handle any relevant events here...
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
}
}
}
}
```
:::
Paywall Custom Actions [#paywall-custom-actions]
Using the [custom tap action](/docs/sdk/guides/advanced/custom-paywall-actions#custom-paywall-actions), you can respond to any arbitrary event from a paywall:
:::android
```kotlin
class SWDelegate : SuperwallDelegate {
override fun handleCustomPaywallAction(withName: String) {
if (withName == "caffeineLogged") {
println("Custom paywall action: $withName")
}
}
}
```
:::
Subscription status changes [#subscription-status-changes]
You can be informed of subscription status changes using the delegate. If you need to set or handle the status on your own, use a [purchase controller](/docs/sdk/guides/advanced-configuration) — this function is only for informational, tracking or similar purposes:
:::android
```kotlin
class SWDelegate : SuperwallDelegate {
override fun subscriptionStatusDidChange(from: SubscriptionStatus, to: SubscriptionStatus) {
println("Subscription status changed from $from to $to")
}
}
```
:::
Paywall events [#paywall-events]
The delegate also has callbacks for several paywall events, such dismissing, presenting, and more. Here's an example:
:::android
```kotlin
class SWDelegate : SuperwallDelegate {
override fun didPresentPaywall(withInfo: PaywallInfo) {
println("Paywall presented: $withInfo")
}
}
```
:::
# Vibe Coding
Overview [#overview]
We've built a few tools to help you Vibe Code using the knowledge of the Superwall Docs, access your Superwall account, and more right in your favorite AI tools:
* [Superwall Skill (Recommended)](#superwall-skill-recommended): Gives AI agents live docs, API access, and step-by-step SDK integration guides. If you're unsure which tool to use, pick this one.
* [Superwall MCP](#superwall-mcp): Expose your Superwall account (projects, paywalls, campaigns) to work with AI tools.
And right here in the Superwall Docs:
* [Superwall AI](#superwall-ai)
* [Docs Links](#docs-links)
* [LLMs.txt](#llmstxt)
Superwall Skill (Recommended) [#superwall-skill-recommended]
The [Superwall Skill](/docs/dashboard/guides/superwall-skill) is the best way to give AI coding agents full context on Superwall. It bundles live documentation, API access, dashboard links, and guided SDK integration flows for every platform, all in one install. If you're unsure which tool to use, pick this one.
```bash
npx skills add superwall/skills
```
Once installed, your agent can look up any Superwall doc on demand, call the API to inspect your projects and applications, and walk through a complete SDK integration step by step. It supports iOS, Android, Flutter, and Expo out of the box with platform-specific quickstart skills.
If you're only going to set up one tool, this is the one to use. See the full [Superwall Skill guide](/docs/dashboard/guides/superwall-skill) for details.
Superwall MCP [#superwall-mcp]
The Superwall MCP connects AI tools to your **Superwall account**, letting agents create and manage projects, paywalls, campaigns, products, entitlements, and webhooks directly. Instead of switching to the dashboard, your AI assistant can set everything up for you.
If you also want live docs access and guided SDK integration help, use the [Superwall Skill](/docs/dashboard/guides/superwall-skill). The MCP is focused on account and resource management.
See the full [Superwall MCP guide](/docs/dashboard/guides/superwall-mcp) for installation, a step-by-step quick setup, and the complete tool reference.
Superwall AI [#superwall-ai]
Superwall AI is available in the bottom right 💬 and is a great place to start if you have a question or issue.
Docs Links [#docs-links]
At the top of each page of the Superwall Docs (including this one!):
* **Copy Markdown**: to copy the page in Markdown format.
Also in the **Open** dropdown menu, you can access these options:
* **View as Markdown**: to view the page in Markdown format
* **Open in ChatGPT**, **Open in Claude**: to open the page in the respective AI tool and add the page as context for your conversation
LLMs.txt [#llmstxt]
The Superwall Docs website has `llms.txt` and `llms-full.txt` files, in total and for each SDK, that you can use to add context to your LLMs.
`llms.txt` is a summary of the docs with links to each page.
`llms-full.txt` is the full text of all of the docs.
| SDK | Summary | Full Text |
| ------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| All | [`llms.txt`](https://superwall.com/docs/llms.txt) | [`llms-full.txt`](https://superwall.com/docs/llms-full.txt) |
| Dashboard | [`llms-dashboard.txt`](https://superwall.com/docs/llms-dashboard.txt) | [`llms-full-dashboard.txt`](https://superwall.com/docs/llms-full-dashboard.txt) |
| iOS | [`llms-ios.txt`](https://superwall.com/docs/llms-ios.txt) | [`llms-full-ios.txt`](https://superwall.com/docs/llms-full-ios.txt) |
| Android | [`llms-android.txt`](https://superwall.com/docs/llms-android.txt) | [`llms-full-android.txt`](https://superwall.com/docs/llms-full-android.txt) |
| Flutter | [`llms-flutter.txt`](https://superwall.com/docs/llms-flutter.txt) | [`llms-full-flutter.txt`](https://superwall.com/docs/llms-full-flutter.txt) |
| Expo | [`llms-expo.txt`](https://superwall.com/docs/llms-expo.txt) | [`llms-full-expo.txt`](https://superwall.com/docs/llms-full-expo.txt) |
| React Native (Deprecated) | [`llms-react-native.txt`](https://superwall.com/docs/llms-react-native.txt) | [`llms-full-react-native.txt`](https://superwall.com/docs/llms-full-react-native.txt) |
| Integrations | [`llms-integrations.txt`](https://superwall.com/docs/llms-integrations.txt) | [`llms-full-integrations.txt`](https://superwall.com/docs/llms-full-integrations.txt) |
| Web Checkout | [`llms-web-checkout.txt`](https://superwall.com/docs/llms-web-checkout.txt) | [`llms-full-web-checkout.txt`](https://superwall.com/docs/llms-full-web-checkout.txt) |
To minimize token use, we recommend using the files specific to your SDK.
# Web Checkout
Dashboard Setup [#dashboard-setup]
1. [Set up Web Checkout in the dashboard](/docs/web-checkout)
2. [Add web products to your paywall](/docs/web-checkout/web-checkout-direct-stripe-checkout)
SDK Setup [#sdk-setup]
:::android
1. [Set up deep links](/docs/sdk/quickstart/in-app-paywall-previews)
2. [Handle Post-Checkout redirecting](/docs/sdk/guides/web-checkout/post-checkout-redirecting)
3. **Only if you're using RevenueCat:** [Using RevenueCat](/docs/sdk/guides/web-checkout/using-revenuecat)
4. **Only if you're using your own PurchaseController:** [Redeeming In-App](/docs/sdk/guides/web-checkout/linking-membership-to-iOS-app)
:::
Testing [#testing]
1. [Testing Purchases](/docs/web-checkout/web-checkout-testing-purchases)
2. [Managing Memberships](/docs/web-checkout/web-checkout-managing-memberships)
Troubleshooting [#troubleshooting]
If a user has issues accessing their purchase in your app after paying via web checkout, direct them to your plan management page to retrieve their redemption link or manage billing:
For example: `http://yourapp.superwall.app/manage`
FAQ [#faq]
[Web Checkout FAQ](/docs/web-checkout/web-checkout-faq)
# Redeeming In-App
After purchasing from a web paywall, the user will be redirected to your app by a deep link to redeem their purchase on device.
Please follow our [Post-Checkout Redirecting](/docs/android/guides/web-checkout/post-checkout-redirecting) guide to handle this user experience.
If you're using Superwall to handle purchases, then you don't need to do anything here.
If you're using your own `PurchaseController`, you will need to update the subscription status with the redeemed web entitlements. If you're using RevenueCat, you should follow our [Using RevenueCat](/docs/android/guides/web-checkout/using-revenuecat) guide.
Using a PurchaseController [#using-a-purchasecontroller]
If you're using Google Play Billing in your PurchaseController, you'll need to merge the web entitlements with the device entitlements before setting the subscription status.
Here's an example of how you might do this:
```kotlin
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.QueryPurchasesParams
import com.superwall.sdk.Superwall
import com.superwall.sdk.models.entitlements.SubscriptionStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
suspend fun syncSubscriptionStatus(billingClient: BillingClient) {
withContext(Dispatchers.IO) {
val productIds = mutableSetOf()
// Get the device entitlements from Google Play Billing
val params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
val purchasesResult = billingClient.queryPurchasesAsync(params)
// Collect purchased product IDs
purchasesResult.purchasesList.forEach { purchase ->
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
purchase.products.forEach { productId ->
productIds.add(productId)
}
}
}
// Get products from Superwall and extract their entitlements
val storeProducts = Superwall.instance.getProducts(productIds)
val deviceEntitlements = storeProducts.flatMap { it.entitlements }.toSet()
// Get the web entitlements from Superwall
val webEntitlements = Superwall.instance.entitlements.web
// Merge the two sets of entitlements
val allEntitlements = deviceEntitlements + webEntitlements
// Update subscription status on the main thread
withContext(Dispatchers.Main) {
if (allEntitlements.isNotEmpty()) {
Superwall.instance.setSubscriptionStatus(
SubscriptionStatus.Active(allEntitlements)
)
} else {
Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Inactive)
}
}
}
}
```
In addition to syncing the subscription status when purchasing and restoring, you'll need to sync it whenever `didRedeemLink(result:)` is called:
```kotlin
import com.superwall.sdk.delegate.SuperwallDelegate
import com.superwall.sdk.models.redemption.RedemptionResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class SWDelegate(private val billingClient: BillingClient) : SuperwallDelegate {
private val coroutineScope = CoroutineScope(Dispatchers.Main)
override fun didRedeemLink(result: RedemptionResult) {
coroutineScope.launch {
syncSubscriptionStatus(billingClient)
}
}
}
```
Refreshing of web entitlements [#refreshing-of-web-entitlements]
If you aren't using a Purchase Controller, the SDK will refresh the web entitlements every 24 hours.
Redeeming while a paywall is open [#redeeming-while-a-paywall-is-open]
If a redeem event occurs when a paywall is open, the SDK will track that as a restore event and the paywall will close.
# Post-Checkout Redirecting
After a user completes a web purchase, Superwall needs to redirect them back to your app. You can configure this behavior in two ways:
Post-Purchase Behavior Modes [#post-purchase-behavior-modes]
You can configure how users are redirected after checkout in your [Application Settings](/docs/web-checkout/web-checkout-configuring-stripe-keys-and-settings#post-purchase-behavior):
Redeem Mode (Default) [#redeem-mode-default]
Superwall manages the entire redemption experience:
* Users are automatically deep linked to your app with a redemption code
* Fallback to App Store/Play Store if the app isn't installed
* Redemption emails are sent automatically
* The SDK handles redemption via delegate methods (detailed below)
This is the recommended mode for most apps.
Redirect Mode [#redirect-mode]
Redirect users to your own custom URL with purchase information:
* **When to use**: You want to show a custom success page, perform additional actions before redemption, or have your own deep linking infrastructure
* **What you receive**: Purchase data is passed as query parameters to your URL
**Query Parameters Included**:
* `app_user_id` - The user's identifier from your app
* `email` - User's email address
* `stripe_subscription_id` - The Stripe subscription ID, or the Stripe Checkout session ID for one-time purchases
* Any custom placement parameters you set
**Example**:
```
https://yourapp.com/success?
app_user_id=user_123&
email=user@example.com&
stripe_subscription_id=sub_1234567890&
campaign_id=summer_sale
```
You'll need to implement your own logic to handle the redirect and deep link users into your app.
***
Setting Up Deep Links [#setting-up-deep-links]
Whether checkout starts from a web link or from a paywall that opens an external browser, the Superwall SDK relies on deep links to redirect back to your app.
Prerequisites [#prerequisites]
1. [Configuring Stripe Keys and Settings](/docs/web-checkout/web-checkout-configuring-stripe-keys-and-settings)
2. [Deep Links](/docs/android/quickstart/in-app-paywall-previews)
If you're not using Superwall to handle purchases, then you'll need to follow extra steps to redeem the web purchase in your app.
* [Using RevenueCat](/docs/android/guides/web-checkout/using-revenuecat)
* [Using a PurchaseController](/docs/android/guides/web-checkout/linking-membership-to-iOS-app#using-a-purchasecontroller)
***
Handling Redemption (Redeem Mode) [#handling-redemption-redeem-mode]
When using Redeem mode (the default), handle the user experience when they're redirected back to your app using `SuperwallDelegate` methods:
willRedeemLink [#willredeemlink]
When your app opens via the deep link, we will call the delegate method `willRedeemLink()` before making a network call to redeem the code.
At this point, you might wish to display a loading indicator in your app so the user knows that the purchase is being redeemed.
```kotlin
class SWDelegate : SuperwallDelegate {
override fun willRedeemLink() {
// Show a loading indicator to the user
showToast("Activating your purchase...")
}
}
```
You can manually dismiss the paywall at this point if needed, but note that the paywall will be dismissed automatically when the `didRedeemLink` method is called.
didRedeemLink [#didredeemlink]
After receiving a response from the network, we will call `didRedeemLink(result:)` with the result of redeeming the code. This result can be one of the following:
* `RedemptionResult.Success`: The redemption succeeded and contains information about the redeemed code.
* `RedemptionResult.Error`: An error occurred while redeeming. You can check the error message via the error parameter.
* `RedemptionResult.ExpiredCode`: The code expired and contains information about whether a redemption email has been resent and an optional obfuscated email address.
* `RedemptionResult.InvalidCode`: The code that was redeemed was invalid.
* `RedemptionResult.ExpiredSubscription`: The subscription that the code redeemed has expired.
On network failure, the SDK will retry up to 6 times before returning an `Error` `RedemptionResult` in `didRedeemLink(result:)`.
Here, you should remove any loading UI you added in `willRedeemLink` and show a message to the user based on the result. If a paywall is presented, it will be dismissed automatically.
```kotlin
class SWDelegate : SuperwallDelegate {
override fun didRedeemLink(result: RedemptionResult) {
when (result) {
is RedemptionResult.ExpiredCode -> {
showToast("Expired Link")
Log.d("Superwall", "Code expired: ${result.code}, ${result.expiredInfo}")
}
is RedemptionResult.Error -> {
showToast(result.error.message)
Log.d("Superwall", "Error: ${result.code}, ${result.error}")
}
is RedemptionResult.ExpiredSubscription -> {
showToast("Expired Subscription")
Log.d("Superwall", "Expired subscription: ${result.code}, ${result.redemptionInfo}")
}
is RedemptionResult.InvalidCode -> {
showToast("Invalid Link")
Log.d("Superwall", "Invalid code: ${result.code}")
}
is RedemptionResult.Success -> {
val email = result.redemptionInfo.purchaserInfo.email
val productIdentifier = result.redemptionInfo.paywallInfo?.productIdentifier
if (email != null) {
Superwall.instance.setUserAttributes(mapOf("email" to email))
showToast("Welcome, $email!")
} else {
showToast("Welcome!")
}
// Access the product identifier if available (2.6.3+)
productIdentifier?.let {
Log.d("Superwall", "Redeemed product: $it")
}
}
}
}
}
```
# Using RevenueCat
After purchasing from a web paywall, the user will be redirected to your app by a deep link to redeem their purchase on device. Please follow our [Post-Checkout Redirecting](/docs/android/guides/web-checkout/post-checkout-redirecting) guide to handle this user experience.
If you're using Superwall to handle purchases, then you don't need to do anything here.
You only need to use a
`PurchaseController`
if you want end-to-end control of the purchasing pipeline. The recommended way to use RevenueCat with Superwall is by putting it in observer mode.
If you're using your own `PurchaseController`, you should follow our [Redeeming In-App](/docs/android/guides/web-checkout/linking-membership-to-iOS-app) guide.
Using a PurchaseController with RevenueCat [#using-a-purchasecontroller-with-revenuecat]
If you're using RevenueCat, you'll need to follow [steps 1 to 4 in their guide](https://www.revenuecat.com/docs/web/integrations/stripe) to set up Stripe with RevenueCat. Then, you'll need to
associate the RevenueCat customer with the Stripe subscription IDs returned from redeeming the code. You can do this by extracting the ids from the `RedemptionResult` and sending them to RevenueCat's API
by using the `didRedeemLink(result:)` delegate method:
This flow is for Stripe subscriptions. Stripe one-time purchases can return Stripe Checkout session IDs through the same legacy `stripeSubscriptionIds` field, but those IDs are not Stripe subscription IDs. Handle one-time purchases with Superwall entitlements or your own backend instead of sending those IDs to RevenueCat's Stripe subscription endpoint.
```kotlin
import com.revenuecat.purchases.Purchases
import com.superwall.sdk.Superwall
import com.superwall.sdk.delegate.SuperwallDelegate
import com.superwall.sdk.models.redemption.RedemptionResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
class SWDelegate : SuperwallDelegate {
private val client = OkHttpClient()
private val coroutineScope = CoroutineScope(Dispatchers.IO)
// The user tapped on a deep link to redeem a code
override fun willRedeemLink() {
Log.d("Superwall", "[!] willRedeemLink")
// Optionally show a loading indicator here
}
// Superwall received a redemption result and validated the purchase with Stripe.
override fun didRedeemLink(result: RedemptionResult) {
Log.d("Superwall", "[!] didRedeemLink: $result")
// Send Stripe IDs to RevenueCat to link purchases to the customer
// Get a list of subscription ids tied to the customer.
val stripeSubscriptionIds = when (result) {
is RedemptionResult.Success -> result.stripeSubscriptionIds
else -> null
} ?: return
val revenueCatStripePublicAPIKey = "strp....." // replace with your RevenueCat Stripe Public API Key
val appUserId = Purchases.sharedInstance.appUserID
// In the background using coroutines...
coroutineScope.launch {
// For each subscription id, link it to the user in RevenueCat
stripeSubscriptionIds.forEach { stripeSubscriptionId ->
try {
val json = JSONObject().apply {
put("app_user_id", appUserId)
put("fetch_token", stripeSubscriptionId)
}
val requestBody = json.toString()
.toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("https://api.revenuecat.com/v1/receipts")
.post(requestBody)
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "application/json")
.addHeader("X-Platform", "stripe")
.addHeader("Authorization", "Bearer $revenueCatStripePublicAPIKey")
.build()
client.newCall(request).execute().use { response ->
val responseBody = response.body?.string().orEmpty()
if (!response.isSuccessful) {
throw IOException("RevenueCat responded with ${response.code}: $responseBody")
}
Log.d("Superwall", "[!] Success: linked $stripeSubscriptionId to user $appUserId: $responseBody")
}
} catch (e: Exception) {
Log.e("Superwall", "[!] Error: unable to link $stripeSubscriptionId to user $appUserId", e)
}
}
/// After all network calls complete, invalidate the cache
Purchases.sharedInstance.getCustomerInfo(
onSuccess = { customerInfo ->
/// If you're using RevenueCat's `UpdatedCustomerInfoListener`, or keeping Superwall Entitlements in sync
/// via RevenueCat's listener methods, you don't need to do anything here. Those methods will be
/// called automatically when this call fetches the most up to date customer info, ignoring any local caches.
/// Otherwise, if you're manually calling `Purchases.sharedInstance.getCustomerInfo` to keep Superwall's entitlements
/// in sync, you should use the newly updated customer info here to do so.
/// You could always access web entitlements here as well
/// val webEntitlements = Superwall.instance.entitlements.web
},
onError = { error ->
Log.e("Superwall", "Error getting customer info", error)
}
)
// After all network calls complete, update UI on the main thread
withContext(Dispatchers.Main) {
// Perform UI updates on the main thread, like letting the user know their subscription was redeemed
}
}
}
}
```
The example surfaces non-200 responses and network exceptions so you can add retries, user messaging,
or monitoring. Customize the error handling to fit your production logging and UX.
If you call `logIn` from RevenueCat's SDK, then you need to call the logic you've implemented
inside `didRedeemLink(result:)` again. For example, that means if `logIn` was invoked from
RevenueCat, you'd either abstract out this logic above into a function to call again, or simply
call this function directly.
The web entitlements will be returned along with other existing entitlements in the `CustomerInfo` object accessible via RevenueCat's SDK.
If you're logging in and out of RevenueCat, make sure to resend the Stripe subscription IDs to RevenueCat's endpoint after logging in.
# Welcome
Quick Links [#quick-links]
Get up and running with the Superwall Android SDK
Most common features and use cases
Reference the Superwall Android SDK
Guides for specific use cases
Example app for the Superwall Android SDK
Guides for troubleshooting common issues
Feedback [#feedback]
We are always improving our SDKs and documentation!
If you have feedback on any of our docs, please leave a rating and message at the bottom of the page.
If you have any issues with the SDK, please [open an issue on GitHub](https://github.com/superwall/superwall-android/issues).
# Configure the SDK
As soon as your app launches, you need to configure the SDK with your **Public API Key**. You'll retrieve this from the Superwall settings page.
Sign Up & Grab Keys [#sign-up--grab-keys]
If you haven't already, [sign up for a free account](https://superwall.com/sign-up) on Superwall. Then, when you're through to the Dashboard, click **Settings** from the panel on the left, click **Keys** and copy your **Public API Key**:
Initialize Superwall in your app [#initialize-superwall-in-your-app]
Begin by editing your main Application entrypoint. Depending on the
platform this could be `AppDelegate.swift` or `SceneDelegate.swift` for iOS,
`MainApplication.kt` for Android, `main.dart` in Flutter, or `App.tsx` for React Native:
:::android
```kotlin Kotlin
// MainApplication.kt
class MainApplication : android.app.Application(), SuperwallDelegate {
override fun onCreate() {
super.onCreate()
// Setup
Superwall.configure(this, "MY_API_KEY")
// OR using the DSL
configureSuperwall("MY_API_KEY") {
purchaseController = MyPurchaseController(this@MainApplication)
}
}
}
```
:::
This configures a shared instance of `Superwall`, the primary class for interacting with the SDK's API. Make sure to replace `MY_API_KEY` with your public API key that you just retrieved.
By default, Superwall handles basic subscription-related logic for you. However, if you’d like
greater control over this process (e.g. if you’re using RevenueCat), you’ll want to pass in a
`PurchaseController` to your configuration call and manually set the `subscriptionStatus`. You can
also pass in `SuperwallOptions` to customize the appearance and behavior of the SDK. See
[Purchases and Subscription Status](/docs/sdk/guides/advanced-configuration) for more.
You've now configured Superwall!
:::android
For further help, check out our [Android example apps](https://github.com/superwall/Superwall-Android/tree/master/Examples) for working examples of implementing the Superwall SDK.
:::
# Presenting Paywalls
This allows you to register a [placement](/docs/dashboard/dashboard-campaigns/campaigns-placements) 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.
Here's an example.
With Superwall [#with-superwall]
:::android
```kotlin Kotlin
fun pressedWorkoutButton() {
// remotely decide if a paywall is shown and if
// navigation.startWorkout() is a paid-only feature
Superwall.instance.register("StartWorkout") {
navigation.startWorkout()
}
}
```
:::
Without Superwall [#without-superwall]
:::android
```kotlin Kotlin
fun pressedWorkoutButton() {
if (user.hasActiveSubscription) {
navigation.startWorkout()
} else {
navigation.presentPaywall { result ->
if (result) {
navigation.startWorkout()
} else {
// user didn't pay, developer decides what to do
}
}
}
}
```
:::
How registering placements presents paywalls [#how-registering-placements-presents-paywalls]
You can configure `"StartWorkout"` to present a paywall by [creating a campaign, adding the placement, and adding a paywall to an audience](/docs/dashboard/dashboard-campaigns/campaigns) in the dashboard.
1. The SDK retrieves your campaign settings from the dashboard on app launch.
2. When a placement is called that belongs to a campaign, audiences are evaluated ***on device*** and the user enters an experiment — this means there's no delay between registering a placement and presenting a paywall.
3. If it's the first time a user is entering an experiment, a paywall is decided for the user based on the percentages you set in the dashboard
4. Once a user is assigned a paywall for an audience, they will continue to see that paywall until you remove the paywall from the audience or reset assignments to the paywall.
5. After the paywall is closed, the Superwall SDK looks at the *Feature Gating* value associated with your paywall, configurable from the paywall editor under General > Feature Gating (more on this below)
1. If the paywall is set to ***Non Gated***, the `feature:` closure on `register(placement: ...)` gets called when the paywall is dismissed (whether they paid or not)
2. If the paywall is set to ***Gated***, the `feature:` closure on `register(placement: ...)` gets called only if the user is already paying or if they begin paying.
6. If no paywall is configured, the feature gets executed immediately without any additional network calls.
Given the low cost nature of how register works, we strongly recommend registering **all core functionality** in order to remotely configure which features you want to gate – **without an app update**.
:::android
```kotlin Kotlin
// on the welcome screen
fun pressedSignUp() {
Superwall.instance.register("SignUp") {
navigation.beginOnboarding()
}
}
// in another view controller
fun pressedWorkoutButton() {
Superwall.instance.register("StartWorkout") {
navigation.startWorkout()
}
}
```
:::
Automatically Registered Placements [#automatically-registered-placements]
The SDK [automatically registers](/docs/sdk/guides/3rd-party-analytics/tracking-analytics) some internal placements which can be used to present paywalls:
Register. Everything. [#register-everything]
To provide your team with ultimate flexibility, we recommend registering *all* of your analytics events, even if you don't pass feature blocks through. This way you can retroactively add a paywall almost anywhere – **without an app update**!
If you're already set up with an analytics provider, you'll typically have an `Analytics.swift` singleton (or similar) to disperse all your events from. Here's how that file might look:
Getting a presentation result [#getting-a-presentation-result]
Use `getPresentationResult(forPlacement:params:)` when you need to ask the SDK what would happen when registering a placement — without actually showing a paywall. Superwall evaluates the placement and its audience filters then returns a `PresentationResult`. You can use this to adapt your app's behavior based on the outcome (such as showing a lock icon next to a pro feature if they aren't subscribed).
In short, this lets you peek at the outcome first and decide how your app should respond:
# Handling Deep Links
1. Previewing paywalls on your device before going live.
2. Deep linking to specific [campaigns](/docs/dashboard/dashboard-campaigns/campaigns).
:::android
3) Web Checkout [Post-Checkout Redirecting](/docs/sdk/guides/web-checkout/post-checkout-redirecting)
:::
Setup [#setup]
:::android
The way to deep link into your app is URL Schemes.
:::
Adding a Custom URL Scheme [#adding-a-custom-url-scheme]
:::android
Add the following to your `AndroidManifest.xml` file:
```xml
```
This configuration allows your app to open in response to a deep link with the format `exampleapp://` from your `MainActivity` class.
:::
Handling Deep Links [#handling-deep-links]
:::android
In your `MainActivity` (or the activity specified in your intent-filter), add the following Kotlin code to handle deep links:
```kotlin Kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Respond to deep links
respondToDeepLinks()
}
private fun respondToDeepLinks() {
intent?.data?.let { uri ->
Superwall.instance.handleDeepLink(uri)
}
}
}
```
:::
::::android
Handling App Links [#handling-app-links]
Adding app links [#adding-app-links]
Android App links enable seamless integration between the Web checkout and your app,
enabling users to redeem the purchase automatically.
To allow Android app links to open your app, you will need to follow these steps:
1\. Add your app's fingerprint and schema to Stripe settings [#1-add-your-apps-fingerprint-and-schema-to-stripe-settings]
To verify that the request to open the app is legitimate, Android requires your app's keystore SHA256 fingerprint, with at least one for your development keystore and one for your release keystore. You can obtain these fingerprints in the following way:
Development fingerprints [#development-fingerprints]
If you're using Android studio or have Android components installed, you can obtain your debug key by running the following command in your terminal:
`keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android`
And then copying the outputted SHA256 fingerprint.
Release fingerprints [#release-fingerprints]
To obtain the release fingerprints, you'll need your own keystore file (the one you use to sign the final application package before publishing).
You can do this by running the following command in your terminal:
`keytool -list -v -keystore -alias `
And then copying the outputted SHA256 fingerprint.
Adding the fingerprints to the project [#adding-the-fingerprints-to-the-project]
To add the fingerprints to Superwall, open the Settings tab of your Superwall Stripe application.
Under the `Android Configuration` title, you should see three fields:
* Package schema - this allows us to know which schema your app uses to open and parse deep links
* Package name - your app's package name, i.e. `com.mydomain.myapp`
* App fingerprints - One or more of your app's fingerprints, comma separated
Once added, click the `Update Configuration` button which will ensure the application asset links are properly generated for Google to verify.
2\. Add the schema to your app's Android Manifest [#2-add-the-schema-to-your-apps-android-manifest]
For this, you'll need to copy the domain from your Superwall Stripe settings.
Then, open your `AndroidManifest.xml` and inside the `` tag declaring your deep link handling activity, add the following, replacing the domain with the one from the settings:
```xml
```
3\. Handle incoming deep links using Superwall SDK [#3-handle-incoming-deep-links-using-superwall-sdk]
In the same activity as in step #2, you'll need to pass deeplinks along to Superwall SDK.
You can do this by overriding your Activity's `onCreate` and `onNewIntent` methods and passing along the intent data to Superwall using `Superwall.handleDeepLinks()` method. The method returns a `kotlin.Result` indicating if the deep link will be handled by Superwall SDK.
```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// your onCreate code
intent?.data?.let { uri ->
Superwall.handleDeepLink(uri)
}
}
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
super.onNewIntent(intent, caller)
// ... Your onNewIntent code
intent?.data?.let { uri ->
Superwall.handleDeepLink(uri)
}
}
```
4\. Handling links while testing [#4-handling-links-while-testing]
If you are running your app using Android Studio, you need to be aware that it won't automatically allow verified links to be opened using the app, but you will need to enable it in settings yourself. This is not the case when installing from Play Store, and all the links will be handled automatically.
To do that, once you install the app on your device, open:
`Settings > Apps > Your Application Name > Open By Default`
Under there, the `Open supported links` should be enabled.
Tap `Add Links` button and selected the available links.
Testing & more details [#testing--more-details]
For details regarding testing and setup, you can refer to [Android's guide for verifying app links](https://developer.android.com/training/app-links/verify-android-applinks).
Note - Superwall generates the assetlinks.json for you. To check the file, you can use the subdomain from your Superwall stripe configuation:
`https://my-app.superwall.app/.well-known/assetlinks.json`
:::android
:::
::::
Previewing Paywalls [#previewing-paywalls]
Next, build and run your app on your phone.
Then, head to the Superwall Dashboard. Click on **Settings** from the Dashboard panel on the left, then select **General**:
With the **General** tab selected, type your custom URL scheme, without slashes, into the **Apple Custom URL Scheme** field:
Next, open your paywall from the dashboard and click **Preview**. You'll see a QR code appear in a pop-up:
On your device, scan this QR code. You can do this via Apple's Camera app. This will take you to a paywall viewer within your app, where you can preview all your paywalls in different configurations.
Using Deep Links to Present Paywalls [#using-deep-links-to-present-paywalls]
Deep links can also be used as a placement in a campaign to present paywalls. Simply add `deepLink_open` as an placement, and the URL parameters of the deep link can be used as parameters! You can also use custom placements for this purpose. [Read this doc](/docs/dashboard/guides/presenting-paywalls-from-one-another) for examples of both.
Related deep link guides [#related-deep-link-guides]
:::android
* [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 deep links, without hardcoding routing logic in your app.
:::
# Install the SDK
Overview [#overview]
To see the latest release, [check out the repository](https://github.com/superwall/Superwall-Android)
Install via Gradle [#install-via-gradle]
[Gradle](https://developer.android.com/build/releases/gradle-plugin) is the
preferred way to install Superwall for Android.
In your `build.gradle` or `build.gradle.kts` add the latest Superwall SDK. You
can find the [latest release here](https://github.com/superwall/Superwall-Android/releases).
```groovy build.gradle
implementation "com.superwall.sdk:superwall-android:2.7.14"
```
```kotlin build.gradle.kts
implementation("com.superwall.sdk:superwall-android:2.7.14")
```
```toml libs.version.toml
[libraries]
superwall-android = { group = "com.superwall.sdk", name = "superwall-android", version = "2.7.14" }
// And in your build.gradle.kts
dependencies {
implementation(libs.superwall.android)
}
```
Make sure to run **Sync Now** to force Android Studio to update.
Go to your `AndroidManifest.xml` and add the following permissions:
```xml AndroidManifest.xml
```
Then add our Activity to your `AndroidManifest.xml`:
```xml AndroidManifest.xml
```
Set your app's theme in the `android:theme` section.
When choosing a device or emulator to run on make sure that it has the Play Store app and that you are signed in to your Google account on the Play Store.
**And you're done!**
Now you're ready to configure the SDK 👇
# Setting User Attributes
By setting user attributes, you can display information about the user on the paywall. You can also define [audiences](/docs/dashboard/dashboard-campaigns/campaigns-audience) in a campaign to determine which paywall to show to a user, based on their user attributes.
If a paywall uses the **Set user attributes** action, the merged attributes are sent back to your app via `SuperwallDelegate.userAttributesDidChange(newAttributes:)`.
You do this by passing a `[String: Any?]` dictionary of attributes to `Superwall.shared.setUserAttributes(_:)`:
:::android
```kotlin Kotlin
val attributes = mapOf(
"name" to user.name,
"apnsToken" to user.apnsTokenString,
"email" to user.email,
"username" to user.username,
"profilePic" to user.profilePicUrl,
"stripe_customer_id" to user.stripeCustomerId // Optional: For Stripe checkout prefilling
)
Superwall.instance.setUserAttributes(attributes) // (merges existing attributes)
```
:::
Usage [#usage]
This is a merge operation, such that if the existing user attributes dictionary
already has a value for a given property, the old value is overwritten. Other
existing properties will not be affected. To unset/delete a value, you can pass `nil`
for the value.
You can reference user attributes in [audience filters](/docs/dashboard/dashboard-campaigns/campaigns-audience) to help decide when to display your paywall. When you configure your paywall, you can also reference the user attributes in its text variables. For more information on how to that, see [Configuring a Paywall](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-overview).
# Tracking Subscription State
Superwall tracks the subscription state of a user for you. However, there are times in your app where you need to know if a user is on a paid plan or not. For example, you might want to conditionally show certain UI elements or enable premium features based on their subscription status.
Using subscriptionStatus [#using-subscriptionstatus]
The easiest way to track subscription status in Android is by accessing the `subscriptionStatus` StateFlow:
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Get current status
val status = Superwall.instance.subscriptionStatus.value
when (status) {
is SubscriptionStatus.Active -> {
Log.d("Superwall", "User has active entitlements: ${status.entitlements}")
showPremiumContent()
}
is SubscriptionStatus.Inactive -> {
Log.d("Superwall", "User is on free plan")
showFreeContent()
}
is SubscriptionStatus.Unknown -> {
Log.d("Superwall", "Subscription status unknown")
showLoadingState()
}
}
}
}
```
The `SubscriptionStatus` sealed class has three possible states:
* `SubscriptionStatus.Unknown` - Status is not yet determined
* `SubscriptionStatus.Active(Set)` - User has active entitlements (set of entitlement identifiers)
* `SubscriptionStatus.Inactive` - User has no active entitlements
Observing subscription status changes [#observing-subscription-status-changes]
You can observe real-time subscription status changes using Kotlin's StateFlow:
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
Superwall.instance.subscriptionStatus.collect { status ->
when (status) {
is SubscriptionStatus.Active -> {
Log.d("Superwall", "User upgraded to pro!")
updateUiForPremiumUser()
}
is SubscriptionStatus.Inactive -> {
Log.d("Superwall", "User is on free plan")
updateUiForFreeUser()
}
is SubscriptionStatus.Unknown -> {
Log.d("Superwall", "Loading subscription status...")
showLoadingState()
}
}
}
}
}
}
```
Using with Jetpack Compose [#using-with-jetpack-compose]
If you're using Jetpack Compose, you can observe subscription status reactively:
```kotlin
@Composable
fun ContentScreen() {
val subscriptionStatus by Superwall.instance.subscriptionStatus
.collectAsState()
Column {
when (subscriptionStatus) {
is SubscriptionStatus.Active -> {
val entitlements = (subscriptionStatus as SubscriptionStatus.Active).entitlements
Text("Premium user with: ${entitlements.joinToString()}")
PremiumContent()
}
is SubscriptionStatus.Inactive -> {
Text("Free user")
FreeContent()
}
is SubscriptionStatus.Unknown -> {
Text("Loading...")
LoadingIndicator()
}
}
}
}
```
Reading detailed purchase history (2.6.6+) [#reading-detailed-purchase-history-266]
When you need more context than `SubscriptionStatus` provides (for example, to show the full transaction history or mix web redemptions with Google Play receipts), subscribe to `Superwall.instance.customerInfo`. The flow emits a `CustomerInfo` object that merges device, web, and external purchase controller data.
```kotlin
class BillingDashboardFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
Superwall.instance.customerInfo.collect { info ->
val subscriptions = info.subscriptions.map { it.productId to it.expiresDate }
val nonSubscriptions = info.nonSubscriptions.map { it.productId to it.purchaseDate }
val entitlementIds = info.entitlements.filter { it.isActive }.map { it.id }
renderCustomerInfo(
activeProducts = info.activeSubscriptionProductIds,
entitlements = entitlementIds,
subscriptions = subscriptions,
oneTimePurchases = nonSubscriptions
)
}
}
}
}
```
Need the latest value immediately (for example, during cold start)? Call `Superwall.instance.getCustomerInfo()` to synchronously read the most recent snapshot before collecting the flow:
```kotlin
val cached = Superwall.instance.getCustomerInfo()
renderCustomerInfo(
activeProducts = cached.activeSubscriptionProductIds,
entitlements = cached.entitlements.filter { it.isActive }.map { it.id },
subscriptions = cached.subscriptions.map { it.productId to it.purchaseDate },
oneTimePurchases = cached.nonSubscriptions.map { it.productId to it.purchaseDate }
)
```
After you start collecting, you can also watch for [`SuperwallDelegate.customerInfoDidChange(from:to:)`](/docs/android/sdk-reference/SuperwallDelegate#customerinfodidchangefrom-customerinfo-to-customerinfo) to run analytics or sync other systems whenever purchases change.
Checking for specific entitlements [#checking-for-specific-entitlements]
If your app has multiple subscription tiers (e.g., Bronze, Silver, Gold), you can check for specific entitlements:
```kotlin
val status = Superwall.instance.subscriptionStatus.value
when (status) {
is SubscriptionStatus.Active -> {
if (status.entitlements.contains("gold")) {
// Show gold-tier features
showGoldFeatures()
} else if (status.entitlements.contains("silver")) {
// Show silver-tier features
showSilverFeatures()
}
}
else -> showFreeFeatures()
}
```
Setting subscription status [#setting-subscription-status]
When using Superwall with a custom purchase controller or third-party billing service, you need to manually update the subscription status. Here's how to sync with RevenueCat:
```kotlin
class RevenueCatPurchaseController : PurchaseController {
override suspend fun purchase(
activity: Activity,
product: StoreProduct
): PurchaseResult {
return try {
val result = Purchases.sharedInstance.purchase(activity, product.sku)
// Update Superwall subscription status based on RevenueCat result
if (result.isSuccessful) {
val entitlements = result.customerInfo.entitlements.active.keys
Superwall.instance.setSubscriptionStatus(
SubscriptionStatus.Active(entitlements)
)
PurchaseResult.Purchased
} else {
PurchaseResult.Failed(Exception("Purchase failed"))
}
} catch (e: Exception) {
PurchaseResult.Failed(e)
}
}
override suspend fun restorePurchases(): RestorationResult {
return try {
val customerInfo = Purchases.sharedInstance.restorePurchases()
val activeEntitlements = customerInfo.entitlements.active.keys
if (activeEntitlements.isNotEmpty()) {
Superwall.instance.setSubscriptionStatus(
SubscriptionStatus.Active(activeEntitlements)
)
} else {
Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Inactive)
}
RestorationResult.Restored
} catch (e: Exception) {
RestorationResult.Failed(e)
}
}
}
```
You can also listen for subscription changes from your billing service:
```kotlin
class SubscriptionManager {
fun syncSubscriptionStatus() {
Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
val activeEntitlements = customerInfo.entitlements.active.keys
if (activeEntitlements.isNotEmpty()) {
Superwall.instance.setSubscriptionStatus(
SubscriptionStatus.Active(activeEntitlements)
)
} else {
Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Inactive)
}
}
}
}
```
Using SuperwallDelegate [#using-superwalldelegate]
You can also listen for subscription status changes using the `SuperwallDelegate`:
```kotlin
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
Superwall.configure(
applicationContext = this,
apiKey = "YOUR_API_KEY",
options = SuperwallOptions().apply {
delegate = object : SuperwallDelegate() {
override fun subscriptionStatusDidChange(
from: SubscriptionStatus,
to: SubscriptionStatus
) {
when (to) {
is SubscriptionStatus.Active -> {
Log.d("Superwall", "User is now premium")
}
is SubscriptionStatus.Inactive -> {
Log.d("Superwall", "User is now free")
}
is SubscriptionStatus.Unknown -> {
Log.d("Superwall", "Status unknown")
}
}
}
}
}
)
}
}
```
Superwall checks subscription status for you [#superwall-checks-subscription-status-for-you]
Remember that the Superwall SDK uses its [audience filters](/docs/dashboard/dashboard-campaigns/campaigns-audience#matching-to-entitlements) for determining when to show paywalls. You generally don't need to wrap your calls to register placements with subscription status checks:
```kotlin
// ❌ Unnecessary
if (Superwall.instance.subscriptionStatus.value !is SubscriptionStatus.Active) {
Superwall.instance.register("campaign_trigger")
}
// ✅ Just register the placement
Superwall.instance.register("campaign_trigger")
```
In your [audience filters](/docs/dashboard/dashboard-campaigns/campaigns-audience#matching-to-entitlements), you can specify whether the subscription state should be considered, which keeps your codebase cleaner and puts the "Should this paywall show?" logic where it belongs—in the Superwall dashboard.
# User Management
Anonymous Users [#anonymous-users]
Superwall automatically generates a random user ID that persists internally until the user deletes/reinstalls your app.
You can call `Superwall.shared.reset()` to reset this ID and clear any paywall assignments.
Identified Users [#identified-users]
If you use your own user management system, call `identify(userId:options:)` when you have a user's identity. This will alias your `userId` with the anonymous Superwall ID enabling us to load the user’s assigned paywalls.
Calling `Superwall.shared.reset()` will reset the on-device userId to a random ID and clear the paywall assignments.
:::android
Google Play receives the identifier you pass to `BillingFlowParams.Builder.setObfuscatedAccountId` as both the **`obfuscatedExternalAccountId`** and the `externalAccountId` we forward to your Superwall backend. By default we SHA-256 hash your `userId` before sending it. If you want the raw `appUserId` to appear in Play Console and downstream server events, set `SuperwallOptions().passIdentifiersToPlayStore = true` before configuring the SDK. Make sure the value complies with [Google's policies](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedAccountId)—it must not contain personally identifiable information.
:::
:::android
```kotlin Kotlin
// After retrieving a user's ID, e.g. from logging in or creating an account
Superwall.instance.identify(user.id)
// When the user signs out
Superwall.instance.reset()
```
:::
**Advanced Use Case**
You can supply an `IdentityOptions` object, whose property `restorePaywallAssignments` you can set to `true`. This tells the SDK to wait to restore paywall assignments from the server before presenting any paywalls. This should only be used in advanced use cases. If you expect users of your app to switch accounts or delete/reinstall a lot, you'd set this when users log in to an existing account.
Best Practices for a Unique User ID [#best-practices-for-a-unique-user-id]
* Do NOT make your User IDs guessable – they are public facing.
* Do NOT set emails as User IDs – this isn't GDPR compliant.
* Do NOT set IDFA or DeviceIds as User IDs – these are device specific / easily rotated by the operating system.
* Do NOT hardcode strings as User IDs – this will cause every user to be treated as the same user by Superwall.
Identifying users from App Store server events [#identifying-users-from-appstore-server-events]
On iOS, Superwall always supplies an [`appAccountToken`](https://developer.apple.com/documentation/storekit/product/purchaseoption/3749440-appaccounttoken) with every StoreKit 2 transaction:
| Scenario | Value used for `appAccountToken` |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| You’ve called `Superwall.shared.identify(userId:)` | The exact `userId` you passed |
| You *haven’t* called `identify` yet | The UUID automatically generated for the anonymous user (the **alias ID**), **without** the `$SuperwallAlias:` prefix |
| You passed a non‑UUID `userId` to `identify` | StoreKit rejects it; Superwall falls back to the alias UUID |
Because the SDK falls back to the alias UUID, purchase notifications sent to your server always include a stable, unique identifier—even before the user signs in.
```swift
// Generate and use a UUID user ID in Swift
let userId = UUID().uuidString
Superwall.shared.identify(userId: userId)
```
# PaywallOptions
`PaywallOptions` is provided via the `paywalls` property on [`SuperwallOptions`](/docs/android/sdk-reference/SuperwallOptions) and is passed to the SDK when you call [`configure`](/docs/android/sdk-reference/configure).
Purpose [#purpose]
Customize how paywalls look and behave, including preload behavior, alerts, dismissal, and haptics.
Signature [#signature]
```kotlin
import com.superwall.sdk.analytics.Tier
import kotlin.time.Duration
class PaywallOptions {
var isHapticFeedbackEnabled: Boolean = true
class RestoreFailed {
var title: String = "No Subscription Found"
var message: String = "We couldn't find an active subscription for your account."
var closeButtonTitle: String = "Okay"
}
var restoreFailed: RestoreFailed = RestoreFailed()
var shouldShowPurchaseFailureAlert: Boolean = true
var shouldPreload: Boolean = true
var preloadDeviceOverrides: Map = emptyMap()
var useCachedTemplates: Boolean = false
var automaticallyDismiss: Boolean = true
enum class TransactionBackgroundView { SPINNER }
var transactionBackgroundView: TransactionBackgroundView? = TransactionBackgroundView.SPINNER
var overrideProductsByName: Map = emptyMap()
var optimisticLoading: Boolean = false
var timeoutAfter: Duration? = null
var onBackPressed: ((PaywallInfo?) -> Boolean)? = null
}
```
Parameters [#parameters]
Usage [#usage]
```kotlin
val paywallOptions = PaywallOptions().apply {
isHapticFeedbackEnabled = true
shouldShowPurchaseFailureAlert = false
shouldPreload = true
preloadDeviceOverrides = mapOf(
Tier.ULTRA_LOW to false,
Tier.LOW to false,
)
useCachedTemplates = false
automaticallyDismiss = true
transactionBackgroundView = PaywallOptions.TransactionBackgroundView.SPINNER
overrideProductsByName = mapOf(
"primary" to "com.example.premium_monthly",
"tertiary" to "com.example.premium_annual",
)
optimisticLoading = false
timeoutAfter = null
onBackPressed = { paywallInfo ->
// Custom back button handling
// Return true to consume the back press, false to use SDK default
false
}
}
val options = SuperwallOptions().apply {
paywalls = paywallOptions
}
Superwall.configure(
application = this,
apiKey = "pk_your_api_key",
options = options,
)
```
`preloadDeviceOverrides` is useful when you want to keep preloading enabled by default but disable it on lower-end devices. Tiers you do not specify continue to use the `shouldPreload` value.
Related [#related]
* [`SuperwallOptions`](/docs/android/sdk-reference/SuperwallOptions)
# PurchaseController
**This interface is not required.** By default, Superwall handles all subscription-related logic automatically using Google Play Billing.
When implementing PurchaseController, you must manually update [`subscriptionStatus`](/docs/android/sdk-reference/subscriptionStatus) whenever the user's entitlements change.
Purpose [#purpose]
Use this interface only if you want complete control over purchase handling, such as when using RevenueCat or other third-party purchase frameworks.
Signature [#signature]
```kotlin
interface PurchaseController {
suspend fun purchase(
activity: Activity,
product: StoreProduct
): PurchaseResult
suspend fun restorePurchases(): RestorationResult
}
```
```java
// Java
public interface PurchaseController {
CompletableFuture purchase(
Activity activity,
StoreProduct product
);
CompletableFuture restorePurchases();
}
```
Parameters [#parameters]
Returns / State [#returns--state]
* `purchase()` returns a `PurchaseResult` (`.Purchased`, `.Failed(Throwable)`, `.Cancelled`, or `.Pending`)
* `restorePurchases()` returns a `RestorationResult` (`.Restored` or `.Failed(Throwable?)`)
When using a PurchaseController, you must also manage [`subscriptionStatus`](/docs/android/sdk-reference/subscriptionStatus) yourself.
Usage [#usage]
For implementation examples and detailed guidance, see [Using RevenueCat](/docs/android/guides/using-revenuecat).
# Superwall
You must call [`configure()`](/docs/android/sdk-reference/configure) before accessing `Superwall.instance`, otherwise your app will crash.
Purpose [#purpose]
Provides access to the configured Superwall instance after calling [`configure()`](/docs/android/sdk-reference/configure).
Signature [#signature]
```kotlin
companion object {
val instance: Superwall
}
```
```java
// Java
public static Superwall getInstance()
```
Parameters [#parameters]
This is a companion object property with no parameters.
Returns / State [#returns--state]
Returns the shared `Superwall` instance that was configured via [`configure()`](/docs/android/sdk-reference/configure).
Usage [#usage]
Configure first (typically in Application class):
```kotlin
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
Superwall.configure(
application = this,
apiKey = "pk_your_api_key"
)
}
}
```
Then access throughout your app:
```kotlin
Superwall.instance.register("feature_access") {
// Feature code here
}
```
Set user identity and attributes:
```kotlin
Superwall.instance.identify("user123")
Superwall.instance.setUserAttributes(mapOf(
"plan" to "premium",
"signUpDate" to System.currentTimeMillis()
))
```
Reset the user:
```kotlin
Superwall.instance.reset()
```
Avoid calling `Superwall.instance.reset()` repeatedly. Resetting rotates the anonymous user ID, clears local paywall assignments, and requires the SDK to re-download configuration state. Only trigger a reset when a user explicitly logs out or you intentionally need to forget their identity. See [User Management](/docs/android/quickstart/user-management) for more guidance.
Set delegate:
```kotlin
Superwall.instance.delegate = this
```
Consume a purchase (2.6.2+):
```kotlin
// Using coroutines
lifecycleScope.launch {
val result = Superwall.instance.consume(purchaseToken)
result.fold(
onSuccess = { token ->
println("Purchase consumed: $token")
},
onFailure = { error ->
println("Failed to consume: ${error.message}")
}
)
}
// Using callback
Superwall.instance.consume(purchaseToken) { result ->
result.fold(
onSuccess = { token ->
println("Purchase consumed: $token")
},
onFailure = { error ->
println("Failed to consume: ${error.message}")
}
)
}
```
Show an alert over the current paywall (2.5.3+):
```kotlin
Superwall.instance.showAlert(
title = "Important Notice",
message = "Your subscription will renew soon",
actionTitle = "View Details",
closeActionTitle = "Dismiss",
action = {
// Handle action button tap
navigateToSubscriptionSettings()
},
onClose = {
// Handle close/dismiss
println("Alert dismissed")
}
)
```
Set integration attributes for analytics (2.5.3+):
```kotlin
import com.superwall.sdk.models.attribution.AttributionProvider
Superwall.instance.setIntegrationAttributes(
mapOf(
AttributionProvider.ADJUST to "adjust_user_id_123",
AttributionProvider.MIXPANEL to "mixpanel_distinct_id_456",
AttributionProvider.META to "meta_user_id_789",
AttributionProvider.GOOGLE_ADS to "google_ads_id_101",
AttributionProvider.GOOGLE_APP_SET to "google_app_set_id_202",
AttributionProvider.APPSTACK to "appstack_user_id_303"
)
)
```
Observe customer info (2.6.6+) [#observe-customer-info-266]
Superwall now exposes purchase history and entitlement snapshots via a `StateFlow`. Each emission contains merged device, web, and external purchase controller data so you can react to subscription changes without wiring up your own polling layer.
```kotlin
lifecycleScope.launch {
Superwall.instance.customerInfo.collect { info ->
val activeProductIds = info.activeSubscriptionProductIds
val activeEntitlementIds = info.entitlements
.filter { it.isActive }
.map { it.id }
updateUi(
subscriptions = activeProductIds,
entitlements = activeEntitlementIds
)
}
}
```
Need an immediate snapshot (for example during cold start)? Call `Superwall.instance.getCustomerInfo()` to synchronously read the latest cached value, or wire both together:
```kotlin
val cachedInfo = Superwall.instance.getCustomerInfo()
render(cachedInfo)
lifecycleScope.launch {
Superwall.instance.customerInfo.collect { render(it) }
}
```
Pair the flow with [`SuperwallDelegate.customerInfoDidChange(from:to:)`](/docs/android/sdk-reference/SuperwallDelegate#customerinfodidchangefrom-customerinfo-to-customerinfo) when you need to mirror changes into analytics.
Java usage:
```java
// Access the instance
Superwall.getInstance().register("feature_access", () -> {
// Feature code here
});
// Set user identity
Superwall.getInstance().identify("user123");
```
# SuperwallDelegate
Set the delegate using `Superwall.instance.delegate = this` to receive these callbacks. For Java, use `setJavaDelegate()` for better Java interop.
Use `handleSuperwallEvent(eventInfo)` to track Superwall analytics events in your own analytics platform for a complete view of user behavior.
Purpose [#purpose]
Provides callbacks for Superwall lifecycle events, analytics tracking, and custom paywall interactions.
Signature [#signature]
```kotlin
interface SuperwallDelegate {
fun subscriptionStatusDidChange(
from: SubscriptionStatus,
to: SubscriptionStatus
) {}
fun customerInfoDidChange(
from: CustomerInfo,
to: CustomerInfo
) {}
fun userAttributesDidChange(newAttributes: Map) {}
fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {}
fun handleCustomPaywallAction(name: String) {}
fun willDismissPaywall(paywallInfo: PaywallInfo) {}
fun willPresentPaywall(paywallInfo: PaywallInfo) {}
fun didDismissPaywall(paywallInfo: PaywallInfo) {}
fun didPresentPaywall(paywallInfo: PaywallInfo) {}
fun paywallWillOpenURL(url: String) {}
fun paywallWillOpenDeepLink(url: String) {}
fun handleLog(
level: LogLevel,
scope: LogScope,
message: String,
info: Map?,
error: Throwable?
) {}
}
```
```java
// Java - Use SuperwallDelegateJava for better Java interop
public interface SuperwallDelegateJava {
default void subscriptionStatusDidChange(
SubscriptionStatus from,
SubscriptionStatus to
) {}
default void customerInfoDidChange(
CustomerInfo from,
CustomerInfo to
) {}
default void userAttributesDidChange(Map newAttributes) {}
default void handleSuperwallEvent(SuperwallEventInfo eventInfo) {}
default void handleCustomPaywallAction(String name) {}
default void willDismissPaywall(PaywallInfo paywallInfo) {}
default void willPresentPaywall(PaywallInfo paywallInfo) {}
default void didDismissPaywall(PaywallInfo paywallInfo) {}
default void didPresentPaywall(PaywallInfo paywallInfo) {}
default void paywallWillOpenURL(String url) {}
default void paywallWillOpenDeepLink(String url) {}
default void handleLog(
LogLevel level,
LogScope scope,
String message,
Map info,
Throwable error
) {}
}
```
Parameters [#parameters]
All methods are optional to implement. Key methods include:
Returns / State [#returns--state]
All delegate methods return `Unit`. They provide information about Superwall events and state changes.
Usage [#usage]
Basic delegate setup:
```kotlin
class MainActivity : AppCompatActivity(), SuperwallDelegate {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Superwall.instance.delegate = this
}
}
```
Track subscription status changes:
```kotlin
override fun subscriptionStatusDidChange(
from: SubscriptionStatus,
to: SubscriptionStatus
) {
println("Subscription changed from $from to $to")
updateUI(to)
}
```
Mirror merged purchase history:
```kotlin
override fun customerInfoDidChange(
from: CustomerInfo,
to: CustomerInfo
) {
if (from.activeSubscriptionProductIds != to.activeSubscriptionProductIds) {
Analytics.track("customer_info_updated", mapOf(
"old_products" to from.activeSubscriptionProductIds.joinToString(),
"new_products" to to.activeSubscriptionProductIds.joinToString()
))
}
refreshEntitlementBadge(to.entitlements.filter { it.isActive }.map { it.id })
}
```
Capture remote attribute changes:
```kotlin
override fun userAttributesDidChange(newAttributes: Map) {
// Paywall forms or surveys can set attributes directly.
// Forward them to your analytics platform or local cache.
analytics.identify(newAttributes)
}
```
Forward analytics events:
```kotlin
override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
when (val event = eventInfo.event) {
is SuperwallEvent.PaywallOpen -> {
Analytics.track("paywall_opened", mapOf(
"paywall_id" to event.paywallInfo.id,
"placement" to event.paywallInfo.placement
))
}
is SuperwallEvent.TransactionComplete -> {
Analytics.track("subscription_purchased", mapOf(
"product_id" to event.product.id,
"paywall_id" to event.paywallInfo.id
))
}
else -> {
// Handle other events
}
}
}
```
Handle custom paywall actions:
```kotlin
override fun handleCustomPaywallAction(name: String) {
when (name) {
"help" -> presentHelpScreen()
"contact" -> presentContactForm()
else -> println("Unknown custom action: $name")
}
}
```
Handle paywall lifecycle:
```kotlin
override fun willPresentPaywall(paywallInfo: PaywallInfo) {
// Pause video, hide UI, etc.
pauseBackgroundTasks()
}
override fun didDismissPaywall(paywallInfo: PaywallInfo) {
// Resume video, show UI, etc.
resumeBackgroundTasks()
}
```
Java usage:
```java
public class MainActivity extends AppCompatActivity implements SuperwallDelegateJava {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Superwall.getInstance().setJavaDelegate(this);
}
@Override
public void subscriptionStatusDidChange(
SubscriptionStatus from,
SubscriptionStatus to
) {
System.out.println("Subscription changed from " + from + " to " + to);
updateUI(to);
}
@Override
public void customerInfoDidChange(
CustomerInfo from,
CustomerInfo to
) {
Logger.i("Superwall", "Customer info updated: " + to.getActiveSubscriptionProductIds());
syncUserPurchases(to);
}
@Override
public void userAttributesDidChange(Map newAttributes) {
analytics.identify(newAttributes);
}
}
```
# SuperwallEvent
These events provide comprehensive analytics about user behavior and paywall performance. Use them to track conversion funnels, user engagement, and revenue metrics in your analytics platform.
Common events to track for conversion analysis include `TriggerFire`, `PaywallOpen`, `TransactionStart`, and `TransactionComplete`.
Purpose [#purpose]
Represents internal analytics events tracked by Superwall and sent to the [`SuperwallDelegate`](/docs/android/sdk-reference/SuperwallDelegate) for forwarding to your analytics platform.
Signature [#signature]
```kotlin
sealed class SuperwallEvent {
// User lifecycle events
object FirstSeen : SuperwallEvent()
object AppOpen : SuperwallEvent()
object AppLaunch : SuperwallEvent()
object AppClose : SuperwallEvent()
object SessionStart : SuperwallEvent()
object IdentityAlias : SuperwallEvent()
object AppInstall : SuperwallEvent()
// Deep linking
data class DeepLink(val url: String) : SuperwallEvent()
// Paywall events
data class TriggerFire(
val placementName: String,
val result: TriggerResult
) : SuperwallEvent()
data class PaywallOpen(val paywallInfo: PaywallInfo) : SuperwallEvent()
data class PaywallPageView(
val paywallInfo: PaywallInfo,
val data: PageViewData
) : SuperwallEvent()
data class PaywallClose(val paywallInfo: PaywallInfo) : SuperwallEvent()
data class PaywallDecline(val paywallInfo: PaywallInfo) : SuperwallEvent()
// Transaction events
data class TransactionStart(
val product: StoreProduct,
val paywallInfo: PaywallInfo
) : SuperwallEvent()
data class TransactionComplete(
val transaction: StoreTransaction?,
val product: StoreProduct,
val type: TransactionType,
val paywallInfo: PaywallInfo
) : SuperwallEvent()
data class TransactionFail(
val error: TransactionError,
val paywallInfo: PaywallInfo
) : SuperwallEvent()
data class TransactionAbandon(
val product: StoreProduct,
val paywallInfo: PaywallInfo
) : SuperwallEvent()
data class TransactionRestore(
val restoreType: RestoreType,
val paywallInfo: PaywallInfo
) : SuperwallEvent()
data class TransactionTimeout(val paywallInfo: PaywallInfo) : SuperwallEvent()
// Subscription events
data class SubscriptionStart(
val product: StoreProduct,
val paywallInfo: PaywallInfo
) : SuperwallEvent()
data class FreeTrialStart(
val product: StoreProduct,
val paywallInfo: PaywallInfo
) : SuperwallEvent()
object SubscriptionStatusDidChange : SuperwallEvent()
// Restore events
sealed class Restore : SuperwallEvent() {
object Start : Restore()
data class Fail(val error: String) : Restore()
object Complete : Restore()
}
// Customer and permission events
data class CustomerInfoDidChange(
val from: CustomerInfo,
val to: CustomerInfo
) : SuperwallEvent()
data class PermissionRequested(
val permissionName: String,
val paywallIdentifier: String
) : SuperwallEvent()
data class PermissionGranted(
val permissionName: String,
val paywallIdentifier: String
) : SuperwallEvent()
data class PermissionDenied(
val permissionName: String,
val paywallIdentifier: String
) : SuperwallEvent()
// Preloading events
data class PaywallPreloadStart(val paywallIdentifier: String) : SuperwallEvent()
data class PaywallPreloadComplete(val paywallCount: Int) : SuperwallEvent()
// Test mode events
class TestModeModalOpen : SuperwallEvent()
class TestModeModalClose : SuperwallEvent()
// System events
data class DeviceAttributes(val attributes: Map) : SuperwallEvent()
data class SurveyResponse(val survey: Survey, val selectedOption: SurveyOption, val customResponse: String?, val paywallInfo: PaywallInfo) : SuperwallEvent()
class SurveyClose : SuperwallEvent()
data class CustomPlacement(val name: String, val params: Map, val paywallInfo: PaywallInfo) : SuperwallEvent()
object Reset : SuperwallEvent()
// And more...
}
```
```java
// Java - SuperwallEvent is a sealed class hierarchy
// Access via pattern matching or instanceof checks
```
Parameters [#parameters]
Each event contains associated values with relevant information for that event type. Common parameters include:
* `paywallInfo: PaywallInfo` - Information about the paywall
* `data: PageViewData` - Metadata for multi-page paywall navigation, including the page name, position in the flow, navigation type, and previous-page timing when available
* `product: StoreProduct` - The product involved in transactions
* `url: String` - Deep link URLs
* `attributes: Map` - Device or user attributes
Returns / State [#returns--state]
This is a sealed class that represents different event types. Events are received via [`SuperwallDelegate.handleSuperwallEvent(eventInfo)`](/docs/android/sdk-reference/SuperwallDelegate).
Usage [#usage]
These events are received via [`SuperwallDelegate.handleSuperwallEvent(eventInfo)`](/docs/android/sdk-reference/SuperwallDelegate) for forwarding to your analytics platform.
Deprecations in 2.7.0 [#deprecations-in-270]
* `PaywallWebviewLoadTimeout` is deprecated. This event was causing confusion due to its naming and has been removed from internal tracking. It will no longer fire.
New events in 2.7.10 [#new-events-in-2710]
* `PaywallPageView` fires when a user navigates within a multi-page paywall. Use `event.data.pageName`, `event.data.flowPosition`, and `event.data.navigationType` to understand how they moved through the flow, and inspect the optional previous-page fields when you need timing context.
New events in 2.6.6+ [#new-events-in-266]
* `CustomerInfoDidChange` fires whenever the SDK merges device, web, and external purchase controller data into a new [`CustomerInfo`](/docs/android/quickstart/tracking-subscription-state#reading-detailed-purchase-history-2-6-6) snapshot. The event includes the previous and next objects so you can diff entitlements or transactions.
* `PermissionRequested`, `PermissionGranted`, and `PermissionDenied` correspond to the new **Request permission** action in the paywall editor. Each event carries the `permissionName` and `paywallIdentifier`.
* `PaywallPreloadStart` and `PaywallPreloadComplete` track when preloading kicks off and how many paywalls finished warming the cache.
Example handler:
```kotlin
override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
when (val event = eventInfo.event) {
is SuperwallEvent.CustomerInfoDidChange -> {
analytics.track("customer_info_updated", mapOf(
"old_products" to event.from.activeSubscriptionProductIds.joinToString(),
"new_products" to event.to.activeSubscriptionProductIds.joinToString()
))
}
is SuperwallEvent.PermissionRequested -> {
analytics.track("permission_requested", mapOf(
"permission" to event.permissionName,
"paywall_id" to event.paywallIdentifier
))
}
is SuperwallEvent.PermissionGranted -> {
featureFlags.unlock(event.permissionName)
}
is SuperwallEvent.PermissionDenied -> {
showPermissionHelpSheet(event.permissionName)
}
is SuperwallEvent.PaywallPageView -> {
analytics.track("paywall_page_view", mapOf(
"paywall_id" to event.paywallInfo.id,
"page_name" to event.data.pageName,
"flow_position" to event.data.flowPosition,
"navigation_type" to event.data.navigationType
))
}
is SuperwallEvent.PaywallPreloadComplete -> {
Logger.i("Superwall", "Preloaded ${event.paywallCount} paywalls")
}
else -> Unit
}
}
```
# SuperwallOptions
Only modify `networkEnvironment` if explicitly instructed by the Superwall team. Use `.RELEASE` (default) for production apps.
Use different `SuperwallOptions` configurations for debug and release builds to optimize logging, purchasing behavior, and Google Play settings for each environment.
Purpose [#purpose]
Configures paywall presentation, logging, Google Play purchase behavior, and other global SDK settings. Pass an instance in to [`Superwall.configure`](/docs/android/sdk-reference/configure).
Signature [#signature]
```kotlin
class SuperwallOptions {
var paywalls: PaywallOptions = PaywallOptions()
var shouldObservePurchases: Boolean = false
var networkEnvironment: NetworkEnvironment = NetworkEnvironment.Release()
var isExternalDataCollectionEnabled: Boolean = true
var localeIdentifier: String? = null
var isGameControllerEnabled: Boolean = false
var passIdentifiersToPlayStore: Boolean = false
var enableExperimentalDeviceVariables: Boolean = false
var logging: Logging = Logging()
var useMockReviews: Boolean = false
var testModeBehavior: TestModeBehavior = TestModeBehavior.AUTOMATIC
}
```
```java
// Java
public class SuperwallOptions {
public PaywallOptions paywalls = new PaywallOptions();
public boolean shouldObservePurchases = false;
public NetworkEnvironment networkEnvironment = new NetworkEnvironment.Release();
public boolean isExternalDataCollectionEnabled = true;
public @Nullable String localeIdentifier = null;
public boolean isGameControllerEnabled = false;
public boolean passIdentifiersToPlayStore = false;
public boolean enableExperimentalDeviceVariables = false;
public Logging logging = new Logging();
public boolean useMockReviews = false;
public TestModeBehavior testModeBehavior = TestModeBehavior.AUTOMATIC;
}
```
Parameters [#parameters]
How `passIdentifiersToPlayStore` affects Google Play [#how-passidentifierstoplaystore-affects-google-play]
Superwall always calls `BillingFlowParams.Builder.setObfuscatedAccountId(Superwall.instance.externalAccountId)` when launching a billing flow.
* **Default (`false`)** – `externalAccountId` is a SHA-256 hash of the `userId`. Google Play displays the hashed value as `obfuscatedExternalAccountId`, and the same hash is sent back to your servers.
* **Enabled (`true`)** – Superwall forwards the exact `appUserId` you passed to [`identify()`](/docs/android/sdk-reference/identify). This makes it easier to correlate Google Play purchases with your users, but the value must comply with [Google's policy](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedAccountId) and must not contain PII.
Configure it as part of your application startup:
```kotlin
val options = SuperwallOptions().apply {
passIdentifiersToPlayStore = true
logging.level = LogLevel.info
}
Superwall.configure(
application = this,
apiKey = "pk_your_api_key",
options = options
)
```
Related [#related]
* [`PaywallOptions`](/docs/android/sdk-reference/PaywallOptions)
# PaywallBuilder
You're responsible for managing the lifecycle of the returned PaywallView. Do not use the same PaywallView instance in multiple places simultaneously.
The remotely configured presentation style is ignored when using this method. You must handle presentation styling programmatically.
Purpose [#purpose]
Creates a PaywallView that you can present however you want, bypassing Superwall's automatic presentation logic.
Signature [#signature]
```kotlin
class PaywallBuilder(private val placement: String) {
fun params(params: Map?): PaywallBuilder
fun overrides(overrides: PaywallOverrides?): PaywallBuilder
fun delegate(delegate: PaywallViewCallback): PaywallBuilder
fun activity(activity: Activity): PaywallBuilder
suspend fun build(): Result
fun buildSync(): PaywallView
fun build(onSuccess: (PaywallView) -> Unit, onError: (Throwable) -> Unit)
}
```
Parameters [#parameters]
Returns / State [#returns--state]
Returns a `Result` that you can add to your view hierarchy. If presentation should be skipped, returns a failure result.
Usage [#usage]
Using with coroutines:
```kotlin
lifecycleScope.launch {
val result = PaywallBuilder("premium_feature")
.params(mapOf("source" to "settings"))
.delegate(object : PaywallViewCallback {
override fun onFinished(
paywall: PaywallView,
result: PaywallResult,
shouldDismiss: Boolean
) {
// Handle paywall completion
}
})
.activity(this@MainActivity)
.build()
result.fold(
onSuccess = { paywallView ->
binding.container.addView(paywallView)
},
onFailure = { error ->
println("Error creating paywall: ${error.message}")
}
)
}
```
Jetpack Compose integration:
```kotlin
@Composable
fun PaywallScreen() {
PaywallComposable(
placement = "premium_feature",
params = mapOf("source" to "settings"),
delegate = object : PaywallViewCallback {
override fun onFinished(
paywall: PaywallView,
result: PaywallResult,
shouldDismiss: Boolean
) {
// Handle completion
}
},
errorComposable = { error ->
Text("Failed to load paywall: ${error.message}")
},
loadingComposable = {
CircularProgressIndicator()
}
)
}
```
# setSubscriptionStatus()
This function should only be used when implementing a custom [`PurchaseController`](/docs/android/sdk-reference/PurchaseController). When using Superwall's built-in purchase handling, the subscription status is managed automatically.
You must call this function whenever the user's entitlements change to keep Superwall's subscription status synchronized with your purchase system.
Purpose [#purpose]
Manually updates the subscription status when using a custom [`PurchaseController`](/docs/android/sdk-reference/PurchaseController) to ensure paywall gating and analytics work correctly.
Signature [#signature]
```kotlin
fun Superwall.setSubscriptionStatus(status: SubscriptionStatus)
```
```java
// Java
public void setSubscriptionStatus(SubscriptionStatus status)
```
Parameters [#parameters]
Returns / State [#returns--state]
This function returns `Unit`. The new status will be reflected in the [`subscriptionStatus`](/docs/android/sdk-reference/subscriptionStatus) StateFlow and will trigger the [`SuperwallDelegate.subscriptionStatusDidChange`](/docs/android/sdk-reference/SuperwallDelegate) callback.
Usage [#usage]
Set active subscription with entitlements:
```kotlin
// User purchased premium subscription
Superwall.instance.setSubscriptionStatus(
SubscriptionStatus.Active(setOf("premium", "pro_features"))
)
```
Set inactive subscription:
```kotlin
// User's subscription expired or was cancelled
Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Inactive)
```
Set unknown status during initialization:
```kotlin
// While checking subscription status on app launch
Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Unknown)
```
Usage with RevenueCat:
```kotlin
class RevenueCatPurchaseController : PurchaseController {
override suspend fun purchase(
activity: Activity,
product: StoreProduct
): PurchaseResult {
return try {
val result = Purchases.sharedInstance.purchase(activity, product.sku)
// Update Superwall subscription status based on RevenueCat result
if (result.isSuccessful) {
val entitlements = result.customerInfo.entitlements.active.keys
Superwall.instance.setSubscriptionStatus(
SubscriptionStatus.Active(entitlements)
)
PurchaseResult.Purchased
} else {
PurchaseResult.Failed(Exception("Purchase failed"))
}
} catch (e: Exception) {
PurchaseResult.Failed(e)
}
}
override suspend fun restorePurchases(): RestorationResult {
return try {
val customerInfo = Purchases.sharedInstance.restorePurchases()
val activeEntitlements = customerInfo.entitlements.active.keys
if (activeEntitlements.isNotEmpty()) {
Superwall.instance.setSubscriptionStatus(
SubscriptionStatus.Active(activeEntitlements)
)
} else {
Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Inactive)
}
RestorationResult.Restored
} catch (e: Exception) {
RestorationResult.Failed(e)
}
}
}
```
Listen for external subscription changes:
```kotlin
class SubscriptionManager {
fun onSubscriptionStatusChanged(isActive: Boolean, entitlements: Set) {
val status = if (isActive) {
SubscriptionStatus.Active(entitlements)
} else {
SubscriptionStatus.Inactive
}
// Update Superwall whenever subscription status changes externally
Superwall.instance.setSubscriptionStatus(status)
}
}
```
Java usage:
```java
// Set active subscription
Set entitlements = Set.of("premium", "pro_features");
Superwall.getInstance().setSubscriptionStatus(
new SubscriptionStatus.Active(entitlements)
);
// Set inactive subscription
Superwall.getInstance().setSubscriptionStatus(
SubscriptionStatus.Inactive.INSTANCE
);
```
# configure()
This is a static method called on the `Superwall` class itself, not on the shared instance. The Android SDK requires an Application context for initialization.
Purpose [#purpose]
Configures the shared instance of Superwall with your API key and optional configurations, making it ready for use throughout your Android app.
```kotlin Android
public fun configure(
applicationContext: Application,
apiKey: String,
purchaseController: PurchaseController? = null,
options: SuperwallOptions? = null,
activityProvider: ActivityProvider? = null,
completion: ((Result) -> Unit)? = null
)
```
Parameters [#parameters]
Returns / State [#returns--state]
Configures the Superwall instance which is accessible via [`Superwall.instance`](/docs/android/sdk-reference/Superwall).
```kotlin Android
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
Superwall.configure(
applicationContext = this,
apiKey = "pk_your_api_key"
)
}
}
```
With custom options:
```kotlin
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
val options = SuperwallOptions().apply {
paywalls.shouldShowPurchaseFailureAlert = false
}
Superwall.configure(
applicationContext = this,
apiKey = "pk_your_api_key",
options = options
) {
println("Superwall configured successfully")
}
}
}
```
# getPresentationResult()
Purpose [#purpose]
Retrieves the presentation result for a placement without presenting the paywall. Call this when you need to know whether a placement would show a paywall, send the user to a holdout, or fail due to missing configuration before you decide how to render UI.
Signature [#signature]
```kotlin
suspend fun Superwall.getPresentationResult(
placement: String,
params: Map? = null,
): Result
```
```kotlin
fun Superwall.getPresentationResultSync(
placement: String,
params: Map? = null,
): Result
```
`getPresentationResultSync` blocks the calling thread. Prefer the suspend function inside a coroutine whenever possible.
Parameters [#parameters]
Returns / State [#returns--state]
Returns a Kotlin `Result`.
* On success, the wrapped `PresentationResult` can be:
* `PresentationResult.PlacementNotFound` – Placement is missing from any live campaign.
* `PresentationResult.NoAudienceMatch` – No audience matched, so nothing would show.
* `PresentationResult.Paywall(experiment)` – A paywall would be presented; inspect the `experiment`.
* `PresentationResult.Holdout(experiment)` – The user is in a holdout group for that experiment.
* `PresentationResult.PaywallNotAvailable` – The SDK could not present (no activity, already showing, offline, etc.).
* On failure, the `Result` contains the thrown exception (for example, the SDK is not configured yet). Inspect it with `exceptionOrNull()` or `onFailure`.
Usage [#usage]
```kotlin
lifecycleScope.launch {
val result = Superwall.instance.getPresentationResult(
placement = "premium_feature",
params = mapOf("source" to "settings")
)
result
.onSuccess { presentation ->
when (presentation) {
is PresentationResult.Paywall -> {
logExperiment(presentation.experiment)
showLockedState()
}
is PresentationResult.Holdout -> showHoldoutBanner()
is PresentationResult.NoAudienceMatch -> unlockFeature()
is PresentationResult.PlacementNotFound -> Timber.w("Missing placement configuration")
is PresentationResult.PaywallNotAvailable -> showOfflineMessage()
}
}
.onFailure { error ->
Timber.e(error, "Unable to fetch presentation result")
}
}
```
```kotlin
// Blocking usage (for example, inside a worker)
val result = Superwall.instance.getPresentationResultSync("premium_feature")
val presentation = result.getOrNull() ?: return
```
Related [#related]
* [`register()`](/docs/android/sdk-reference/register) – Registers a placement and may present a paywall.
* [`SuperwallDelegate`](/docs/android/sdk-reference/SuperwallDelegate) – Receive callbacks when paywalls are presented.
# handleDeepLink()
Configure deep link campaigns on the Superwall dashboard by adding the `deepLink` event to a campaign trigger.
Deep link events are also tracked via [`SuperwallEvent.DeepLink`](/docs/android/sdk-reference/SuperwallEvent) and sent to your [`SuperwallDelegate`](/docs/android/sdk-reference/SuperwallDelegate).
Purpose [#purpose]
Processes a deep link URL and triggers any associated paywall campaigns configured on the Superwall dashboard.
Signature [#signature]
```kotlin
fun Superwall.handleDeepLink(url: String)
```
```java
// Java
public void handleDeepLink(String url)
```
Parameters [#parameters]
Returns / State [#returns--state]
This function returns `Unit`. If the URL matches a campaign configured on the dashboard, it may trigger a paywall presentation.
Usage [#usage]
In your Activity:
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Handle deep link on app launch
intent?.let { handleIntent(it) }
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
intent?.let { handleIntent(it) }
}
private fun handleIntent(intent: Intent) {
val action = intent.action
val data = intent.data
if (Intent.ACTION_VIEW == action && data != null) {
val url = data.toString()
// Handle the deep link with Superwall
Superwall.instance.handleDeepLink(url)
// Continue with your app's deep link handling
handleAppDeepLink(url)
}
}
}
```
In your manifest (declare your deep link schemes):
```xml
```
Handle deep links in different scenarios:
```kotlin
fun handleDeepLinkFromNotification(url: String) {
// Handle deep link from push notification
Superwall.instance.handleDeepLink(url)
navigateToContent(url)
}
fun handleDeepLinkFromWebView(url: String) {
// Handle deep link from web view or external browser
Superwall.instance.handleDeepLink(url)
processWebViewDeepLink(url)
}
```
Java usage:
```java
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
handleIntent(intent);
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
handleIntent(intent);
}
private void handleIntent(Intent intent) {
String action = intent.getAction();
Uri data = intent.getData();
if (Intent.ACTION_VIEW.equals(action) && data != null) {
String url = data.toString();
// Handle the deep link with Superwall
Superwall.getInstance().handleDeepLink(url);
// Continue with your app's deep link handling
handleAppDeepLink(url);
}
}
}
```
Example deep link campaign setup:
```kotlin
// When users click a deep link like "myapp://premium-feature"
// The dashboard campaign can be configured to:
// 1. Track the deepLink event
// 2. Show a paywall if user is not subscribed
// 3. Direct to premium content if user is subscribed
Superwall.instance.handleDeepLink("myapp://premium-feature?source=email")
```
# identify()
Call this as soon as you have a user ID, typically after login or when the user's identity becomes available.
Purpose [#purpose]
Links a user ID to Superwall's automatically generated alias, creating an account for analytics and personalization.
Signature [#signature]
```kotlin
fun Superwall.identify(
userId: String,
options: IdentityOptions? = null
)
```
```java
// Java
public void identify(
String userId,
@Nullable IdentityOptions options
)
```
Parameters [#parameters]
Returns / State [#returns--state]
This function returns `Unit`. After calling, [`isLoggedIn`](/docs/android/sdk-reference/userId) will return `true` and [`userId`](/docs/android/sdk-reference/userId) will return the provided user ID.
Usage [#usage]
Basic identification:
```kotlin
Superwall.instance.identify("user_12345")
```
With options for account switching scenarios:
```kotlin
val options = IdentityOptions().apply {
restorePaywallAssignments = true
}
Superwall.instance.identify(
userId = "returning_user_67890",
options = options
)
```
Call as soon as you have a user ID:
```kotlin
fun userDidLogin(user: User) {
Superwall.instance.identify(user.id)
// Set additional user attributes
Superwall.instance.setUserAttributes(mapOf(
"email" to user.email,
"plan" to user.subscriptionPlan,
"signUpDate" to user.createdAt
))
}
```
Java usage:
```java
// Basic identification
Superwall.getInstance().identify("user_12345");
// With options
IdentityOptions options = new IdentityOptions();
options.setRestorePaywallAssignments(true);
Superwall.getInstance().identify("returning_user_67890", options);
```
# Overview
Welcome to the Superwall Android SDK Reference [#welcome-to-the-superwall-android-sdk-reference]
You can find the source code for the SDK [on GitHub](https://github.com/superwall/Superwall-Android) along with our [example app](https://github.com/superwall/Superwall-Android/tree/develop/example).
Feedback [#feedback]
We are always improving our SDKs and documentation!
If you have feedback on any of our docs, please leave a rating and message at the bottom of the page.
If you have any issues with the SDK, please [open an issue on GitHub](https://github.com/superwall/superwall-android/issues).
# localResources
Available in Android SDK `2.7.7+`.
Purpose [#purpose]
`localResources` lets you map paywall asset IDs to local Android resources or file URIs. Paywalls can then request those assets with `swlocal://resource-id` instead of downloading them from a remote URL.
Signature [#signature]
```kotlin
var localResources: Map
```
```java
public Map getLocalResources()
public void setLocalResources(Map localResources)
```
Schema [#schema]
PaywallResource variants [#paywallresource-variants]
Returns / State [#returns--state]
This is a mutable property on [`Superwall.instance`](/docs/android/sdk-reference/Superwall). Set it before presenting paywalls that depend on local assets.
Related [#related]
* [Local Resources guide](/docs/android/guides/local-resources)
# register()
Purpose [#purpose]
Registers a placement so that when it's added to a campaign on the Superwall Dashboard, it can trigger a paywall and optionally gate access to a feature.
Signature [#signature]
```kotlin
fun Superwall.register(
placement: String,
params: Map? = null,
handler: PaywallPresentationHandler? = null,
feature: () -> Unit,
)
```
```kotlin
fun Superwall.register(
placement: String,
params: Map? = null,
handler: PaywallPresentationHandler? = null,
)
```
Parameters [#parameters]
Returns / State [#returns--state]
This function returns `Unit`. If you supply a `feature` lambda, it will be executed according to the paywall's gating configuration, as described above.
Usage [#usage]
```swift iOS
func pressedWorkoutButton() {
// remotely decide if a paywall is shown and if
// navigation.startWorkout() is a paid-only feature
Superwall.shared.register(placement: "StartWorkout") {
navigation.startWorkout()
}
}
```
```kotlin Android
fun pressedWorkoutButton() {
// remotely decide if a paywall is shown and if
// navigation.startWorkout() is a paid-only feature
Superwall.instance.register("StartWorkout") {
navigation.startWorkout()
}
}
```
```dart Flutter
void pressedWorkoutButton() {
// remotely decide if a paywall is shown and if
// navigation.startWorkout() is a paid-only feature
Superwall.shared.registerPlacement('StartWorkout', feature: () {
navigation.startWorkout();
});
}
```
```typescript React Native
// remotely decide if a paywall is shown and if
// navigation.startWorkout() is a paid-only feature
Superwall.shared.register({
placement: 'StartWorkout',
feature: () => {
navigation.navigate('LaunchedFeature', {
value: 'Non-gated feature launched',
});
}
});
```
Register without feature gating:
```kotlin
Superwall.instance.register(
placement = "onboarding_complete",
params = mapOf("source" to "onboarding"),
handler = this
)
```
# setUserAttributes()
These attributes should not be used as a source of truth for sensitive information.
Keys beginning with `$` are reserved for Superwall internal use and will be ignored. Arrays and nested maps are not supported as values.
Purpose [#purpose]
Sets custom user attributes that can be used in paywall personalization, audience filters, and analytics on the Superwall dashboard.
Signature [#signature]
```kotlin
fun Superwall.setUserAttributes(attributes: Map)
```
```java
// Java
public void setUserAttributes(Map attributes)
```
Parameters [#parameters]
Returns / State [#returns--state]
This function returns `Unit`. If an attribute already exists, its value will be overwritten while other attributes remain unchanged.
Usage [#usage]
Set multiple user attributes:
```kotlin
val attributes = mapOf(
"name" to "John Doe",
"email" to "john@example.com",
"plan" to "premium",
"signUpDate" to System.currentTimeMillis(),
"profilePicUrl" to "https://example.com/pic.jpg",
"isVip" to true,
"loginCount" to 42
)
Superwall.instance.setUserAttributes(attributes)
```
Set individual attributes over time:
```kotlin
Superwall.instance.setUserAttributes(mapOf("lastActiveDate" to System.currentTimeMillis()))
Superwall.instance.setUserAttributes(mapOf("featureUsageCount" to 15))
```
Remove an attribute by setting it to null:
```kotlin
Superwall.instance.setUserAttributes(mapOf("temporaryFlag" to null))
```
Real-world example after user updates profile:
```kotlin
fun updateUserProfile(user: User) {
Superwall.instance.setUserAttributes(mapOf(
"name" to user.displayName,
"avatar" to user.avatarURL,
"preferences" to user.notificationPreferences,
"lastUpdated" to System.currentTimeMillis()
))
}
```
Java usage:
```java
// Set multiple attributes
Map attributes = new HashMap<>();
attributes.put("name", "John Doe");
attributes.put("email", "john@example.com");
attributes.put("plan", "premium");
attributes.put("loginCount", 42);
Superwall.getInstance().setUserAttributes(attributes);
```
# subscriptionStatus
If you're using a custom [`PurchaseController`](/docs/android/sdk-reference/PurchaseController), you must update this property whenever the user's entitlements change.
You can also observe changes via the [`SuperwallDelegate`](/docs/android/sdk-reference/SuperwallDelegate) method `subscriptionStatusDidChange(from, to)`.
Purpose [#purpose]
Indicates the current subscription status of the user and can be observed for changes using Kotlin StateFlow.
Signature [#signature]
```kotlin
val subscriptionStatus: StateFlow
// For setting the status (when using custom PurchaseController)
fun setSubscriptionStatus(status: SubscriptionStatus)
```
```java
// Java
public StateFlow getSubscriptionStatus()
public void setSubscriptionStatus(SubscriptionStatus status)
```
Parameters [#parameters]
This property accepts a `SubscriptionStatus` sealed class value:
* `SubscriptionStatus.Unknown` - Status is not yet determined
* `SubscriptionStatus.Active(Set)` - User has active entitlements (set of entitlement identifiers)
* `SubscriptionStatus.Inactive` - User has no active entitlements
Returns / State [#returns--state]
Returns a `StateFlow` that emits the current subscription status. When using a [`PurchaseController`](/docs/android/sdk-reference/PurchaseController), you must set this property yourself using `setSubscriptionStatus()`. Otherwise, Superwall manages it automatically.
Usage [#usage]
Set subscription status (when using PurchaseController):
```kotlin
Superwall.instance.setSubscriptionStatus(
SubscriptionStatus.Active(setOf("premium", "pro_features"))
)
Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Inactive)
```
Get current subscription status:
```kotlin
val status = Superwall.instance.subscriptionStatus.value
when (status) {
is SubscriptionStatus.Unknown ->
println("Subscription status unknown")
is SubscriptionStatus.Active ->
println("User has active entitlements: ${status.entitlements}")
is SubscriptionStatus.Inactive ->
println("User has no active subscription")
}
```
Observe changes with StateFlow:
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
Superwall.instance.subscriptionStatus.collect { status ->
updateUI(status)
}
}
}
private fun updateUI(status: SubscriptionStatus) {
when (status) {
is SubscriptionStatus.Active -> showPremiumContent()
is SubscriptionStatus.Inactive -> showFreeContent()
is SubscriptionStatus.Unknown -> showLoadingState()
}
}
}
```
Jetpack Compose observation:
```kotlin
@Composable
fun ContentScreen() {
val subscriptionStatus by Superwall.instance.subscriptionStatus
.collectAsState()
Column {
when (subscriptionStatus) {
is SubscriptionStatus.Active -> {
Text("Premium user with: ${subscriptionStatus.entitlements.joinToString()}")
}
is SubscriptionStatus.Inactive -> {
Text("Free user")
}
is SubscriptionStatus.Unknown -> {
Text("Loading...")
}
}
}
}
```
Java usage:
```java
// Get current status
SubscriptionStatus status = Superwall.getInstance()
.getSubscriptionStatus().getValue();
// Observe changes
Superwall.getInstance().getSubscriptionStatus()
.observe(this, status -> {
updateUI(status);
});
// Set status (when using PurchaseController)
Superwall.getInstance().setSubscriptionStatus(
new SubscriptionStatus.Active(Set.of("premium"))
);
```
# userId
The anonymous user ID is automatically generated and persisted to disk, so it remains consistent across app launches until the user is identified.
Purpose [#purpose]
Returns the current user's unique identifier, either from a previous call to [`identify()`](/docs/android/sdk-reference/identify) or an anonymous ID if not identified.
Signature [#signature]
```kotlin
// Accessed via Superwall.instance
val userId: String
```
```java
// Java
public String getUserId()
```
Parameters [#parameters]
This is a read-only property on the [`Superwall.instance`](/docs/android/sdk-reference/Superwall) with no parameters.
Returns / State [#returns--state]
Returns a `String` representing the user's ID. If [`identify()`](/docs/android/sdk-reference/identify) has been called, returns that user ID. Otherwise, returns an automatically generated anonymous user ID that is cached to disk.
Usage [#usage]
Get the current user ID:
```kotlin
val currentUserId = Superwall.instance.userId
println("User ID: $currentUserId")
```
Check if user is identified:
```kotlin
if (Superwall.instance.isLoggedIn) {
println("User is identified with ID: ${Superwall.instance.userId}")
} else {
println("User is anonymous with ID: ${Superwall.instance.userId}")
}
```
Example usage in analytics:
```kotlin
fun trackAnalyticsEvent() {
val userId = Superwall.instance.userId
Analytics.track("feature_used", mapOf(
"user_id" to userId,
"timestamp" to System.currentTimeMillis()
))
}
```
Example usage in custom logging:
```kotlin
fun logError(error: Throwable) {
Logger.log("Error for user ${Superwall.instance.userId}: ${error.message}")
}
```
Java usage:
```java
// Get current user ID
String currentUserId = Superwall.getInstance().getUserId();
System.out.println("User ID: " + currentUserId);
// Check if user is identified
if (Superwall.getInstance().isLoggedIn()) {
System.out.println("User is identified with ID: " +
Superwall.getInstance().getUserId());
} else {
System.out.println("User is anonymous with ID: " +
Superwall.getInstance().getUserId());
}
```
# Changelog
# Capacitor
**Community SDK**
This SDK is developed and maintained by [Capawesome](https://capawesome.io), not by Superwall. For support, please visit the [official documentation](https://capawesome.io/plugins/superwall/).
The [Capacitor Superwall plugin](https://capawesome.io/plugins/superwall/) by [Capawesome](https://capawesome.io) enables you to integrate Superwall into your Capacitor apps for iOS and Android.
Installation [#installation]
Install the plugin via npm:
```bash
npm install @capawesome/capacitor-superwall
npx cap sync
```
Android Setup [#android-setup]
Add the following activities to your `AndroidManifest.xml` inside the `` tag:
```xml
```
Configuration [#configuration]
Initialize the SDK with your API key:
```typescript
import { Superwall } from '@capawesome/capacitor-superwall';
await Superwall.configure({
apiKey: 'YOUR_API_KEY',
options: {
paywalls: {
shouldPreload: true,
automaticallyDismiss: true
},
logging: {
level: 'WARN',
scopes: ['ALL']
}
}
});
```
Core Methods [#core-methods]
| Method | Description |
| ------------------------- | ----------------------------------------- |
| `configure()` | Initialize the SDK with your API key |
| `register()` | Present a paywall for a placement |
| `getPresentationResult()` | Check if a paywall would display |
| `identify()` | Associate a user ID with the current user |
| `reset()` | Clear user identity on logout |
| `setUserAttributes()` | Set custom user attributes |
| `getSubscriptionStatus()` | Get the current subscription status |
Event Listeners [#event-listeners]
The plugin supports the following event listeners:
* `superwallEvent` - Analytics events
* `subscriptionStatusDidChange` - Subscription status changes
* `paywallPresented` - Paywall displayed
* `paywallWillDismiss` - Before paywall closes
* `paywallDismissed` - After paywall closes
* `customPaywallAction` - Custom paywall element interactions
Full Documentation [#full-documentation]
View the complete API reference and detailed documentation on capawesome.io
# Community SDKs
**Community SDKs**
These SDKs are developed and maintained by the community, not by Superwall. For support, please contact the SDK maintainer or visit their official documentation.
Available SDKs [#available-sdks]
Superwall plugin for Capacitor apps by Capawesome. Supports iOS and Android.
# Creating Paywalls
The legacy editor is deprecated. Please visit the docs covering our new
[editor](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-overview).
On the Superwall dashboard under **Paywalls**, click **+ New Paywall** and select **From Template**:
This will redirect you to the templates page where you can browse different types of paywall templates. Clicking on one will allow you to preview it:
Click **Try it**, give it a name, and click **Create**. This will bring you to the paywall editor:
The Paywall Editor consists of 3 sections:
1. [The settings sidebar](/docs/paywall-settings-sidebar) - General paywall settings, designs and products to show on the paywall.
2. [The device preview](/docs/interactive-paywall-preview) - An interactive preview of your paywall in a device of your choosing.
3. [The variable editor](/docs/interactive-paywall-preview#adding-variables) - Fully customise items, such as text or images, on your paywall.
# Assets
Assets is a media library for all of your aywalls. It lets you upload images and videos once, then reuse them. This helps you avoid uploading the same file multiple times or copying URLs out of older paywalls when you want to reuse media.
To open it, click **Assets** in the **sidebar**:
How assets get into the library [#how-assets-get-into-the-library]
* **Upload from Assets:** Open **Assets** from the sidebar, then click **Add Assets** or drag files onto the page.
* **Upload from the editor:** Upload an image or video from a paywall field and Superwall adds it to **Assets** automatically.
* **Discover existing media:** Use **Discover Assets** to scan existing paywalls and add images and videos that are already in use.
Upload assets from the Assets page [#upload-assets-from-the-assets-page]
* **Open Assets:** Click **Assets** in the sidebar for the currently selected app.
* **Add files:** Click **Add Assets**, or drag and drop image and video files into the page.
* **Wait for the upload to finish:** Uploaded files appear in the library when the upload completes.
Discover assets from existing paywalls [#discover-assets-from-existing-paywalls]
Use this if you already have paywalls with media and want to build the library from what is already in use.
* **Open the menu:** Click the menu in the top-right corner of the **Assets** page.
* **Run discovery:** Click **Discover Assets**.
* **Review the results:** Superwall scans existing unarchived paywalls and adds any images and videos it finds to the library.
Discovering assets does not change any existing paywalls. It only creates asset entries so those files can be reused later.
Use an asset in a paywall [#use-an-asset-in-a-paywall]
* **Select a component:** In the paywall editor, select an image or video component.
* **Open the asset picker:** Click the bookmark button next to the source field to open **Pick from Assets**.
* **Choose an asset:** Search or browse the library, then select the asset you want to use.
* **Save the paywall:** The selected asset is applied to that component.
For videos, the asset picker sets the main video source. Configure the thumbnail separately in the video settings if needed.
Find and review assets [#find-and-review-assets]
* **Filter by type:** Switch between **All**, **Images**, and **Videos**.
* **Search by name:** Use the search field to find a specific asset.
* **Preview an asset:** Click an asset card to open a larger preview and view its details.
* **Download an asset:** Use the asset menu or preview dialog to download the file.
Delete assets [#delete-assets]
* **Remove it from the library:** Use the asset menu and click **Delete**.
* **Keep existing paywalls working:** Deleting an asset does not remove it from paywalls that already use it. It only removes it from the reusable library.
Things to know [#things-to-know]
* **Supported media:** Assets supports images and videos.
* **Selected app context:** The library you see is based on the app you have selected.
* **Project sharing:** If the selected app belongs to a project, the same asset library is shared across the apps in that project.
* **No automatic replacements:** Uploading, discovering, or deleting an asset does not update existing paywalls automatically.
# Active Subscriptions
What it shows [#what-it-shows]
Active Subscriptions shows the count of unexpired, paid subscriptions over time.
How to use it [#how-to-use-it]
Use this chart to track the size of your paying subscriber base. It is one of the clearest ways to see whether new subscriptions and renewals are outpacing expirations.
Good to know [#good-to-know]
Active Subscriptions does not tell the full story by itself. Pair it with MRR, Subscriber Churn, and Auto Renew Status to understand revenue and retention quality.
# Active Users
What it shows [#what-it-shows]
Active Users shows the count of users who were active during each period.
How to use it [#how-to-use-it]
Use this chart to understand audience size and engagement. It helps explain changes in paywall exposure, conversion volume, and revenue that may be driven by app usage.
Good to know [#good-to-know]
Active Users is a useful denominator when reviewing monetization. If revenue is flat while active users grow, look at paywall exposure and conversion rates next.
# ARR
What it shows [#what-it-shows]
ARR shows annualized recurring revenue from subscriptions. It normalizes subscription revenue into an annual view.
How to use it [#how-to-use-it]
Use ARR to understand your recurring revenue run rate and how it changes over time. It is helpful when you want a longer-range view than monthly movement can provide.
Good to know [#good-to-know]
ARR is an annualized metric, so use it for directional recurring revenue trends instead of short-term cash timing.
# Auto Renew Status
What it shows [#what-it-shows]
Auto Renew Status shows how much MRR is currently set to renew versus churn based on subscription renewal status.
How to use it [#how-to-use-it]
Use this chart to understand renewal risk before subscriptions expire. It is useful for spotting periods where a larger share of recurring revenue is at risk.
Good to know [#good-to-know]
Use Auto Renew Status alongside Subscriber Churn. One shows renewal intent before expiration, while the other shows subscriptions that actually expired.
# Checkout Conversion
What it shows [#what-it-shows]
Checkout Conversion shows the percentage of users who converted after starting checkout.
How to use it [#how-to-use-it]
Use this chart to spot purchase flow friction. If users start checkout but do not complete it, the issue may be closer to product selection, store purchase flow, payment state, or purchase restoration.
Good to know [#good-to-know]
Compare Checkout Conversion with Paywall Conversion. A drop in checkout conversion points later in the funnel than a drop in paywall conversion.
# Cohorted Proceeds
What it shows [#what-it-shows]
Cohorted Proceeds shows net proceeds grouped by install date. Each cohort represents users who installed during the same period.
How to use it [#how-to-use-it]
Use this chart to understand how install cohorts monetize over time. It is a good fit for comparing acquisition quality, measuring payback windows, and reviewing the impact of product or campaign changes by cohort.
Good to know [#good-to-know]
Pair this chart with acquisition spend when you want to understand whether new cohorts are earning back what you spent to acquire them.
# Conversions
What it shows [#what-it-shows]
Conversions shows the count of completed transactions.
How to use it [#how-to-use-it]
Use this chart to track purchase volume over time. It is useful for reviewing campaign launches, product changes, offer changes, and seasonal traffic patterns.
Good to know [#good-to-know]
Conversion count is volume, not rate. Pair it with Paywall Conversion and New Users when you need to understand whether performance changed because of traffic or efficiency.
# Charts
To view charts breaking down your app's performance, click the **Charts** button in the **sidebar**:
Check out a video overview of our charts on [YouTube](https://youtu.be/7UIO99LSvTQ).
Chart types [#chart-types]
Choose between different charts by making a selection from the left sidebar:
You can also toggle which charts are showing by using the chevrons on the left-hand side.
Currently, we offer the following charts:
Revenue Charts [#revenue-charts]
* [**Proceeds**](/docs/dashboard/charts/proceeds): Revenue after refunds, store fees, and taxes.
* [**Sales**](/docs/dashboard/charts/sales): Revenue before refunds, taxes, and fees.
* [**Cohorted Proceeds**](/docs/dashboard/charts/cohorted-proceeds): Net proceeds cohorted by install date, after refunds, taxes, and fees.
* [**ARR**](/docs/dashboard/charts/arr): Annualized recurring revenue from subscriptions.
* [**MRR**](/docs/dashboard/charts/mrr): Monthly recurring revenue from subscriptions.
* [**Realized LTV per new user**](/docs/dashboard/charts/realized-ltv-per-new-user): Net proceeds per new user, cohorted by install date.
* [**Realized LTV per paid user**](/docs/dashboard/charts/realized-ltv-per-paid-user): Net proceeds per paying user, cohorted by install date.
Subscription Charts [#subscription-charts]
* [**Active Subscriptions**](/docs/dashboard/charts/active-subscriptions): Count of unexpired, paid subscriptions.
* [**Paid Conversion**](/docs/dashboard/charts/paid-conversion): Percent of installs who became paying users.
* [**New Trials**](/docs/dashboard/charts/new-trials): Trial starts, cohorted by trial start date.
* [**Trial Conversion**](/docs/dashboard/charts/trial-conversion): Percentage of trials that converted to paid subscriptions.
* [**New Subscriptions**](/docs/dashboard/charts/new-subscriptions): New subscriptions, cohorted by subscription start date.
* [**Auto Renew Status**](/docs/dashboard/charts/auto-renew-status): How much of your MRR is set to renew versus churn.
Paywall Charts [#paywall-charts]
* [**Initial Conversion**](/docs/dashboard/charts/initial-conversion): Percent of new users who converted on a paywall.
* [**Paywalled Users**](/docs/dashboard/charts/paywalled-users): Count of unique users who opened paywalls.
* [**Paywall Rate**](/docs/dashboard/charts/paywall-rate): Percent of new users who opened paywalls.
* [**Paywall Conversion**](/docs/dashboard/charts/paywall-conversion): Percent of users who converted after opening a paywall.
* [**Conversions**](/docs/dashboard/charts/conversions): Count of completed transactions.
* [**Checkout Conversion**](/docs/dashboard/charts/checkout-conversion): Percentage of users who converted after starting checkout.
User Charts [#user-charts]
* [**New Users**](/docs/dashboard/charts/new-users): Count of new users.
* [**Active Users**](/docs/dashboard/charts/active-users): Count of active users.
Retention & Churn Charts [#retention--churn-charts]
* [**Subscriber Churn**](/docs/dashboard/charts/subscriber-churn): Percentage of paid subscriptions that expired in each period.
* [**Refund Rate**](/docs/dashboard/charts/refund-rate): Ratio of refunds to gross proceeds, cohorted by first purchase date.
* [**Subscription Retention**](/docs/dashboard/charts/subscription-retention): Subscription retention by cohort over time.
Filtering chart data [#filtering-chart-data]
To filter data on a chart, **click** the **Filter** button at the top right:
Filter data by choosing a filter type and **clicking** on **+ Add Filter** to apply it. You can add one, or several, filters:
When you're done, **click** on the **Apply** button, and the chart will refresh with the data filtered by your selections:
To remove an individual filter, **click** on the **trash can** icon on the trailing side of it:
To remove an individual component that's part of a filter, such as breaking down by **Application** and removing one included app, **click** on the **X** button on its trailing side:
To remove all filters, **click** on the **Clear Filters** button:
Breaking down chart data [#breaking-down-chart-data]
To break down data in a chart, **click** the **Breakdown** toggle at the top right:
The breakdowns available are tailored to the type of chart you have selected. After you apply a selection, the chart and the table below it update automatically:
Breaking down a
**Proceeds**
chart by
**Placements**
is a powerful way to directly correlate features you make, or similar things you've paywalled, to your app's revenue growth.
Selecting time ranges [#selecting-time-ranges]
To customize the time span and level of detail of the data displayed on the chart, use the two date toggles at the top right:
These controls adjust the chart's view interval and data range:
**Display Interval:** Sets the interval at which data is displayed on the chart. Choose options like hourly, daily, weekly, or monthly to adjust how granular the chart appears. Selecting **Auto** automatically optimizes the interval based on the selected date range.
**Data Fetch Range:** Defines the total date range used to populate the chart. Options include **Yesterday**, **Last 7 Days**, **Last 30 Days**, and more.
You can also use natural language to set a data fetch range. For example, "last month", "two weeks ago", or similar ranges.
Changing chart formats [#changing-chart-formats]
Each chart type can display its data in different chart formats. To change the default display, **click** on the **Chart** button found at the top right:
You can toggle the chart format between **Stacked Area**, **Line**, **Stacked Bar**, or **Bar**. Here is the same chart data in each format:
Additionally, you can hover any chart element to see more details about the data point:
Exporting chart data [#exporting-chart-data]
You can export any chart data as a `.csv` file. Just **click** the **Export** button at the bottom-right of any chart:
# Initial Conversion
What it shows [#what-it-shows]
Initial Conversion shows the percentage of new users who converted on a paywall.
How to use it [#how-to-use-it]
Use this chart to evaluate the first monetization path for new users. It is useful when you are tuning onboarding, first-session paywalls, placements, or introductory offers.
Good to know [#good-to-know]
If Initial Conversion changes, compare it with New Users and Paywall Rate to make sure the shift is not just a change in acquisition volume or paywall exposure.
# MRR
What it shows [#what-it-shows]
MRR shows monthly recurring revenue from subscriptions. It normalizes subscription revenue into a monthly view.
How to use it [#how-to-use-it]
Use MRR as the baseline for subscription revenue health. It makes it easier to see whether recurring revenue is expanding, flattening, or shrinking over time.
Good to know [#good-to-know]
Review MRR alongside Active Subscriptions and Auto Renew Status to separate subscriber count changes from renewal risk.
# New Subscriptions
What it shows [#what-it-shows]
New Subscriptions shows new subscription starts, grouped by subscription start date.
How to use it [#how-to-use-it]
Use this chart to understand subscription acquisition. It is helpful for reviewing launches, pricing tests, offer changes, and campaign experiments.
Good to know [#good-to-know]
Break this chart down by subscription start type when you want to separate direct paid starts from trial conversions.
# New Trials
What it shows [#what-it-shows]
New Trials shows trial starts, grouped by trial start date.
How to use it [#how-to-use-it]
Use this chart to understand how often users are entering trials. It is especially helpful after changing offers, pricing, trial eligibility, or paywall presentation.
Good to know [#good-to-know]
Trial volume is only one side of the story. Review Trial Conversion to see whether those trials are turning into paid subscriptions.
# New Users
What it shows [#what-it-shows]
New Users shows the count of users who were first seen during each period.
How to use it [#how-to-use-it]
Use this chart to understand acquisition volume. It gives context for revenue, conversion, paywall exposure, and subscription metrics that may move because traffic changed.
Good to know [#good-to-know]
When conversion or revenue rates change, check New Users to see whether the app's audience mix or volume changed at the same time.
# Paid Conversion
What it shows [#what-it-shows]
Paid Conversion shows the percentage of installs that became paying users.
How to use it [#how-to-use-it]
Use this chart to connect acquisition to paid outcomes. It is useful when you want to know whether new users are becoming customers at a healthy rate.
Good to know [#good-to-know]
Paid Conversion sits downstream of several steps. If it drops, compare it against Paywall Rate, Paywall Conversion, Trial Conversion, and Checkout Conversion to find where the funnel changed.
# Paywall Conversion
What it shows [#what-it-shows]
Paywall Conversion shows the percentage of users who converted after opening a paywall.
How to use it [#how-to-use-it]
Use this chart to judge how well paywalls convert the users who actually see them. It is helpful for comparing paywall designs, products, pricing, and experiments.
Good to know [#good-to-know]
Review Paywall Conversion together with Paywall Rate. One tells you how often users see paywalls, and the other tells you how often exposed users convert.
# Paywall Rate
What it shows [#what-it-shows]
Paywall Rate shows the percentage of new users who opened paywalls.
How to use it [#how-to-use-it]
Use this chart to understand how much of your new user base is reaching a monetization moment. It is useful when tuning placements, campaign audiences, onboarding flows, or feature gating.
Good to know [#good-to-know]
A low Paywall Rate can hide a strong paywall. If too few users see it, pair this chart with Paywall Conversion before changing the paywall itself.
# Paywalled Users
What it shows [#what-it-shows]
Paywalled Users shows the count of unique users who opened paywalls.
How to use it [#how-to-use-it]
Use this chart to understand paywall exposure. It helps answer whether enough users are reaching monetization moments before you judge paywall conversion performance.
Good to know [#good-to-know]
Break this chart down by placement or paywall when you want to see which surfaces are driving the most paywall views.
# Proceeds
What it shows [#what-it-shows]
Proceeds shows the revenue your app keeps after refunds, store fees, and taxes.
How to use it [#how-to-use-it]
Use this as your grounded revenue view. It is the chart to reach for when you want to understand how much revenue is actually making it through after deductions, instead of only looking at top-line sales.
Good to know [#good-to-know]
Break proceeds down by renewal type, placement, product, or country when you need to understand which parts of the business are driving net revenue.
# Realized LTV per new user
What it shows [#what-it-shows]
Realized LTV per new user shows net proceeds per new user, cohorted by install date.
How to use it [#how-to-use-it]
Use this chart to understand how much revenue new users have actually generated so far. It is useful for evaluating acquisition quality, pricing changes, and onboarding or paywall changes across install cohorts.
Good to know [#good-to-know]
Newer cohorts have had less time to generate revenue, so compare them carefully against older cohorts.
# Realized LTV per paid user
What it shows [#what-it-shows]
Realized LTV per paid user shows net proceeds per paying user, cohorted by install date.
How to use it [#how-to-use-it]
Use this chart to understand how valuable paying users are after they convert. It helps separate paid customer quality from broader acquisition volume.
Good to know [#good-to-know]
Compare this chart with Realized LTV per new user to see whether changes are coming from conversion rate, paid user value, or both.
# Refund Rate
What it shows [#what-it-shows]
Refund Rate shows refunds as a share of gross proceeds, cohorted by first purchase date.
How to use it [#how-to-use-it]
Use this chart to detect refund spikes and understand whether specific purchase cohorts are refunding at a higher rate. It is helpful after offer, pricing, product, or audience changes.
Good to know [#good-to-know]
If Refund Rate rises while Sales stays healthy, review the matching Proceeds chart to understand the net revenue impact.
# Sales
What it shows [#what-it-shows]
Sales shows gross revenue before refunds, taxes, and store fees are deducted.
How to use it [#how-to-use-it]
Use Sales to understand top-line purchase volume and demand. It is especially useful when you want to compare how much users purchased against what eventually became proceeds.
Good to know [#good-to-know]
If Sales is growing but Proceeds is not, look at refunds, store fees, taxes, and product mix to understand where the gap is coming from.
# Subscriber Churn
What it shows [#what-it-shows]
Subscriber Churn shows the percentage of paid subscriptions that expired during each period.
How to use it [#how-to-use-it]
Use this chart to monitor retention risk and understand when paid subscriptions are dropping out. It is useful after pricing changes, product changes, offer changes, or onboarding updates.
Good to know [#good-to-know]
Compare Subscriber Churn with Auto Renew Status to see whether renewal risk is turning into actual expired subscriptions.
# Subscription Retention
What it shows [#what-it-shows]
Subscription Retention shows how subscription cohorts retain over time.
How to use it [#how-to-use-it]
Use this chart to understand long-term subscriber retention by cohort. It helps you see whether newer cohorts are holding onto subscriptions better or worse than earlier cohorts.
Good to know [#good-to-know]
Newer cohorts have less elapsed time, so their later retention periods may not be complete yet. Use wider date ranges when you need a more mature retention view.
# Trial Conversion
What it shows [#what-it-shows]
Trial Conversion shows the percentage of trials that converted to paid subscriptions.
How to use it [#how-to-use-it]
Use this chart to understand trial quality and renewal behavior. It helps you see whether trial starts are leading to paid outcomes after the trial period ends.
Good to know [#good-to-know]
When trial conversion moves, check the trial length, offer type, onboarding experience, cancellation behavior, and product usage during the trial window.
# Creating Projects
Projects can contain one or more applications. For example, a project for one "app" could have an iOS, Android and web checkout app in the same project. To create a new project, follow these steps:
Open the menu by selecting your existing project from the top-level side of
the sidebar
*You may need to scroll down if you have many apps*
From there, name your app and choose the platform you're building for. You can always add more platforms later.
Superwall can prefill existing iOS apps live on the App Store. If you haven't launched yet, simply choose "Not released yet" and you'll be good to go.
Once you're all done, you should be able to see your new project and its app and switch between
them using the project switcher on the top left that we used to get started! 🎉
# Rules
This page is outdated. Please visit this [one](/docs/dashboard/dashboard-campaigns/campaigns-audience) for the most relevant
information.
1. Rules are evaluated in order.
2. Once a rule is matched, no other rules are evaluated within the campaign.
3. A user's paywall assignment is sticky.
**Assignments Are "Sticky"**
Once a user is assigned a paywall or a holdout within a rule, they will continue to see that assignment, regardless of the paywall's percentage, unless you reset assignments by clicking the reset icon next to Assigned or remove the paywall from the rule via the X button.
Remember: Changing a paywall's percentage only affects **new users**. It doesn't affect assignments for users who already saw that paywall.
This allows you to decide if you should continue showing an old paywall to users who already saw it. For example, you may decide to increase prices but keep the paywall with the old pricing visible for those who've already seen it.
Adding Rules [#adding-rules]
Add a rule to a campaign by clicking the **Add Rule** button from within a [campaign](/docs/dashboard/dashboard-campaigns/campaigns).
Updating Conditions with the Rule Editor [#updating-conditions-with-the-rule-editor]
Change a rule's condition by clicking the highlighted condition itself:
In this example, we add a condition that evaluates to true if user has logged greater than or equal to 3 days.
This opens the **Rule Editor**. Here, you can edit the rule to set conditions based on user, device or event parameters and set a limit to how often the rule is matched:
In this example, only users who have the `en` `deviceLanguageCode` and have a `creator` `account_type` will match this rule. They will only match this rule once every 2 days.
Clicking on the condition reveals a dropdown of possible conditions which you can filter on:
Conditions are added to this list when data is retrieved from the SDK via registering events or setting user attributes. If a condition doesn't yet exist in the drop down, you can manually add it by referencing it with dot syntax. For example, `user.custom_parameter` would reference `custom_parameter` on the `user` object. As with [paywall text variables](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables), the following objects are all available to use:
| Object | Description |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| user | User attributes that you set via the SDK using setUserAttributes(\_:). See [Setting User Attributes](/docs/sdk/quickstart/setting-user-properties) |
| params | Parameters defined when [registering an event](/docs/sdk/quickstart/feature-gating). |
| device | Device attributes that are gathered by the SDK. |
Additionally, you can use the following device properties: `device.minutesSince_X`, `device.hoursSince_X`, `device.daysSince_X`, `device.monthsSince_X`, and `device.yearsSince_X`, where X is the name of an event that you've [registered](/docs/sdk/quickstart/feature-gating) or a [Superwall event](/docs/sdk/guides/3rd-party-analytics/tracking-analytics). This gives you the days etc since the last occurrence of the event that you specify, excluding the event that triggered the paywall. For example, a campaign with an `app_open` event and the rule `device.daysSince_app_open > 3` will present a paywall on app open only if the last `app_open` event was over 3 days ago.
Limit [#limit]
You can also add a limit to how often a rule should trigger. This allows you to
say "show this once per day" or "show this once per week". It allows you to
balance between number of paywall impressions (which increase conversions) with
the potential impact on retention if you show the paywall too often.
Segmenting Users into Cohorts Across Campaigns [#segmenting-users-into-cohorts-across-campaigns]
Users are assigned a random number from 0 to 99 on app install (which is reassigned if you call `reset()`). You can use this to segment users into cohorts across campaigns. For example, in campaign A you may have a rule `if user.seed < 50 { show variant A } else { show variant B }`, in campaign B you may a rule `if user.seed < 50 { show variant X } else { show variant Y }`. Therefore users who see variant A will then see variant X.
Rule Settings [#rule-settings]
The following settings can be access by clicking the ellipse icon to the right of any rule
| Setting | Description |
| --------- | ----------------------------------------------------------------- |
| Move Up | Swaps the rule's order with the rule directly above it. |
| Move Down | Swaps the rule's order with the rule directly below it. |
| Pause | Pauses the rule, preventing it from being evaluated all together. |
| Delete | Deletes the rule. |
# Audiences
Audiences allow you to set up simple or complex filtering rules to match certain users and show a paywall to them. For a user to see a paywall, they must be matched to an audience. An audience can show one or more paywalls based on a percentage you set (i.e. show paywall A to 70% of users, and paywall B to 30%).
**Another way to think of them is this: If you're wanting to create conditions, filters or certain rules or flows that must happen to show a paywall — then you create an audience for it.**
If creating filters to show a paywall under certain conditions doesn't apply to you, then you can simply leave the default audience on — it'll match everyone who hits a [placement](/docs/dashboard/dashboard-campaigns/campaigns-placements).
In the audience view, you can set up filtering rules, check results of experiments and recent transactions resulting from them. All of your current audiences will show in the left-hand side of the campaign details screen:
The audience section lets you [edit the order](#reordering-audiences) in which audiences are evaluated. **Superwall evaluates audiences top-to-bottom.** For example, consider you had three audiences for a caffeine tracking app:
* An audience for users who tried to set a custom app icon.
* An audience for users who've logged caffeine late at night.
* And, everyone else.
If a user logged caffeine in the morning, Superwall would first check if they matched the custom app icon audience, and then the audience for logging caffeine late at night. Since neither of those match (since they are logging caffeine in the morning, and not setting a custom icon), they'd land in the "everyone else" audience bucket.
Adding a new audience [#adding-a-new-audience]
To create a new audience, **click** the **+** button in the audiences section, located at the left-hand side of the campaign details view:
You have two options for creating a new audience:
1. **From scratch:** This is the default option. It will create a new audience with no filters.
2. **Import existing...:** Use this to copy an existing audience and use it as a template for a new one. See [Duplicate an audience](#duplicate-an-audience) for more details.
Renaming Audiences [#renaming-audiences]
To rename an audience, **click** the **pencil icon**, located at the top of a selected audience:
Configuring an audience [#configuring-an-audience]
To use an audience to filter for a particular set of events, rules or any other condition — you use **filters**, specify if an **entitlement** should be evaluated, along with an optional **limit**.
Creating filters [#creating-filters]
You can add filters (i.e. rules or conditions to match against) by **clicking** on an audience, and then clicking the **+ Add Filter** button:
From there, select any of the events to create a filter with. For example, if you want to use a placement you've made to match against:
1. Click "+ Add Filter".
2. Type in "event\_name".
3. For the evaluation operator, choose "is".
4. And then, type in the placement's name.
For example, if we wanted to show a certain paywall for users who tried to set a custom icon, it might look like this:
When you have a condition setup, **click** the **Save** button towards the bottom to apply it:
If you don't want to save any filter you're working on, **click** the **Discard** button by the save button.
You can combine rules together, too. In the following example, if we only wanted the paywall to show on iOS, and not Android, you can simply click "+Add Filter" once more, and add the condition:
Using user properties or placement parameters [#using-user-properties-or-placement-parameters]
You can reference [user attributes](/docs/sdk/quickstart/setting-user-properties) and [placement parameters](/docs/using-placement-parameters) in campaign filters. For example, if you were to set `hasLoggedCoffee` on your user, you could use that in a filter.
**Adding user properties**
1. **Click** on Add Filter, and then click the **+** icon:
2. Select **User** and name the property, then save it:
3. Now you can select **User** (or type the property name) and the new property is available for user in your filter. Here, it's at the bottom:
**Adding placement parameters**
This works exactly the same as above, just choose "Placement" instead:
Using rule groups [#using-rule-groups]
You can combine rules together in groups. For example, you can mix **AND** and **OR** operators in the same group. To create a rule group, **click** on **+ Add Group** in the filter editor.
In the following example, we've created a filter that matches...
* Users who have logged caffeine at least 5 times in the last week.
* And their user seed greater than or equal to 60.
* And if the app has been launched at least twice this week *and* they are on iOS.
For a hands on tutorial of creating multiple filters to show different paywalls, check out this video:
Assignments Are "Sticky". Once a user is assigned a paywall or a holdout within an audience, they
will continue to see that assignment unless you reset them (by clicking the reset icon next to
Assigned) or remove the paywall from the rule via the X button. Remember: Changing a paywall's
percentage only affects **new users**. It doesn't affect assignments for users who already saw
that paywall.
Matching to entitlements or subscription status [#matching-to-entitlements-or-subscription-status]
To match your campaign to specific entitlement, or base it on the user's current subscription status, **click** the entitlements button and choose an option:
1. **Unsubscribed users (default):** Users without an active entitlement match.
2. **All users**: All users match.
3. **Auto-renew disabled**: Users who have opted out of auto-renew.
4. **Active trials, auto-renew disabled**: Users currently in an active trial, but they've already cancelled it before their trial period expired.
5. **Active subscriptions, auto-renew disabled**: Users with an active subscription who have auto-renew turned off (i.e. these users will eventually churn).
6. **Expired entitlements**: Users whose entitlements have expired.
7. **Specify entitlements...**: Users with the specified entitlement(s) and state are matched. Here, you can combine multiple entitlement checks, too. For example, if `gold` is `active` but `platinum` is `inactive`:
Once you've set up entitlement checks for the campaign, **click** the **Save** button that appears at the bottom:
Setting a limit [#setting-a-limit]
To set a limit for an audience, **click** the **+ Add Limit** button — located below the entitlements section:
This is useful if you want to limit how many times a user can match with the audience. You can choose how many times the limit should be placed, along with a time duration and time span. For example: 1 (times) every 60 (time duration) minutes (time span):
Once you've set up a limit, **click** the **Save** button at the bottom:
Using AI audience generation [#using-ai-audience-generation]
Superwall can generate an audience for you based on a description of the audience you want to target. To do this, **click** the **AI Audience** button — located here:
Then, simply describe the audience you want to target, and Superwall will generate a filter for you. Superwall can use your custom placements and user attributes to generate a filter for you. For example, you could type "Target all users in the United States who have opened at least once":
From there, Superwall will generate a filter for you. You can then **click** the **Save** button to apply it:
Audience details [#audience-details]
When you select an audience, you can toggle between four main sections:
Filters [#filters]
This is where you can configure filters and limits. If there isn't one set, this will say "Everyone", indicating the audience will match all users.
Paywalls [#paywalls]
This section displays the paywalls which will present for the audience.
You can add new paywalls for the audience to use, or set a percentage to show across multiple paywalls when the audience is matched. To add a new paywall, click on **+ Add Paywall** to associate one to the current campaign.
Results [#results]
Here, you can see how paywalls are performing for the given audience.
Superwall will show top-level metrics here, but if you want to see more details, **click** the **Charts** button at the top-right hand side of the metrics.
Users [#users]
Finally, the users section shows **recent matches** in the audience from filters set up for it, and **transactions** that have resulted from them.
When viewing either one, Superwall will show which placement resulted in the paywall being presented (recent matches), and which placement led to a conversion (transactions).
Reordering audiences [#reordering-audiences]
To change the order that Superwall evaluates audiences, simply drag and drop them in the left-hand sidebar of any opened campaign:
Remember, Superwall will check the audience at the top of the list here, and then go down one-by-one until it hits a match. These checks occur when a user hits a code path where you've registered a [placement](/docs/dashboard/dashboard-campaigns/campaigns-placements) or if an automatically tracked placement is triggered (i.e. something like `survey_response`).
Changing audience status [#changing-audience-status]
You can **duplicate**, **delete**, **pause** or **archive** an audience using the buttons at the top of open audience:
Archived audiences can be restored at any point. Paused campaigns are not evaluated by Superwall.
Duplicate an audience [#duplicate-an-audience]
To duplicate or copy an existing audience, **click** the **+** button and choose "Import existing...". Then, you can select the audience you want to copy. Click it to use it as a template for a new audience:
From there, you can edit its name, filters, paywalls, and more.
Common filters [#common-filters]
Event count filters [#event-count-filters]
Requires SDK version 4.7.0 and above.
Event count filters are a powerful way to target users based on the number of times they've performed an action or fired a placement. In this example, we would target users who have triggered the `caffeineLogged` placement at least 3 times in the last week:
You can choose from the following time ranges:
* Hour
* Day
* Week
* Month
* Since install
To create an event count filter, **click** on the **+ Add Filter** button, and then select one of the "Occurrences..." options:
Then, filter for the event or placement you want to target. In our example, we're filtering for the `caffeineLogged` placement:
Then choose the operator you want to use and the number of times the event or placement must have occurred. In our example above, we're targeting users who have logged caffeine at least 5 times in the last week. Be sure to **click** the **Save** button to save it:
App versions [#app-versions]
To filter by app version, add `appVersion` as a filter. This is helpful if you want to show a paywall to users on a specific version of your app. For example, to target users on version 1.0.1 or later, add a filter for `appVersion`, the operator to is greater than or equal to, and the value to 1.0.1:
Another useful app version filter is `appVersionPadded`. When filtering by app version, string comparisons can cause unexpected behavior for versions with triple digits. For example, `3.100.0` would incorrectly compare as less than `3.65.0` when using a standard `appVersion` filter.
To solve this, use the `appVersionPadded` in your filter. It automatically zero-pads each segment of the version (e.g., `3.65.0` becomes `003.065.000` and `3.100.0` becomes `003.100.000`), allowing greater than and less than comparisons to work as expected.
Use `appVersionPadded` instead of `appVersion` whenever you're doing a greater than or less than comparison across major or minor version updates that could exceed two digits.
Here's an example:
| Version | `appVersion` Comparison to 3.65.0 | `appVersionPadded` Comparison to 003.065.000 |
| ------- | --------------------------------- | -------------------------------------------- |
| 3.64.0 | Less than | Less than |
| 3.65.0 | Equal | Equal |
| 3.66.0 | Greater than | Greater than |
| 3.100.0 | **Less than** ❌ | **Greater than** ✅ |
User seeds [#user-seeds]
One particularly useful property to filter by are *user seeds*, which are automatically assigned by Superwall to a user from 0-99. You can add them as a filter by entering `user.seed`. User seeds are primarily used to control entirely different user experiences across different campaigns.
For example, imagine testing whether showing a paywall early in onboarding or at the end of it works better for conversion:
**Campaign A: Placement early in onboarding**
For your audience filter, you could use user seeds 0-49.
Here's what that would look like when setting up the filter:
**Campaign B: Placement late in onboarding**
And here, you'd filter by user seeds 50-99.
Even though these are filters that were set up across two entirely different campaigns, you can still define certain user audiences without creating custom placements for each of them. Using user seeds, you can easily compare the campaign results, too.
# Paywalled Users
Paywalled users are users that have been presented a paywall from the selected audience. To see recent matches from your audience filter and its resulting transactions, **click** on the **Users** tab above the campaign details:
You'll find two main sections:
1. **Recent Matches:** Here, you'll see every user which matched your audience filter.
2. **Transactions:** Next, this displays any resulting transaction which occurred within the audience.
Note that both Recent Matches and Transactions show the [placement](/docs/dashboard/dashboard-campaigns/campaigns-placements) which
triggered the match or transaction. This is incredibly useful in helping you gauge which actions
are resulting in conversions.
Recent matches [#recent-matches]
Recent Matches show you each user, their locale, and the placement which matched them to your selected audience (among other data) from the last 24 hours:
You can click on any user to see more details about them, including the history of the actions they took. You can also filter events by Superwall-managed events, your own app events and more:
For more on viewing users, check out this [doc](/docs/dashboard/overview-users).
Transactions [#transactions]
The transactions shows all transactions which came from the campaign in the last 24 hours:
You can click on any user here, too, to see more details about them. Again, take note of the placements here — because they were directly linked to a conversion.
# Priority Placements
By default, Superwall's SDK preloads every paywall attached to your campaigns when the app launches. For most apps, this works seamlessly. But if you have a paywall that needs to appear *immediately* — like an onboarding paywall shown right at first launch — you can tell the SDK to preload that campaign's paywalls first.
That's what **Priority Placements** do. This feature is also referred to as **prioritized placements** or **prioritized campaign preloading**. When you mark a campaign as prioritized, the SDK fetches and caches that campaign's paywalls before anything else. Other campaign paywalls are still preloaded afterward in the background.
Only **one campaign per app** can be prioritized at a time. If you prioritize a new campaign, the previously prioritized one is automatically deprioritized.
When to use it [#when-to-use-it]
Prioritizing a campaign is most useful when:
* **You show a paywall on first launch or during onboarding** — the paywall needs to be ready the moment the user hits the placement, with zero loading delay.
* **A placement is triggered very early in a session** — such as `session_start` or `app_open` — and you want to guarantee the paywall appears instantly.
* **You have many campaigns** and want to ensure one specific campaign's paywalls take precedence during preloading.
If your app only has a few campaigns, preloading happens quickly enough that you likely won't need this. It's most impactful when you have several campaigns and want to control the order they load.
How to prioritize a campaign [#how-to-prioritize-a-campaign]
In the campaign editor, look for the **flag icon** next to the **Placements** header:
Click the flag to prioritize the campaign. The flag turns green to indicate the campaign is now prioritized. The dashboard describes this action as prioritizing campaign preloading.
To deprioritize, click the green flag again — it will revert to its default state.
Switching between campaigns [#switching-between-campaigns]
If another campaign is already prioritized and you click the flag on a different campaign, a confirmation dialog will appear:
> **Switch prioritized campaign?**
> "\[Other campaign name]" is currently prioritized. Only one campaign can be prioritized at a time. Switching will deprioritize it.
Confirm to switch, or cancel to keep the current priority.
How it works under the hood [#how-it-works-under-the-hood]
When the SDK fetches its configuration and sees a prioritized campaign:
1. **Phase 1 — Prioritized preload:** The SDK identifies all paywalls belonging to the prioritized campaign and preloads them first.
2. **Phase 2 — Remaining preload:** After a 5-second delay, the SDK preloads all remaining campaign paywalls in the background.
This two-phase approach ensures the most important paywalls are cached and ready before others, without skipping preloading for the rest of your campaigns. The prioritized campaign's paywalls will be ready to present with no loading time, while other paywalls continue loading in the background.
Prioritization only affects *preload order* — it does not change how placements, audiences, or experiments work. Your campaign logic stays exactly the same.
SDK version requirements [#sdk-version-requirements]
Priority Placements require the following minimum SDK versions:
| SDK | Minimum version | Notes |
| ------------ | ------------------- | ----------------------------------------------------------------------------------------------------------- |
| iOS | **4.14.0** | Full support. |
| Android | **2.7.10** | Full support. |
| Flutter | **2.4.12** | Full support via bundled iOS SDK 4.14.2 and Android SDK 2.7.11. iOS-only support started in Flutter 2.4.11. |
| Expo | **1.0.11** | Full support via bundled iOS SDK 4.14.1 and Android SDK 2.7.11. iOS-only support started in Expo 1.0.8. |
| React Native | *Not yet supported* | The current React Native SDK bundles native SDK versions that predate prioritized campaign preloading. |
Priority Placements are fully backward-compatible. Older SDK versions ignore the prioritization flag and preload all paywalls in the default order.
# Placements
Placements are the building blocks of a campaign. There are two types of placements:
1. **Standard placements:** These are placements you can use which Superwall already tracks and manages for you. Things like app installs, session start, failed transactions and more. We go into more detail about them [here](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements).
2. **Placements you create:** These are app-specific placements you create. They usually correlate to some "pro" action in your app, like "chartsOpened" or "workoutStarted" in a weight lifting app.
At their core, you register placements that, in turn, present paywalls. They can be as simple as that, or you can combine them with [audiences](/docs/dashboard/dashboard-campaigns/campaigns-audience) to create specific filtering rules to control paywall presentations, create holdouts and more.
To see how they work with our SDK, check out the [docs](/docs/sdk/quickstart/feature-gating). For a quick example, here's what it looks like on iOS:
```swift
Superwall.shared.register(placement: "caffeineLogged") {
// Action to take if they are on a paid plan
}
```
**Don't be shy about adding placements.** If you think you *might* want to use a certain feature in your app with a placement — do it now. You can add the placement, and keep it paused. Then, if you ever want to feature-gate that particular flow, you can enable it. No app update required.
In short, add placements for everything you want to feature gate, and things you may *want* to in the future.
If a campaign's paywall needs to be ready immediately, such as during onboarding or on first app launch, use [Priority Placements](/docs/dashboard/dashboard-campaigns/campaigns-placements-prioritized) to preload that campaign before the rest of your app's campaigns.
Placement parameters [#placement-parameters]
Placement parameters let you attach contextual data when registering a placement ([SDK docs](/docs/sdk/quickstart/feature-gating)). That data travels with the placement into the dashboard so you can branch logic or personalize the experience without shipping new app code.
Once parameters arrive in the dashboard, you can:
* Reference them in [audience filters](/docs/dashboard/dashboard-campaigns/campaigns-audience#using-user-properties-or-placement-parameters) to decide which users should see a paywall, holdout, or rule group.
* Surface them in the paywall editor as custom variables to drive copy, images, or logic. See [Using Placement Parameters](/docs/using-placement-parameters) for templating examples.
* Pass them along to analytics exports or downstream workflows so your broader stack understands the same context the campaign used.
The placements interface [#the-placements-interface]
Under the placements section, you can:
* **Add** new placements.
* **Pause** running placements.
* **Delete** existing placements.
Adding a placement [#adding-a-placement]
To add a placement, **click** the "+" button in the top-right side of the placements section:
A modal will appear, and from there you can add a placement via two different means:
1. **Use an existing Superwall event:** Superwall automatically manages several events that can be used as placements. For example, the `survey_response` event could be used to show an entirely different paywall with a discounted offering if a user responded with a particular answer. See the [list](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements) of the Superwall-managed events to learn more.
2. **Create your own, app-specific placement:** Here, you type in whatever event you want to use as a placement in your own app. In a caffeine tracking app, one of them might be when a user logs caffeine — something like `caffeineLogged`.
Either way, once you've selected one from our existing events or typed in your own, **click** on **Add Event** to associate the placement to your campaign:
You can also add placements "on the fly" by invoking `register(placement:"myNewPlacement")`. If
the placement you pass doesn't exist for a campaign, Superwall will automatically add it.
Basic example of placement usage [#basic-example-of-placement-usage]
Consider a caffeine tracking app. At a basic level, we want a paywall to show when a user tries to log caffeine, and they are not on a "pro" plan:
Step One: Make the placement [#step-one-make-the-placement]
We'd make a placement called `caffeineLogged` inside a campaign:
Step Two: Assign a paywall [#step-two-assign-a-paywall]
You can use the same paywall across different campaigns, placements, filters and more. In our case, we have one that we to show. So, since this campaign has a paywall linked to it already — we are good to go:
Step Three: Register inside our app [#step-three-register-inside-our-app]
Inside our caffeine tracking app, when the user taps a button to log caffeine, we would register the `caffeineLogged` event. This way, if the user is pro, the closure is called and the interface to log caffeine is shown. If they are not pro, then our paywall will show:
```swift
Button("Log") {
Superwall.shared.register(placement: "caffeineLogged") {
presentLogCaffeine.toggle()
}
}
```
And that's it!
Remember, you can pause placements at any point. So here, if you wanted to run a campaign where
logging caffeine was free for a weekend — no update would be required. Just tell your users, and
pause the placement in your Superwall dashboard. No app update required.
There are also several out-of-the box placements you can use, learn more about standard placements [here](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements).
# Standard Placements
Standard placements are events that Superwall automatically manages. The following [Superwall Events](/docs/sdk/guides/3rd-party-analytics/tracking-analytics) are registered by the SDK and can be added as placements in campaigns to present paywalls:
* [`app_install`](#app_install)
* [`app_launch`](#app_launch)
* [`deepLink_open`](#deeplink_open)
* [`session_start`](#session_start)
* [`paywall_decline`](#paywall_decline)
* [`transaction_fail`](#transaction_fail)
* [`transaction_abandon`](#transaction_abandon)
* [`survey_response`](#survey_response)
* [`touches_began`](#touches_began)
`app_install` [#app_install]
Usage [#usage]
This is registered when the SDK is configured for the first time. Use it for first-launch onboarding flows or one-time offers.
Parameters [#parameters]
These parameters are always available:
`app_launch` [#app_launch]
Usage [#usage-1]
This is registered when the app is launched from a cold start. Use it to present paywalls on fresh launches.
Parameters [#parameters-1]
Same as `app_install`:
`deepLink_open` [#deeplink_open]
Usage [#usage-2]
This is registered when a user opens the app via a deep link. First, you need to make sure to [tell Superwall when a deep link has been opened](/docs/sdk/quickstart/in-app-paywall-previews).
You can use the URL parameters of the deep link within your rules. This works for both URL schemes and universal links.
For example, you could make three conditions to match this deep link: `myapp://paywall?offer=July20`. Here's how:
1. Add a rule to see if the event is `deepLink_open`. See the `paywall_decline` example below for how to add a standard placement.
2. Add `params.offer` is equal to whatever you've made, like `July20` for a timeboxed offer you made in that month.
3. Then, you'd also add `params.path` is equal to the text of a path you setup, like `paywall`.
Parameters [#parameters-2]
After the app has emitted the first `deepLink_open` event for a given URL, these fields become available to audience filters:
`session_start` [#session_start]
Usage [#usage-3]
This is registered when the app is opened after at least 60 minutes since the last `app_close`.
Parameters [#parameters-3]
Same as `app_install`:
`paywall_decline` [#paywall_decline]
Usage [#usage-4]
This is registered when a user manually dismisses any paywall. You can combine this with rules to show a paywall when a user closes a specific paywall. First, [add](/docs/dashboard/dashboard-campaigns/campaigns-placements#adding-a-placement) the standard placement to a campaign:
Then, create a filter in the audience using it:
Here, when a user closes the paywall named `PaywallA`, a new paywall will show.
Note that you can't reference parameters that you've passed in to your original register call in your rules for `paywall_decline`.
Parameters [#parameters-4]
Audience filters for `paywall_decline` placements can use the following parameters (empty values mean the field isn't applicable):
`transaction_fail` [#transaction_fail]
Usage [#usage-5]
This is registered when the payment sheet fails to complete a transaction (this does not include user cancellation). Use it to show an exit offer after a failed attempt.
Parameters [#parameters-5]
Audience filters for `transaction_fail` placements can use the following parameters (empty values mean the field isn't applicable):
The event payload also includes a failure `message`; see [Superwall Events](/docs/sdk/guides/3rd-party-analytics/tracking-analytics) for full details.
`transaction_abandon` [#transaction_abandon]
Usage [#usage-6]
This is registered when a user dismisses the store purchase sheet before the transaction completes. If a transaction-abandon paywall matches, Superwall immediately closes the current paywall and presents the new one.
Parameters [#parameters-6]
Audience filters for `transaction_abandon` placements can use the following parameters (empty values mean the field isn't applicable):
For example, to show a transaction-abandon paywall only for onboarding paywalls, add a `transaction_abandon` placement and set `presented_by_event_name` to `onboarding`. To limit it to a single paywall, add `paywall_id` as an additional condition.
`survey_response` [#survey_response]
Usage [#usage-7]
This is registered when a response to a paywall survey has been recorded. First, you need to make sure your paywall [has a survey attached](/docs/dashboard/surveys).
You can combine this with rules to show a paywall whenever a survey response is recorded or when the user gives a specific response. Again, [add](/docs/dashboard/dashboard-campaigns/campaigns-placements#adding-a-placement) the standard placement `survey_response` to a campaign. Then, add another condition using `survey_selected_option_title` that's equal to the text of a particular response.
For example, if the user selected a survey option named `Too Expensive`, you could present another paywall with a discounted option. This is a great opportunity to show a discounted paywall to improve your conversion rate.
Parameters [#parameters-7]
Audience filters for `survey_response` placements can use the following parameters (empty values mean the field isn't applicable):
`touches_began` [#touches_began]
Usage [#usage-8]
This is registered when the user touches the app's UIWindow for the first time. It is only tracked if there is an active `touches_began` placement in a campaign.
Parameters [#parameters-8]
Same as `app_install`:
# Starting an Experiment
You run experiments in Superwall by adding multiple paywalls to an audience. To start an experiment:
1. Select an audience.
2. Click the Paywalls tab.
3. Add two or more paywalls.
Here's a .gif, beginning to end, setting up an experiment:
It's as simple as that to start a paywall experiment.
Setting presentation percentages [#setting-presentation-percentages]
You must set a presentation percentage between your paywalls within the experiment. This determines how often they'll show based off of the percentage set for each one.
To set a percentage, **click** the **pencil icon** above *any* of the paywalls attached to the audience:
Then, assign percentages from 0%-100% for each of them. In total, your percentages should equal 100 (i.e. paywall A shows 10%, paywall B shows 30%, and paywall c shows 60% of the time) unless you're purposely creating a [holdout](/docs/dashboard/dashboard-campaigns/campaigns-starting-an-experiment#creating-holdouts). When you're done, **click** the **Checkmark** icon below any paywall:
Resetting assignments [#resetting-assignments]
If you change your experiment, or simply want to change the presentation percentages between your paywalls, you might want to reset your assignments. Remember that when an audience matches a user, it's *sticky* — and the same is true of when someone is matched to a paywall within an experiment.
So, if you want to make sure everyone is matched again to a paywall based off new percentages, **click** the refresh button below a paywall when editing percentages (next to where it says "X assigned"):
Resetting assignments also resets the stats for the experiment.
Creating holdouts [#creating-holdouts]
A *holdout* occurs when you purposely edit an audience to *not* present a paywall in some cases. Setting a holdout is useful when you want to test the effectiveness of showing a paywall.
**To create a holdout, set your paywall presentation percentages to be less than 100% across all of the paywall you're using.**
Here's an example of one paywall set to show 50% of the time, meaning the other 50% of users who match this audience will be in a holdout:
It's common to pair holdouts to certain [placements](/docs/dashboard/dashboard-campaigns/campaigns-placements) to see whether a holdout increases or decreases transactions. The holdout group will act as a control which you can compare against.
Removing variants [#removing-variants]
During an experiment, you may find that one or more paywalls are performing significantly worse than the others. In that case, you would probably consider removing it. You can simply remove the paywall, or set its presentation percent to 0%, and your experiment will continue. No metrics will be affected or reset. **Resetting assignments will reset metrics, removing paywalls will not.**
# Campaign Structure
Once you open a campaign, you can edit its details, placements, control experiments, view results, manage paywalls and more. The campaign detail screen is divided into three separate sections:
Placements [#placements]
Under the placements section, you can add, pause or delete existing placements. Placements are actions, or implicit events, which you can use to show a paywall or implement feature gating. Be liberal with adding placements, you can always pause them on-the-fly without app updates. Or, if you later decide to paywall a particular feature, it gives you more flexibility if you already have a placement for that particular action.
Learn more about placements [here](/docs/dashboard/dashboard-campaigns/campaigns-placements).
Audiences [#audiences]
Audiences allow you to set up simple or complex filtering rules to match against certain users, show a particular paywall to them, view results of experiments and recent transactions resulting from them. All of your current audiences will show in the left-hand side of the campaign details screen.
Learn more about audiences [here](/docs/dashboard/dashboard-campaigns/campaigns-audience).
Experiments [#experiments]
Audiences are also where you set up paywall experiments, and view their performance. Experiments allow you to show one or more paywalls, and see which one is "winning".
Learn more about experiments [here](/docs/dashboard/dashboard-campaigns/campaigns-starting-an-experiment).
Renaming campaigns [#renaming-campaigns]
To rename a campaign, click on the pencil icon next to the campaign's name, located in the top-left hand side of the campaign details screen:
# Understanding Experiment Results
To view the results of any paywall experiment that's running, **click** the **Results** tab in the campaign details view:
There are three main sections: **Paywalls**, **Placements**, and **Graphs (defaults to Proceeds Per User)**. Each section has a toggle at the top right to change associated metrics.
Paywalls [#paywalls]
Here, you'll see each paywall being used (or that was used) in an experiment. Superwall will show you metrics such as proceeds, users and much more. There are several metric to explore, and you can hover over any of them to get more details about what each metric represents:
Subscription lifecycle events (i.e. renewals, cancellations, etc) are matched to paywall
conversions using unique identifiers provided by the platform at checkout and via webhook events.
You can also filter results per paywall. Click the checkbox next to one to have the results page only show data for that specific paywall:
Placements [#placements]
Here, you can get a detailed breakdown of each placement associated with the campaign. This helps you form a clear picture of what features or actions are leading to conversions.
Graphs [#graphs]
Finally, the last section has several graphs to explore campaign performance. It defaults to Proceeds Per User.
Setting up revenue tracking [#setting-up-revenue-tracking]
Before any metrics based on revenue will display, you need to set up revenue tracking. To set up revenue tracking:
1. **Click** on **Settings** in the dashboard.
2. **Click** on **Revenue Tracking**.
3. Use the guides to follow any of the revenue tracking methods. For more details, check out our [docs](/docs/dashboard/dashboard-settings/overview-settings-revenue-tracking).
If you don't have revenue tracking setup, you will see a banner on your dashboard:
A note on conversions, trial starts, and subscription starts [#a-note-on-conversions-trial-starts-and-subscription-starts]
Each experiment will notably report **conversions**, **trials starts** and **subscription starts**. In some cases, it may seem like these numbers don't match up quite how you'd expect. That could be due to a few different reasons:
1. **Reporting methods:** Conversions are an *SDK reported* event, while trial and subscription starts are *server reported* events. Sometimes, the server events might be a little behind on their reporting — whereas SDK events are usually instantaneous.
2. **Understanding Resubscriptions and Cancellations:** When someone resubscribes or restarts a paused subscription through a paywall, it *won't* be considered a new trial or a new subscription start. However, it *will* be counted as a **conversion**. As such, any revenue generated will be linked to that paywall. If they later decide to cancel the subscription, the cancellation will also be linked to the same paywall.
3. **Attribution:** And finally, attribution can sometimes be a complicated metric to track. If something doesn't look right on your end, please feel free to reach out to us and we'll always export your data so you can exactly where our numbers are coming from.
Confidence intervals [#confidence-intervals]
Use confidence intervals to gauge how each paywall is performing against the other ones in your experiments. Hover over a specific metric to view the confience interval (i.e. Conversion Rate, Proceeds Per User, etc.):
Keep in mind that these intervals represent the percentage of users converted, it doesn't take into account revenue. Put differently, paywall A could have a higher conversion rate, but with a much cheaper offering than paywall B. Paywall B could still be making more money, but at a lower conversion rate with the higher-priced product.
For more on confidence intervals, check out our in-depth [blog post](https://superwall.com/blog/confidence-intervals-in-experiment-readouts).
Identifiers and cohorting with 3rd party analytics [#identifiers-and-cohorting-with-3rd-party-analytics]
If you scroll to the end of the experiment results table, you'll find some useful identifiers which you can use to interface with third-party tools you may be using:
1. **Experiment id:** The identifier of the experiment that the paywall is a part of.
2. **Variant id:** The identifier representing the variant the paywall represented in the experiment.
3. **Paywall id:** The identifier for the paywall in the experiment, which associates back to the variant.
To learn more about interfacing with 3rd party analytics, check out this [doc](/docs/sdk/guides/3rd-party-analytics/cohorting-in-3rd-party-tools).
# Campaigns
View **Campaigns** by clicking them over on the left-hand **sidebar**:
Campaigns consist of three main concepts:
1. [Placements](/docs/sdk/quickstart/feature-gating)
2. [Audiences](/docs/dashboard/dashboard-campaigns/campaigns-audience)
3. [Paywalls](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-overview)
Campaigns are the centerpiece of Superwall. You can use one, or several, campaigns that can run concurrently. To understand campaigns, think of them like this:
* In a campaign, you add **placements** — which are actions you want to result in a paywall, or might someday want to result in a paywall(i.e. `loggedCaffeine`, `addedEntry`, etc).
* Then, as users take actions in your app, those placements are **registered** in the Superwall SDK.
* When a placement is registered, it's then evaluated by Superwall. Superwall looks at your campaign **[filters](/docs/dashboard/dashboard-campaigns/campaigns-audience#configuring-an-audience)**, and may or may not show a matching **paywall**.
With this setup, you can be incredibly simple or make in-depth, complex filters which determine when (and what) paywall is shown. You can set the percentage of new users that see each paywall, or even configure no paywall (a.k.a. a holdout) to be shown for certain placements.
Toggling campaigns by status [#toggling-campaigns-by-status]
You can toggle between campaigns by their status using the tabs at the top (above the campaigns):
* **All:** The default view. This shows all of your campaigns, regardless of their status.
* **Active:** Campaigns being used in production and serving up paywalls.
* **Inactive:** Campaigns that are not serving paywalls in production, but can be quickly re-enabled.
* **Archived:** Campaigns that have been archived, and not attached to any campaign. These can be restored.
Viewing campaign top-level metrics [#viewing-campaign-top-level-metrics]
Each campaign will also display its active placements and top-level metrics (if any are available). In this example, the campaign at the top has data, while the one below it doesn't:
Metrics shown include:
* **Opens:** The amount of time any of the campaign's placements resulted in a paywall being presented.
* **Conversions:** The number of conversions produced from any paywall attached to the campaign.
* **Conversion Rate:** The conversion rate of the current campaign.
If the campaign isn't currently serving any paywalls because none have been attached to it, you'll
see a wanting indicating that in this view. In the image above, that's the case for the campaign
at the bottom, called "Survey".
Toggling campaigns by date [#toggling-campaigns-by-date]
To toggle the date range that the metrics previously mentioned should display within, use the date toggle at the top-right side:
Viewing campaign details [#viewing-campaign-details]
To view more about any campaign, to set up filters, edit placements, paywalls and more — simply **click** on any campaign listed in the table. Then, campaigns details will be presented. More on that in the next [page](/docs/dashboard/dashboard-campaigns/campaigns-structure).
# Flows Analytics
Flows Analytics help you understand how users move through a flow after it is live. Instead of only seeing whether a paywall opened or converted, you can inspect each step in the journey, see where users drop off, and compare how different flow variants perform.
You will find Flows Analytics in experiment results for flows. The section is called **Flow Journey**.
What Flow Journey Shows [#what-flow-journey-shows]
Flow Journey is built around page views inside a multi-page flow. Each time a user reaches a page, Superwall records the page, its position in the flow, how the user got there, and how long they spent on the previous page.
Use it to answer questions like:
* Which page has the largest drop-off?
* Are users reaching the paywall page in the flow?
* Which branch performs better?
* Do users spend too long on a particular step?
* Are different variants producing different journey shapes?
Flow Journey is only available for flows that use route-based navigation. Standard single-page paywalls and older index-based navigation do not produce Flow Journey page-view data.
Viewing Drop-Off [#viewing-drop-off]
The drop-off chart shows how many users reach each step in the flow. Each step represents a page position in the active route, not the order of pages in the editor sidebar.
The chart is useful for quickly finding the step where the most users leave. For example, if Step 1 has strong volume but Step 2 drops sharply, inspect the transition between those pages. The issue might be unclear copy, a missing **Navigate Page** action, an unexpected branch, or a page that asks too much too early.
The hourglass value under each step shows the median time users spent on that page before continuing, which can help you distinguish normal reading time from friction.
If your flow has multiple variants, you can compare them in the same view. This is useful when testing different onboarding lengths, survey questions, product positioning, or paywall placement inside a flow.
Reading Flow Steps [#reading-flow-steps]
Flow steps are based on the route a user takes through the flow:
* **Step 1** is the page reached from the flow entry point.
* Later steps follow the active route and branch conditions.
* Branches can cause different pages to share the same step position.
* Unlinked pages do not appear unless users can reach them through a route.
This means the analytics view follows the user journey, not the visual arrangement on the Canvas.
If the analytics view does not match what you expected, return to the Canvas and check the flow entry point, route connections, branch rules, and **Navigate Page** actions.
Branching View [#branching-view]
When a flow includes branching, Flow Journey can show a branching view. This is a Sankey-style chart, which means wider paths represent more users moving between pages. It helps you see how users split across different routes and where they go next.
Use this view when your flow has conditional paths, such as:
* Different onboarding paths based on a multiple choice answer.
* A skip path and a setup path from the same page.
* Different post-purchase or post-permission outcomes.
* Survey responses that route users to different offers.
The branching view is most useful when you want to understand branch distribution. For example, if most users select one path but that path converts poorly, you may want to adjust the offer, shorten that branch, or route those users to a different page.
Time on Previous Page [#time-on-previous-page]
Flow Journey also tracks how long users spend before moving to the next page. This can help you spot pages that create friction.
Longer time is not always bad. A page with a video, product comparison, or detailed survey question may naturally take longer. But if users spend a long time on a simple transition page, check whether the CTA is visible, whether the copy is clear, and whether the next action is obvious.
Coverage [#coverage]
You may see a coverage notice when some paywall opens do not have Flow Journey page-view data.
This can happen when some users open a flow from an SDK or runtime that does not emit page-view events, or when an older flow setup does not use route-based navigation. When coverage is incomplete, use Flow Journey directionally and avoid treating it as a perfect count of every open.
Best Practices [#best-practices]
* Make sure every route has a reachable **Navigate Page** action, unless the page uses auto-advance.
* Name flow pages clearly so analytics labels are easy to read later.
* Keep branch conditions simple enough that the branching view remains understandable.
* Use indicators in longer flows so users understand how much of the journey remains.
* Compare variants by both conversion and drop-off, not conversion alone.
For help building the flow structure that powers this reporting, see [Linking Pages](/docs/dashboard/dashboard-creating-flows/linking-pages) and [The Canvas](/docs/dashboard/dashboard-creating-flows/the-canvas).
# Flow Elements
Flows can be enhanced with interactive components designed for multi-page experiences. They capture user input, show progress, or request permissions. Use them to personalize the flow or gather information for branching.
Multiple Choice [#multiple-choice]
The multiple choice element presents options for users to select. This is the key element for enabling branching. User selections can determine which page they see next.
Configuration [#configuration]
* **Single-select or multi-select:** Choose whether users can pick one option or multiple.
* **Randomize order:** Shuffle the options each time (useful for surveys to reduce bias).
* **Choice items:** Each choice has a label and a value.
Labels and values [#labels-and-values]
Each choice has two parts:
* **Label:** What users see (e.g., "Grow subscriptions").
* **Value:** What gets stored (e.g., `goal_grow`).
The value is used internally for routing conditions and user attributes. Keep values short and consistent (lowercase, underscores).
Storing selections [#storing-selections]
When a user makes a selection, two variables are available: `selectedValue` (the internal value, e.g., `goal_grow`) and `selectedLabel` (the display text, e.g., "Grow subscriptions"). If localization is active, `selectedLabel` returns the translated label for the user's locale.
You can use these in routing conditions to branch the flow, store them as user attributes for analytics or personalization, reference them in dynamic content on later pages, or pass them to your backend via webhooks.
Multiple choice controls are commonly used for [branching](/docs/dashboard/dashboard-creating-flows/linking-pages).
Input [#input]
The input element lets users type a response, like their name, email, or feedback.
Configuration [#configuration-1]
* **Placeholder text:** The hint shown before users type.
* **Keyboard type:** Choose the appropriate keyboard (default, email, number, etc.).
Storing responses [#storing-responses]
Like multiple choice, text entry values can be stored as user attributes. This is useful for personalizing later pages with the user's name, capturing email addresses for follow-up, or collecting feedback or custom responses.
Indicator [#indicator]
The indicator element shows progress through the flow, like "Step 2 of 5."
Configuration [#configuration-2]
* **Style:** Choose from different visual styles (dots, bars, numbers).
* **Current step:** Which step to highlight.
* **Total steps:** How many steps to show.
Add an indicator when your flow has more than 3-4 pages. Users are more likely to complete a flow when they can see their progress and know how much is left. You can also use their properties in dynamic values such as progression:
Date Picker [#date-picker]
The date picker element lets users select a date, time, or both using a scrollable wheel or compact input. This is useful for collecting birthdates, scheduling preferences, or any date-related input during onboarding.
Configuration [#configuration-3]
* **Style:** Choose between **Wheel** (scrollable columns) or **Compact** (native date/time input).
* **Components:** Choose what to collect.
* Wheel style supports **Date & Time**, **Date**, **Time**, and **Time List** (a single scrolling column with pre-formatted time options).
* Compact style supports **Date** and **Time**.
* **Min Date / Max Date:** Constrain the selectable range. Options are **No Limit**, **Today**, **Relative** (e.g., 30 days before or 1 year after today), or a **Fixed Date**.
* **Minute Interval:** When using a time component with the wheel style, set the interval between selectable minutes (1, 5, 10, 15, 30, or 60).
Storing selections [#storing-selections-1]
The selected value is stored as a string and accessible as a variable. The format depends on what components are configured: `YYYY-MM-DD` for date, `HH:MM:SS` for time, or `YYYY-MM-DDTHH:MM:SS` for date and time. You can use this value in routing conditions, dynamic content on later pages, or pass it to your backend.
# Getting Started with Flows
Flows let you string together multiple pages into a single, seamless experience. They're ideal for onboarding, cancellation surveys, upsells, or any multi-step journey. They're built right into the same editor you use for paywalls.
Visual learner? Watch our [Flows walkthrough on YouTube](https://youtu.be/q_PAkCeKFfc).
Use cases [#use-cases]
Flows are built to work well for any multi-step experience:
* **Onboarding flows** that branch based on user goals or preferences.
* **[Web Flows](/docs/dashboard/guides/web-flows)** that qualify users on the web before sending them to your app.
* **Cancellation surveys** with conditional paths based on feedback.
* **Multi-step upsell funnels** that guide users to the right product.
* **Personalized welcome experiences** tailored to user segments.
Familiar with paywalls? [#familiar-with-paywalls]
If you've built paywalls in Superwall, you'll be right at home with Flows. They use the same editor, with some key enhancements added:
* You'll use the **Navigation element** to connect pages together.
* The **Canvas view** lets you see your entire flow at once.
* **Routes** define how users move between pages, including conditional branching.
Once you add a [navigation component](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-navigation-component), a new option called "Flow" becomes available in the [floating toolbar](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-floating-toolbar):
# How Flows are Structured
A Flow is a collection of pages connected by routes. Unlike single paywalls, the order of pages in the sidebar doesn't determine the flow. The connections (i.e. *routes*) you create do. The Navigation element is what makes a paywall opt into becoming a Flow.
To understand flows, you only need to be aware of these core concepts to get started:
1. **Navigation Component:** The base component which contains your flow.
2. **Pages:** The content of your flow, each one is housed within a central navigation component.
3. **Routes:** The user-defined ordering of how users progress through a flow.
4. **Branches:** A way to dynamically decide which route to take.
Not all flows need to use branches. If your flow is a linear journey, then they aren't required.
The Navigation element [#the-navigation-element]
The Navigation element is what turns a paywall into a Flow. Without it, you have a standard paywall. With it, you unlock the Canvas view and the ability to connect pages together.
To add it:
1. In the left sidebar, click **+** to add a new element.
2. Choose **Navigation** under the "Base Elements" header.
Once added, you'll see your paywall appear in the Canvas view, ready to be connected to other pages.
Pages [#pages]
Each page in a Flow is built the same way you build a paywall. Once you have a navigation element, adding pages to it enables the Flow editing capabilities:
A "page" here is any content you add, such a stack, into the navigation element. Each top level container creates a page in your flow.
You can add elements, style them, and configure actions just like you would with any paywall.
* A Flow can have as many pages as you need.
* Pages that aren't connected to the flow are labeled "unlinked".
* Each page can have its own products, styling, and behavior.
Once you add one or more pages, the "Flow" button the floating toolbar will become active:
Routes [#routes]
Routes are the connections between pages. You create them by linking one page to another in the Canvas view. To begin, you'll **click** and **drag** from the starting point of the flow to the first page you want to use:
* Each route defines how users move from one page to the next.
* Routes can have different animation styles (push, fade, etc.).
* The first page in your flow connects to the "flow entry point".
You can control any routes animation style by clicking on it:
Branching [#branching]
Routes themselves can be conditional. If you need to show different pages based on user input or attributes, you can by creating a branch. Any *route* can become a *branch*. For example:
* If a user selected "Grow subscriptions" in a multiple choice element, go to Page A.
* Otherwise, go to Page B.
Branching is configured in the route settings, not on buttons or CTAs. This keeps your flow logic centralized and easier to maintain.
The floating toolbar [#the-floating-toolbar]
The floating toolbar has been updated to support Flows. You'll find new controls for:
1. Switching between Device view and Canvas view.
2. Fitting the viewport to fit the entire flow canvas.
3. Editing branches.
4. Toggling the mini-map.
For more details, see [The Canvas](/docs/dashboard/dashboard-creating-flows/the-canvas).
# Linking Pages
Linking pages is how you define the path through your flow. You'll connect pages using nodes in the Canvas view, and each connection (route) can have its own animation and conditions.
Creating connections [#creating-connections]
In Canvas view, you'll see nodes on the edges of each page. These are your connection points.
To link two pages:
1. Click the node on the edge of the source page.
2. Drag to the destination page.
3. Release to create the route.
The first page in your flow should connect to the **flow entry point**, which is the starting node that appears in the Canvas. This marks where users begin:
The edge toolbar [#the-edge-toolbar]
When you click on a route (the line connecting two pages), an edge toolbar appears with several options:
1. **Animation:** Choose a transition style for this route (see below).
2. **Add condition:** Opens a help dialog explaining how to set up conditional branching on this route.
3. **Duplicate:** Duplicates the source destination page and then creates a branch between them.
4. **Delete:** Remove the connection between two pages.
Animation styles [#animation-styles]
Each route has an animation style that controls how the transition looks when users move between pages.
To change an animation:
1. Click on a route (the line connecting two pages).
2. In the edge toolbar that appears, select an animation style:
Available animations:
* **Push**: Slides the new page in from the right.
* **Fade**: Crossfades between pages.
* **Slide**: Smooth horizontal transition, like scrolling through a carousel.
* **Fade & Slide**: Combines a fade with a slide transition.
* **None**: Instant transition with no animation.
Unlinked pages [#unlinked-pages]
Pages that aren't connected to the flow show a label indicating they're unlinked. These pages won't appear in the user's journey until you connect them:
Unlinked pages are useful for drafts you're still working on, pages you want to keep but aren't using yet, or testing different versions before connecting them.
Branching [#branching]
Routes can be conditional, meaning users can see different pages based on their input or attributes. This is the core of personalized flows. You might change the page that shows next based off a multiple choice answer, or certain component tapped, etc.
To add branching:
1. Connect a route in the Canvas to a page.
2. Then, from the same source page, click and drag to add *another* route to a different destination page.
3. Then, configure the rules from the resulting popup.
In this example, a route is already in place to go from the left-most page to the middle one. Adding another route from the same page to a new page creates a branch. The Flow editor recognizes that the route can now end up in more than one place:
For example, if you have a multiple choice element asking about user goals:
* Route 1: If user selected "Grow subscriptions" → Go to Growth Tips page.
* Route 2: If user selected "Reduce churn" → Go to Retention Tips page.
* Default route: Go to General Tips page.
Editing branch rules [#editing-branch-rules]
A branch dictates navigation by its *routing conditions*, and these are edited once a branch is made:
If you are familiar with [dynamic values](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-dynamic-values), rules are created exactly the same way.
These routing conditions can be based on things like:
* **User attributes:** Properties you've set on the user (e.g., subscription status, country).
* **User input:** Selections from Flow Elements like multiple choice or text entry.
* **Combinations:** Use AND/OR logic to combine multiple conditions.
When building conditions, you can use operators like **equals**, **not equals**, **contains**, and more. Two additional operators are available for checking whether a value exists at all:
* **is empty:** True when the value is not set (null, undefined, or an empty string). Useful for checking if a user hasn't made a selection yet or if an input field was skipped.
* **is not empty:** True when the value has any content. Useful for branching only after a user has provided input.
These operators don't require a comparison value. They check the variable itself.
Multiple choice branch example [#multiple-choice-branch-example]
Here's an example using a multiple choice component to create a branch. When choosing a condition, in the popup select **Element** and choose the multiple choice response to dictate the flow (this assumes you have added a multiple choice component on the flow already):
Interactive elements that can control routing conditions will be available under the **Element** category when editing the rule. In the screenshot above, it's shown since a multiple choice component is used in the pages involved in the branch. You can see that here:
The multiple choice responses will automatically populate in the rule editor too:
When you are done, **click** on the **save** button and your branch will be saved. The canvas will update to reflect the branch:
To **edit** a branch, simply click on it from within the canvas to bring the rule editor back up.
Connection warnings [#connection-warnings]
The editor validates your flow connections and shows warnings when it detects potential problems. For example, if a page is connected to the next page but no element on the source page has a "Navigate Page" tap behavior, you'll see a warning indicating that the destination page may be unreachable.
If you see a warning, check that the source page has at least one element with a **Navigate Page** tap behavior set to **Next** so users can actually reach the connected page.
Branching by tapped element [#branching-by-tapped-element]
Sometimes you want different buttons on the same page to navigate to different destinations without a traditional condition. For example, a footer with "Continue" and "Skip Setup" buttons where each should go to a different page.
To set this up, use `navigationNode.tappedElement` as the routing condition. This lets you create a route based on which element triggered the "Navigate Page" action:
To configure this:
1. Create a branch from your page to multiple destinations.
2. In the rule editor, select **Element** as the condition type.
3. Choose **Navigation.tappedElement** from the dropdown.
4. Set it equal to the specific element that should trigger this route.
This approach is useful when you have multiple navigation buttons on a single page and each should lead somewhere different. The routing condition checks which button was actually tapped rather than relying on stored user input.
Start simple. Get your basic flow working first, then add branching once you're comfortable with the structure.
# Navigation
Navigation in Flows is handled by the Navigation element and other components you add tap behaviors to (such as CTA buttons). Users move forward along the routes you've defined, or backward through the pages they've already visited. The system is intentionally simple. Complex routing logic lives in the routes, not the buttons.
The Navigation element [#the-navigation-element]
The [Navigation element](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-navigation-component) is what enables flow navigation. Add it to any page to unlock forward and backward controls.
To add it:
1. In the left sidebar, click **+** to add a new element.
2. Choose **Navigation** under the "Base Elements" header.
Without a Navigation element, you have a paywall. With it, you can create a Flow.
Adding navigation to components [#adding-navigation-to-components]
Any element can have a [tap behavior](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements#tap-behaviors). Using the "Navigate Page" behavior, you can tell a component to progress the flow forward or backwards:
To configure a component to navigate:
1. Select the button element.
2. In the right sidebar, find **Tap Behavior**.
3. Choose **Navigate Page** from the action options.
4. Select **Forward** or **Backward**.
To see them in action, change the canvas view to **Device**, and then click on the component to fire off its tap behavior:
Additionally, you can manually set which page should be navigated to within the floating toolbar using its variable editor:
Going forward and backward [#going-forward-and-backward]
When a user taps **Forward**, they move to the next page based on the route you've connected from the current page. If there's no branching, they go to the single connected page. If there's branching, the route conditions determine which page comes next. When they tap **Back**, they return to the last page they visited in that session. Back navigation follows the user's history; it does not re-evaluate route conditions in reverse.
Auto-advance timer [#auto-advance-timer]
For pages that don't require user interaction, you can automatically advance to the next page after a set duration. This is useful for animation screens, feature previews, or any page where you want to keep the user moving through the flow without requiring a tap.
To set up auto-advance:
1. Select the page you want to auto-advance.
2. Find the **Auto Advance** hour glass button below the page.
3. Enter the duration in seconds.
Once configured, the page will automatically navigate forward when the timer completes:
CTA buttons are simple by design [#cta-buttons-are-simple-by-design]
Since routes and branches determine where a user ends up, remember that CTA buttons in Flows commonly do one of two things: progress it forward or go backward.
You won't set a specific page number on a button in Flows. Instead, you simply move forward or backwards. All conditional logic (which page to show next based on user input or attributes) is defined in the routes, not the buttons. This keeps your flow easier to maintain and reason about.
Think of CTA buttons as "next" and "back". The routes decide where "next" actually goes.
# Ordering Screens
In Flows, the order of pages in the sidebar doesn't determine the user's path. Routes define the order, not the list position. The sidebar and Canvas positions are purely for your organization.
How order works [#how-order-works]
The sidebar order does not dictate which page shows next in the flow. The flow starts at the **entry point** and follows the routes you've created, so a page listed first in the sidebar can still appear last in the user's journey. Reordering pages in the sidebar or Canvas won't change what users see.
Routes define the flow, not page order. Rearranging pages won't change the user experience.
Reordering on the Canvas [#reordering-on-the-canvas]
Drag pages on the Canvas to arrange them visually. This is especially helpful for:
* Making complex flows easier to understand at a glance.
* Aligning pages that share similar content or purpose.
* Creating visual groupings for different branches.
To reset the layout:
1. Look for the **snap to order** icon in the floating toolbar.
2. Click it to return all pages to their default positions.
You can also rename any page for organizational purposes. Just **click** on the text label above any page to edit its label name:
Best practices [#best-practices]
* **Match your mental model:** Arrange pages in a way that reflects how you think about the flow.
* **Keep related pages close:** Pages that are connected should generally be near each other on the Canvas.
* **Use space intentionally:** Spread out complex branching so it's easier to follow the routes.
And, don't be afraid to keep things scattered if that helps you work out a flow either. The ordering for the user is always decided by the routes, and you can the snap button anytime to get things tidy again.
# Permission Prompts
Requesting permissions is a natural part of many flows, especially onboarding. Rather than prompting for notifications or location access at a random moment, you can ask at the right point in a flow after the user understands the value.
Permission prompts are not a standalone element. They are a [tap behavior](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements#tap-behaviors) called **Request Permission** that you attach to a button or other tappable component. When the user taps it, the system permission dialog appears.
Available permissions [#available-permissions]
* **Notification:** Ask for permission to send system notifications.
* **Background Location:** Request location access when the app isn't in use.
* **Location:** Request location access while the app is in use.
* **Read Images:** Access the user's photo library/camera roll.
* **Contacts:** Access the user's contacts.
* **Camera:** Access the device camera.
* **App Tracking Transparency:** Ask to track the user across apps and websites.
* **Microphone:** Access the device microphone.
Permission prompts require iOS SDK 4.12.5+.
If Granted / If Denied [#if-granted--if-denied]
You can add follow-up actions that run depending on the user's response. Use the **If Granted** section to add actions that run when the user allows the permission, and **If Denied** for when they decline. For example, you could navigate to the next page on grant, or show a different page explaining why the permission matters on deny.
Testing permissions in the editor [#testing-permissions-in-the-editor]
You can test permission prompts directly in the editor preview without deploying to a device. When a permission request fires, the editor shows a simulation toast with **Grant** and **Deny** buttons. Clicking either one triggers the corresponding **If Granted** or **If Denied** follow-up actions, so you can verify your entire permission flow works before shipping.
Testing callbacks in the editor [#testing-callbacks-in-the-editor]
Custom Callback actions can also be tested in the editor. When a callback fires, the editor shows a simulation toast with **Success** and **Failure** buttons. If the callback is configured as **Blocking**, the action chain pauses until you click one. If it's **Non-blocking**, the chain continues immediately and you can click whenever you're ready. This lets you test both paths of your callback logic without writing any SDK code.
Best practices [#best-practices]
* Request permissions **after** providing value. Users are more likely to accept.
* Explain the benefit clearly (e.g., "Get notified about exclusive deals").
* Consider placing permission prompts after a purchase or key engagement moment.
For more guidance on iOS, view Apple's Human Interface Guidelines [here](https://developer.apple.com/design/human-interface-guidelines/privacy#Requesting-permission).
# The Canvas
The Canvas is a display mode in the paywall editor tailored to help you see your entire flow laid out. It works similar to many design tool canvas' in that you can pan around, zoom to see the big picture, or click any page to focus it.
Device view vs. Canvas view [#device-view-vs-canvas-view]
There are two ways to view your flow:
* **Device view:** The familiar single-page editor for building individual pages. This is where you add elements, style them, and configure actions.
* **Canvas view:** The zoomed-out view showing all pages and their connections. This is where you link pages together and see the overall structure.
Switch between them using the floating toolbar, or by clicking on a page in Canvas view to zoom into Device view.
Zooming [#zooming]
Use these controls to navigate the Canvas:
* **Keyboard:** `Cmd + scroll` (Mac) or `Ctrl + scroll` (Windows) to zoom in and out.
* **Toolbar:** Use the zoom controls in the floating toolbar.
* **Click:** Click any page in Canvas view to zoom into that page.
The mini-map [#the-mini-map]
For larger flows, the mini-map helps you navigate. It shows a thumbnail of your entire flow with your current viewport highlighted.
You can toggle the mini-map on or off from the floating toolbar. Also, you can click and drag anywhere within the minmap to focus the canvas to that specific area.
Reordering pages on the Canvas [#reordering-pages-on-the-canvas]
You can drag pages on the Canvas to arrange them visually. This helps make complex flows easier to understand at a glance. Simply **click** and **drag** any page to reposition it.
You can use the toolbar icon to snap pages back to their default order:
Remember that how pages are arranged on the Canvas is purely for your organization. The routes and branches you create determine the actual user journey.
# Tips
Want to watch some of these tips in action? Check out this video:
[Watch on YouTube](https://youtu.be/lkIxyC6tQwo)
Setting custom user attributes [#setting-custom-user-attributes]
Setting custom user attributes inside a flow is one of the most useful techniques available. Any tap behavior can set a user attribute, which means buttons, multiple choice selections, and other interactive elements can all tag users with data as they move through the flow.
Once a user attribute is set, you can use it in a few different ways:
* **Within the same flow.** Personalize a later screen with the value (e.g., "Awesome John, welcome to the app!"), route to a different page via branching, or change which products and offers to show.
* **In your app.** Use the [SuperwallDelegate](/docs/sdk/guides/using-superwall-delegate) to send it straight to your analytics provider, create user cohorts, or handle it however you need.
For example, a "Next" button can do more than navigate to the next page. It could, for example, also read from a multiple choice selection, and set its selection to a custom user attribute. By using the tap behavior of "Set Attribute", the value will be set to the user:
In addition, your app can handle the attribute using the delegate:
```swift
extension MySuperwallDelegate: SuperwallDelegate {
func userAttributesDidChange(newAttributes: [String : Any]) {
// The attribute set in the flow is sent here
}
}
```
This works for any data you collect in a flow, not just multiple choice. Text input values, quiz responses, demographic selections, and preferences can all be stored as attributes and forwarded to your analytics, CRM, or backend.
After purchase behavior [#after-purchase-behavior]
By default, when a user makes a purchase, the paywall or flow will close. But in Flows, you might want to continue. For example, you could show a thank-you message or collect feedback.
To set something like this up:
1. Select the purchase action on your button.
2. Look for the **After Purchase** setting.
3. Choose from the available options:
* **Close:** Dismisses the flow (the default).
* **Navigate Page:** Advances to the next page in the flow. This is the most common choice for flows where the purchase happens mid-journey.
* **Open URL:** Opens a link after purchase.
* **Custom Action:** Triggers a custom action in your app.
* **Custom Placement:** Registers a placement after purchase.
* **Set Attribute:** Sets a user attribute when the purchase completes.
* **Set State:** Updates a state variable.
* **None:** Does nothing, letting the flow proceed naturally.
The **Navigate Page** option is particularly useful in flows. Instead of closing after purchase, the user moves to the next connected page. This opens up use cases like:
* Showing a personalized welcome or thank-you message.
* Collecting feedback about why they subscribed.
* Presenting an upsell for an add-on product.
* Guiding users through initial setup.
* Placing a paywall in the middle of a flow and continuing the journey after conversion.
Simulate permission prompts [#simulate-permission-prompts]
Using the permissions tap behavior, you can test Flows without having to run it on device. The canvas view will allow you to mock either response when you interact with a component with the permission behavior:
Use indicators for longer flows [#use-indicators-for-longer-flows]
If your flow has more than 3-4 pages, add an Indicator element. Users are more likely to complete a flow when they can see:
* How far they've come.
* How much is left.
Progress visibility reduces abandonment, especially in onboarding flows where users might otherwise wonder "how much longer is this?"
Keep flows focused [#keep-flows-focused]
Flows work best when they have a clear, single purpose:
* **Onboarding:** Gathering preferences and introducing the app.
* **Cancellation:** Understanding why users are leaving and offering alternatives.
* **Upsell:** Guiding users to a higher tier or add-on.
If a flow is getting too long or trying to do too many things, consider splitting it into multiple flows. A focused 5-page flow is better than a sprawling 15-page one. When building a new flow, build linear first so all your pages are created and connected in a straight line, then test the basics to make sure navigation works and content looks right, and finally add branching once the foundation is solid. It's much easier to debug a simple flow than a complex one, so get the basics working before adding sophistication.
# Autoscroll
Adding an autoscroll component [#adding-an-autoscroll-component]
The autoscroll component was built to make creating marquee-like content easy. To use the autoscroll component:
1. In the left sidebar, click **+** to add a new element.
2. Choose **Autoscroll** under the "Layout" header.
The autoscroll component requires an explicit `width` set. Generally, setting this to 100% of the viewport's width works well. This is also the default size set:
Adding contents to autoscroll [#adding-contents-to-autoscroll]
The autoscroll component has a few demonstration items added to it by default. You can remove these and add your own content:
Controlling scroll speed [#controlling-scroll-speed]
To control the scrolling speed, change the `Infinite Scroll Speed` property when the autoscroll component is selected. When it's first added, this value is intentionally set low so you can configure the component first. Change this to a higher value to see its content scroll:
# Carousel
Adding a carousel component [#adding-a-carousel-component]
The carousel component was built to make progressing slide designs easy. It's similar to a [slides component](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-slides-component), except it automatically progresses through its contents instead of being primarily gesture driven. To use the carousel component:
1. In the left sidebar, click **+** to add a new element.
2. Choose **Carousel** under the "Layout" header.
The carousel component requires an explicit `width` set. Generally, setting this to 100% of the viewport's width works well. This is also the default size set:
By default:
* The carousel `Scroll` property is set to `Paging`. Required.
* It's `Wrap` property is set to `Don't Wrap`. Required.
* The `Snap Position` property is set to `Center`. Editable.
* `Auto Paging` is set to `Enabled`. Editable.
* Finally, `Paging Delay` is intentionally set low to help with designing its content. Set it to a higher value to see the carousel in action.
Adding contents to carousels [#adding-contents-to-carousels]
The carousel component has a few demonstration items added to it by default. You can remove these and add your own content:
Tracking or updating the displayed element in a carousel [#tracking-or-updating-the-displayed-element-in-a-carousel]
When a carousel element is added, Superwall automatically creates an element variable for it (accessed through the **[Variables](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables)** tab in the left sidebar, or the variables button in the **[floating toolbar](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-floating-toolbar)**). Its name will match whatever is in the element hierarchy in the left sidebar:
You can use this to:
* Select a product based off of the index of the carousel.
* Have a button progress to the next slide.
* Change text using [dynamic values](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-dynamic-values) based on the index.
* etc.
The variable's name is derived by the node's unique identifier. You don't need to set or generally
be aware of this value.
For example, here the button progresses to the next slide by incrementing the slides `Child Page Index` variable:
As another example, we could change the text to represent the different product periods we've set up for our fictional products of weekly, monthly and annual. By using a dynamic value, we can simply check which carousel index is showing and change the text accordingly:
# Debugger
To view the paywall debugger, click the **Debugger** button from the **sidebar**
The debugger shows a raw representation of selected components. This is useful if you're trying to debug layouts or designs, since the CSS Superwall is generating will show here. It also will show what variables are applied and what they're specifically being evaluated to.
In this example, there is a variable called "Safe Area Top", set to 64 pixels. It's been set to the component's horiztonal padding for demonstration purposes:
When the component is selected, and the debugger is opened — you can confirm that variable is being applied to the component's padding:
If you're unsure of why a paywall has a design quirk or isn't displaying as you'd expect, open the debugger and take a look at the CSS.
# Drawers
Adding a drawer component [#adding-a-drawer-component]
The drawer component was built to make displaying contents from a bottom drawer easy, right out of the box. To use the drawer component:
1. In the left sidebar, click **+** to add a new element.
2. Choose **Drawer** under the "Base Elements" header.
Drawers will automatically show a dimming view behind them when presented. Tapping on it will dismiss the drawer. By default, they are interactive — meaning they can be dismissed via a drag gesture. You can also change this to be `manual`, letting you explicity control when it presents or dismisses. Toggle this under the `Dismissable` property:
Presenting drawers [#presenting-drawers]
When a drawer element is added, Superwall automatically creates an element variable for it (accessed through the **[Variables](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables)** tab in the left sidebar, or the variables button in the **[floating toolbar](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-floating-toolbar)**). Its name will match whatever is in the element hierarchy in the left sidebar:
To toggle its open state, you can use a tap behavior on a button or another element. In this example, we add a [tap behavior](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements#tap-behaviors) to the button, which toggles the element variable's `Is Open` value:
You can also bind a drawer's open state to another paywall variable. For example, use `state.didAbandonTransaction` to open a recovery offer drawer after the user cancels the App Store or Google Play purchase sheet. See [Abandoned Transaction Paywalls](/docs/dashboard/guides/tips-abandoned-transaction-paywall) for the full setup.
The variable's name is derived by the node's unique identifier. You don't need to set or generally be aware of this value.
Adding content to drawers [#adding-content-to-drawers]
By default, drawers have a minimum height set. This is a general size that works well, but in most cases you'll want to add a [stack](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-stacks) and have the drawer derive its height from that. Or, you can add a stack and set its height equal to the drawer's minimum height. Generally, that approach is not as flexible since you likely want the drawer's height to be determined by the content inside of it:
By default, there is a minimum height on the drawer. Here, we've removed that so that the inner stack will control its height:
Since the drawer's minimum height was cleared, now it'll derive its height from the stack inside of it:
# Duplicating Paywalls
To duplicate a paywall live, click the **Duplicate** button in the top-right side of the editor:
You can choose to duplicate the paywall into the current project, or into another one. If you choose to duplicate it into another project, you'll be prompted to select which app you want to duplicate it into:
Additionally, you can duplicate a paywall by opening the **Paywalls** view from the left-hand sidebar for any app you have. Below any paywall to duplicate, click the **Duplicate** button at the bottom:
# Dynamic Values
Dynamic Values allow you to create rules and control flow statements to conditionally apply variables. You can use it for things like:
* Changing the text of a component based on which product is selected.
* Hide and show a modal from a button click.
To open the dynamic values editor, **click** on either the gear icon in the **component editor**, or simply **click** on any property in the **component editor**. In the dropdown, choose **Dynamic**:
When the dynamic values editor shows, **click** on **Add Value** to get started.
Check out our introductory video covering [dynamic values on YouTube](https://youtu.be/bw9ve8d2rek?feature=shared).
Assigning variables without conditions [#assigning-variables-without-conditions]
**First off, to simply assign a variable *without* a condition, you still use the dynamic values editor.** For example, if you want some text component's color to match something you have in your [theme](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-theme) — just select it and don't insert any rule.
Here, we set the text to the theme's primary color:
Setting dynamic values [#setting-dynamic-values]
The dynamic values editor works like most control flow interfaces. You set a condition, and choose what should happen when it's met. You can chain multiple conditions together, too. Or, simply use an if/else format.
Check out this example:
Notice how you can use [variables](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables) within the dynamic values editor,
too.
It's saying:
1. When the product has an introductory offer (i.e. the condition)
2. Then set the text of the component to "Start \{\{ products.selected.trialPeriodText }} free trial" (i.e. what to do when a condition is met)
3. Otherwise, set it to "Subscribe for \{\{ products.selected.price }} / \{\{ products.selected.period }}."
You can also add rules within a group.
Rules versus group [#rules-versus-group]
When you add a condition, you'll have the choice to either add a rule or a group:
Think about them like this:
* Use **rule** when you have one condition you're checking.
* Ex: If the user has a free trial available, do this.
* Use **group** when you need to aggregate several conditions together to check.
* Ex: If the user has a free trial available *and* they are in the United States, do this.
* Use **both** of them together to check complex conditions.
* Ex: If the user has a free trial available *and* they are in the United States, *and* they are on a certain version, do this.
In programming terms, it's a bit like this:
```swift
if user.hasPro && (user.isLegacy && user.isEligibleForProPlus) {
showUpsellToLegacyUsers()
}
```
The first part of that statement would be a **rule** and the second check that's grouped together would be a **group**.
You can add rules within groups, or more groups within an existing group.
Free trial detection [#free-trial-detection]
A common use-case of dynamic values is to conditionally show or hide components, or change copy, based on whether or not the user is eligible for a free trial. To do this, set up a dynamic value as follows:
In short, use `products.hasIntroductoryOffer` to detect whether or not a free trial is available.
If a user has already claimed a free trial for any of the products within the subscription group,
this value will be `false`.
Examples [#examples]
This text component's color is to set to the theme's primary color without any condition (ie. it
should always be this color).
If the product has an introductory offer, the text component will read "Try for free".
Here, we set the text to be larger than it normally would be if the user an introductory offer
and they haven't seen a paywall in 3 days.
Here, some text is set if the user's app version is greater than `1.1.0` and they are on an
iPhone. If those are true, and they have an introductory offer — the text "Power up your iPhone
like never before" is used.
# Floating Toolbar
The floating toolbar sits at the bottom of the editor preview. It controls how you view and interact with your paywall or flow. Your selected view mode is remembered across sessions.
View modes [#view-modes]
The left side of the toolbar has a three-way switcher: **Legacy**, **Device**, and **Flow**.
**Device** focuses on a single page at a time. It zooms in on the selected page so you can interact with it like a real device. Tap buttons, test navigation, trigger actions, and see how things behave. If you are testing a paywall or stepping through a flow page by page, Device is the right mode.
**Flow** expands out and shows all of your pages at once, laid out with their connections visible. This is where you build and edit the structure of a flow: add pages, draw routes, set up branches, and rearrange the layout. If you are creating or editing a flow, this is the mode to work in.
**Legacy** is the original editor toolbar with zoom controls, a refresh button, and basic preview options. If you are working on an older paywall, this is the view you are used to.
A good mental model: Device is for testing, Flow is for building. You can switch between them at any time.
Toolbar controls [#toolbar-controls]
Most controls are shared across Device and Flow modes. From left to right:
* **View mode switcher:** Legacy, Device, or Flow.
* **Center canvas:** Resets the canvas position so everything is centered in the viewport.
* **Auto-layout branches** (Flow mode only)**:** Snaps all pages into a clean, centered layout. Useful when you have been dragging pages around while building a flow and want to tidy things up.
* **Toggle minimap:** Shows or hides a thumbnail overview. In Flow mode, the minimap is especially helpful for navigating larger flows. You can click and drag within it to jump to a specific area.
* **Device selector:** Switch between iPhone, iPhone SE, iPhone XL, iPad, and Desktop previews.
* **Orientation:** Toggle between portrait and landscape.
* **Variables:** Open the variable editor to view or edit variables used across your paywall. You can filter by variables in use, or show all of them.
Zooming [#zooming]
Zooming works differently depending on the view mode.
In **Legacy** mode, use the zoom slider in the toolbar to adjust the preview scale:
In **Device** and **Flow** modes, pinch and zoom with your trackpad or mouse to zoom in and out of the canvas.
# Indicator
The indicator element displays progress through a multi-page flow, showing users where they are and how much remains. Adding an indicator reduces abandonment by setting clear expectations.
Adding an indicator element [#adding-an-indicator-element]
To add an indicator element:
1. In the left sidebar, click **+** to add a new element.
2. Choose **Indicator** under the "Onboarding" header.
Configuration [#configuration]
The indicator has two main properties: **Current Item** and **Total Items**.
**Current Item** controls which step is highlighted as active. By default, this uses the indicator's own state (`"Indicator".currentIndex`), which automatically tracks the user's position in the flow.
**Total Items** determines how many steps the indicator displays. You can set this using one of three modes:
* **Number:** Enter a fixed number directly (e.g., `5` for a five-page flow). Use this when you know the exact number of steps ahead of time.
* **Child Count:** Select another element on the page, and the indicator will use that element's child count as the total. This is useful when the number of steps is determined by dynamic content, such as a stack of slides.
* **State:** Bind the total to a variable. This gives you full control over the count through your flow's state, and is useful when the total changes based on branching or other conditions.
Dynamic progress [#dynamic-progress]
Any indicator item is available as a variable. You can view variables either from the left side [variables](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables) menu, or via the floating toolbar. Either case, it's exposed via **Element -> Indicator**.
You can bind the indicator to the actual flow progress using dynamic values. This way, the indicator automatically updates as users move through pages.
Use the indicator's properties in dynamic values to show progression:
Placement [#placement]
Common indicator placements:
* **Top of the page:** Shows progress at a glance as users enter each page.
* **Inside a navigation bar:** Integrates with back/forward controls.
* **Above the CTA:** Reminds users they are almost done before the final action.
For shorter flows (2-3 pages), an indicator may be unnecessary. Use your judgment based on the content density and expected completion time.
# Input
The input element lets users type a response directly into your paywall or flow. Use it to capture names, email addresses, feedback, or any custom text input.
Adding an input element [#adding-an-input-element]
To add an input element:
1. In the left sidebar, click **+** to add a new element.
2. Choose **Input** under the "Onboarding" header.
Placeholder text [#placeholder-text]
Set placeholder text to give users a hint about what to enter. The placeholder appears inside the input field before the user starts typing.
Try to make the placeholder text contextual to the type of input you expect:
* "Enter your name"
* "[you@example.com](mailto\:you@example.com)"
* "Tell us what you think"
Keyboard type [#keyboard-type]
Choose the appropriate keyboard type to make input easier for users:
* **Text:** Standard text keyboard. This is the default.
* **Email:** Keyboard optimized for email addresses (includes @ and . keys).
* **Number:** Numeric keypad for number-only input.
* **Telephone:** Phone number keypad.
* **URL:** Keyboard optimized for entering URLs.
* **Password:** Text keyboard with secure text entry enabled.
* **Search:** Text keyboard with a search-style return key.
Selecting the right keyboard type improves the user experience and reduces input errors.
Using responses [#using-responses]
The text users enter can be stored as a user attribute, used as a variable, dynamic values, and more. Any multiple choice item is available as a variable. You can view variables either from the left side [variables](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables) menu, or via the floating toolbar. Either case, it's exposed via **Element -> Input**.
This is useful for:
* **Personalization:** Use the user's name on later pages (e.g., "Welcome, Sarah!").
* **Email capture:** Collect email addresses during onboarding for follow-up.
* **Feedback collection:** Gather open-ended responses for product insights.
* **Backend integration:** Pass the value to your backend via webhooks or the SDK.
Using input values in dynamic content [#using-input-values-in-dynamic-content]
After a user enters text, you can reference it elsewhere using dynamic values. For example, if you store the input under the key `user_name`, you can display it on the next page with a personalized greeting.
Input elements work well paired with a "Continue" button that has a Navigate Page action. The user enters their response, then taps to proceed.
# Layout
The **Layout** tab in the **sidebar** provides a visual outline of your paywall's components. Hovering over elements will highlight them in the device preview:
Adding elements [#adding-elements]
Click the **+** in the left sidebar or in the **Layout** tab to select an element to add to your paywall. This will present our library of components along with any snippets you've made (along with some of our own stock recipes):
At the top, you can choose from our core, fundamental building blocks such as a stack, text and more.
Most of your layouts should probably start with a Stack component. From there, add child elements
to them to construct any layout.
Our stock components include:
* **Stack:** The foundation of any layout, it works like CSS Flexbox layout. Check out this [reference](https://flexbox.help) if you're new to Flexbox rules, alignment or just need a refresher.
* **Text:** Text components that can use [variables](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables), different font styles and more.
* **Image:** Add any image, or URL where it's located, to your paywall.
* **Video:** Add any video, or URL where it's located, to your paywall. You can choose to loop it, show or hide playback controls, mute it or toggle autoplay. We support most file formats but we do recommend keeping the file size around 2-5 megabytes.
* **Icon:** Superwall has over 1,000 searchable icons to available, along with the ability to edit their weight and color. You can browse them by category by clicking on the right hand side:
* **Lottie:** A [Lottie](https://airbnb.design/lottie/) file. Either point to a URL of where it's located at, or upload one yourself. You can also customize looping, playback speed and more.
* **Drawer:** A basic drawer which presents from the bottom that can be configured to dismiss via fluid gestures or manually.
* **Navigation:** A container to set up multi-page designs, complete with a transition style.
Snippets [#snippets]
Snippets allow you to aggregate one or more components together to reuse. For example, if you have a stack component with an icon and a text label, you could group that together to use as a component either in the current paywall, or another one later.
To add a snippet, select the **component** you want to use for your snippet and click the **bookmark** icon:
Then, give your snippet a name and add a description. When you're finished, click **Save**:
From then on, you can reuse it when adding a new component to any paywall. You'll see your own snippets at the bottom. Here's an example of a few custom snippets:
Note that you can edit any snippet you add, it will *not* overwrite the original snippet. If you do want any edits you make to be used as a snippet, simply make another snippet with the one you've edited.
Re-ordering components or adding them as children [#re-ordering-components-or-adding-them-as-children]
To reorder components on your paywall, you can simply drag and drop them in the **Layout** tab:
1. **Hovering on** another component will add the current component you're dragging as a child of the other component.
2. **Hovering above or below** other components will reorder the component you're dragging either above or below the other component.
You'll see a box filled in when you're adding a component as a child component, or you'll see a thin line to indicate you're reordering components. In the image below, notice how "The second cool feature" should be listed in the middle. Simply dragging it above the component in the middle will correctly reorder it:
Reordering and adding components as children is all done via the **Layout** tab. It'll always represent the current hierarchy of your paywall's components.
Deleting, renaming and copying elements [#deleting-renaming-and-copying-elements]
You can delete and copy components by selecting them in the **Layout** tab and then clicking on one of the following icons as seen in this image:
From left to right, here's what each icon does:
* **Trashcan:** Deletes the component.
* **Bookmark:** Creates a snippet from the component.
* **Square on Square:** Copies the component.
* **Plus sign:** Adds a new component.
You can also select a component directly in the **live preview canvas** and use `⌘+C` (Mac) or `Ctrl+C` (Windows) to copy it, then `⌘+V` or `Ctrl+V` to paste it.
To **rename** a component, **double click** on its current name to edit it.
Context menu [#context-menu]
You can also right-click on a component to open a context menu. It contains nearly all of the editing options you'll find in the component editor. In addition, this is a great way to see and learn the available keyboard shortcuts:
Component editor [#component-editor]
Any component you select will open its editable properties in the **component editor**, which is on the right side of the editor window. You can change padding, text and anything related to a component here. To learn more, check out the dedicated page over editing components [here](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements).
Editor toolbar [#editor-toolbar]
In the top right of the editor, you'll see a toolbar with a few icons:
From left to right, here's what each icon does:
* **Undo:** Undo the last action. You can also use the `command/control+Z` keyboard shortcut.
* **Redo:** Redo the last action.
* **Preview:** Preview the paywall on device. For more, read this [doc](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-previewing).
* **Duplicate:** Duplicate the current paywall within the current project, or into another one.
* **Share:** Allows you to share your paywall externally. See "Paywall sharing" below for more.
* **History:** View paywall edit history and revert to a previous version. See "Paywall history" below for more.
* **Publish:** Publish the current edits to your paywall and make them live.
Paywall sharing [#paywall-sharing]
Clicking the share icon allows you to share your paywall externally. This is useful for sharing with your team or clients. Once you click on share, you'll be prompted to generate a link:
From there, you can share the link with anyone. When they open it, the paywall will be duplicated into their own Superwall project (though, without your existing products):
You'll know that a paywall has a share link available when the green banner at the top of the editor is present:
To make the paywall private again, simply click the **Share** button once more and **click** the **Make Private** option.
Paywall history [#paywall-history]
All edits you make in the paywall editor are stored in the history view:
**Click** on any entry to rollback your paywall to that version. Additionally, you can rename and add more context about any entry by clicking the **pencil** icon:
Anytime you save a your paywall, a new entry will be created in the history.
To quickly see a snapshot in your history, you can click on an entry and it will show a live preview in the editor.
# Liquid
Liquid is a templating language that you can use to easily build text in your paywall. The simplest way to get started is simply by referencing a variable
with curly brackets. `{{ user.firstName }}` will output the user's first name. (Assuming you've called `setUserAttributes` with `firstName` previously in the SDK).
However, Liquid is much more flexible then simple curly brackets. It also offers "filters" which allow you to operate on the
variables before outputting them. Ex: `{{ 1 | plus: 3 }}` will output `4`. They work left to right and do not support order of
operations. (You can get around this limitation by using `assign`).
Liquid syntax formatting [#liquid-syntax-formatting]
In text, you can use [Liquid filters](https://shopify.github.io/liquid/filters/abs/) to modify output. To use filters, add a pipe after the variable. Then, add in one or more filters:
```
// Results in 17, the absolute value of -17
{{ -17 | abs }}
```
For example, to capitalize a text variable, you would write:
```
// If the name was "jordan", this results in "JORDAN"
{{ user.name | upcase }}
```
Working with Product Prices [#working-with-product-prices]
When working with product prices in your paywall, you have two options depending on whether you need the raw numeric value or a pre-formatted price string.
Formatted vs. Raw Prices [#formatted-vs-raw-prices]
**Formatted Price (`{{ products.selected.price }}`)**
This provides a pre-formatted price string that includes the currency symbol and is formatted according to the user's locale with two decimal places.
```liquid
{{ products.selected.price }}
// Output -> "$0.99" (for US users)
// Output -> "€0.99" (for EU users)
// Output -> "¥99" (for Japanese users)
```
**Raw Price (`{{ products.selected.rawPrice }}`)**
This provides the raw numeric value without any formatting, which is useful when you need to perform mathematical operations.
```liquid
{{ products.selected.rawPrice }}
// Output -> 0.99
// Output -> 9.99
// Output -> 99
```
Formatting Numbers to Two Decimal Places [#formatting-numbers-to-two-decimal-places]
If you're working with raw prices or performing calculations, you may need to format the result to show exactly two decimal places. You can use Liquid's `round` filter combined with number formatting:
```liquid
// Format a raw price to two decimal places
${{ products.selected.rawPrice | round: 2 }}
// Output -> "$0.99"
// Calculate a discount and format to two decimal places
{% assign discounted_price = products.selected.rawPrice | times: 0.8 %}
Sale Price: ${{ discounted_price | round: 2 }}
// Output -> "Sale Price: $0.79" (for a $0.99 product with 20% discount)
// Calculate savings and format to two decimal places
{% assign original_price = 9.99 %}
{% assign current_price = products.selected.rawPrice %}
{% assign savings = original_price | minus: current_price %}
You save: ${{ savings | round: 2 }}!
// Output -> "You save: $5.00!" (if current price is $4.99)
```
Use `{{ products.selected.price }}` when you want a properly formatted price string that respects the user's currency and locale. Use `{{ products.selected.rawPrice }}` when you need to perform calculations or custom formatting.
Liquid inside Image URLs [#liquid-inside-image-urls]
You can use Liquid for any image URL in the Superwall editor. It can either be the entire URL, or interpolated with an existing one:
```javascript
// As the entire URL...
{{ user.profilePicture1 }}
// Or interpolated within one...
https://myApp.cdn.{{ events.activeEvent }}
```
You can access any variable available too ([including user created ones](/docs/sdk/quickstart/feature-gating#placement-parameters)), which makes it the right tool to display dynamic content for your images. Here are some examples:
* **User Profile Picture in a Dating App:** Display the profile image of a user that someone has tapped on:
`https://datingApp.cdn.{{ user.profilePicture1 }}`
* **Event-Specific Banners for Sports Apps:** Pull in images like team logos or event banners for ongoing or upcoming games: `https://sportsApp.cdn.{{ events.currentGame.teamLogo }}`
Here's an example:
Custom Liquid filters [#custom-liquid-filters]
To make it easier to express dates & countdowns we've added several non-standard filters to our Liquid engine.
`date_add` [#date_add]
Add a specified amount of time to a date.
**Usage**:
`[date string] | date_add: [number|ms string], (unit)`
There are two ways to specify the amount of time
to add:
1. By passing a number and a unit as arguments. The unit can be one of `seconds`, `minutes`, `hours`, `days`, `weeks`,
`months`, or `years`. For example, `{{ "2024-08-06T07:16:26.802Z" | date_add: 1, "days" }}` adds one day to the
date.
2. By using the ['ms'](https://github.com/vercel/ms?tab=readme-ov-file#ms) style of specifying a duration. This format is flexible
but generally you specify a number followed by a unit as part of a single string. Ex: `1d` (1 day), `2h` (2 hours), `30m` (30 minutes), etc.
For example, `{{ "2024-08-06T07:16:26.802Z" | date_add: "1d" }}` adds one day to the date.
You can chain multiple `date_add` and `date_subtract` filters together to add or subtract multiple units of time.
Ex: `{{ "2024-08-06T07:16:26.802Z" | date_add: 1, "days" | date_add: 2, "hours" }}` adds one day and two hours to the date.
**More Examples**:
```liquid
{{ "2024-08-06T07:16:26.802Z" | date_add_minutes: 30 }}
// Output -> '2024-08-06T07:46:26.802Z'
```
`date_subtract` [#date_subtract]
Subtract a specified amount of time from a date.
**Usage**:
`[date string] | date_subtract: [number|ms string], (unit)`
There are two ways to specify the amount of time
to subtract:
1. By passing a number and a unit as arguments. The unit can be one of `seconds`, `minutes`, `hours`, `days`, `weeks`,
`months`, or `years`. For example, `{{ "2024-08-06T07:16:26.802Z" | date_subtract: 1, "days" }}` subtracts one day from the
date.
2. By using the ['ms'](https://github.com/vercel/ms?tab=readme-ov-file#ms) style of specifying a duration. This format is flexible
but generally you specify a number followed by a unit as part of a single string. Ex: `1d` (1 day), `2h` (2 hours), `30m` (30 minutes), etc.
For example, `{{ "2024-08-06T07:16:26.802Z" | date_subtract: "1d" }}` subtracts one day to the date.
You can chain multiple `date_add` and `date_subtract` filters together to add or subtract multiple units of time.
Ex: `{{ "2024-08-06T07:16:26.802Z" | date_subtract: 1, "days" | date_subtract: 2, "hours" }}` subtracts one day and two hours from the date.
**More Examples**:
```liquid
{{ "2024-08-06T07:16:26.802Z" | date_subtract_minutes: 30 }}
// Output -> '2024-08-06T06:46:26.802Z'
```
`date` [#date]
Format a date in a specific way.
**Usage**:
`[date string] | date: [format string]`
The [`date`](https://liquidjs.com/filters/date.html) filter is a standard Liquid filter that formats a date. You can use it to format a date in any way that
Javascript's default date utility can parse. For example, `{{ "2024-08-06T07:16:26.802Z" | date: "%s" }}` formats the
date as a Unix timestamp. Here are some common date formats:
**Common Formats**
| Format | Example Output | Description |
| ------------------- | ------------------------- | ---------------------------------------------------------------------------- |
| `%s` | `1722929186` | Unix timestamp |
| `%Y-%m-%d %H:%M:%S` | `2024-08-06 07:16:26` | Year, month, day, hour, minute, second |
| `%a %b %e %T %Y` | `Sun Aug 6 07:16:26 2024` | Abbreviated day of the week, abbreviated month, day of the month, time, year |
| `%m/%d/%y` | `08/06/24` | Month, day, year (Common US Format) |
**Format Reference**
| Format | Example | Description |
| ------ | ---------- | --------------------------------------------- |
| %a | Tue | Shorthand day of the week |
| %A | Tuesday | Full day of the week |
| %b | Aug | Shorthand month |
| %B | August | Full month |
| %d | 06 | Zero padded day of the month |
| %H | 07 | Zero padded 24-hour hour |
| %I | 07 | Zero padded 12-hour hour |
| %j | 219 | Day of the year |
| %m | 08 | Zero padded month |
| %M | 16 | Zero padded minute |
| %p | AM | AM or PM |
| %S | 26 | Zero padded second |
| %U | 31 | Week number of the year, starting with Sunday |
| %W | 31 | Week number of the year, starting with Monday |
| %x | 8/6/2024 | Locale date |
| %X | 7:16:26 AM | Locale time |
| %y | 24 | Two digit year |
| %Y | 2024 | Four digit year |
| %z | +0000 | Timezone offset |
| %% | % | Literal % |
**More Examples**:
```liquid
{{ "2024-08-06T07:16:26.802Z" | date: "%Y-%m-%d %H:%M" }}
// Output -> '2024-08-06 00:16'
{{ "2024-08-06T07:16:26.802Z" | date: "%B %d, %Y" }}
// Output -> 'August 06, 2024'
{{ "2024-08-06T07:16:26.802Z" | date: "%I:%M %p" }}
// Output -> '12:16 AM'
{{ "2024-08-06T07:16:26.802Z" | date: "%A, %B %d, %Y" }}
// Output -> 'Tuesday, August 06, 2024'
```
`countdown_from` [#countdown_from]
Calculates and formats the difference between two dates as a countdown.
**Usage**:
`[end_date string] | countdown_from: [start_date string], (style), (max unit)`
* `end_date` (required): The end date of the countdown.
* `start_date` (required): The start date of the countdown. Almost always `state.now`.
* `style` (optional): The style of the countdown. Can be one of `digital`, `narrow`, `short`, `long`, `long_most_significant`. The default is `digital`.
* `digital`: Displays the countdown in the format `HH:MM:SS`.
* `narrow`: Displays the countdown in the format `1d 2h 3m 4s`.
* `short`: Displays the countdown in the format `2 hr, 3 min, 4 sec`.
* `long`: Displays the countdown in the format `2 hours, 3 minutes, 4 seconds`.
* `long_most_significant`: Displays the countdown in the format `2 hours, 3 minutes, 4 seconds`, but only shows the most significant unit. Ex: `2 hours` if the countdown is less than 1 day or `3 days` if the countdown is less than 1 month.
* `max unit` (optional): The maximum unit to display in the countdown. Can be one of `years`, `months`, `weeks`, `days`, `hours`, `minutes`, or `seconds`. The default is `hours`. This means for a digital countdown of 72 hours would be represented as `72:00:00`, rather than `3 days`.
* `column` (optional): The column to display in the countdown. Can be one of `years`, `months`, `weeks`, `days`, `hours`, `minutes`, or `seconds`. This means for a digital countdown with the column set to `minutes`, the countdown `47:12:03` would be represented as `12`.
**Common Usage**:
```liquid
// Simple countdown timer
{{ device.deviceInstalledAt | date_add: '3d' | countdown_from: state.now }}
// Output -> '03:00:19'
// Fixed end date, with a message
Our summer sale ends in {{ "2024-08-06T07:16:26.802Z" | countdown_from: state.now, "long_most_significant" }}!
// Output -> Our summer sales ends in 3 days!
// Countdown with a custom column
{{ "2024-08-06T07:16:26.802Z" | countdown_from: state.now, "long", "days", "minutes" }}
// Output -> 12
// One hour countdown timer, starting from the moment a paywall is opened, formatted with just the hour and minute, i.e. 59:36 for fifty-nine minutes, thirty-six seconds
{{ device.localDateTime | date_add: "60m" | countdown_from: state.now, "long", "days", "minutes" }}:{% assign seconds = device.localDateTime | date_add: "60m" | countdown_from: state.now, "long", "days", "seconds" %}{% if seconds < 10 %}0{{ seconds }}{% else %}{{ seconds }}{% endif %}
```
In practice you will almost always use `state.now` as the start date. This is a special variable
that represents the current time. Referencing it will ensure that the countdown re-renders every
second.
`event_name` [#event_name]
You can add "event\_name" as a [variable](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables#custom-variables) to get the name of the placement (trigger event) that caused the paywall to be displayed:
Then, it will be available to use as a custom variable. Once created, it should be listed under the **left sidebar -> Variables -> Params -> Event Name**:
**Common Usage**:
You could display the value in any text element:
```liquid
Triggered by: {{ event_name }}
```
But, more commonly, you might use it with a [dynamic value](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-dynamic-values). Then, you can customize your paywall based on the event name:
# Local Resources
Local resources let you reference media files (such as images and videos) that are bundled directly in your app rather than hosted on a remote server. This means faster load times, no network dependency for those assets, and a smoother experience for your users.
Local resources require **iOS SDK v4.13.0+** or **Android SDK v2.7.7+**. They are not available on
other platforms at this time.
How it works [#how-it-works]
Instead of pointing an image or video to a URL, you can point it to a **local resource ID**. This ID maps to a file that the developer has registered in the native SDK. When the paywall loads, the SDK intercepts the request and serves the file directly from the device. No network call is required.
Set up those resource IDs in your app first by following the [SDK Local Resources guide](/docs/sdk/guides/local-resources).
The editor discovers which resource IDs are available by looking at device attribute events your app has reported in the last 7 days. This means at least one device running your app with the SDK configured must have reported its local resources before they appear in the editor.
Setting a local resource on an image [#setting-a-local-resource-on-an-image]
To use a local resource for an image component:
1. **Select** the image component in the Layout tab or on the canvas.
2. In the component editor, find the image source property.
3. **Click** the **+ Add Local Resource** button.
4. A dropdown will appear listing all resource IDs that devices have reported recently. **Select** the one you want.
The image source will update to use the selected local resource. You can still provide a regular image URL as a **fallback**. If the local resource is unavailable (for example, in the web preview or on a device that hasn't registered that resource), the paywall will fall back to the remote URL automatically.
Setting a local resource on a video [#setting-a-local-resource-on-a-video]
The same flow applies to video components. Select a video, click **+ Add Local Resource**, and choose the resource ID from the dropdown. A fallback URL is recommended for the same reasons as images.
Fallback behavior [#fallback-behavior]
When a local resource is set, the paywall rendering follows this order:
1. **Try the local resource.** The SDK attempts to load the file from the device using the registered resource ID.
2. **Fall back to the remote URL.** If the local file isn't available (not registered, missing from the bundle, or running in the web preview), the regular image or video URL is used instead.
This means you can safely set a local resource without breaking the paywall for users on older SDK versions or other platforms.
Availability in the editor [#availability-in-the-editor]
The resource ID dropdown is populated from device attribute events sent by your app. If you don't see any resource IDs:
* Make sure at least one test device is running your app with the local resources configured in the SDK. For setup instructions, see the [SDK Local Resources guide](/docs/sdk/guides/local-resources).
* The device must have opened a paywall or otherwise triggered a device attributes event within the **last 7 days**.
* Only **iOS** and **Android** platforms support local resources. The dropdown will not appear for other platforms.
If a resource ID hasn't been reported by any device in the last 7 days, the editor will show a
warning. This usually means no active devices have that resource registered, so double-check your
SDK configuration.
When to use local resources [#when-to-use-local-resources]
Local resources are a great fit for:
* **Onboarding videos or hero images** that are critical to the first paywall experience and shouldn't depend on network conditions.
* **Large media files** where you want to avoid CDN costs or ensure instant loading.
* **Offline scenarios** where users may not have a reliable connection when the paywall is presented.
For smaller or frequently changing images, remote URLs are still the simpler choice since they don't require an app update to change.
Related [#related]
* [SDK Local Resources Guide](/docs/sdk/guides/local-resources): How to register local resources in supported SDKs.
* [Styling Elements](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements): General component styling and image editing.
* [Liquid inside Image URLs](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-liquid#liquid-inside-image-urls): Using dynamic URLs for images.
# Paywall Localization
To localize your paywall, **click** on the **Localization** button from the **sidebar**:
There are two ways to localize your paywall:
1. **Simple**: Here, you can use AI to localize your paywall into any language. You can manually refine each value at any point. Quick and accurate.
2. **Advanced**: User external .strings files to localize your paywall. This is ideal when you are using external localization services.
You can switch between the two methods at any time.
Simple localization [#simple-localization]
Simple localization covers all translatable content on your paywall, including text elements and [multiple choice](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-multiple-choice-component) labels. Choice labels are treated the same as text: they appear in the translation panel, are included in CSV exports, and are covered by AI auto-translate.
Once enabled, a new side panel will present to help you localize you paywall:
You can control localization with the options at the top:
Here's what each options does, left-to-right:
| Name | Description |
| ------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Localization | Opens a menu with options to access AI localize settings, switch to advanced localizations, or clear all localizations. |
| Missing Only | Filters the list to show only keys that have no localized value. |
| Import/Export | Allows importing or exporting localization data as a `.csv` file. |
| AI Localize | Starts the AI-powered localization process for selected keys. |
| Add Language | Lets you specify a new language to add for localization. |
To start the AI localization process, **click** on the **Add Language** button. Then, choose **AI Localize**. Superwall will being to localize each value, while respecting the AI Localize settings in place:
Once finished, you'll see all localized values:
You can click on any value to edit it manually.
AI localization settings [#ai-localization-settings]
You can customize how AI localization behaves by changing the settings in the **AI Localize Settings** menu:
The **Formality Style** lets you toggle between formal and informal language. Use the **Localization Style Guide** to provide specific instructions to your brand's voice accurately.
Managing languages [#managing-languages]
To remove or reset a language, **click** on the **three dotts** button next to the language:
To switch back and forth between languages, simply **click** the language at the top:
Advanced localization [#advanced-localization]
After opening the localization panel referenced above, **click** on the **Add Language** button. Choose the language identifier of the locale you're localizing for, and **click** on **Add**:
If there are any existing text components on your paywall, all of them with currently *unlocalized* strings will populate in the sidebar (in this example, we're localizing our text for Spanish speakers):
Click on the **Localize** button on any of them to enter in localized values. When you're done, click **Save**:
From there, go through and localize all of the values. Keep an eye on the progress bar at the top to see how far along you are. Remember to **click** on the **Publish** button at the top right of the editor to commit any localization edits.
When you are localizing strings, the editor will reflect the locale you're editing against so you
can see a live preview of how the text will appear.
Associating localized strings to new or existing text components [#associating-localized-strings-to-new-or-existing-text-components]
When you add new text components, or need to associate a different localization to an existing one — **click** the **Localize** button when the text component is selected. You can either use an existing localized string, or add a new one by clicking the plus button:
When a text component has a localized string attached to it, you'll see the localized string's key in place of the localize button:
You can use variables with localized strings, too. Simply use liquid syntax within your localized
string values to access any variable. Currently, variables themselves are not able to be
localized. Learn more about using variables [here](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables).
Using .strings files [#using-strings-files]
You can download and import .strings files to speed up your translations. This is ideal when you are using external localization services or have a large number of strings to localize.
Exporting .strings files [#exporting-strings-files]
Select **Localization** from the left sidebar, **click** on the **Import** button. Choose "Download template" and the .strings file will be downloaded with all of your currently localized strings:
Importing .strings files [#importing-strings-files]
Select **Localization** from the left sidebar, **click** on the **Import** button. Choose "Import Strings File" and select your local .strings file to upload. Then, all of the updated values will be reflected in the editor.
Localizing period lengths [#localizing-period-lengths]
Superwall will automatically localize period lengths for products. Simply use any of the period-based variables in your text:
For example:
```liquid
Analyze the math of caffeine this {{ products.primary.period | locale: "en" }}
// English, French, and any or any other localized regions you support...
Analyze the math of caffeine this year
Analysez les chiffres de la caféine ce année
```
You can override and remove auto-localization on specific items by setting a `locale` filter:
```liquid
Analyze the math of caffeine this {{ products.primary.period | locale: “en” }}
// Now, it'll show in English in every language
Analysez les chiffres de la caféine ce year
```
Testing localized strings [#testing-localized-strings]
You can preview how localized strings will appear on device. To set this up:
1. Make sure you've got [in-app previews](/docs/sdk/quickstart/in-app-paywall-previews) configured.
2. Open the paywall editor and click "Preview":
3. When it opens, tap the menu located at the top left and choose "Localization":
4. Then, select the language to display and the preview will reload with it:
# Multiple Choice
The multiple choice element presents a set of options for users to select from. It is commonly used in onboarding flows to gather preferences, capture survey responses, or enable [branching](/docs/dashboard/dashboard-creating-flows/linking-pages) based on user input.
Adding a multiple choice element [#adding-a-multiple-choice-element]
To add a multiple choice element:
1. In the left sidebar, click **+** to add a new element.
2. Choose **Multiple Choice** under the "Onboarding" header.
A multiple choice component has configuration options to add items, randomize ordering, and more. Select it from the sidebar, and you'll see these options on the right sidebar:
Selection mode [#selection-mode]
You can configure whether users select one option or multiple:
* **Single-select:** Users pick one option. The selection replaces any previous choice.
* **Multi-select:** Users can pick multiple options. All selections are stored.
Randomize order [#randomize-order]
Enable **Randomize order** to shuffle the options each time the element appears. This is useful for surveys where you want to reduce selection bias from item ordering.
Items [#items]
Each choice has two parts:
* **Label:** The text users see (e.g., "Grow subscriptions").
* **Value:** The internal value stored when selected (e.g., `goal_grow`).
Keep values short and consistent. Use lowercase letters and underscores for readability (e.g., `preferred_plan`, `user_goal`).
To add more choices, click **+ Add** in the component editor. You can reorder choices by dragging them.
Using selections [#using-selections]
Any multiple choice item is available as a variable. You can view variables either from the left side [variables](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables) menu, or via the floating toolbar. Either case, it's exposed via **Element -> Multiple Choice**.
Two variables are available for each multiple choice element:
* **`selectedValue`:** The programmatic value of the selected choice (e.g., `goal_grow`). Use this for routing conditions, storing as user attributes, or any logic that depends on a stable internal value.
* **`selectedLabel`:** The display label of the selected choice (e.g., "Grow subscriptions"). This is useful for showing the user's selection back to them in text on a later page. If localization is active, `selectedLabel` returns the translated label for the user's locale.
When a user makes a selection, these variables can be used in several ways:
* **Routing conditions:** Branch the flow based on what the user selected. See [branching](/docs/dashboard/dashboard-creating-flows/linking-pages#branching).
* **User attributes:** Store the selection as a user attribute for later personalization or analytics.
* **Dynamic values:** Reference the selection in text elsewhere on the page or in later pages.
Using selections for branching [#using-selections-for-branching]
Multiple choice is the primary way to enable conditional branching in flows. After a user selects an option, you can route them to different pages based on their choice.
For example, if you ask "What is your primary goal?" with options like "Grow subscriptions" and "Reduce churn," you can send each group to a tailored page.
See [Linking Pages](/docs/dashboard/dashboard-creating-flows/linking-pages) for detailed branching setup.
Localization [#localization]
Multiple choice labels can be localized just like text elements. When you add a language in the [localization panel](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-localization), choice labels are included alongside your other translatable strings. This means:
* **AI Localize** translates choice labels automatically with the rest of your text.
* **CSV export/import** includes choice labels as rows, so your translation workflow covers them.
* **Missing translation filters** account for choice labels, so you can spot untranslated options.
* **Outdated detection** flags choice labels when the base text changes after translation.
When a user makes a selection while localization is active, the `selectedLabel` variable returns the translated text for their locale.
Multiple choice elements work in both standalone paywalls and multi-page flows. In flows, they unlock branching. In paywalls, they can capture preferences before purchase.
# Navigation
The navigation component is also the foundation for [Flows](/docs/dashboard/dashboard-creating-flows/getting-started). Adding a navigation element to a paywall unlocks multi-page experiences with branching, conditional routing, and more. If you're building something like onboarding or a cancellation survey, check out the [Flows docs](/docs/dashboard/dashboard-creating-flows/getting-started).
Adding a navigation component [#adding-a-navigation-component]
The navigation component was built to make paging, or navigating through paywall content, easy. To use the navigation component:
1. In the left sidebar, click **+** to add a new element.
2. Choose **Navigation** under the "Base Elements" header.
You'll see the navigation component in the element hierarchy. **In most cases, you'll want a navigation's width to be 100% of the viewport**:
From there, add in content to create pages using [stacks](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-stacks):
Similar to the parent navigation element, it helps to have the width of your stacks be 100% of the parent.
Changing pages [#changing-pages]
When a navigation element is added, Superwall automatically creates an element variable for it (accessed through the **[Variables](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables)** tab in the left sidebar, or the variables button in the **[floating toolbar](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-floating-toolbar)**). Its name will match whatever is in the element hierarchy in the left sidebar:
Each top-level child within the navigation component represents a *current index* value, starting from 0. Changing this value will change which page is displayed. In this example, we add a [tap behavior](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements#tap-behaviors) to the button, which increments the element variable's `current index` value:
The variable's name is derived by the node's unique identifier. You don't need to set or generally be aware of this value.
Editing transitions [#editing-transitions]
A navigation component has four different transitions to use. Edit them by **clicking** on the navigation component from the left sidebar, and then selecting a value in the trailing sidebar:
Available transitions are:
1. **No Transition:** No animation will occur during page changes.
2. **Push:** Each page is pushed on or off of the next page, similar to navigation stacks in iOS.
3. **Fade:** Each page change results in an opacity fade in or out.
4. **Slide:** Similar to push, but the animation results in a smooth transition between pages, much like scrolling through a carousel would look.
# Notifications
To configure a notification which displays before a free trial ends, click the **Notifications** button from the **sidebar**:
You can add a local notification that fires before a free trial ends. After the user starts a free trial, the app will ask them to enable notifications if they haven't already done so.
In sandbox mode, the free trial reminder will fire after x minutes, instead of x days.
Configuration [#configuration]
To turn on a trial reminder notification, click **+ Add Notification**. From there, there are four fields to configure:
1. **Title**: Shows at the top of the notification.
2. **Subtitle**: Displays directly below the title in a smaller font. Not required.
3. **Body**: Shows in the primary body of the notification.
4. **Delay**: How many days before the trial ends the notification should fire.
Here's where those values show up on a notification:
These are scheduled as local notifications as soon as they are configured.
Dynamic notification timing [#dynamic-notification-timing]
Requires iOS SDK v4.10.7+ or Android SDK v2.6.6+.
The SDK automatically calculates the actual trial end date based on the product's introductory offer period from the app store. This means notifications are scheduled relative to when the trial **actually ends** — not when it starts.
For example, if you set a delay of 3 days:
| Product trial length | Notification fires on |
| -------------------- | ---------------------------- |
| 7-day trial | Day 4 (3 days before end) |
| 14-day trial | Day 11 (3 days before end) |
| 1-month trial | \~Day 27 (3 days before end) |
This ensures users receive trial-ending reminders at the right time — when the reminder is actually relevant to their subscription decision — regardless of the product's trial length.
On older SDK versions (pre-4.10.7 on iOS), the notification fires X days **after** the trial starts rather than X days **before** it ends. Upgrade to the latest SDK for accurate timing.
# Getting Started with the Paywall Editor
There are two primary ways to create a paywall:
1. Using our editor from scratch.
2. Or, start with a [template](https://superwall.com/applications/\:app/templates) and edit it to fit your needs.
Using the Editor [#using-the-editor]
On the Superwall dashboard under **Paywalls**, click **+ New Paywall** and select **From Scratch**:
The Paywall Editor consists of 3 sections:
1. **Sidebar -** General paywall settings, designs and products to show on the paywall. You can toggle through its sections using keyboard shortcuts such as `command/control 1/2/3`, etc.
2. **[Device Preview](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-previewing) -** An interactive preview of your paywall on a mock device.
3. **Component Editor -** Fully customize components, use variables and more on your paywall.
Starting with a Template [#starting-with-a-template]
On the Superwall dashboard under **Paywalls**, click **+ New Paywall** and select **Use Template**:
This will redirect you to the templates page where you can browse different types of paywall templates. Clicking on one will allow you to preview it:
Click **Use Template**, and it will open in our editor ready to customize:
Request a Design [#request-a-design]
If you have an existing design in Figma, Sketch or something similar, you can ask Superwall to create it for you in our editor. Please allow about five business days from the date of your request (give or take). Once it's finished, it'll be uploaded to your Superwall account.
To request one, on the Superwall dashboard under **Paywalls**, click **+ New Paywall** and select **Request a Template**:
Legacy Editor [#legacy-editor]
If you're still using our legacy editor, you can still access those docs [here](/docs/configuring-a-paywall). If you're not sure which editor you're using, any legacy editor will have a `v3` or lower in the URL:
# Previewing
To preview a paywall on device, click **Preview** in the top-right side of the editor:
To enable this functionality, you'll need to use deep links.
Adding a Custom URL Scheme (iOS) [#adding-a-custom-url-scheme-ios]
To handle deep links on iOS, you'll need to add a custom URL scheme for your app.
Open **Xcode**. In your **info.plist**, add a row called **URL Types**. Expand the automatically created **Item 0**, and inside the **URL identifier** value field, type your **Bundle ID**, e.g., **com.superwall.Superwall-SwiftUI**. Add another row to **Item 0** called **URL Schemes** and set its **Item 0** to a URL scheme you'd like to use for your app, e.g., **exampleapp**. Your structure should look like this:
With this example, the app will open in response to a deep link with the format **exampleapp\://**. You can [view Apple's documentation](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app) to learn more about custom URL schemes.
Adding a Custom Intent Filter (Android) [#adding-a-custom-intent-filter-android]
For Android, add the following to your `AndroidManifest.xml` file:
```xml
```
This configuration allows your app to open in response to a deep link with the format `exampleapp://` from your `MainActivity` class.
Handling Deep Links (iOS) [#handling-deep-links-ios]
Depending on whether your app uses a SceneDelegate, AppDelegate, or is written in SwiftUI, there are different ways to tell Superwall that a deep link has been opened.
Be sure to click the tab that corresponds to your architecture:
```swift AppDelegate.swift
import SuperwallKit
class AppDelegate: UIResponder, UIApplicationDelegate {
// NOTE: if your app uses a SceneDelegate, this will NOT work!
func application(\_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
return Superwall.shared.handleDeepLink(url)
}
}
```
```swift SceneDelegate.swift
import SuperwallKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// for cold launches
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let url = connectionOptions.urlContexts.first?.url {
Superwall.shared.handleDeepLink(url)
}
}
// for when your app is already running
func scene(_ scene: UIScene, openURLContexts URLContexts: Set) {
if let url = URLContexts.first?.url {
Superwall.shared.handleDeepLink(url)
}
}
}
```
```swift SwiftUI
import SuperwallKit
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
Superwall.shared.handleDeepLink(url) // handle your deep link
}
}
}
}
```
```swift Objective-C
// In your SceneDelegate.m
#import "SceneDelegate.h"
@import SuperwallKit;
@interface SceneDelegate ()
@end
@implementation SceneDelegate
- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions {
[self handleURLContexts:connectionOptions.URLContexts];
}
- (void)scene:(UIScene *)scene openURLContexts:(NSSet *)URLContexts {
[self handleURLContexts:URLContexts];
}
#pragma mark - Deep linking
- (void)handleURLContexts:(NSSet *)URLContexts {
[URLContexts enumerateObjectsUsingBlock:^(UIOpenURLContext * _Nonnull context, BOOL * _Nonnull stop) {
[[Superwall sharedInstance] handleDeepLink:context.URL];
}];
}
@end
```
Handling Deep Links (Android) [#handling-deep-links-android]
In your `MainActivity` (or the activity specified in your intent-filter), add the following Kotlin code to handle deep links:
```kotlin Kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Respond to deep links
respondToDeepLinks()
}
private fun respondToDeepLinks() {
intent?.data?.let { uri ->
Superwall.instance.handleDeepLink(uri)
}
}
}
```
Previewing Paywalls [#previewing-paywalls]
Next, build and run your app on your phone.
Then, head to the Superwall Dashboard. Click on **Settings** from the Dashboard panel on the left, then select **General**:
With the **General** tab selected, type your custom URL scheme, without slashes, into the **Apple Custom URL Scheme** field:
Next, open your paywall from the dashboard and click **Preview**. You'll see a QR code appear in a pop-up:
On your device, scan this QR code. You can do this via Apple's Camera app. This will take you to a paywall viewer within your app, where you can preview all your paywalls in different configurations.
```
```
# Products
To add products to your paywall, click the **Products** button from the **sidebar**:
If you haven't added products to your app, do that first. Check out this [doc](/docs/dashboard/products) for
more.
Choosing products [#choosing-products]
You can display as many products as you see fit on your paywall. Superwall will automatically fill in a name for the first three added ("primary", "secondary", and "tertiary") but you're free to name them anything else. Regardless, the name is used to reference them using Liquid Syntax in the editor. For example, `products.primary.price`.
It's important to remember that *you* retain full control over which of your products show in a paywall, and how. For example, use them along with [dynamic values](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-dynamic-values) to hide or show them to create any U.X. your design calls for.
Understanding the selected product and selected product index variables [#understanding-the-selected-product-and-selected-product-index-variables]
The `products.selected` variable will always represent any product the user has selected on your paywall. By default, it will be the *first* product you've added. In addition, the `products.selectedIndex` variable will also be updated as products are selected. This opens up many patterns to use, such as customizing copy, images, videos, or anything else based on which product the user has tapped on.
Many of our [built-in elements](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-layout#adding-elements) which display products will update these values. If you wish to add a custom element which selects a product, **click** on the element and add a **[tap behavior](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements#tap-behaviors)** to choose a product (the selected index will update automatically as a result).
Customizing pricing copy [#customizing-pricing-copy]
In my most cases, Superwall will format your product's price in a localized manner. For example, look at this paywall from left-to-right:
1. The primary product is displayed in the button.
2. Below it, the call-to-action button formats its text as `Subscribe for {{ products.selected.price }} / {{ products.selected.period }} `.
3. That means any selected product's price will display with a similar pattern in the call-to-action button.
You can use [Liquid syntax](https://shopify.github.io/liquid/) to format prices in several different ways. For example, if you wanted to show an annual price differently, you could write `Subscribe for only {{ products.primary.monthlyPrice }} / month.` to display the localized price in monthly terms. If a product cost $120.00 a year, then the text would read as "Subscribe for only $10.00 / month."
Copy like this is achieved by using variables. To learn more about them, visit this [page](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables).
Offer pricing copy [#offer-pricing-copy]
A common use-case is showing copy that reflects the selected product's trial or offer terms. Consider this example product:
| Product Identifier | Trial | Trial Price | Price | Period |
| ---------------------- | ------ | ----------- | ------ | ------ |
| myapp.annual40.1wkFree | 1 week | Free | $39.99 | 1 year |
In Superwall, all data for the one-week free trial is found in the `trial` liquid variables, which are a part of a `product`. Below, critical details about its duration, offer price and more are shown in the example paywall. Take note of the text box on the right, which shows how these variables can be used:
Products missing App Store Connect API [#products-missing-app-store-connect-api]
When using Apple-based products, Superwall will automatically fetch the product information from App Store Connect. However, if you haven't set up the App Store Connect API, you may see a message indicating that the product information is missing:
To resolve this, follow the steps in our [App Store Connect API setup
guide](/docs/dashboard/dashboard-settings/overview-settings-revenue-tracking#app-store-connect-api).
# Publishing
To set your paywall live, click the **Publish** button in the top-right side of the editor:
Setting your paywall live doesn't necessarily mean that users will see it. You'll first need to associate a paywall with a [campaign](/docs/dashboard/dashboard-campaigns/campaigns). From there, either a user is matched to an audience filter or a [placement](/docs/sdk/quickstart/feature-gating) is evaluated which results in a paywall being presented.
Changes you make are saved locally, even if you haven't published the paywall. Refreshing won't
erase progress if you close the tab and come back later, unless you sign out.
# Renaming Paywalls
To rename a paywall, click the **Pencil Icon** button in the top-left side of the editor:
Type in a name, and click **Save**:
# Settings
To configure settings for your paywall, click the **Settings** button from the **sidebar**:
You have four primary properties of your paywall to configure here, all are set with default values.
Presentation Style [#presentation-style]
Toggle the presentation style of your paywall. Available options are:
1. **Fullscreen:** The paywall will cover the entire device screen.
2. **Push:** The paywall will push onto a hierarchy, such as a `UINavigationController` on iOS.
3. **Modal:** The paywall presents with the platform's default modal API.
4. **No Animation:** The paywall presents modally, but without any animation.
5. **Drawer:** The paywall presents from a bottom drawer with customizable height and corner radius.
6. **Popup:** The paywall presents as a modal popup with customizable width, height, and corner radius from the center of the screen.
Drawer Configuration [#drawer-configuration]
When using the **Drawer** presentation style, you can configure:
* **Height:** Set the height of the drawer as a percentage of the screen (default: 70%).
* **Corner Radius:** Set the corner radius for the drawer corners (default: 15px).
* **Scrolling:** Enable or disable scrolling within the drawer.
Popup Configuration [#popup-configuration]
When using the **Popup** presentation style, you can configure:
* **Width:** Set the width of the popup as a percentage of the screen (default: 80%).
* **Height:** Set the height of the popup as a percentage of the screen (default: 60%).
* **Corner Radius:** Set the corner radius for the popup corners (default: 15px).
Popup style requires iOS SDK v4.8.0+
Scrolling [#scrolling]
Toggle the scrolling behavior of your paywall. Available options are:
1. **Enabled (Default):** The paywall can scroll its contents when presented on a device.
2. **Disabled:** Disables all scrolling behavior on the paywall.
Requires iOS SDK v3.11.2+ and Android SDK v1.4.0+
Game Controller Support [#game-controller-support]
Toggle game controller support for paywalls — obviously, ideal for paywalls shown in games where controllers may be in use. Available options are:
1. **Enabled:** The paywall can scroll its contents when presented on a device.
2. **Disabled (Default):** Disables all scrolling behavior on the paywall.
Learn more about game controller support [here](/docs/sdk/guides/advanced/game-controller-support#game-controller-support).
Feature Gating [#feature-gating]
Feature gating allows you to control whether or not [placements](/docs/dashboard/dashboard-campaigns/campaigns-placements) should restrict access to features. Using either method, the paywall will still be presented if a user isn't subscribed:
1. **Non Gated:** Placements *will always* fire your feature block. Specifically, once the paywall is dismissed.
2. **Gated:** Placements *will only* fire your feature block if the user is subscribed. Note that if they are subscribed, the paywall will *not* be presented.
For example:
```swift
// With non gated - `logCaffeine()` is still invoked
Superwall.shared.register(placement: "caffeineLogged") {
logCaffeine()
}
// With gated - `logCaffeine()` is invoked only if the user is subscribed
Superwall.shared.register(placement: "caffeineLogged") {
logCaffeine()
}
```
This is useful to dynamically change what is paywalled in production without an app update. For example, in a caffeine tracking app — perhaps you might run a weekend campaign where logging caffeine is free. You'd simply change the paywall to be **Non Gated**. Then, the paywall would still be presented, but users would be able to continue and log caffeine.
For information on how this behaves when offline, view this [section](/docs/sdk/quickstart/feature-gating#handling-network-issues).
Feature gating does not apply if you are manually presenting a paywall via `getPaywall`.
Cache on Device [#cache-on-device]
If enabled, Superwall's SDK will cache the paywall on device. This can be useful if you have a paywall that could take a few seconds to fetch and present (i.e. if there is a video as part of your design). On-device caching can lead to quicker presentation.
Device caching is currently only available on iOS.
Identifier [#identifier]
The identifier for the paywall. Non-editable.
Present Paywall [#present-paywall]
This is now deprecated in iOS SDK version 4 and above, and version 2 and above for all other SDKs. Instead, use the [entitlements](/docs/dashboard/dashboard-campaigns/campaigns-audience#matching-to-entitlements) feature when creating campaign filters.
You can have a paywall present under two different conditions when a [placement](/docs/dashboard/dashboard-campaigns/campaigns-placements) is matched:
1. **Check User Subscription:** Present the paywall only if the user's subscription is not active.
2. **Always:** Present the paywall regardless of the user's subscription status.
Reroute back button [#reroute-back-button]
If enabled, allows you to run custom logic on back button press and consuming the event.
To use it, once the option has been enabled, use the `PaywallOptions.onBackPressed` and return true to consume the back press event or false to let the SDK handle it.
Back button rerouting is currently only supported on Android SDK 2.5.6 or higher
# Slides
Adding a slides component [#adding-a-slides-component]
The slide component was built to make interactive slide designs easy. It's similar to a [carousel](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-carousel-component), except its meant to be driven by user gestures instead of automatically progressing through its contents. To use the slides component:
1. In the left sidebar, click **+** to add a new element.
2. Choose **Slides** under the "Layout" header.
The slides component requires an explicit `width` set. Generally, setting this to 100% of the viewport's width works well. This is also the default size set:
By default:
* The slides component is set to `Horizontal` scrolling.
* Scrolling indicators are created for you. Read below to see how you can customize these.
* A default height is assigned.
* The slides respond to gestures.
Adding contents to slides [#adding-contents-to-slides]
The slides component has a few demonstration items added to it by default. You can remove these and add your own content:
Here, the container stack determines the height. You can customize this as needed.
Tracking or updating the displayed element in slides [#tracking-or-updating-the-displayed-element-in-slides]
When a slides element is added, Superwall automatically creates an element variable for it (accessed through the **[Variables](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables)** tab in the left sidebar, or the variables button in the **[floating toolbar](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-floating-toolbar)**). Its name will match whatever is in the element hierarchy in the left sidebar:
You can use its `Child Page Index` variable to change which slide is showing. You can use this to manually set which slide is showing. Here, the button progresses to the next slide by incrementing the slides `Child Page Index` variable:
The variable's name is derived by the node's unique identifier. You don't need to set or generally be aware of this value.
You can also reference this variable when using a [dynamic value](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-dynamic-values) to do things such as:
* Select a product based off of the index of the slide.
* Show custom paging indicators.
* Change text using [dynamic values](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-dynamic-values) based on the index.
* etc.
# Stacks
From a component standpoint, stacks are the foundation of every layout. Most components and snippets will start with a stack. Under the hood, they mimic a flexbox layout.
If you are new to CSS Flexbox, try out this interactive [tool](https://flexbox.help). Or, simply change the properties in the editor to see realtime changes.
Stack Specific Properties [#stack-specific-properties]
Stacks have unique properties:
* **Axis**: Determines the arrangement of items within the stack.
1. `Horizontal`: Items are arranged left to right.
2. `Vertical`: Items are arranged top to bottom.
3. `Layered`: Items are stacked on top of each other.
* **Vertical**: Controls the vertical alignment of the items within the stack.
1. `Top`: Aligns items to the top of the container.
2. `Center`: Aligns items vertically in the center of the container.
3. `Bottom`: Aligns items to the bottom of the container.
4. `Stretch`: Stretches items to fill the vertical space of the container.
5. `Baseline`: Aligns items according to their baseline.
* **Horizontal**: Controls the horizontal alignment of the items within the stack.
1. `Left`: Aligns items to the left of the container.
2. `Center`: Aligns items horizontally in the center of the container.
3. `Right`: Aligns items to the right of the container.
4. `Fill Equally`: Distributes items evenly across the container, filling the space equally.
5. `Space Evenly`: Distributes items with equal space around them.
6. `Space Around`: Distributes items with space around them, with half-size space on the edges.
7. `Space Between`: Distributes items with space only between them, with no space at the edges.
* **Spacing**: Defines the amount of space between items within the stack, measured in pixels by default.
* **Wrap**: Specifies how items within the stack should behave when they exceed the container's width.
1. `Don't Wrap`: Items remain in a single line and do not wrap onto a new line.
2. `Wrap`: Items wrap onto the next line when they exceed the container's width.
3. `Wrap Reverse`: Items wrap onto the previous line in reverse order.
* **Scroll**: Determines the scrolling behavior of the stack.
1. `None`: Disables scrolling within the stack.
2. `Normal`: Enables standard scrolling behavior.
3. `Paging`: Enables paginated scrolling, allowing users to swipe through pages of items. See "Creating Carousels" below.
4. `Infinite`: Endless scrolling, items clone and repeat themselves once they reach the end.
* **Snap Position**: Defines the position at which items snap into place during paging. Only relevant if `Scroll` is set to `Paging`.
1. `Start`: Items snap to the start of the container.
2. `Center`: Items snap to the center of the container.
3. `End`: Items snap to the end of the container.
* **Auto Paging**: Controls whether a carousel's contents should automatically page between items. Only relevant if `Scroll` is set to `Paging`.
1. `Disabled`: Auto paging is turned off, and items page via user interaction.
2. `Enabled`: Auto paging is turned on and items will automatically page according to the paging delay.
* **Paging Delay**: The duration to automatically advance the slides. Only relevant if `Scroll` is set to `Paging` and `Auto Paging` is set to `Enabled`.
* **Infinite Scroll Speed**: The amount of pixels per frame that the carousel should advance. Only relevant if `Scroll` is set to `Infinite`.
To see how to use stacks for common designs, check out these pages:
* [Carousel](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-carousel-component)
* [Autoscrolling](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-autoscroll-component)
* [Slides](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-slides-component)
* [Navigation](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-navigation-component)
# Styling Elements
Anytime you click on a component in the **Layout** tab, its editable properties will open on the right side of the editor window in the **component editor**. Here, you can see that clicking on the component in the layout tab opened its relevant properties in the component editor:
Component Specific Properties [#component-specific-properties]
By default, when you select a component you'll see properties that are specific to it at the top of the component editor. For example, when you select a stack, you'll see stack-specific options. The same is true of text and any other component. Notice how the options change at the top-right here when a stack versus a text component are selected:
Image generation [#image-generation]
You can use A.I. to generate images. By selecting any image component, you can **click** on the **AI Tools** button to create an image based on the text and style you provide:
You are presented with two options:
1. **Generate Image:** Use this to create a brand new image, or regenerate an existing one.
2. **Remove Background:** This attempts to remove the background of the existing image element.
**Generating images:**
When the image generation editor modal is open, you can provide:
* **Image description:** A description of the image you'd like to generate. Generally speaking, the more detail you provide, the better the result will be. Instead of saying "Coffee up close", you might say "A close-up of a steaming cup of coffee with a heart-shaped foam design.""
* **Style:** The primary style of the image you're generating. Each one has several sub styles associated with it.
* **Sub-style:** A more specific style within the primary style you've chosen. For example, if you've chosen "Realistic Image", you might choose "Black and White" as a sub-style.
* **Remove background automatically:** Select this to have the image generator attempt to remove the background from the resulting image.
Here's an example prompt:
And, its result:
Common component properties [#common-component-properties]
Most components share similar properties related to things such as sizing, padding and more. Those are covered in more detail below.
Note that for any property that deals with editing a particular size, you can click on the disclosure arrow to choose a specific unit (such as `rems`):
Further, most values accept either an input amount or you can use a slider to set one. Some elements support hovering over them to reveal a slider, as well:
Tap Behaviors [#tap-behaviors]
You can have a component trigger a specific action when it's tapped or clicked by adding a **tap behavior**. To add a tap behavior, **click** a component from either the Layout tab or the preview canvas, and in the **component editor** under under **Tap Behavior** click **+ Add Action**:
Available tap actions are:
**Purchases**
* **Purchase:** Begin a purchasing flow of the selected product. After a successful purchase, you can configure what happens next using the **After Purchase** setting. Options include **Close** (default), **Navigate Page** (advance to the next page in a flow), **Open URL**, **Custom Action**, **Custom Placement**, **Set Attribute**, **Set State**, or **None**.
* **Select Product:** Puts the chosen product in a selected state. Useful for scenarios such as focusing a product in a paywall drawer, for example, when something is tapped or clicked.
* **Restore:** Begins a purchase restore operation. Useful for restoring purchases someone may have made prior.
**Navigation**
* **Navigate Page:** Moves through a [Flow](/docs/dashboard/dashboard-creating-flows/getting-started). Use this to progress users through multi-page experiences like onboarding.
* **Next:** Advances to the next page in the flow.
* **Back:** Returns to the previous page in the flow.
* **Close:** Closes the paywall or flow.
* **Open URL:** Opens the given URL via one of three different ways:
1. **In-app browser:** Attempts to open the link *inside* of the app using the platform's web browser control. For example, on iOS, this opens an instance of [`SFSafariViewController`](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller).
2. **External:** Attempts to open the link *outside* of the app using the platform's web browser app. For example, on iOS, this opens Safari. Please note that the paywall stays presented when the link is opened.
3. **Deep Link:** Similar to the **external** option, but it'll *close* the paywall before opening the URL. This is useful for internal navigation or when you are attempting to link to something in a web page and you'd like the paywall to close when doing so.
* **Scroll To Element:** Scrolls the view to a specific element on the page.
For Stripe Checkout or other external purchase links on iOS, use the **External** option. Do not use the in-app browser option for purchase flows.
**State & Variables**
* **Update Variable:** Sets or updates an existing variable's value. Options specific to the variable type will also be displayed. For example, a **Number** variable will have an option to choose an **Operation** such as Set, Increment or Decrement. Or, a **Boolean** variable's **Operation** will offer to either **Set** or **Toggle** the boolean value.
* **Set Attribute:** Sets a user attribute directly from the paywall. Enter a **Key** (e.g., `preferredPlan`, `onboardingComplete`) and a **Value** (e.g., `premium`, `true`). You can add multiple attributes per tap using **+ Add Attribute**. This behaves the same as calling `setUserAttributes()` in the SDK. Common use cases include capturing user preferences from paywall surveys, tracking paywall engagement, or segmenting users for A/B tests based on their choices. *Requires iOS SDK v4.10.7+.*
**Prompts**
* **Request Permission:** Requests a system permission. Useful for gathering permissions during onboarding flows. Available permissions are:
* **Notification:** Ask for permission to send system notifications.
* **Background Location:** Request location access when the app isn't in use.
* **Location:** Request location access while the app is in use.
* **Read Images:** Access the user's photo library.
* **Contacts:** Access the user's contacts.
* **Camera:** Access the device camera.
* **App Tracking Transparency:** Ask to track the user across apps and websites.
* **Microphone:** Access the device microphone.
You can add follow-up actions using **If Granted** and **If Denied** to run different actions depending on the user's response. For example, navigate to the next page when granted, or show a different page when denied. In the editor preview, permission requests are simulated with **Grant** and **Deny** buttons so you can test both paths without deploying.
* **Request Store Review:** Requests a review for the given app storefront.
1. **Rating Prompt:** Attempts to display the system-level ratings prompt for the platform. On Android, you may use `useMockReviews` on `SuperwallOptions` to ensure a prompt shows while testing.
2. **Written Review:** This option deeplinks to the respective App Store so the user can write a written review.
**Custom Actions**
* **Custom Action:** Performs a custom action that you specify. Using custom actions, you can tie application logic to anything tapped or clicked on your paywall. Check out the docs [here](/docs/sdk/guides/advanced/custom-paywall-actions).
* **Custom Placement:** Registers the placement you specify in the **Name** field. The name field supports dynamic values via Liquid templates, so you can insert state variables to create placement names that resolve at runtime (e.g., a name that includes the user's selected plan). Use the variable picker button next to the name field to insert variables. One common use-case is presenting another paywall from a currently opened one. Check out the example [here](/docs/dashboard/guides/presenting-paywalls-from-one-another).
* **Delay:** Pauses the action chain for a specified number of **Seconds** before continuing to the next action. This is useful when you have multiple actions on a single tap and want to space them out. For example, setting a variable and then closing the paywall after a short pause. It's also useful for driving animation states or creating auto-advance timers. The **Interruptible** option controls what happens if the user taps the same element again while a delay is pending: **Yes** cancels the current delay and restarts the action chain, while **No** ignores the tap until the delay completes.
* **Custom Callback:** Sends a named callback request to the SDK, allowing you to run custom app logic and respond with a result. Enter a **Name** for the callback and choose a **Behavior**: **Blocking** waits for the SDK to respond before continuing the action chain, while **Non-blocking** fires the request and continues immediately. You can pass paywall variables to the SDK using **+ Add Variable**. Use the **On Success** and **On Failure** sections to add follow-up actions that run depending on the SDK's response. For example, you could validate something in your app and then update a variable or close the paywall based on the result. In the editor preview, callbacks are simulated with **Success** and **Failure** buttons so you can test both paths. *Requires iOS SDK v4.12.10+ or Android SDK v2.7.0+.*
Animation [#animation]
Each tap behavior includes an **Animation** dropdown that plays a visual effect when the element is tapped. Available options are:
* **None:** No animation (default).
* **Shrink:** The element briefly scales down and back up.
* **Grow:** The element briefly scales up and back down.
* **Fade:** The element briefly fades out and back in.
Haptics [#haptics]
Each tap behavior includes a **Haptics** dropdown that triggers a platform-native haptic feedback pattern on the user's device. Available on iOS and Android. Available options are:
* **None:** No haptic feedback (default).
* **Light:** A subtle, light tap.
* **Medium:** A moderate tap.
* **Heavy:** A strong, pronounced tap.
* **Success:** A feedback pattern indicating a successful action.
* **Warning:** A feedback pattern indicating a cautionary action.
* **Error:** A feedback pattern indicating a failed action.
* **Selection:** A light tap suited for selection changes.
If a component has a tap action associated to it, you'll also see a **triangle** icon next to it within the sidebar:
Component styling properties [#component-styling-properties]
The properties below are editable regardless of which kind of component you are editing. The most important thing to keep in mind is that all of the properties below will work the same as they would on a web page. When you change these properties, behind the scenes — Superwall is applying the corresponding CSS code to the paywall.
Typography [#typography]
Edit the text, size, color and more of a component's text:
If you've added a [custom font](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-theme#custom-fonts), you can also select it here.
Layer [#layer]
Properties here edit the underlying layer of the component:
If you want a background fill, to toggle its overall opacity or other similar things — the layer is a great spot to do it.
Size [#size]
Use size properties to set an explicitly width or height on the component:
Using the dropdown arrow, you can set a range of minimum and maximum values:
Padding [#padding]
Apply padding to the content within the component:
To have more granular control, expand each disclosure arrow to set only the top, bottom, left or right padding values:
Margin [#margin]
Apply spacing to the content outside of the component, further separating it from adjacent components:
Corners [#corners]
Applies a corner radius to the component:
Note that you may not see any change unless the component has some sort of background or layer fill. Further, if you'd like to adjust just one or more corners, click the dashed square icon to individually enter values:
Borders [#borders]
Set a border around the the component:
You can set its width, style (i.e. dashed, solid, etc) and more.
Position [#position]
Specifies the position strategy used to place the component:
If you'd like to layer a component on top or below another component, the `Z Index` value is perfect here.
The most important value is what you use for `position`. Again, all of these options mirror their CSS counterpart. The available options are:
* **Normal:** The default option. The component will be positioned according to the normal flow of the paywall's hierarchy. This is analogous to `static` in CSS.
* **Relative:** Positions the component relative to its normal position, allowing you to move it with top, right, bottom, and left offsets *without* affecting the layout of surrounding components.
* **Absolute:** Removes the component from the normal paywall hierarchy flow and positions it relative to its nearest positioned component (which won't necessarily be a parent component).
* **Fixed:** Positions the component relative to the viewport, meaning it will stay in the same place even when the paywall is scrolled.
* **Sticky:** The component will behave as if it has a `normal` position *until* it teaches a certain point in the viewport. Then, it acts like it has a `fixed` position, sticking in place. This is great for sticky headers,footers or banners.
Mozilla has excellent developer docs over how the [position](https://developer.mozilla.org/en-US/docs/Web/CSS/position) CSS property works, along with interactive code samples. If you're having some issues getting things placed how you'd like, this is a great resource to check out.
Effects [#effects]
Applies CSS effects to the component:
If you're new to CSS transitions and animations, check out this interactive [reference](https://www.w3schools.com/css/css3_transitions.asp).
Custom CSS [#custom-css]
If you need to add some one-off custom CSS code, you can add them here. Just click **+ Add Property**:
From there, type in the CSS property you need and select it from our dropdown menu:
Here, you can see a manually set background value for the selected component:
Using the Custom CSS section should be your last step, and only if you absolutely cannot achieve
the design you need. For example, here we should simply use the `layer` section to set a
background color.
CSS Output [#css-output]
As you make any changes to these properties, you can see the actual CSS that Superwall is applying by scrolling down in the component editor to the **CSS Output** section:
This can be useful for debugging or further refining your designs.
# Surveys
To set up a survey to show users when they close a paywall or perform a purchase, click the **Surveys** button from the **sidebar**:
Every survey you've created for your app will be selectable in this section. Superwall's surveys display natively on the platform it's presented on. For example, on iOS, it will present as an action sheet.
Close Survey [#close-survey]
Select a **close survey** to present when a user closes a paywall and *does not* purchase a product or start a trial:
Ideally, you'd use these to discover why users aren't willing to pay or become a trialist. By default, Superwall's survey template is a good place to start.
Post Purchase Survey [#post-purchase-survey]
Select a **post purchase survey** to present after the user starts a trial or performs a purchase:
These are useful to hone in on what's working, or gain insights about the types of users who are paying.
Creating or editing surveys [#creating-or-editing-surveys]
You can open the survey editor from the survey section by clicking **Editing Surveys**:
To learn more about creating a survey, view the [docs here](/docs/dashboard/surveys).
# Theme
To configure a paywall's theme, click the **Theme** button in the **sidebar**:
The theme options let you control the overall styling of your paywall. For example, you can change the background color, your primary color, and more. In addition, you can add your own variables to a theme to reference throughout your paywall's components.
A great place to start is to set the
`primary`
color to your brand's prominent color.
For example, notice how the entire background of the paywall changes when the `background` theme is changed from black to white:
Remember, these are *variables*, so while some of them like `background` immediately reflect their changes, most of them will be referenced by you within a component. For example, if you wanted to reference the default **padding** variable under the "Device size" section, you would:
1. Select a component.
2. Hover over the padding value you want to change (i.e. horizontal, vertical, individual values, etc.)
3. Hold the `option` or `alt` key and click **Edit**.
4. Select **padding** to apply it.
Here's what that example would look like:
Notice how the "padding" button now displays as purple, indicating it's referencing a variable.
There are three main theme groups for variables:
* **Interface:** These variables change automatically depending on the interface style of the device.
* **Device size:** These variables change automatically depending on the device size.
* **Theme:** Variables added here are static, and by default there is variable for a font choice.
Interface [#interface]
Use the **Interface** toggle to have your theme values be reflected in either light or dark mode. Any values you set will only apply when the device's interface theme matches the selected choice (i.e. light or dark).
**By default, Superwall has all of your theme apply to both light and dark mode.** But, if you click the **+** icon you can add dark mode specific values, too:
Superwall will copy all of your theme values over to the dark interface style, and from there you can customize them specifically for dark mode.
Superwall provides three interface theme variables out of the box:
* **Background:** The fill color of the paywall's background.
* **Primary:** The fill color of core component layers, like a button.
* **Text:** The text color.
However, you are free to add as many different theme variables as you need. Read below under "Creating theme variables" for more.
Device Size [#device-size]
You can tailor variables to react to a device size. There are a total three different device sizes:
* **Small:** Typical iPhone device size in portrait.
* **Medium:** Typical iPhone device size in landscape, or a tablet in portrait or landscape.
* **Large:** Devices such as a desktop or laptop.
You can see the device preview change size as you toggle through the sizes:
By default, Superwall uses the **small** device size. Simply click the **+** button to add more. By default, Superwall provides a padding device size variable:
* **Padding:** A default padding of 16 pixels you can apply to components by referencing this variable.
Theme static values [#theme-static-values]
Variables you add here are static, meaning they don't react to device parameters and update their values. This is useful for thing you likely want to stay the same, regardless if light or dark mode is on, no matter the size of the device, etc.
Superwall provides a **font** static variable. Use it to set a default font to use for any text component.
Custom fonts [#custom-fonts]
Using the default **font** variable, you can also add a custom font. Click the **+** button in the font variable to add one:
Additionally, you can add a custom font by **selecting** a text component, and under the **Typography** section in the component editor, click the **+** button:
Creating theme variables [#creating-theme-variables]
To add your own theme variable, click **+ Add Theme Variable**:
There are three different types you can add, all of which use CSS under the hood:
1. **Color:** Set up a color variable using a color picker.
2. **Length:** Set up a length variable using a value of either pixels, a percent, viewport values and more.
3. **Font:** Set up a font variable using the font picker.
Once you've given it a name, value type and initial value, click **Create** to begin using it:
# Variables
To add or edit variables, click the **Variables** button from the **sidebar**:
Variables allow you to reuse common values, adapt to the device's characteristics (such as size or color scheme), and do things like dynamically hide or how components. All of these scenarios can be achieved using variables in conjunction with our [dynamic values](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-dynamic-values) editor:
* Presenting or hiding a bottom sheet of products.
* Changing the padding of several elements based on the device's available width.
* Formatting the price of a product based on different parameters, such as trial availability.
By default, Superwall has three different type of variables for use:
* **Device:** Relates to the device, the app's install date, bundle ID and more.
* **Product:** Represents the products added to the paywall, and their properties. In addition, there are variables for the selected product in addition to the primary, secondary or tertiary product.
* **User:** User-specific values, such as their alias ID.
While those will allow you to cover several cases, you can also add your own custom variables too.
Variables dealing with any product period, such as `someProduct.period`, `someProduct.periodly`,
and other similar variables, can localize automatically now. Learn more [here](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-localization#localizing-period-lengths).
Using Variables [#using-variables]
You primarily use variables in the **component editor** and with [dynamic values](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-dynamic-values). When using a variable in text, follow the Liquid syntax to reference one: `{{ theVariable }}`. For example, to reference a variable in some text, you would:
1. Select the text component.
2. Under Text -> click "+ Add Variable".
3. Then, drill down or search for the variable or its corresponding property.
4. Click on it, and it'll be added to your text and correctly formatted.
To use a variable with a component property, **click** on the property and choose **Dynamic**:
The [dynamic values](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-dynamic-values) editor will appear. Next to **then:**, choose your variable and click **Save**:
Above, the "padding" variable was used. You can ignore the if/then rules above if you simply want to apply a variable, but to dynamically enable or disable them — you can set conditions accordingly. Read the docs over [dynamic values](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-dynamic-values) to learn more.
You can also hover a property and hold down the **Option/Alt** key to bring up the dynamic values
editor.
Clearing variables [#clearing-variables]
To remove a variable that's in use, **click** the property or gear icon (which will be purple when a variable is being used) and selected **Clear**.
Stock variable documentation [#stock-variable-documentation]
Below are all of the stock variables and their types. You don't have to memorize any of these — when the variable picker shows, each of the correct liquid
syntax appears above every variable, and it be will auto-inserted for you when selected.
| Property | Type | Example |
| ------------------------------- | ------ | ------------------------------------------------------------------------------- |
| App Install Date | Text | 2024-04-11 02:40:44.918000 |
| App User Id | Text | $SuperwallAlias:2580915A-8A2A-40B6-A947-2BE75A42461E |
| App Version | Text | 1.0.2 |
| Bundle Id | Text | com.yourOrg.yourApp |
| Days Since Install | Number | 0 |
| Days Since Last Paywall View | Number | |
| Device Currency Code | Text | AED |
| Device Currency Symbol | Text | AED |
| Device Language Code | Text | en |
| Device Locale | Text | en\_AE |
| Device Model | Text | iPhone14 |
| Interface Style | Text | light |
| Interface Type | Text | iphone, ipad, mac. Returns "mac" for Mac Catalyst, "ipad" for iPad apps on mac. |
| Is Low Power Mode Enabled | Number | 0 |
| Is Mac | Number | 0 |
| Local Date | Text | 2024-05-02 |
| Local Date Time | Text | 2024-05-02T21:31:52 |
| Local Time | Text | 21:31:52 |
| Minutes Since Install | Number | 7 |
| Minutes Since Last Paywall View | Number | 1 |
| Orientation | String | "landscape" or "portrait" |
| Os Version | Text | 17.4.1 |
| Platform | Text | iOS |
| Public Api Key | Text | pk\_ccdfsriotuwiou23435 |
| Radio Type | Text | WiFi |
| Total Paywall Views | Number | 10 |
| Utc Date | Text | 2024-05-02 |
| Utc Date Time | Text | 2024-05-02T17:31:52 |
| Utc Time | Text | 17:31:52 |
| Vendor Id | Text | CC93GCD-ESB6-4DFF-A165-0963D0257221 |
| View Port Breakpoint | Text | X-Small/Small/Medium/Large/Extra Large/ Extra extra large |
| View Port Width | Number | 844 |
| View Port Height | Number | 390 |
Reference any of the variables above by using the `device` variable. For example, if you were creating a filter or dynamic value based off whether or not the device was a Mac, you'd write `{{ device.isMac }}`.
| Property | Type | Example |
| -------------------------- | ---- | -------------------- |
| Currency Code | Text | USD |
| Currency Symbol | Text | $ |
| Daily Price | Text | $0.26 |
| Identifier | Text | efc.1m799.3dt |
| Lanauge Code | Text | en |
| Locale | Text | en\_US\@currency=USD |
| Localized Period | Text | 1m |
| Monthly Price | Text | $10.00 |
| Period | Text | month |
| Period Alt | Text | 1m |
| Period Days | Text | 30 |
| Period Months | Text | 1 |
| Period Weeks | Text | 4 |
| Period Years | Text | 0 |
| Periodly | Text | monthly |
| Price | Text | $7.99 |
| Raw Price | Text | 7.99 |
| Raw Trial Period Price | Text | 0 |
| Trial Period Daily Price | Text | $0.00 |
| Trial Period Days | Text | 0 |
| Trial Period End Date | Text | May 2, 2024 |
| Trial Period Monthly Price | Text | $0.00 |
| Trial Period Months | Text | 0 |
| Trial Period Price | Text | $0.00 |
| Trial Period Text | Text | 7-days |
| Trial Period Weekly Price | Text | $1.00 |
| Trial Period Weeks | Text | 1 |
| Trial Period Yearly Price | Text | $0.00 |
| Trial Period Years | Text | 0 |
| Weekly Price | Text | $1.83 |
| Yearly Price | Text | $100.00 |
The values above apply to any referenced product. There is the notion of a **primary**, **secondary**, **tertiary** and **selected** product. Whichever you use, you can use any of the above variables with it.
For example, to reference the price of the selected product (i.e. one the user has clicked or tapped on within the paywall) — you could write `The selected product cost {{ products.selected.price }}`.
There are also stock variables that deal with products, but aren't part of a single product variable itself. They are referenced via the `products` variable:
| Property | Type | Example |
| ---------------------- | ------ | ---------- |
| Has Introductory Offer | Bool | True/False |
| Selected Index | Number | 0 |
Use `products.hasIntroductoryOffer` to detect whether or not a user has a trial available. Further, `products.selectedIndex` represents the index of a selected product (i.e. primary would equal 0).
Superwall also exposes `products.abandoned` after a user cancels the store purchase sheet, and `products.purchased` after a transaction completes. Once those states are set, these variables point at the product the user abandoned or purchased, so you can use the same fields shown above:
| Property | Type | Example |
| ------------------------ | ---- | ----------------------------------- |
| Abandoned Product Price | Text | `{{ products.abandoned.price }}` |
| Abandoned Product Period | Text | `{{ products.abandoned.periodly }}` |
| Purchased Product Price | Text | `{{ products.purchased.price }}` |
| Purchased Product Period | Text | `{{ products.purchased.periodly }}` |
| Property | Type | Example |
| ------------------------ | ---- | ------------------------------------ |
| Did Abandon Transaction | Bool | `{{ state.didAbandonTransaction }}` |
| Did Complete Transaction | Bool | `{{ state.didCompleteTransaction }}` |
Use `state.didAbandonTransaction` to react when a user opens the App Store or Google Play purchase sheet and then cancels before purchase. A common pattern is to bind a drawer's open state to this variable so a recovery offer appears inside the same paywall. See [Abandoned Transaction Paywalls](/docs/dashboard/guides/tips-abandoned-transaction-paywall) for a full example.
| Property | Type | Example |
| --------------------------- | ------ | ---------------------------------------------------- |
| Alias Id | Text | $SuperwallAlias:606Z8824-434B-2270-BBD9-F1DF3E994087 |
| Application Installed At Id | Text | 2024-04-15T04:59:31.163Z |
| Event Name | Text | custom\_value |
| Seed | Number | 0 |
Use any variable above by referencing the `user` variable first: `{{ user.seed }}`.
Custom Variables [#custom-variables]
To create your own variable, click **+ Add Variable** in the **sidebar** under the **Variables** section:
The variable editor will be presented:
You'll be presented with four fields to fill out:
1. **Type:** The type of variable to create. Choose **State** if you'd like the variable to be mutated by [tap behaviors](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements#tap-behaviors). **Parameter** variables are similar, but initial values can be passed in [from your app](/docs/sdk/quickstart/feature-gating#register-everything).
2. **Name:** How you will reference the variable. Any name will autocorrect to camel case, i.e. "Cool Variable" will be `coolVariable`.
3. **Value Type:** The variable type. Choose from `text`, `number`, or `boolean`.
4. **Initial Value:** The initial value of the variable. This only displays once a variable type has been chosen.
Once you have everything entered, click **Save**. Your variable will show up in a section in the **sidebar** under **Variables** called **Params**:
From there, they are able to be referenced the same way as any other variable:
Liquid syntax formatting [#liquid-syntax-formatting]
In text, you can use [Liquid filters](https://shopify.github.io/liquid/filters/abs/) to modify output. To use filters, add a pipe after the variable. Then, add in one or more filters:
```
// Results in 17, the absolute value of -17
{{ -17 | abs }}
```
For example, to capitalize a text variable, you would write:
```
// If the name was "jordan", this results in "JORDAN"
{{ user.name | upcase }}
```
Custom Liquid filters [#custom-liquid-filters]
To make it easier to express dates & countdowns we've added several non-standard filters to our Liquid engine.
`date_add_*` and `date_subtract_*` [#date_add_-and-date_subtract_]
These filters add or subtract a specified amount of time to/from a date.
| Filter | Description |
| ----------------------- | ----------------------------------------------------- |
| `date_add_seconds` | Adds the specified number of seconds to a date |
| `date_add_minutes` | Adds the specified number of minutes to a date |
| `date_add_hours` | Adds the specified number of hours to a date |
| `date_add_days` | Adds the specified number of days to a date |
| `date_add_weeks` | Adds the specified number of weeks to a date |
| `date_add_months` | Adds the specified number of months to a date |
| `date_add_years` | Adds the specified number of years to a date |
| `date_subtract_seconds` | Subtracts the specified number of seconds from a date |
| `date_subtract_minutes` | Subtracts the specified number of minutes from a date |
| `date_subtract_hours` | Subtracts the specified number of hours from a date |
| `date_subtract_days` | Subtracts the specified number of days from a date |
| `date_subtract_weeks` | Subtracts the specified number of weeks from a date |
| `date_subtract_months` | Subtracts the specified number of months from a date |
| `date_subtract_years` | Subtracts the specified number of years from a date |
**Example Usage**:
```liquid
{{ "2024-08-06T07:16:26.802Z" | date_add_minutes: 30 | date: "%s" }}
```
Output: `1722929186`
`countdown_*_partial` [#countdown__partial]
These filters calculate the partial difference between two dates in various units. This is usefull for
formatting a countdown timer by exach segment.
| Filter | Description |
| --------------------------- | ----------------------------------------------------------------------------- |
| `countdown_minutes_partial` | Returns the remaining minutes in the current hour |
| `countdown_hours_partial` | Returns the remaining hours in the current day |
| `countdown_days_partial` | Returns the remaining days in the current week |
| `countdown_weeks_partial` | Returns the remaining weeks in the current month (assuming 4 weeks per month) |
| `countdown_months_partial` | Returns the remaining months in the current year |
| `countdown_years` | Returns the full number of years between the two dates |
**Example Usage**:
```liquid
{{ "2024-08-06T07:16:26.802Z" | countdown_hours_partial: "2024-08-06T15:30:00.000Z" }}:{{ "2024-08-06T07:16:26.802Z" | countdown_minutes_partial: "2024-08-06T15:30:00.000Z" }}
```
Output: `8:33`
`countdown_*_total` [#countdown__total]
These filters calculate the total difference between two dates in various units.
| Filter | Description |
| ------------------------- | ----------------------------------------------------- |
| `countdown_minutes_total` | Returns the total number of minutes between two dates |
| `countdown_hours_total` | Returns the total number of hours between two dates |
| `countdown_days_total` | Returns the total number of days between two dates |
| `countdown_weeks_total` | Returns the total number of weeks between two dates |
| `countdown_months_total` | Returns the total number of months between two dates |
| `countdown_years_total` | Returns the total number of years between two dates |
**Example Usage**:
```liquid
{{ "2024-08-06T07:16:26.802Z" | countdown_days_total: "2024-08-16T07:16:26.802Z" }}
```
Output: `10`
All these filters expect date strings as arguments. Anything that Javascript's default date
utility can parse will work. For countdown filters, the first argument is the starting date, and
the second argument (where applicable) is the end date.
The `countdown_*_total` filters calculate the total difference, while the `countdown_*_partial`
filters calculate the remainder after dividing by the next larger unit (except for years).
For `countdown_months_total` and `countdown_years_total`, the calculations account for varying
month lengths and leap years.
All countdown filters assume that the end date is later than the start date. If this isn't always
the case in your application, you may need to handle this scenario separately.
Snippets with variables [#snippets-with-variables]
If you create a group of components built off of variables and conditions, save it as a [snippet](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-layout#snippets) to reuse. There are several stock snippets built this way. For example the **Product Selector** snippet:
Adding this snippet shows your products in a vertical stack:
When one is selected, the checkmark next to it is filled in. This is achieved with stock variables (i.e. the selected product) and then changing layer opacity based on if the checkmark's corresponding product is selected or not:
Testing and handling different states [#testing-and-handling-different-states]
Often times, you'll want to test things like introductory or trial offers, a certain page within a paging design, or keep a modal drawer open to tweak its contents or look. To do that, simply change the variable's value in the editor. On the left sidebar, click **Variables** and then search for the one you're after and set its value.
Here are some common examples:
1. **Testing introductory offers:** To test trial or introductory offer states, change the `products.hasIntroductoryOffer` to `true` or `false`. In the example below, the text on the paywall changes based on whether or not a trial is available. To easily test it, you can toggle `products.hasIntroductoryOffer`:
2. **Testing abandoned transaction states:** To test a drawer or offer that appears after a canceled purchase, change `state.didAbandonTransaction` to `true`. If your copy references the abandoned product, choose a product by setting `products.abandonedProductId` to its product reference, such as `primary` or `secondary`.
3. **Testing a particular page in a paging paywall:** In this design, there are three distinct pages:
By default, the first one is showing. Though, if you needed to easily edit the second or third page, start by finding the variable that is controlling which page is shown. Typically, it'll be a state variable. Here, it's `changeScreen`:
By changing it, you can easily pull up each individual page and edit it as needed:
# Using Demand Score in Campaigns
Once you understand your demand score distribution, you can act on it by creating targeted audiences in your campaigns. Superwall provides a quick-start flow and manual options for building demand-score-based experiments.
Using Demand Score in campaign audience filters requires the **Scale** plan. Viewing Demand Score insights is available on all plans.
Launching an experiment from Demand Score [#launching-an-experiment-from-demand-score]
The fastest way to get started is the **Launch Experiment** button at the bottom of the Demand Score page:
When you click it, Superwall handles the setup automatically:
1. **If you have no campaigns**, Superwall creates a new one called "Demand Score Campaign."
2. **If you have one campaign**, Superwall uses it directly.
3. **If you have multiple campaigns**, a dropdown appears so you can choose which campaign to use.
Superwall then creates a new audience named **"Demand Score 80-100"** with the filter rule `demandScore >= 80 AND demandScore <= 100`. The audience starts disabled so you can configure your paywall and settings before going live.
You'll be taken to the campaign page with the new audience ready for configuration. From there, you can [attach paywalls](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-overview), [adjust the score range](/docs/dashboard/dashboard-campaigns/campaigns-audience), and enable the audience when ready.
Creating a custom demand score audience [#creating-a-custom-demand-score-audience]
You can also build demand score audiences manually in any campaign. This gives you full control over the score ranges and combinations:
1. Navigate to your campaign and click to add a new **audience**.
2. In the audience filter settings, add a filter using the `demandScore` property.
3. Set the operator and value to define your target range.
For example, to target mid-intent users:
* `demandScore` **is greater than or equal to** `40`
* **AND** `demandScore` **is less than or equal to** `79`
You can combine demand score filters with any other audience filters (country, platform, app version, etc.) to create precise segments.
For full details on audience configuration, see [Audiences](/docs/dashboard/dashboard-campaigns/campaigns-audience).
Choosing your score ranges [#choosing-your-score-ranges]
Every app's demand score distribution is different. Rather than using fixed tiers, use the [Demand Score charts](/docs/dashboard/dashboard-demand-score/demand-score-insights) to find natural breakpoints in your own data. Look for where conversion rate jumps or where user volume is concentrated, then define ranges that match your audience.
For example, if the Conversion Rate chart shows a clear uplift starting at score 65, you might define:
* **High intent:** 65–100
* **Mid intent:** 30–64
* **Low intent:** 1–29
The right ranges depend on your app. Start with what the charts show you, run an experiment, and refine from there.
Experiment strategies [#experiment-strategies]
Here are a few approaches to get started with demand score experiments:
**Target high-intent users with premium offers**
Create an audience for your highest-scoring users and show them your strongest paywall with premium pricing, annual plans emphasized, and minimal distractions. These users are already likely to convert, so reduce friction and let your best offer do the work.
**Use softer approaches for lower intent**
For lower-scoring users, consider delaying the paywall, offering a free trial with a longer duration, or using introductory pricing. These users may need more time to see value before committing.
**A/B test by score range**
Run parallel experiments where different score ranges see different paywalls. For example:
* High-scoring users see a direct purchase paywall with annual pricing.
* Lower-scoring users see a trial-first paywall with monthly pricing and a "cancel anytime" message.
Compare conversion rates across the ranges to learn what resonates with each segment.
**Act on placement-specific insights**
If the [Breakdown by Placement](/docs/dashboard/dashboard-demand-score/demand-score-insights#breakdown-by-placement) chart shows a placement with high demand but low conversion, that's a sign the paywall at that placement isn't matching user intent. Create a demand-score-filtered audience specifically for that placement and test a different offer.
Use the [AI Analysis](/docs/dashboard/dashboard-demand-score/demand-score-insights#ai-analysis) suggestions as a starting point. They're tailored to your actual data and often highlight the highest-leverage experiments to run first.
# Understanding Demand Score Insights
The Demand Score page provides several charts and breakdowns to help you understand how demand score correlates with conversion behavior in your app. Each section is collapsible and includes explanatory notes directly below the chart.
Conversion rate [#conversion-rate]
The **Conversion Rate** chart shows the observed conversion rate for each demand score bucket. Higher-demand buckets should generally have higher conversion rates. If they don't, it may point to a paywall or offer issue in that range.
You can click
**Copy Data**
in most Demand Score components to copy its data.
Each bar is shaded by **confidence level** based on the sample size in that bucket:
| Confidence | Meaning |
| ---------- | ----------------------------------------------------------------- |
| **High** | Large sample size with a tight confidence interval. Reliable. |
| **Medium** | Moderate sample size. Directionally useful. |
| **Low** | Small sample or wide confidence interval. Interpret with caution. |
Look for variation points in the curve. Buckets where conversion drops unexpectedly may indicate that your paywall or pricing isn't resonating with that intent level.
Total paywalled users by conversion [#total-paywalled-users-by-conversion]
This stacked bar chart shows the **absolute number of users** per demand score bucket, split into conversions and non-conversions:
Unlike the conversion rate chart (which normalizes by percentage), this view shows where your actual volume sits. A high-volume bucket with a low conversion rate represents more potential revenue impact than a low-volume bucket with the same rate.
Use this chart to:
* **Identify where your users are concentrated.** If most volume sits in the 80–100 range, your user acquisition is bringing in high-intent users.
* **Prioritize experiments.** A high-volume, low-conversion bucket is the highest-leverage place to test a new offer.
Trial conversion and billing issues [#trial-conversion-and-billing-issues]
This stacked bar chart breaks down **uncancelled trial outcomes** by demand score bucket:
Each bar shows three outcome types:
| Outcome | Description |
| -------------------------------- | -------------------------------------------------------------------------------------------- |
| **Trial conversion** | Users who completed their trial and converted to a paid subscription without billing issues. |
| **Billing issues (recovered)** | Users who hit a payment problem on conversion but later recovered and converted. |
| **Billing issues (unrecovered)** | Users who hit a payment problem and did not convert. |
This chart helps you understand post-conversion behavior. If certain demand tiers show high billing issues or low trial conversion, consider adjusting trial length, payment timing, or trial-to-paid messaging for those segments.
Breakdown by placement [#breakdown-by-placement]
The **Breakdown by Placement** table shows how demand score and conversion vary across each of your paywall placements:
Each row displays:
| Column | Description |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Placement** | The placement name (e.g., `GetStarted`, `transaction_abandon`). |
| **Demand Score** | A range visualization showing the Q1 (lower quartile), median, Q3 (upper quartile), and average demand score for users at that placement. |
| **Conversion Rate** | The overall conversion rate at that placement. |
| **Paywalled Users** | Total number of unique users who saw a paywall at that placement. |
Use the **Min. Paywalled Users** filter to hide low-volume placements and focus on statistically meaningful data.
**How to read the demand score range:** A tight range (Q1 and Q3 close together) means you're addressing a specific demand tier at that placement. A wide spread suggests the placement sees a mix of intent levels, and you may benefit from sub-experiments targeting different tiers within that placement.
High demand score with low conversion at a placement may indicate a paywall or offer issue. Low demand score with solid conversion is a good sign that your offering resonates even with lower-intent users.
Breakdown by country [#breakdown-by-country]
The **Breakdown by Country** table uses the same format as the placement breakdown, but groups data by the user's country:
Use this view to:
* **Compare intent vs. performance across markets.** If two countries have similar demand score ranges but different conversion rates, the gap is likely driven by localization, pricing, or product-market fit rather than user intent.
* **Simplify segmentation.** If countries with similar demand scores also show similar conversion rates, targeting by demand score alone may be more effective than targeting by geography.
* **Find underperforming markets.** Countries with reasonable demand ranges but low conversion are candidates for localized pricing or copy experiments.
AI Analysis [#ai-analysis]
The **AI Analysis** section generates an AI-powered summary of your demand score data for the selected date range. Click **Generate AI analysis** to create a report:
The report includes three sections:
* **Insights:** Key patterns across your data, including what's working, what stands out, and where the opportunities are.
* **Demographics:** Observations about your user distribution and how volume concentration affects the analysis.
* **Experiments:** Two to three concrete next steps based on placement performance, country data, or demand tier opportunities.
The analysis is cached locally. If you change the date range or the cached report is more than a day old, click **Regenerate** to get a fresh analysis.
The AI analysis is a great starting point for deciding what experiments to run. See [Using Demand Score in Campaigns](/docs/dashboard/dashboard-demand-score/demand-score-experiments) for how to act on these recommendations.
Adjusting bucket size [#adjusting-bucket-size]
Each chart section includes a **Bucket size** slider that controls how demand scores are grouped. The available sizes are 1, 2, 4, 5, 10, 20, and 25:
* **Smaller buckets** (e.g., 1 or 2) give more granular data but can be noisy with low sample sizes.
* **Larger buckets** (e.g., 10 or 25) smooth out noise and show clearer trends.
Start with the default bucket size of 10 and adjust based on your user volume.
Exporting data [#exporting-data]
Each chart section has a **Copy Data** button that copies the chart's data to your clipboard in CSV format. Use this to perform further analysis in a spreadsheet or share data with your team.
# Using Demand Score
Demand Score helps you understand how likely each user is to convert, so you can target the right people with the right offers. To view it, click **Demand Score** in the **sidebar**:
Demand Score is currently in **beta**. Anyone can view Demand Score insights, but using it to target audiences in campaigns requires the **Scale** plan.
What is Demand Score? [#what-is-demand-score]
Demand Score is a number from 1 to 100 assigned to each user by Superwall on every app open. A higher score means the user is more likely to convert. It's generated using several signals and data points, though no first-party user attributes are used.
Some signals used include device model, OS version, device age, App Store country, connection type, number of app opens, and paywall views. The model is trained on hundreds of millions of real-world data points across the Superwall network. You can use demand score a few different ways:
* **View interactive charts** that show how conversion rate, volume, and trial outcomes vary across demand score buckets.
* **Break down performance by placement and country** to see where your offerings resonate and where they don't.
* **Generate an AI-powered analysis** that highlights key patterns in your data and recommends experiments to run.
* **Filter campaign audiences** using `demandScore` to target users based on their likelihood to convert.
* **Launch experiments directly** from the Demand Score page to create high-intent audiences with one click.
Because Demand Score relies on device-level signals and not user attributes, it works out of the box. There's nothing to configure in the SDK.
Data thresholds [#data-thresholds]
If your app is new, doesn't have enough paywall activity yet, or enough data processed, the Demand Score page will display empty results:
This happens when Superwall hasn't observed enough user sessions to generate reliable demand scores for your app. The model needs a baseline of app opens and paywall views across your user base before it can assign scores.
No action is needed on your end. As users interact with your app and encounter paywalls, Superwall will automatically begin assigning demand scores and the charts will populate.
You can also try expanding the date range to **Last 90 days** or **Last 180 days** to capture a wider window of activity.
Coverage [#coverage]
At the top of the Demand Score page, the **Coverage** card shows what percentage of your recent paywall viewers have been assigned a demand score:
Coverage is color-coded to help you quickly assess data reliability:
| Coverage | Indicator | Meaning |
| --------- | --------- | ------------------------------------------------------------------------ |
| Above 80% | Green | Great, you can confidently segment and run demand-score-based audiences. |
| 50–80% | Yellow | OK, results are usable but may have gaps. |
| Below 50% | Red | Low, try selecting a longer date range for more reliable results. |
Selecting a date range [#selecting-a-date-range]
Use the date range selector in the top-right corner to adjust the time window for all charts and breakdowns on the page. Options include **Last 7 days**, **Last 30 days**, **Last 90 days**, and **Last 180 days**:
All sections on the page (coverage, charts, breakdowns, and AI analysis) update to reflect the selected range.
Using Demand Score for targeting [#using-demand-score-for-targeting]
The `demandScore` attribute (a number from 1 to 100) is available as an audience filter in [campaigns](/docs/dashboard/dashboard-campaigns/campaigns). Rather than using fixed tiers, use the charts on this page to understand where natural breakpoints exist in your own data, then create custom score ranges that match your app's audience.
For example, if the [Conversion Rate](/docs/dashboard/dashboard-demand-score/demand-score-insights#conversion-rate) chart shows a clear jump at score 70, you might target 70–100 as your "high intent" range. Every app's distribution is different, so let your data guide the ranges you choose.
For details on setting up demand score filters, see [Using Demand Score in Campaigns](/docs/dashboard/dashboard-demand-score/demand-score-experiments).
# Access Controls
Use **Access Controls** to decide who can work inside your organization, which projects they can access, and what organization API keys are allowed to do.
Access controls apply at the organization level. You can give a member or API key access to every project, or restrict it to specific projects.
Opening Access Controls [#opening-access-controls]
Open an app and go to **Settings > Team** to manage member roles and project access.
Use these settings pages for access management:
| Page | Use it to |
| -------- | ---------------------------------------------------------------------------------------- |
| Team | Invite teammates, update organization roles, and restrict members to specific projects. |
| API Keys | Create, update, or revoke organization API keys with selected scopes and project access. |
Only **Owners** and **Admins** can manage access. Owners can manage any role, including other Owners. Admins can manage most members and API keys, but they cannot assign or manage the Owner role.
If an Admin is restricted to specific projects, they can only manage access for projects they can already access. Restricted Admins cannot grant unrestricted organization access.
Organization roles [#organization-roles]
Organization roles control the maximum set of actions a member can take. Project access can narrow where those actions apply, but it cannot grant permissions beyond the member's organization role.
| Role | What it can do |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Owner | Full organization control. Owners can manage billing, settings, access controls, API keys, and other Owners. Owners always have access to all projects. |
| Admin | Full working access and access-management permissions, except for managing Owners. Admins can be restricted to specific projects. |
| User (Legacy) | Legacy Admin-level role kept for backward compatibility. Treat this as full access and reassign it when possible. |
| Editor | Can create and edit paywalls, campaigns, notifications, and assets. Editors can view related resources, but cannot manage access or sensitive organization settings. |
| Reader | Read-only visibility into dashboard resources. Readers cannot create, update, or delete resources. |
| Analyst | Read-only, analytics-focused visibility for stakeholders who need reporting access without edit permissions. |
Project access [#project-access]
Each member has one project access mode:
| Mode | What it means |
| ------------ | ------------------------------------------------------------------------------------------ |
| All Projects | The member can access every current and future project allowed by their organization role. |
| Restricted | The member can only access the projects you assign to them. |
When a member is **Restricted**, assign one role for each project they can access:
| Project role | Use it for |
| ------------ | ------------------------------------- |
| Admin | Project-level management access. |
| Editor | Editing resources inside the project. |
| Viewer | Read-only access to the project. |
Project roles are capped by the organization role. For example, a Reader with a Project Admin grant is still read-only because the organization role does not allow writes.
Use the **Project access** dropdown when inviting or editing a member to choose **Restricted**. When selected, Superwall shows the project assignments and project role controls for that member.
Invite a member [#invite-a-member]
1. Open **Settings > Team**.
2. Click **Invite member**.
3. Enter the member's name and email.
4. Choose an organization role.
5. Choose **All Projects** or **Restricted**.
6. If restricted, select the projects they can access and choose a project role for each one.
7. Click **Invite**.
The invite appears as pending until the user accepts it.
Update a member [#update-a-member]
From **Settings > Team**, click **Edit** next to a member. You can change their organization role, project access mode, and project assignments.
Owners cannot remove or demote the last Owner in an organization. Admins cannot assign the Owner role or edit existing Owners.
API key access [#api-key-access]
Organization API keys use the same access model:
| Setting | What it controls |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Scopes | Which resources the key can read or write, such as paywalls, campaigns, products, webhooks, charts, users, assets, access controls, or ClickHouse analytics data. |
| Project Access | Whether the key can operate across all projects or only selected projects. |
Both checks must pass. For example, an API key with `paywalls:write` and **Restricted** access to one project can only update paywalls in that project.
In the create key dialog, choose the scopes first, then use **Project access** to decide whether the key can access all projects or only selected projects.
When you create a key, Superwall shows the token once. Copy it before closing the dialog. After that, the dashboard only shows a masked token.
Revoke or update an API key [#revoke-or-update-an-api-key]
Use **Settings > API Keys** to review each key's scopes, project access, creation date, and last-used timestamp. Edit the key to change its scopes or project restrictions, or revoke it when it is no longer needed.
Prefer restricted API keys for automation. Give each service only the scopes and projects it needs.
Keys with `data:read` can use the [ClickHouse query API](/docs/dashboard/guides/query-clickhouse) to run read-only SQL against your organization's analytics data.
Troubleshooting [#troubleshooting]
If a member cannot see a project, confirm that their project access mode is **All Projects** or that the project is selected in their restricted assignments.
If an API request is denied, check both the key's scopes and its project access. The key needs the correct resource scope and access to the target project.
If you cannot assign an Owner, make sure you are signed in as an Owner. Admins cannot grant or manage Owner access.
Related [#related]
* [Team settings](/docs/dashboard/dashboard-settings/overview-settings-team)
* [Projects](/docs/dashboard/dashboard-settings/overview-settings-projects)
* [Keys](/docs/dashboard/dashboard-settings/overview-settings-keys)
# Advanced
In the **Advanced** section within **Settings**, you can view system health and remove your app:
Disabling Superwall [#disabling-superwall]
If you'd like to temporarily disable Superwall, **click** the **Disable Superwall** button. You can later resume it, but disabling Superwall stops all paywalls from being presented or placements being evaluated.
Removing your app [#removing-your-app]
To permanently remove your app from Superwall, **click** the **Delete Application** button. You cannot undo this action.
# All teams
In the **All Teams** section within **Settings**, you can easily view each team that's part of your Superwall account:
When you click on that link, a modal appears to quickly filter through available options:
You can also activate this by using the ⌘+K keyboard shortcut.
# Apple Search Ads
In the **Apple Search Ads** section within **Integrations**, you can the enable Apple Search Ads integration with Superwall:
Apple offers two different search ad services, "Basic" and "Advanced" tiers. Superwall supports
both of them, though more data is available with the Advanced ads.
Basic search ads setup [#basic-search-ads-setup]
If you're only using basic search ads, **click** the toggle next to **Basic Apple Search Ads** to enable the integration:
That's it, you're all set. With basic Apple Search Ads enabled, you'll be to see users acquired via search ads in the [users page](/docs/dashboard/overview-users).
To see what you can do with advanced search ads data, skip down to the [use cases](#use-cases) section.
Advanced search ads setup [#advanced-search-ads-setup]
Advanced search ads takes a few more steps since it requires the [Campaign Management API](https://searchads.apple.com/help/campaigns/0022-use-the-campaign-management-api). The overview is as follows, with more details about each step below them:
* First, you'll need to create a user in Apple Search Ads **using a different Apple Account** than your primary Apple Account.
* This new user will need to be set up with either the API Account Manager or API Account Read Only role.
* Then, you'll generate three things by pasting in a public key from Superwall: a client ID, team ID and key ID.
* Finally, you'll enter those three values into Superwall.
**Step One: Invite a new user**
1. Go to [searchads.apple.com](https://searchads.apple.com) and click **Sign In -> Advanced**.
2. Locate your account name in the top right corner and click **Account Name -> Settings**.
3. Under User Management, click **Invite Users**.
4. Grant the user appropriate permissions and enter in the rest of the details. The email address here is the one you'll want to use to create a new user in Apple Search Ads:
**Step Two: Accept the invitation**
Open the email and follow Apple's instructions to set up a new user with Apple Search ads. The email will look similar to this:
Once you've accepted the invitation using the invited Apple Account:
1. Once again, go to [searchads.apple.com](https://searchads.apple.com) and click **Sign In -> Advanced**.
2. Locate your account name in the top right corner and click **Account Name -> Settings**.
3. Over in Superwall, go to the **Settings -> Apple Search Ads -> click copy** under the public key:
4. Back in Apple Search Ads, paste the public key under **Public Key** and click **Generate API Client**:
**Step Three: Generate the client ID, team ID and key ID**
Now, you should see three values that have been generated by Apple Search Ads, a client ID, team ID and key ID.
1. Copy each generated value.
2. In Superwall, paste each value in and click "Update ASA Configuration."
3. Finally, click on "Check Configuration" and confirm everything is set up properly.
Use cases [#use-cases]
Once you've enabled Apple Search Ads, you can use the data in a few ways. First, users who've been acquired from a search ad will display that information in the users page under "Apple Search Ads." This is available with either the basic or advanced search ads. This can be useful for understanding the quality of users acquired from search ads.
If you're using advanced search ads, you get significantly more capabilities:
* You can leverage search ad data in your campaigns. This opens up the ability to do things like showing a specific paywall to a user who was acquired via a search ad, tailor messaging from the keyword that was used, and more.
* You can view search ads data in charts, breaking down metrics by campaign name and more.
Viewing users acquired via Apple Search Ads [#viewing-users-acquired-via-apple-search-ads]
If any user was acquired via a search ad, you'll see that data in the [users page](/docs/dashboard/overview-users). This can be useful for understanding the quality of users acquired from search ads:
Here's a breakdown of the attributes you'll see:
| Attribute | Example | Description |
| ----------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Ad Group Id | 1684936422 | The identifier for the ad group. Use Get Ad Group-Level Reports to correlate your attribution response by adGroupId. |
| Ad Group Name | Primary Ad Group | The name of the ad group for organizational and reporting purposes. |
| Ad Id | -1 | The identifier representing the assignment relationship between an ad object and an ad group. Applies to devices running iOS 15.2 and later. |
| Attribution | true | A Boolean value indicating if the attribution was successful. Returns true if a user clicks an ad up to 30 days before downloading your app. |
| Bid Amount | 0.25 | The cost-per-click (CPC) bid amount placed for this ad group. |
| Bid Currency | GBP | The currency used for the bid amount. |
| Campaign Id | 1633810596 | The unique identifier for the campaign. Use Get Campaign-Level Reports to correlate your attribution response by campaignId. |
| Campaign Name | Primary Campaign (US) | The name of the campaign, useful for tracking and organizational purposes. |
| Conversion Type | Download | The type of conversion, either Download or Redownload. |
| Country Or Region | US | The country or region for the campaign. |
| Keyword Id | 1685193881 | The identifier for the keyword. |
| Keyword Name | baskeball app | The specific keyword that triggered the ad. |
| Match Type | EXACT | The keyword matching type used to trigger the ad (e.g., EXACT, BROAD, or PHRASE). |
| Org Id | 3621140 | The identifier of the organization that owns the campaign. This is the same as your account in the Apple Search Ads UI. |
Using search ad data in campaigns [#using-search-ad-data-in-campaigns]
Using the table above, you can turn around and use any of those values to create [campaign filters](/docs/dashboard/dashboard-campaigns/campaigns-audience#filters):
There is a delay from the moment a user downloads your app via a search ad to the time that event
is sent to Superwall from Apple's servers. For that reason, using search ad data as a filter on
events like an app's launch is discouraged.
Charts [#charts]
Use data from Apple Search Ads in our [charts](/docs/dashboard/charts) as a breakdown and filter:
Apple Search Ads data can be used in the following charts:
* **Proceeds**
* **Sales**
* **Conversions**
* **New Subscriptions**
* **New Trials**
* **Trial Conversions**
* **Refund Rate**
As far as search ads data, you can create breakdowns using the following:
* **Ad Group Name**
* **Campaign Name**
* **Keywords Match Name**
* **Match Type**
Some common use cases here are:
* Attributing new trials from a search campaign.
* Seeing which keywords generate the most revenue.
* Understanding the quality of users acquired from a search ad.
* etc.
# Billing
In the **Billing** section in **Settings**, you can enter, or change, billing details for your Superwall account:
Enter or edit billing details [#enter-or-edit-billing-details]
Below the invoice section, you can enter (or edit) your billing details at any point. When you're finished, **click** the **Update Billing Details** button to save your changes.
View past invoices [#view-past-invoices]
You can view upcoming and previous invoices from Superwall in this section, along with your subscription status right above it as well. The table will show payment status, the amount owed or paid, and the period the invoice fell within.
You can also click any invoice row to download it as a .pdf.
Entering in or editing a card on file [#entering-in-or-editing-a-card-on-file]
To enter in a credit card for payments, **click** the **Add Card** button above the invoice section:
From there, you can manually enter in your card details:
Or, you can use the Autofill Link service by clicking the green "Autofill Link" button:
If you have a card already present, you can remove it and add a different one here as well.
If you have any questions about your billing details or invoices, please feel free to reach out to us.
# Keys
In the **Keys** section under **Settings**, you can easily view or copy your API key, view session starts by the last seven days, and app version sessions from the last seven days.
API Key [#api-key]
You'll use your API key to initialize the Superwall client in your apps. Each one is prepended with `pk_` and then the identifier. To easily copy it, **click** the **Copy** icon on the right-hand side:
These are public SDK keys used to configure Superwall in your app. For scoped organization API keys used by servers, CI jobs, or other backend automation, use [Access Controls](/docs/dashboard/dashboard-settings/overview-settings-access-controls).
Session starts by SDK version [#session-starts-by-sdk-version]
Use the session starts by SDK chart to see how many sessions are being started, split out by the SDK version of Superwall.
This helps debug any potential issues that may occur by comparing sessions against the version of our SDK users are on. Of course, if your issue appears to be related to Superwall's SDK — always feel free to reach out so we can investigate.
App version [#app-version]
Similarly, the app version chart helps you narrow down issues by showing sessions split out by app versions. If the use of our SDK didn't change, but your app version did, and an issue is occurring — then it may related to your app specific logic.
# Localization
Todo.
# Projects
In the **Projects** section within **Settings**, you logically group your apps together (regardless of the platform they are on):
Projects are typically the same app on multiple platforms. That is, the same app on iOS and Android. Grouping them together as a project can help with organization and make reporting a bit easier to manage.
Creating a new project [#creating-a-new-project]
To create a new project, **click** the **Create Project** button at the top right:
From there, give it a name and **click** the **Save** button:
Adding apps to a project [#adding-apps-to-a-project]
After you've created a project, you can select an app for the iOS and Android platform by clicking their respective buttons:
# Public Beta
In the **Public Beta** section within **Settings**, you can opt-in to Superwall beta features:
Simply toggle which features you'd like to activate for your Superwall account.
These features are in beta for a reason. You may encounter bugs or errors.
# Refund Protection
In the **Refund Protection** section under **Settings**, you can configure settings to better equip Apple to handle refund requests from your iOS app. This could result in fewer refunds being issued based on the context you provide Apple:
Before you configure this, make sure you have
[revenue tracking](/docs/dashboard/dashboard-settings/overview-settings-revenue-tracking)
set up.
Refund protection options [#refund-protection-options]
When you opt into refund protection, there are four different options to choose from:
1. **Do not handle (default):** The default option, which means Apple will handle refunds as they see fit.
2. **Ask Apple to grant all refunds:** This option will inform Apple to grant **all** refunds, regardless of the context you provide.
3. **Ask Apple to decline all refunds:** This option will inform Apple that you wish to default to declining refunds. For example, if you have an app that has a credits-based system and a user requests a refund, you may want to try and decline that refund if the services were provided.
4. **Submit data and let Apple decide:** This option will inform Apple that you wish to submit data to them for each refund request. This data could be used to help Apple make a more informed decision on whether to grant or decline the refund.
In-app purchase Configuration [#in-app-purchase-configuration]
You'll need to have in-app purchases configured with Superwall to use refund protection. For more information on setup, check this doc [out](/docs/dashboard/dashboard-settings/overview-settings-revenue-tracking#in-app-purchase-configuration).
# Google Play Revenue Tracking
In the **Revenue Tracking** section under **Settings**, you can now setup Google
Play Revenue Tracking.
Google has made this really hard, so if you don't exactly follow the steps
below, you might not get it working.
Once configured, it can take up to 36 hours for the keys to start working.
1\. Enable Required APIs [#1-enable-required-apis]
* Enable: [Google Play Android Developer
API](https://console.cloud.google.com/apis/library/androidpublisher.googleapis.com)
* Enable: [Cloud Pub/Sub
API](https://console.cloud.google.com/apis/library/pubsub.googleapis.com)
If these are enabled, you'll see a blue "Manage" button next to the "Try this
API" button and a green API Enabled check.
2\. Create a Service Account [#2-create-a-service-account]
Create a new service account [#create-a-new-service-account]
* Visit [Service
Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts) and
create a new user.
* 1. Create service account
You can specify anything here.
* 2. Permissions
* Add the "Pub/Sub Admin" role
* Add the "Monitoring Viewer" role
* 3. Principals with access
Skip this step.
Download the service account credentials [#download-the-service-account-credentials]
1. Click on the newly created service account
2. Go to the "Keys" tab
3. Click "Add key"
4. Select "Create new key"
5. Select "JSON"
6. Click "Create"
7. Upload that key file to Superwall under "Google Play Private Key"
3\. Add Service Account to Google Play Console [#3-add-service-account-to-google-play-console]
1. Visit [Google Play Console](https://play.google.com/console/u/0/signup)
2. Select "Users and Permissions"
3. Click "Invite new users"
4. Paste in the email address of the service account you created. You'll find this under the "Details" tab of your service account.
5. Select "Account Permissions"
6. Add the following permissions:
* "View app information and download bulk reports"
* "View financial data, orders, and cancellation survey responses"
* "Manage orders and subscriptions"
7. Click "Invite"
4\. Setup Pub/Sub Topic [#4-setup-pubsub-topic]
1. Go to your app within the Google Play Console
2. Select "Monetize with Play"
3. Select "Monetization setup"
4. Under "Google Play Billing", check "Enable real-time notifications"
5. Copy the "Topic Name" from Superwall and paste it into the "Topic name" field in the Google Play Console.
* If you do not see this field, ensure the service account from [Step 3](#3-add-service-account-to-google-play-console) has been correctly added to the Play Console
6. Under "Notification content", select "Subscriptions, voided purchases, and all one-time products"
7. Click "Save"
# Revenue Tracking
In the **Revenue Tracking** section under **Settings**, you can set up revenue tracking three different ways. Revenue tracking is required to show revenue metrics in the Superwall dashboard.
Status [#status]
Your revenue tracking status will be listed at the top, indicating if you've successfully set it up or not:
Until we receive the first event for your app (including Sandbox events), the configuration will still show as **missing**.
Methods [#methods]
There are different methods for revenue tracking depending on your platform:
* iOS: [App Store Connect](#ios-app-store-connect)
* Android: [Google Play](#android-google-play)
If you're using RevenueCat for purchase handling: [RevenueCat](#revenuecat)
Choose only **one** of these methods.
As soon as you've completed the steps for any of them, you should see integrated events begin to show up in Superwall's metrics.
iOS: App Store Connect [#ios-app-store-connect]
Option 1 - App Store Connect Server Notifications [#option-1---app-store-connect-server-notifications]
Use this method to forward subscriptions events from App Store Connect back to Superwall. To get started, go to **App Store Connect → App Information → App Store Server Notifications → Production & Sandbox URL fields:**
For the URL, use the value in Superwall that was prefilled by clicking the copy button:
Then, enter this in App Store Connect modal:
**Click** the **Save** button once you've entered in the URL.
Option 2 - Event Forwarding [#option-2---event-forwarding]
If you handle subscription logic on your own server and are using Apple's subscription events notifications, use this method. It will forward Apple subscription events from your server to Superwall.
To implement this method, simply forward the **unmodified request** to Superwall before any other application logic.
Here's a Node.js example, just be sure to use your own API key in place of `YOUR_PRIVATE_KEY` in the snippet below:
Your private key is
**not**
the same as your public key. Superwall will prefill your private key in the code snippets on the Revenue Tracking page for both Event Forwarding and App Store Connect setup.
```javascript
request.post(
{
url: "https://superwall.com/api/integrations/app-store-connect/webhook?pk=YOUR_PRIVATE_KEY",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req.body),
timeout: 5000,
},
(error, response, body) => {
if (!error && response.statusCode == 200) {
console.log("Successfully forwarded to Superwall")
} else {
console.error("Failed to send notification to Superwall", error)
}
}
)
```
In-App Purchase configuration [#in-app-purchase-configuration]
Setting up the in-app purchase configuration for iOS apps allows Superwall to power features like refund
consumption.{" "}
To complete setup, follow these steps:
* Navigate to [App Store Connect](https://appstoreconnect.apple.com). - Click on **Users and
Access**.
* Click on **Integrations** at the top. - Under **Keys**, choose **In-App Purchase**.
* Click on **+** to create a new key if you don't have one. Add a name, and click **Generate**.
You can reuse the **same key** for all of the apps falling under the same App Store Connect
account. Though, you must still have access to the one-time download of its generated P8 key
file. If you don't have access to this anymore, simply create a new one.
* **Click** on "**Download In-App Purchase Key**" for the new key. On the resulting
modal, click **Download**.
**IMPORTANT**: You only have one chance to download the key file. Make sure to save it in a
secure location.
* Upload the key file you downloaded in the previous step to Superwall under "P8 Key File." -
Fill in the **Bundle ID** of your app. - Enter the **Key ID** of the key you created in App
Store Connect. You can find it in the "Key ID" column shown in the image from step 3. Locate the
row for the key you made and copy the corresponding Key ID here.
Bundle IDs are case-sensitive. Enter the Bundle ID exactly as it appears in App Store Connect
or Xcode, including capitalization. For example, `com.Company.App` and `com.company.app` are
treated as different bundle IDs.
- For **Issuer ID**, fill in
the value found at **Users and Access -> Integrations -> In-App Purchase** in App Store Connect:
- **Click** on
**Update** and confirm everything is set up correctly.
App Store Connect API [#app-store-connect-api]
Setting up the App Store Connect API helps Superwall pull product data from the App Store.
To complete setup, follow these steps:
* Navigate to [App Store Connect](https://appstoreconnect.apple.com). - Click on **Users and
Access**.
* Click on **Integrations** at the top. - Under **Keys**, choose **App Store Connect API**.
* Choose **Team Keys** and create a new one. - Add a name, and for **Role** choose **App
Manager**. - **Click** on **Generate**. - **Click** on **Download** for the new key. On the
resulting modal, click **Download**.
**IMPORTANT**: You only have one chance to download the key file. Make sure to save it in a
secure location.
* Upload the key file you downloaded in the previous step to Superwall under "P8 Key File." -
Enter the **Key ID** of the key you created in App Store Connect. You can find it in the "Key
ID" column shown in the image from step 3. Locate the row for the key you made and copy the
corresponding Key ID here.
- For **Issuer ID**,
fill in the value found at **Users and Access -> Integrations -> In-App Purchase** in App Store Connect:
- **Click** on
**Update** and confirm everything is set up correctly.
Android: Google Play [#android-google-play]
You can now forward subscription events directly from Google Play to Superwall. For implementation details, please refer to our guide on [Revenue Tracking for Google Play](/docs/dashboard/dashboard-settings/overview-settings-revenue-tracking-google-play).
RevenueCat [#revenuecat]
Finally, if you're using RevenueCat, you can forward subscription events from RevenueCat to back to Superwall. For implementation details, please refer to their [documentation](https://www.revenuecat.com/docs/superwall).
Tracking Revenue with RevenueCat for iOS and Android Projects [#tracking-revenue-with-revenuecat-for-ios-and-android-projects]
If you are using RevenueCat and have *both* an Android and iOS app under the same project, be aware that you should have **two** Superwall apps (one for Android and iOS). Then, you can link them together as one project under [Settings -> Projects](/docs/dashboard/dashboard-settings/overview-settings-projects). In your RevenueCat project, you can refer to either the iOS or Android key from Superwall. Superwall will still segment the data by platform.
Here's an example, this app has both an Android and iOS project in Superwall. Both of them use RevenueCat:
In Superwall, those have been linked together as a project:
For *either* the iOS or Android project, go to **Settings -> Revenue Tracking -> RevenueCat** and get the integration token (or make one if you haven't yet):
Finally, in RevenueCat, use the token in their integration settings for Superwall:
# Team
In the **Team** section within **Settings**, you can view and edit your Superwall team:
Team members can collaborate across your Superwall organization. By default, members can access all projects allowed by their role. To restrict members to specific projects, use [Access Controls](/docs/dashboard/dashboard-settings/overview-settings-access-controls).
Invite users [#invite-users]
To invite a user to collaborate on your apps, **click** on the **Invite Users** button at the top right:
From there, fill out the details (name and email address) and **click** the **Invite** button:
Once the user accepts the invite, they'll show up in your Team section. You can add or remove team members at anytime. To remove a team member, **click** the **trashcan** icon under **Actions**.
Team roles [#team-roles]
Only **Owners** or **Admins** can change team member roles. For project-level restrictions and API key permissions, use [Access Controls](/docs/dashboard/dashboard-settings/overview-settings-access-controls).
Owner — Full control [#owner--full-control]
* Can perform all actions on the team/organization
* Can invite/remove team members
* Can assign or change any team member's role including other Owners
* Can modify billing and organization settings
* Access to all features including sensitive data (webhooks, API keys)
* Maximum privileges
Admin — Full access, limited team management [#admin--full-access-limited-team-management]
* Can perform most administrative actions
* Can invite/remove team members
* Cannot assign or change Owner roles
* Access to sensitive features like webhook destinations
* Full create/update/delete permissions on paywalls, campaigns, products, etc.
Editor — Can create and modify content [#editor--can-create-and-modify-content]
* Can create and update paywalls, campaigns, notifications, and assets
* Can view applications and organizations
* Cannot delete applications
* Cannot access team management (invite/remove members)
* Cannot access sensitive settings like webhooks or billing
Reader — View-only access [#reader--view-only-access]
* Can view/read all resources (paywalls, campaigns, analytics, etc.)
* Cannot create, update, or delete anything
* Useful for stakeholders who need visibility but shouldn't make changes
Analyst — Analytics-focused visibility [#analyst--analytics-focused-visibility]
* Can view analytics and reporting surfaces
* Cannot create, update, or delete resources
* Useful for finance, data, or growth stakeholders who need visibility without edit access
User (Legacy) [#user-legacy]
The User role is a legacy role kept for backward compatibility. It has the same permissions as Admin. Use Admin, Editor, Reader, or Analyst for new assignments when possible.
* Has the same permissions as Admin
* Exists only for backward compatibility with accounts created before the current role system
* If you see team members with the User role, consider reassigning them to the appropriate role
Renaming your team [#renaming-your-team]
To rename your team, enter in a new value name under the **Team Name** section, and **click** the **Save** button:
# General
To access settings for your Superwall account, **click** the **Settings** button in the sidebar:
Under the General section, you can set or edit metadata and some integration API keys for your app:
Some sections will hide or show depending on the platform your app is on. For example, for an
Android app, Superwall will hide Apple-specific items.
**Name**
The name for your application in Superwall. This isn't user-facing and only used within Superwall. You can change it anytime.
**Revenue Cat Public API Key**
Add in your Revenue Cat [public API key](https://www.revenuecat.com/docs/welcome/authentication) to have Superwall automatically pull in product identifiers. Note that you'll need to create products in Superwall along with their pricing — this just makes it a bit easier to do by fetching identifiers for you.
**Apple App ID**
Fill in your app's Apple identifier here. You can find it by going to **App Store Connect -> General -> App Information**.
**Bundle ID**
Fill in your iOS app's bundle identifier exactly as it appears in App Store Connect or Xcode. Bundle IDs are case-sensitive, and the casing must match when configuring Apple keys such as your In-App Purchase configuration.
**Apple Custom URL Scheme**
We use URL schemes to perform deep link logic and for in-app previews of paywalls for iOS apps. To learn more about setting up deep links, visit this [doc](/docs/sdk/quickstart/in-app-paywall-previews#using-deep-links-to-present-paywalls).
**Google Custom URL Scheme**
We use URL schemes to perform deep link logic and for in-app previews of paywalls for Android apps. To learn more about setting up deep links, visit this [doc](/docs/sdk/quickstart/in-app-paywall-previews#using-deep-links-to-present-paywalls).
**Apple Small Business Program**
If you are a part of Apple's small business program, add in the date you were accepted into it. Optionally, add the date you were removed (if applicable). We'll use this to accurately report revenue metrics.
If you added your Apple Small Business program status later on, Superwall will accurately reflect
revenue for any new data. It doesn't backfill existing revenue metrics.
Application settings [#application-settings]
Here, you can enter metadata about your iOS app that corresponds to your Stripe integration.
1. **Icon:** An icon to represent your app, we recommend using the same one that your iOS app does. This will appear on the checkout and subscription management pages.
2. **Web Paywall Domain:** The domain your paywalls will be shown from. This was set when the Stripe app was created, and cannot be changed.
3. **Application Name:** The name of your app, we recommend using the same name as your iOS app.
4. **Support URL:** A URL to your support page. This will be shown on the checkout and subscription management pages.
5. **Support Email:** An email you provide customers for support questions and general reach out
6. **Redeemable on Desktop:** If your app is an iPad app on Mac, enable this option so that users can redeem products on their Mac. If you aren't using iPads Apps on the Mac, you can disable this. If this is disabled, Superwall enforces redemption on an iOS device.
Stripe Live Configuration [#stripe-live-configuration]
This section allows you to connect Stripe keys with Superwall. You will need a:
1. **Publishable Key:** A Stripe publishable key. Stripe creates this key for you, you don't need to generate it yourself.
2. **Secret Key:** A Stripe secret key that you create. Once you've made one, paste it here.
You can find these keys [in your Stripe account](https://dashboard.stripe.com/settings/apps/com.superwall.stripe-rak). If you need help getting set up, check out the docs [here](/docs/web-checkout/web-checkout-configuring-stripe-keys-and-settings).
Stripe Sandbox Configuration [#stripe-sandbox-configuration]
The sandbox configuration allows you to test purchasing flows with your web checkout integration. If you need to find these keys, you can find them in [your Stripe account](https://dashboard.stripe.com/test/settings/apps/com.superwall.stripe-rak).
1. **Publishable Key:** A Stripe publishable key. Stripe creates this key for you, you don't need to generate it yourself.
2. **Secret Key:** A Stripe secret key that you create. Once you've made one, paste it here.
iOS configuration [#ios-configuration]
This section has critical information for your iOS app. Without it, web checkout won't work.
1. **Apple Custom URL Scheme (no slashes)**: Your custom URL scheme. If you haven't set this up, view the [documentation](/docs/sdk/quickstart/in-app-paywall-previews).
2. **Apple App ID**: Your app's Apple ID. You can find this in **App Store Connect -> General -> App Information**.
# RevenueCat Migration Guide
If you're looking to migrate off RevenueCat and use Superwall, here's what you'll need to do along with a few considerations. Your setup can look a little different depending on how you're using RevenueCat, so we'll break it down into a few different sections. Jump to the one that fits your current architecture.
If you're currently using RevenueCat and not Superwall [#if-youre-currently-using-revenuecat-and-not-superwall]
If you've not installed or shipped the Superwall SDK, and are only using RevenueCat — then it's a matter of removing one SDK and adding the other:
1. Remove the RevenueCat SDK from your project.
2. Install the Superwall SDK by following the [installation guide](/docs/getting-started-with-our-sdks).
3. Update any local data models to correlate purchase status.
For step 3, you might've been doing something similar to this to see if a user was subscribed:
```swift
// In RevenueCat's SDK
let customerInfo = try? await Purchases.shared.customerInfo()
return customerInfo.entitlements.active["Pro"]?.isActive ?? false
```
In Superwall, the concept is similar. You query active entitlements:
```swift
switch Superwall.shared.subscriptionStatus {
case .active(let entitlements):
logger.info("User has active entitlements: \(entitlements)")
handler(true)
case .inactive:
logger.info("User is free plan.")
handler(false)
case .unknown:
logger.info("User is inactive.")
handler(false)
}
```
Or, if you're only dealing with one entitlement, you can simplify the above to:
```swift
if Superwall.shared.subscriptionStatus.isActive {
// The user has an active entitlement
}
```
If you're using a [PurchaseController](/docs/sdk/guides/advanced-configuration) with Superwall and RevenueCat [#if-youre-using-a-purchasecontroller-with-superwall-and-revenuecat]
In this case, it's mostly a matter of removing the `PurchaseController` implementation. Remember, a purchase controller is for manually assigning a subscription state to a user and performing purchase logic. Superwall's SDK does all of that out of the box without any code from you:
```swift
// Remove the `PurchaseController` implementation from your app.
// Change this code...
let purchaseController = RCPurchaseController()
Superwall.configure(
apiKey: "MY_API_KEY",
purchaseController: purchaseController
)
// To this...
Superwall.configure(apiKey: "MY_API_KEY")
```
Now, when Superwall is configured without a purchase controller, the SDK takes over all purchasing, restoring and entitlement management.
If you're using observer mode [#if-youre-using-observer-mode]
If you're using RevenueCat today just with [observer mode](/docs/sdk/guides/using-revenuecat#using-purchasesarecompletedby) — you're free to continue to do so. Simply install the Superwall SDK and continue on.
Considerations [#considerations]
1. **Paywalls:** RevenueCat's paywalls can be displayed if an entitlement isn't active, manually, or by providing custom logic. Superwall can do all of those presentation methods as well. The core difference is with Superwall, typically users [register a placement](/docs/sdk/quickstart/feature-gating) at the call site instead of looking at an entitlement. This means you can show a paywall based on one or several conditions, not just whether or not a user has an entitlement.
2. **Purchases:** Superwall uses the relevant app storefront (App Store or Google Play) to check for a source of truth for purchases. This is tied to the account logged into the device. For example, if a user is logged into the same Apple ID across an iPad, Mac and iPhone — any subscription they buy in-app will work on all of those devices too. RevenueCat uses a similar approach, so there typically isn't much you need to do. If any subscription status issues arise, typically restoring the user's purchases puts things into place.
Even if you're using [web checkout](/docs/web-checkout) with either platform, Superwall allows you to manually assign a subscription state to a user via [a `PurchaseController`](/docs/sdk/guides/advanced-configuration).
3. **Platform differences:** Like all products, Superwall and RevenueCat bring different features to the table, even though there are a lot of similarities. While both offer subscription SDKs, paywalls, and analytics - it helps to familiarize yourself with how Superwall is different. Superwall works on the foundations of registering placements and filtering users who activate them into audiences. Superwall groups those concepts together into [campaigns](/docs/dashboard/dashboard-campaigns/campaigns). This means that you're ready from day one to run all sorts of price tests, paywall experiments, and more.
In terms of reporting, RevenueCat currently offers some metrics like LTV and MRR that you may still need. If so, you can continue using RevenueCat alongside Superwall in [observer mode](/docs/sdk/guides/using-revenuecat#using-purchasesarecompletedby) and all of your dashboard analytics should work as they always have.
***
Whatever your setup, Superwall is ready to meet you where you're at. Whether you want to go all-in with Superwall, use it with RevenueCat or any other approach, our SDK is flexible enough to support you.
# Pre-Launch Checklist
In your Superwall account, make sure you've got a card on file to avoid any service disruptions.
Go to `Settings->Billing` in your Superwall account to add one if you haven't yet.
Set up your products in their respective storefront first, whether that's App Store Connect or
Google Play. Once you've done that, add them into Superwall. All of their respective identifiers
should match what they are in each storefront. For more details, refer to this
[page](/docs/dashboard/products).
Each paywall should display one or more of those previously added products. You can associate
them easily on the left hand side of the paywall editor.
Be sure your paywall presents and our SDK is configured in your app. If you need to double check
things, check out the [docs for the relevant platform](/docs/getting-started-with-our-sdks).
Next, after your paywall shows in Testflight and beta builds, make sure you can successfully
purchase a product, start a trial or kick off a subscription. If you run into an issue, try
these steps in our [troubleshooting guide](/docs/support/troubleshooting). They solve a majority of the
common problems.
Finally, make sure that your subscriptions have been approved in each storefront. On App Store
Connect, for example, you'll have to send off each one individually for review. If this is your
initial launch, you can have them approved alongside the first build of your app.
If everything looks good here, you should be ready to launch with Superwall.
Bonus Steps [#bonus-steps]
These aren't essential, but they are good to think about to make sure you're leveraging all Superwall has to offer.
No matter how much you optimize flows, designs or copy — the truth is that, statistically speaking, the majority of users will not convert. Finding out why is key, and you can do that with our surveys that you can attach to any paywall.
Once a user closes a paywall, we'll present the survey attached to it. See how to set them up [here](/docs/dashboard/surveys).
If you're new to Superwall, it might be tempting to use one, do-it-all placement — like `showPaywall` or something similar. We don't recommend this, please use an individual placement for each action or scenario that could possibly trigger a paywall. The more placements you have, the more flexible you can be. It opens up things like:
1. Showing a particular paywall based on a placement. For example, in a caffeine tracking app, two of them might be `caffeineLogged` and `viewedCharts`. Later, you could tailor the paywall based on which placement was fired.
2. You can dynamically make some placements "Pro" or temporarily free to test feature gating without submitting app updates.
3. In your campaign view, you can see which placements resulted in conversions. This helps you see what particular features users might value the most.
The easy advice is to simply create a placement for each action that might be paywalled. For a quick video on how to use placements, check out this [YouTube Short](https://youtube.com/shorts/lZx8fAL8Nvw?feature=shared).
Audiences are how Superwall can segment users by filtering by several different values such as the time of day, app version and more. This lets you target different paywalls to certain audiences. To see an example of how you might set up an advanced example, see this video:
# Presenting Paywalls from One Another
It's possible to present another paywall from one already showing. This can be useful if you want to highlight a special discount, offer, or emphasize another feature more effectively using a different paywall. Check out the example here:
* A [placement](/docs/dashboard/dashboard-campaigns/campaigns-placements) is evaluated when the button is tapped. Superwall sees that the user isn't subscribed, so a paywall is shown.
* Next, the user taps the "Custom Icons Too 👀" button.
* The current paywall dismisses, and then presents the icons-centric paywall.
You can extend this technique to be used with several other interesting standard placements. For
example, presenting a paywall when the user abandons a transaction, responds to a survey and more.
Check out the examples [here](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements#standard-placements).
There are two different ways you can do this, with [custom placements](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements#tap-behaviors) or by using [deep links](/docs/sdk/quickstart/in-app-paywall-previews). We recommend using custom placements, as the setup is a little easier.
Custom placements minimum SDK requirements are 3.7.3 for iOS, 1.2.4 for Android, 1.2.2 for
flutter, and 1.2.6 for React Native.
They both have the same idea, though. You create a new campaign specifically for this purpose, attach a paywall and either add a filter (for deep linking) or a new placement (for custom placements) to match users to it.
While it's not *required* to make a new campaign, it is best practice. Then, if you later have
other paywalls you wish to open in the same manner, you can simply add a new
[audience](/docs/dashboard/dashboard-campaigns/campaigns-audience) for them in the campaign you make from the steps below.
Use Custom Placements [#use-custom-placements]
Select a component on your paywall and add a **Custom Placement** Tap Behavior, and name it whatever you wish (i.e. showIconPaywall).
Finally, be sure to click **Publish** at the top of the editor to push your changes live
Create a new [campaign](/docs/dashboard/dashboard-campaigns/campaigns) specifically for this purpose, here — it's called "Custom Placement Example":
In your new campaign, [add a new placement](/docs/dashboard/dashboard-campaigns/campaigns-placements#adding-a-placement) that matches the name of your custom action you added in step one. For us, that's `showIconPaywall`:
Finally, choose a paywall that should present by **clicking** on the **Paywalls** button at the top:
Use Deep Links [#use-deep-links]
You'll need [deep links](/docs/sdk/quickstart/in-app-paywall-previews) set up for your app. This is how Superwall
will query parameters and later launch your desired paywall.
Choose the paywall you want to open another paywall from. Then, click the element (a button, text, etc.) that should open the new paywall:
1. In its component properties on the right-hand side, add a **Tap Behavior**.
2. Set its **Action** to **Open Url**.
3. For the URL, use your deep link scheme from step one, and then append a parameter which will represent which other paywall to present. This is specific to your app, but here — `offer` is the key and `icons` is the value. Your resulting URL should be constructed like this: `deepLinkScheme://?someKey=someValue`.
4. Set its **Type** to **Deep Link**.
5. Click **Done**.
Here's what it should look like (again, with your own values here):
Finally, be sure to click **Publish** at the top of the editor to push your changes live.
Create a new [campaign](/docs/dashboard/dashboard-campaigns/campaigns) specifically for this purpose, here — it's called "Deeplink Example":
In your new campaign, [add a placement](/docs/dashboard/dashboard-campaigns/campaigns-placements#adding-a-placement) for the `deepLink_open` standard placement:
Edit the default audience's filter to match `params.[whatever-you-named-the-parameter]`. Recall that in our example, the parameter was `offer` and the value was `icons`. So here, we'd type `params.offer` and **click** the **+** button:
Superwall will ask what type of new parameter this is — choose **Placement** and enter the parameter name once more (i.e. "offer"). Click **Save**:
Finally, choose the **is** operator and type in the value of your parameter (in our case, "icons"). Then, **click** the **+ Add Filter** button. Here's what it should look like:
Finally, choose a paywall that should present by **clicking** on the **Paywalls** button at the top:
Test Opens [#test-opens]
After following the steps above for either method, be sure to test out your presentation. Open the relevant paywall on a device and tap on whichever button should trigger the logic. The currently presented paywall should dismiss, and then immediately after — the other paywall will show.
# Query ClickHouse
The ClickHouse query API gives you direct SQL access to the same analytics data Superwall uses for charts and campaign results. Use it when you need flexible reporting, internal dashboards, or ad hoc analysis without maintaining a separate data warehouse.
Requests are scoped to your organization and require an organization API key with the `data:read` scope. Superwall provisions a read-only ClickHouse user for your organization on first use, then applies row-level policies so queries only return data for your organization's applications.
Treat `data:read` keys as sensitive. They can query analytics data for your organization, so create dedicated keys, store them in a secret manager, and revoke them when they are no longer needed.
Endpoint [#endpoint]
Use either `POST` or `GET`:
| Method | Path | SQL location |
| ------ | -------------------------------------------------------------------- | --------------------- |
| `POST` | `https://api.superwall.com/v2/organizations/{organization_id}/query` | Request body |
| `GET` | `https://api.superwall.com/v2/organizations/{organization_id}/query` | `query` URL parameter |
`POST` is recommended for most queries because SQL can be long and multiline.
Authentication [#authentication]
1. Open **Settings > API Keys** in the Superwall dashboard.
2. Create an organization API key.
3. Give the key the `data:read` scope.
4. Copy the token when Superwall shows it.
Pass the token as a bearer token:
```bash
Authorization: Bearer YOUR_SECRET_TOKEN
```
The token must belong to the organization in the path. A key from another organization cannot query this endpoint.
Send a query [#send-a-query]
Set your organization ID and API key:
```bash
export SUPERWALL_ORG_ID="123"
export SUPERWALL_API_KEY="sk_..."
```
Run a query with `POST`:
```bash
curl "https://api.superwall.com/v2/organizations/$SUPERWALL_ORG_ID/query" \
--request POST \
--header "Authorization: Bearer $SUPERWALL_API_KEY" \
--data-binary "SELECT count() FROM sw.events_rep"
```
Run a query with `GET`:
```bash
curl --get "https://api.superwall.com/v2/organizations/$SUPERWALL_ORG_ID/query" \
--header "Authorization: Bearer $SUPERWALL_API_KEY" \
--data-urlencode "query=SELECT count() FROM sw.events_rep"
```
The response is the raw ClickHouse HTTP response. If you do not specify a format, ClickHouse returns its default text format. Add a `FORMAT` clause when you need JSON:
```bash
curl "https://api.superwall.com/v2/organizations/$SUPERWALL_ORG_ID/query" \
--request POST \
--header "Authorization: Bearer $SUPERWALL_API_KEY" \
--data-binary "
SELECT
name,
count() AS events
FROM sw.events_rep
WHERE ts >= now() - INTERVAL 7 DAY
GROUP BY name
ORDER BY events DESC
LIMIT 20
FORMAT JSONEachRow
"
```
Use ClickHouse HTTP options [#use-clickhouse-http-options]
The endpoint proxies ClickHouse HTTP requests after Superwall authenticates your organization API key. You can pass standard ClickHouse URL parameters, such as `query`, `database`, or `default_format`, through the query string:
```bash
curl --get "https://api.superwall.com/v2/organizations/$SUPERWALL_ORG_ID/query" \
--header "Authorization: Bearer $SUPERWALL_API_KEY" \
--data-urlencode "query=SELECT name, count() FROM events_rep GROUP BY name LIMIT 20" \
--data-urlencode "database=sw" \
--data-urlencode "default_format=JSONEachRow"
```
Superwall does not expose the generated ClickHouse username and password. Authenticate to the Superwall endpoint with your bearer token instead of connecting directly to the ClickHouse cluster.
Available tables [#available-tables]
Your read-only user can query the analytics tables Superwall exposes for customer reporting:
| Table | Use it for |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| `sw.events_rep` | Raw Superwall events, including event name, metadata, properties, sandbox flag, application ID, and timestamp. |
| `sw.events_hr_agg` | Hourly event aggregates. |
| `sw.demand_score_events_rep` | Demand Score event data. |
| `open_revenue.attributed_events_by_ts_rep` | Revenue and attribution events ordered by event time. |
| `open_revenue.paywall_open_events_agg` | Aggregated paywall open events. |
| `sw.subscription_status_rep` | Subscription status records. |
| `sw.user_attributes_rep` | User attributes set through the SDK or paywall flows. |
| `sw.applications_rep` | Application metadata available in ClickHouse. |
Use ClickHouse introspection queries to inspect columns before writing a production query:
```sql
SHOW TABLES FROM sw;
SHOW TABLES FROM open_revenue;
DESCRIBE TABLE sw.events_rep;
DESCRIBE TABLE open_revenue.attributed_events_by_ts_rep;
```
Query JSON properties [#query-json-properties]
Some event details are stored in JSON strings such as `props` and `meta`. Use ClickHouse JSON functions to extract them:
```sql
SELECT
JSONExtractString(props, '$placement_name') AS placement,
count() AS opens
FROM sw.events_rep
WHERE name = 'paywall_open'
AND ts >= now() - INTERVAL 30 DAY
GROUP BY placement
ORDER BY opens DESC
LIMIT 20
FORMAT JSONEachRow;
```
Limits [#limits]
Queries run as a read-only organization user with ClickHouse settings applied:
| Limit | Value |
| ---------------------- | ----------- |
| Maximum execution time | 300 seconds |
| Maximum threads | 4 |
| Maximum memory | 8 GB |
| Maximum bytes read | 20 GB |
If a query times out or uses too much memory, narrow the date range, add filters on `applicationId`, `isSandbox`, or event `name`, and avoid selecting large JSON columns unless you need them.
Troubleshooting [#troubleshooting]
| Status | What to check |
| ------ | ------------------------------------------------------------------------------------------------------------------------ |
| `401` | The request is missing a bearer token, or the token is invalid or revoked. |
| `403` | The API key does not include the `data:read` scope. |
| `404` | The requested organization resource could not be found. |
| `429` | Too many requests were sent in a short period. Retry later. |
| `500` | ClickHouse returned an unexpected error or Superwall could not proxy the request. Check the SQL and try a smaller query. |
Related [#related]
* [Access Controls](/docs/dashboard/dashboard-settings/overview-settings-access-controls)
* [Charts](/docs/dashboard/charts)
* [Superwall Skill](/docs/dashboard/guides/superwall-skill)
# Superwall MCP
The Superwall MCP lets AI agents manage your Superwall account through the [Model Context Protocol](https://modelcontextprotocol.io). Instead of clicking through the dashboard, you can create projects, paywalls, campaigns, products, and more directly from tools like Claude Code, Cursor, and Codex.
This is different from the [Superwall Skill](/docs/dashboard/guides/superwall-skill), which gives AI coding agents live docs, API access, and step-by-step SDK integration guides in one install. The Superwall MCP is focused on giving AI tools access to your Superwall account to create and manage resources.
Installation [#installation]
Add the Superwall MCP to your platform of choice using the URL `https://superwall-mcp.superwall.com/mcp`. You'll be prompted to log in to your Superwall account on first use.
Claude.ai [#claudeai]
Go to [Settings → Connectors](https://claude.ai/settings/connectors), click **Add Custom Connector**, and enter `https://superwall-mcp.superwall.com/mcp`:
Cursor [#cursor]
Add the following to your `~/.cursor/mcp.json` file:
```json
{
"mcpServers": {
"superwall": {
"url": "https://superwall-mcp.superwall.com/mcp"
}
}
}
```
Claude Code [#claude-code]
```bash
claude mcp add superwall --transport http https://superwall-mcp.superwall.com/mcp
```
Codex [#codex]
```bash
codex mcp add superwall --url https://superwall-mcp.superwall.com/mcp
```
When you add the MCP via a CLI tool (Claude Code, Codex, or Cursor), a browser window will open to complete authentication. Log in to your Superwall account, and it will authenticate automatically. After that, ask the agent something like "check if you're connected to Superwall" — it will call the `whoami` tool and confirm the connection.
What you can do [#what-you-can-do]
The Superwall MCP can manage nearly everything you'd normally do in the dashboard:
* **Organizations** — list your organizations or create new ones.
* **Projects** — create, update, archive, and unarchive projects.
* **Applications** — add iOS, Android, or web apps to a project.
* **Entitlements** — create, update, list, and delete entitlements that define what features users unlock.
* **Products** — create and manage products linked to App Store Connect or Google Play, with subscription details and pricing.
* **Templates** — browse available paywall templates to use as a starting point.
* **Paywalls** — create paywalls from templates or from scratch, attach products, set presentation style and feature gating, and archive or unarchive them.
* **Campaigns** — set up simple campaigns that show a paywall to 100% of users, or create advanced campaigns with A/B testing, holdout groups, and automatic optimization.
* **Webhooks** — create and manage webhook endpoints, inspect event deliveries, rotate secrets, and retry failed deliveries.
Quick setup [#quick-setup]
You can use the Superwall MCP to go from zero to a fully working paywall setup without ever opening the dashboard. Just ask your AI agent to set up Superwall for your app. You can give it your app name, platform, bundle ID, and store product IDs, and it can create the project, application, products, paywall, and campaign for you.
When the agent creates an application, it returns a `public_api_key`. That's what you pass to `Superwall.configure()` in your app. From there, fire a placement event with `register` and your paywall will show.
Related [#related]
* [Superwall Skill](/docs/dashboard/guides/superwall-skill) — recommended if you want docs access, guided SDK integration, and account management in one tool.
* [Vibe Coding](/docs/sdk/guides/vibe-coding) — overview of the AI tools available for working with Superwall.
# Superwall Skill
The [Superwall Skill](https://github.com/superwall/skills) is a set of [Agent Skills](https://agentskills.io) that give AI coding agents everything they need to work with Superwall. It has access to live documentation, API access, dashboard information, and guided SDK integration flows for every platform.
This is different from the [Superwall MCP](/docs/dashboard/guides/superwall-mcp), which connects AI tools to your Superwall account to create and manage resources. The Superwall Skill can perform all of the tasks the MCP can, and is recommended.
Installation [#installation]
Install with the [skills.sh](https://skills.sh) CLI. This works with any agent that supports skills, including Claude Code, Cursor, and Codex.
Install all skills (recommended):
```bash
npx skills add superwall/skills
```
Or install individual skills:
```bash
# General skill (docs, API, dashboard links)
npx skills add superwall/skills --skill superwall
# Platform-specific quickstart
npx skills add superwall/skills --skill superwall-ios-quickstart
npx skills add superwall/skills --skill superwall-android-quickstart
npx skills add superwall/skills --skill superwall-flutter-quickstart
npx skills add superwall/skills --skill superwall-expo-quickstart
```
What's included [#whats-included]
The Superwall Skill is made up of a general-purpose skill and platform-specific quickstart skills.
General skill [#general-skill]
The `superwall` skill gives agents access to:
* **Live documentation**: Agents fetch docs on demand from `llms.txt` and per-page markdown endpoints, so they always have the latest information.
* **API access**: A bundled `sw-api.sh` helper wraps the Superwall V2 API. Agents can list projects, inspect applications, and manage resources directly from the terminal.
* **Dashboard links**: URL patterns for every dashboard page, so agents can link you to the right place (settings, campaigns, paywalls, users, and more).
* **SDK source cloning**: Agents can clone SDK repos locally to trace internal behavior when debugging.
* **Webhook and integration catalog**: Fetched live from the Superwall integrations endpoint.
Platform quickstart skills [#platform-quickstart-skills]
Each quickstart skill walks the agent through a full SDK integration, step by step:
| Skill | Platform |
| ------------------------------ | ------------------------- |
| `superwall-ios-quickstart` | iOS (Swift / Objective-C) |
| `superwall-android-quickstart` | Android (Kotlin / Java) |
| `superwall-flutter-quickstart` | Flutter |
| `superwall-expo-quickstart` | Expo |
Every quickstart follows the same flow:
1. **Install**: Installs the SDK dependency.
2. **Configure**: Ensures Superwall is ready at app launch.
3. **User management**: Identify users on sign-in, reset on logout.
4. **Feature gating**: Register placements and present paywalls.
5. **Subscription tracking**: Observe subscription status changes.
6. **User properties**: Set custom attributes for audience targeting.
7. **Paywall previews**: Sset up deep links for on-device previews.
The agent reads bundled reference docs for each step, inspects your project, and implements minimal, production-safe changes before moving on to the next step.
API access [#api-access]
The general skill includes a bash helper (`sw-api.sh`) that wraps the Superwall REST API V2. It requires a `SUPERWALL_API_KEY` environment variable. That's an org-scoped bearer token you can generate from [API Keys settings](https://superwall.com/select-application?pathname=/applications/\:app/settings/api-keys).
```bash
# List all available API routes (no API key needed)
sw-api.sh --help
# Show the full spec for a specific route
sw-api.sh --help /v2/projects
# List all projects
sw-api.sh /v2/projects
# Get a specific project
sw-api.sh /v2/projects/{id}
# Create a project
sw-api.sh -m POST -d '{"name":"My Project"}' /v2/projects
```
The `--help` flag fetches the live OpenAPI spec, so the route reference is always current.
Data hierarchy [#data-hierarchy]
Superwall organizes data as **Organization → Projects → Applications**. Each application has a `platform` (ios, android, flutter, react\_native, web), a `bundle_id`, and a `public_api_key` used for SDK initialization. The org-scoped `SUPERWALL_API_KEY` is separate, it's used for API calls.
Quick start [#quick-start]
Ask your AI agent to integrate Superwall into your app. The agent will:
1. Detect your platform from the project structure (e.g., `Package.swift` → iOS, `pubspec.yaml` → Flutter).
2. Determine your purchase controller path, whether you're using Superwall's default purchase handling, RevenueCat, or a custom setup.
3. Walk through the quickstart steps, implementing each one in your codebase.
If you've installed the general skill with an API key, the agent can also look up your projects and applications to find the right `public_api_key` for SDK configuration.
Related [#related]
* [Superwall MCP](/docs/dashboard/guides/superwall-mcp): For managing your Superwall account from AI tools.
* [Vibe Coding](/docs/sdk/guides/vibe-coding): All the AI tools available for working with Superwall listed in one place.
# Abandoned Transaction Paywalls
When a user opens the store purchase sheet and dismisses it before completing the purchase, Superwall tracks a `transaction_abandon` event. You can respond to that in two ways:
1. Show another paywall with a `transaction_abandon` placement.
2. Keep the user on the current paywall and reveal a drawer, offer, or survey using the `didAbandonTransaction` paywall state.
Show another paywall instead [#show-another-paywall-instead]
You can add `transaction_abandon` as a placement in a campaign. If a matching paywall is available, Superwall closes the current paywall and presents the new one.
Use this approach when the recovery experience should be a completely separate paywall, such as a dedicated discount page, a transaction-abandon survey template, or a later campaign with its own audience filters.
For campaign setup details and available audience filter parameters, see [`transaction_abandon`](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements#transaction_abandon).
Use `didAbandonTransaction` in the current paywall [#use-didabandontransaction-in-the-current-paywall]
Use the `didAbandonTransaction` state when you want the recovery offer to feel like part of the same paywall instead of closing one paywall and opening another.
`didAbandonTransaction` is a boolean state variable that Superwall manages for you. It starts as `false` when the paywall opens or when a new purchase begins. If the user cancels the store purchase sheet, Superwall sets it to `true`.
You can use that state to open a drawer after the abandoned transaction:
In the paywall editor, add a [Drawer](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-drawer-component) element. Put the follow-up offer, survey, or personalized message inside the drawer.
Select the drawer and set its open state to use a dynamic value. Use the `state.didAbandonTransaction` variable as the condition so the drawer opens when the value is `true`.
Add a button inside the drawer that starts the purchase you want to offer next. For example, you might show the same product with clearer copy, a discounted product, or a lower-priced alternative.
Preview the paywall on a device, tap the purchase button, then dismiss the App Store or Google Play purchase sheet. The drawer should appear on the same paywall after the transaction is abandoned.
If you need to edit or preview the drawer in the paywall editor, open the **Variables** panel and
temporarily set `state.didAbandonTransaction` to `true`.
Personalize the recovery offer [#personalize-the-recovery-offer]
When a transaction is abandoned, Superwall also stores the abandoned product reference. This lets you personalize copy based on the product the user tried to buy.
For example, if the user attempted to purchase the annual product, you can use the abandoned product variables to show annual-specific copy or pricing inside the drawer:
```liquid
Still interested in {{ products.abandoned.periodly }} access?
```
You can use the same product fields available for your other product variables, such as `products.abandoned.price`, `products.abandoned.periodly`, or `products.abandoned.trialPeriodText`.
`products.abandoned.*` refers to the product on the current paywall that the user attempted to
purchase. Campaign audience filters use a separate `abandoned_product_id` value, which is the
store product identifier.
# First Touch Paywalls
What [#what]
App installs to paywall views is one of the most critical metrics you can track. Using the first touch event to present a paywall is a great way to boost it.
Why [#why]
Showing a paywall from the user's first touch can be an effective alternative to showing one simply after the app launches. This way, it feels less "in the way" and much less like a popup a user had no control over — while still ensuring users view your products.
How [#how]
# Showing Unique Paywalls
What [#what]
Using [audiences](/docs/dashboard/dashboard-campaigns/campaigns-audience) within a campaign, you can:
1. Show unique paywalls for each one.
2. And, within an audience, you can show multiple paywalls based on a [percentage](/docs/dashboard/dashboard-campaigns/campaigns-audience#paywalls).
Why [#why]
Our data clearly demonstrates that showing the right paywall, to the right user, and at the right time dramatically affects revenue. There is rarely a one-size-fits all paywall, so you should be testing different variations of them often.
How [#how]
# Feature Gating
What [#what]
Toggle [feature gating](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-settings#feature-gating) on a paywall to change whether or not a placement restricts access to features.
Why [#why]
When you use "non-gated", it means users will still see a paywall *but* will have access to whatever feature is behind that paywall. This can be useful to allow access to pro features for a limited time — which hopefully will lead to more conversions later on down the road.
How [#how]
# Custom Actions
What [#what]
Use [custom actions](/docs/sdk/guides/advanced/custom-paywall-actions#custom-paywall-actions) to trigger application-specific functionality or logic from within your app.
Why [#why]
Custom actions allow you to fire any arbitrary logic inside your app, allowing you to navigate to certain places or trigger platform-specific APIs (such as playing haptic feedback when tapping on a button on iOS).
How [#how]
# Using Flows for Onboarding
Flows are the primary way to build onboarding in Superwall. Use them when you want to introduce the app, collect user preferences, branch users into different paths, request permissions at the right moment, present a paywall, and keep iterating without shipping app updates.
This guide focuses on in-app onboarding after the user has installed or opened your app. If the journey starts on the web before app install, use [Web Flows](/docs/dashboard/guides/web-flows) instead.
If you are new to Flows, start with [Getting Started with Flows](/docs/dashboard/dashboard-creating-flows/getting-started), then come back here for onboarding-specific structure and launch guidance.
How onboarding Flows work [#how-onboarding-flows-work]
An onboarding Flow is a paywall with a [Navigation element](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-navigation-component) and route-based pages. Campaigns and placements decide when the onboarding Flow appears. The Flow editor controls what happens after that: screens, buttons, routes, branches, permission prompts, products, and post-purchase steps.
Some common patterns found in onboarding Flows might include:
1. A welcome or value proposition page.
2. One or more preference questions using [Flow Elements](/docs/dashboard/dashboard-creating-flows/flow-elements).
3. A personalized education or recommendation page.
4. A permission prompt, if the permission is part of the product experience.
5. A paywall or offer page.
6. A final confirmation, setup, or "start using the app" page.
You do not need every step in every app. Keep the Flow focused on the user decision or setup step that matters most.
Choose an entry point [#choose-an-entry-point]
Most onboarding Flows start from either the `app_install` placement or a custom placement.
Use `app_install` for first launch [#use-app_install-for-first-launch]
The [`app_install`](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements) standard placement fires when the SDK is configured for the first time. Use it when the onboarding Flow should appear as part of the first-launch experience.
To set it up:
1. Create a [campaign](/docs/dashboard/dashboard-campaigns/campaigns) for onboarding.
2. Add `app_install` as a [placement](/docs/dashboard/dashboard-campaigns/campaigns-placements).
3. Assign your onboarding Flow paywall to that placement.
4. If the Flow must be ready immediately, use [Priority Placements](/docs/dashboard/dashboard-campaigns/campaigns-placements-prioritized) so Superwall preloads it before the rest of your campaigns.
Because `app_install` fires once per install, it works well for simple first-run onboarding. If your app has account creation, migration screens, or other native setup before onboarding should begin, use a custom placement instead.
Use a custom placement for native control [#use-a-custom-placement-for-native-control]
Use a custom placement when your app decides exactly when onboarding should begin. For example, you might present onboarding after account creation, after a welcome screen, or after restoring an existing session.
```swift iOS
Superwall.shared.register(placement: "onboarding_start")
```
```kotlin Android
Superwall.instance.register(placement = "onboarding_start")
```
```dart Flutter
await Superwall.shared.registerPlacement("onboarding_start");
```
```typescript React Native
await Superwall.shared.register({ placement: "onboarding_start" });
```
Create the same placement in your onboarding campaign and assign the Flow paywall to it.
Build the Flow [#build-the-flow]
Create the onboarding experience in the paywall editor:
1. Add a [Navigation element](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-navigation-component).
2. Add pages for each onboarding step.
3. Switch to Canvas view.
4. Connect the entry point to the first page.
5. Connect the rest of the pages with [routes](/docs/dashboard/dashboard-creating-flows/linking-pages).
6. Add buttons with the **Navigate Page** tap behavior so users can move forward or backward.
Start with a linear Flow first. Once the basic path works, add branching, permission prompts, purchase steps, and post-purchase pages.
Personalize the path [#personalize-the-path]
Onboarding is usually more effective when the user sees a path that reflects what they told you. Use [Multiple Choice](/docs/dashboard/dashboard-creating-flows/flow-elements#multiple-choice), [Input](/docs/dashboard/dashboard-creating-flows/flow-elements#input), and [Date Picker](/docs/dashboard/dashboard-creating-flows/flow-elements#date-picker) elements to collect onboarding data.
Common examples:
* Ask about the user's goal, then branch to different education screens.
* Ask about experience level, then change the amount of setup guidance.
* Collect a name, then use it in later copy.
* Ask for a target date, reminder time, or preference, then save it as an attribute.
Branching is configured on routes in the Canvas, not inside each CTA. For example, a single **Continue** button can move forward while route conditions decide whether the next page is a beginner path, advanced path, permission explainer, or paywall.
For setup details, see [Linking Pages](/docs/dashboard/dashboard-creating-flows/linking-pages) and [Flow Elements](/docs/dashboard/dashboard-creating-flows/flow-elements).
Set user attributes [#set-user-attributes]
Use onboarding answers to set user attributes. Those attributes can personalize the rest of the Flow, target future campaigns, or feed your analytics and lifecycle tools.
Good onboarding attributes are stable and useful later:
* `onboarding_goal`
* `experience_level`
* `preferred_reminder_time`
* `has_completed_onboarding`
* `permission_intro_seen`
Inside the editor, add a **Set Attribute** tap behavior to a button or other tappable element. You can store a static value, such as `has_completed_onboarding = true`, or store a value collected from a Flow Element, such as the selected multiple-choice value.
If your app needs to react when attributes change, use the SDK delegate or event callbacks for your platform. For SDK setup, see [Setting User Properties](/docs/sdk/quickstart/setting-user-properties).
Request permissions in context [#request-permissions-in-context]
Flows can request system permissions from a tap behavior. This is useful during onboarding because you can explain the value before showing the native prompt.
Examples:
* Ask for notification permission after explaining what reminders the user will receive.
* Ask for location permission after showing the location-based feature.
* Ask for camera or photo access after the user chooses a feature that needs it.
* Ask for App Tracking Transparency after explaining how attribution helps personalize offers.
Use [Permission Prompts](/docs/dashboard/dashboard-creating-flows/permission-prompts) to configure the request, then add **If Granted** and **If Denied** follow-up actions. The denied path can route to a short explanation page or continue onboarding without that capability.
Add the paywall at the right moment [#add-the-paywall-at-the-right-moment]
You can place a paywall anywhere in the onboarding Flow. Common patterns include:
| Pattern | When to use it |
| -------------------------- | ---------------------------------------------------------------------- |
| Early paywall | The product value is already clear and conversion is the primary goal. |
| Mid-flow paywall | The user has answered enough questions to personalize the offer. |
| End-of-onboarding paywall | The user has seen the core value and is ready to start. |
| Post-purchase continuation | The user buys, then continues through setup or a welcome page. |
By default, a purchase closes the paywall or Flow. If onboarding should continue after purchase, set the purchase action's **After Purchase** behavior to **Navigate Page**. See [After purchase behavior](/docs/dashboard/dashboard-creating-flows/tips#after-purchase-behavior) for setup.
Measure onboarding performance [#measure-onboarding-performance]
Use [Flow Journey analytics](/docs/dashboard/dashboard-creating-flows/analytics) to understand how users move through onboarding after the Flow is live.
Use Flow Journey to answer:
* Which onboarding step has the largest drop-off?
* Which branch produces the highest trial or purchase conversion?
* Are users reaching the paywall?
* Are users spending too long on a question or permission page?
* Do different variants produce different journey shapes?
For campaign-level testing, create variants of the Flow paywall or test different campaign audiences. Compare both conversion and step-by-step drop-off; the highest-converting onboarding path is not always the shortest one.
Best practices [#best-practices]
* Keep onboarding focused on one job: activation, preference collection, education, permission setup, or monetization.
* Build the first version linearly, then add branching once the core path works.
* Use indicators when the Flow has more than 3-4 steps.
* Ask only for information you will use in the Flow, app, or future targeting.
* Request permissions after explaining why they matter.
* Keep branch conditions simple enough that the Canvas and analytics remain easy to read.
* Name pages clearly so Flow Journey labels are useful later.
* Use a final page or attribute to mark onboarding completion.
Related docs [#related-docs]
* [Getting Started with Flows](/docs/dashboard/dashboard-creating-flows/getting-started)
* [How Flows are Structured](/docs/dashboard/dashboard-creating-flows/how-flows-are-structured)
* [Linking Pages](/docs/dashboard/dashboard-creating-flows/linking-pages)
* [Flow Elements](/docs/dashboard/dashboard-creating-flows/flow-elements)
* [Permission Prompts](/docs/dashboard/dashboard-creating-flows/permission-prompts)
* [Flow Journey analytics](/docs/dashboard/dashboard-creating-flows/analytics)
* [Web Flows](/docs/dashboard/guides/web-flows)
# Web Flows
Web Flows are web-based acquisition and conversion flows for web-to-app growth. Use them to qualify users from paid campaigns, collect preferences, personalize the path ahead, attribute traffic sources, present web checkout, and then send users to download or open your app.
Web Flows are part of Superwall's [Flows](/docs/dashboard/dashboard-creating-flows/getting-started) feature, adapted for web-to-app journeys. They are designed for the people running growth campaigns. If you are building in-app onboarding after install, start with app Flows. If you are sending traffic from Meta, TikTok, search, influencers, lifecycle email, or landing pages into a web-to-app journey, start here.
Web funnels are a function of Web Flows. In Superwall, the product capability is Web Flows: the same underlying editor and navigation system as Flows, used for web-based acquisition, ad attribution, targeting, web checkout, and app handoff.
Here is an example of a live Web Flow built from the flow shown above. The user enters from a web URL, moves through the personalized steps, and then continues toward checkout, app download, or app activation.
Web Flows vs. app Flows [#web-flows-vs-app-flows]
Web Flows and app Flows share editor concepts, but they solve different jobs:
| Use case | App Flows | Web Flows |
| ------------------ | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Primary audience | Product and app teams | Growth and marketing teams |
| User context | User is already in the app | User starts on the web before app activation |
| Common entry point | SDK placement, app install, feature moment, account setup | Ad click, social link, search campaign, influencer link, email, landing page |
| Main goal | Onboard, educate, collect preferences, request permissions, set user attributes | Qualify traffic, personalize the offer, track attribution, convert on web, send to app |
| Technical focus | App permissions, user attributes, native app behavior | Campaign URLs, query parameters, audience filters, web checkout, attribution |
When to use Web Flows [#when-to-use-web-flows]
Use Web Flows when the first meaningful step in the user journey happens on the web:
* **Paid acquisition flows** that start from Meta, TikTok, Google, social, search, influencer links, email, or a campaign landing page.
* **Quiz-style onboarding** that collects goals, experience level, preferences, or intent before app install.
* **Personalized plan builders** that branch users into different recommendations before checkout or download.
* **Web checkout flows** that let users purchase on the web, then redeem or activate access in your app.
* **Web-to-app funnel workflows** where Web Flows power the flow, checkout, targeting, testing, and app handoff from Superwall.
How Web Flows work [#how-web-flows-work]
A Web Flow combines campaign routing, flow screens, and web checkout:
1. [Web Checkout links](/docs/web-checkout/web-checkout-creating-campaigns-to-show-paywalls) make each campaign placement available as a URL, such as `https://yourapp.superwall.app/start`.
2. Flow-style navigation defines the multi-step experience, including pages, routes, branching, transitions, and analytics.
3. Campaigns, audience filters, and query string parameters let you target and personalize the flow for each traffic source.
When a user visits the URL, Superwall presents the paywall or Flow assigned to that placement. From there, the Flow behaves like any other route-based Flow: buttons can move users between pages, routes can branch based on conditions, and Flow Journey analytics can show where users drop off.
If the Web Flow includes a purchase, use [Web Checkout](/docs/web-checkout) to sell subscriptions or one-time products through Stripe. After checkout, use your configured [post-purchase behavior](/docs/web-checkout/web-checkout-configuring-stripe-keys-and-settings#post-purchase-behavior) to send the user to your app, a redemption flow, or a custom redirect URL.
Create a Web Flow [#create-a-web-flow]
Need help setting up your first web funnel with Web Flows? We have options to help. Reach out to us at [support@superwall.com](mailto\:support@superwall.com).
1\. Set up Web Checkout [#1-set-up-web-checkout]
Create and configure your Web Checkout app first, even if your first version only sends users to download the app:
1. [Create a Web Checkout app](/docs/web-checkout/web-checkout-creating-an-app).
2. [Configure Stripe keys and settings](/docs/web-checkout/web-checkout-configuring-stripe-keys-and-settings).
3. [Add Stripe products](/docs/web-checkout/web-checkout-adding-a-stripe-product) if the flow will include a purchase.
4. [Create a campaign and placement URL](/docs/web-checkout/web-checkout-creating-campaigns-to-show-paywalls).
2\. Plan campaign targeting [#2-plan-campaign-targeting]
Decide how each traffic source should enter the flow. You can use separate placements for major campaigns, or use query string parameters for campaign details:
```html
https://yourapp.superwall.app/start?utm_source=meta&utm_campaign=python-beginner&utm_content=video-a
```
Use those parameters in [audience filters](/docs/dashboard/dashboard-campaigns/campaigns-audience#using-user-properties-or-placement-parameters) or as [paywall variables](/docs/using-placement-parameters) to change copy, route users into different branches, or compare performance by source.
3\. Build the flow screens [#3-build-the-flow-screens]
Open the paywall assigned to the web placement and build the Web Flow:
1. Add a [Navigation element](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-navigation-component).
2. Switch the editor to Flow view.
3. Add pages for the flow steps, such as welcome, goals, preferences, commitment, offer, checkout, and app handoff.
4. Connect pages with [routes](/docs/dashboard/dashboard-creating-flows/linking-pages).
5. Use [conditional branching](/docs/dashboard/dashboard-creating-flows/how-flows-are-structured#branching) to personalize the path based on what the user selects.
For the full editor walkthrough, start with [Getting Started with Flows](/docs/dashboard/dashboard-creating-flows/getting-started).
4\. Design the web-to-app handoff [#4-design-the-web-to-app-handoff]
The final pages should make the next step clear. Depending on your setup, that might be:
* Starting [Stripe checkout](/docs/web-checkout/web-checkout-testing-purchases).
* Redirecting to the App Store, another app download page, or your own install instructions.
* Opening your app with a deep link or universal link.
* Sending the user through Superwall's web checkout redemption flow.
* Redirecting to your own account creation or activation page.
If you are using Redeem mode, make sure your app is ready to handle the post-checkout deep link. See [SDK setup for Web Checkout](/docs/web-checkout/web-checkout-sdk-setup).
Attribute and target campaign traffic [#attribute-and-target-campaign-traffic]
For growth teams, the Web Flow URL is part of the campaign. Treat each URL as a trackable entry point:
* Use UTM parameters for traffic source, campaign, ad set, creative, and keyword.
* Pass known identifiers, such as `email` or `app_user_id`, when you already have them.
* Use audience filters to show different flows or offers by placement parameter.
* Use paywall variables to personalize headlines, quiz copy, offer framing, and handoff pages.
This keeps the acquisition workflow separate from in-app onboarding. A marketer can reason about traffic sources, targeting, creative, checkout, and drop-off without needing to think through native permission prompts or app state.
What is different from app Flows? [#what-is-different-from-app-flows]
Most editor behavior works the same way on the web. The main difference is context: a Web Flow runs in a browser, before the app can use native app capabilities.
That means app-only tap behaviors will not work in a Web Flow. Avoid actions that require the installed app or native permissions, such as requesting location, camera, contacts, photos, or notification permissions. If you need those permissions, ask for them later inside the app after the user installs or opens it.
Web Flows are best for web-safe steps:
* Multi-step education and onboarding.
* Multiple-choice questions.
* Personalization and branching.
* Ad attribution and campaign-specific targeting.
* Web checkout.
* Campaign-specific messaging.
* App download, app open, or account activation calls to action.
Track performance [#track-performance]
For route-based Flows, Superwall tracks Flow page views so you can inspect the user journey and identify drop-off. See [Flow Journey analytics](/docs/dashboard/dashboard-creating-flows/analytics) for details.
For acquisition campaigns, add query string parameters to the Web Checkout link and use them in audience filters or paywall variables. This is useful for paid ads, creator campaigns, lifecycle email, and other sources where you want the flow to adapt to the traffic source. See [query string parameters in Web Checkout links](/docs/web-checkout/web-checkout-creating-campaigns-to-show-paywalls#how-query-string-parameters-work).
Related docs [#related-docs]
* [Getting Started with Flows](/docs/dashboard/dashboard-creating-flows/getting-started)
* [How Flows are Structured](/docs/dashboard/dashboard-creating-flows/how-flows-are-structured)
* [Linking Pages](/docs/dashboard/dashboard-creating-flows/linking-pages)
* [Web Checkout](/docs/web-checkout)
* [Web Checkout Links](/docs/web-checkout/web-checkout-creating-campaigns-to-show-paywalls)
# Welcome
Get up and running with the Superwall Dashboard
Learn to use the Paywall Editor
Learn to setup and use Campaigns
Integrate Web Checkout with your app
Documentation for the Superwall SDK
Feedback [#feedback]
We are always improving our documentation!
If you have feedback on any of our docs, please leave a rating and message at the bottom of the page.
# Account Management
The Account Settings page allows you to manage your personal profile information, security preferences, connected accounts, and passkeys. You can access this page by clicking on the profile menu in the bottom left corner of the dashboard and selecting **Manage**:
The account management page is organized into four main sections:
1. **Profile Information:** Manage your account details and email preferences.
2. **Security Settings:** Configure password and two-factor authentication.
3. **Connected Accounts:** Link social accounts for easier sign-in.
4. **Passkeys:** Set up password-free authentication using passkeys.
Profile Information [#profile-information]
The Profile Information section displays your account details and email verification status.
Name [#name]
Your account display name. This field can be edited to update how your name appears throughout the Superwall dashboard.
Email [#email]
Your account email address is used for:
* Signing into your Superwall account.
* Receiving notifications about your apps and campaigns.
* Account recovery and security alerts.
Email Verification [#email-verification]
If your email is not verified, you'll see a **Not Verified** badge next to your email address. To verify your email:
1. Click the **Resend verification email** link below the email field.
2. Check your inbox for a verification email from Superwall.
3. Click the verification link in the email to complete the process.
Verifying your email ensures you can receive important notifications and helps secure your account.
Security Settings [#security-settings]
The Security Settings section helps you protect your account with password management and two-factor authentication.
Password [#password]
Manage your account password to keep your account secure. If you need to change your password:
1. Click the **Request Password Reset** button.
2. Check your email for a password reset link.
3. Follow the instructions in the email to set a new password.
Use a strong, unique password for your Superwall account to maintain security best practices.
Two-Factor Authentication (2FA) [#two-factor-authentication-2fa]
Two-factor authentication adds an extra layer of security to your account by requiring a second form of verification in addition to your password.
When 2FA is **Disabled**, you'll see a **Disabled** badge and an **Enable 2FA** button. To enable two-factor authentication:
1. Click the **Enable 2FA** button.
2. Follow the setup wizard to configure 2FA using an authenticator app.
3. Save your backup codes in a secure location.
Once enabled, you'll need to provide a verification code from your authenticator app each time you sign in.
Make sure to save your backup codes when setting up 2FA. These codes can be used to access your account if you lose access to your authenticator app.
Connected Accounts [#connected-accounts]
The Connected Accounts section allows you to link your Google or GitHub accounts for faster, more convenient sign-in to Superwall.
Google Account [#google-account]
Connect your Google account to sign in to Superwall using Google authentication.
**When not connected:**
* You'll see a **Not Connected** badge.
* Click **Connect Google** to link your Google account.
* You'll be redirected to Google's authentication page to authorize the connection.
**After connecting:**
* You can use "Sign in with Google" on the Superwall login page.
* You can disconnect your Google account at any time.
GitHub Account [#github-account]
Connect your GitHub account to sign in to Superwall using GitHub authentication.
**When not connected:**
* You'll see a **Not Connected** badge.
* Click **Connect GitHub** to link your GitHub account.
* You'll be redirected to GitHub's authorization page to approve the connection.
**After connecting:**
* You can use "Sign in with GitHub" on the Superwall login page.
* You can disconnect your GitHub account at any time.
Passkeys [#passkeys]
Passkeys provide a secure, password-free way to sign in to your Superwall account using your device's biometrics (like Face ID, Touch ID, or Windows Hello) or a security key.
What are Passkeys? [#what-are-passkeys]
Passkeys are a modern authentication method that:
* Eliminate the need to remember passwords.
* Provide stronger security against phishing and credential theft.
* Use your device's built-in biometric authentication.
* Work across your devices when synced through your operating system.
Adding Your First Passkey [#adding-your-first-passkey]
When you haven't set up any passkeys yet, you'll see an empty state with instructions. To add a passkey, click the **Add Passkey** button to open the passkey creation dialog:
Passkey Name [#passkey-name]
Give your passkey a descriptive name to help you identify it later, such as "MacBook Pro," "YubiKey," or "iPhone." This is especially helpful when managing multiple passkeys across different devices.
Authenticator Type [#authenticator-type]
Choose between two authenticator types:
**Cross-Platform (Recommended)**
Use a physical security key like YubiKey that works across multiple devices. This option is ideal if you want to use the same passkey on different computers or need a portable authentication method.
**Platform Authenticator**
Use your current device's built-in biometrics (Touch ID, Face ID, or Windows Hello). This option ties the passkey to your specific device and is convenient for single-device use.
Once you've entered a name and selected your authenticator type, click **Add Passkey** to complete the setup. Your browser or device will prompt you to authenticate, and your new passkey will be ready to use.
Managing Multiple Passkeys [#managing-multiple-passkeys]
You can add multiple passkeys to your account, which is useful if you:
* Sign in from different devices (work computer, personal laptop, tablet, etc.).
* Want backup authentication methods.
* Share access across different locations.
Each passkey will be listed in this section with options to remove or rename them.
We recommend adding at least two passkeys to your account as a backup in case you lose access to one of your devices.
Using Passkeys to Sign In [#using-passkeys-to-sign-in]
Once you've added a passkey:
1. Go to the Superwall login page.
2. Select the "Sign in with Passkey" option.
3. Your device will prompt you to authenticate using biometrics or your security key.
4. You'll be signed in immediately without entering a password.
Passkeys are tied to specific devices and browsers. If you clear your browser data or switch to a new device, you'll need to add a new passkey or use an alternative sign-in method.
# Managing Localization Updates
If you're only dealing with one paywall, or trying to get started with localizing — read this
[doc](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-localization) first.
When you make changes to a localized string referenced across more than one paywall, or one is changed via a localization platform provider — you can review those changes by **clicking** on the **Localization** button from the sidebar:
You can also use the `⌘+5` keyboard shortcut to open the Localization page from the Overview
screen.
When you open the Localization page, you'll see two primary sections:
1. **Missing Translations:** Any keys that are missing a localized value will show up here to address.
2. **Review & Publish:** Any localizations that have changed which are used on multiple paywalls (or changed from an external localization provider) show up here. Paywalls using any edited localization key here will continue to use the "old" value it had before it was changed, and you can publish the updates live for the other paywalls after reviewing them.
For example, here the key `dominate_the_pitch` had its value changed on Paywall A and it's now live. But, here, Paywall B also references the key `dominate_the_pitch`, so you can hover over each locale identifier and see its old and new value. From there, you can either ignore those changes or put them live for each paywall.
# Overview
Once you've logged into Superwall, you'll be taken to the **Overview** page. Here, you can view key metrics and important campaign performance about your app.
You can toggle between your other apps to view with the Overview page too. Just use the toggle at the top left to choose another app.
Quickstart [#quickstart]
When you log in for the first time or add a new app, the Overview page displays a Quickstart wizard to help you get up and running. Complete the interactive checklist to finish your Superwall integration:
You can also get started with AI using our MCP and prompts:
Dashboard sections [#dashboard-sections]
The overview dashboard is broken down between five main sections:
1. **Toggles:** Switch between new install or all users, and apply different time filters between them.
2. **Key Metrics:** Critical data about how your app is performing.
3. **SDK Alerts & News:** Alerts about new SDK versions available and company news.
4. **Campaigns:** Breakdowns of your active campaigns.
5. **Recent Transactions:** Displays the most recent transactions from the selected app.
New installs versus all users [#new-installs-versus-all-users]
Using the toggles at the top, you can switch between viewing data about **New Installs** or **All Users**, along with changing date ranges between either of them:
Here's the difference between New Installs and All Users:
* **New Installs:** Represents users who installed your app within the selected time frame.
* **All Users:** Represents any user of your app, including returning and new users.
Changing dates [#changing-dates]
Use the toggles at the top to change date ranges:
All of the key metrics and campaign data will be updated based on the range you select. If you need fine-grain control, choose **Custom** and choose any date range you like.
Key metrics [#key-metrics]
View insightful metrics at the top of the overview page:
Here's what they mean, from left-to-right:
Each metric is representative of the data you've selected from the toggles above them.
| Name | Description |
| ----------------- | --------------------------------------------------------------------------------------------------------- |
| Users | The number of unique users who opened your app. |
| Paywall Opens | How many paywall presentations occurred. |
| Conversions | A total number of Conversions resulting from a presented paywall. |
| Paywalls Per User | The percentage of paywalls shown per user. Remember, each user may see more than one paywall per session. |
| Paywalled | The percentage of how many users were shown a paywall in total. |
| Converted | The percentage of users who converted (trial or paid). |
Use these metrics as a way to quickly get a sense of how your app is performing from a monetization standpoint.
Click on any metric to view a chart breakdown with more details about it.
Campaigns [#campaigns]
The active campaigns section gives you a quick overview of how your selected app's campaigns are performing:
For each campaign, Superwall will show:
* The **[placements](https://youtube.com/shorts/lZx8fAL8Nvw)** in-use by the campaign.
* How many **opens** those placements resulted in. Put differently, how many paywall presentations have occurred for the campaign.
* How many **conversions** the campaign has produced.
* The **conversion rate** the campaign currently holds.
Like the metrics section, all the data here is representative of the users or new installs and
time frame you've chosen from the toggles at the top of the Overview page.
Click on any campaign to dig deeper into them. If you would like to view all campaigns (active or paused), click **All Campaigns** at the top-right. Learn more about campaign [here](/docs/dashboard/dashboard-campaigns/campaigns).
Recent transactions [#recent-transactions]
To view recent transactions from your app, use the "Recent Transactions" view:
The transaction view displays:
* **User**: The user ID the event belongs to, along with an emoji flag representing their territory. Click on this value to quickly copy it to your clipboard.
* **Placement**: The placement that the event associates to. You can click this to open the campaign that the placement belongs to.
* **Paywall**: The paywall that the event took place on. Click this value to quickly open a modal preview of the paywall.
* **Product**: The product the event represents. Hover over this value to get a tooltip that will display the product's identifier.
* **Revenue**: Any revenue generated from the event.
* **Purchased**: The time the event occurred.
* **Type**: The event type. The list of events can be found below.
Keep in mind that Superwall displays transaction events based on the moment a server notification is received from the relevant store front. That means that the timing of the event may not necessarily be right when it actually occurred.
Transaction event types [#transaction-event-types]
Each of the following transaction types can show up in the transaction view:
| Event Name | Description |
| ------------------------- | ---------------------------------------------------------------------------- |
| **Main Events (Default)** | A collection of key subscription events for tracking purposes. |
| **All Events** | Includes every possible subscription-related event for complete tracking. |
| **Paywall Conversion** | Triggered when a user successfully converts or starts a trial via a paywall. |
| **Trial Start** | Indicates the beginning of a free trial period. |
| **Direct Sub Start** | A subscription starts without a trial period. |
| **One Time Purchase** | A non-subscription, single-payment transaction is completed. |
| **Intro Offer** | A user subscribes using an introductory offer. |
| **Trial Convert** | A trial that successfully converted into a paid subscription. |
| **Renewal** | An existing subscription renews for another billing period. |
| **Refund** | A user is refunded for a previous purchase. |
| **Trial Cancel** | A user cancels their trial before it converts to a paid subscription. |
| **Trial Expire** | A free trial ends without converting to a paid subscription. |
| **Cancel** | A user cancels their active subscription. |
| **Uncancel** | A previously canceled subscription is reactivated before expiration. |
| **Expire** | A subscription fully expires and is no longer active. |
You can filter the transaction view by any of these using the toggle at the top right:
If you see the paywall or placement values blank — don't worry. This means the user started a subscription, reactivated or whatever the event may be out *outside* of your app. Typically, this occurs when they visit their Apple ID's settings and change subscription or products there manually. Further, processing simply represents just that — Superwall received an event, but some of the details are still being fetched. Typically, this shouldn't make more than a few minutes.
# Users
To view information about users who've recently triggered a placement in your app, **click** on the **Users** button in the sidebar. Looking for a summary of how Superwall keeps subscription states in sync and where this data surfaces? See [Subscription Management](/docs/dashboard/subscription-management).
Once there, you'll see a list of users who've had a session within the last 24 hours by default (or you can filter them by a specific event):
Searching by user identifier [#searching-by-user-identifier]
If you need to find a specific user, use the search box at the top:
This will find users by their Superwall identifier (i.e. `$SuperwallAlias:44409AAF-244D-9F08-A18A-8F66B52FDZ01`). Hit **Enter** once you've copied or typed in an identifier, and the matched user's details will display.
Filtering by event [#filtering-by-event]
Use the toggle at the right-hand side to toggle by a specific [placement](/docs/dashboard/dashboard-campaigns/campaigns-placements) or [standard placement](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements) (such as session start, app close, app open, etc).
Below, Superwall displays all of the users who have opened a paywall the last 24 hours:
Any placements that are specific to your own app (i.e. ones that you've manually added to a campaign) will show with your app's logo next to it. All of Superwall's standard placements will have a Superwall logo.
Another great use of the Users dashboard? Get a quick preview of how many times one of your
placements has fired within the last day. Choose one from the placement toggle, and then you can
quickly see how many times it's been hit by the resulting users Superwall returns.
Viewing user profiles [#viewing-user-profiles]
To see more details about a user, click anywhere on one of the rows. Then, the user profile will be presented where you can review revenue events, SDK events, and more:
It's divided into these sections:
1. **Overview:** Displays key user information including App User ID, Country, Total Spent, SDK Version, and user registration/last seen dates.
2. **Recent Events:** View revenue events and conversions, SDK events, and see the overall breadcrumb of actions the user has taken. You can filter or search by certain events as well.
3. **Entitlements:** Displays any active entitlements the user has attached, how long they'll be active and their corresponding identifiers. See "Granting entitlements" below for more.
4. **Aliases:** Any alias that Superwall has assigned the user will show here. Read more about how user aliases are created [here](/docs/sdk/quickstart/user-management).
5. **Apple Search Ads:** If you have the [Apple Search Ads](/docs/integrations/apple-search-ads) integration activated, you'll see any A.S.A. data that relates to the user (such as the keywords used which led to install, etc).
6. **User:** This houses basic information about the user, such as their install date, user seed and more.
7. **Device:** The user's device details. All device attributes are searchable here as well.
The user profile contains a wealth of information. You can search events by name by using the **Search Events** textbox, and quickly filter by event domains using the toggle at the top-right of the event browser:
The domains of events you can search and filter by are:
1. **Overview:** The default option, this shows all of the key events from today.
2. **Superwall Events:** These are [events](/docs/sdk/guides/3rd-party-analytics/tracking-analytics) automatically tracked by Superwall.
3. **App Events:** Placements that you've manually added to a campaign.
4. **Subscriptions Events:** Any transactions, trial starts, and similar subscription events.
5. **All Events:** Displays every single event that's occurred today.
Click on any of them to see more information about the event:
Granting entitlements [#granting-entitlements]
You can manually grant a user any entitlement your app offers. This is useful for activating pro features for someone, handling support issues, and more.
To grant an entitlement, **click** on the **+** icon:
Then, select an entitlement, expiration date, and optionally a reason for granting it:
**Click** on the **Grant Entitlement** button to save your changes.
To revoke any entitlement you've granted, **click** on the **Trash icon**:
If you are using a purchase controller, take care to follow our [implementation guide](/docs/sdk/guides/advanced-configuration). For example, manually granted entitlements register in our SDK as web entitlements. If you aren't accounting for those in your purchase controller code, manually granted entitlements will not work. See the example linked above under "Complete example for iOS" for guidance.
# Paywalls & Flows
The **Paywalls & Flows** section shows the paywalls and flows created for the selected app. Use this page to create new paywalls or flows, open existing ones, preview them, duplicate them, or archive them.
Campaign results are no longer viewed from this page. To review experiment performance, recent transactions, or paywall results, open the relevant [Campaign](/docs/dashboard/dashboard-campaigns/campaigns) instead.
Browse paywalls and flows [#browse-paywalls-and-flows]
Each card represents a paywall or flow in the selected app. The card shows its name, current status, last edited time, and a preview of the experience.
Use the status filters at the top of the page to narrow the grid:
| Filter | Description |
| -------- | ----------------------------------------------------------------------------------- |
| All | Shows every paywall and flow for the selected app. |
| Active | Shows paywalls and flows currently attached to active campaign traffic. |
| Inactive | Shows paywalls and flows that are not currently serving through an active campaign. |
| Archived | Shows paywalls and flows that have been archived. |
| Search | Finds paywalls and flows by name. |
Archived paywalls and flows can be restored at any point.
Manage an existing paywall or flow [#manage-an-existing-paywall-or-flow]
Below each card, you can:
* **Preview** the paywall or flow.
* **Duplicate** it to create a copy.
* **Archive** it when you no longer want it shown in the main grid.
Click the card itself to open it in the editor.
Create a paywall or flow [#create-a-paywall-or-flow]
Click **+ New** at the top-right of the page to open the creation menu:
The menu includes:
| Option | What it does |
| ------------------ | ------------------------------------------------------------------- |
| Use Template | Opens the template gallery so you can start from a prebuilt design. |
| New Paywall | Creates a blank paywall. |
| New Flow | Creates a new flow for multi-step experiences. |
| Editor Docs | Opens the editor documentation. |
| Request a Template | Lets you ask the Superwall team for a template. |
For a walkthrough of the paywall editor, start with [Creating Paywalls](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-overview#using-the-editor). For flows, start with [Getting Started with Flows](/docs/dashboard/dashboard-creating-flows/getting-started).
View results in campaigns [#view-results-in-campaigns]
Paywalls and flows are created here, but performance is reviewed in campaigns. Open the campaign that presents the paywall or flow, then use the campaign views to inspect audiences, experiments, transactions, and results.
For more, see [Campaigns](/docs/dashboard/dashboard-campaigns/campaigns), [Audiences](/docs/dashboard/dashboard-campaigns/campaigns-audience), and [Understanding Experiment Results](/docs/dashboard/dashboard-campaigns/campaigns-understanding-experiment-results).
# Adding Products
Add your existing products from their respective storefront, such as the App Store or the Google Play Store, to an app so they can be used in one or more paywalls. For adding Stripe products, please view [this doc](/docs/web-checkout/web-checkout-adding-a-stripe-product). For iOS products purchased through your own billing system, see [Custom Store Products](/docs/ios/guides/custom-store-products).
Before you attempt to test a paywall on iOS via TestFlight, make sure they are in the "Ready to Submit" phase if it's their initial launch. For local testing, you can use a [StoreKit configuration file](/docs/sdk/guides/testing-purchases) at any point.
Right now, Superwall for iOS does not support [Promotional
Offers](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/setting_up_promotional_offers),
only [Introductory
Offers](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_introductory_offers_in_your_app).
Superwall for Android only supports 1 billing phase per offer.
To get started, select an app. Then **click** the **Products** button from the sidebar. Choose **+ Add Product**:
From there, you have five fields to fill out:
| Field | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------- |
| Identifier | The StoreKit, Google Play, or custom billing product identifier for your product. |
| Trial | The trial duration attached to the product, if any. |
| Price | The price attached to the product. Either type one in, or just the dropdown to select common price points. |
| Period | The length of the subscription. |
| Entitlements | The entitlements this product belongs to. |
When you're done, click **Save**.
Note that the pricing information you enter here is **only** used in the Paywall Editor for App Store and Google Play products. On device, that information is pulled directly from the App Store or Google Play Store and will be localized. For iOS Custom Store Products, the SDK uses the product metadata from Superwall and routes purchase attempts to your `PurchaseController`.
Take care to make sure your product identifier is correct and matches its storefront. This is the
most common cause for products not working correctly when testing.
Entitlements [#entitlements]
Entitlements represent the amount of access or features users are entitled to. They can be used to offer different tiers of service, or just represent a single "active" subscription if your app only has one level of service (i.e. "Pro" unlocks everything). All products are granted a default entitlement.
**Subscription status is determined by entitlements.**
If a product has no entitlements then when a user purchases it their subscription status will be **Inactive**.
Ensure each subscription product is linked to at least one entitlement.
If you don't have multiple tiers of service, you can use only the default `pro` entitlement — you don't need to create any additional entitlements.
To add an entitlement, **click** on the **Entitlements** tab within the products page. Then click **Add Entitlement**:
From there, give it a name, and click **Create**:
At this point, you can go back to your products and attach one or more entitlements to each one.
Editing entitlements [#editing-entitlements]
To edit or delete an entitlement, click on the trailing pencil icon or the trash can icon to remove one. Note that if your entitlement is associated with any product, you'll need to remove it first before you can delete it.
Getting product identifiers [#getting-product-identifiers]
*If you use RevenueCat to handle in-app subscriptions, skip to [Using RevenueCat](/docs/dashboard/products#using-revenuecat)*
Using App Store Connect [#using-app-store-connect]
On **App Store Connect**, head over to **Your App ▸ App Store ▸ Subscriptions ▸ *Your Subscription Group***:
Then, copy your **Product ID**:
Your products, whether live or pre-release, shouldn't be in the "Missing Metadata" state. If they are, you won't be able to test them on device. To fix this, make sure your products are in the "Ready to Submit" or "Approved" state. Superwall will automatically warn you if they are in this state when you have the [App Store Connect API set up](/docs/dashboard/dashboard-settings/overview-settings-revenue-tracking#app-store-connect-api):
Using Google Play Console [#using-google-play-console]
To add subscription products, on **Google Play Console**, head over to **Your App ▸ Monetize ▸ Products ▸
Subscriptions**:
You can also grab your **base plan id** and any offer ids if you're going to use
them.
To add in-app products, no base plan id is required. For **period**, select **None(Lifetime/Consumable)**:
Google Play Offers [#google-play-offers]
Google play allows you to create multiple base plans and multiple offers for
each base plan. When using Superwall, you can either specify a specific offer
or let Superwall choose the best offer for the user.
**Automatically Choosing Offers**
Once Google has returned offers that are applicable for that user, Superwall
will use the following logic to choose the best offer for the user:
* Find the longest free trial the customer is eligible for
* If there is no free trial, find the cheapest introductory period the customer is eligible for
* If there is none, fall back to the base plan
* If you have an offer on one of your products that you never want to automatically be selected by this logic (for example, because it is a discount only used for a specific customer group), add the tag `sw-ignore-offer` to that offer in Google Play Console.
To add the `sw-ignore-offer` tag in **Google Play Console**:
1. Open the subscription offer you want to exclude and choose **Advanced settings**.
2. In **Tags**, add `sw-ignore-offer` (all lowercase) and click **Save**.
Superwall will ignore any offer that includes this tag when selecting the best offer for the user.
That means that if your eligibility criteria is set so that someone can use an
offer only once, we'll respect that and choose from the best remaining offers.
**Specifying Offers**
Let's say you have a base plan with two or more offers which differ in trial
duration. You may want to A/B test these offers to see which one performs best.
To achieve this, you can specify the offer id in the Superwall dashboard.
When we specify an offer id, we'll ignore the logic above and always use the
offer **if the user is eligible**. If the user is not eligible for the offer,
we'll fall back to the base plan. The [eligiblity criteria](https://support.google.com/googleplay/android-developer/answer/12154973?hl=en#:~\:text=or%20inactive%20states.-,Offer%20eligibility%C2%A0,-You%20can%20provide) is set in the Google
Play Console, and is based on the user's purchase history.
Using RevenueCat [#using-revenuecat]
For those who use RevenueCat, Superwall can automatically pre-populate your product identifiers to choose from when adding a product. In the **Add Product** modal, Superwall will display **any product attached to an offering** by following the steps below.
On RevenueCat, make sure your products are associated with an offering (it doesn't need to be the current offering):
Then, add your [RevenueCat Public API Key](https://docs.revenuecat.com/docs/authentication) inside of settings by clicking the **cog wheel icon** in the navigation bar from any page and selecting **Settings**. Paste your API key then click **Update Application**:
Using products in paywalls [#using-products-in-paywalls]
After you've added products to an app, you're ready to start using them in paywalls. Check out our [docs](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-products) for a step-by-step guide on how to do that.
Understanding how consumable and non-consumable products work [#understanding-how-consumable-and-non-consumable-products-work]
Superwall uses entitlements to determine ongoing access. Consumables and non-consumables are both one-time purchases, but they should be configured differently:
* **Consumable products** (e.g., credits, tokens, energy boosts) should usually have **Period** set to **None (Lifetime / Consumable)** and **no entitlements** selected. Your app grants the benefit after the purchase event.
* **Non-consumable products** (e.g., a lifetime unlock) should use **Period** set to **None (Lifetime / Consumable)** and should be linked to the entitlement they unlock.
If a product has no entitlements, purchasing it will not make the user's subscription status active. This is expected for consumables.
Finish setup in the SDK guide for your platform:
* [iOS consumable products](/docs/ios/guides/consumable-products)
* [Android consumable products](/docs/android/guides/consumable-products)
* [Flutter consumable products](/docs/flutter/guides/consumable-products)
* [Expo consumable products](/docs/expo/guides/consumable-products)
On iOS, consumables also require `SKIncludeConsumableInAppPurchaseHistory` in your `Info.plist` as a Boolean set to `YES`. On pre-iOS 18 devices, StoreKit 1 will be used when this key is present. See the [iOS consumables guide](/docs/ios/guides/consumable-products) for the exact setup.
Understanding paid offer types [#understanding-paid-offer-types]
Any **paid up front** or **pay as you go** product offer types will also be referenced using the `trial` variables. In Superwall, these are represented as "paid trials". For example, to reference the product's trial price of $3.99 in the image below, you'd use `products.selected.trialPeriodPrice`:
For more on setting customized text using Liquid Templating, visit this [doc](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-liquid).
A note on StoreKit configuration files [#a-note-on-storekit-configuration-files]
If you're using a StoreKit Configuration file, pricing information will come from there during local testing. Therefore, it's important to keep your StoreKit Configuration file, Superwall, and the App Store products all in sync. Follow our [Setting up StoreKit testing](/docs/sdk/guides/testing-purchases) guide for more information.
Having an issue on device with products not appearing? Run through [this
checklist](/docs/support/troubleshooting/products-not-loading) to make sure everything is configured
correctly.
# Subscription Management
Overview [#overview]
* **One source of truth:** Superwall ingests purchase lifecycle events from the App Store, Play Store, and Stripe-powered web checkout flows. The platform reconciles those events into user entitlements that power paywall targeting, analytics, and access gates.
* **Entitlements-first:** Products attach to entitlements that represent access tiers. Learn more about configuring them in [Adding Products → Entitlements](/docs/dashboard/products#entitlements).
* **Real-time syncing:** When an event (purchase, renewal, cancellation, refund) lands, Superwall updates the user profile and campaign eligibility automatically.
Dashboard [#dashboard]
Users Page [#users-page]
The Users page gives you a per-customer timeline that includes subscription events, paywall impressions, and entitlement snapshots. See [Users](/docs/dashboard/overview-users) for the full walkthrough.
* Confirm active entitlements and their expiration.
* Review recent renewals, cancellations, and billing issues.
* See paywall views, SDK events, and other analytics-style activity for that user.
Audience Filters & Campaign Targeting [#audience-filters--campaign-targeting]
Campaigns can check entitlements directly, letting you show different paywalls or post-purchase experiences to subscribers vs. trials. See [Campaign Audience Filters](/docs/dashboard/dashboard-campaigns/campaigns-audience) for details on the filter capabilities.
Web checkout [#web-checkout]
Web checkout purchases follow the same entitlement pipeline as native stores and surface throughout the dashboard:
* **Checkout and campaigns** – Configure Stripe credentials and connect campaigns that present web paywalls with [Configuring Stripe Keys and Settings](/docs/web-checkout/web-checkout-configuring-stripe-keys-and-settings) and [Creating Campaigns to Show Paywalls](/docs/web-checkout/web-checkout-creating-campaigns-to-show-paywalls).
* **Redemption** – After purchase, users receive a redemption email. Validate the flow using [Testing Purchases](/docs/web-checkout/web-checkout-testing-purchases) and share the manage URL pattern (`https://{your-domain}.superwall.app/manage`) for manual redemption.
* **Manage page** – Customers update billing, cancel, or request new redemption links from the manage portal documented in [Managing Memberships](/docs/web-checkout/web-checkout-managing-memberships).
* **Settings** – Brand the manage page and configure support contact info in **Settings → General → For Stripe apps**. See [General Settings](/docs/dashboard/dashboard-settings/overview-settings) for field descriptions.
Integrations [#integrations]
Superwall emits webhook events for every subscription lifecycle change. Connect these via the Integrations page to power downstream systems:
* **Webhooks** – Review payloads and event types in [Integrations](/docs/integrations). Common uses include syncing CRM subscription status, triggering feature flags, or updating internal billing systems.
* **Slack** – Route high-value events into a revenue channel by enabling the [Slack integration](/docs/integrations/slack).
* **Analytics tools** – Send proceeds and lifecycle events into [Mixpanel](/docs/integrations/mixpanel) or other analytics tooling to correlate subscription momentum with product usage.
SDK [#sdk]
Superwall's SDK tracks subscription status automatically based on your dashboard setup, so adding new products or entitlements does not require code changes. For platform-specific details, start with [Tracking Subscription State](/docs/sdk/quickstart/tracking-subscription-state).
# Surveys
To attach a survey to a paywall, edit one or manage existing surveys, **click** the **Survey** button found on the sidebar:
Once selected, you'll see an overview of all of the surveys you've created:
There are two types of surveys you can present:
* **Close Survey:** When a user declines to transact with a paywall or closes it.
* **Post-Purchase Survey:** When a user successfully transacts with a paywall.
No matter the type, each one is bound to present within the presentation percentages you set for it (more on that below).
Our surveys present using the native controls for the given platform (i.e. on iOS, a
`UIActionSheet`).
Creating a new survey or editing existing ones [#creating-a-new-survey-or-editing-existing-ones]
To create a new survey, click the **+ New Survey** button:
If you already have existing surveys, **click** the **+ Add Survey** button located at the top-right to make another one.
The survey editor will appear, and here you can edit all of the data for existing ones, or change the default options for a new one:
Superwall will provide sensible defaults for a new survey. If you're not quite sure what kind of
questions to ask, the default options are a great place to start and will yield insightful data.
All of the edits you make will be reflected in the preview on the right-hand side.
**Title**
The title of the survey, which will appear at the top.
**Message**
The message displays below the title, and you can use it to provide more context about the survey.
**Response options**
Each option you add here will be a response the user can choose. You'll see data about which one was selected once the survey is live. You can remove an option by using the trash icon on the right side of the text field. To add another option, **click** the **Add Option** button at the bottom of the existing options.
All survey options are shuffled for each user. This helps combat any ordering bias. However, the
"Other" button will always appear last.
**Using the "Other" button**
The "Other" button lets users type in a free text field. This is useful if users are willing to provide more context about why they declined (or purchased from) the paywall. If this is used, it will always display as the *last* option in the survey.
**Using the "Close" button**
You can also provide a "Close" button. Here, the user can exit the survey without providing a response. If you omit it, the user can only dismiss the survey by choosing a response.
**Toggling presentation percentages**
Use the percentage field to control how many users should receive the survey, from 0%-100%.
When you're done editing your survey, **click** the **Save** button at the top-right:
Attach a survey to a paywall [#attach-a-survey-to-a-paywall]
Creating a survey *does not* mean it will start appearing. Instead, you choose which paywalls should present the survey. To attach a survey to a paywall, **click** the **Connect Paywall +** button in the bottom right of the survey editor:
Then, in the modal that's presented, select the paywall you wish to attach it to:
After you've selected a paywall, **click** the **Connect** button and you're all set. From there, each user will only see the survey **once** per paywall.
You can also attach surveys from the paywall editor itself. This is also where you specify whether
you want a close or post-purchase survey. Read how in this [doc](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-surveys).
Managing surveys [#managing-surveys]
**Deleting surveys**
To delete a survey, **click** the **Survey** button on the sidebar. Then, for the survey you wish to delete, click the **trashcan** icon:
**Duplicating surveys**
To duplicate a survey, **click** the **Duplicate** button at the top-right when inside the survey editor:
Viewing survey stats and results [#viewing-survey-stats-and-results]
To see the results for any survey, click on one and then **click**
the **Stats** button at the top-right:
From there, you'll see the survey responses:
**Viewing "Other" responses**
If you've included the "Other" button in your survey, you can view the responses from users by **clicking** the **"View "Other" Responses"** button:
**Resetting survey stats**
Finally, if you wish to reset the survey results, **click** the **Reset Responses** button underneath the results:
Keep in mind that when you reset survey data, it also means that it will present to everyone once
again (within your presentation percentages).
Tip: showing a paywall based off of a survey response [#tip-showing-a-paywall-based-off-of-a-survey-response]
One particularly useful technique to use with surveys is to show a paywall with a discounted price if the user indicated the pricing was too expensive in their response. You can easily do this using a [standard placement](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements) — and we have a step-by-step guide on how to do exactly this right [here](/docs/dashboard/dashboard-campaigns/campaigns-standard-placements#using-the-survey-response-event).
# Templates
Click **Templates** from the **sidebar** to start your paywall design process with one of our templates:
We hand-select the paywalls these templates are based on, and each one is from a high-performing segment on the App Store or Google Play Store. We recommend picking one that matches your design goals the best, and then tweaking them to fit your branding and needs from there.
Each template can be easily changed to support as many products as you need.
Requesting a template [#requesting-a-template]
Superwall offers a complimentary white glove paywall design service for all users. Supply Superwall with either an existing paywall in production, a Figma link, Sketch file or anything else for Superwall's designers to use as a reference.
To get started, simply click **Request a Template** at the top, and fill out the form:
Turnaround is typically about 3-4 business days, but can also be up to a week depending on demand. Rest assured that Superwall's designers take the time to get these done right.
Sorting paywall templates [#sorting-paywall-templates]
To sort available templates by date (either newest to oldest, or vice-versa) or by recently updated — **click** the toggle in the top right and make a selection:
Superwall frequently updates existing templates, as well as adding new ones. Check back often!
# Changelog
Changelog [#changelog]
1.1.1 [#111]
Patch Changes [#patch-changes]
* b136375: Update Android version, resolve ANR when used without provider
1.1.0 [#110]
Minor Changes [#minor-changes]
* 0c3396d: Bump native SDKs and expose new APIs.
**iOS — SuperwallKit 4.14.1 → 4.15.1**
* New `paywallPageView` event for multi-page paywall navigation tracking (with `PageViewData` payload).
* `PaywallInfo.presentationId` is now bridged so events within a single presentation can be correlated.
* Custom store products are fully bridged: `Product` now carries `store` (`APP_STORE` | `STRIPE` | `PADDLE` | `PLAY_STORE` | `SUPERWALL` | `CUSTOM` | `OTHER`) plus per-store identifier objects (`appStoreProduct`, `stripeProduct`, `paddleProduct`, `customProduct`). `onPurchase` also receives `store` so JS can route `CUSTOM` products to its own purchase logic instead of StoreKit.
**Android — Superwall-Android 2.7.11 → 2.7.12**
* Bridges the new `customerInfo` field on `PaywallInfo` (subscriptions, non-subscriptions, entitlements, userId).
* Picks up new intro-offer eligibility logic for Stripe/Paddle products and bottom-sheet dismiss fix on newer Samsung devices.
1.0.11 [#1011]
Patch Changes [#patch-changes-1]
* 975de31: Add android 'consume' method
* 2204ee8: Bump Android version
1.0.10 [#1010]
Patch Changes [#patch-changes-2]
* 9d23138: Replace any in getPresentation result, improve chaining on delegate
1.0.9 [#109]
Patch Changes [#patch-changes-3]
* 836249d: Fix `useSuperwallEvents` so non-interactive Superwall callbacks are not dropped on first app launch before React listeners mount.
1.0.8 [#108]
Patch Changes [#patch-changes-4]
* d0f5d72: Bump Android SDK version
* 9c51c45: Bump iOS SDK to 4.14.1
1.0.7 [#107]
Patch Changes [#patch-changes-5]
* 6a8fea6: Fix compat event decoding for `paywallPreloadStart`.
1.0.6 [#106]
Patch Changes [#patch-changes-6]
* a343340: Prevent `configure()` from settling its Expo promise more than once during
native setup on iOS and Android. This avoids crashes such as
`PromiseAlreadySettledException` if the native SDK completion handler is
invoked more than once.
1.0.5 [#105]
Patch Changes [#patch-changes-7]
* 49ef4ff: Add `appstackId` integration attribute to `setIntegrationAttributes()` for Appstack integration support.
* 49ef4ff: Fix `setUserAttributes` silently failing when JavaScript attribute values are
`null` by making the bridge value types nullable on iOS and Android, and update
TypeScript signatures to explicitly allow nullable user attribute values.
* 7c53e77: Update Android, add appstack integration id
1.0.4 [#104]
Patch Changes [#patch-changes-8]
* Update Android & iOS SDK, add TestMode support, fix undefined in attributes
1.0.3 [#103]
Patch Changes [#patch-changes-9]
* Update Android & iOS SDKs, add Custom callbacks
* Update Android to 2.7.2, fixing experimental properties option
1.0.2 [#102]
Patch Changes [#patch-changes-10]
* Bump SuperwallKit iOS to 4.12.9
* Bump Superwall Android SDK to 2.6.8
* Add `introOfferToken` property to `StoreProduct`.
* Add missing SuperwallEvents: `paywallWebviewProcessTerminated`, `paywallProductsLoadMissingProducts`, `networkDecodingFail`, `customerInfoDidChange`, `integrationAttributes`, `reviewRequested`, `permissionRequested`, `permissionGranted`, `permissionDenied`, `paywallPreloadStart`, `paywallPreloadComplete`.
1.0.1 [#101]
Patch Changes [#patch-changes-11]
* a165d76: Bump superwall-android to 2.6.7
* a165d76: Bump SuperwallKit iOS to 4.12.3
* d96b449: Bridged android back button reroute handler
1.0.0 [#100]
Major Changes [#major-changes]
* 197c0c8: Add missing SDK configuration options from native iOS and Android SDKs:
* `shouldObservePurchases` (iOS & Android): Observe purchases made outside of Superwall
* `shouldBypassAppTransactionCheck` (iOS only): Disables app transaction check on SDK launch
* `maxConfigRetryCount` (iOS only): Number of retry attempts for config fetch (default: 6)
* `useMockReviews` (Android only): Enable mock review functionality
Also fixes `enableExperimentalDeviceVariables` not being passed to the Android native SDK.
**Breaking change**: Removed deprecated `collectAdServicesAttribution` option (AdServices attribution is now collected automatically by the native iOS SDK).
0.8.1 [#081]
Patch Changes [#patch-changes-12]
* ec326e8: bump ios to 4.10.6
0.8.0 [#080]
Minor Changes [#minor-changes-1]
* 0fcbf57: hotfix web2app redemption by disabling shouldShowWebPurchaseConfirmationAlert per default
Patch Changes [#patch-changes-13]
* 5768d51: expose shouldShowWebPurchaseConfirmationAlert option
0.7.2 [#072]
Patch Changes [#patch-changes-14]
* 5e2491a: improve event listens to always subscribe no matter what
0.7.1 [#071]
Patch Changes [#patch-changes-15]
* bab902d: fix compat android serialziaiton
0.7.0 [#070]
Minor Changes [#minor-changes-2]
* 183a7d2: feat: comprehensive error handling for SDK configuration failures
Added robust error handling to prevent apps from hanging indefinitely when SDK configuration fails (e.g., during offline scenarios). This introduces three new ways for developers to handle configuration errors:
**New Features:**
* Added `configurationError` state to store for programmatic error access
* Added `onConfigurationError` callback prop to `SuperwallProvider` for error tracking/analytics
* Added `SuperwallError` component for declarative error UI rendering
* Listen to native `configFail` events to capture configuration failures
* Improved `SuperwallLoading` and `SuperwallLoaded` to respect error states
**Breaking Changes:** None - all changes are backward compatible
**Fixes:**
* Fixed app hanging in loading state when offline or configuration fails
* Fixed unhandled promise rejections in deep link initialization
* Fixed loading state not resetting on configuration failure
Developers can now gracefully handle offline scenarios and provide better UX when SDK initialization fails.
Patch Changes [#patch-changes-16]
* 4e246c9: fix: resolve Android handleDeepLink promise consistently with iOS
Fixed Android crash on app launch caused by "Not a superwall link" error. The Android implementation now resolves the handleDeepLink promise with a boolean value (matching iOS behavior) instead of rejecting it for non-Superwall links. This prevents unhandled promise rejections that were causing production app crashes.
Additionally added error handling in TypeScript as a safety net for any future edge cases.
* 4e246c9: fix: filter our expo specific deeplinks
0.6.11 [#0611]
Patch Changes [#patch-changes-17]
* ed77ab7: fix: filter our expo specific deeplinks
0.6.10 [#0610]
Patch Changes [#patch-changes-18]
* 2e2fc96: fix: compat products when empty crashing
0.6.9 [#069]
Patch Changes [#patch-changes-19]
* 7f28aa0: bump kotlin to 2.6.4
* ec246be: Added integration attributes support for third-party platforms
0.6.8 [#068]
Patch Changes [#patch-changes-20]
* f70ebe4: Fix undefined being returned for PaywallResult
0.6.7 [#067]
Patch Changes [#patch-changes-21]
* 02a9d2d: add missing productIdentifier to RedmeptionPaywallInfo
0.6.6 [#066]
Patch Changes [#patch-changes-22]
* 9c5a9a5: bump ios to 4.10.1
0.6.5 [#065]
Patch Changes [#patch-changes-23]
* e4c6aec: add getEntitlements to useUser hook
0.6.4 [#064]
Patch Changes [#patch-changes-24]
* fedde41: fix: compat superwall options on android
0.6.3 [#063]
Patch Changes [#patch-changes-25]
* 0278ad4: bump ios to 4.10.0. This fixes missing localization for app2web restore flow
0.6.2 [#062]
Patch Changes [#patch-changes-26]
* 72519cd: remove unused expo plugin
0.6.1 [#061]
Patch Changes [#patch-changes-27]
* 7920773: fix(android): handle nullable properties in RedemptionResult JSON serialization
Fixed a Kotlin compilation error where nullable properties (`variantId`, `experimentId`, `productIdentifier`) were being assigned directly to a Map\. Now using the null-safe let operator to conditionally add these properties only when they have values.
0.6.0 [#060]
Minor Changes [#minor-changes-3]
* b816292: # Custom Purchase Controller API Improvement
Changed `CustomPurchaseControllerContext` return types from `Promise` to `Promise` for cleaner success handling.
Now you can simply not return anything for success instead of `return undefined`:
```tsx
import Purchases, { PURCHASES_ERROR_CODE } from "react-native-purchases";
{
try {
const products = await Purchases.getProducts([params.productId]);
const product = products[0];
if (!product) {
return { type: "failed", error: "Product not found" };
}
await Purchases.purchaseStoreProduct(product);
// Success - no return needed ✨
} catch (error: any) {
if (error.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
return { type: "cancelled" };
}
return { type: "failed", error: error.message };
}
},
onPurchaseRestore: async () => {
try {
await Purchases.restorePurchases();
// Success - no return needed ✨
} catch (error: any) {
return { type: "failed", error: error.message };
}
},
}}
>
{/* Your app */}
;
```
Patch Changes [#patch-changes-28]
* acb9956: feature: add StoreProduct Type to exports
0.5.1 [#051]
Patch Changes [#patch-changes-29]
* 889aaf7: fix: improve custom purchase type handling
* 56a72c9: Bump Android version to 2.6.3
* 465a215: Exposes Product identifier in Redemption Info
0.5.0 [#050]
Minor Changes [#minor-changes-4]
* 8c2c14f: User identification and attribute operations are now non-blocking async calls, preventing UI freezes while ensuring proper state synchronization
Thanks to @gursheyss for the PR #90
0.4.1 [#041]
Patch Changes [#patch-changes-30]
* 86a3b28: Update Android version to 2.6.1 adding app2web support
* 6df6cc4: Adds paddle store identifiers
0.4.0 [#040]
Minor Changes [#minor-changes-5]
* 6d3e625: bump ios to fix critical webview bug
0.3.2 [#032]
Patch Changes [#patch-changes-31]
* 5555e8e: make error handling more defensive
0.3.1 [#031]
Patch Changes [#patch-changes-32]
* 4a3f540: fix: compat typeissues
0.3.0 [#030]
Minor Changes [#minor-changes-6]
* 9ed73eb: feat: improve error handling of Custom Purchase Controller
0.2.9 [#029]
Patch Changes [#patch-changes-33]
* bd460a7: Expose signature in android StoreTransaction
* e9eeff8: Expose appAcounttoken and purchaseToken on Android StoreTransaction
0.2.8 [#028]
Patch Changes [#patch-changes-34]
* e0b57bc: fix(compat): none nullable access
* 314be3c: bump deps
0.2.7 [#027]
Patch Changes [#patch-changes-35]
* adccfe4: Update Android SDK to 2.5.4 and iOS to 4.8.2
0.2.6 [#026]
Patch Changes [#patch-changes-36]
* 10bb039: force release?
0.2.5 [#025]
Patch Changes [#patch-changes-37]
* 95636a6: Bump internal android sdk to 2.5.1
0.2.4 [#024]
Patch Changes [#patch-changes-38]
* c274e6a: Add typed SuperwallOptions and fix mispelled option name
0.2.3 [#023]
Patch Changes [#patch-changes-39]
* f9372f1: Exposes StoreTransaction in /compat
0.2.2 [#022]
Patch Changes [#patch-changes-40]
* 3f832c7: Updates Android SDK to 2.3.2
0.2.1 [#021]
Patch Changes [#patch-changes-41]
* 4327c59: expose internal types
0.2.0 [#020]
Minor Changes [#minor-changes-7]
* 3b58ea4: Updates Android SDK to 2.3.1 (with Google Play Billing library 7)
0.1.3 [#013]
Patch Changes [#patch-changes-42]
* d273d2a: bump expo module
0.1.2 [#012]
Patch Changes [#patch-changes-43]
* f243226: fix: type issues
0.1.1 [#011]
Patch Changes [#patch-changes-44]
* 707e513: temp fix swift types
0.1.0 [#010]
Minor Changes [#minor-changes-8]
* b39e98e: feat: Remove the export of the internal SuperwallExpoModule Class,
this class should have not been used since it's an internal class and could break the state of the internal SuperwallStore.
If you have used in prior for a usecase that the current SDK doesn't support, please open an issue.
Patch Changes [#patch-changes-45]
* 32112a6: feat: handle deeplink automatically, no need for manual handling
0.0.18 [#0018]
Patch Changes [#patch-changes-46]
* 3a93b2b: feat: fix inital loading state
0.0.17 [#0017]
Patch Changes [#patch-changes-47]
* db980b6: fix missing types on native
0.0.16 [#0016]
Patch Changes [#patch-changes-48]
* e19e626: require Expo 53+
0.0.15 [#0015]
Patch Changes [#patch-changes-49]
* 020c22a: fix: old exports
0.0.14 [#0014]
Patch Changes [#patch-changes-50]
* 6153163: mark things as internal
* 6fbaa94: add types to TransactionProductIdentifier
0.0.13 [#0013]
Patch Changes [#patch-changes-51]
* 2ead245: feat: add getDeviceAttributes
* efbd9d5: feat: add getDeviceAttributes to ios
0.0.12 [#0012]
Patch Changes [#patch-changes-52]
* 4751b75: Fixes issues with identify on Android, updates Android SDK to 2.2.3
0.0.11 [#0011]
Patch Changes [#patch-changes-53]
* 9c053b3: feat: add experimentalDeviceVariables for ios
0.0.10 [#0010]
Patch Changes [#patch-changes-54]
* d5beb70: fix(compat): subscription event emitter not firing
0.0.9 [#009]
Patch Changes [#patch-changes-55]
* 67edd16: feat: export internal SuperwallExpoModule for advance usage
0.0.8 [#008]
Patch Changes [#patch-changes-56]
* 0175478: feat: set subscription status to UNKNOWN on startup
* d8390ab: feat: bump ios SDK version
0.0.7 [#007]
Patch Changes [#patch-changes-57]
* f5a1d9a: fix: signout state changes
* 4df7557: fix: ios getSubscriptionStatus
0.0.6 [#006]
Patch Changes [#patch-changes-58]
* fc22062: fix: android getSubscriptionStatus returning undefined
0.0.5 [#005]
Patch Changes [#patch-changes-59]
* 8f4d758: fix compat subscriptionStatus access failing
* 9d98a30: fix: android sdk version not being passed correctly
0.0.4 [#004]
Patch Changes [#patch-changes-60]
* eb98aeb: feat: add ability to use CustomPurchaseController
Just wrap your app with CustomPurchaseControllerProvider and pass your own handler functions to it.
It will await the result of these handler functions to continue the purchase/restore flow.
```tsx
{
// Set stuff in ur system here
if (params.platform === "ios") {
console.log("onPurchase", params);
} else {
console.log("onPurchase", params.productId);
}
return;
},
onPurchaseRestore: async () => {
console.log("onPurchaseRestore");
// Set stuff in ur system here
return;
},
}}
>
```
0.0.3 [#003]
Patch Changes [#patch-changes-61]
* 72d9879: fix: adding ability to let superwall manage subscriptions
0.0.2 [#002]
Patch Changes [#patch-changes-62]
* 8914f05: Initialize new experimental Hook based SDK.
0.0.1 [#001]
Patch Changes [#patch-changes-63]
* 0cd5243: Inital Release
* 0cd5243: Change Delegate class to normal class from abstract
Unpublished [#unpublished]
🛠 Breaking changes [#-breaking-changes]
🎉 New features [#-new-features]
🐛 Bug fixes [#-bug-fixes]
💡 Others [#-others]
# Cohorting in 3rd Party Tools
:::android
```kotlin Kotlin
override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
when(eventInfo.event) {
is SuperwallEvent.TriggerFire -> {
MyAnalyticsService.shared.setUserAttributes(
mapOf(
"sw_experiment_${eventInfo.params.get("experiment_id").toString()}" to true,
"sw_variant_${eventInfo.params.get("variant_id").toString()}" to true
)
)
}
else -> {}
}
}
```
:::
:::flutter
```dart Flutter
@override
void handleSuperwallEvent(SuperwallEventInfo eventInfo) async {
final experimentId = eventInfo.params?['experiment_id'];
final variantId = eventInfo.params?['variant_id'];
switch (eventInfo.event.type) {
case EventType.triggerFire:
MyAnalyticsService.shared.setUserAttributes({
"sw_experiment_$experimentId": true,
"sw_variant_$variantId": true
});
break;
default:
break;
}
}
```
:::
:::expo
```typescript React Native
handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
const experimentId = eventInfo.params?['experiment_id']
const variantId = eventInfo.params?['variant_id']
if (!experimentId || !variantId) {
return
}
switch (eventInfo.event.type) {
case EventType.triggerFire:
MyAnalyticsService.shared.setUserAttributes({
`sw_experiment_${experimentId}`: true,
`sw_variant_${variantId}`: true
});
break;
default:
break;
}
}
```
:::
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:
:::expo
```typescript
handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
console.log(`handleSuperwallEvent: ${eventInfo}`);
switch (eventInfo.event.type) {
case EventType.appOpen:
console.log("appOpen event");
break;
case EventType.deviceAttributes:
console.log(`deviceAttributes event: ${eventInfo.event.deviceAttributes}`);
break;
case EventType.paywallOpen:
const paywallInfo = eventInfo.event.paywallInfo;
console.log(`paywallOpen event: ${paywallInfo}`);
if (paywallInfo !== null) {
paywallInfo.identifier().then((identifier: string) => {
console.log(`paywallInfo.identifier: ${identifier}`);
});
paywallInfo.productIds().then((productIds: string[]) => {
console.log(`paywallInfo.productIds: ${productIds}`);
});
}
break;
default:
break;
}
}
```
:::
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`:
:::expo
```typescript
handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
console.log(`handleSuperwallEvent: ${eventInfo}`);
switch (eventInfo.event.type) {
case EventType.appOpen:
console.log("appOpen event");
break;
case EventType.deviceAttributes:
console.log(`deviceAttributes event: ${eventInfo.event.deviceAttributes}`);
break;
case EventType.paywallOpen:
const paywallInfo = eventInfo.event.paywallInfo;
console.log(`paywallOpen event: ${paywallInfo}`);
if (paywallInfo !== null) {
paywallInfo.identifier().then((identifier: string) => {
console.log(`paywallInfo.identifier: ${identifier}`);
});
paywallInfo.productIds().then((productIds: string[]) => {
console.log(`paywallInfo.productIds: ${productIds}`);
});
}
break;
default:
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.
:::expo
```typescript React Native
export class MyPurchaseController extends PurchaseController {
// 1
async purchaseFromAppStore(productId: string): Promise {
// TODO
// ----
// Purchase via StoreKit, RevenueCat, Qonversion or however
// you like and return a valid PurchaseResult
}
async purchaseFromGooglePlay(
productId: string,
basePlanId?: string,
offerId?: string
): Promise {
// TODO
// ----
// Purchase via Google Billing, RevenueCat, Qonversion or however
// you like and return a valid PurchaseResult
}
// 2
async restorePurchases(): Promise {
// TODO
// ----
// Restore purchases and return true if successful.
}
}
```
:::
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:
:::expo
```typescript React Native
export default function App() {
React.useEffect(() => {
const apiKey = Platform.OS === "ios" ? "MY_IOS_API_KEY" : "MY_ANDROID_API_KEY"
const purchaseController = new MyPurchaseController()
Superwall.configure({
apiKey: apiKey,
purchaseController: purchaseController,
})
}, [])
}
```
:::
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:
:::expo
```typescript React Native
// When a subscription is purchased, restored, validated, expired, etc...
myService.addSubscriptionStatusListener((subscriptionInfo: SubscriptionInfo) => {
const entitlements = Object.keys(subscriptionInfo.entitlements.active).map((id) => ({
id,
}))
if (entitlements.length === 0) {
Superwall.shared.setSubscriptionStatus(SubscriptionStatus.Inactive())
} else {
Superwall.shared.setSubscriptionStatus(
SubscriptionStatus.Active(entitlements.map((id) => new Entitlement(id)))
)
}
})
```
:::
`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:
:::expo
```typescript React Native
Superwall.shared.subscriptionStatusEmitter.addListener("change", (status) => {
switch (status.status) {
case "ACTIVE":
break
default:
break
}
})
```
:::
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
**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 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`:
:::expo
```typescript
handleCustomPaywallAction(name: string) {
if (name == "help_center") {
HelpCenterManager.present();
}
}
```
:::
Remember to set `Superwall.shared.delegate`! For implementation details, see the [Superwall Delegate](/docs/sdk/guides/using-superwall-delegate) guide.
# Game Controller Support
:::android
First set the `SuperwallOption` `isGameControllerEnabled` to `true`:
```kotlin
Superwall.instance.options.isGameControllerEnabled = true
```
Then Superwall will automatically listen for gamepad events and forward them to your paywall!
:::
:::flutter
First set the `SuperwallOption` `isGameControllerEnabled` to `true`:
```dart
Superwall.instance.options.isGameControllerEnabled = true
```
Then Superwall will automatically listen for gamepad events and forward them to your paywall!
:::
:::expo
First set the `SuperwallOption` `isGameControllerEnabled` to `true`:
```typescript
Superwall.instance.options.isGameControllerEnabled = true
```
Then Superwall will automatically listen for gamepad events and forward them to your paywall!
:::
# 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:
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.
# 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
}
});
```
:::expo
```tsx React Native
import { usePlacement } from "expo-superwall";
import { Button } from "react-native";
function PaywallButton() {
const { registerPlacement } = usePlacement({
onPresent: (paywallInfo) => {
console.log(`Handler (onPresent): ${paywallInfo.name}`);
},
onDismiss: (paywallInfo, paywallResult) => {
console.log(`Handler (onDismiss): ${paywallInfo.name}`);
// Check the result to see if user purchased
console.log(`Result:`, paywallResult);
},
onError: (error) => {
console.log(`Handler (onError): ${error}`);
},
onSkip: (skipReason) => {
console.log(`Handler (onSkip):`, skipReason);
},
});
const handlePress = async () => {
await registerPlacement({
placement: 'campaign_trigger',
feature: () => {
// Feature launched
console.log("Feature unlocked!");
},
});
};
return ;
}
```
:::
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()
}
}
}
}
}
```
# Configuring
Expo-specific options [#expo-specific-options]
Expo apps inherit the native Superwall option surface. The following fields were added in release 1.0.0 and later and live directly on the `options` object that you pass to ``.
```tsx
import { SuperwallProvider } from "expo-superwall";
export function App() {
return (
{/* your app */}
);
}
```
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:
:::expo
```typescript
const options = new SuperwallOptions()
options.logging.level = LogLevel.Warn
options.logging.scopes = [LogScope.PaywallPresentation, LogScope.PaywallTransactions]
Superwall.configure(
"MY_API_KEY",
null,
options: options
);
// Or you can set:
await Superwall.shared.setLogLevel(LogLevel.Warn)
```
:::
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`:
:::expo
```typescript
const options = new SuperwallOptions()
options.paywalls.shouldPreload = false
Superwall.configure(
"MY_API_KEY",
null,
options: options
);
// Or you can set:
Superwall.instance.logLevel = LogLevel.Warn
```
:::
Then, if you'd like to preload paywalls for specific placements you can use `preloadPaywalls(forPlacements:)`:
:::expo
```typescript
var placements = {"campaign_trigger"};
Superwall.shared.preloadPaywalls(placements);
```
:::
If you'd like to preload all paywalls you can use `preloadAllPaywalls()`:
:::expo
```typescript
// Coming soon
```
:::
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`:
:::expo
```typescript
const options = SuperwallOptions()
options.isExternalDataCollectionEnabled = false
Superwall.configure(
"MY_API_KEY",
options: options
);
```
:::
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`:
:::expo
```typescript
const options = SuperwallOptions()
options.paywalls.automaticallyDismiss = false
Superwall.configure(
"MY_API_KEY",
null,
options: options
);
```
:::
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:
:::expo
```typescript
const options = SuperwallOptions()
options.paywalls.restoreFailed.title = "My Title"
options.paywalls.restoreFailed.message = "My message";
options.paywalls.restoreFailed.closeButtonTitle = "Close";
Superwall.configure(
"MY_API_KEY",
null,
options: options
);
```
:::
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:
:::expo
```typescript
const options = SuperwallOptions()
options.paywalls.isHapticFeedbackEnabled = false;
Superwall.configure(
"MY_API_KEY",
null,
options: options
);
```
:::
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`:
:::expo
```typescript
const options = SuperwallOptions()
options.paywalls.transactionBackgroundView = TransactionBackgroundView.none
Superwall.configure(
"MY_API_KEY",
null,
options: options
);
```
:::
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`:
:::expo
```typescript
const options = SuperwallOptions()
options.paywalls.shouldShowPurchaseFailureAlert = false;
Superwall.configure(
"MY_API_KEY",
null,
options: options
);
```
:::
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:
:::expo
```typescript
const options = new SuperwallOptions()
options.paywalls.shouldShowWebPurchaseConfirmationAlert = true
Superwall.configure(
"MY_API_KEY",
null,
options: options
);
```
:::
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:
:::expo
```typescript
const options = SuperwallOptions()
options.localeIdentifier = "en_GB";
Superwall.configure(
"MY_API_KEY",
null,
options: options
);
```
:::
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 covers the Superwall-only flow where purchases are started from paywalls and you are not using a custom purchase controller.
The platform sections below explain the iOS and Android store requirements that still apply to Expo apps.
iOS [#ios]
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);
}
}
```
Android [#android]
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 then consume the Google Play purchase token so the item can be purchased again.
Dashboard Setup [#dashboard-setup-1]
1. Create the product as an in-app product in Google Play Console.
2. Add the product in Superwall from **Products**.
3. Use the Google Play product ID.
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.
Consume The Purchase Token [#consume-the-purchase-token]
Google Play in-app products must be consumed after you grant the benefit. If you do not consume the purchase token, the user may not be able to buy that same consumable again.
Use the `purchaseToken` from the `TransactionComplete` event, grant the benefit, then call `Superwall.instance.consume(purchaseToken)`.
`Superwall.instance.consume(purchaseToken)` is available in Android SDK 2.6.2 and later.
```kotlin Kotlin
import androidx.lifecycle.lifecycleScope
import com.superwall.sdk.Superwall
import com.superwall.sdk.analytics.superwall.SuperwallEvent
import com.superwall.sdk.analytics.superwall.SuperwallEventInfo
import com.superwall.sdk.delegate.SuperwallDelegate
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity(), SuperwallDelegate {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Superwall.instance.delegate = this
}
override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
val event = eventInfo.event
if (event !is SuperwallEvent.TransactionComplete) {
return
}
if (event.product.productIdentifier != "coins_100") {
return
}
val purchaseToken = event.transaction?.purchaseToken ?: return
lifecycleScope.launch {
ConsumablesService.grantCoins(
count = 100,
productId = event.product.productIdentifier,
purchaseToken = purchaseToken,
)
Superwall.instance.consume(purchaseToken)
.onFailure { error ->
// Retry consumption after confirming the benefit was granted.
println("Failed to consume purchase: ${error.message}")
}
}
}
}
```
```java Java
import com.superwall.sdk.Superwall;
import com.superwall.sdk.analytics.superwall.SuperwallEvent;
import com.superwall.sdk.analytics.superwall.SuperwallEventInfo;
import com.superwall.sdk.delegate.SuperwallDelegateJava;
import com.superwall.sdk.store.abstractions.transactions.StoreTransactionType;
import kotlin.Unit;
public class MainActivity extends AppCompatActivity implements SuperwallDelegateJava {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Superwall.getInstance().setJavaDelegate(this);
}
@Override
public void handleSuperwallEvent(SuperwallEventInfo eventInfo) {
if (!(eventInfo.getEvent() instanceof SuperwallEvent.TransactionComplete)) {
return;
}
SuperwallEvent.TransactionComplete event =
(SuperwallEvent.TransactionComplete) eventInfo.getEvent();
if (!event.getProduct().getProductIdentifier().equals("coins_100")) {
return;
}
StoreTransactionType transaction = event.getTransaction();
if (transaction == null) {
return;
}
String purchaseToken = transaction.getPurchaseToken();
ConsumablesService.grantCoins(100, event.getProduct().getProductIdentifier(), purchaseToken);
Superwall.getInstance().consume(purchaseToken, result -> {
// Check the Result in your app and retry if consumption fails.
return Unit.INSTANCE;
});
}
}
```
Grant the benefit before consuming the token. If your grant call fails, leave the purchase unconsumed and retry after your app confirms the user received the benefit.
Read Purchase History [#read-purchase-history-1]
Consumable and non-consumable purchases appear in `customerInfo.nonSubscriptions`. Use `isConsumable` to distinguish consumables from lifetime purchases.
```kotlin Kotlin
val customerInfo = Superwall.instance.getCustomerInfo()
val consumables = customerInfo.nonSubscriptions.filter { it.isConsumable }
for (purchase in consumables) {
println("Consumable purchased: ${purchase.productId}")
}
```
```java Java
CustomerInfo customerInfo = Superwall.getInstance().getCustomerInfo();
for (NonSubscriptionTransaction purchase : customerInfo.getNonSubscriptions()) {
if (purchase.isConsumable()) {
System.out.println("Consumable purchased: " + purchase.getProductId());
}
}
```
Expo Event Handling [#expo-event-handling]
In Expo, listen for purchase events with [`useSuperwallEvents`](/docs/expo/sdk-reference/hooks/useSuperwallEvents). On Android, the `transactionComplete` event can include `transaction.purchaseToken`; after you grant the benefit, pass that token to [`Superwall.consume(purchaseToken)`](/docs/expo/sdk-reference/hooks/consume).
```tsx
import { Platform } from "react-native"
import Superwall from "expo-superwall/compat"
import { useSuperwallEvents } from "expo-superwall"
export function ConsumableEvents() {
useSuperwallEvents({
onSuperwallEvent: async ({ event }) => {
if (event.event !== "transactionComplete" || Platform.OS !== "android") {
return
}
if (event.product.productIdentifier !== "coins_100") {
return
}
const purchaseToken = event.transaction?.purchaseToken
if (!purchaseToken) {
return
}
await grantCoins({
count: 100,
productId: event.product.productIdentifier,
purchaseToken,
})
await Superwall.consume(purchaseToken)
},
})
return null
}
```
# Debugging
Cannot find native module 'SuperwallExpo' [#cannot-find-native-module-superwallexpo]
This error occurs when the native Superwall module isn't properly linked in your app. There are several common causes.
Cause 1: Using Expo Go [#cause-1-using-expo-go]
**Expo Go does not support custom native modules.** Superwall requires native code that isn't included in Expo Go.
**Solution:** Use an [Expo Development Build](https://docs.expo.dev/develop/development-builds/introduction/) instead.
```bash
# For iOS
npx expo run:ios
# For Android
npx expo run:android
```
You can tell you're running Expo Go if the app icon is the Expo logo. Your development build will show your own app icon.
Cause 2: Outdated Native Folders [#cause-2-outdated-native-folders]
If you added `expo-superwall` to an existing project, your `ios` and `android` folders may be outdated and missing the native module configuration.
**Solution:** Regenerate your native folders:
```bash
# Clean and regenerate native folders
npx expo prebuild --clean
```
This will delete your existing `ios` and `android` folders and regenerate them with the correct native module configuration.
If you've made manual changes to your native folders, back them up first. The `--clean` flag will remove all custom native code.
Cause 3: EAS Build Not Updated [#cause-3-eas-build-not-updated]
If you're using EAS Build, you need to create a new development build after adding `expo-superwall`.
**Solution:** Build a new development client:
```bash
eas build --profile development --platform ios
# or
eas build --profile development --platform android
```
Cause 4: Stale Caches [#cause-4-stale-caches]
Cached build artifacts can cause issues after updating dependencies.
**Solution:** Clear all caches and rebuild:
```bash
# Clear watchman (if installed)
watchman watch-del-all
# Clear Expo cache
npx expo start --clear
# Remove and reinstall dependencies
rm -rf node_modules
npm install # or yarn/pnpm/bun
# For iOS: reinstall pods
cd ios && pod install --repo-update && cd ..
# Rebuild
npx expo run:ios
```
Cause 5: Version Incompatibility [#cause-5-version-incompatibility]
Your Expo SDK version may not be compatible with `expo-superwall`.
**Solution:** Run the Expo doctor to check for issues:
```bash
npx expo-doctor
npx expo install --check
```
Superwall requires **Expo SDK 53 or higher**. If you're on an older version, upgrade your Expo SDK first.
Still Having Issues? [#still-having-issues]
If none of the above solutions work:
1. **Completely clean rebuild:**
```bash
rm -rf node_modules ios android .expo
npm install
npx expo prebuild --clean
npx expo run:ios
```
2. **Verify the package is installed:**
```bash
npm ls expo-superwall
```
3. **Check for conflicting packages** that might interfere with native module resolution
# 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`.
:::expo
Platform Availability [#platform-availability-1]
These variables are currently only available on **iOS**, support for Android is not yet available.
:::
# 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:
:::expo
```typescript
function handleUrl(url: string) {
const path = new URL(url).pathname;
let placement: string | undefined;
switch (path) {
case "/promo":
placement = "promoPlacement";
break;
case "/onboarding":
placement = "onboardingPlacement";
break;
case "/upgrade":
placement = "upgradePlacement";
break;
case "/special-offer":
placement = "specialOfferPlacement";
break;
}
if (placement) {
superwall.register(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.
:::expo
```typescript
function handleUrl(url: string) {
SuperwallExpoModule.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:
:::expo
* [Deep link setup](/docs/sdk/quickstart/in-app-paywall-previews)
:::
Related deep link guides [#related-deep-link-guides]
:::expo
* [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.
:::
# Managing Users
Overview [#overview]
The [`useUser`](/docs/expo/sdk-reference/hooks/useUser) hook provides functions to identify users, sign them out, update their attributes, and access user and subscription status information.
Example [#example]
```tsx
import { useUser } from "expo-superwall";
import { Button, Text, View } from "react-native";
function UserManagementScreen() {
const { identify, user, signOut, update, subscriptionStatus } = useUser();
const handleLogin = async () => {
// Identify the user with a unique ID
await identify(`user_${Date.now()}`);
};
const handleSignOut = async () => {
await signOut();
};
const handleUpdateUserAttributes = async () => {
// Update custom user attributes
await update((oldAttributes) => ({
...oldAttributes,
customProperty: "new_value",
counter: (oldAttributes.counter || 0) + 1,
}));
};
return (
Subscription Status: {subscriptionStatus?.status ?? "unknown"}
{user && User ID: {user.appUserId}}
{user && User Attributes: {JSON.stringify(user, null, 2)}}
);
}
```
# Migrating from React Native SDK
This guide is for React Native projects that already have Superwall's legacy React Native SDK installed and want to migrate to the new Expo SDK.
**This doesn't sound like you?**
* **Expo project** → Use the standard [installation guide](/docs/expo/quickstart/install)
* **React Native app, new to Superwall** → See our [installation guide for bare React Native apps](/docs/expo/guides/using-expo-sdk-in-bare-react-native)
**Important: Expo SDK 53+ Required**
This SDK is exclusively compatible with Expo SDK version 53 and newer. For projects using older Expo versions, please use our [legacy React Native SDK](https://github.com/superwall/react-native-superwall).
This guide is to help you migrate your project using the legacy `react-native-superwall` SDK to the new Expo SDK. The `expo-superwall/compat` SDK is intended to make it easy for existing projects to migrate over.
Installation [#installation]
First, you need to install `expo-superwall` using your package manager of choice.
```bash bun
bunx expo install expo-superwall
```
```bash pnpm
pnpm dlx expo install expo-superwall
```
```bash npm
npx expo install expo-superwall
```
```bash yarn
yarn dlx expo install expo-superwall
```
Basic Setup [#basic-setup]
Configure the SDK with your **Public API Key**. You'll retrieve this from the Superwall settings page, same as your existing React Native project.
```tsx
import Superwall from "expo-superwall/compat"
import { useEffect, useState } from "react"
import { Platform } from "react-native"
// Initialize Superwall
useEffect(()=> {
const apiKey = Platform.OS === "ios"
? "yourSuperwall_iOSKey"
: "yourSuperwall_androidKey";
Superwall.configure({ // `await` is optional here if not chaining or needing immediate confirmation
apiKey,
});
})
```
Identify User [#identify-user]
```tsx
// Identify a user
await Superwall.shared.identify({ userId })
// Set User Attributes
await Superwall.shared.setUserAttributes({
someCustomVal: "abc",
platform: Platform.OS,
timestamp: new Date().toISOString(),
})
```
Present a Paywall [#present-a-paywall]
```tsx
// Present a paywall
Superwall.shared.register({
placement: "yourPlacementName",
feature() {
console.log(`Feature called!`)
},
})
```
Listen to Events [#listen-to-events]
```tsx
// 1. Define your Superwall Delegate
import {
EventType,
type PaywallInfo,
type RedemptionResult,
type SubscriptionStatus,
SuperwallDelegate,
type SuperwallEventInfo,
} from "expo-superwall/compat"
export class MyDelegate extends SuperwallDelegate {
handleSuperwallEvent(eventInfo) {
switch (eventInfo.event.type) {
case EventType.paywallOpen:
console.log("Paywall opened");
break;
case EventType.paywallClose:
console.log("Paywall closed");
break;
}
}
}
// 2. Simply set the delegate
const delegate = new MyDelegate()
await Superwall.shared.setDelegate(delegate)
```
# Migrating from v1 to v2 - React Native
**Legacy SDK Migration Guide**
This guide is for migrating between v1 and v2 of the **legacy** `react-native-superwall` SDK. If you're using the modern `expo-superwall` SDK, you don't need this guide. See the [migration guide for moving from react-native-superwall to expo-superwall](/docs/expo/guides/migrating-react-native) instead.
Migration steps [#migration-steps]
1\. Update code references [#1-update-code-references]
1.1 Update the `configure`, `register`, and `identify` functions [#11-update-the-configure-register-and-identify-functions]
These functions now use an object with named parameters as their argument.
Before:
```typescript (Before)
Superwall.configure(apiKey, options, purchaseController, completion)
Superwall.shared.register(event, params, handler, feature)
Superwall.shared.identify(userId, options)
```
Now:
```typescript (Now)
Superwall.configure({
apiKey,
options,
purchaseController,
completion,
})
Superwall.shared.register({
placement,
params,
handler,
feature,
})
Superwall.shared.identify({
userId,
options
})
```
1.2 Rename references from `event` to `placement` [#12-rename-references-from-event-to-placement]
In some cases, you should be able to update references using the automatic renaming suggestions in your editor. For other cases where this hasn't been possible, you'll need to run through this list to manually update your code.
| Before | After |
| ------------------------------------------ | --------------------------------------------- |
| async register(event) | async register(placement) |
| async preloadPaywalls(eventNames) | async preloadPaywalls(placementNames) |
| async getPresentationResult(event, params) | async getPresentationResult(placement,params) |
| TriggerResult.eventNotFound | TriggerResult.placementNotFound |
| TriggerResult.noRuleMatch | TriggerResult.noAudienceMatch |
2\. Getting the purchased product [#2-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.
3\. Entitlements [#3-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.setSubscriptionStatus(SubscriptionStatus.ACTIVE) | Superwall.shared.setSubscriptionStatus(SubscriptionStatus.Active(entitlements)) |
Here is an example of how you'd sync your subscription status with Superwall if you were using RevenueCat for example:
```typescript RevenueCat
syncSubscriptionStatus() {
Purchases.addCustomerInfoUpdateListener((customerInfo) => {
const entitlements = Object.keys(customerInfo.entitlements.active).map((id) => ({
id,
}))
Superwall.shared.setSubscriptionStatus(
entitlements.length === 0
? SubscriptionStatus.Inactive()
: SubscriptionStatus.Active(entitlements)
)
})
}
```
You can listen to the emitter property `Superwall.shared.subscriptionStatusEmitter` to be notified when the subscriptionStatus changes by passing in a `change` listener. Or you can use the `SuperwallDelegate`
method `subscriptionStatusDidChange(from:to:)`, which replaces `subscriptionStatusDidChange(to:)`.
4\. Paywall Presentation Condition [#4-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 v2 of the SDK, this is replaced with a check on the entitlements within the audience filter. As you migrate your users from v1 to v2 of the
SDK, you'll need to make sure you set both the entitlements check and the paywall presentation condition in the paywall editor.
5\. Check out the full change log [#5-check-out-the-full-change-log]
You can view this on [our GitHub page](https://github.com/superwall/react-native-superwall/blob/master/CHANGELOG.md).
6\. Check out our updated example apps [#6-check-out-our-updated-example-apps]
All of our example apps ([standard React Native](https://github.com/superwall/react-native-superwall/tree/main/example) and [Expo](https://github.com/superwall/react-native-superwall/tree/main/expo-example)) 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 RevenueCat.
# Setting a Locale
Overview [#overview]
The Expo SDK automatically uses the device's locale to localize paywalls and evaluate campaign rules. Override the locale with the `localeIdentifier` option when you need to:
* preview translations without changing the simulator or device settings
* QA rules that gate content by market or language
* capture store screenshots or marketing assets in a specific language
`localeIdentifier` accepts standard BCP‑47 identifiers such as `en_US`, `en_GB`, or `fr_CA`. You can reference Apple's [complete locale list](https://gist.github.com/jacobbubu/1836273) if you need the exact identifier for a region.
Set the locale before Superwall config [#set-the-locale-before-superwall-config]
`` configures the native SDK only once, so be sure the locale you want to test is decided before the provider mounts.
```tsx
import { SuperwallProvider, type PartialSuperwallOptions } from "expo-superwall";
const localeOptions: PartialSuperwallOptions = {
localeIdentifier: "fr_FR",
};
export default function App() {
return (
);
}
```
Verify the active locale [#verify-the-active-locale]
Use `useSuperwall()` and `getDeviceAttributes()` to confirm which locale the native SDK currently sees. This is helpful when debugging targeting issues.
```tsx
import { useEffect } from "react";
import { useSuperwall } from "expo-superwall";
export function LocaleDebug() {
const superwall = useSuperwall();
useEffect(() => {
superwall.getDeviceAttributes().then((attrs) => {
console.log("[Superwall] localeIdentifier:", attrs.localeIdentifier);
});
}, [superwall]);
return null;
}
```
Related resources [#related-resources]
* [Localization overview](/docs/localization)
* [In-App Paywall Previews](/docs/expo/quickstart/in-app-paywall-previews)
# StoreKit testing (iOS only)
StoreKit testing in Xcode is a local test environment for testing in-app purchases without requiring a connection to App Store servers. To use it with Expo, you must run a development build and open the generated iOS project in Xcode.
Expo prerequisites [#expo-prerequisites]
**Expo Go is not supported**
The Superwall SDK uses native modules that are not available in Expo Go. You must use an [Expo Development Build](https://docs.expo.dev/develop/development-builds/introduction/) to run your app with StoreKit testing.
```bash
npx expo run:ios
```
If you don't already have an `ios` folder in your project, generate it with Expo prebuild:
```bash
npx expo prebuild
```
Then open the iOS workspace in Xcode:
```bash
xed ios
```
Running `npx expo prebuild --clean` will regenerate the native project and reset Xcode scheme settings. If you use `--clean`, you'll need to reselect your StoreKit configuration file in the scheme afterward.
Once the iOS project is open in Xcode, follow the steps below.
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:
# Using Expo SDK in Bare React Native Apps
This guide is for React Native developers who want to integrate Superwall for the first time using our Expo SDK, even though their project doesn't use Expo.
**This doesn't sound like you?**
* **Expo project** → Use the standard [installation guide](/docs/expo/quickstart/install)
* **React Native app with existing Superwall SDK** → See our [migration guide](/docs/expo/guides/migrating-react-native)
What are Expo Modules? [#what-are-expo-modules]
Expo Modules allow you to use Expo SDK packages in any React Native project, even if you're not using Expo as your development framework. This means bare React Native apps can benefit from Expo's ecosystem while maintaining their existing project structure.
Superwall's Expo SDK (`expo-superwall`) is now our recommended SDK for all React Native projects. By installing Expo Modules in your bare React Native app, you can use our latest SDK with the best features and support.
Prerequisites [#prerequisites]
Before starting, ensure you have:
* A React Native project (compatible with React Native 0.79+)
* iOS deployment target set to 15.1 or higher
* Android minimum SDK version 21 or higher
* Node.js 18 or newer
Step 1: Install Expo Modules [#step-1-install-expo-modules]
First, you need to install Expo modules in your React Native project. This allows you to use any Expo SDK package, including `expo-superwall`.
For comprehensive installation details, refer to [Expo's official guide](https://docs.expo.dev/bare/installing-expo-modules/)
Automatic Installation (Recommended) [#automatic-installation-recommended]
Run the following command in your project root:
```bash
npx install-expo-modules@latest
```
This command automatically configures your iOS and Android projects to support Expo modules.
Manual Installation (If Automatic Fails) [#manual-installation-if-automatic-fails]
If the automatic installation doesn't work (common in highly customized projects), follow these steps:
1. Install the expo package:
```bash
npm install expo
```
2. Configure your iOS project:
* Set iOS deployment target to 15.1 in Xcode
* Update your `AppDelegate` files as per [Expo's manual instructions](https://docs.expo.dev/bare/installing-expo-modules/#manual-installation)
* Run `npx pod-install` to install iOS dependencies
3. Configure your Android project:
* Update `android/settings.gradle` and `android/app/build.gradle`
* Follow the Android configuration steps in [Expo's guide](https://docs.expo.dev/bare/installing-expo-modules/#manual-installation)
Step 2: Install Superwall Expo SDK [#step-2-install-superwall-expo-sdk]
Once Expo modules are configured, install the Superwall SDK:
```bash npm
npm install expo-superwall
```
```bash yarn
yarn add expo-superwall
```
```bash pnpm
pnpm add expo-superwall
```
```bash bun
bun add expo-superwall
```
Step 3: Platform-Specific Configuration [#step-3-platform-specific-configuration]
iOS Configuration [#ios-configuration]
After installing the SDK, run:
```bash
cd ios && pod install
```
Android Configuration [#android-configuration]
Ensure your `android/app/build.gradle` has:
```groovy gradle
android {
compileSdkVersion 34
defaultConfig {
minSdkVersion 21
targetSdkVersion 34
}
}
```
Troubleshooting [#troubleshooting]
If you encounter any issues during installation, refer to [Expo's installation guide](https://docs.expo.dev/bare/installing-expo-modules/) for detailed troubleshooting steps and platform-specific configuration details.
What's Next? [#whats-next]
Continue with the [Superwall configuration guide](/docs/expo/quickstart/configure) to complete your setup.
# Using RevenueCat
Not using RevenueCat? No problem! Superwall works out of the box without any additional SDKs.
You only need to use a
`PurchaseController`
if you want end-to-end control of the purchasing pipeline. The recommended way to use RevenueCat with Superwall is by putting it in observer mode.
You can integrate RevenueCat with Superwall in one of two ways:
* [`CustomPurchaseControllerProvider` component (recommended)](#custompurchasecontrollerprovider-component)
* [`PurchaseController` (legacy)](#purchasecontroller-legacy)
`CustomPurchaseControllerProvider` component [#custompurchasecontrollerprovider-component]
The easiest way to integrate RevenueCat with Superwall is using the `CustomPurchaseControllerProvider` component. This approach uses modern React patterns and requires much less code.
1\. Configure RevenueCat and Superwall [#1-configure-revenuecat-and-superwall]
```tsx
import { useEffect } from "react"
import { Platform } from "react-native"
import Purchases, {
PRODUCT_CATEGORY,
PURCHASES_ERROR_CODE,
} from "react-native-purchases"
import {
CustomPurchaseControllerProvider,
SuperwallProvider,
SuperwallLoaded,
SuperwallLoading,
} from "expo-superwall"
const REVENUECAT_API_KEYS = {
ios: "appl_YOUR_IOS_KEY_HERE",
android: "goog_YOUR_ANDROID_KEY_HERE",
}
const SUPERWALL_API_KEYS = {
ios: "YOUR_SUPERWALL_IOS_KEY",
android: "YOUR_SUPERWALL_ANDROID_KEY",
}
function App() {
useEffect(() => {
const apiKey = Platform.OS === "ios"
? REVENUECAT_API_KEYS.ios
: REVENUECAT_API_KEYS.android
Purchases.configure({ apiKey })
}, [])
return (
{
try {
const products = await Promise.all([
Purchases.getProducts([params.productId], PRODUCT_CATEGORY.SUBSCRIPTION),
Purchases.getProducts([params.productId], PRODUCT_CATEGORY.NON_SUBSCRIPTION),
]).then((results) => results.flat())
const product =
products.find((product) => product.identifier === params.productId) ??
(params.platform === "android"
? products.find(
(product) => product.identifier === `${params.productId}:${params.basePlanId}`
)
: undefined) ??
products[0]
if (!product) {
return { type: "failed", error: "Product not found" }
}
if (params.platform === "android" && product.subscriptionOptions?.length) {
const optionId = params.offerId
? `${params.basePlanId}:${params.offerId}`
: params.basePlanId
const option = product.subscriptionOptions.find((option) => option.id === optionId)
if (!option) {
return { type: "failed", error: "Subscription option not found" }
}
await Purchases.purchaseSubscriptionOption(option)
return
}
await Purchases.purchaseStoreProduct(product)
} catch (error: any) {
if (error.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
return { type: "cancelled" }
}
return { type: "failed", error: error.message }
}
},
onPurchaseRestore: async () => {
try {
await Purchases.restorePurchases()
} catch (error: any) {
return { type: "failed", error: error.message }
}
},
}}
>
{/* Loading UI */}
{/* Your app */}
)
}
```
On Android, `onPurchase` includes a `basePlanId` and may include an `offerId`. Use those values to find the matching RevenueCat subscription option and call `Purchases.purchaseSubscriptionOption(option)`. Calling `Purchases.purchaseStoreProduct(product)` for a Google Play subscription lets RevenueCat choose the product's default offer, which may not be the offer selected on the Superwall paywall.
2\. Sync Subscription Status [#2-sync-subscription-status]
Listen for RevenueCat subscription changes and update Superwall:
```tsx
import { useSuperwallEvents, useUser } from 'expo-superwall'
function SubscriptionSync() {
const { setSubscriptionStatus } = useUser()
useEffect(() => {
// Listen for RevenueCat customer info updates
const listener = Purchases.addCustomerInfoUpdateListener((customerInfo) => {
const entitlementIds = Object.keys(customerInfo.entitlements.active)
setSubscriptionStatus({
status: entitlementIds.length === 0 ? "INACTIVE" : "ACTIVE",
entitlements: entitlementIds.map(id => ({
id,
type: "SERVICE_LEVEL"
}))
})
})
// Get initial customer info
const syncInitialStatus = async () => {
try {
const customerInfo = await Purchases.getCustomerInfo()
const entitlementIds = Object.keys(customerInfo.entitlements.active)
setSubscriptionStatus({
status: entitlementIds.length === 0 ? "INACTIVE" : "ACTIVE",
entitlements: entitlementIds.map(id => ({
id,
type: "SERVICE_LEVEL"
}))
})
} catch (error) {
console.error("Failed to sync initial subscription status:", error)
}
}
syncInitialStatus()
return () => {
listener?.remove()
}
}, [setSubscriptionStatus])
return null // This component just handles the sync
}
```
That's it! This approach is much simpler than the class-based implementation and uses modern React patterns.
Check out our sample app for a working example: [Expo Example](https://github.com/superwall/expo-superwall/tree/main/example)
***
`PurchaseController` (legacy) [#purchasecontroller-legacy]
This approach is for apps using the legacy `expo-superwall/compat` import. For new projects, use the hooks-based integration above.
You can integrate RevenueCat with Superwall using purchase controllers:
1. **Using a purchase controller:** Use this route if you want to maintain control over purchasing logic and code.
2. **Using PurchasesAreCompletedBy:** Here, you don't use a purchase controller and you tell RevenueCat that purchases are completed by your app using StoreKit. In this mode, RevenueCat will observe the purchases that the Superwall SDK makes. For more info [see here](https://www.revenuecat.com/docs/migrating-to-revenuecat/sdk-or-not/finishing-transactions).
1\. Create a PurchaseController [#1-create-a-purchasecontroller]
Create a new file called `RCPurchaseController`, then copy and paste the following:
```typescript
import { Platform } from "react-native"
import Superwall, {
PurchaseController,
PurchaseResult,
RestorationResult,
SubscriptionStatus,
PurchaseResultCancelled,
PurchaseResultFailed,
PurchaseResultPending,
PurchaseResultPurchased,
} from 'expo-superwall/compat';
import Purchases, {
type CustomerInfo,
PRODUCT_CATEGORY,
type PurchasesStoreProduct,
type SubscriptionOption,
PURCHASES_ERROR_CODE,
type MakePurchaseResult,
} from "react-native-purchases"
export class RCPurchaseController extends PurchaseController {
constructor() {
super()
Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG);
const apiKey = Platform.OS === 'ios' ? 'ios_rc_key' : 'android_rc_key';
Purchases.configure({ apiKey });
}
syncSubscriptionStatus() {
// Listen for changes
Purchases.addCustomerInfoUpdateListener((customerInfo) => {
const entitlementIds = Object.keys(customerInfo.entitlements.active)
Superwall.shared.setSubscriptionStatus(
entitlementIds.length === 0
? SubscriptionStatus.Inactive()
: SubscriptionStatus.Active(entitlementIds)
)
})
}
async purchaseFromAppStore(productId: string): Promise {
const products = await Promise.all([
Purchases.getProducts([productId], PRODUCT_CATEGORY.SUBSCRIPTION),
Purchases.getProducts([productId], PRODUCT_CATEGORY.NON_SUBSCRIPTION),
]).then((results) => results.flat())
// Assuming an equivalent for Dart's firstOrNull is not directly available in TypeScript,
// so using a simple conditional check
const storeProduct = products.length > 0 ? products[0] : null
if (!storeProduct) {
return new PurchaseResultFailed("Failed to find store product for $productId")
}
return await this._purchaseStoreProduct(storeProduct)
}
async purchaseFromGooglePlay(
productId: string,
basePlanId?: string,
offerId?: string
): Promise {
// Find products matching productId from RevenueCat
const products = await Promise.all([
Purchases.getProducts([productId], PRODUCT_CATEGORY.SUBSCRIPTION),
Purchases.getProducts([productId], PRODUCT_CATEGORY.NON_SUBSCRIPTION),
]).then((results) => results.flat())
// Choose the product which matches the given base plan.
// If no base plan set, select first product or fail.
const storeProductId = `${productId}:${basePlanId}`
// Initialize matchingProduct as null explicitly
let matchingProduct: PurchasesStoreProduct | null = null
// Loop through each product in the products array
for (const product of products) {
// Check if the current product's identifier matches the given storeProductId
if (product.identifier === storeProductId) {
// If a match is found, assign this product to matchingProduct
matchingProduct = product
// Break the loop as we found our matching product
break
}
}
let storeProduct: PurchasesStoreProduct | null =
matchingProduct ??
(products.length > 0 && typeof products[0] !== "undefined" ? products[0] : null)
// If no product is found (either matching or the first one), return a failed purchase result.
if (storeProduct === null) {
return new PurchaseResultFailed("Product not found")
}
switch (storeProduct.productCategory) {
case PRODUCT_CATEGORY.SUBSCRIPTION:
const subscriptionOption = await this._fetchGooglePlaySubscriptionOption(
storeProduct,
basePlanId,
offerId
)
if (subscriptionOption === null) {
return new PurchaseResultFailed("Valid subscription option not found for product.")
}
return await this._purchaseSubscriptionOption(subscriptionOption)
case PRODUCT_CATEGORY.NON_SUBSCRIPTION:
return await this._purchaseStoreProduct(storeProduct)
default:
return new PurchaseResultFailed("Unable to determine product category")
}
}
private async _purchaseStoreProduct(
storeProduct: PurchasesStoreProduct
): Promise {
const performPurchase = async (): Promise => {
// Attempt to purchase product
const makePurchaseResult = await Purchases.purchaseStoreProduct(storeProduct)
return makePurchaseResult
}
return await this.handleSharedPurchase(performPurchase)
}
private async _fetchGooglePlaySubscriptionOption(
storeProduct: PurchasesStoreProduct,
basePlanId?: string,
offerId?: string
): Promise {
const subscriptionOptions = storeProduct.subscriptionOptions
if (subscriptionOptions && subscriptionOptions.length > 0) {
// Concatenate base + offer ID
const subscriptionOptionId = this.buildSubscriptionOptionId(basePlanId, offerId)
// Find first subscription option that matches the subscription option ID or use the default offer
let subscriptionOption: SubscriptionOption | null = null
// Search for the subscription option with the matching ID
for (const option of subscriptionOptions) {
if (option.id === subscriptionOptionId) {
subscriptionOption = option
break
}
}
// If no matching subscription option is found, use the default option
subscriptionOption = subscriptionOption ?? storeProduct.defaultOption
// Return the subscription option
return subscriptionOption
}
return null
}
private buildSubscriptionOptionId(basePlanId?: string, offerId?: string): string {
let result = ""
if (basePlanId !== null) {
result += basePlanId
}
if (offerId !== null) {
if (basePlanId !== null) {
result += ":"
}
result += offerId
}
return result
}
private async _purchaseSubscriptionOption(
subscriptionOption: SubscriptionOption
): Promise {
// Define the async perform purchase function
const performPurchase = async (): Promise => {
// Attempt to purchase product
const purchaseResult = await Purchases.purchaseSubscriptionOption(subscriptionOption)
return purchaseResult
}
const purchaseResult: PurchaseResult = await this.handleSharedPurchase(performPurchase)
return purchaseResult
}
private async handleSharedPurchase(
performPurchase: () => Promise
): Promise {
try {
// Perform the purchase using the function provided
const makePurchaseResult = await performPurchase()
// Handle the results
if (this.hasActiveEntitlementOrSubscription(makePurchaseResult.customerInfo)) {
return new PurchaseResultPurchased()
} else {
return new PurchaseResultFailed("No active subscriptions found.")
}
} catch (e: any) {
// Catch block to handle exceptions, adjusted for TypeScript
if (e.userCancelled) {
return new PurchaseResultCancelled()
}
if (e.code === PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
return new PurchaseResultPending()
} else {
return new PurchaseResultFailed(e.message)
}
}
}
async restorePurchases(): Promise {
try {
await Purchases.restorePurchases()
return RestorationResult.restored()
} catch (e: any) {
return RestorationResult.failed(e.message)
}
}
private hasActiveEntitlementOrSubscription(customerInfo: CustomerInfo): Boolean {
return (
customerInfo.activeSubscriptions.length > 0 &&
Object.keys(customerInfo.entitlements.active).length > 0
)
}
}
```
As discussed in [Purchases and Subscription Status](/docs/expo/guides/advanced-configuration), this `PurchaseController` is responsible for handling the subscription-related logic. Take a few moments to look through the code to understand how it does this.
2\. Configure Superwall [#2-configure-superwall]
Initialize an instance of `RCPurchaseController` and pass it in to `Superwall.configure(apiKey:purchaseController)`:
```typescript
React.useEffect(() => {
const apiKey = Platform.OS === "ios" ? "MY_SUPERWALL_IOS_API_KEY" : "MY_SUPERWALL_ANDROID_API_KEY"
const purchaseController = new RCPurchaseController()
Superwall.configure(apiKey, null, purchaseController)
purchaseController.syncSubscriptionStatus()
}, [])
```
3\. Sync the subscription status [#3-sync-the-subscription-status]
Then, call `purchaseController.syncSubscriptionStatus()` to keep Superwall's subscription status up to date with RevenueCat.
That's it! Check out our sample app for working examples:
* [Expo](https://github.com/superwall/expo-superwall/tree/main/example)
* [React Native (deprecated)](https://github.com/superwall/react-native-superwall/blob/main/example/src/RCPurchaseController.tsx)
Using PurchasesAreCompletedBy [#using-purchasesarecompletedby]
If you're using RevenueCat's [PurchasesAreCompletedBy](https://www.revenuecat.com/docs/migrating-to-revenuecat/sdk-or-not/finishing-transactions), you don't need to create a purchase controller. Register your placements, present a paywall — and Superwall will take care of completing any purchase the user starts. However, there are a few things to note if you use this setup:
1. Here, you aren't using RevenueCat's [entitlements](https://www.revenuecat.com/docs/getting-started/entitlements#entitlements) as a source of truth. If your app is multiplatform, you'll need to consider how to link up pro features or purchased products for users.
2. If you require custom logic when purchases occur, then you'll want to add a purchase controller. In that case, Superwall handles purchasing flows and RevenueCat will still observe transactions to power their analytics and charts.
3. Be sure that user identifiers are set the same way across Superwall and RevenueCat.
For more information on observer mode, visit [RevenueCat's docs](https://www.revenuecat.com/docs/migrating-to-revenuecat/sdk-or-not/finishing-transactions).
# Using the Superwall Delegate
Use Superwall's delegate to extend our SDK's functionality across several surface areas by assigning to the `delegate` property:
:::expo
```tsx
import { useSuperwallEvents } from "expo-superwall";
// Use the useSuperwallEvents hook to subscribe to Superwall events
function MyApp() {
useSuperwallEvents({
onSuperwallEvent: (eventInfo) => {
console.log('Superwall Event:', eventInfo.event.event, eventInfo.params);
},
onSubscriptionStatusChange: (status) => {
console.log('Subscription Status Changed:', status.status);
},
onPaywallPresent: (info) => {
console.log('Paywall Presented:', info.name);
},
onPaywallDismiss: (info, result) => {
console.log('Paywall Dismissed:', info.name, 'Result:', result);
},
onCustomPaywallAction: (name) => {
console.log('Custom Action:', name);
// Handle custom actions here
},
});
return (
// Your app content
);
}
```
The new hooks-based SDK uses `useSuperwallEvents()` instead of the delegate pattern. For delegate functionality with the compat SDK, use `expo-superwall/compat`.
:::
Some common use cases for using the Superwall delegate include:
* **Custom actions:** [Respond to custom tap actions from a paywall.](/docs/sdk/guides/advanced/custom-paywall-actions#custom-paywall-actions)
* **Respond to purchases:** [See which product was purchased from the presented paywall.](/docs/sdk/guides/advanced/viewing-purchased-products)
* **Analytics:** [Forward events from Superwall to your own analytics.](/docs/sdk/guides/3rd-party-analytics)
Below are some commonly used implementations when using the delegate.
Superwall Events [#superwall-events]
Most of what occurs in Superwall can be viewed using the delegate method to respond to events:
:::expo
```typescript
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:
// Handle any other placement types as needed...
break
}
}
}
```
:::
Paywall Custom Actions [#paywall-custom-actions]
Using the [custom tap action](/docs/sdk/guides/advanced/custom-paywall-actions#custom-paywall-actions), you can respond to any arbitrary event from a paywall:
:::expo
```tsx
import { useSuperwallEvents } from "expo-superwall";
function MyApp() {
useSuperwallEvents({
onCustomPaywallAction: (name) => {
console.log("Handling custom paywall action:", name);
// Handle your custom actions here
},
});
return (
// Your app content
);
}
```
:::
Subscription status changes [#subscription-status-changes]
You can be informed of subscription status changes using the delegate. If you need to set or handle the status on your own, use a [purchase controller](/docs/sdk/guides/advanced-configuration) — this function is only for informational, tracking or similar purposes:
:::expo
```tsx
import { useSuperwallEvents } from "expo-superwall";
function MyApp() {
useSuperwallEvents({
onSubscriptionStatusChange: (status) => {
console.log("Subscription status changed to:", status.status);
if (status.status === "ACTIVE") {
console.log("Active entitlements:", status.entitlements);
}
},
});
return (
// Your app content
);
}
```
:::
Paywall events [#paywall-events]
The delegate also has callbacks for several paywall events, such dismissing, presenting, and more. Here's an example:
:::expo
```typescript
export class MySuperwallDelegate extends SuperwallDelegate {
didPresentPaywall(paywallInfo: PaywallInfo): void {
console.log("Paywall did present:", paywallInfo)
}
}
```
:::
# Vibe Coding
Overview [#overview]
We've built a few tools to help you Vibe Code using the knowledge of the Superwall Docs, access your Superwall account, and more right in your favorite AI tools:
* [Superwall Skill (Recommended)](#superwall-skill-recommended): Gives AI agents live docs, API access, and step-by-step SDK integration guides. If you're unsure which tool to use, pick this one.
* [Superwall MCP](#superwall-mcp): Expose your Superwall account (projects, paywalls, campaigns) to work with AI tools.
And right here in the Superwall Docs:
* [Superwall AI](#superwall-ai)
* [Docs Links](#docs-links)
* [LLMs.txt](#llmstxt)
Superwall Skill (Recommended) [#superwall-skill-recommended]
The [Superwall Skill](/docs/dashboard/guides/superwall-skill) is the best way to give AI coding agents full context on Superwall. It bundles live documentation, API access, dashboard links, and guided SDK integration flows for every platform, all in one install. If you're unsure which tool to use, pick this one.
```bash
npx skills add superwall/skills
```
Once installed, your agent can look up any Superwall doc on demand, call the API to inspect your projects and applications, and walk through a complete SDK integration step by step. It supports iOS, Android, Flutter, and Expo out of the box with platform-specific quickstart skills.
If you're only going to set up one tool, this is the one to use. See the full [Superwall Skill guide](/docs/dashboard/guides/superwall-skill) for details.
Superwall MCP [#superwall-mcp]
The Superwall MCP connects AI tools to your **Superwall account**, letting agents create and manage projects, paywalls, campaigns, products, entitlements, and webhooks directly. Instead of switching to the dashboard, your AI assistant can set everything up for you.
If you also want live docs access and guided SDK integration help, use the [Superwall Skill](/docs/dashboard/guides/superwall-skill). The MCP is focused on account and resource management.
See the full [Superwall MCP guide](/docs/dashboard/guides/superwall-mcp) for installation, a step-by-step quick setup, and the complete tool reference.
Superwall AI [#superwall-ai]
Superwall AI is available in the bottom right 💬 and is a great place to start if you have a question or issue.
Docs Links [#docs-links]
At the top of each page of the Superwall Docs (including this one!):
* **Copy Markdown**: to copy the page in Markdown format.
Also in the **Open** dropdown menu, you can access these options:
* **View as Markdown**: to view the page in Markdown format
* **Open in ChatGPT**, **Open in Claude**: to open the page in the respective AI tool and add the page as context for your conversation
LLMs.txt [#llmstxt]
The Superwall Docs website has `llms.txt` and `llms-full.txt` files, in total and for each SDK, that you can use to add context to your LLMs.
`llms.txt` is a summary of the docs with links to each page.
`llms-full.txt` is the full text of all of the docs.
| SDK | Summary | Full Text |
| ------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| All | [`llms.txt`](https://superwall.com/docs/llms.txt) | [`llms-full.txt`](https://superwall.com/docs/llms-full.txt) |
| Dashboard | [`llms-dashboard.txt`](https://superwall.com/docs/llms-dashboard.txt) | [`llms-full-dashboard.txt`](https://superwall.com/docs/llms-full-dashboard.txt) |
| iOS | [`llms-ios.txt`](https://superwall.com/docs/llms-ios.txt) | [`llms-full-ios.txt`](https://superwall.com/docs/llms-full-ios.txt) |
| Android | [`llms-android.txt`](https://superwall.com/docs/llms-android.txt) | [`llms-full-android.txt`](https://superwall.com/docs/llms-full-android.txt) |
| Flutter | [`llms-flutter.txt`](https://superwall.com/docs/llms-flutter.txt) | [`llms-full-flutter.txt`](https://superwall.com/docs/llms-full-flutter.txt) |
| Expo | [`llms-expo.txt`](https://superwall.com/docs/llms-expo.txt) | [`llms-full-expo.txt`](https://superwall.com/docs/llms-full-expo.txt) |
| React Native (Deprecated) | [`llms-react-native.txt`](https://superwall.com/docs/llms-react-native.txt) | [`llms-full-react-native.txt`](https://superwall.com/docs/llms-full-react-native.txt) |
| Integrations | [`llms-integrations.txt`](https://superwall.com/docs/llms-integrations.txt) | [`llms-full-integrations.txt`](https://superwall.com/docs/llms-full-integrations.txt) |
| Web Checkout | [`llms-web-checkout.txt`](https://superwall.com/docs/llms-web-checkout.txt) | [`llms-full-web-checkout.txt`](https://superwall.com/docs/llms-full-web-checkout.txt) |
To minimize token use, we recommend using the files specific to your SDK.
# Web Checkout
Dashboard Setup [#dashboard-setup]
1. [Set up Web Checkout in the dashboard](/docs/web-checkout)
2. [Add web products to your paywall](/docs/web-checkout/web-checkout-direct-stripe-checkout)
SDK Setup [#sdk-setup]
:::expo
1. [Set up deep links](/docs/sdk/quickstart/in-app-paywall-previews)
2. [Handle Post-Checkout redirecting](/docs/sdk/guides/web-checkout/post-checkout-redirecting)
3. **Only if you're using RevenueCat:** [Using RevenueCat](/docs/sdk/guides/web-checkout/using-revenuecat)
4. **Only if you're using your own PurchaseController:** [Redeeming In-App](/docs/sdk/guides/web-checkout/linking-membership-to-iOS-app)
:::
Testing [#testing]
1. [Testing Purchases](/docs/web-checkout/web-checkout-testing-purchases)
2. [Managing Memberships](/docs/web-checkout/web-checkout-managing-memberships)
Troubleshooting [#troubleshooting]
If a user has issues accessing their purchase in your app after paying via web checkout, direct them to your plan management page to retrieve their redemption link or manage billing:
For example: `http://yourapp.superwall.app/manage`
FAQ [#faq]
[Web Checkout FAQ](/docs/web-checkout/web-checkout-faq)
# Redeeming In-App
After purchasing from a web paywall, the user will be redirected to your app by a deep link to redeem their purchase on device.
Please follow our [Post-Checkout Redirecting](/docs/expo/guides/web-checkout/post-checkout-redirecting) guide to handle this user experience.
If you're using Superwall to handle purchases, then you don't need to do anything here.
If you're using your own `PurchaseController`, you will need to update the subscription status with the redeemed web entitlements. If you're using RevenueCat, you should follow our [Using RevenueCat](/docs/expo/guides/web-checkout/using-revenuecat) guide.
Using a PurchaseController [#using-a-purchasecontroller]
If you're using a custom PurchaseController (with either iOS StoreKit or Android Play Billing), you'll need to merge the web entitlements with the device entitlements before setting the subscription status.
Here's an example of how you might do this:
```typescript
import Superwall, { SubscriptionStatus, Entitlement } from 'expo-superwall/compat';
async function syncSubscriptionStatus(): Promise {
// Get the device entitlements from your purchase controller
// This will vary based on whether you're using RevenueCat, StoreKit, or Play Billing
const deviceEntitlements = await getDeviceEntitlements();
// Get the web entitlements from Superwall
const webEntitlements = await Superwall.shared.getWebEntitlements();
// Merge the two sets of entitlements
const allEntitlementIds = new Set([
...deviceEntitlements,
...webEntitlements.map(e => e.id)
]);
// Update subscription status
if (allEntitlementIds.size > 0) {
const entitlements = Array.from(allEntitlementIds).map(id =>
new Entitlement(id)
);
Superwall.shared.setSubscriptionStatus(
SubscriptionStatus.Active(entitlements)
);
} else {
Superwall.shared.setSubscriptionStatus(SubscriptionStatus.Inactive());
}
}
// Helper function to get device entitlements
// This is a simplified example - your implementation will depend on your purchase system
async function getDeviceEntitlements(): Promise {
// For RevenueCat:
// const customerInfo = await Purchases.getCustomerInfo();
// return Object.keys(customerInfo.entitlements.active);
// For custom StoreKit/Play Billing:
// Query your store's API for current purchases
// Extract entitlement IDs from those purchases
// Return array of entitlement IDs
return []; // Replace with your actual implementation
}
```
In addition to syncing the subscription status when purchasing and restoring, you'll need to sync it whenever `didRedeemLink(result)` is called:
```typescript
import { SuperwallDelegate, RedemptionResult } from 'expo-superwall/compat';
export class SWDelegate extends SuperwallDelegate {
async didRedeemLink(result: RedemptionResult): Promise {
await syncSubscriptionStatus();
}
}
```
Refreshing of web entitlements [#refreshing-of-web-entitlements]
If you aren't using a Purchase Controller, the SDK will refresh the web entitlements every 24 hours.
Redeeming while a paywall is open [#redeeming-while-a-paywall-is-open]
If a redeem event occurs when a paywall is open, the SDK will track that as a restore event and the paywall will close.
# Post-Checkout Redirecting
After a user completes a web purchase, Superwall needs to redirect them back to your app. You can configure this behavior in two ways:
Post-Purchase Behavior Modes [#post-purchase-behavior-modes]
You can configure how users are redirected after checkout in your [Application Settings](/docs/web-checkout/web-checkout-configuring-stripe-keys-and-settings#post-purchase-behavior):
Redeem Mode (Default) [#redeem-mode-default]
Superwall manages the entire redemption experience:
* Users are automatically deep linked to your app with a redemption code
* Fallback to App Store/Play Store if the app isn't installed
* Redemption emails are sent automatically
* The SDK handles redemption via delegate methods (detailed below)
This is the recommended mode for most apps.
Redirect Mode [#redirect-mode]
Redirect users to your own custom URL with purchase information:
* **When to use**: You want to show a custom success page, perform additional actions before redemption, or have your own deep linking infrastructure
* **What you receive**: Purchase data is passed as query parameters to your URL
**Query Parameters Included**:
**Example**:
```
https://yourapp.com/success?
app_user_id=user_123&
email=user@example.com&
stripe_subscription_id=sub_1234567890&
campaign_id=summer_sale
```
You'll need to implement your own logic to handle the redirect and deep link users into your app.
***
Setting Up Deep Links [#setting-up-deep-links]
Whether checkout starts from a web link or from a paywall that opens an external browser, the Superwall SDK relies on deep links to redirect back to your app.
Prerequisites [#prerequisites]
1. [Configuring Stripe Keys and Settings](/docs/web-checkout/web-checkout-configuring-stripe-keys-and-settings)
2. [Deep Links](/docs/expo/quickstart/in-app-paywall-previews)
If you're not using Superwall to handle purchases, then you'll need to follow extra steps to redeem the web purchase in your app.
* [Using RevenueCat](/docs/expo/guides/web-checkout/using-revenuecat)
* [Using a PurchaseController](/docs/expo/guides/web-checkout/linking-membership-to-iOS-app#using-a-purchasecontroller)
***
Handling Redemption (Redeem Mode) [#handling-redemption-redeem-mode]
When using Redeem mode (the default), handle the user experience when they're redirected back to your app using `SuperwallDelegate` methods:
willRedeemLink [#willredeemlink]
When your app opens via the deep link, we will call the delegate method `willRedeemLink()` before making a network call to redeem the code.
At this point, you might wish to display a loading indicator in your app so the user knows that the purchase is being redeemed.
```typescript
import { SuperwallDelegate } from 'expo-superwall/compat';
import { Toast } from 'react-native-toast-message'; // or your preferred toast library
export class SWDelegate extends SuperwallDelegate {
willRedeemLink(): void {
// Show a loading indicator to the user
Toast.show({
type: 'info',
text1: 'Activating your purchase...',
});
}
}
```
You can manually dismiss the paywall at this point if needed, but note that the paywall will be dismissed automatically when the `didRedeemLink` method is called.
didRedeemLink [#didredeemlink]
After receiving a response from the network, we will call `didRedeemLink(result)` with the result of redeeming the code. This result can be one of the following:
* `RedemptionResult` with `type: 'success'`: The redemption succeeded and contains information about the redeemed code.
* `RedemptionResult` with `type: 'error'`: An error occurred while redeeming. You can check the error message via the error parameter.
* `RedemptionResult` with `type: 'expiredCode'`: The code expired and contains information about whether a redemption email has been resent and an optional obfuscated email address.
* `RedemptionResult` with `type: 'invalidCode'`: The code that was redeemed was invalid.
* `RedemptionResult` with `type: 'expiredSubscription'`: The subscription that the code redeemed has expired.
On network failure, the SDK will retry up to 6 times before returning an `error` `RedemptionResult` in `didRedeemLink(result)`.
Here, you should remove any loading UI you added in `willRedeemLink` and show a message to the user based on the result. If a paywall is presented, it will be dismissed automatically.
```typescript
import { SuperwallDelegate, RedemptionResult } from 'expo-superwall/compat';
import Superwall from 'expo-superwall/compat';
import { Toast } from 'react-native-toast-message'; // or your preferred toast library
export class SWDelegate extends SuperwallDelegate {
didRedeemLink(result: RedemptionResult): void {
switch (result.type) {
case 'expiredCode':
Toast.show({
type: 'error',
text1: 'Expired Link',
});
console.log('[!] code expired', result.code, result.expiredInfo);
break;
case 'error':
Toast.show({
type: 'error',
text1: result.error.message,
});
console.log('[!] error', result.code, result.error);
break;
case 'expiredSubscription':
Toast.show({
type: 'error',
text1: 'Expired Subscription',
});
console.log('[!] expired subscription', result.code, result.redemptionInfo);
break;
case 'invalidCode':
Toast.show({
type: 'error',
text1: 'Invalid Link',
});
console.log('[!] invalid code', result.code);
break;
case 'success':
const email = result.redemptionInfo?.purchaserInfo?.email;
if (email) {
Superwall.shared.setUserAttributes({ email });
Toast.show({
type: 'success',
text1: `Welcome, ${email}!`,
});
} else {
Toast.show({
type: 'success',
text1: 'Welcome!',
});
}
break;
}
}
}
```
Access detailed product data [#access-detailed-product-data]
Successful redeems now populate `result.redemptionInfo?.paywallInfo?.product` with the exact product the customer purchased. Use it to show localized pricing on your success screen or to pass metadata to your billing system.
```ts
function trackProduct(result: RedemptionResult) {
if (result.type !== "success") return;
const product = result.redemptionInfo?.paywallInfo?.product;
if (!product) return;
analytics.track("web_checkout_completed", {
productId: product.identifier,
price: product.price,
period: product.periodly,
currency: product.currencyCode,
});
}
```
`productIdentifier` remains for backwards compatibility but will be removed in a future Expo release, so migrate to the richer `product` object now.
# Using RevenueCat
After purchasing from a web paywall, the user will be redirected to your app by a deep link to redeem their purchase on device. Please follow our [Post-Checkout Redirecting](/docs/expo/guides/web-checkout/post-checkout-redirecting) guide to handle this user experience.
If you're using Superwall to handle purchases, then you don't need to do anything here.
You only need to use a
`PurchaseController`
if you want end-to-end control of the purchasing pipeline. The recommended way to use RevenueCat with Superwall is by putting it in observer mode.
If you're using your own `PurchaseController`, you should follow our [Redeeming In-App](/docs/expo/guides/web-checkout/linking-membership-to-iOS-app) guide.
Using a PurchaseController with RevenueCat [#using-a-purchasecontroller-with-revenuecat]
If you're using RevenueCat, you'll need to follow [steps 1 to 4 in their guide](https://www.revenuecat.com/docs/web/integrations/stripe) to set up Stripe with RevenueCat. Then, you'll need to
associate the RevenueCat customer with the Stripe subscription IDs returned from redeeming the code. You can do this by extracting the ids from the `RedemptionResult` and sending them to RevenueCat's API
by using the `didRedeemLink(result)` delegate method:
This flow is for Stripe subscriptions. Stripe one-time purchases can return Stripe Checkout session IDs through the same legacy `stripeSubscriptionIds` field, but those IDs are not Stripe subscription IDs. Handle one-time purchases with Superwall entitlements or your own backend instead of sending those IDs to RevenueCat's Stripe subscription endpoint.
```typescript
import { SuperwallDelegate, RedemptionResult } from 'expo-superwall/compat';
import Purchases from 'react-native-purchases';
export class SWDelegate extends SuperwallDelegate {
// The user tapped on a deep link to redeem a code
willRedeemLink(): void {
console.log('[!] willRedeemLink');
// Optionally show a loading indicator here
}
// Superwall received a redemption result and validated the purchase with Stripe.
async didRedeemLink(result: RedemptionResult): Promise {
console.log('[!] didRedeemLink', result);
// Send Stripe IDs to RevenueCat to link purchases to the customer
// Get a list of subscription ids tied to the customer.
const stripeSubscriptionIds =
result.type === 'success' ? result.stripeSubscriptionIds : null;
if (!stripeSubscriptionIds) {
return;
}
const revenueCatStripePublicAPIKey = 'strp.....'; // replace with your RevenueCat Stripe Public API Key
const appUserId = Purchases.getAppUserID();
// In the background, process all subscription IDs
await Promise.all(
stripeSubscriptionIds.map(async (stripeSubscriptionId) => {
try {
const response = await fetch('https://api.revenuecat.com/v1/receipts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Platform': 'stripe',
'Authorization': `Bearer ${revenueCatStripePublicAPIKey}`,
},
body: JSON.stringify({
app_user_id: appUserId,
fetch_token: stripeSubscriptionId,
}),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(
`RevenueCat responded with ${response.status}: ${responseText || 'No body'}`
);
}
const data = responseText ? JSON.parse(responseText) : {};
console.log(`[!] Success: linked ${stripeSubscriptionId} to user ${appUserId}`, data);
} catch (error) {
console.error(`[!] Error: unable to link ${stripeSubscriptionId} to user ${appUserId}`, error);
}
})
);
/// After all network calls complete, invalidate the cache
try {
const customerInfo = await Purchases.getCustomerInfo({ fetchPolicy: 'FETCH_CURRENT' });
/// If you're using RevenueCat's `addCustomerInfoUpdateListener`, or keeping Superwall Entitlements in sync
/// via RevenueCat's listener methods, you don't need to do anything here. Those methods will be
/// called automatically when this call fetches the most up to date customer info, ignoring any local caches.
/// Otherwise, if you're manually calling `Purchases.getCustomerInfo` to keep Superwall's entitlements
/// in sync, you should use the newly updated customer info here to do so.
/// You could always access web entitlements here as well
/// const webEntitlements = Superwall.shared.entitlements.web
// Perform UI updates to let the user know their subscription was redeemed
// This runs automatically on the main thread in React Native
} catch (error) {
console.error('Error getting customer info', error);
}
}
}
```
The example explicitly checks HTTP status codes and throws on failures so you can plug in retries,
alerting, or user messaging. Tailor the error handling to your networking stack.
If you call `logIn` from RevenueCat's SDK, then you need to call the logic you've implemented
inside `didRedeemLink(result)` again. For example, that means if `logIn` was invoked from
RevenueCat, you'd either abstract out this logic above into a function to call again, or simply
call this function directly.
The web entitlements will be returned along with other existing entitlements in the `CustomerInfo` object accessible via RevenueCat's SDK.
If you're logging in and out of RevenueCat, make sure to resend the Stripe subscription IDs to RevenueCat's endpoint after logging in.
# Welcome
**Important: Expo SDK 53+ Required**
This SDK is exclusively compatible with Expo SDK version 53 and newer. For projects using older Expo versions, please use our [legacy React Native SDK](https://github.com/superwall/react-native-superwall).
Quick Links [#quick-links]
Get up and running with the Superwall Expo SDK
Reference the Superwall Expo SDK
Guides for specific use cases
Example app for the Superwall Expo SDK
Guides for troubleshooting common issues
Feedback [#feedback]
We are always improving our SDKs and documentation! The Expo SDK is being actively developed, and we're committed to making it the best way to integrate paywalls into your Expo projects!
If you have feedback on any of our docs, please leave a rating and message at the bottom of the page.
If you have any issues please [open an issue on GitHub](https://github.com/superwall/expo-superwall/issues).
# Configure the SDK
Superwall does **not** refetch its configuration during hot reloads. So, if you add products, edit a paywall, or otherwise change anything with Superwall, re-run your app to see those changes.
As soon as your app launches, you need to configure the SDK with your **Public API Key**. You'll retrieve this from the Superwall settings page.
Sign Up & Grab Keys [#sign-up--grab-keys]
If you haven't already, [sign up for a free account](https://superwall.com/sign-up) on Superwall. Then, when you're through to the Dashboard, click **Settings** from the panel on the left, click **Keys** and copy your **Public API Key**:
Initialize Superwall in your app [#initialize-superwall-in-your-app]
To use the Superwall SDK, you need to wrap your application (or the relevant part of it) with the ``. This provider initializes the SDK with your API key.
```tsx
import { SuperwallProvider } from "expo-superwall";
// Replace with your actual Superwall API key
export default function App() {
return (
{/* Your app content goes here */}
);
}
```
You've now configured Superwall!
# Feature Gating
This allows you to register a [placement](/docs/dashboard/dashboard-campaigns/campaigns-placements) 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.
Here's an example.
With Superwall [#with-superwall]
:::expo
```tsx React Native
import { usePlacement } from "expo-superwall";
function WorkoutButton() {
const { registerPlacement } = usePlacement();
const handlePress = async () => {
// remotely decide if a paywall is shown and if
// navigation.startWorkout() is a paid-only feature
await registerPlacement({
placement: 'StartWorkout',
feature: () => {
navigation.navigate('LaunchedFeature', {
value: 'Non-gated feature launched',
});
},
});
};
return ;
}
```
:::
Without Superwall [#without-superwall]
:::expo
```typescript React Native
function pressedWorkoutButton() {
if (user.hasActiveSubscription) {
navigation.startWorkout()
} else {
navigation.presentPaywall().then((result: boolean) => {
if (result) {
navigation.startWorkout()
} else {
// user didn't pay, developer decides what to do
}
})
}
}
```
:::
How registering placements presents paywalls [#how-registering-placements-presents-paywalls]
You can configure `"StartWorkout"` to present a paywall by [creating a campaign, adding the placement, and adding a paywall to an audience](/docs/dashboard/dashboard-campaigns/campaigns) in the dashboard.
1. The SDK retrieves your campaign settings from the dashboard on app launch.
2. When a placement is called that belongs to a campaign, audiences are evaluated ***on device*** and the user enters an experiment — this means there's no delay between registering a placement and presenting a paywall.
3. If it's the first time a user is entering an experiment, a paywall is decided for the user based on the percentages you set in the dashboard
4. Once a user is assigned a paywall for an audience, they will continue to see that paywall until you remove the paywall from the audience or reset assignments to the paywall.
5. After the paywall is closed, the Superwall SDK looks at the *Feature Gating* value associated with your paywall, configurable from the paywall editor under General > Feature Gating (more on this below)
1. If the paywall is set to ***Non Gated***, the `feature:` closure on `register(placement: ...)` gets called when the paywall is dismissed (whether they paid or not)
2. If the paywall is set to ***Gated***, the `feature:` closure on `register(placement: ...)` gets called only if the user is already paying or if they begin paying.
6. If no paywall is configured, the feature gets executed immediately without any additional network calls.
Given the low cost nature of how register works, we strongly recommend registering **all core functionality** in order to remotely configure which features you want to gate – **without an app update**.
:::expo
```tsx React Native
import { usePlacement } from "expo-superwall";
// on the welcome screen
function SignUpButton() {
const { registerPlacement } = usePlacement();
const handlePress = async () => {
await registerPlacement({
placement: 'SignUp',
feature: () => {
navigation.beginOnboarding();
},
});
};
return ;
}
function WorkoutButton() {
const { registerPlacement } = usePlacement();
const handlePress = async () => {
await registerPlacement({
placement: 'StartWorkout',
feature: () => {
navigation.startWorkout();
},
});
};
return ;
}
```
:::
Automatically Registered Placements [#automatically-registered-placements]
The SDK [automatically registers](/docs/sdk/guides/3rd-party-analytics/tracking-analytics) some internal placements which can be used to present paywalls:
Register. Everything. [#register-everything]
To provide your team with ultimate flexibility, we recommend registering *all* of your analytics events, even if you don't pass feature blocks through. This way you can retroactively add a paywall almost anywhere – **without an app update**!
If you're already set up with an analytics provider, you'll typically have an `Analytics.swift` singleton (or similar) to disperse all your events from. Here's how that file might look:
Getting a presentation result [#getting-a-presentation-result]
Use `getPresentationResult(forPlacement:params:)` when you need to ask the SDK what would happen when registering a placement — without actually showing a paywall. Superwall evaluates the placement and its audience filters then returns a `PresentationResult`. You can use this to adapt your app's behavior based on the outcome (such as showing a lock icon next to a pro feature if they aren't subscribed).
In short, this lets you peek at the outcome first and decide how your app should respond:
# Handling Deep Links
1. Previewing paywalls on your device before going live.
2. Deep linking to specific [campaigns](/docs/dashboard/dashboard-campaigns/campaigns).
:::expo
3) Web Checkout [Post-Checkout Redirecting](/docs/sdk/guides/web-checkout/post-checkout-redirecting)
:::
Setup [#setup]
:::expo
There are two ways to deep link into your app: URL Schemes and Universal Links (iOS only).
:::
Adding a Custom URL Scheme [#adding-a-custom-url-scheme]
:::expo
iOS [#ios-1]
Open **Xcode**. In your **info.plist**, add a row called **URL Types**. Expand the automatically created **Item 0**, and inside the **URL identifier** value field, type your **Bundle ID**, e.g., **com.superwall.Superwall-SwiftUI**. Add another row to **Item 0** called **URL Schemes** and set its **Item 0** to a URL scheme you'd like to use for your app, e.g., **exampleapp**. Your structure should look like this:
With this example, the app will open in response to a deep link with the format **exampleapp\://**. You can [view Apple's documentation](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app) to learn more about custom URL schemes.
:::
:::expo
Android [#android-1]
Add the following to your `AndroidManifest.xml` file:
```xml
```
This configuration allows your app to open in response to a deep link with the format `exampleapp://` from your `MainActivity` class.
:::
:::expo
Adding a Universal Link (iOS only) [#adding-a-universal-link-ios-only-1]
Only required for [Web Checkout](/docs/web-checkout), otherwise you can skip this step.
Before configuring in your app, first [create](/docs/web-checkout/web-checkout-creating-an-app) and [configure](/docs/web-checkout/web-checkout-configuring-stripe-keys-and-settings) your Stripe app on the Superwall Dashboard.
Add a new capability in Xcode [#add-a-new-capability-in-xcode-2]
Select your target in Xcode, then select the **Signing & Capabilities** tab. Click on the **+ Capability** button and select **Associated Domains**. This will add a new capability to your app.
Set the domain [#set-the-domain-2]
Next, enter in the domain using the format `applinks:[your-web-checkout-url]`. This is the domain that Superwall will use to handle universal links. Your `your-web-checkout-url` value should match what's under the "Web Paywall Domain" section.
Testing [#testing-2]
If your Stripe app's iOS Configuration is incomplete or incorrect, universal links **will not work**
You can verify that your universal links are working a few different ways. Keep in mind that it usually takes a few minutes for the associated domain file to propagate:
1. **Use Branch's online validator:** If you visit [branch.io's online validator](https://branch.io/resources/aasa-validator//) and enter in your web checkout URL, it'll run a similar check and provide the same output.
2. **Test opening a universal link:** If the validation passes from either of the two steps above, make sure visiting a universal link opens your app. Your link should be formatted as `https://[your web checkout link]/app-link/` — which is simply your web checkout link with `/app-link/` at the end. This is easiest to test on device, since you have to tap an actual link instead of visiting one directly in Safari or another browser. In the iOS simulator, adding the link in the Reminders app works too:
:::
Handling Deep Links [#handling-deep-links]
Previewing Paywalls [#previewing-paywalls]
Next, build and run your app on your phone.
Then, head to the Superwall Dashboard. Click on **Settings** from the Dashboard panel on the left, then select **General**:
With the **General** tab selected, type your custom URL scheme, without slashes, into the **Apple Custom URL Scheme** field:
Next, open your paywall from the dashboard and click **Preview**. You'll see a QR code appear in a pop-up:
On your device, scan this QR code. You can do this via Apple's Camera app. This will take you to a paywall viewer within your app, where you can preview all your paywalls in different configurations.
Using Deep Links to Present Paywalls [#using-deep-links-to-present-paywalls]
Deep links can also be used as a placement in a campaign to present paywalls. Simply add `deepLink_open` as an placement, and the URL parameters of the deep link can be used as parameters! You can also use custom placements for this purpose. [Read this doc](/docs/dashboard/guides/presenting-paywalls-from-one-another) for examples of both.
Related deep link guides [#related-deep-link-guides]
:::expo
* [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 deep links, without hardcoding routing logic in your app.
:::
# Install the SDK
This guide is for Expo projects that want to integrate Superwall using our Expo SDK.
**This doesn't sound like you?**
* **React Native app, new to Superwall** → See our [installation guide for bare React Native apps](/docs/expo/guides/using-expo-sdk-in-bare-react-native)
* **React Native app with existing Superwall SDK** → See our [migration guide](/docs/expo/guides/migrating-react-native)
**Important: Expo SDK 53+ Required**
This SDK is exclusively compatible with Expo SDK version 53 and newer. For projects using older Expo versions, please use our [legacy React Native SDK](https://github.com/superwall/react-native-superwall).
**Expo Go is Not Supported**
The Superwall SDK uses native modules that are not available in Expo Go. You must use an [Expo Development Build](https://docs.expo.dev/develop/development-builds/introduction/) to run your app with Superwall.
To create a development build:
```bash
npx expo run:ios
# or
npx expo run:android
```
If you see the error `Cannot find native module 'SuperwallExpo'`, see our [Debugging guide](/docs/expo/guides/debugging) for solutions.
To see the latest release, check out the [Superwall Expo SDK repo](https://github.com/superwall/expo-superwall).
```bash bun
bunx expo install expo-superwall
```
```bash pnpm
pnpm dlx expo install expo-superwall
```
```bash npm
npx expo install expo-superwall
```
```bash yarn
yarn dlx expo install expo-superwall
```
Version Targeting [#version-targeting]
Superwall requires iOS 15.1 or higher, as well as Android SDK 21 or higher. Ensure your Expo project targets the correct minimum OS version by updating app.json or app.config.js.
First, install the `expo-build-properties` config plugin if your Expo project hasn’t yet:
```bash
npx expo install expo-build-properties
```
Then, add the following to your `app.json` or `app.config.js` file:
```json
{
"expo": {
"plugins": [
...
[
"expo-build-properties",
{
"android": {
"minSdkVersion": 21
},
"ios": {
"deploymentTarget": "15.1" // or higher
}
}
]
]
}
}
```
**And you're done!**
Now you're ready to configure the SDK
# Present Your First Paywall
Placements [#placements]
With Superwall, you present paywalls by registering a [Placement](/docs/dashboard/dashboard-campaigns/campaigns-placements). Placements are the configurable entry points to show (or not show) paywalls based on your [Campaigns](/docs/dashboard/dashboard-campaigns/campaigns) as setup in your Superwall dashboard.
The placement `campaign_trigger` is set to show an example paywall by default.
Usage [#usage]
The [`usePlacement`](/docs/expo/sdk-reference/hooks/usePlacement) hook allows you to register placements that you've configured in your Superwall dashboard.
The hook returns a `registerPlacement` function that you can use to register a placement.
```tsx
import { usePlacement, useUser } from "expo-superwall";
import { Alert, Button, Text, View } from "react-native";
function PaywallScreen() {
const { registerPlacement, state: placementState } = usePlacement({
onError: (err) => console.error("Placement Error:", err),
onPresent: (info) => console.log("Paywall Presented:", info),
onDismiss: (info, result) =>
console.log("Paywall Dismissed:", info, "Result:", result),
});
const handleTriggerPlacement = async () => {
await registerPlacement({
placement: "campaign_trigger"
});
};
return (
{placementState && (
Last Paywall Result: {JSON.stringify(placementState)}
)}
);
}
```
# Setting User Attributes
By setting user attributes, you can display information about the user on the paywall. You can also define [audiences](/docs/dashboard/dashboard-campaigns/campaigns-audience) in a campaign to determine which paywall to show to a user, based on their user attributes.
If a paywall uses the **Set user attributes** action, the merged attributes are sent back to your app via `SuperwallDelegate.userAttributesDidChange(newAttributes:)`.
You do this by passing a `[String: Any?]` dictionary of attributes to `Superwall.shared.setUserAttributes(_:)`:
:::expo
```tsx React Native
import { useUser } from "expo-superwall";
function UserProfile() {
const { update } = useUser();
const updateUserAttributes = async (user) => {
await update({
name: user.name,
apnsToken: user.apnsTokenString,
email: user.email,
username: user.username,
profilePic: user.profilePicUrl,
stripe_customer_id: user.stripeCustomerId, // Optional: For Stripe checkout prefilling
});
};
// Or update using a function that receives old attributes
const incrementCounter = async () => {
await update((oldAttributes) => ({
...oldAttributes,
counter: (oldAttributes.counter || 0) + 1,
}));
};
return (
<>