# Helium MCP Server
Source: https://docs.tryhelium.com/ai-tools/mcp
Connect Claude, ChatGPT, Cursor and other AI tools to your Helium dashboard data.
The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is a standard for connecting AI tools to external systems. The Helium MCP server gives your AI tool a read-only view of your Helium organization: paywalls, workflows, triggers, targeting, experiments, metrics, and these docs. Ask questions like "which paywall is live on my onboarding trigger?" or "how did trial starts move last week?" and the tool answers from your real dashboard data.
The Helium MCP server is read-only. It cannot edit, publish, or delete anything in your organization.
## Connect
The server URL is the same for every client:
```text theme={null}
https://app.tryhelium.com/api/mcp
```
There are no API keys to copy. When your client connects for the first time it opens the Helium login page in your browser. Sign in, choose the organization you want to connect, approve the permissions, and you are done. Pick your client:
```bash theme={null}
claude mcp add --scope project --transport http helium https://app.tryhelium.com/api/mcp
```
Or add it to `.mcp.json` in your project:
```json theme={null}
{
"mcpServers": {
"helium": {
"type": "http",
"url": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Run `/mcp` inside Claude Code, select **helium**, and choose **Authenticate** to sign in.
```bash theme={null}
codex mcp add helium --url https://app.tryhelium.com/api/mcp
```
Or add it to `~/.codex/config.toml`:
```toml theme={null}
[mcp_servers.helium]
url = "https://app.tryhelium.com/api/mcp"
```
Run `codex mcp login helium` to sign in, or `/mcp` inside Codex.
```bash theme={null}
grok mcp add --transport http helium https://app.tryhelium.com/api/mcp
```
Or add it to `~/.grok/config.toml`:
```toml theme={null}
[mcp_servers.helium]
url = "https://app.tryhelium.com/api/mcp"
```
Run `/mcps` inside Grok, select **helium**, and press `i` to sign in.
```bash theme={null}
gemini mcp add -t http helium https://app.tryhelium.com/api/mcp
```
Or add it to `.gemini/settings.json`:
```json theme={null}
{
"mcpServers": {
"helium": {
"httpUrl": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Run `/mcp auth helium` inside Gemini CLI to sign in.
```bash theme={null}
copilot mcp add --transport http helium https://app.tryhelium.com/api/mcp
```
Or add it to `~/.copilot/mcp-config.json`:
```json theme={null}
{
"mcpServers": {
"helium": {
"type": "http",
"url": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Run `copilot`, then `/mcp show helium` to open the server and authenticate.
Add to `~/.config/opencode/opencode.json`:
```json theme={null}
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"helium": {
"type": "remote",
"url": "https://app.tryhelium.com/api/mcp",
"enabled": true
}
}
}
```
Run `opencode mcp auth helium` to sign in.
```bash theme={null}
droid mcp add helium https://app.tryhelium.com/api/mcp --type http
```
Or add it to `~/.factory/mcp.json`:
```json theme={null}
{
"mcpServers": {
"helium": {
"type": "http",
"url": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Run `/mcp` inside droid to sign in.
Add to `~/.fx/mcp.json`:
```json theme={null}
{
"mcp": {
"helium": {
"type": "http",
"url": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Run `/mcp auth helium --open` inside fx to sign in, then `/mcp list` to check the connection.
1. Open **Customize > Connectors** in Claude (web or desktop).
2. Click **+**, then **Add custom connector**.
3. Name it **Helium** and paste `https://app.tryhelium.com/api/mcp` as the URL.
4. Click **Add**, then **Connect** and sign in with your Helium account.
On Team and Enterprise plans an owner adds the connector first under **Organization settings > Connectors**, then each member clicks **Connect**.
1. Open **Settings > Security and login** and turn on **Developer mode**.
2. Go to **ChatGPT Plugins** at chatgpt.com/plugins and click **+**.
3. Paste `https://app.tryhelium.com/api/mcp` as the MCP server URL, name it **Helium**, add a short description, and click **Create**. ChatGPT detects the Helium sign-in on its own.
4. Start a new chat, click **+**, open **More**, and enable **Helium**. You are asked to sign in the first time a Helium tool runs.
Developer mode needs a Plus, Pro, Business, Enterprise or Education plan.
1. Open **Settings > Connections > MCP servers** and click **Add a custom MCP**.
2. Name it **Helium**, set **Transport** to **HTTP**, and paste `https://app.tryhelium.com/api/mcp` as the server URL.
3. Set **Authentication method** to **OAuth**, save, and sign in with your Helium account when the browser prompt opens.
4. Click **Test listing tools** to confirm the connection.
```bash theme={null}
goose session --with-streamable-http-extension https://app.tryhelium.com/api/mcp
```
Or add it to `~/.config/goose/config.yaml`:
```yaml theme={null}
extensions:
helium:
name: Helium
type: streamable_http
uri: https://app.tryhelium.com/api/mcp
enabled: true
timeout: 300
```
Goose opens the Helium sign-in in your browser the first time the extension connects.
Add to `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for all projects:
```json theme={null}
{
"mcpServers": {
"helium": {
"url": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Or [install with one click](cursor://anysphere.cursor-deeplink/mcp/install?name=helium\&config=eyJ1cmwiOiJodHRwczovL2FwcC50cnloZWxpdW0uY29tL2FwaS9tY3AifQ==). Then open **Customize** in the sidebar and sign in next to helium.
Add to `.vscode/mcp.json`:
```json theme={null}
{
"servers": {
"helium": {
"type": "http",
"url": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Or [install with one click](https://vscode.dev/redirect/mcp/install?name=helium\&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fapp.tryhelium.com%2Fapi%2Fmcp%22%7D). VS Code prompts you to sign in the first time Copilot uses the server.
Add to `~/.gemini/config/mcp_config.json`, or `.agents/mcp_config.json` in your workspace:
```json theme={null}
{
"mcpServers": {
"helium": {
"serverUrl": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Open **Agent Settings** (`Cmd+,` or `Ctrl+,`), go to the **Customizations** tab, and click **Authenticate** next to helium. You can also open the **...** menu and choose **MCP Servers > Manage MCP Servers > View raw config** to edit the file.
Add to `~/.kiro/settings/mcp.json`, or `.kiro/settings/mcp.json` in your workspace:
```json theme={null}
{
"mcpServers": {
"helium": {
"url": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Kiro opens the Helium sign-in in your browser the first time it connects.
Add to `~/.config/devin/mcp_config.json` (macOS and Linux) or `%APPDATA%\devin\mcp_config.json` (Windows):
```json theme={null}
{
"mcpServers": {
"helium": {
"url": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Run `devin mcp login helium` in a terminal, or wait for the sign-in prompt the first time the server is used. Windsurf became Devin Desktop in June 2026 and uses this same configuration.
Add to `.kimi-code/mcp.json` in your project, or `~/.kimi-code/mcp.json` for all projects:
```json theme={null}
{
"mcpServers": {
"helium": {
"transport": "http",
"url": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Run `/mcp-config login helium` inside Kimi Code to sign in.
Add to `~/.warp/.mcp.json`:
```json theme={null}
{
"mcpServers": {
"helium": {
"url": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Warp opens the Helium sign-in in your browser the first time it connects to the server.
Add to your Zed `settings.json`:
```json theme={null}
{
"context_servers": {
"helium": {
"url": "https://app.tryhelium.com/api/mcp"
}
}
}
```
Zed opens the Helium sign-in in your browser the first time the Agent Panel uses the server.
Open **Settings > Tools > AI Assistant > Model Context Protocol (MCP)**, click **Add**, and paste:
```json theme={null}
{
"mcpServers": {
"helium": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://app.tryhelium.com/api/mcp"]
}
}
}
```
JetBrains AI Assistant cannot complete an OAuth sign-in on its own, so the `mcp-remote` bridge opens the Helium sign-in in your browser and keeps the connection. Requires Node.js.
Any other client that supports remote MCP servers with OAuth works the same way: give it the URL above and sign in when prompted.
## Permissions
During sign-in your client asks for these permissions. Each one unlocks the tools listed in [Available tools](#available-tools) below; a tool you have not granted returns a message telling you which permission to add.
| Permission | What it unlocks |
| ------------------ | ------------------------------------------------------------------- |
| `org:read` | Who you are and which organization is connected |
| `paywalls:read` | Paywalls, their versions, source code, previews and publish history |
| `workflows:read` | Workflows, triggers and targeting |
| `experiments:read` | Experiments and their variants |
Metrics and documentation tools are available to every connection. Everything is scoped to the organization you picked at sign-in. To switch organizations, disconnect the server in your client and connect again.
## Available tools
### Paywalls
| Tool | What it returns |
| ------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `list_paywalls` | Your paywalls, newest first, with optional name search |
| `get_paywall` | One paywall with the products, product buckets and live version it serves, plus its web paywall link |
| `get_paywall_preview` | The preview image of a paywall |
| `list_paywall_versions` | The saved versions of a paywall |
| `list_paywall_publish_history` | Publish and rollback history of a paywall |
| `get_paywall_version_code` | The source of one file in a paywall version |
| `diff_paywall_versions` | A diff of one file between two versions |
| `find_paywall_usage` | Every workflow, trigger and experiment a paywall appears in |
### Workflows, triggers and targeting
| Tool | What it returns |
| ---------------- | ------------------------------------------------------------- |
| `list_workflows` | Your workflows with their triggers and targeting rules |
| `get_workflow` | One workflow with each rule's audience, paywall or experiment |
| `list_triggers` | Your triggers and the workflow each one belongs to |
| `list_targeting` | Your saved targeting definitions |
| `get_targeting` | One targeting definition and its rule |
### Experiments
| Tool | What it returns |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `list_experiments` | Your experiments with their variants and traffic split |
| `get_experiment` | One experiment and the workflows and triggers it is wired into |
| `get_experiment_metrics` | Per-variant time series for one experiment page metric, with the All Sources / Paywall Variant toggle and filters |
| `check_experiment_health` | Whether an experiment can declare a winner: verdict, significance per decision metric, users needed per arm and days remaining |
### Metrics
| Tool | What it returns |
| ---------------- | ----------------------------------------------------------------- |
| `list_metrics` | Every dashboard metric with a one-line description |
| `explain_metric` | The full definition of one metric and the filters it accepts |
| `get_metrics` | Time series for up to a few metrics with the dashboard's defaults |
### Docs and account
| Tool | What it returns |
| ------------- | --------------------------------------------- |
| `search_docs` | Search results from docs.tryhelium.com |
| `get_doc` | A full documentation page |
| `whoami` | The signed-in user and connected organization |
## Good to know
A few things the tools report exactly as the SDK sees them, which can differ from what a paywall's edit view shows:
* A paywall's products, product buckets and content come from its **published version**. A paywall that has never been published is not served anywhere.
* A trigger with no workflow attached shows no paywall.
* Targeting rules are evaluated top to bottom and the first matching rule wins. A rule can point at a paywall, at an experiment, or at "show no paywall".
* An experiment serves only while a workflow rule points at it and it is not stopped. Its start and end dates are labels, not a schedule.
* Variant percentages that do not add up to 100 are scaled so that they do.
## Security
* The server is read-only and can only see the organization you connected. There are no tools that change anything.
* Your client stores a token for the connection; Helium does not store it. Remove the connection from your client to stop access.
* Treat tool output like any other data you paste into a chat: paywall names and descriptions are your own content, but review before sharing a transcript outside your team.
## Troubleshooting
* **"This tool needs the paywalls:read permission"**: reconnect the server and approve all permissions on the consent screen.
* **Sign-in loops or 401 errors**: remove the server from your client and add it again; the stored token may belong to an organization you no longer have access to.
* **Tool results are cut off**: list tools are paged. Ask for the next page or narrow with a name search.
* **Your client does not show the newest tools**: claude.ai and Claude Code cache the tool list. Refresh the Helium server in your client's connector settings, then start a new chat.
# Introduction
Source: https://docs.tryhelium.com/getting-started/introduction
Learn about Helium
## App monetization for the AI era
Helium helps you launch paywall experiments in minutes to boost in-app revenue.
## What is Helium?
Edit paywalls instantly without app updates
Run experiments to find what converts best
Let AI continuously improve your conversion
Track performance with detailed insights
## How It Works
Migrate your existing paywall or create a new one
Use the Helium visual editor to change UI, add pages, modify products, add discounting, and more
Run personalization experiments to automatically discover what drives conversion and incremental revenue
# Quickstart
Source: https://docs.tryhelium.com/getting-started/quickstart
Get set up with a Helium paywall and incorporate it into your app
This quickstart shows you how to set up, preview, and go live with an AI-editable Helium paywall in your app.
## Create your account on Helium
Create your Helium account at [app.tryhelium.com](https://app.tryhelium.com).
This will take you to the **Helium dashboard**, where you can create paywalls, manage api keys and integrations, run A/B tests, view analytics, and more.
## Connect your products
Visit [Integrations → Products](https://app.tryhelium.com/integrations/products) and sync your products from the App Store and/or Google Play store.
## Create a new paywall
1. Click on the **Paywalls** tab in the sidebar (or go [here](https://app.tryhelium.com/paywalls)).
2. Click **New Paywall** and complete that flow
You can swap products in and out as desired. Just remember to have your products linked in the paywall before going live!
## Create a workflow and trigger(s)
Once you have published a pawyall, visit [Workflows](https://app.tryhelium.com/workflows) to create your first workflow. Once created, add a **trigger**.
**Triggers** are what you use in your app to determine which workflow (and its associated paywall/s) to show to a user.
A **workflow** is a configuration of trigger(s) that should point to the same paywall, or that share the same targeting or experimentation setup.
## Install and set up the SDK
Choose your mobile platform and follow the integration quickstart:
Swift/SwiftUI/UIKit
Kotlin/Jetpack Compose
React Native/Expo
Flutter integration
## Set up revenue reporting
Follow our [guide on revenue reporting](https://docs.tryhelium.com/guides/revenue-reporting) to connect Helium to your app's revenue events via webhooks.
Revenue reporting enables Helium to provide detailed analytics about your app's monetization. It's a *must* to get the most out of Helium!
## Set up fallbacks to guarantee 100% paywall uptime for your users
Follow our [guide on fallbacks](https://docs.tryhelium.com/guides/fallback-bundle) to guarantee that you'll have 100% uptime of your paywalls even if a user doesn't have a network connection.
## Pre-launch and go live!
Before going live, here's a quick checklist. As always, reach out via your Helium slack channel or to [founders@tryhelium.com](mailto:founders@tryhelium.com) if you'd like any help with testing.
### Testing Checklist
* Paywall created with products
* Workflow + trigger created
* SDK set up and paywall displays
* Purchases can be completed from your paywall(s)
* [Fallbacks](/guides/fallback-bundle) are configured
🎉 Congratulations! You now have a live Helium paywall and are ready to take your app's monetization to new heights!
# Handling Custom Paywall Events
Source: https://docs.tryhelium.com/guides/custom-events
Send custom events from your paywall to your app to trigger actions beyond purchases.
## Overview
Helium's event handling system has native handlers for most common paywall events (purchases, navigation, etc), but in some cases you might want to have custom interactions on your paywall that aren't handled by default. For example, you might want to have a toggle that when selected sends a trial notification reminder using your own notification system. Helium lets you do this with **Custom Events**.
## Custom Events
A custom event is fired from the paywall and handled from the SDK. You can have as many different types of custom events as you want within a single paywall. Custom events have a few properties:
**Action Name** - (String) The name of the event. For example, "did\_switch\_trial\_toggle"
**Params** - (Dictionary of string keys -> any type of value) Dictionary of arbitrary parameters associated with the action. The keys have to be strings, the values can be numbers, boolean, strings, arrays, or dictionaries themselves. For example,
```
{
"is_toggle_selected": False,
"iso_timestamp": 13041241255
}
```
We recommend asking the editor to separate out different interactions into different actions. For example, if you have
* a trial notification toggle
* a little survey that you ask the user within the paywall
* a custom dismiss button
Separating these out into different actions with their own parameters will make it easier to handle from the SDK side.
There are two steps to setting up custom event handling with Helium:
## Editor: Add a button that emits a custom action to your paywall
In the Helium paywall editor, you can add a button or interaction that sends a custom action by just asking the chat. For example,
* Add a trial reminder toggle using custom actions.
* Add a button that triggers requesting permissions for location if a user press an opt in button.
* Make it so that if the user selects the Watch Ads button, the editor sends a custom action to handle so that the SDK can handle that case on purchase pressed "
Some tips:
* You can verify the editor is using a custom action using the **Actions Panel**.
* Feel free to tell the editor the specific action name and params you want it to use!
* Reach out to support with your use case - happy to set this up for you and tell you what the corresponding SDK code should look like to handle the new event!
## SDK: Add an onCustomPaywallAction handler
Then, within your SDK you can use Paywall Event Handlers with the `onCustomPaywallAction` event. Visit our SDK's quickstart pages for more info on getting set up with the SDK.
```swift theme={null}
Helium.shared.presentPaywall(
trigger: "your_trigger",
eventHandlers: PaywallEventHandlers()
.onCustomPaywallAction { event in
if event.actionName == "your_action_name",
let value = event.params["key1"] as? String {
// Handle your custom action
// E.g., mark that we should send a notification on purchase
print("Received: \(value)")
}
}
)
```
The `CustomPaywallActionEvent` contains:
* `actionName`: The action name
* `params`: Dictionary of parameters
* `triggerName`: The trigger that opened this paywall
* `paywallName`: The name of the paywall
* `timestamp`: ISO timestamp for when the event occurred
```kotlin theme={null}
import com.tryhelium.paywall.core.event.PaywallEventHandlers
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.contentOrNull
Helium.shared.addPaywallEventListener(
lifecycleOwner = this, // Your activity or fragment
listener = PaywallEventHandlers(
onCustomPaywallAction = { event ->
if (event.actionName == "your_action_name") {
// Extract a string value from the params
val value = event.params["key1"]?.jsonPrimitive?.contentOrNull
// Handle your custom action
Log.d("PaywallEvents", "Received custom action with value: $value")
}
}
)
)
```
The `CustomPaywallAction` event contains:
* `actionName`: The name of the event.
* `params`: A `JsonObject` of arbitrary parameters associated with the action.
* `triggerName`: The trigger that opened this paywall.
* `paywallName`: The name of the paywall.
* `timestamp`: Unix timestamp for when the event occurred.
```typescript theme={null}
import { presentUpsell } from 'expo-helium';
// or: import { presentUpsell } from '@tryheliumai/paywall-sdk-react-native';
presentUpsell({
triggerName: 'your_trigger',
eventHandlers: {
onCustomPaywallAction: (event) => {
if (event.actionName === 'your_action_name') {
const value = event.params.key1;
// Handle your custom action
console.log('Received:', value);
}
},
},
});
```
The `CustomPaywallActionEvent` contains:
* `actionName`: The action identifier from JavaScript
* `params`: Object of parameters from JavaScript
* `triggerName`: The trigger that opened this paywall
* `paywallName`: The paywall template name
# Fallback Paywalls
Source: https://docs.tryhelium.com/guides/fallback-bundle
How to download fallback paywalls from the dashboard and include it in your app.
## What are "fallbacks"?
The Helium SDK does its best to fetch the latest paywalls to display in your app. But if for some reason a paywall is not available — perhaps if an app user temporarily did not have a data connection — Helium will attempt to show a "fallback".
You can download copies of your actual Helium paywalls and include that in your app to use in the rare case that a Helium paywall is not ready for display.
## Download Fallbacks
Go to the [Workflows](https://app.tryhelium.com/workflows) page in your dashboard and find the **Download Fallbacks** button:
This will open a dialog and allow you to select which paywall/s to use as fallback. Most of the time you only need to set Default Fallbacks.
After selection, click **Download** and your browser should download a file named **helium-fallbacks.json**.
Whenever you update paywalls consider whether you want to repeat this process to update your fallbacks.
## Add fallbacks to your Helium SDK integration
1. Integrate the SDK with the [iOS Quickstart](/sdk/quickstart-ios) if you haven't done so already.
2. Drag your downloaded file into the Project navigator of your Xcode project.
3. You should see a dialog with **Action** and **Targets** fields. For **Action**, select your preference between the move and copy options. For **Targets** ensure that your app's main target is selected.
4. That's it! The SDK automatically detects any file with the name `helium-fallbacks.json`.
To use a custom file name, set it before calling `initialize()`:
```swift theme={null}
Helium.config.customFallbacksURL = Bundle.main.url(
forResource: "fallback-bundle-xxxx-xx-xx",
withExtension: "json"
)
```
1. Integrate the SDK with the [Android Quickstart](/sdk/quickstart-android) if you haven't done so already.
2. In your app's `src/main` directory, create an `assets` folder if one doesn't already exist.
3. Drag your downloaded bundle into the `assets` folder.
4. That's it! The SDK automatically detects `helium-fallbacks.json` in your assets.
To use a custom file name, set it before calling `initialize()`:
```kotlin theme={null}
Helium.config.customFallbacksFileName = "fallback-bundle-xxxx-xx-xx.json"
```
1. Integrate the SDK with the [React Native Quickstart](/sdk/quickstart-react-native) if you haven't done so already.
2. Drag your downloaded bundle into your React Native project where desired.
3. Pass in to `Helium.initialize`
```typescript theme={null}
const fallbackBundle = require('./assets/fallback-bundle-xxxx-xx-xx.json');
initialize({
apiKey: apiKey,
// ... other parameters ...
fallbackBundle: fallbackBundle,
});
```
Make sure to use the correct path and filename in your `require` call!
1. Integrate the SDK with the [Flutter Quickstart](/sdk/quickstart-flutter) if you haven't done so already.
2. Drag your downloaded bundle into your Flutter project where desired.
3. In your `pubspec.yaml` add the new asset:
```yaml theme={null}
assets:
- .env
- fallback-bundle-xxxx-xx-xx.json # add this line
```
4. Pass in to `Helium.initialize`
```dart theme={null}
heliumFlutter.initialize(
// ... other parameters ...
fallbackBundleAssetPath: "fallback-bundle-xxxx-xx-xx.json"
```
Make sure to use the correct filename in both `pubspec.yaml` and the call to `initialize`!
## Testing Fallbacks
Look at your debug log output when running a debug build of your app to see if fallbacks are successfully loaded.
To see a fallback paywall, you can run your app in airplane mode or use a non-existent trigger when presenting a paywall.
## How to Monitor and Minimize Fallback Rate
You can check your app's fallback rate by going to your [Metrics page](https://app.tryhelium.com/metrics) and clicking on the "Fallback Rate" tab under Monitoring. This rate shows the percentage of paywall opens that use a fallback paywall. If you notice your fallback rate is higher than expected, here are some tips on minimizing it:
* **Move your initialize() call earlier in your app's lifecycle.** We recommend doing so right when your app is launched.
* **Set a higher loading budget.** If your paywall is still downloading when presented, Helium will show a loading state. The default loading budget of 7 seconds is generally plenty of time to ensure your paywall will show before fallback is used. But if you have overridden the budget to be a lower value, consider increasing it.
* **Reduce the size of image assets in your paywall.** We recommend keeping each paywall to be under about 3-5 MB in total size. If you're adding high resolution imagery to your paywall (which is fine!) we recommend converting images to .webp format - e.g. with an online converter like [https://cloudconvert.com/webp-converter](https://cloudconvert.com/webp-converter).
If you still see a rate higher than expected, please get in touch!
## Fallback Causes
### Paywall is still downloading
This is the most common reason.
Helium fetches your latest paywalls when the Helium SDK is initialized. If a user is on a very slow connection or has no connection at all, a fallback will be shown.
### Paywalls failed to download
Similarly, if user has no connection or a very limited connection, the paywall retrieval may fail entirely. Or in the unlikely event Helium servers are temporarily down.
### No products associated with paywall for platform
Can occur when a paywall is downloaded but does not have products properly linked for the platform (iOS/Android) it is served to.
### Other Causes
The above are the most likely causes, but a fallback can also occur if paywall html is malformed or from any other unexpected Helium bug.
# Google Play Data Safety (Android)
Source: https://docs.tryhelium.com/guides/google-play-data-safety
What to declare in your Play Console Data safety section when your app includes the Helium Android SDK.
Google Play requires every developer to declare how their app collects and handles user data, **including data handled by any third-party SDK they bundle**. This page tells you which boxes to tick in the Play Console **Data safety** section when your app includes the Helium Android SDK.
Google is explicit that the declaration is yours to make: "You alone are responsible for making complete and accurate declarations in your app's store listing on Google Play." This page covers the Helium SDK only. Your final declaration must also account for your own app's data collection and every other SDK you ship.
## Data collection and security
Three questions gate the rest of the form.
### Does your app collect or share any of the required user data types?
Select **Yes**. Helium collects a device identifier, purchase history, and paywall interaction data.
### Is all of the user data collected by your app encrypted in transit?
Select **Yes** as far as Helium is concerned. All Helium network traffic uses HTTPS.
Google only lets you select "Yes" if encryption in transit applies to **all** user data your app and every one of its SDKs transmits off the device. Confirm this holds across your whole app before selecting it.
### Do you provide a way for users to request that their data is deleted?
This one depends on your own support process, not on Helium. If you select **Yes**, make sure users have a route to reach you, such as an in-app link, a contact form, or a support email.
On the client, `Helium.resetHelium()` clears the locally persisted Helium identity and user traits. For deletion of data already sent to Helium, contact Helium support.
## Data types
✅ Required when using Helium
💡 May be required, depending on how you configure the SDK
❌ Not collected by Helium
| Category | Data type | Required? |
| ------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Location | Approximate location, Precise location | ❌ Helium reads no location APIs and declares no location permission. It collects device locale, currency, and Play store country, which Google does not classify as location. |
| Personal info | User IDs | 💡 Only if you set `Helium.identity.userId`, `revenueCatAppUserId`, or `thirdPartyAnalyticsAnonymousId` to a value that relates to an identifiable person, such as your own account ID. If you set none of them, Helium falls back to its generated installation ID, which is covered by Device or other IDs below. |
| Personal info | Name, Email address, Phone number, Address | 💡 Only if you put them into `HeliumUserTraits`, or if you use the optional Stripe module. |
| Financial info | **Purchase history** | ✅ Helium collects product IDs, Google Play order IDs and purchase tokens, purchase dates, quantity, and store country. |
| Financial info | User payment info | 💡 Optional Stripe module only. |
| Financial info | Credit score, Other financial info | ❌ |
| Health and fitness | All types | ❌ |
| Messages | All types | ❌ |
| Photos and videos | All types | ❌ |
| Audio files | All types | ❌ |
| Files and docs | Files and docs | ❌ |
| Calendar | Calendar events | ❌ |
| Contacts | Contacts | ❌ |
| App activity | **App interactions** | ✅ Paywall opens, closes and dismissals, button presses, product selection, purchase flow steps, and experiment allocation. |
| App activity | Other actions | 💡 Only if your paywalls emit custom actions carrying additional user activity. |
| App activity | In-app search history, Installed apps, Other user-generated content | ❌ |
| Web browsing | Web browsing history | ❌ Paywalls render inside a WebView that loads Helium-controlled content. Helium does not read browsing history. |
| App info and performance | **Diagnostics** | ✅ Paywall load and render timings, configuration download outcomes, retry counts, and error descriptions. |
| App info and performance | Crash logs, Other app performance data | ❌ Helium bundles no crash reporter. |
| Device or other IDs | **Device or other IDs** | ✅ The Android ID (`Settings.Secure.ANDROID_ID`), plus Helium's own generated installation, session, and initialization IDs. |
Helium declares more than a billing-only SDK would, because paywall interaction data is what powers experiments and optimization rather than being a side effect. The four bolded rows above are the ones you always need.
## Answering the per-data-type questions
For each data type you select, Google asks four follow-up questions. All four required Helium data types (Purchase history, App interactions, Diagnostics, and Device or other IDs) get the same answers. Any 💡 rows that apply to your setup generally follow the same pattern, though review the purposes for your own case.
### Is this data collected, shared, or both?
Select **Collected** if Helium processes this data solely on your behalf and under your instructions. That is Google's test for a service provider, and sharing does not apply.
Select **Collected** and **Shared**, and declare the purposes of that sharing, if either of the following is true:
* Helium uses your app's data for its own cross-customer optimization or experiment purposes rather than solely on your instructions.
* You forward Helium events to third parties that are not acting as your service providers.
Google is explicit that "if an SDK provider is building advertising profiles across multiple customers based on your app data, that would not be considered 'service provider' activity." Confirm which of the above describes your contract and configuration before answering.
### Is this data processed ephemerally?
Select **No**. Helium retains this data to power paywall experimentation and reporting.
### Is this data required for your app, or can users choose whether it's collected?
Select **Data collection is required** by default. The Helium SDK does not expose a per-user opt-out, so once you initialize it, collection begins.
You can declare this data **optional** instead if you gate `Helium.initialize()` behind your own consent prompt and only call it for users who agree. Google's bar is that all users, regardless of device or region, must be able to opt in or out.
A consent gate answers this Play Data safety question only. It does not by itself establish compliance with GDPR, CCPA, or other privacy regimes, which impose separate requirements such as having a lawful basis for processing, giving notice at collection, and handling consumer rights requests. Review the jurisdictions you operate in with privacy counsel.
### Why is this user data collected?
Select **App functionality** and **Analytics**.
Also select **Personalization** if you use audience targeting or experiment rules that tailor which paywall a user sees based on traits you provide.
## What Helium does not collect
Worth knowing, because it keeps several categories off your form entirely:
* **No advertising identifier.** Helium does not read the Google Advertising ID or App Set ID, and declares no `com.google.android.gms.permission.AD_ID` permission.
* **No location.** No location APIs, no location permissions.
* **No contacts, photos, videos, audio, files, calendar, or messages.**
* **No crash logs.** Helium bundles no crash reporting library.
* **No health or fitness data.**
The SDK contributes only these permissions to your merged manifest:
| Permission | Why |
| ----------------------------------------- | --------------------------------------------------- |
| `android.permission.INTERNET` | Fetching paywall configuration and bundles |
| `android.permission.ACCESS_NETWORK_STATE` | Detecting connectivity before network calls |
| `com.android.vending.BILLING` | Google Play Billing, for purchases and entitlements |
## Optional modules
Two Helium modules change your answers if you use them.
The `revenue-cat` module sets Helium's persistent installation ID as the RevenueCat app user ID, and by default also as a `helium_hpid` subscriber attribute. You can turn the attribute off by constructing the delegate with `allowHeliumUserAttribute = false`.
Using this module means RevenueCat's own data collection applies to your app as well. Refer to [RevenueCat's Data Safety guidance](https://www.revenuecat.com/docs/platform-resources/google-platform-resources/google-plays-data-safety) alongside this page.
## What's next
How `Helium.identity.userId` and `HeliumUserTraits` work, which drive the conditional rows above
Play Console documentation for the Data safety section
# Helium Events
Source: https://docs.tryhelium.com/guides/helium-events
Reference for the full list of helium events
## Overview
Helium emits paywall and lifecycle events related to paywalls, purchasing, configuration, and experimentation.
This page acts as a reference to what events are available in Helium, and when each one is fired. Visit each SDK's quickstart page to see how to listen for and handle events.
## Event Types
```swift theme={null}
// Base protocol for all events
protocol HeliumEvent {
var eventName: String { get }
var timestamp: Date { get }
func toDictionary() -> [String: Any]
}
// Events with paywall context
protocol PaywallContextEvent: HeliumEvent {
var triggerName: String { get }
var paywallName: String { get }
var isSecondTry: Bool { get }
}
// Product-related events
protocol ProductEvent: PaywallContextEvent {
var productId: String { get }
}
// All Event Types:
// Lifecycle: PaywallOpenEvent, PaywallCloseEvent, PaywallDismissedEvent,
// PaywallSkippedEvent, PaywallButtonPressedEvent
// Purchase: ProductSelectedEvent, PurchasePressedEvent, PurchaseSucceededEvent,
// PurchaseCancelledEvent, PurchaseFailedEvent, PurchaseRestoredEvent,
// PurchaseRestoreFailedEvent, PurchasePendingEvent,
// PurchaseAlreadyEntitledEvent
// System: InitializeStartEvent, PaywallsDownloadSuccessEvent,
// PaywallsDownloadErrorEvent, PaywallWebViewRenderedEvent
// Experiment: UserAllocatedEvent
```
```kotlin theme={null}
// Base sealed class for all events
sealed class HeliumEvent(
open val timestamp: Long
)
// Events with paywall context
sealed class PaywallContextEvent(
override val timestamp: Long,
open val triggerName: String,
open val paywallName: String,
open val isSecondTry: Boolean,
) : HeliumEvent(timestamp)
// Product-related events
sealed class ProductEvent(
override val timestamp: Long,
override val triggerName: String,
override val paywallName: String,
override val isSecondTry: Boolean,
open val productId: String,
) : PaywallContextEvent(timestamp, triggerName, paywallName, isSecondTry)
// All Event Classes:
// Lifecycle: PaywallOpen, PaywallClose, PaywallDismissed, PaywallOpenFailed,
// PaywallSkipped, PaywallButtonPressed
// Purchase: ProductSelected, PurchasedPressed, PurchaseSucceeded, PurchaseCancelled,
// PurchaseFailed, PurchaseRestored, PurchaseRestoreFailed, PurchasePending
// System: InitializeStart, InitializeCalled, PaywallsDownloadSuccess,
// PaywallsDownloadError, PaywallWebViewRendered
```
```typescript theme={null}
export type HeliumPaywallEvent = {
type: 'paywallOpen' | 'paywallClose' | 'paywallDismissed' |
'paywallOpenFailed' | 'paywallSkipped' | 'paywallButtonPressed' |
'productSelected' | 'purchasePressed' | 'purchaseSucceeded' |
'purchaseCancelled' | 'purchaseFailed' | 'purchaseRestored' |
'purchaseAlreadyEntitled' |
'purchaseRestoreFailed' | 'purchasePending' | 'initializeCalled' |
'paywallsDownloadSuccess' | 'paywallsDownloadError' |
'paywallWebViewRendered' | 'userAllocated';
triggerName?: string;
paywallName?: string;
productId?: string;
buttonName?: string;
configId?: string;
numAttempts?: number;
downloadTimeTakenMS?: number;
webviewRenderTimeTakenMS?: number;
imagesDownloadTimeTakenMS?: number;
fontsDownloadTimeTakenMS?: number;
bundleDownloadTimeMS?: number;
dismissAll?: boolean;
isSecondTry?: boolean;
error?: string;
experimentInfo?: ExperimentInfo;
timestamp?: number;
}
```
```dart theme={null}
// Base event structure
abstract class HeliumEvent {
String get eventName;
DateTime get timestamp;
Map toMap();
}
// Events with paywall context
abstract class PaywallContextEvent extends HeliumEvent {
String get triggerName;
String get paywallName;
bool get isSecondTry;
}
// Product-related events
abstract class ProductEvent extends PaywallContextEvent {
String get productId;
}
// All Event Types:
// Lifecycle: PaywallOpenEvent, PaywallCloseEvent, PaywallDismissedEvent,
// PaywallOpenFailedEvent, PaywallSkippedEvent, PaywallButtonPressedEvent
// Purchase: ProductSelectedEvent, PurchasePressedEvent, PurchaseSucceededEvent,
// PurchaseCancelledEvent, PurchaseFailedEvent, PurchaseRestoredEvent,
// PurchaseRestoreFailedEvent, PurchasePendingEvent
// System: InitializeStartEvent, PaywallsDownloadSuccessEvent,
// PaywallsDownloadErrorEvent, PaywallWebViewRenderedEvent
```
## Available Events
### Paywall and Purchase Events
| Event Name | Description | Parameters |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **paywallOpen** | Fires when a paywall is displayed | `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `viewType` (String) - presented, triggered, or embedded `loadTimeTakenMS` (Number, optional) - Loading time in milliseconds `loadingBudgetMS` (Number, optional) - Loading budget in milliseconds `timestamp` (Number) - When event occurred |
| **paywallClose** | Fires when a paywall is closed (removed from view hierarchy). **Fires on both explicit dismissal AND purchase success** | `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `timestamp` (Number) - When event occurred |
| **paywallDismissed** | Fires when paywall is explicitly dismissed by user | `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `dismissAll` (Boolean) - Whether entire paywall stack dismissed `timestamp` (Number) - When event occurred |
| **paywallSkipped** | Fires if paywall display is skipped due to targeting, workflow configuration, or existing entitlement | `triggerName` (String) - Associated trigger `timestamp` (Number) - When event occurred |
| **paywallButtonPressed** | Fires when a non-purchase button is pressed | `buttonName` (String) - Button identifier `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `timestamp` (Number) - When event occurred |
| **paywallWebViewRendered** | Fires when the paywall content has finished rendering | `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `webviewRenderTimeTakenMS` (Number, optional) - Time to render in milliseconds `timestamp` (Number) - When event occurred |
| **productSelected** | Fires when a user selects a product on the paywall | `productId` (String) - Product identifier `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `timestamp` (Number) - When event occurred |
| **purchasePressed** | Fires when a purchase button is pressed in the paywall | `productId` (String) - Product being purchased `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `timestamp` (Number) - When event occurred |
| **purchaseSucceeded** | Fires when a purchase is successfully completed. | `productId` (String) - Product identifier `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `storeKitTransactionId` (String, optional) - Transaction ID `storeKitOriginalTransactionId` (String, optional) - Original transaction ID `skPostPurchaseTxnTimeMS` (Number, optional) - Post-purchase transaction time `timestamp` (Number) - When event occurred |
| **purchaseCancelled** | Fires when the purchase process is cancelled by the user | `productId` (String) - Product that was cancelled `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `timestamp` (Number) - When event occurred |
| **purchaseFailed** | Fires when the purchase fails for any reason | `productId` (String) - Product that failed `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `error` (String, optional) - Error message `timestamp` (Number) - When event occurred |
| **purchaseRestored** | Fires when a previous purchase is successfully restored | `productId` (String) - Restored product ID `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `timestamp` (Number) - When event occurred |
| **purchaseAlreadyEntitled** | Fires when a purchase attempt resolves to an entitlement the user already had (iOS only). Not a new revenue event | `productId` (String) - Product the user already owns `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `storeKitTransactionId` (String, optional) - Existing transaction ID `storeKitOriginalTransactionId` (String, optional) - Original transaction ID `timestamp` (Number) - When event occurred |
| **purchaseRestoreFailed** | Fires when an attempt to restore purchases fails | `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `timestamp` (Number) - When event occurred |
| **purchasePending** | Fires when purchase is in a pending state (e.g. waiting for parental approval) | `productId` (String) - Pending product `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `timestamp` (Number) - When event occurred |
### System Events
| Event Name | Description | Parameters |
| --------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **initializeCalled** | Fires when SDK initialization is called | `timestamp` (Number) - When event occurred |
| **paywallsDownloadSuccess** | Fires when Helium paywalls downloaded and initialized successfully | `downloadTimeTakenMS` (Number, optional) - Config download time in milliseconds `imagesDownloadTimeTakenMS` (Number, optional) - Images download time in milliseconds `fontsDownloadTimeTakenMS` (Number, optional) - Fonts download time in milliseconds `bundleDownloadTimeMS` (Number, optional) - Bundle download time in milliseconds `numAttempts` (Number, optional) - Number of attempts made `timestamp` (Number) - When event occurred |
| **paywallsDownloadError** | Fires when paywalls download failed | `error` (String) - Error description `numAttempts` (Number, optional) - Number of attempts made `timestamp` (Number) - When event occurred |
| **paywallOpenFailed** | Fires if a paywall fails to open and fallback fails to show | `triggerName` (String) - Associated trigger `paywallName` (String) - Paywall name `isSecondTry` (Boolean) - True if paywall is "second try" flow `error` (String) - Error message describing failure `timestamp` (Number) - When event occurred |
### Experiment Events
| Event Name | Description | Parameters |
| ----------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **userAllocated** | Fires once per user upon the first experimental paywall open. | `type` (String) - "userAllocated" `experimentInfo` (Object) - Complete experiment allocation data (see below) `timestamp` (Number) - When allocation occurred |
### Accessing ExperimentInfo from Events
All paywall events (any event that implements `PaywallContextEvent`) provide a `getExperimentInfo()` method to access experiment allocation data on-demand:
```swift theme={null}
func onPaywallEvent(_ event: HeliumEvent) {
// Access experiment info from any paywall event
if let openEvent = event as? PaywallOpenEvent,
let experimentInfo = openEvent.getEventExperimentInfo() {
print("Variant: \(experimentInfo.chosenVariantDetails?.allocationIndex ?? 0)")
print("Experiment: \(experimentInfo.experimentName ?? "none")")
}
// Track which variant led to purchase
if let purchaseEvent = event as? PurchaseSucceededEvent,
let experimentInfo = purchaseEvent.getEventExperimentInfo() {
analytics.track("conversion", variant: experimentInfo.chosenVariantDetails?.allocationIndex)
}
}
```
You can also access experiment info directly by trigger:
```swift theme={null}
if let experimentInfo = Helium.shared.getExperimentInfoForTrigger("onboarding") {
print("Variant: \(experimentInfo.chosenVariantDetails?.allocationIndex ?? 0)")
}
```
### Fetch experiments for this user
You can also fetch all experiments for the current user with a few methods available on the `Helium` top level object. Make sure to call these methods after `Helium.shared.initialize()`
```swift theme={null}
// Gets all experiments for which this user is eligible
func allExperiments() -> [ExperimentInfo]?
// Gets all experiments for which this user is eligible AND has already been enrolled
func enrolledExperiments() -> [ExperimentInfo]?
```
### ExperimentInfo Structure
The `experimentInfo` object in the `userAllocated` event contains experiment allocation data. This event fires once per session when a user is assigned to an experiment variant, which happens on the **first** trigger the user sees.
#### Core Fields
| Field | Type | Description |
| ----------------- | ------ | ----------------------------------------------------------- |
| `enrolledTrigger` | String | The trigger where this user was enrolled in the experiment. |
#### Experiment Details
| Field | Type | Description |
| ---------------- | ------ | ------------------------------------------------------------------------------------------------ |
| `experimentName` | String | Name of the experiment |
| `experimentId` | String | Unique identifier for the experiment. You can look this experiment id up in the Helium dashboard |
| `experimentType` | String | Type of experiment ("A/B/n test") |
| `startDate` | String | When the experiment started (ISO8601 string) |
| `endDate` | String | When the experiment is scheduled to end (ISO8601 string) |
#### Targeting Details
| Field | Type | Description |
| -------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `audienceId` | String | Audience ID that user matched (for lookup in Helium) |
| `audienceData` | String | Stringified JSON of the logged targeting logic. Useful for reference, though we recommend using the audience defined in the Helium dashboard. |
#### Variant Details
| Field | Type | Description |
| -------------------------------------- | ------- | --------------------------------------------------------------------------------------------- |
| `chosenVariantDetails` | Object | Details about the assigned variant |
| `chosenVariantDetails.allocationName` | String | Variant name. This will be the paywall name for paywall A/B/n tests. More names to come soon! |
| `chosenVariantDetails.allocationId` | String | Variant UUID |
| `chosenVariantDetails.allocationIndex` | Integer | Variant number (0-indexed) |
#### Hash Details
These fields provide information about how the user was deterministically assigned to a variant.
| Field | Type | Description |
| -------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `hashDetails` | Object | Hash bucketing information |
| `hashDetails.hashedUserIdBucket1To100` | Integer | User's hash bucket (1-100) - deterministic value for consistent variant assignment |
| `hashDetails.hashedUserId` | String | The actual user ID that was hashed (either the userId or heliumPersistentId depending on hashMethod) |
| `hashDetails.hashMethod` | String | Hash method used: - `"HASH_USER_ID"`: we're hashing the user id passed into Helium.initialize() - `"HASH_HELIUM_PERSISTENT_ID"`: We're hashing a persistent device ID (does NOT persist across re-installs) |
## Logged Events
All Helium events (see the full list [above](#available-events)) get logged to your analytics backend automatically with automatic event forwarding, along with the parameters listed there. Each event's parameters will usually show up as event properties.
Event names and properties might show up as underscored/camelcase/JSON, depending on the platform.
In addition, all lifecycle, paywall, downloadSuccess, and experimental events will include **contextual** and **custom** traits as event parameters:
### Contextual Traits
Helium automatically and securely collects device and software attributes:
* **Locale**: country, currency, currency symbol, language, preferred languages, time zone, decimal separator, uses metric system
* **Screen**: brightness, bounds, native bounds, scale, native scale, dark mode enabled
* **Device**: device identifier, orientation, system name, system version, device model, interface idiom, storage capacity
* **Application**: version, build number, app name, Helium SDK version, environment
### Custom Traits
Custom user traits included in the `Helium.initialize()` call also get sent as part of all events. If you pass custom user traits as part of a paywall presentation method call, these will **override** any custom traits of the same name passed in during `initialize()`.
# How Helium Works
Source: https://docs.tryhelium.com/guides/how-does-helium-work
A general overview of how the Helium platform works.
# Useful Terms
A visual display that contains subscription options and in-app purchases. Design paywalls using AI in your Helium dashboard.
A configuration that defines which paywall to show to a user, with options for targeting and experiments.
An identifier used in your mobile app integration to determine which workflow to use when displaying a paywall. One workflow can have multiple triggers.
An "audience" is a segment of users that you define based on dimensions such as language, age, etc. Workflows can show different paywalls to different audiences.
Your mobile app integration can define custom "user traits" for use in targeting.
An experiment is an A/B/n test that allows you to compare paywall A versus paywall B for a specific audience.
# How do paywalls get displayed on my app?
This is an overview of how paywalls are actually served to users.
```mermaid theme={null}
flowchart LR
A([📱 App Initializes Helium SDK]) --> Server
subgraph Server ["☁️ Helium Server"]
direction TB
B[For Each Workflow] --> C[Apply Targeting]
C --> D[Resolve Experiment]
D --> P[Determine Paywall for Each Trigger]
end
Server --> E([📲 Helium SDK Prepares Paywalls])
E --> F([🎬 App Calls 'Present Paywall' for Trigger])
F --> G([✅ Mobile SDK Displays Paywall])
style Server fill:#e8f0fe,stroke:#4a90d9,stroke-width:1.5px
style A fill:#d4edda,stroke:#28a745,color:#000
style E fill:#d4edda,stroke:#28a745,color:#000
style F fill:#fff3cd,stroke:#ffc107,color:#000
style G fill:#cce5ff,stroke:#004085,color:#000
```
# Paddle Onboarding Guide
Source: https://docs.tryhelium.com/guides/paddle-onboarding-guide
This guide walks you through integrating Paddle with Helium so you can sell web-based subscriptions alongside (or instead of) your iOS in-app purchases.
## What You'll Accomplish
By the end of this guide, you'll have:
* A Paddle account connected to Helium, with products synced and ready to add to paywalls.
* Paywalls that offer Paddle products — either on their own or alongside iOS IAPs.
* A hosted web checkout flow that lets users purchase in their browser and deep-links them back into your app.
* Entitlement tracking across Paddle, the Helium SDK, and (optionally) RevenueCat.
* Free trials with built-in abuse protection (one trial per customer, per lifetime).
* A "Manage Subscription" flow that routes users to Paddle's customer portal.
## Prerequisites
Before you start, make sure you have:
* A Paddle account. If you don't have one, start with Paddle's [setup checklist](https://developer.paddle.com/build/onboarding/set-up-checklist#sign-up) and [onboarding overview](https://developer.paddle.com/build/onboarding/overview).
* The Helium SDK (iOS native, React Native/Expo, or Flutter) already integrated in your app.
* A deep link scheme configured in your app — users need a way back into your app after purchase.
* Admin access to your Helium dashboard.
* (Optional) A RevenueCat account, if you want Paddle purchases to reflect in RevenueCat entitlements.
## Implementation Steps
### 1. Set up your Paddle account
1. Sign up for Paddle and complete their onboarding checklist (KYC, banking, tax info). Live payments are blocked until this is done.
2. Add your products and prices in the Paddle dashboard. Paddle's [product creation guide](https://developer.paddle.com/build/products/create-products-prices) is the canonical reference.
### 2. Connect Paddle to Helium
**Create an API key in Paddle** with the following permissions:
| Permission | Purpose |
| :------------------------------- | :----------------------------------------------------------------------------- |
| Adjustments `READ` | View refunds and chargebacks |
| Client-side tokens `WRITE` | Programmatically create client-side tokens |
| Customer portal sessions` WRITE` | Generate customer portal links so end users can manage or cancel subscriptions |
| Customers` WRITE` | Create customers for end users |
| Discounts` READ` | Read discount info for paywalls |
| Notification settings` WRITE` | Auto-configure webhooks |
| Payment methods` READ` | List saved payment methods |
| Prices` WRITE` | Create and sync prices |
| Products` WRITE` | Create and sync products |
| Subscriptions` WRITE` | Manage subscription lifecycle |
| Transactions` WRITE` | Create transactions for purchases |
**Add the API key to Helium.** In the Helium dashboard, go to *Settings → Integrations → Paddle* and paste your key.
**Configure the webhook.** Helium will show you a webhook URL — create a matching webhook in Paddle, then copy the webhook's signing secret back into Helium. This powers revenue reporting and entitlement updates.
### 3. Default payment link
1. Go to [Paddle website approval](https://vendors.paddle.com/request-domain-approval) and add a new domain.
2. Go to [Paddle Checkout Settings](https://vendors.paddle.com/checkout-settings) and find the **Default payment link** section. Paste in the same domain you just added for **Paddle website approval**.
3. At the top of the same page, make sure **Apple Pay** has been selected as a **Payment Method**
### 4. Sync your Paddle products
In the Helium dashboard, click **Sync Products**. Your Paddle products and prices will appear alongside your iOS products and can be added to paywalls.
### 5. (Optional) Add free trials
To offer a free trial on a Paddle product, configure it on the product in Helium. A few things to know:
* **One trial per customer, ever.** Paddle doesn't natively prevent trial abuse (a user could start a trial, cancel, and start another), so Helium enforces this at the entitlement layer. Once a customer has held a trial — even if they cancel — they won't be offered another.
* **A payment method is required up front.** Payment is deferred, not skipped.
### 6. Build your paywalls
Paddle checkout requires **two paywalls**:
1. **The original paywall**, shown in-app, containing your Paddle products (and optionally iOS products).
2. **A hosted web paywall**, which runs the actual Paddle checkout in the user's browser on `clickthrough.to`.
When the user taps a CTA on the original paywall, they're kicked out to the hosted web paywall, where they complete the purchase.
To set this up:
1. Create the original paywall and add Paddle products to it.
2. Create a hosted web paywall (the editor will prompt you).
3. Link the two. The original paywall can't be published without a linked hosted web paywall.
The hosted web paywall doesn't have to show the same products as the original — you can use it to customize the checkout experience (e.g., upsell a different plan at purchase time). Hosted web paywalls can't be added to workflows, experiments, or targeting rules; they exist only to back a Paddle paywall.
### 7. SDK integration
**Enable external web checkout before initializing Helium:**
```swift theme={null}
Helium.config.enableExternalWebCheckout(
redirectURL: "yourapp://openapp",
paymentProcessors: .paddle
)
```
The `redirectURL` is set once, globally. Checkout redirects back to it whether the purchase succeeds, is canceled, or fails. Make sure it's registered as a deep link in your app (via scheme or universal link).
On older SDK versions that don't have `redirectURL`, use the equivalent two-URL form with the same URL for both: `enableExternalWebCheckout(successURL:cancelURL:paymentProcessors:)`. That form still works, but is deprecated.
**Set your user ID on Helium** before the user reaches a paywall. If you must set user ID after showing a paywall, set the `allowWebCheckoutWithoutUserId` option to true.
**Handle the checkout return.** Whether checkout succeeds, is canceled, or fails, the user is deep-linked back to your app via the redirect URL. For a smoother handoff, call `Helium.shared.handleURL(url)` from your `onOpenURL` / `application(_:open:)` handler.
**Add a "Manage Subscription" button** so users can cancel or update payment methods via Paddle's customer portal:
```swift theme={null}
if await Helium.entitlements.hasActivePaddleEntitlement() {
// show the manage subscription button
}
// when the button is tapped:
do {
let url = try await Helium.shared.createPaddlePortalSession()
UIApplication.shared.open(url)
} catch {
print("Failed to open customer portal: \\(error)")
}
```
The portal link opens `paddle.net`, where the user enters the email they purchased with, receives a magic link, and lands in the customer portal. This is Paddle's default, recommended flow: a secure email verification step in front of the portal.
**Want a pre-authenticated portal link?** If you'd rather skip the paddle.net email verification step and drop users straight into the customer portal, generate the link from your backend instead. Look up the user's Paddle customer ID with Helium:
```swift theme={null}
let paddleCustomerId = await Helium.shared.getPaddleCustomerId()
```
Send that ID to your server and follow [Paddle's guide for generating pre-authenticated customer portal links](https://developer.paddle.com/build/customers/integrate-customer-portal/).
Available in `expo-helium` 3.4.13+ and `@tryheliumai/paywall-sdk-react-native` 3.1.2+.
**Enable external web checkout before initializing Helium:**
```tsx theme={null}
enableExternalWebCheckout({
redirectURL: "yourapp://openapp",
paymentProcessors: ["paddle"],
});
```
The `redirectURL` is set once, globally. Checkout redirects back to it whether the purchase succeeds, is canceled, or fails. Make sure it's registered as a deep link in your app (via scheme or universal link).
On older SDK versions that don't have `redirectURL`, pass the equivalent `successURL` and `cancelURL` parameters with the same URL for both. That form still works, but is deprecated.
**Set your user ID on Helium** before the user reaches a paywall. If you must set user ID after showing a paywall, make sure to call `setAllowWebCheckoutWithoutUserId(true)`.
**Handle the checkout return.** Whether checkout succeeds, is canceled, or fails, the user is deep-linked back to your app via the redirect URL. For a smoother handoff, call `heliumHandleURL(url)` from your deep link handling:
```tsx theme={null}
useEffect(() => {
const sub = Linking.addEventListener('url', (event) => heliumHandleURL(event.url));
void Linking.getInitialURL().then(heliumHandleURL);
return () => sub.remove();
}, []);
```
You do not need to do anything with the links other than forward them to `heliumHandleURL` like shown above. If you are using **Expo Router** you may need to [override the default behavior](https://docs.expo.dev/router/advanced/native-intent/#sending-navigation-events-to-third-party-services).
**Add a "Manage Subscription" button** so users can cancel or update payment methods via Paddle's customer portal:
```tsx theme={null}
if (await hasActivePaddleEntitlement()) {
// show the manage subscription button
}
// when the button is tapped:
const url = await createPaddlePortalSession();
if (url) {
await Linking.openURL(url);
}
```
The portal link opens `paddle.net`, where the user enters the email they purchased with, receives a magic link, and lands in the customer portal. This is Paddle's default, recommended flow: a secure email verification step in front of the portal.
**Want a pre-authenticated portal link?** If you'd rather skip the paddle.net email verification step and drop users straight into the customer portal, generate the link from your backend instead. Look up the user's Paddle customer ID with Helium:
```tsx theme={null}
const paddleCustomerId = await getPaddleCustomerId();
```
Send that ID to your server and follow [Paddle's guide for generating pre-authenticated customer portal links](https://developer.paddle.com/build/customers/integrate-customer-portal/).
**Enable external web checkout before initializing Helium:**
```dart theme={null}
await HeliumFlutter().enableExternalWebCheckout(
redirectURL: "yourapp://openapp",
paymentProcessors: {HeliumWebCheckoutProcessor.paddle},
);
```
The `redirectURL` is set once, globally. Checkout redirects back to it whether the purchase succeeds, is canceled, or fails.
On older SDK versions that don't have `redirectURL`, pass the equivalent `successURL` and `cancelURL` parameters with the same URL for both. That form still works, but is deprecated.
**Register the deep link.** Both URLs must be registered as deep links in your app. For a custom scheme, add a `CFBundleURLTypes` entry to `ios/Runner/Info.plist`:
```xml theme={null}
CFBundleURLTypesCFBundleURLNamecom.yourcompany.yourappCFBundleURLSchemesyourapp
```
To use a universal link (`https://...`) instead, set up [associated domains](https://docs.flutter.dev/ui/navigation/deep-linking) and use that URL as the `redirectURL`.
**Set your user ID on Helium** before the user reaches a paywall. If you must set user ID after showing a paywall, make sure to call `setAllowWebCheckoutWithoutUserId(true)`.
**Handle the checkout return.** Whether checkout succeeds, is canceled, or fails, the user is deep-linked back to your app via the redirect URL. Forward incoming links to Helium with `handleURL(url)` — it's safe to forward every link, since URLs that aren't Helium's are ignored. If you don't already handle deep links, add [app\_links](https://pub.dev/packages/app_links) with `flutter pub add app_links`:
```dart theme={null}
import 'package:app_links/app_links.dart';
_linkSubscription = AppLinks().uriLinkStream.listen((uri) {
HeliumFlutter().handleURL(uri.toString());
});
```
`uriLinkStream` also emits the link that launched the app, so cold starts are covered — there is no separate initial-link step. If you use `app_links`, set `FlutterDeepLinkingEnabled` to `false` in `Info.plist` so Flutter's built-in deep-link handling doesn't consume the link first.
**Add a "Manage Subscription" button** so users can cancel or update payment methods via Paddle's customer portal. `launchUrl` comes from the [url\_launcher](https://pub.dev/packages/url_launcher) package:
```dart theme={null}
final hasPaddle = await HeliumFlutter().hasActivePaddleEntitlement();
if (hasPaddle) {
// show the manage subscription button
}
// when the button is tapped:
final urlString = await HeliumFlutter().createPaddlePortalSession();
if (urlString != null) {
await launchUrl(Uri.parse(urlString));
}
```
The portal link opens `paddle.net`, where the user enters the email they purchased with, receives a magic link, and lands in the customer portal. This is Paddle's default, recommended flow: a secure email verification step in front of the portal.
**Want a pre-authenticated portal link?** If you'd rather skip the paddle.net email verification step and drop users straight into the customer portal, generate the link from your backend instead. Look up the user's Paddle customer ID with Helium's `getPaddleCustomerId()`, send it to your server, and follow [Paddle's guide for generating pre-authenticated customer portal links](https://developer.paddle.com/build/customers/integrate-customer-portal/).
### 8. (Optional) Connect RevenueCat
If you use RevenueCat for entitlement management:
1. Create a Paddle web configuration in the [RevenueCat dashboard](https://app.revenuecat.com).
1. Select your project > **Web** > Add web provider > Paddle
2. Create a new [Paddle API Key](https://vendors.paddle.com/authentication-v2) for RevenueCat. **See permissions below**
3. Paste in the new Paddle API key
4. Purchase tracking should be **Automatic**
5. Under **App user ID matching** select **Use a custom field** with `rc_user_id` as the field key
2. Go to **Product catalog** > **Products** and Import your Paddle products into RevenueCat.
3. Attach those products to desired entitlement(s).
* Addresses `READ`
* Adjustments - `READ`
* Businesses - `READ`
* Client-side tokens - `WRITE`
* Customer portal sessions - `WRITE`
* Customers - `READ`
* Discounts - `READ`
* Notification settings - `WRITE`
* Notifications - `READ`
* Payment methods - `READ`
* Prices - `READ`
* Products - `READ`
* Subscriptions - `READ`
* Transactions - `WRITE`
RevenueCat entitlements take a couple seconds to reflect Paddle purchases. For instant post-purchase UX, check Helium entitlements in addition to RevenueCat — the SDK will push RevenueCat to sync as quickly as possible, but Helium's own entitlement is always the fastest source of truth.
* Make sure you have done the above steps and include all of your Paddle products in your RevenueCat entitlements.
* Find your Paddle -> RevenueCat webhook at [https://vendors.paddle.com/notifications-v2](https://vendors.paddle.com/notifications-v2) and click on it to see that notifications are being sent successfully. If you see errors, you may need to adjust the permissions on the Paddle API Key you provided to RevenueCat.
* If you still have issues, delete the existing Paddle web provider in RevenueCat, revoke the Paddle API Key you created for RevenueCat (leave the Helium one), and run through the above steps again.
## Testing
To test without affecting production traffic, create a test targeting group that includes:
* Paddle eligible users
* `sandbox` or `debug` environment
Target the App2Web paywall to that group, then run through these scenarios in Paddle's sandbox before going live:
1. **Happy path purchase.** Open an App2Web paywall in-app, tap a web product, and confirm the app correctly opens the browser. Complete the standard purchase flow, then confirm you're deep-linked back with an active entitlement. Dismiss the paywall before purchasing as well, and confirm the app returns to the expected screen.
2. **Entitlement sync.** After purchase, confirm `hasActivePaddleEntitlement()` returns `true` immediately, and that RevenueCat (if connected) updates within a few seconds.
3. **Cancel flow.** Open the customer portal from your app, cancel a subscription, and confirm the entitlement expires at period end.
4. **Canceled checkout.** Back out of the checkout page without paying and confirm you're deep-linked back to the app with no entitlement granted and no success state shown.
5. **Webhook delivery.** In the Paddle dashboard, confirm webhooks are reaching Helium with 2xx responses.
6. **Pre-login purchase (if applicable).** If your App2Web paywall can be shown before you set a durable `user_id`, make a purchase before setting it. Set the durable user ID afterwards and confirm the entitlement attaches correctly.
**App2Web paywall not appearing?** Eligibility is checked server-side against the device's locale country, App Store storefront country, and IP location — all three must be US, and there is no client-side error when a device is filtered out. If you're testing from the US, disable VPNs. If you're testing from outside the US: set your device region to the United States, sign in with a sandbox Apple account on the US storefront, and connect through a US VPN so your IP resolves to the US.
## Appendix
### How entitlements work
Helium maps your `user_id` to Paddle's `customer_id`. Paddle deduplicates customers strictly by email, so two Paddle customers can't share an address. If the user isn't logged in at purchase time, we use the `helium_persistent_id` as a stand-in and reconcile once a real user ID is set.
On successful purchase, the SDK's entitlement cache updates immediately. The Helium backend processes Paddle's webhook to update the customer mapping and forward events to your analytics/revenue pipeline. Entitlements are also checked server-side (which ultimately reflects Paddle's transaction state), so they stay consistent across devices.
### Why two paywalls?
The original in-app paywall presents your Paddle offerings. The hosted web paywall — served from `clickthrough.to` — runs the actual Paddle checkout, which has to happen in a browser. The two can be customized independently and don't need to show the same products.
### Known limitations and gotchas
* **Availability:** Paddle App2Web is currently iOS-only and supported for US storefronts only. iOS-only applies across all SDKs — in React Native and Flutter apps, the web-checkout APIs are safe no-ops on Android, so shared code can ship unchanged and Android users keep your regular in-app purchase flow.
* **California compliance** for subscription cancellation: guidance coming soon.
* **Publish states:** both the original paywall and its linked hosted web paywall must be published before the flow works end-to-end.
* **Localizations:** hosted web paywalls currently support one language.
* **Testing:** [Paywall Previews](/guides/paywall-previews) (triple tap) test the App2Web flow end to end, including a simulated purchase and a real purchase when the device is eligible. Before launch, also open the flow through a real trigger — previews bypass targeting and do not exercise paywall traits or post-purchase handlers.
## Before you go live
Run through the [App2Web go-live checklist](/guides/app2web-go-live-checklist) before sending traffic to this flow.
# Paywall Traits
Source: https://docs.tryhelium.com/guides/paywall-traits
iOS 4.4.2+Android 4.4.2+React Native 3.4.3+Flutter 3.3.3+
Paywall traits are values passed into a paywall upon presentation, allowing your paywall to display or behave differently depending on those values.
This must be configured in two places:
1. Your paywall via the paywall editor
2. Your app by passing in values to the Helium mobile SDK
## Paywall Editor
Open the paywall editor from the [Helium dashboard](https://app.tryhelium.com/paywalls). Type something like
```text theme={null}
use a paywall trait with key "name" to display a customized greeting
```
You MUST provide a **default value** or behavior for each trait, in case the app does not pass in a value.
## Pass in from the SDK
By default, user traits will automatically be included as custom paywall traits in all of your paywalls. (Just make sure your SDK version is higher than what's listed at the top of this page.)
To pass in additional paywall traits during paywall presentation, use the `customPaywallTraits` parameter.
```swift iOS theme={null}
Helium.shared.presentPaywall(
trigger: "your-trigger",
config: PaywallPresentationConfig(
customPaywallTraits: HeliumUserTraits(["name": nameValue])
)
) { paywallNotShownReason in }
```
```kotlin Android theme={null}
import com.tryhelium.paywall.core.HeliumUserTraits.Companion.create
Helium.presentPaywall(
"your-trigger",
config = PaywallPresentationConfig(
customPaywallTraits = mapOf(
"name" to nameValue,
).create()
)
)
```
```tsx React Native theme={null}
presentUpsell({
triggerName: "your-trigger",
customPaywallTraits: { name: nameValue },
});
```
```dart Flutter theme={null}
await HeliumFlutter().presentUpsell(
context: context,
trigger: "your-trigger",
customPaywallTraits: { "name": nameValue },
);
```
## Targeting and Metrics Considerations
If you want to evaluate the effects of different traits in your paywall, utilize user traits in your SDK integration.
Helium does not currently support the use of`customPaywallTraits` (passed during paywall presentation) as metrics filters.
User traits can also be used for targeting.
### **What if I set both?**
If the same key is present in both user traits and `customPaywallTraits`, the paywall will see the value from `customPaywallTraits`.
Metrics still only sees the value from user traits.
## Coming Soon
We are working on adding default traits you can use without passing in from SDK.
# Set Up Revenue Tracking
Source: https://docs.tryhelium.com/guides/revenue-reporting
Set up webhooks to track your app's revenue and subscription events. This is important if you want Helium to display subscription, revenue, and other metrics!
Go to [Integrations > Revenue Tracking](https://app.tryhelium.com/integrations/revenue) to configure webhooks.
## Configure iOS
Choose ONE of the following 3 options:
### 1) App Store Server Notifications
This option is recommended if you are using StoreKit to handle your purchases.
Send purchase events direct from Apple to Helium.
### 2) RevenueCat Webhooks
This option is recommended if you are using RevenueCat to handle your purchases.
Use RevenueCat webhooks to send revenue events to Helium. Follow the instructions shown under **RevenueCat Webhooks**.
### 3) Forward App Store Server Notifications
This option is recommended if you are using StoreKit to handle your purchases but want to consume App Store Server Notifications on your own server and forward those events to Helium.
Grab the **Webhook URL** from the **App Store Server Notifications** section and use that value in your forwarding code.
In your existing App Store Server Notifications handler, add a snippet that forwards the payload to Helium **unchanged**. Place it as early as possible — after you've parsed the incoming body, but before any type filtering or early returns — so that Helium receives *every* notification type, not just the subset your own handler acts on.
**Don't couple your response to the forward.** Await the forward and wrap it in a `try/catch` that only logs on failure. It should never throw or change the status you return to Apple — otherwise a hiccup forwarding to Helium would make Apple retry the whole notification and re-run your own handling.
Forwarding is just a POST — send the request body to your Helium Webhook URL with `Content-Type: application/json`. The examples below cover a few common stacks, but any language works. Reach out to Helium support if you have any questions.
```javascript Node/Express theme={null}
// Inside your existing app.post(...) ASSN handler, near the top:
try {
await axios.post(
'your-helium-webhook-url-here',
req.body,
{
headers: { 'Content-Type': 'application/json' },
timeout: 5000
}
);
} catch (error) {
console.error('Failed to forward webhook to Helium:', error.message);
}
```
```javascript Next.js theme={null}
// Inside your existing POST handler, right after you read the body:
// App Router: const body = await request.json();
// Pages Router: const body = req.body;
try {
const response = await fetch(
'your-helium-webhook-url-here',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000)
}
);
if (!response.ok) {
console.error(`Failed to forward webhook to Helium: status ${response.status}`);
}
} catch (error) {
console.error('Failed to forward webhook to Helium:', error);
}
```
```go Go theme={null}
// Inside your existing handler, after reading the request body into `body`
// (a []byte holding the raw JSON payload), near the top:
func() {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"your-helium-webhook-url-here", bytes.NewReader(body))
if err != nil {
log.Printf("Failed to forward webhook to Helium: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("Failed to forward webhook to Helium: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
log.Printf("Failed to forward webhook to Helium: status %d", resp.StatusCode)
}
}()
```
```python Python w/ Flask theme={null}
# Inside your existing route, near the top:
try:
response = requests.post(
'your-helium-webhook-url-here',
json=request.json,
headers={'Content-Type': 'application/json'},
timeout=5
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f'Failed to forward webhook to Helium: {e}')
```
```python Python w/ FastAPI theme={null}
# Inside your existing handler, right after you read the body:
# body = await request.json()
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
'your-helium-webhook-url-here',
json=body,
headers={'Content-Type': 'application/json'}
)
response.raise_for_status()
except httpx.HTTPError as e:
print(f'Failed to forward webhook to Helium: {e}')
```
## Configure Android
Choose ONE of the following 2 options:
### 1) Google Play RTDN
This option is recommended if you are NOT using RevenueCat to handle your purchases.
Utilize Google Play Real-Time Developer Notifications to send revenue events to Helium.
### 2) RevenueCat Webhooks
This option is recommended if you are using RevenueCat to handle your purchases.
Use RevenueCat webhooks to send revenue events to Helium. Follow the instructions shown under **RevenueCat Webhooks**.
# StoreKit Testing (iOS)
Source: https://docs.tryhelium.com/guides/storekit-testing
This guide covers how to set up StoreKit Testing in Xcode to test paywall displays across different regions and configurations.
## Overview
StoreKit Testing allows you to test in-app purchases and paywall presentations locally without connecting to App Store servers or making real purchases.
To test your actual products across different regions, we suggest creating a sandbox test user:
* Create one at [https://appstoreconnect.apple.com/access/users/sandbox](https://appstoreconnect.apple.com/access/users/sandbox)
* Change the **Country or Region** field for the sandbox user
* On your test device, go to Settings -> Developer and sign in to the sandbox account
* Simulator *can* work… but sometimes has issues so physical device is recommended
* Run app from Xcode and the selected storefront country should be reflected
* More details on [this page](https://developer.apple.com/documentation/storekit/testing-in-app-purchases-with-sandbox#Sign-in-to-your-Sandbox-Apple-Account-for-a-development-signed-app)
## Prerequisites
* Xcode 14 or later (for synced StoreKit configuration files)
* iOS 15.0+ target
## Setting Up StoreKit Testing
### 1. Create a StoreKit Configuration File
1. In Xcode, go to **File → New → File from Template...**
2. Search for and select **StoreKit Configuration File** as the file template, then click **Next**
3. Optional — check the "Sync this file with an app in App Store Connect" box to automatically configure the file with products you have already set up in App Store Connect
4. Complete the file creation flow, saving the file in the top-level folder of your project. You don't need to add it to a target.
### 2. Configure your StoreKit Configuration File
**Note —** If you’ve chosen to sync your configuration file with the App Store Connect, your file will already be populated with your products. To re-sync with App Store Connect, click on the file and then Sync at the bottom-left. If you want to manually edit it, use **Editor > Convert to Local StoreKit Configuration**.
Use the **+** at the bottom-left to add a product.
Complete the form that pops up. Once created, you can edit all details of a product such as price, product ID, etc.
## Run Your App With StoreKit Testing
You can either edit an existing scheme or create a new one (recommended):
1. In Xcode, go to **Product → Scheme → Manage Schemes...**
2. Select your primary scheme and find the option to **Duplicate**:
3. Edit the new scheme's name so you know it's for StoreKit testing.
4. Select the **Run** action.
5. Click the **Options** tab.
6. For the **StoreKit Configuration** option, select a configuration file and click Close.
7. Run your project (make sure your active scheme is the one with the StoreKit Configuration option set!)
An Xcode project can contain multiple StoreKit configuration files but only one can be active at a time. When it’s active, your app gets StoreKit data as defined in your configuration file instead of accessing App Store Connect or the sandbox server.
## Regional Testing
Go to your StoreKit config file and under **Configuration Settings**, change **Default Storefront**:
Then run your app again, and you should be able to see pricing displays for the selected region. Note that the **price numerical value will still be what is specified in the config file**, but you should see a change in currency symbols.
Make sure you explicitly save (cmd+s) the StoreKit Configuration File before running the app — changes made here won’t get picked up otherwise!
## Additional Resources
### Test Purchases
For more complex purchase flows with StoreKit Testing, refer to Apple's documentation on the [transaction manager](https://developer.apple.com/documentation/xcode/testing-in-app-purchases-with-storekit-transaction-manager-in-code).
### Other Ways to Test Across Regions
1. Use Xcode and your sandbox account. You can [change the region of your sandbox account](https://developer.apple.com/documentation/storekit/testing-in-app-purchases-with-sandbox#Test-In-App-Purchases-for-all-regions). Make sure you have disabled StoreKit Testing for this.
2. On a TestFlight build, you can [use your sandbox account as well](https://developer.apple.com/documentation/storekit/testing-in-app-purchases-with-sandbox#Sign-in-to-your-Sandbox-Apple-Account-for-a-TestFlight-app), but only do this on a dedicated testing device!
### Automated Testing
You can utilize StoreKit Testing in your continuous integration tests. See Apple's documentation [here](https://developer.apple.com/documentation/StoreKitTest) and a more detailed testing overview [here](https://developer.apple.com/documentation/StoreKit/testing-at-all-stages-of-development-with-xcode-and-the-sandbox).
# Integrate with Your 3rd Party Analytics System
Source: https://docs.tryhelium.com/guides/third-party-analytics
Understanding Helium's event forwarding and analytics integration.
## Configure Automatic Event Forwarding
Don't see your analytics provider here, or have a custom setup you want us to send events to? Reach out to [founders@tryhelium.com](mailto:founders@tryhelium.com) — we might support it under the hood.
Visit the [Integrations page](https://app.tryhelium.com/integrations/analytics) of the Helium dashboard and follow the instructions there to configure your analytics provider so Helium will automatically forward [Helium Events](https://docs.tryhelium.com/guides/helium-events).
## What events and properties are logged?
All Helium events are automatically forwarded to your analytics backend.
Helium forward events in batches. It may take several minutes for live events to populate in your analytics provider.
### Event Names
| Event Name | Description |
| ------------------------------- | -------------------------------------------------------------- |
| `initialization_started` | SDK initialization began |
| `initialization_succeeded` | SDK successfully initialized and downloaded paywalls |
| `initialization_failed` | SDK failed to initialize or download paywalls |
| `experience_opened` | A paywall was presented to the user |
| `experience_open_failed` | A paywall failed to open |
| `experience_view_rendered` | The paywall view finished rendering |
| `experience_view_render_failed` | The paywall view failed to render |
| `experience_skipped` | A paywall was skipped (e.g. targeting rules excluded the user) |
| `experience_dismissed` | The user dismissed the paywall |
| `experience_view_closed` | The paywall view was closed |
| `purchase_started` | The user tapped a purchase button |
| `purchase_pending` | A purchase is pending (e.g. awaiting parental approval) |
| `purchase_succeeded` | A purchase completed successfully |
| `purchase_failed` | A purchase failed |
| `purchase_abandoned` | The user cancelled the purchase flow |
| `entitlement_restored` | A previous purchase was successfully restored |
| `entitlement_restore_failed` | A purchase restore attempt failed |
| `user_experiment_allocated` | The user was allocated to an experiment variant |
| `custom_event` | A custom analytics event fired from the paywall |
### Field Names
Below is a preview of the key fields that are sent with Helium events to your analytics system. For the complete list of all fields, download the CSV file.
Download a JSON file containing all event field names, types, and descriptions
#### Common Event Fields
| Field Name | Description |
| ------------------------------ | -------------------------------------------- |
| `helium_event_timestamp` | Timestamp when the event occurred |
| `helium_event_name` | Event type name (see Event Name Mapping tab) |
| `experiment_id` | Experiment unique identifier |
| `experiment_chosen_variant_id` | Unique identifier of the assigned variant |
| `geo_country_name` | Country name derived from IP address |
| `locale_country` | Device country code |
| `trigger_name` | Name of the trigger that opened the paywall |
| `paywall_id` | Paywall unique identifier |
| `paywall_name` | Human-readable paywall name |
## Linking & Identifying Users
See the **Identifying Users** section of the SDK quickstart guides. Let Helium know the user ID of your users so forwarded analytics events will link up.
If your user starts out anonymous, be sure to set the **third party analytics anonymous ID** on the Helium SDK with the anonymous/device ID from your analytics provider SDK. This will allow events from an anonymous user to link up as well.
# Ways to Show a Paywall
Source: https://docs.tryhelium.com/guides/ways-to-show-paywall
Advanced and alternative ways to present Helium paywalls beyond the standard present method.
## Overview
Most of the time you'll want to use `presentPaywall()` (or `presentUpsell()` depending on your SDK version) to show paywalls. This approach is covered in each platform's quickstart guide (see navigation menu on the left). But if you need more control over how and where paywalls appear in your app, there are a few other options.
## iOS
#### Embedded View
```swift theme={null}
HeliumPaywall(
trigger: "onboarding"
) { paywallNotShownReason in
Text("Paywall failed to show")
}
```
The iOS SDK will attempt to handle dismissal for embedded views but may not be able to depending on where you place it. You can dismissal yourself with PaywallEventHandlers.
```swift theme={null}
HeliumPaywall(
trigger: "post_onboarding",
eventHandlers: PaywallEventHandlers()
.onOpen { event in
print("open for trigger \(event.triggerName)")
}
.onClose { event in
print("close for trigger \(event.triggerName)")
}
.onDismissed { event in
// handle user dismissal here (i.e. navigate to another screen)
print("dismiss for trigger \(event.triggerName)")
}
.onPurchaseSucceeded { event in
print("purchase made for trigger \(event.triggerName)")
}
.onCustomPaywallAction { event in
print("Custom action: \(event.actionName) with params: \(event.params)")
}
) { paywallNotShownReason in
Text("Paywall failed to show")
}
```
`customPaywallTraits` are captured when the paywall **displays** and then stay fixed for that
presentation. In SwiftUI this is handled for you as long as the traits come from state that
re-renders the view. This is the normal way you'd pass dynamic values into a view.
When you host `HeliumPaywall` in UIKit with a `UIHostingController`, UIKit won't refresh the view
for you. If you build the hosting controller ahead of time and update traits before showing it,
drive the view from an `ObservableObject` so the latest traits reach the paywall before display:
```swift theme={null}
final class PaywallTraitsStore: ObservableObject {
@Published var traits: HeliumUserTraits?
}
struct PaywallHost: View {
@ObservedObject var store: PaywallTraitsStore
let trigger: String
var body: some View {
HeliumPaywall(
trigger: trigger,
config: PaywallPresentationConfig(customPaywallTraits: store.traits)
) { paywallNotShownReason in
Text("Paywall failed to show")
}
}
}
// UIKit
let store = PaywallTraitsStore()
store.traits = initialTraits
let vc = UIHostingController(rootView: PaywallHost(store: store, trigger: "onboarding"))
// Later, before the paywall is displayed:
store.traits = freshTraits // reaches the paywall via the refreshed config
```
This applies before the paywall is on screen. Once displayed, traits are locked for that
presentation. Updating the store afterward won't change an already-shown paywall.
#### SwiftUI ViewModifier
Attach a paywall to any SwiftUI view using the **`.heliumPaywall`** view modifier:
```swift theme={null}
struct ContentView: View {
@State var isPresented: Bool = false
var body: some View {
VStack {
Button {
isPresented = true
} label: {
Text("Show paywall")
}
}.heliumPaywall(
isPresented: $isPresented,
trigger: "post_onboarding") { paywallNotShownReason in
Text("Paywall failed to show")
}
}
}
```
If you are using UIKit, you can use a UIHostingController.
## Android
#### Embedded Paywall (Compose)
Make sure to add `com.tryhelium.paywall:compose-ui:4.0.0` to your dependencies.
```kotlin theme={null}
HeliumPaywall(
trigger = "onboarding",
onPaywallNotShown = { reason ->
Text("Paywall failed to show")
}
)
```
## React Native
React Native (both `expo-helium` and `@tryheliumai/paywall-sdk-react-native`) currently supports full-screen presentation via `presentUpsell()` only — embedded paywall views are not yet available.
## Flutter
#### Widget Integration
Flutter embedded widget currently only works for iOS. Android support is in the works.
Embed a paywall directly in your widget tree using **`HeliumFlutter.getUpsellWidget`**:
```dart theme={null}
class ExamplePageWithEmbeddedPaywall extends StatelessWidget {
const ExamplePageWithEmbeddedPaywall({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: HeliumPaywall(
trigger: "insert-trigger-here",
paywallNotShownBuilder: (context, reason) => Text("Paywall failed to show"),
),
);
}
}
```
You will have to handle your own dismissal. You can do so by passing in PaywallEventHandlers and using the `onDismissed` handler.
# Android: Migrating from v0 to v4
Source: https://docs.tryhelium.com/migrations/android-0-to-4
Migration guide for Helium Android SDK v4
**Breaking Changes in v4:**
* `presentUpsell()` renamed to `presentPaywall()` with new signature
* `hideUpsell()` renamed to `hidePaywall()`
* `hideAllUpsells()` renamed to `hideAllPaywalls()`
* Initialize parameters moved to `Helium.identity` and `Helium.config` namespaces
* `HeliumFallbackConfig` removed - use `Helium.config` properties instead
**What's New:**
* Simplified `initialize()` - just pass context and API key
* Automatic `PlayStorePaywallDelegate` setup if not explicitly configured
* Cleaner namespaced APIs: `Helium.identity`, `Helium.config`
* New `PaywallPresentationConfig` consolidates presentation options
* New `PaywallNotShownReason` for handling paywall failures
## Installation Update
Update your version constraint to `4.x`:
```kotlin theme={null}
// app/build.gradle.kts
dependencies {
implementation("com.tryhelium.paywall:core:4.0.0")
}
```
```kotlin theme={null}
// app/build.gradle.kts
dependencies {
implementation("com.tryhelium.paywall:compose-ui:4.0.0")
}
```
```kotlin theme={null}
// app/build.gradle.kts
dependencies {
implementation("com.tryhelium.paywall:revenue-cat:4.0.0")
}
```
Then sync your Gradle files.
## Initialize Migration
The biggest change in v4 is the simplified initialization. Configuration that was previously passed to `initialize()` is now set on dedicated namespaces beforehand.
**v0 (many parameters):**
```kotlin theme={null}
val heliumPaywallDelegate = PlayStorePaywallDelegate(this)
Helium.initialize(
context = this,
apiKey = "...",
heliumPaywallDelegate = heliumPaywallDelegate,
customUserId = "user-123",
customUserTraits = HeliumUserTraits(
traits = mapOf("plan" to HeliumUserTraitsArgument.StringParam("free"))
),
fallbackConfig = HeliumFallbackConfig.withFallbackBundle(
fallbackBundleName = "fallback.json"
),
environment = HeliumEnvironment.PRODUCTION,
)
```
**v4 (simplified):**
```kotlin theme={null}
// Set up user identification
Helium.identity.userId = "user-123"
Helium.identity.setUserTraits(HeliumUserTraits(
traits = mapOf("plan" to HeliumUserTraitsArgument.StringParam("free"))
))
// Set up configuration (if needed)
Helium.config.customFallbacksFileName = "fallback.json"
// Note: PlayStorePaywallDelegate is auto-created if not set
// Helium.config.heliumPaywallDelegate = PlayStorePaywallDelegate(this)
// Initialize
Helium.initialize(
context = this,
apiKey = "...",
environment = HeliumEnvironment.PRODUCTION,
)
```
Set identity and configuration values **before** calling `initialize()` for best results.
See the [quickstart](/sdk/quickstart-android) for more details.
## presentUpsell to presentPaywall
The method has been renamed and the signature has changed significantly. Parameters are now consolidated into `PaywallPresentationConfig`.
```kotlin v0 theme={null}
Helium.presentUpsell(
context = this,
trigger = "premium",
activityContext = activity,
dontShowIfAlreadyEntitled = true,
eventListener = myListener
)
```
```kotlin v4 theme={null}
Helium.presentPaywall(
trigger = "premium",
config = PaywallPresentationConfig(
fromActivityContext = activity,
dontShowIfAlreadyEntitled = true
),
eventListener = myListener,
onPaywallNotShown = { reason ->
// handle paywall not shown
}
)
```
### Full Example with All Parameters
**v4:**
```kotlin theme={null}
Helium.presentPaywall(
trigger = "premium",
config = PaywallPresentationConfig(
fromActivityContext = activity, // optional - SDK auto-tracks activities
customPaywallTraits = HeliumUserTraits(...),
dontShowIfAlreadyEntitled = true,
disableSystemBackNavigation = false,
presentationStyle = HeliumPresentationStyle.SLIDE_UP,
fullscreen = false
),
onEntitled = {
// User is now entitled (purchased or restored)
},
eventListener = myListener,
onPaywallNotShown = { reason ->
when (reason) {
is PaywallNotShownReason.TargetingHoldout -> {
// User is in holdout group
}
is PaywallNotShownReason.AlreadyEntitled -> {
// User already has access
}
is PaywallNotShownReason.Error -> {
// Handle error, check reason.unavailableReason
}
}
}
)
```
## Method Renames
| v0 | v4 |
| ------------------------- | -------------------------- |
| `Helium.presentUpsell()` | `Helium.presentPaywall()` |
| `Helium.hideUpsell()` | `Helium.hidePaywall()` |
| `Helium.hideAllUpsells()` | `Helium.hideAllPaywalls()` |
## API Namespace Migration
### Helium.identity
| v0 | v4 |
| ----------------------------------- | ------------------------------------ |
| `initialize(customUserId: ...)` | `Helium.identity.userId = ...` |
| `initialize(customUserTraits: ...)` | `Helium.identity.setUserTraits(...)` |
### Helium.config
| v0 | v4 |
| ---------------------------------------- | ------------------------------------------- |
| `initialize(heliumPaywallDelegate: ...)` | `Helium.config.heliumPaywallDelegate = ...` |
| `initialize(fallbackConfig: ...)` | See Fallback Changes below |
## Fallback Changes
v4 removes `HeliumFallbackConfig` and simplifies fallback configuration.
### What's Changed
* **No more `HeliumFallbackConfig` class** - Use `Helium.config` properties directly
* **No more fallback views** - Use fallback bundles exclusively
* **No more per-trigger config** - Use global settings only
* **Automatic detection** - SDK auto-detects `helium-fallbacks.json` in your assets
### Migration
**v0:**
```kotlin theme={null}
Helium.initialize(
// ... other parameters
fallbackConfig = HeliumFallbackConfig(
fallbackBundleName = "my-fallback.json",
fallbackView = myFallbackView,
useLoadingState = true,
loadingBudgetInMs = 5000,
perTriggerLoadingConfig = mapOf(...)
)
)
```
**v4:**
```kotlin theme={null}
// Option 1: Automatic (recommended)
// Just add helium-fallbacks.json to your assets - SDK finds it automatically
// Option 2: Custom file name
Helium.config.customFallbacksFileName = "my-fallback.json"
Helium.config.defaultLoadingBudgetInMs = 5000
Helium.config.defaultLoadingView = myLoadingView // optional
Helium.initialize(
// initialize parameters
)
```
### Configuration Property Mapping
| v0 (HeliumFallbackConfig) | v4 (Helium.config) |
| ------------------------- | -------------------------------------------- |
| `fallbackBundleName` | `customFallbacksFileName` |
| `loadingBudgetInMs` | `defaultLoadingBudgetInMs` |
| `loadingView` | `defaultLoadingView` |
| `useLoadingState` | Computed from `defaultLoadingBudgetInMs > 0` |
| `perTriggerLoadingConfig` | Removed |
| `fallbackPerTrigger` | Removed |
| `fallbackView` | Removed (use fallback bundles only) |
Download your fallbacks from the Helium dashboard. See the [Fallback Bundle Guide](/guides/fallback-bundle) for details.
## New Classes
### PaywallPresentationConfig
Consolidates all paywall presentation options into a single data class:
```kotlin theme={null}
data class PaywallPresentationConfig(
val fromActivityContext: Activity? = null,
val customPaywallTraits: HeliumUserTraits? = null,
val dontShowIfAlreadyEntitled: Boolean = false,
val disableSystemBackNavigation: Boolean = false,
val presentationStyle: HeliumPresentationStyle = HeliumPresentationStyle.SLIDE_UP,
val fullscreen: Boolean = false
) {
companion object {
val Default: PaywallPresentationConfig
}
}
```
### PaywallNotShownReason
Sealed class for handling paywall presentation failures:
```kotlin theme={null}
sealed class PaywallNotShownReason {
data object TargetingHoldout : PaywallNotShownReason()
data object AlreadyEntitled : PaywallNotShownReason()
data class Error(val unavailableReason: PaywallUnavailableReason?) : PaywallNotShownReason()
}
```
## Quick Migration Checklist
Use this checklist to ensure you've covered all migration steps:
* Update SDK to 4.x
* Move user ID/traits to `Helium.identity.*` before `initialize()`
* Move delegate to `Helium.config.*` before `initialize()` (or let SDK auto-create)
* Replace `presentUpsell()` with `presentPaywall()` and update parameters
* Replace `hideUpsell()` with `hidePaywall()`
* Replace `hideAllUpsells()` with `hideAllPaywalls()`
* Remove `HeliumFallbackConfig` usage, migrate to `Helium.config.*` properties
* Set up fallback bundle (download from dashboard, add as `helium-fallbacks.json`)
* Add `onPaywallNotShown` handler to `presentPaywall()` calls
* Test paywall presentation and purchases
## Need Help?
* Review the [Android SDK Quickstart](/sdk/quickstart-android)
* Contact support via Slack or [founders@tryhelium.com](mailto:founders@tryhelium.com)
# Migrating from 2.x to 3.0
Source: https://docs.tryhelium.com/migrations/ios-2-to-3
Key changes and migration guide for Helium iOS SDK v3.0
## Summary
React Native & Flutter: This guide applies for anyone upgrading React Native / Flutter from 0.x.x to 3.0.x. React Native and Flutter did not have a major version 1 or 2.
**Breaking Changes in v3.0:**
* **Minimum iOS version**: Now requires iOS 15.0+ (up from iOS 14.0)
**What's new:**
* Event handlers can be passed directly to `presentUpsell`
* Loading budgets for paywall downloads
* [Improved event system](/guides/helium-events)
* StoreKitDelegate used by default (iOS only)
* `customPaywallTraits` can be set when showing a paywall
Most if not all of your 2.x Helium usages should work without change, but might be deprecated.
## Event Handlers in presentUpsell
You can now pass completion handlers directly when presenting paywalls.
### Before v3 (iOS example)
```swift theme={null}
Helium.shared.presentUpsell(trigger: "premium_upgrade")
```
### v3
```swift theme={null}
Helium.shared.presentUpsell(
trigger: "premium_upgrade",
eventHandlers: PaywallEventHandlers()
.onOpen { event in
print("\(event.paywallName) via trigger \(event.triggerName)")
}
.onClose { event in
print("\(event.paywallName) for trigger \(event.triggerName)")
}
.onDismissed { event in
print("\(event.paywallName) for trigger \(event.triggerName)")
}
.onPurchaseSucceeded { event in
print("\(event.paywallName) for trigger \(event.triggerName)")
}
)
```
## Loading Budget for Paywalls
Before v3, if a Helium download was in progress but you wanted to show a paywall immediately (e.g., on app open), the Helium SDK would just show you a fallback paywall.
In v3.0, it'll now show a loading state (by default, a shimmer view for 2 seconds) and wait for the download to complete. You can configure, turn off, or set trigger-specific loading budgets.
### How It Works
```mermaid theme={null}
graph LR
A[Trigger Paywall] --> B{Config Ready?}
B -->|Yes| C[Show Remote Paywall]
B -->|No| D[Start Loading]
D --> E{Loading Budget}
E -->|Time Remaining| F[Show Loading View]
E -->|Budget Exceeded| G[Show Fallback]
F --> H{Config Downloaded?}
H -->|Yes| C
H -->|No| E
```
### Configure Loading Budgets
Set different loading budgets per trigger or globally:
```swift iOS theme={null}
let fallbackConfig = HeliumFallbackConfig(
fallbackView: YourFallbackView(),
// Global loading budget (in seconds)
loadingBudget: 2.0,
// Per-trigger loading budgets
perTriggerLoadingConfig: [
"onboarding": TriggerLoadingConfig(loadingBudget: 4),
"quick_upgrade": TriggerLoadingConfig(useLoadingState: false),
]
)
Helium.shared.initialize(
apiKey: "your-api-key",
fallbackConfig: fallbackConfig
)
```
```tsx React Native theme={null}
const paywallLoadingConfig: HeliumPaywallLoadingConfig = {
useLoadingState: true,
loadingBudget: 4,
perTriggerLoadingConfig: {
"onboarding": {
loadingBudget: 5,
},
"trial": {
useLoadingState: false,
}
},
};
initialize({
apiKey: "helium-api-key",
purchaseConfig: createRevenueCatPurchaseConfig(
{apiKey: 'rc-api-key'}
),
onHeliumPaywallEvent: (event: HeliumPaywallEvent) => {
console.log('Helium Paywall Event:', event);
},
fallbackBundle: require('./assets/fetched.json'),
paywallLoadingConfig: paywallLoadingConfig, // << pass to initialize
});
```
```dart Flutter theme={null}
final paywallLoadingConfig = HeliumPaywallLoadingConfig(
loadingBudget: 5,
perTriggerLoadingConfig: {
"onboarding": TriggerLoadingConfig(useLoadingState: false),
"from_menu": TriggerLoadingConfig(useLoadingState: true, loadingBudget: 2),
}
);
HeliumFlutter().initialize(
// [Required] You'll get this from Helium founders during setup!
apiKey: "",
paywallLoadingConfig: paywallLoadingConfig,
);
```
### Custom Loading Views (iOS only)
Provide custom loading views during the budget period:
```swift theme={null}
let fallbackConfig = HeliumFallbackConfig(
fallbackView: YourFallbackView(),
loadingView: { progress in
VStack {
ProgressView("Loading premium options...")
Text("\(Int(progress * 100))% complete")
}
}
)
```
### Disable Loading Budget
To show fallback immediately without waiting:
```swift theme={null}
let fallbackConfig = HeliumFallbackConfig(
fallbackView: YourFallbackView(),
useLoadingState: false // Immediate fallback
)
```
## Required Fallbacks (iOS only)
Fallbacks are mandatory. You must either provide a `fallbackConfig` (v3.0 way) or a `fallbackPaywall` (old way). The SDK will fail to initialize without some type of fallback configuration.
### Before (v2.x)
```swift theme={null}
Helium.shared.initialize(
apiKey: "your-api-key",
heliumPaywallDelegate: delegate,
fallbackPaywall: YourFallbackView()
)
```
### After (v3.0)
```swift theme={null}
let fallbackConfig = HeliumFallbackConfig(
// Required: Basic fallback view
fallbackView: Text("Premium Features Available"),
// Recommended: Fallback bundle
fallbackBundle: Bundle.main.url(
forResource: "fallback-bundle",
withExtension: "json"
)
)
Helium.shared.initialize(
apiKey: "your-api-key",
fallbackConfig: fallbackConfig
)
```
### Per-Trigger Fallbacks
Configure different fallbacks for different triggers:
```swift theme={null}
let fallbackConfig = HeliumFallbackConfig(
fallbackView: DefaultFallbackView(),
fallbackPerTrigger: [
"onboarding": OnboardingFallbackView(),
"premium_feature": PremiumFeatureFallbackView(),
"trial_expiry": TrialExpiryFallbackView()
]
)
```
## Event System Changes
The event system now uses protocol-based events with better type safety and IDE support.
### Key Event Changes From Before v3
#### Name Changes
* `paywallTemplateName` → `paywallName`
* `productKey` → `productId`
* All `subscription*` events → `purchase*` events (e.g., `subscriptionSucceeded` → `PurchaseSucceededEvent`)
* `ctaPressed` event → `PaywallButtonPressedEvent`
* `error` is now `Error` type instead of `String` on `PurchaseFailedEvent`
React Native & Flutter had several changes to event `type`:
* ctaPressed → paywallButtonPressed
* offerSelected → productSelected
* subscriptionPressed → purchasePressed
* subscriptionCancelled → purchaseCancelled
* subscriptionSucceeded → purchaseSucceeded
* subscriptionFailed → purchaseFailed
* subscriptionRestored → purchaseRestored
* subscriptionRestoreFailed → purchaseRestoreFailed
* subscriptionPending → purchasePending
#### Removed
* `configId` removed from download success events
### New Delegate Method (iOS only)
#### Before v3
`onHeliumPaywallEvent(event:)` (deprecated but still works)
```swift theme={null}
func onHeliumPaywallEvent(event: HeliumPaywallEvent) {
switch event {
case .paywallOpen(let trigger, let paywallTemplateName, _):
analytics.track("paywall_open", trigger: trigger)
case .subscriptionSucceeded(let productKey, let trigger, _):
analytics.track("purchase", product: productKey)
case .subscriptionFailed(let productKey, _, _, let errorString):
print("Purchase failed: \(errorString ?? "unknown")")
default:
break
}
}
```
#### v3
`onPaywallEvent(_:)` (recommended)
```swift theme={null}
func onPaywallEvent(_ event: PaywallEvent) {
switch event {
case let openEvent as PaywallOpenEvent:
analytics.track("paywall_open",
trigger: openEvent.triggerName,
paywall: openEvent.paywallName
)
case let purchaseEvent as PurchaseSucceededEvent:
analytics.track("purchase",
product: purchaseEvent.productId
)
case let failedEvent as PurchaseFailedEvent:
if let error = failedEvent.error {
print("Purchase failed: \(error.localizedDescription)")
}
default:
break
}
}
```
## StoreKit Delegate by Default (iOS only)
If no delegate is provided, v3.0 uses StoreKitDelegate automatically.
### Before (v2.x)
```swift theme={null}
Helium.shared.initialize(
apiKey: "your-api-key",
heliumPaywallDelegate: StoreKitDelegate(), // Required
fallbackPaywall: fallbackView
)
```
### After (v3.0)
```swift theme={null}
Helium.shared.initialize(
apiKey: "your-api-key",
fallbackConfig: fallbackConfig
// No delegate needed - StoreKitDelegate is default
)
```
## Custom Paywall Traits
You can now pass custom paywall traits when displaying a paywall, replacing the deprecated `HeliumPaywallDelegate.getCustomVariableValues` method.
```swift theme={null}
Helium.shared.presentUpsell(
trigger: "premium_upgrade",
customPaywallTraits: [
"feature_attempted": "export_pdf",
"usage_this_session": 15,
"current_screen": "document_editor"
]
)
```
## Minimum iOS Version
Update your app's minimum deployment target to iOS 15.0 or later.
1. Select your project in Xcode
2. Go to your app target
3. Set "Minimum Deployments" to iOS 15.0
If using Expo:
1. Make sure the `expo-build-properties` package is installed (should be installed by default)
2. Update your app config (app.json, app.config.js, app.config.ts):
```json theme={null}
[
"expo-build-properties",
{
ios: {
deploymentTarget: "15.0", // or higher
},
},
],
```
If not using Expo, adjust in your Xcode project directly.
[Flutter minimum](https://docs.tryhelium.com/sdk/quickstart-flutter#ios-settings) was already iOS 15.
## Need Help?
* Review the [iOS SDK Quickstart](/sdk/quickstart-ios)
* See the [Complete Example App](https://github.com/cloudcaptainai/example-helium-swift) on GitHub
* Contact support via Slack or [founders@tryhelium.com](mailto:founders@tryhelium.com)
# iOS: Migrating from v3 to v4
Source: https://docs.tryhelium.com/migrations/ios-3-to-4
Migration guide for Helium iOS SDK v4
**Breaking Changes in v4:**
* `presentUpsell()` renamed to `presentPaywall()` with required `onPaywallNotShown` handler
* Initialize parameters moved to `Helium.identify` and `Helium.config` namespaces
* Fallback views removed - use fallback bundles only
**What's New:**
* Simplified `initialize()` - just pass your API key
* Automatic fallback bundle detection (`helium-fallbacks.json`)
* Cleaner namespaced APIs: `Helium.identify`, `Helium.config`, `Helium.experiments`, `Helium.entitlements`
* New `HeliumPaywall` SwiftUI component for embedded paywalls
* **HeliumRevenueCat** is now a separate SPM package (CocoaPods unchanged)
## Installation Update
Update your version constraint to `~> 4.0`:
Update the **Dependency Rule** in Xcode to allow 4.x versions, or remove and re-add the package with the new version constraint.
**RevenueCat users:** HeliumRevenueCat is now a separate SPM package. After updating to v4, you may see a build error:
```
Product 'HeliumRevenueCat' not found in package 'helium-swift'
```
To fix this:
1. In your target's **Frameworks, Libraries, and Embedded Content**, remove the broken **HeliumRevenueCat** reference
2. Go to **Package Dependencies** and add the new package:
```
https://github.com/cloudcaptainai/helium-swift-revenuecat.git
```
3. Add the **HeliumRevenueCat** product from the new package to your app's main target
Update your Podfile:
```ruby theme={null}
# Core only
pod 'Helium', '~> 4.0'
# Or with RevenueCat
pod 'Helium/RevenueCat', '~> 4.0'
```
Then run:
```bash theme={null}
pod install
```
## Initialize Migration
The biggest change in v4 is the simplified initialization. Configuration that was previously passed to `initialize()` is now set on dedicated namespaces beforehand.
**v3 (many parameters):**
```swift theme={null}
// Deprecated v3 initialize
Helium.shared.initialize(
apiKey: "...",
heliumPaywallDelegate: RevenueCatDelegate(),
customUserId: "user-123",
customUserTraits: HeliumUserTraits(["plan": "free"]),
fallbackConfig: .withFallbackBundle(bundleURL)
)
```
**v4 (simplified):**
```swift theme={null}
// Set up user identification
Helium.identify.userId = "user-123"
Helium.identify.setUserTraits(HeliumUserTraits(["plan": "free"]))
// If using RevenueCat
Helium.identify.revenueCatAppUserId = Purchases.shared.appUserID
// Set up advanced configuration (if needed)
Helium.config.customFallbacksURL = Bundle.main.url(
forResource: "fallback-bundle-2026-01-25",
withExtension: "json"
)
Helium.config.purchaseDelegate = RevenueCatDelegate()
// Initialize with just API key
Helium.shared.initialize(apiKey: "...")
```
Set identity and configuration values **before** calling `initialize()` for best results.
See the [quickstart](/sdk/quickstart-ios) for more details.
## presentUpsell to presentPaywall
The method has been renamed and now requires an `onPaywallNotShown` handler.
```swift v3 theme={null}
Helium.shared.presentUpsell(trigger: "premium")
```
```swift v4 theme={null}
Helium.shared.presentPaywall(trigger: "premium") { notShownReason in
// handle paywall not shown
}
```
### Full Example with All Parameters
**v4:**
```swift theme={null}
Helium.shared.presentPaywall(
trigger: "premium",
config: PaywallPresentationConfig(
presentFromViewController: viewController,
customPaywallTraits: ["source": "settings"],
dontShowIfAlreadyEntitled: true,
loadingBudget: nil, // use default
),
eventHandlers: PaywallEventHandlers()
.onPurchaseSucceeded { event in
print("Purchased!")
},
onEntitled: {
// User is now entitled (purchased or restored)
}
) { paywallNotShownReason in
switch paywallNotShownReason {
case .targetingHoldout:
break
case .alreadyEntitled:
// User already has access
break
default:
// Handle rare failure cases
break
}
}
```
## API Namespace Migration
### Helium.identify
| v3 | v4 |
| --------------------------- | ------------------------------------------- |
| `initialize(customUserId:)` | `Helium.identify.userId = ...` |
| `overrideUserId()` | `Helium.identify.userId = ...` |
| `getHeliumUserId()` | `Helium.identify.userId` |
| `setAppAttributionToken()` | `Helium.identify.appAccountToken = ...` |
| `setRevenueCatAppUserId()` | `Helium.identify.revenueCatAppUserId = ...` |
### Helium.config
| v3 | v4 |
| ------------------------------------ | ------------------------------------------- |
| `initialize(heliumPaywallDelegate:)` | `Helium.config.purchaseDelegate = ...` |
| `setLightDarkModeOverride()` | `Helium.config.lightDarkModeOverride = ...` |
| `Helium.restorePurchaseConfig` | `Helium.config.restorePurchasesDialog` |
### Helium.experiments
| v3 | v4 |
| ------------------------------- | ------------------------------------- |
| `getExperimentInfoForTrigger()` | `Helium.experiments.infoForTrigger()` |
| `enrolledExperiments()` | `Helium.experiments.enrolled()` |
| `allExperiments()` | `Helium.experiments.all()` |
### Helium.entitlements
| v3 | v4 |
| ---------------------------- | ------------------------------------------------ |
| `hasAnyEntitlement()` | `Helium.entitlements.hasAny()` |
| `hasAnyActiveSubscription()` | `Helium.entitlements.hasAnyActiveSubscription()` |
| `hasEntitlementForPaywall()` | `Helium.entitlements.hasForPaywall()` |
| `hasActiveEntitlementFor()` | `Helium.entitlements.hasActiveFor()` |
| `purchasedProductIds()` | `Helium.entitlements.purchasedProductIds()` |
| `activeSubscriptions()` | `Helium.entitlements.activeSubscriptions()` |
## Fallback Changes
v4 removes fallback views and simplifies fallback configuration.
### What's Changed
* **No more fallback views** - Use fallback bundles exclusively
* **Automatic detection** - SDK auto-detects `helium-fallbacks.json` in your main bundle
* **Optional override** - Use `Helium.config.customFallbacksURL` if needed
### Migration
**v3:**
```swift theme={null}
let fallbackConfig = HeliumFallbackConfig(
fallbackView: YourFallbackView(),
fallbackBundle: Bundle.main.url(
forResource: "fallback-bundle",
withExtension: "json"
)
)
Helium.shared.initialize(
apiKey: "...",
fallbackConfig: fallbackConfig
)
```
**v4:**
```swift theme={null}
// Option 1: Automatic (recommended)
// Just add helium-fallbacks.json to your bundle - SDK finds it automatically
// Option 2: Custom location
Helium.config.customFallbacksURL = Bundle.main.url(
forResource: "my-fallbacks",
withExtension: "json"
)
Helium.shared.initialize(apiKey: "...")
```
Download your fallbacks from the Helium dashboard. See the [Fallback Bundle Guide](/guides/fallback-bundle) for details.
## Embedded Paywall Views
The SwiftUI view for embedding paywalls has been replaced.
```swift v3 (deprecated) theme={null}
let paywallView = Helium.shared.upsellViewForTrigger("premium")
```
```swift v4 theme={null}
HeliumPaywall(
trigger: "premium",
onPaywallNotShown: { reason in
// Handle paywall not shown
}
)
```
See [ways to show a paywall](/guides/ways-to-show-paywall) for more information.
## Removed APIs
The following APIs have been removed in v4:
| Removed API | Replacement |
| ------------------------------------------ | -------------------------------------- |
| `getHeliumExperimentInfo()` (map version) | `Helium.experiments.all()` |
| `HeliumFallbackConfig` class | Use `Helium.config.customFallbacksURL` |
| All `fallback*` parameters from initialize | Add `helium-fallbacks.json` to bundle |
| `upsellViewForTrigger()` | `HeliumPaywall` component |
## Quick Migration Checklist
Use this checklist to ensure you've covered all migration steps:
* Update SDK to 4.x (`~> 4.0`)
* Move user ID/traits to `Helium.identify.*` before `initialize()`
* Move delegate/config to `Helium.config.*` before `initialize()`
* Replace `presentUpsell()` with `presentPaywall()` and add required handler
* Set up fallback bundle (download from dashboard, add as `helium-fallbacks.json`)
* Update experiment calls to `Helium.experiments.*`
* Update entitlement calls to `Helium.entitlements.*`
* Replace `upsellViewForTrigger()` with `HeliumPaywall` component
* Test paywall presentation and purchases
## Need Help?
* Review the [iOS SDK Quickstart](/sdk/quickstart-ios)
* Contact support via Slack or [founders@tryhelium.com](mailto:founders@tryhelium.com)
# SDK Quickstart (Android)
Source: https://docs.tryhelium.com/sdk/quickstart-android
Integrate Helium into your Android app
To get a better understanding of how paywalls are served, visit [here](/guides/how-does-helium-work).
Migrating from v0? View [the migration guide](/migrations/android-0-to-4).
# Present a Paywall
### Install the SDK
Add the Helium SDK to your project using Gradle.
#### Requirements
* **Kotlin Version**: 2.0.0 or higher
* **Java Version**: 8 or higher
* **Minimum Android SDK**: 23 or higher
* **Compile Android SDK**: 35 or higher
#### 1. Add repositories to your `settings.gradle.kts` file:
Ensure you have `mavenCentral()` and `google()` in your repositories blocks.
```kotlin theme={null}
// settings.gradle.kts
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
// You might have other repositories here
}
}
```
If you don't have a `dependencyResolutionManagement` block, ensure `google()` and `mavenCentral()` are present in your `pluginManagement { repositories { ... } }` block.
#### 2. Add the dependency to your module-level `build.gradle.kts` file (e.g., `app/build.gradle.kts`):
```kotlin theme={null}
// app/build.gradle.kts
dependencies {
implementation("com.tryhelium.paywall:core:4.0.0")
}
```
### Initialize Helium
Find your API key [here](https://app.tryhelium.com/profile). If you do not have a Helium account set up yet, you can still integrate but will not be able to show a real paywall.
Initialize the Helium SDK as early as possible in your app's lifecycle.
```kotlin theme={null}
Helium.initialize(
context = this,
apiKey = "YOUR_API_KEY",
environment = HeliumEnvironment.PRODUCTION,
)
```
The supplied environment can be SANDBOX or PRODUCTION. If not sure, use PRODUCTION because Helium will automatically treat debug builds as SANDBOX.
Choose the appropriate location based on your app's architecture:
```kotlin theme={null}
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
//*** Add this:
configureHelium()
}
//*** And this:
private fun configureHelium() {
Helium.initialize(
context = this,
apiKey = "YOUR_API_KEY",
environment = HeliumEnvironment.PRODUCTION,
)
}
}
```
```kotlin theme={null}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//*** Add this:
configureHelium()
}
//*** And this:
private fun configureHelium() {
Helium.initialize(
context = this,
apiKey = "YOUR_API_KEY",
environment = HeliumEnvironment.PRODUCTION,
)
}
}
```
And add necessary imports:
```kotlin theme={null}
import com.tryhelium.paywall.core.Helium
```
### Show Your First Paywall 🎉
Set up a trigger and workflow in the [dashboard](https://app.tryhelium.com/workflows) to show your desired paywall. Create unique triggers for each location a paywall can show in your app! This provides more flexibility with paywall variation and experiments, allowing you to take full advantage of Helium.
Call `presentPaywall` wherever you want to show a full-screen paywall:
```kotlin theme={null}
Helium.presentPaywall(
trigger = "premium",
onPaywallNotShown = { reason ->
// Handle any scenario where the paywall does not show
}
)
```
The trigger name configured in the Helium dashboard.
*(Optional)* Configuration for this paywall presentation.
```kotlin theme={null}
data class PaywallPresentationConfig(
// Activity to present from. SDK auto-tracks if not provided.
val fromActivityContext: Activity? = null,
// Custom traits to send to the paywall
val customPaywallTraits: HeliumUserTraits? = null,
// Don't show paywall if user is entitled to a product in paywall.
// In most cases the default of false is desired -- see "Checking
// Subscription Status & Entitlements" below before enabling this.
val dontShowIfAlreadyEntitled: Boolean = false,
// Disable system back button closing the paywall
val disableSystemBackNavigation: Boolean = false,
// How the paywall animates in
val presentationStyle: HeliumPresentationStyle = HeliumPresentationStyle.SLIDE_UP,
// Show in fullscreen "immersive" mode which will hide system status bars.
val fullscreen: Boolean = false
)
```
*(Optional)* A handler for when user is entitled to a product in the paywall, via purchase or existing entitlement.
*(Optional)* Event listener for paywall lifecycle events.
*(Optional but highly recommended)* Handle any scenario where the paywall does not show. If user is already entitled and `config.dontShowIfAlreadyEntitled` is true, `onPaywallNotShown(AlreadyEntitled)` will be called only if `onEntitled` is not provided.
You should now be able to see Helium paywalls in your app! Well done! 🎉
Looking for alternative presentation methods? Check out the guide on [Ways to Show a Paywall](/guides/ways-to-show-paywall).
# Recommended Setup
Here are some common additional steps that you may want to consider.
### Identifying Users
Identifying users is optional but can help with targeting and when forwarding events to external analytics platforms. If you are not sure, you probably do not need to identify your users.
Identify users as early as you can to maximize consistency in metrics and targeting. Ideally right before you call `Helium.initialize`!
Set a custom user ID:
```kotlin theme={null}
Helium.identity.userId = "custom-user-id"
```
Set custom user traits for targeting and analytics visibility:
```kotlin theme={null}
Helium.identity.setUserTraits(HeliumUserTraits(
traits = mapOf(
"hasOnboarded" to HeliumUserTraitsArgument.BoolParam(true),
"accountAge" to HeliumUserTraitsArgument.IntParam(30)
)
))
// or Helium.identity.addUserTraits() if you don't want to clear existing traits
```
### Helium Events
Helium dispatches various events during paywall presentation and purchase flow. You can optionally handle these events in your mobile app. You can also configure Helium to forward them to your [existing analytics provider](/guides/third-party-analytics).
#### PaywallEventHandlers
You can create an instance of `PaywallEventHandlers` and provide lambdas for the events you are interested in.
The available handlers are:
* `onOpen`: Called when a paywall is displayed to the user.
* `onClose`: Called when a paywall is closed for any reason.
* `onDismissed`: Called when the user explicitly dismisses a paywall without purchasing.
* `onPurchaseSucceeded`: Called when a purchase completes successfully.
* `onCustomPaywallAction`: Called when a custom action is triggered from the paywall.
* `onAnyEvent`: Called for any of the above events.
To register your handlers, use `Helium.shared.addPaywallEventListener`. You can either tie the listener to a lifecycle (recommended) or manage it manually.
**Lifecycle-Aware (Recommended)** Pass a `LifecycleOwner` (like an `Activity` or `Fragment`) to have the listener automatically removed when the lifecycle is destroyed.
```kotlin theme={null}
class MyActivity : AppCompatActivity() {
private val paywallEventHandlers = PaywallEventHandlers(
onOpen = { event -> print("Paywall opened: ${event.paywallName}") },
onClose = { event -> print("Paywall closed: ${event.paywallName}") }
// ... other event handlers
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Add the listener once, it will be cleaned up automatically
Helium.shared.addPaywallEventListener(this, paywallEventHandlers)
}
}
```
If you don't provide a `LifecycleOwner`, you are responsible for removing the listener with `removeHeliumEventListener()` to prevent memory leaks.
#### Global Event Listener
For a more centralized approach, your class can implement the `HeliumEventListener` interface and handle all events in a single `onHeliumEvent` method.
```kotlin theme={null}
import com.tryhelium.paywall.core.event.*
class MyActivity : AppCompatActivity(), HeliumEventListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Add the listener once, it will be cleaned up automatically
Helium.shared.addPaywallEventListener(this, this)
}
override fun onHeliumEvent(event: HeliumEvent) {
when (event) {
is PaywallOpen -> {
// Handle paywall open
}
is PaywallClose -> {
// Handle paywall close
}
// ... handle other event types
}
}
}
```
### Fallback Paywalls
It is **highly recommended** that you set up "fallbacks" to handle the rare case when a paywall fails to display. Please follow [this guide](/guides/fallback-bundle) to do so.
Do this *after* you have a paywall created that you want to use in production.
### Checking Subscription Status & Entitlements
Helium checks entitlements directly through Google Play Billing and provides helper methods to make this easier. Consider checking entitlements to gate premium features.
If you have a backend server that manages user entitlements, you may have no use for these helpers.
Accessed from `Helium.entitlements`:
`hasAnyEntitlement()` Checks if the user has purchased any subscription or non-consumable product.
`hasAnyActiveSubscription()` Checks if the user has any active subscription.
`hasEntitlementForPaywall(trigger: String)` Checks if the user has entitlements for any product in a specific paywall. Returns `null` if paywall configuration hasn't been downloaded yet.
If you want to check entitlement status before presenting a paywall, here are a couple of ways to do so.
For paywalls that are user-initiated (e.g. "Upgrade to Premium") and onboarding paywalls, checking entitled status is not recommended. Apps usually want to consistently show a paywall for these locations, and users can still "Restore Purchases" as needed.
```kotlin Use dontShowIfAlreadyEntitled with presentPaywall theme={null}
Helium.presentPaywall(
trigger = "my_paywall_trigger",
config = PaywallPresentationConfig(
dontShowIfAlreadyEntitled = true,
),
onPaywallNotShown = { paywallNotShownReason ->
// handle paywall not shown
}
)
```
```kotlin Check before showing paywall theme={null}
val hasActiveSubscription = Helium.entitlements.hasAnyActiveSubscription()
if (hasActiveSubscription) {
// access premium content
} else {
// show paywall
}
```
# Advanced
### Consumables
This only applies if using the default `PlayStorePaywallDelegate` for purchase handling. If you are using `RevenueCatPaywallDelegate`, configure RevenueCat to consume appropriate purchases. If you use a custom `HeliumPaywallDelegate`, you are responsible for consuming purchases.
If your app sells consumable products (e.g., coins, credits, or tokens) and you want Helium to consume purchases for these products, relay these product IDs to Helium:
```kotlin theme={null}
Helium.config.consumableIds = setOf("coins_100", "gems_50")
```
### RevenueCat
By default, Helium will handle purchases for you! This section is typically for users who already use RevenueCat in their app.
Helium integrates seamlessly with RevenueCat so you can continue to let RevenueCat handle your purchases and entitlements.
#### Install HeliumRevenueCat
Add the RevenueCat dependency to your module-level `build.gradle.kts`:
```kotlin theme={null}
// app/build.gradle.kts
dependencies {
implementation("com.tryhelium.paywall:core:4.0.0")
implementation("com.tryhelium.paywall:revenue-cat:4.0.0")
}
```
#### Configure Helium to use RevenueCat for Purchases
Simply use Helium's pre-built `RevenueCatPaywallDelegate`:
```kotlin theme={null}
Helium.config.heliumPaywallDelegate = RevenueCatPaywallDelegate()
```
Make sure to initialize RevenueCat *before* creating the RevenueCatPaywallDelegate!
It is best to do this configuration *before* you call `Helium.initialize`.
#### RevenueCat appUserID
If you ever [change the appUserID](https://www.revenuecat.com/docs/customers/identifying-customers#logging-in-after-configuration) of a user, keep Helium in sync with:
```kotlin theme={null}
Helium.identify.revenueCatAppUserId = Purchases.sharedInstance.appUserID
```
### Custom Purchase Handling
By default, Helium will handle purchases for you! This section is for those who want to implement custom purchase logic.
Want to add some custom behavior but still use the built-in purchase logic? Just subclass `PlayStorePaywallDelegate` or `RevenueCatPaywallDelegate`! (Be sure to make a `super` call for any overridden methods.)
You can create a custom delegate and implement your own purchase logic. The `HeliumPaywallDelegate` is defined as follows:
```kotlin theme={null}
interface HeliumPaywallDelegate {
suspend fun makePurchase(
productDetails: ProductDetails,
basePlanId: String?,
offerId: String?,
): HeliumPaywallTransactionStatus
suspend fun restorePurchases(): Boolean
fun onHeliumEvent(event: HeliumEvent)
}
```
### Additional Features
For the full public API and detailed parameter documentation, see the inline docstrings in the SDK source. Import Helium in your project and use your IDE's autocomplete or jump-to-definition to explore all available methods and types.
By default, web links (`http`/`https`) opened via a paywall's navigate action open in the external browser. To open them in-app in a Chrome Custom Tab over the paywall instead:
```kotlin theme={null}
Helium.config.openPaywallLinksInApp = true
```
Non-web links (e.g. `mailto:`, `tel:`) and direct HTML anchor taps always open externally.
In most cases there is no need to check download status. Helium will display a loading indication if a paywall is presented before download has completed.
The `downloadStatus` is a Kotlin `Flow` that emits `HeliumConfigStatus` states. The possible states are:
* `HeliumConfigStatus.NotYetDownloaded`: The initial state before the download has started.
* `HeliumConfigStatus.Downloading`: Indicates that the paywall configuration is currently being downloaded.
* `HeliumConfigStatus.DownloadFailure`: Indicates that the paywall configuration download has failed.
* `HeliumConfigStatus.DownloadSuccess`: Indicates that the paywall configuration has been successfully downloaded.
Here's how you can observe the `downloadStatus` flow in your Activity or Fragment:
```kotlin theme={null}
// In your Activity or Fragment
lifecycleScope.launch {
Helium.shared.downloadStatus.collect { status ->
when (status) {
is HeliumConfigStatus.NotYetDownloaded -> {
// Handle not yet downloaded state
}
is HeliumConfigStatus.Downloading -> {
// Handle downloading state
}
is HeliumConfigStatus.DownloadFailure -> {
// Handle download failure
}
is HeliumConfigStatus.DownloadSuccess -> {
// Handle download success
}
}
}
}
```
You can programmatically hide paywalls using:
```kotlin theme={null}
// Hide the current paywall
Helium.hidePaywall()
// Hide all currently displayed paywalls
Helium.hideAllPaywalls()
```
Reset Helium entirely so you can call initialize again, for example after changing user traits that can affect the paywalls a user might see via targeting.
```kotlin theme={null}
Helium.resetHelium()
```
# SDK Quickstart (Flutter)
Source: https://docs.tryhelium.com/sdk/quickstart-flutter
Integrate Helium into your Flutter App
To get a better understanding of how paywalls are served, visit [here](/guides/how-does-helium-work).
# Present a Paywall
### Install the SDK
The minimum version of Flutter supported by this SDK is **3.24.0**.
To add the Helium package, run:
```bash theme={null}
flutter pub add helium_flutter
```
Or if you prefer, you can add the helium\_flutter package to your pubspec.yaml:
```yaml theme={null}
dependencies:
helium_flutter: ^3.1.3
```
then run:
```bash theme={null}
flutter pub get
```
**Recommended -** Make sure that Swift Package Manager support is enabled:
```bash theme={null}
flutter upgrade
flutter config --enable-swift-package-manager
```
See this [Flutter documentation](https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-app-developers) for more details about Swift Package Manager.
You can still use Cocoapods for your dependencies if preferred. If you need to disable Swift Package Manager dependencies after having enabled it, refer to that same [Flutter documentation](https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-app-developers#how-to-turn-off-swift-package-manager).
#### iOS Settings
Helium requires iOS 15+. If your app already has a minimum of iOS 15 or higher, you're all set. This can be specified in your `ios/Podfile` with:
```text theme={null}
platform :ios, '15.0'
```
If you still see errors related to minimum iOS version, consider updating to 15.0 or higher [directly in the Xcode project](https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-app-developers#how-to-use-a-swift-package-manager-flutter-plugin-that-requires-a-higher-os-version).
### Initialize Helium
Find your API key [here](https://app.tryhelium.com/profile). If you do not have a Helium account set up yet, you can still integrate but will not be able to show a real paywall.
In your app's initialization code (typically in `main.dart` or your root widget):
```dart theme={null}
import 'package:helium_flutter/helium_flutter.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
final heliumFlutter = HeliumFlutter();
await heliumFlutter.initialize(
apiKey: "",
);
runApp(const MyApp());
}
```
You can create or retrieve your api key from [Account Settings](https://app.tryhelium.com/profile).
*(Optional)* Pass in a listener for paywall events. See the Helium Events section.
*(Optional)* Delegate for handling your own purchase logic. If not provided, Helium will handle purchase logic for you. See the RevenueCat and Custom Purchase Handling sections.
*(Optional)* A widget to display if paywall fails to display. See Fallback Paywalls section.
*(Optional)* A custom user id to use instead of Helium's. We'll use this id when forwarding to third party analytics services, so this can be used for attribution. See Identifying Users section.
*(Optional)* Custom user traits to be used for targeting, personalization, and dynamic paywall content. See Identifying Users section.
*(Optional)* RevenueCat ONLY. Supply RevenueCat appUserID here (and initialize RevenueCat before Helium initialize). See RevenueCat section.
*(Optional)* Path to fallback bundle. See Fallback Paywalls section.
*(Optional)* Set loading budget in seconds and whether to show a loading state. See Fallback Paywalls section.
### Show Your First Paywall 🎉
Set up a trigger and workflow in the [dashboard](https://app.tryhelium.com/workflows) to show your desired paywall. Create unique triggers for each location a paywall can show in your app! This provides more flexibility with paywall variation and experiments, allowing you to take full advantage of Helium.
Call `presentUpsell` wherever you want to show a full-screen paywall:
```dart theme={null}
ElevatedButton(
onPressed: () {
final heliumFlutter = HeliumFlutter();
heliumFlutter.presentUpsell(context: context, trigger: 'insert-trigger-here');
},
child: Text('Show Premium Features'),
),
```
Do not call `presentUpsell` in `Widget build()` as this can have unpredictable behavior.
You should now be able to see Helium paywalls in your app! Well done! 🎉
Looking for alternative presentation methods? Check out the guide on [Ways to Show a Paywall](/guides/ways-to-show-paywall).
# Recommended Setup
Here are some common additional steps that you may want to consider.
### Identifying Users
Identifying users is optional but can help with targeting and when forwarding events to external analytics platforms. If you are not sure, you probably do not need to identify your users.
Identify users as early as you can to maximize consistency in metrics and targeting. Ideally in your `initialize` call!
```dart Set during initialize theme={null}
await heliumFlutter.initialize(
apiKey: "",
customUserId: "your-custom-user-id",
customUserTraits: {
"exampleTrait": "value",
"userType": "premium",
},
);
```
```dart Update after initialize theme={null}
await heliumFlutter.overrideUserId(
newUserId: "your-custom-user-id",
traits: {
"exampleTrait": "value",
"userType": "premium",
},
);
```
### Helium Events
Helium dispatches various events during paywall presentation and purchase flow. You can optionally handle these events in your mobile app. You can also configure Helium to forward them to your [existing analytics provider](/guides/third-party-analytics).
#### PaywallEventHandlers
When displaying a paywall you can pass in event handlers to listen for select events:
```dart theme={null}
HeliumFlutter().presentUpsell(
trigger: 'my_paywall',
context: context,
eventHandlers: PaywallEventHandlers(
onOpen: (event) {
log('${event.type} - trigger: ${event.triggerName}');
},
onClose: (event) {
log('${event.type} - trigger: ${event.triggerName}');
},
onDismissed: (event) {
log('${event.type} - trigger: ${event.triggerName}');
},
onPurchaseSucceeded: (event) {
log('${event.type} - trigger: ${event.triggerName}');
},
onAnyEvent: (event) {
// A handler for all paywall-related events.
// Note that if you have other handlers (i.e. onOpen) set up,
// both that handler AND this one will fire during paywall open.
},
),
);
```
#### Global Event Listener
You can also listen for all Helium events globally by implementing `HeliumCallbacks` and passing it to `initialize`:
```dart theme={null}
abstract class HeliumCallbacks {
Future onPaywallEvent(HeliumPaywallEvent heliumPaywallEvent);
}
```
For example:
```dart theme={null}
class LogCallbacks implements HeliumCallbacks {
@override
Future onPaywallEvent(HeliumPaywallEvent heliumPaywallEvent) async {
log('onPaywallEvent: ${heliumPaywallEvent.type} - trigger: ${heliumPaywallEvent.triggerName}');
}
}
```
Then pass it to initialize:
```dart theme={null}
await heliumFlutter.initialize(
apiKey: "",
callbacks: LogCallbacks(),
);
```
### Fallback Paywalls
It is **highly recommended** that you set up "fallbacks" to handle the rare case when a paywall fails to display. Please follow [this guide](/guides/fallback-bundle) to do so.
Do this *after* you have a paywall created that you want to use in production.
### Checking Subscription Status & Entitlements
Helium checks entitlements directly through the underlying app store (StoreKit on iOS, Google Play Billing on Android) and provides helper methods to make this easier. Consider checking entitlements to gate premium features.
If you have a backend server that manages user entitlements, you may have no use for these helpers.
Called on a `HeliumFlutter` instance:
```dart theme={null}
/// Checks if the user has any active subscription (including non-renewable)
Future hasAnyActiveSubscription();
/// Checks if the user has any entitlement
Future hasAnyEntitlement();
/// Checks if the user has an active entitlement for any product attached to
/// the paywall that will show for the provided trigger.
/// Returns `null` if not known (i.e. the paywall is not downloaded yet).
Future hasEntitlementForPaywall(String trigger);
```
If you want to check entitlement status before presenting a paywall, here are a couple of ways to do so.
For paywalls that are user-initiated (e.g. "Upgrade to Premium") and onboarding paywalls, checking entitled status is not recommended. Apps usually want to consistently show a paywall for these locations, and users can still "Restore Purchases" as needed.
```dart Use dontShowIfAlreadyEntitled with presentUpsell theme={null}
heliumFlutter.presentUpsell(
trigger: 'my_paywall',
context: context,
dontShowIfAlreadyEntitled: true
);
```
```dart Check before showing paywall theme={null}
final heliumFlutter = HeliumFlutter();
final hasActiveSubscription = await heliumFlutter.hasAnyActiveSubscription();
if (hasActiveSubscription) {
featureGatedLogic(); // replace this with your logic
} else {
heliumFlutter.presentUpsell(
trigger: 'my_paywall',
context: context,
eventHandlers: PaywallEventHandlers(
onPurchaseSucceeded: (event) {
featureGatedLogic(); // replace this with your logic
},
)
);
}
```
# Advanced
### RevenueCat
By default, Helium will handle purchases for you! This section is typically for users who already use RevenueCat in their app.
Helium integrates seamlessly with RevenueCat so you can continue to let RevenueCat handle your purchases and entitlements.
#### Install HeliumRevenueCat
Add the **helium\_revenuecat** package alongside helium\_flutter:
```bash theme={null}
flutter pub add helium_revenuecat
```
#### Configure Helium to use RevenueCat for Purchases
Use the built-in `RevenueCatPurchaseDelegate`:
```dart theme={null}
import 'package:helium_revenuecat/helium_revenuecat.dart';
final heliumFlutter = HeliumFlutter();
await heliumFlutter.initialize(
apiKey: "",
purchaseDelegate: RevenueCatPurchaseDelegate(),
);
```
Make sure to configure RevenueCat *before* initializing Helium.
#### RevenueCat appUserID
If you ever [change the appUserID](https://www.revenuecat.com/docs/customers/identifying-customers#logging-in-after-configuration) of a user, keep Helium in sync with:
```dart theme={null}
HeliumFlutter().setRevenueCatAppUserId(await Purchases.appUserID);
```
### Custom Purchase Handling
By default, Helium will handle purchases for you! This section is for those who want to implement custom purchase logic.
Implement `HeliumPurchaseDelegate` and pass it to `initialize`:
```dart theme={null}
abstract class HeliumPurchaseDelegate {
Future makePurchase(String productId);
Future restorePurchases();
}
```
### Additional Features
For the full public API and detailed parameter documentation, see the inline docstrings in the SDK source. Import Helium in your project and use your IDE's autocomplete or jump-to-definition to explore all available methods and types.
In most cases there is no need to check download status. Helium will display a loading indication if a paywall is presented before download has completed.
You can listen to the status of the paywalls download via a stream:
```dart theme={null}
HeliumFlutter.downloadStatus.listen((status) {
print('Download status: ${status.name}');
});
```
The download status will be one of the following:
* `notDownloadedYet`: The download has not been initiated.
* `inProgress`: The download is in progress.
* `downloadSuccess`: The download was successful.
* `downloadFailure`: The download failed.
You can also check if paywalls have been downloaded with `await heliumFlutter.paywallsLoaded()`
You can programmatically hide paywalls using:
```dart theme={null}
// Hide the current paywall
await HeliumFlutter().hideUpsell();
// Hide all currently displayed paywalls
await HeliumFlutter().hideAllUpsells();
```
Reset Helium entirely so you can call initialize again, for example after changing user traits that can affect the paywalls a user might see via targeting.
```dart theme={null}
await HeliumFlutter().resetHelium();
```
# SDK Quickstart (iOS)
Source: https://docs.tryhelium.com/sdk/quickstart-ios
Integrate Helium into your iOS app
To get a better understanding of how paywalls are served, visit [here](/guides/how-does-helium-work).
Migrating from v3? View [the migration guide](/migrations/ios-3-to-4).
# Present a Paywall
### Install the Helium SDK
Helium requires a minimum deployment target of iOS 15 and Xcode 14+. (Using the latest Xcode is recommended.)
We recommend using Swift Package Manager (SPM) but also offer a Cocoapod.
1. In Xcode, navigate to your project's **Package Dependencies:**
2. Click the **+** button and search for the Helium package URL:
```text theme={null}
https://github.com/cloudcaptainai/helium-swift.git
```
For **Dependency Rule** we recommend the default **Up to Next Major Version** to make sure you get non-breaking bug fixes. View the [list of releases](https://github.com/cloudcaptainai/helium-swift/releases) here.
3. Click **Add Package**.
4. In the dialog that appears, make sure to add the **Helium** product to your app's main target:
5. Select **Add Package** in the dialog and Helium should now be ready for import.
Add this to your Podfile:
```ruby theme={null}
pod 'Helium', '~> 4.0'
```
Then run:
```bash theme={null}
pod install
```
### Initialize Helium
Initialize the Helium SDK as early as possible in your app's lifecycle.
Find your API key [here](https://app.tryhelium.com/profile). If you do not have a Helium account set up yet, you can still integrate but will not be able to show a real paywall.
```swift theme={null}
Helium.shared.initialize(
apiKey: "helium-api-key"
)
```
Choose the appropriate location based on your app's architecture:
```swift theme={null}
@main
struct MyApp: App {
init() {
//*** Add this:
configureHelium()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
//*** And this:
private func configureHelium() {
Helium.shared.initialize(apiKey: "helium-api-key")
}
}
```
```swift theme={null}
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
//*** Add this:
configureHelium()
}
//*** And this:
private func configureHelium() {
Helium.shared.initialize(apiKey: "helium-api-key")
}
}
```
```swift theme={null}
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
//*** Add this:
configureHelium()
return true
}
//*** And this:
private func configureHelium() {
Helium.shared.initialize(apiKey: "helium-api-key")
}
}
```
And import Helium in the same file:
```swift theme={null}
import Helium
```
### Show Your First Paywall 🎉
Set up a trigger and workflow in the [dashboard](https://app.tryhelium.com/workflows) to show your desired paywall. Create unique triggers for each location a paywall can show in your app! This provides more flexibility with paywall variation and experiments, allowing you to take full advantage of Helium.
Call `presentPaywall` wherever you want to show a full-screen paywall:
```swift theme={null}
Helium.shared.presentPaywall(
trigger: "premium"
) { paywallNotShownReason in
// Handle any scenario where the paywall does not show
}
```
The trigger name configured in the Helium dashboard
*(Optional)* Configuration for this paywall presentation
```swift theme={null}
struct PaywallPresentationConfig {
// View controller to present from. Defaults to current top view controller
var presentFromViewController: UIViewController? = nil
// Custom traits to send to the paywall
var customPaywallTraits: HeliumUserTraits? = nil
// Don't show paywall if user is entitled to a product in paywall.
// In most cases the default of false is desired -- see "Checking
// Subscription Status & Entitlements" below before enabling this.
var dontShowIfAlreadyEntitled: Bool = false
// How long to allow loading state before switching to fallback logic.
// Use zero or negative value to disable loading state.
var loadingBudget: TimeInterval = DEFAULT_LOADING_BUDGET
}
```
*(Optional)* Event handlers for paywall lifecycle events
*(Optional)* A handler for when user is entitled to a product in the paywall, via purchase or existing entitlement. Receives a `PaywallEntitledEvent` identifying how the user became entitled:
* `.purchased` — a new purchase completed successfully
* `.restored` — an existing entitlement was surfaced via restore
* `.alreadyEntitled` — a purchase attempt resolved to an entitlement the user already had
* `.skipped` — the paywall was not shown because the user is already entitled (requires `config.dontShowIfAlreadyEntitled`)
Existing no-argument closures keep working (they map to a deprecated overload).
Handle any scenario where the paywall does not show. If user is already entitled and `config.dontShowIfAlreadyEntitled` is true, `onPaywallNotShown(.alreadyEntitled)` will be called only if `onEntitled` is not provided.
You should now be able to see Helium paywalls in your app! Well done! 🎉
Looking for alternative presentation methods? Check out the guide on [Ways to Show a Paywall](/guides/ways-to-show-paywall).
# Recommended Setup
Here are some common additional steps that you may want to consider.
### Identifying Users
Identifying users is optional but can help with targeting and when forwarding events to external analytics platforms. If you are not sure, you probably do not need to identify your users.
Identify users as early as you can to maximize consistency in metrics and targeting. Ideally right before you call Helium.shared.initialize!
Set a custom user ID
```swift theme={null}
Helium.identify.userId = "custom-user-id"
```
Set custom user traits for targeting and analytics visibility
```swift theme={null}
Helium.identify.setUserTraits(HeliumUserTraits(["hasOnboarded": true]))
// or Helium.identify.addUserTraits() if you don't want to clear existing traits
```
If you use an `appAccountToken` for your existing purchases, then we recommend you also share this value with Helium so Helium can apply the `appAccountToken` to paywall purchases.
```swift theme={null}
if let appAccountTokenUUID = UUID(uuidString: "app-account-token-uuid") {
Helium.identify.appAccountToken = appAccountTokenUUID
}
```
### Helium Events
Helium dispatches various events during paywall presentation and purchase flow. You can optionally handle these events in your mobile app. You can also configure Helium to forward them to your [existing analytics provider](/guides/third-party-analytics).
#### PaywallEventHandlers
When displaying a paywall you can pass in event handlers to listen for select events:
```swift theme={null}
Helium.shared.presentPaywall(
trigger: "post_onboarding",
eventHandlers: PaywallEventHandlers()
.onOpen { event in
print("open via trigger \(event.triggerName)")
}
.onClose { event in
print("close for trigger \(event.triggerName)")
}
.onDismissed { event in
print("dismiss for trigger \(event.triggerName)")
}
.onPurchaseSucceeded { event in
print("purchase succeeded for trigger \(event.triggerName)")
}
.onCustomPaywallAction { event in
print("Custom action: \(event.actionName) with params: \(event.params)")
}
.onAnyEvent { event in
// A handler for all paywall-related events.
// Note that if you have other handlers (i.e. onOpen) set up,
// both that handler AND this one will fire during paywall open.
}
) { paywallNotShownReason in
// handle paywall not shown
}
```
#### Global Helium Event Listener
You can also add one or more global event listeners. For example:
```swift theme={null}
/// Implement this where you want to handle events
public protocol HeliumEventListener : AnyObject {
func onHeliumEvent(event: HeliumEvent)
}
/// Add a listener for all Helium events.
public func addHeliumEventListener(_ listener: HeliumEventListener)
/// Remove a specific Helium event listener.
public func removeHeliumEventListener(_ listener: HeliumEventListener)
```
Listeners are held **weakly** to prevent memory leaks. If you don't maintain a strong reference to your listener, it will be deallocated immediately and no events will fire.
```swift theme={null}
// ❌ Wrong - listener is deallocated immediately, no events will fire
Helium.shared.addHeliumEventListener(MyListener())
// ✅ Works - singleton keeps a strong reference
class MyHeliumEventListener: HeliumEventListener {
static let shared = MyHeliumEventListener()
func onHeliumEvent(event: any HeliumEvent) {
print("Helium event: \(event.toDictionary())")
}
}
// And make sure to register it:
Helium.shared.addHeliumEventListener(MyHeliumEventListener.shared)
```
### Fallback Paywalls
It is **highly recommended** that you set up "fallbacks" to handle the rare case when a paywall fails to display. Please follow [this guide](/guides/fallback-bundle) to do so.
Do this *after* you have a paywall created that you want to use in production.
### Checking Subscription Status & Entitlements
Helium checks entitlements directly through StoreKit and provides helper methods to make this easier. Consider checking entitlements to gate premium features.
If you have a backend server that manages user entitlements, you may have no use for these helpers.
Accessed from `Helium.entitlements`:
`hasAny()` Checks if the user has purchased any subscription or non-consumable product.
`hasAnyActiveSubscription()` Checks if the user has any active subscription.
`hasEntitlementForPaywall(trigger: String, considerAssociatedSubscriptions: Bool = false)` Checks if the user has entitlements for any product in a specific paywall. Returns `nil` if paywall configuration hasn't been downloaded yet.
`hasActiveEntitlementFor(productId: String)` Checks if the user has entitlement to a specific product.
`hasActiveSubscriptionFor(productId: String)` Checks if the user has an active subscription for a specific product.
`hasActiveSubscriptionFor(subscriptionGroupID: String)` Checks if the user has an active subscription in a specific subscription group.
`purchasedProductIds()` Retrieves a list of all product IDs the user currently has access to.
`activeSubscriptions()` Returns detailed information about all active auto-renewing subscriptions.
`subscriptionStatusFor(productId: String)` Gets detailed subscription status for a specific product, including state information like subscribed, expired, or in grace period.
`subscriptionStatusFor(subscriptionGroupID: String)` Gets detailed subscription status for a specific subscription group.
If you want to check entitlement status before presenting a paywall, here are a couple of ways to do so.
For paywalls that are user-initiated (e.g. "Upgrade to Premium") and onboarding paywalls, checking entitled status is not recommended. Apps usually want to consistently show a paywall for these locations, and users can still "Restore Purchases" as needed.
```swift Use dontShowIfAlreadyEntitled with presentPaywall theme={null}
Helium.shared.presentPaywall(
trigger: "my_paywall_trigger",
config: PaywallPresentationConfig(
dontShowIfAlreadyEntitled: true
),
onEntitled: { entitledEvent in
// handle user with existing entitlement
// or new entitlement after they completed/restored a purchase
}
) { paywallNotShownReason in
// handle paywall not shown
}
```
```swift Check before showing paywall theme={null}
let hasActiveSubscription = await Helium.entitlements.hasAnyActiveSubscription()
if hasActiveSubscription {
// access premium content
} else {
// show paywall
}
```
# Advanced
### RevenueCat
By default, Helium will handle purchases for you! This section is typically for users who already use RevenueCat in their app.
Helium integrates seamlessly with RevenueCat so you can continue to let RevenueCat handle your purchases and entitlements.
#### Install HeliumRevenueCat
1. In Xcode, navigate to your project's **Package Dependencies**.
2. Click the **+** button and search for the **HeliumRevenueCat** package URL:
```text theme={null}
https://github.com/cloudcaptainai/helium-swift-revenuecat.git
```
3. Add the **HeliumRevenueCat** product to your app's main target.
The **HeliumRevenueCat** package includes [purchases-ios-spm](https://github.com/RevenueCat/purchases-ios-spm) as a dependency, *not* [purchases-ios](https://github.com/RevenueCat/purchases-ios) and you may encounter build issues if you are using **purchases-ios** with SPM. (We recommend just switching to **purchases-ios-spm**).
Replace **Helium** with this in your Podfile:
```ruby theme={null}
pod 'Helium/RevenueCat', '~> 4.0'
```
Then run:
```bash theme={null}
pod install
```
#### Configure Helium to use RevenueCat for Purchases
Simply use Helium's pre-built `RevenueCatDelegate` to let RevenueCat handle paywall purchases.
```swift theme={null}
import HeliumRevenueCat // unless using Cocoapod then can just import Helium
Helium.config.purchaseDelegate = RevenueCatDelegate(
// Optional - pass in to have Helium handle RevenueCat initialization.
revenueCatApiKey: ""
)
```
If you do not supply `revenueCatApiKey`, make sure to initialize RevenueCat *before* creating the RevenueCatDelegate!
It is best to do this configuration *before* you call `Helium.shared.initialize`
#### RevenueCat appUserID
If you ever [change the appUserID](https://www.revenuecat.com/docs/customers/identifying-customers#logging-in-after-configuration) of a user, keep Helium in sync with:
```swift theme={null}
Helium.identify.revenueCatAppUserId = Purchases.shared.appUserID
```
### Custom Purchase Handling
By default, Helium will handle purchases for you! This section is for those who want to implement custom purchase logic.
Want to add some custom behavior but still use the built-in purchase logic? Just subclass `StoreKitDelegate` or `RevenueCatDelegate`! (Be sure to make a `super` call for any overridden methods.)
You can also create a custom delegate and implement your own purchase logic. You can look at our [StoreKitDelegate](https://github.com/cloudcaptainai/helium-swift/blob/main/Sources/Helium/HeliumCore/StoreKitDelegate.swift) and [RevenueCatDelegate](https://github.com/cloudcaptainai/helium-swift/blob/main/Sources/HeliumRevenueCat/HeliumRevenueCat.swift) in the SDK for examples (also linked below).
The `HeliumPaywallDelegate` is defined as follows:
```swift theme={null}
public protocol HeliumPaywallDelegate: AnyObject {
// Execute the purchase of a product given the product ID.
func makePurchase(productId: String) async -> HeliumPaywallTransactionStatus
// (Optional) - Restore any existing subscriptions.
// Return a boolean indicating whether the restore was successful.
func restorePurchases() async -> Bool
// (Optional) - Called for all Helium events (e.g. PaywallOpenEvent)
func onPaywallEvent(_ event: HeliumEvent)
}
```
### Additional Features
For the full public API and detailed parameter documentation, see the inline docstrings in the SDK source. Import Helium in your project and use your IDE's autocomplete or jump-to-definition to explore all available methods and types.
By default, web links (`http`/`https`) opened via a paywall's navigate action open in the external browser. To open them in-app in a Safari page sheet over the paywall instead:
```swift theme={null}
Helium.config.openPaywallLinksInApp = true
```
Non-web links (e.g. `mailto:`, `tel:`) and direct HTML anchor taps always open externally.
In most cases there is no need to check download status. Helium will display a loading indication if a paywall is presented before download has completed.
You can check the status of the paywall configuration download using the `Helium.shared.getDownloadStatus()` method. This method returns a value of type `HeliumFetchedConfigStatus`, which is defined as follows:
```swift theme={null}
public enum HeliumFetchedConfigStatus: String, Codable, Equatable {
case notDownloadedYet
case inProgress
case downloadSuccess
case downloadFailure
}
```
You can also simply check if paywalls have been successfully downloaded with `Helium.shared.paywallsLoaded()`.
Retrieve basic information about the paywall for a specific trigger with `Helium.shared.getPaywallInfo(trigger: String)` which returns:
```swift theme={null}
public struct PaywallInfo {
public let paywallTemplateName: String
// shouldShow only false if the paywall should not be shown due to targeting or workflow configuration (Helium handles this for you in presentPaywall)
public let shouldShow: Bool
}
```
This method can be used if you want to be certain that a paywall is ready for display before attempting to display.
You can programmatically hide paywalls using:
```swift theme={null}
// Hide the current paywall
Helium.shared.hidePaywall()
// Hide all currently displayed paywalls
Helium.shared.hideAllPaywalls()
```
Reset Helium entirely so you can call initialize again, for example after changing user traits that can affect the paywalls a user might see via targeting.
```swift theme={null}
Helium.resetHelium()
```
# SDK Quickstart (React Native)
Source: https://docs.tryhelium.com/sdk/quickstart-react-native
Integrate Helium into your React Native App.
To get a better understanding of how paywalls are served, visit [here](/guides/how-does-helium-work).
# Present a Paywall
### Install the SDK
Install the SDK using your preferred package manager:
```bash Expo 52+ theme={null}
npx expo install expo-helium
```
```bash Older Expo / Bare theme={null}
npm install @tryheliumai/paywall-sdk-react-native
# or
yarn add @tryheliumai/paywall-sdk-react-native
# [Bare React Native] the native module autolinks; install pods
cd ios && pod install
# [Expo 49-51] regenerate the native projects instead
npx expo prebuild
```
We support Expo 49+ but recommend using Helium with **Expo 53+**.
The Older Expo / Bare SDK requires React Native 0.71.7+, iOS 15+, and Android minSdk 24.
### Initialize Helium
Find your API key [here](https://app.tryhelium.com/profile). If you do not have a Helium account set up yet, you can still integrate but will not be able to show a real paywall.
Initialize Helium by calling `initialize()` early in your app's lifecycle, typically in your root component:
```tsx Expo 52+ theme={null}
import { initialize } from 'expo-helium';
function App() {
const asyncHeliumInit = async () => {
await initialize({
apiKey: '',
});
};
useEffect(() => {
void asyncHeliumInit();
}, []);
}
```
```tsx Older Expo / Bare theme={null}
import { initialize } from '@tryheliumai/paywall-sdk-react-native';
function App() {
const asyncHeliumInit = async () => {
await initialize({
apiKey: '',
});
};
useEffect(() => {
void asyncHeliumInit();
}, []);
}
```
### Show Your First Paywall 🎉
Set up a trigger and workflow in the [dashboard](https://app.tryhelium.com/workflows) to show your desired paywall. Create unique triggers for each location a paywall can show in your app! This provides more flexibility with paywall variation and experiments, allowing you to take full advantage of Helium.
Call `presentUpsell` wherever you want to show a full-screen paywall:
```tsx Expo 52+ theme={null}
import { presentUpsell } from 'expo-helium';
function YourComponent() {
const handlePremiumPress = () => {
presentUpsell({
triggerName: 'premium_feature_press',
});
};
return (
);
}
```
```tsx Older Expo / Bare theme={null}
import { presentUpsell } from '@tryheliumai/paywall-sdk-react-native';
function YourComponent() {
const handlePremiumPress = () => {
presentUpsell({
triggerName: 'premium_feature_press',
});
};
return (
);
}
```
Pass in the trigger for the workflow you configured in the dashboard.
*(Optional)* Handlers that allow you to respond to open, close, dismiss, purchase, open-fail, and custom paywall action [events](/guides/helium-events).
*(Optional)* Custom key/value pairs that you want available to the paywall.
*(Optional)* If `true`, the paywall will not be shown if the user already has an entitlement for a product in the paywall. In most cases the default of `false` is desired — see "Checking Subscription Status & Entitlements" below before enabling this.
*(Optional)* Called upon purchase success, purchase restore, or a purchase attempt resolving to an existing entitlement (iOS only). If `dontShowIfAlreadyEntitled` is true, this handler will also be called when the paywall is not shown to users who already have entitlement for a product in the paywall. Receives an optional `PaywallEntitledEvent` identifying how the user became (or was found to be) entitled. Existing no-argument handlers keep working.
*(Optional)* Called if desired paywall and fallback paywall did not show for any reason. This is uncommon, but best practice to handle it just in case. See [Fallback Paywalls](/guides/fallback-bundle).
You should now be able to see Helium paywalls in your app! Well done! 🎉
Looking for alternative presentation methods? Check out the guide on [Ways to Show a Paywall](/guides/ways-to-show-paywall).
# Recommended Setup
Here are some common additional steps that you may want to consider.
### Identifying Users
Identifying users is optional but can help with targeting and when forwarding events to external analytics platforms. If you are not sure, you probably do not need to identify your users.
Identify users as early as you can to maximize consistency in metrics and targeting. Ideally in your `initialize` call!
```tsx Set during initialize theme={null}
await initialize({
apiKey: '',
// (Optional) Custom user id, e.g. your amplitude analytics user id.
customUserId: '',
// (Optional) Custom user traits
customUserTraits: {
"example_trait": "example_value",
},
});
```
```tsx Update after initialize theme={null}
import { setCustomUserId } from 'expo-helium';
// or: import { setCustomUserId } from '@tryheliumai/paywall-sdk-react-native';
setCustomUserId('your-custom-user-id');
```
You can also read or clear the id later with `getCustomUserId()` / `clearCustomUserId()`.
### Helium Events
Helium dispatches various events during paywall presentation and purchase flow. You can optionally handle these events in your mobile app. You can also configure Helium to forward them to your [existing analytics provider](/guides/third-party-analytics).
#### PaywallEventHandlers
When displaying a paywall you can pass in event handlers to listen for select events:
```tsx theme={null}
presentUpsell({
triggerName: 'my_paywall',
eventHandlers: {
onOpen: (event) => {
console.log(`${event.type}`)
},
onClose: (event) => {
console.log(`${event.type}`)
},
onPurchaseSucceeded: (event) => {
console.log(`${event.type}`)
},
onDismissed: (event) => {
console.log(`${event.type}`)
},
onOpenFailed: (event) => {
console.log(`${event.type}`)
},
onCustomPaywallAction: (event) => {
console.log(`${event.type}`)
},
onAnyEvent: (event) => {
// A handler for all paywall-related events.
// Note that if you have other handlers (i.e. onOpen) set up,
// both that handler AND this one will fire during paywall open.
},
},
});
```
#### Global Event Listener
You can also listen for all Helium events globally by passing `onHeliumPaywallEvent` to `initialize`:
```tsx theme={null}
await initialize({
apiKey: '',
onHeliumPaywallEvent: (event) => {
switch (event.type) {
case 'paywallOpen':
break;
case 'purchaseSucceeded':
// Handle successful purchase
break;
// handle other events as desired
}
},
});
```
### Fallback Paywalls
It is **highly recommended** that you set up "fallbacks" to handle the rare case when a paywall fails to display. Please follow [this guide](/guides/fallback-bundle) to do so.
Do this *after* you have a paywall created that you want to use in production.
### Checking Subscription Status & Entitlements
Helium checks entitlements directly through the underlying app store (StoreKit on iOS, Google Play Billing on Android) and provides helper methods to make this easier. Consider checking entitlements to gate premium features.
If you have a backend server that manages user entitlements, you may have no use for these helpers.
```typescript theme={null}
/**
* Checks if the user has any active subscription (including non-renewable)
*/
export const hasAnyActiveSubscription = async (): Promise;
/**
* Checks if the user has any entitlement
*/
export const hasAnyEntitlement = async (): Promise;
/**
* Checks if the user has an active entitlement for any product attached to
* the paywall that will show for the provided trigger.
* Returns undefined if not known (i.e. the paywall is not downloaded yet).
*/
export const hasEntitlementForPaywall = async (trigger: string): Promise;
```
If you want to check entitlement status before presenting a paywall, here are a couple of ways to do so.
For paywalls that are user-initiated (e.g. "Upgrade to Premium") and onboarding paywalls, checking entitled status is not recommended. Apps usually want to consistently show a paywall for these locations, and users can still "Restore Purchases" as needed.
```typescript Use dontShowIfAlreadyEntitled with presentUpsell theme={null}
presentUpsell({
triggerName: 'my_paywall',
dontShowIfAlreadyEntitled: true
});
```
```typescript Check before showing paywall theme={null}
const hasActiveSubscription = await hasAnyActiveSubscription();
if (hasActiveSubscription) {
featureGatedLogic(); // replace this with your logic
} else {
presentUpsell({
triggerName: 'my_paywall',
eventHandlers: {
onPurchaseSucceeded: (event: PurchaseSucceededEvent) => {
featureGatedLogic(); // replace this with your logic
},
},
});
}
```
# Advanced
### RevenueCat
By default, Helium will handle purchases for you! This section is typically for users who already use RevenueCat in their app.
Helium integrates seamlessly with RevenueCat so you can continue to let RevenueCat handle your purchases and entitlements.
If you haven't already, make sure to [install](https://www.revenuecat.com/docs/getting-started/installation/expo) RevenueCat (for non-Expo see [here](https://www.revenuecat.com/docs/getting-started/installation/reactnative)).
Make sure to initialize RevenueCat (`Purchases.configure()`) before initializing Helium.
Configure Helium to use RevenueCat by passing `createRevenueCatPurchaseConfig` to `initialize`:
```tsx Expo 52+ theme={null}
import { initialize } from 'expo-helium';
import { createRevenueCatPurchaseConfig } from "expo-helium/src/revenuecat";
const asyncHeliumInit = async () => {
await initialize({
apiKey: '',
purchaseConfig: createRevenueCatPurchaseConfig(),
revenueCatAppUserId: await Purchases.getAppUserID()
});
};
useEffect(() => {
void asyncHeliumInit();
}, []);
```
```tsx Older Expo / Bare theme={null}
import { initialize } from '@tryheliumai/paywall-sdk-react-native';
import { createRevenueCatPurchaseConfig } from "@tryheliumai/paywall-sdk-react-native/src/revenuecat";
const asyncHeliumInit = async () => {
await initialize({
apiKey: '',
purchaseConfig: createRevenueCatPurchaseConfig(
// (optional) Set if you want Helium to initialize RevenueCat for you.
// Otherwise initialize RevenueCat (`Purchases.configure()`) before initializing Helium.
// Platform-specific keys are also supported: { apiKeyIOS, apiKeyAndroid }
{ apiKey: 'revenue_cat_api_key' }
),
revenueCatAppUserId: await Purchases.getAppUserID()
});
};
useEffect(() => {
void asyncHeliumInit();
}, []);
```
#### RevenueCat appUserID
If you ever [change the appUserID](https://www.revenuecat.com/docs/customers/identifying-customers#logging-in-after-configuration) of a user, keep Helium in sync with:
```tsx theme={null}
import { setRevenueCatAppUserId } from 'expo-helium';
// or: import { setRevenueCatAppUserId } from '@tryheliumai/paywall-sdk-react-native';
setRevenueCatAppUserId(await Purchases.getAppUserID());
```
### Custom Purchase Handling
By default, Helium will handle purchases for you! This section is for those who want to implement custom purchase logic.
Pass a custom purchase config to `initialize`:
```tsx Expo 52+ theme={null}
import { createCustomPurchaseConfig } from 'expo-helium';
// In your initialize call:
purchaseConfig: createCustomPurchaseConfig({
makePurchaseIOS: async (productId) => {
// Your purchase logic here
// Return a HeliumTransactionStatus
return { status: 'purchased' };
},
makePurchaseAndroid: async (productId) => {
// Your purchase logic here
// Return a HeliumTransactionStatus
return { status: 'purchased' };
},
restorePurchases: async () => {
// Your restore logic here
return true;
}
}),
```
```tsx Older Expo / Bare theme={null}
import { createCustomPurchaseConfig } from '@tryheliumai/paywall-sdk-react-native';
// In your initialize call:
purchaseConfig: createCustomPurchaseConfig({
makePurchaseIOS: async (productId) => {
// Your purchase logic here
// Return a HeliumTransactionStatus
return { status: 'purchased' };
},
makePurchaseAndroid: async (productId, basePlanId, offerId) => {
// Your purchase logic here
// Return a HeliumTransactionStatus
return { status: 'purchased' };
},
restorePurchases: async () => {
// Your restore logic here
return true;
}
}),
```
```tsx theme={null}
type HeliumTransactionStatus =
'purchased' | 'failed' | 'cancelled' | 'pending' | 'restored';
```
### Testing Purchases
Stub purchase flows for demos, UI tests, and CI — no store configuration needed. Debug builds only; gate the calls so they never run in production:
```tsx theme={null}
import { heliumTesting } from 'expo-helium';
// or: from '@tryheliumai/paywall-sdk-react-native';
heliumTesting.setPurchaseResult('purchased');
heliumTesting.setRestoreResult(true);
heliumTesting.setIntroOfferEligibility(true); // call before initialize()
heliumTesting.reset(); // back to real billing
```
### Additional Features
For the full public API and detailed parameter documentation, see the inline docstrings in the SDK source. Import Helium in your project and use your IDE's autocomplete or jump-to-definition to explore all available methods and types.
You can programmatically hide paywalls using:
```typescript theme={null}
// Hide the current paywall
hideUpsell();
// Hide all currently displayed paywalls
hideAllUpsells();
```
Reset Helium entirely so you can call initialize again, for example after changing user traits that can affect the paywalls a user might see via targeting.
```typescript theme={null}
import { resetHelium } from 'expo-helium';
// or: import { resetHelium } from '@tryheliumai/paywall-sdk-react-native';
await resetHelium();
```
```typescript theme={null}
// Override light/dark appearance for paywalls
setLightDarkModeOverride('dark'); // 'light' | 'dark' | 'system'
// Customize or disable the dialog shown when "Restore Purchases" fails
setCustomRestoreFailedStrings('Title', 'Message', 'Close');
disableRestoreFailedDialog();
// Toggle the triple-tap paywall previews gesture in dev builds
setPaywallPreviewsEnabledInDevBuilds(false);
```
```typescript theme={null}
// 'downloadSuccess' | 'downloadFailure' | 'inProgress' | 'notDownloadedYet'
const status = await getDownloadStatus();
// { paywallTemplateName, shouldShow } for a trigger
const info = await getPaywallInfo('my_trigger');
```
Native SDK logs are also routed to the JS console prefixed with `[Helium]`, so configuration problems surface in Metro during development.
### Expo Development Build
Please note that the Helium SDK uses native code, so you must create a [development build](https://docs.expo.dev/develop/development-builds/introduction/). A common command to run for this is:
```bash theme={null}
npx expo run:ios # or npx expo run:ios --device
```
Bare React Native apps always include native code — just run `npx react-native run-ios` / `run-android` as usual.
### Example Apps
Working example apps (Expo 51 and bare React Native) live in the [SDK repo's examples folder](https://github.com/cloudcaptainai/paywall-sdk-react-native/tree/main/examples).
### Troubleshooting
#### Check if the Helium SDK is installed
```bash Expo 52+ theme={null}
[ -d "node_modules/expo-helium" ] && echo "✅ expo-helium package found in node_modules" || echo "❌ expo-helium package NOT found in node_modules"
```
```bash Older Expo / Bare theme={null}
[ -d "node_modules/@tryheliumai/paywall-sdk-react-native" ] && echo "✅ paywall-sdk-react-native package found in node_modules" || echo "❌ paywall-sdk-react-native package NOT found in node_modules"
```
If the package is not found, install it again:
```bash Expo 52+ theme={null}
npx expo install expo-helium
```
```bash Older Expo / Bare theme={null}
npm install @tryheliumai/paywall-sdk-react-native
# or
yarn add @tryheliumai/paywall-sdk-react-native
# [Bare React Native] then reinstall the native pods
cd ios && pod install
```
#### Check if Helium is properly installed
To verify that the Helium pod is correctly installed in your project, run:
```bash Expo 52+ theme={null}
grep -E "Helium" ios/Podfile.lock > /dev/null && echo "✅ Helium found in ios/Podfile.lock" || echo "❌ Helium not found in ios/Podfile.lock" && grep -E "HeliumPaywallSdk" ios/Podfile.lock > /dev/null && echo "✅ HeliumPaywallSdk found in ios/Podfile.lock" || echo "❌ HeliumPaywallSdk not found in ios/Podfile.lock"
```
```bash Older Expo / Bare theme={null}
grep -E "Helium" ios/Podfile.lock > /dev/null && echo "✅ Helium found in ios/Podfile.lock" || echo "❌ Helium not found in ios/Podfile.lock"
```
If not found, try these commands:
```bash Expo theme={null}
# regenerate the ios (and android) directories
npx expo prebuild --clean
# run a development build
npx expo run:ios # or npx expo run:ios --device
```
```bash Bare React Native theme={null}
# reinstall the native pods
cd ios && pod install
# rebuild the app
npx react-native run-ios # or run-android
```