diff --git a/analytics/third-party-integrations.mdx b/analytics/third-party-integrations.mdx new file mode 100644 index 0000000..5e80537 --- /dev/null +++ b/analytics/third-party-integrations.mdx @@ -0,0 +1,84 @@ +--- +title: "Third-party analytics integrations" +description: "Connect Helium with your analytics platforms" +--- + +Helium can forward paywall events to your existing analytics platforms so you can track paywall performance alongside the rest of your product data — without writing any additional instrumentation code. + +--- + +### **Supported Platforms** + +| Platform | Description | +| :------------ | :---------------------------------------------------------------------------------------------------------------- | +| **Mixpanel** | Paywall events appear as Mixpanel events, tied to user identity. Build funnels from paywall open to subscription. | +| **Amplitude** | Paywall events flow into Amplitude for behavioral analysis and cohort building. | +| **PostHog** | Paywall events as PostHog events, useful for product analytics and session replay correlation. | +| **Statsig** | Paywall events forwarded to Statsig for feature flag analysis and experimentation. | + +You can connect **multiple platforms at the same time**. Events are sent to all connected destinations simultaneously. + +--- + +### **How It Works** + +1. The **Helium SDK** captures paywall interactions in your app. +2. Events are sent to Helium's event pipeline (a Segment-compatible layer). +3. The pipeline **routes events** to Helium's own analytics (for your dashboard metrics) _and_ to any third-party destinations you've configured. + +Once connected, forwarding is fully automatic. No SDK changes or additional code required. + +--- + +### **Events Forwarded** + +| Event | When it fires | +| :----------------------------- | :-------------------------------------------------------------- | +| helium_paywallOpen | A paywall is displayed to the user. | +| helium_paywallDismissed | The user dismisses the paywall. | +| helium_subscriptionPressed | The user taps the subscribe button. | +| helium_subscriptionSucceeded | The purchase completes successfully. | +| helium_subscriptionFailed | The purchase fails. | +| helium_subscriptionCanceled | The user cancels during the purchase flow. | +| helium_paywallsDownloadSuccess | The SDK successfully initializes and downloads paywall configs. | + +Each event includes contextual properties: paywall ID, paywall name, trigger name, experiment ID and variant (if applicable), device type, OS, and user identifiers. + +--- + +### **Setup** + + + + + + + + + + - **Mixpanel** — Project Token + - **Amplitude** — API Key + - **PostHog** — Project API Key and Host + - **Statsig** — Server Secret Key + + + + + + +To disconnect, open the integration and remove it from the Integrations page. Forwarding stops right away. + +--- + +### **Key Points** + +- **No SDK code changes needed** — connecting a destination in the dashboard is all it takes. + + +- Events are forwarded **in real time** as they occur in the app. + + +- **Fallback paywalls also send events** — the analytics configuration is embedded in the fallback bundle, so tracking works even when the paywall is served from cache. + + +- Helium's own dashboard metrics are **not affected** by third-party integrations — they always use Helium's primary data pipeline regardless of what destinations are connected. \ No newline at end of file diff --git a/docs.json b/docs.json index d7f6f1e..41cea59 100644 --- a/docs.json +++ b/docs.json @@ -9,65 +9,81 @@ }, "favicon": "/favicon.svg", "navigation": { + "global": { + "anchors": [ + { + "anchor": "More Info", + "icon": "lightbulb", + "href": "https://tryhelium.com" + } + ] + }, "groups": [ { - "group": "Getting Started", + "group": "Introduction", "icon": "flag", "pages": [ - "getting-started/introduction", - "getting-started/quickstart" + "introduction/introduction", + "introduction/quickstart" ] }, { "group": "SDK Quickstarts", "icon": "code", "pages": [ - "sdk/quickstart-ios", - "sdk/quickstart-android", - "sdk/quickstart-react-native", - "sdk/quickstart-flutter", - "sdk/helium-events" + "sdk-quickstarts/overview", + "sdk-quickstarts/i-os", + "sdk-quickstarts/android", + "sdk-quickstarts/react-native", + "sdk-quickstarts/flutter", + "sdk-quickstarts/feature-availability" ] }, { - "group": "Guides", - "icon": "compass", + "group": "Primitives", + "icon": "cube", "pages": [ - "guides/revenue-reporting", - "guides/fallback-bundle", - "guides/ways-to-show-paywall", - "guides/third-party-analytics", - "guides/custom-events", - "guides/storekit-testing" + { + "group": "Paywalls", + "pages": [ + "primitives/paywalls", + "primitives/paywalls/localization", + "sdk-quickstarts/fallbacks" + ] + }, + "primitives/workflows", + "primitives/triggers", + "primitives/experiments", + "primitives/audiences", + "primitives/product-buckets" ] }, { - "group": "SDK Reference", - "icon": "book-open", + "group": "Events", + "icon": "chart-line", "pages": [ - "sdk-reference/sdk-features", - "sdk-reference/fallback-causes" + "events/custom-paywall-events", + "events/revenue-tracking" + ] + }, + { + "group": "Analytics", + "icon": "chart-bar", + "pages": [ + "analytics/third-party-integrations" ] }, { "group": "Migrations", "icon": "arrow-up-right-dots", "pages": [ + "migrations/overview", "migrations/ios-3-to-4", "migrations/android-0-to-4", "migrations/ios-2-to-3" ] } - ], - "global": { - "anchors": [ - { - "anchor": "More Info", - "href": "https://tryhelium.com", - "icon": "lightbulb" - } - ] - } + ] }, "logo": { "light": "/logo/LargeLogo-cropped.svg", diff --git a/events/custom-paywall-events.mdx b/events/custom-paywall-events.mdx new file mode 100644 index 0000000..d36b775 --- /dev/null +++ b/events/custom-paywall-events.mdx @@ -0,0 +1,337 @@ +--- +title: Custom paywall events +description: Track custom events from your paywalls +--- + +# Custom paywall events + +Custom paywall events allow you to track user interactions and behaviors within your paywalls beyond the standard events Helium automatically tracks. + +## What are custom paywall events? + +Custom events are user-defined tracking points that capture specific interactions or milestones in your paywall experience. + +## Why track custom events? + +Custom events help you: + +- **Understand user behavior**: See how users interact with paywalls +- **Optimize conversion**: Identify friction points +- **Measure engagement**: Track which elements users interact with +- **Debug issues**: Identify where users drop off +- **Inform decisions**: Make data-driven improvements + +## Automatic events + +Helium automatically tracks these events: + +- `paywall_viewed`: Paywall was displayed +- `paywall_dismissed`: User closed the paywall +- `product_selected`: User tapped a product +- `purchase_started`: Purchase flow initiated +- `purchase_completed`: Subscription purchased +- `purchase_failed`: Purchase encountered an error +- `restore_started`: User tapped restore purchases +- `restore_completed`: Restore purchases succeeded + +## Creating custom events + +Track custom events from your paywalls: + +```swift +// iOS example +Helium.shared.trackPaywallEvent("feature_comparison_viewed") +``` + +```kotlin +// Android example +Helium.trackPaywallEvent("feature_comparison_viewed") +``` + +## Event properties + +Add context with event properties: + +```swift +// iOS example +Helium.shared.trackPaywallEvent("video_played", properties: [ + "video_id": "intro_video", + "duration": 30, + "completed": false +]) +``` + +```kotlin +// Android example +Helium.trackPaywallEvent("video_played", mapOf( + "video_id" to "intro_video", + "duration" to 30, + "completed" to false +)) +``` + +## Common custom events + +### User interactions + +```swift +// User tapped FAQ +Helium.shared.trackPaywallEvent("faq_tapped") + +// User expanded feature details +Helium.shared.trackPaywallEvent("feature_details_expanded", properties: [ + "feature": "advanced_editor" +]) + +// User watched testimonial video +Helium.shared.trackPaywallEvent("testimonial_video_viewed") +``` + +### Navigation + +```swift +// User scrolled to bottom +Helium.shared.trackPaywallEvent("paywall_scrolled_to_bottom") + +// User switched between tabs +Helium.shared.trackPaywallEvent("tab_changed", properties: [ + "from_tab": "monthly", + "to_tab": "annual" +]) +``` + +### Engagement + +```swift +// User spent significant time +Helium.shared.trackPaywallEvent("paywall_engaged", properties: [ + "time_spent": 45 +]) + +// User interacted with calculator +Helium.shared.trackPaywallEvent("savings_calculator_used", properties: [ + "calculated_savings": 120 +]) +``` + +## Event naming conventions + +Use clear, consistent names: + +### Good names + +- `feature_comparison_viewed` +- `testimonial_video_played` +- `faq_item_expanded` +- `pricing_calculator_used` + +### Avoid + +- `event1` +- `click` +- `thing_happened` +- `test` + +## Event properties best practices + +### Use consistent types + +```swift +// ✅ Good +properties: [ + "duration": 30, // Number + "completed": true, // Boolean + "video_id": "intro" // String +] + +// ❌ Avoid +properties: [ + "duration": "30 seconds", // Should be number + "completed": "yes", // Should be boolean +] +``` + +### Keep properties relevant + +```swift +// ✅ Good - relevant properties +Helium.shared.trackPaywallEvent("product_selected", properties: [ + "product_id": "monthly_premium", + "price": 9.99, + "position": 1 +]) + +// ❌ Too much - irrelevant properties +Helium.shared.trackPaywallEvent("product_selected", properties: [ + "product_id": "monthly_premium", + "user_name": "John", + "device_model": "iPhone 14", + "battery_level": 85 +]) +``` + +## Viewing custom events + +### In the dashboard + +View custom events in the Helium dashboard: + +1. Navigate to **Events** +2. Filter by event name +3. View event properties +4. Analyze trends over time + +### Event analytics + +Track metrics for custom events: + +- **Event count**: How many times event fired +- **Unique users**: How many users triggered event +- **Conversion correlation**: How events relate to conversions +- **Property distribution**: Common property values + +## Integration with analytics + +Custom events can be forwarded to third-party analytics platforms. See [Third-party analytics integrations](/analytics/third-party-integrations). + +## Use cases + +### Optimize paywall design + +Track which elements users interact with: + +```swift +Helium.shared.trackPaywallEvent("feature_bullet_tapped", properties: [ + "feature": "unlimited_exports" +]) +``` + +Analyze which features resonate most with users. + +### Measure content effectiveness + +Track engagement with different content: + +```swift +Helium.shared.trackPaywallEvent("testimonial_viewed", properties: [ + "testimonial_id": "user_123", + "position": 2 +]) +``` + +See which testimonials drive conversions. + +### Identify friction points + +Track where users hesitate: + +```swift +Helium.shared.trackPaywallEvent("terms_link_tapped") +Helium.shared.trackPaywallEvent("privacy_policy_viewed") +``` + +Understand what concerns users have. + +### A/B test insights + +Track variant-specific interactions: + +```swift +Helium.shared.trackPaywallEvent("cta_button_tapped", properties: [ + "variant": "variant_a", + "button_text": "Start Free Trial" +]) +``` + +## Performance considerations + +### Event volume + +Be mindful of event volume: +- Don't track every pixel scroll +- Focus on meaningful interactions +- Batch events when possible + +### Property size + +Keep properties concise: +- Avoid large strings +- Don't include sensitive data +- Limit number of properties + +## Privacy and compliance + +### User consent + +Ensure you have user consent for tracking: +- Follow GDPR requirements +- Respect user privacy settings +- Provide opt-out mechanisms + +### Sensitive data + +Never track: +- Personal identifying information +- Payment details +- Passwords or credentials +- Health information + +## Testing custom events + +### Debug mode + +Enable debug logging to verify events: + +```swift +// iOS example +Helium.shared.setLogLevel(.verbose) +Helium.shared.trackPaywallEvent("test_event") +// Check console for event confirmation +``` + +### Event validation + +Verify events in dashboard: +1. Trigger event in test environment +2. Check dashboard for event appearance +3. Verify properties are correct +4. Confirm event timing + +## Best practices + +### Track meaningful events + +Focus on events that: +- Inform product decisions +- Correlate with conversions +- Identify user pain points +- Measure engagement + +### Consistent naming + +Use a naming convention: +- `object_action` format (e.g., `video_played`) +- Lowercase with underscores +- Descriptive and specific + +### Document events + +Maintain documentation of: +- Event names and purposes +- Property definitions +- When events fire +- How to interpret data + +### Regular review + +Periodically review: +- Which events are still useful +- Event volume and performance +- Data quality +- New events needed + +## Next steps + +- Learn about [Revenue tracking](/events/revenue-tracking) +- Explore [Third-party analytics integrations](/analytics/third-party-integrations) +- Set up [Experiments](/primitives/experiments) using event data diff --git a/events/revenue-tracking.mdx b/events/revenue-tracking.mdx new file mode 100644 index 0000000..69c7a0f --- /dev/null +++ b/events/revenue-tracking.mdx @@ -0,0 +1,131 @@ +--- +title: "Revenue tracking" +description: "Track and analyze subscription revenue" +--- + +Revenue tracking connects your purchase and subscription data to Helium so it can **attribute revenue to specific paywalls, experiments, and triggers**. Without it, Helium can track paywall opens and user interactions, but can't tell you which paywall actually drove a subscription. + +Revenue data flows _into_ Helium from your payment platforms via webhooks. + +--- + +### **Supported Sources** + +| Source | How it connects | What it sends | +| :--------------------------------- | :-------------- | :--------------------------------------------------------------------- | +| **RevenueCat** | Webhook | Purchases, renewals, cancellations, refunds, billing issues, and more. | +| **App Store Server Notifications** | Webhook | Apple purchase, renewal, and refund events directly from Apple. | +| **Google Play RTDN** | Pub/Sub | Google Play purchase and subscription state changes. | +| **Stripe Connect** | Webhook | Stripe subscription and payment events. | + +You can connect **multiple sources**. If you use RevenueCat as a wrapper around App Store and Google Play, you typically only need the RevenueCat integration. If you process payments directly, connect the platform-specific webhooks instead. + +--- + +### **RevenueCat** + +The most common integration. RevenueCat aggregates purchases from all stores and forwards them to Helium in a single webhook. + +#### Setup + +1. Go to **Integrations** → **Revenue Tracking** in the Helium dashboard. +2. Copy the **webhook URL** and **authorization token**. +3. In RevenueCat, go to **Integrations** → **Webhooks**. +4. Add a new webhook with the URL and token from Helium. +5. Select the events you want to forward. + +#### Event Mapping + +| RevenueCat Event | Helium Type | +| :-------------------- | :------------------------ | +| INITIAL_PURCHASE | subscription_start | +| RENEWAL | subscription_renewal | +| CANCELLATION | subscription_cancel | +| NON_RENEWING_PURCHASE | purchase | +| UNCANCELLATION | subscription_reactivation | +| BILLING_ISSUE | billing_issue | +| EXPIRATION | subscription_expiration | + +Helium normalizes all RevenueCat events into a standard schema with fields like revenue_usd, product_id, store, and app_user_id, so metrics work consistently regardless of the original store. + +--- + +### **App Store Server Notifications** + +For teams that don't use RevenueCat and want purchase data directly from Apple. + +#### Setup + +1. Copy the webhook URL from the **Revenue Tracking** tab in the Helium dashboard. + + +1. In **App Store Connect**, go to your app → **App Information**. + + +1. Paste the URL into the **Production Server URL** field. + +Apple will send purchase, renewal, refund, and subscription state change events directly to Helium. + +--- + +### **Google Play Real-Time Developer Notifications** + +For teams processing Google Play purchases without RevenueCat. + +#### Setup + +1. In the **Revenue Tracking** tab, create or connect a **Cloud Pub/Sub topic**. + + +1. In the **Google Play Console**, go to **Monetization Setup** → **Real-Time Developer Notifications**. + + +1. Configure it to publish to the Pub/Sub topic from step 1. + +Google Play will send purchase and subscription state changes to Helium via the Pub/Sub topic. + +--- + +### **Stripe Connect** + +For teams using Stripe for web-based or Apple Pay subscriptions. + +#### Setup + +1. In the **Revenue Tracking** tab, connect your Stripe account. +2. Helium receives subscription and payment events via Stripe webhooks automatically. + +#### Avoiding Double-Counting + +If you use **both RevenueCat and Stripe Connect**, RevenueCat may also forward Stripe events. To prevent double-counting, the Revenue Tracking tab lets you choose which source Helium uses for Stripe revenue: + +| Setting | Behavior | +| :----------------- | :--------------------------------------------------------------------------------- | +| **Stripe Connect** | Helium uses Stripe's webhooks directly. Stripe events from RevenueCat are ignored. | +| **RevenueCat** | Helium uses RevenueCat's webhook for all revenue, including Stripe. | + +Pick one source per store — don't send the same purchase from both. + +--- + +### **What Revenue Tracking Enables** + +Once connected, revenue data powers several features in Helium: + +| Feature | How revenue data is used | +| :-------------------------- | :---------------------------------------------------------------------------- | +| **Paywall attribution** | See which paywall a subscriber converted on. | +| **Experiment metrics** | Compare revenue per variant — ARPU, conversion rate, trial-to-paid, and more. | +| **Trigger-level analytics** | Break down revenue by trigger to see which entry points drive the most value. | +| **Subscription lifecycle** | Track renewals, cancellations, and refunds attributed to specific paywalls. | +| **Monitoring** | Revenue metrics on the dashboard (MRR, ARPU, LTV projections). | + +--- + +### **Key Points** + +- Revenue tracking is **inbound** — purchase platforms send data to Helium, not the other way around. +- All revenue events are **normalized** into a standard schema, regardless of source. +- **Pick one source per store** to avoid double-counting (especially Stripe via RevenueCat vs. Stripe Connect). +- Revenue data is **linked to paywall sessions** using user identity — this is how Helium attributes a subscription to the specific paywall that converted the user. +- Events are processed and available in metrics **within minutes** of being received. \ No newline at end of file diff --git a/guides/paddle-setup.mdx b/guides/paddle-setup.mdx new file mode 100644 index 0000000..3b79914 --- /dev/null +++ b/guides/paddle-setup.mdx @@ -0,0 +1,266 @@ +# 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 iOS SDK 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 | +| :-- | :-- | +| `product.write` | Create and sync products | +| `price.write` | Create and sync prices | +| `customer.write` | Create customers for end users | +| `transaction.write` | Create transactions for purchases | +| `subscription.write` | Manage subscription lifecycle | +| `adjustment.read` | View refunds and chargebacks | +| `notification_setting.write` | Auto-configure webhooks | +| `payment_method.read` | List saved payment methods | +| `client_token.write` | Programmatically create client-side tokens | +| `discount.read` | Read discount info for paywalls | + +**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. 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. + +### 4. (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. +- **Intro pricing** (e.g., \$1.99 for the first 3 months, then \$9.99/mo) is **not yet supported.** + +### 5. 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. + +### 6. SDK integration + +iOS (native) 4.4.7\+ + +**Enable external web checkout before initializing Helium:** + +```swift +Helium.config.enableExternalWebCheckout( + successURL: "yourapp://openapp", + cancelURL: "yourapp://openapp", + paymentProcessors: .paddle +) +``` + +The `successURL` and `cancelURL` are set once, globally. Make sure both are registered as deep links in your app (via scheme or universal link). + +**Set your user ID on Helium** before the user reaches a paywall. User IDs are strongly preferred. If one isn't set at purchase time, the SDK falls back to a persistent device ID and reconciles after login. _If you must set user ID after showing paywall, let us know — there is a workaround._ + +**Handle the post-purchase return.** When checkout completes, the user is deep-linked back to your app via the success 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 +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)") +} +``` + +**(Optional) Add Apple Pay Capability** for more advanced targeting options. In Xcode, open your project settings, click the **Signing & Capabilities** tab, and add the **Apple Pay** capability. This requires you [create a merchant ID](https://developer.apple.com/help/account/capabilities/configure-apple-pay#create-a-merchant-identifier). + +Expo 3.4.4\+ + +**Enable external web checkout before initializing Helium:** + +```tsx +enableExternalWebCheckout( + successURL: "yourapp://openapp", + cancelURL: "yourapp://openapp", + paymentProcessors: ["paddle"], +) +``` + +The `successURL` and `cancelURL` are set once, globally. Make sure both are registered as deep links in your app (via scheme or universal link). + +**Set your user ID on Helium** before the user reaches a paywall. User IDs are strongly preferred. If one isn't set at purchase time, the SDK falls back to a persistent device ID and reconciles after login. _If you must set user ID after showing paywall, let us know — there is a workaround._ + +**Handle the post-purchase return.** When checkout completes, the user is deep-linked back to your app via the success URL. For a smoother handoff, call `heliumHandleURL(url)` from your deep link handling: + +```tsx +useEffect(() => { + const sub = Linking.addEventListener('url', (event) => heliumHandleURL(event.url)); + void Linking.getInitialURL().then(heliumHandleURL); + return () => sub.remove(); +}, []); +``` + +**Add a "Manage Subscription" button** so users can cancel or update payment methods via Paddle's customer portal: + +```tsx +if (await hasActivePaddleEntitlement()) { + // show the manage subscription button +} + +// when the button is tapped: +const url = await createPaddlePortalSession(); +if (url) { + await Linking.openURL(url); +} +``` + +**(Optional) Add Apple Pay Capability** for more advanced targeting options. This requires you [create a merchant ID](https://developer.apple.com/help/account/capabilities/configure-apple-pay#create-a-merchant-identifier). + +Add the Apple Pay entitlement to your `app.json` (or `app.config.js`): + +```json +{ + "expo": { + "ios": { + "entitlements": { + "com.apple.developer.in-app-payments": [ + "your-merchant-identifier" + ] + } + } + } +} +``` + +Flutter 3.3.4\+ + +**Enable external web checkout before initializing Helium:** + +```dart +await HeliumFlutter().enableExternalWebCheckout( + successURL: "yourapp://openapp", + cancelURL: "yourapp://openapp", + paymentProcessors: {HeliumWebCheckoutProcessor.paddle}, +) +``` + +The `successURL` and `cancelURL` are set once, globally. Make sure both are registered as deep links in your app (via scheme or universal link). + +**Set your user ID on Helium** before the user reaches a paywall. User IDs are strongly preferred. If one isn't set at purchase time, the SDK falls back to a persistent device ID and reconciles after login. _If you must set user ID after showing paywall, let us know — there is a workaround._ + +**Handle the post-purchase return.** When checkout completes, the user is deep-linked back to your app via the success URL. For a smoother handoff, call `handleURL(url)` from your deep link handling: + +```dart +_linkSubscription = AppLinks().uriLinkStream.listen((uri) { + HeliumFlutter().handleURL(uri.toString()); +}); +``` + +**Add a "Manage Subscription" button** so users can cancel or update payment methods via Paddle's customer portal: + +```dart +final hasPaddle = await HeliumFlutter().hasActivePaddleEntitlement(); +if (hasPaddle) { + // show the manage subscription button +} + +// when the button is tapped: +final url = await HeliumFlutter().createPaddlePortalSession(); +if (url != null) { + final Uri url = Uri.parse(url); + await launchUrl(url); +} +``` + +**(Optional) Add Apple Pay Capability** for more advanced targeting options. Open `ios/Runner.xcodeproj` in Xcode, click the **Signing & Capabilities** tab on the Runner target, and add the **Apple Pay** capability. This requires you [create a merchant ID](https://developer.apple.com/help/account/capabilities/configure-apple-pay#create-a-merchant-identifier). + +### 7. (Optional) Connect RevenueCat + +If you use RevenueCat for entitlement management: + +1. Create a Paddle web configuration in the RevenueCat dashboard. +2. Set purchase tracking to **automatic**, with the `rc_user_id` metadata field. +3. Import your Paddle products into RevenueCat. +4. Attach those products to the appropriate entitlement(s). + +RevenueCat entitlements take a moment 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. + +## Testing + +Run through these scenarios in Paddle's sandbox before going live: + +1. **Happy path purchase.** Open a Paddle paywall in-app, tap a product, complete checkout in the browser, and confirm you're deep-linked back with an active entitlement. +2. **Entitlement sync.** After purchase, confirm `hasActivePaddleEntitlement()` returns `true` immediately, and that RevenueCat (if connected) updates within a few seconds. +3. **Free trial.** Purchase a trial product, cancel it, and confirm the trial is no longer offered on re-purchase. +4. **Cancel flow.** Open the customer portal from your app, cancel a subscription, and confirm the entitlement expires at period end. +5. **Pre-login purchase.** Make a purchase before setting a user ID. After you set the user ID, confirm the entitlement attaches correctly. +6. **Abandoned checkout.** Close the browser mid-checkout and confirm the app handles the cancel URL gracefully. +7. **Webhook delivery.** In the Paddle dashboard, confirm webhooks are reaching Helium with 2xx responses. + +## 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. + +### Why are success/cancel URLs set in the SDK? + +Deep links are specific to your app build, so it's cleaner to configure them alongside SDK init than in the dashboard. They're global: set once, applied to every Paddle purchase. + +### Known limitations and gotchas + +- **Intro pricing** (`$X for first N months, then $Y/mo`) is not yet supported. +- **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:** if you support multiple languages in-app, confirm your hosted web paywall's localizations match. +- **Prepaid card failures / declined payments:** the user is returned via the `cancelURL`. Your app should treat this as "no purchase made." \ No newline at end of file diff --git a/images/Screenshot2026-03-12at12.48.32PM.png b/images/Screenshot2026-03-12at12.48.32PM.png new file mode 100644 index 0000000..62687f0 Binary files /dev/null and b/images/Screenshot2026-03-12at12.48.32PM.png differ diff --git a/images/Screenshot2026-03-12at12.55.29PM.png b/images/Screenshot2026-03-12at12.55.29PM.png new file mode 100644 index 0000000..953b575 Binary files /dev/null and b/images/Screenshot2026-03-12at12.55.29PM.png differ diff --git a/images/Screenshot2026-03-12at4.55.05PM-1.png b/images/Screenshot2026-03-12at4.55.05PM-1.png new file mode 100644 index 0000000..c6b21cd Binary files /dev/null and b/images/Screenshot2026-03-12at4.55.05PM-1.png differ diff --git a/images/Screenshot2026-03-12at4.55.05PM.png b/images/Screenshot2026-03-12at4.55.05PM.png new file mode 100644 index 0000000..c6b21cd Binary files /dev/null and b/images/Screenshot2026-03-12at4.55.05PM.png differ diff --git a/images/Screenshot2026-03-12at5.01.55PM.png b/images/Screenshot2026-03-12at5.01.55PM.png new file mode 100644 index 0000000..bab401f Binary files /dev/null and b/images/Screenshot2026-03-12at5.01.55PM.png differ diff --git a/images/Screenshot2026-03-12at5.02.25PM.png b/images/Screenshot2026-03-12at5.02.25PM.png new file mode 100644 index 0000000..2ad0dee Binary files /dev/null and b/images/Screenshot2026-03-12at5.02.25PM.png differ diff --git a/images/Screenshot2026-03-12at5.03.10PM.png b/images/Screenshot2026-03-12at5.03.10PM.png new file mode 100644 index 0000000..8306d19 Binary files /dev/null and b/images/Screenshot2026-03-12at5.03.10PM.png differ diff --git a/introduction/introduction.mdx b/introduction/introduction.mdx new file mode 100644 index 0000000..4d354d0 --- /dev/null +++ b/introduction/introduction.mdx @@ -0,0 +1,40 @@ +--- +title: "Introduction" +description: "Learn about Helium" +keywords: ["introduction"] +--- + +## 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 + + \ No newline at end of file diff --git a/introduction/quickstart.mdx b/introduction/quickstart.mdx new file mode 100644 index 0000000..4a03639 --- /dev/null +++ b/introduction/quickstart.mdx @@ -0,0 +1,93 @@ +--- +title: "Setup and Configuration" +description: "Get set up with a Helium paywall and incorporate it into your app!" +keywords: ["quickstart"] +--- + +This quickstart shows you how to set up, preview, and go live with an AI-editable Helium paywall in your app. + + + + Create your Helium account at [app.tryhelium.com](https://app.tryhelium.com). + + + Screenshot 2026 03 12 At 12 55 29 PM + + 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. + + + Visit [Integrations → Products](https://app.tryhelium.com/integrations/products) and sync your products from the App Store and/or Google Play store. + + + Navigate to the Paywalls tab in the sidebar and click **New Paywall** to get started. You will get a modal that will then allow you to create a paywall from scratch, use an existing template, or get started straight from screenshots. + + + ![Screenshot 2026 03 12 At 4 55 05 PM](/images/Screenshot2026-03-12at4.55.05PM-1.png) + + Once you create your paywall you will need to add the product buckets that you added above. Then use our editor to tune and edit your paywall to your liking: you can check out our editor best practices to get a better understanding of the editor capabilities.  + + + If you're looking for battle tested ideas for your Paywall be sure to check out our guide: https://tryhelium.com/blog/100-paywall-tests-that-work + + + + A workflow defines which paywall(s) to show a user and when. Each workflow contains one or more triggers, which you reference from your app code to display the appropriate paywall. + + 1. Navigate to **Workflows** and click **Create Workflow**. (Navigate to the workflows page from the sidebar navigation \> Click create workflow \> Give your workflow a name \> Select your newly created paywall \> Save!) + 2. Add a **trigger** to your workflow. (Navigate to your newly created workflow \> Click on Add Trigger \> Create a new trigger using the modal \> Add the newly created trigger to your workflow \> Save!) + + + + + ![Creating a workflow.](/images/Screenshot2026-03-12at5.02.25PM.png) + + + + + ![Screenshot 2026 03 12 At 5 03 10 PM](/images/Screenshot2026-03-12at5.03.10PM.png) + + + + Triggers should reflect the specific moments in your app where a paywall should appear — for example, when a user attempts to access a premium feature. + + + Select your SDK framework and complete the guide to get integrated in your app: + + + + Swift/SwiftUI/UIKit + + + Kotlin/Jetpack Compose + + + React Native/Expo + + + Flutter integration + + + + + Revenue reporting enables Helium to provide detailed analytics about your app's monetization performance. **Enabling it is recommended to get the most out of Helium.** + + Follow our [guide on revenue reporting](https://docs.tryhelium.com/guides/revenue-reporting) to connect Helium to your app's revenue events via webhooks. + + + +## Pre-launch and go live! + +Before going live, here's a quick 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 +- Tested in Testflight \ No newline at end of file diff --git a/migrations/overview.mdx b/migrations/overview.mdx new file mode 100644 index 0000000..a54a11c --- /dev/null +++ b/migrations/overview.mdx @@ -0,0 +1,164 @@ +--- +title: Migrations +description: Migrate between Helium SDK versions +--- + +# Migrations + +This guide helps you migrate between different versions of the Helium SDK. + +## Available migration guides + +Select your platform and version to view the migration guide: + +### iOS + + + + Migrate from iOS SDK version 3.x to 4.x + + + Migrate from iOS SDK version 2.x to 3.x + + + +### Android + + + Migrate from Android SDK version 0.x to 4.x + + +## Migration best practices + +### Before you migrate + +1. **Review changelog**: Read the full changelog for breaking changes +2. **Test thoroughly**: Test migration in development environment first +3. **Backup code**: Commit current working code before starting +4. **Plan timing**: Schedule migration during low-traffic periods +5. **Communicate**: Inform team members about upcoming changes + +### During migration + +1. **Follow guide**: Complete all steps in the migration guide +2. **Update dependencies**: Ensure all dependencies are compatible +3. **Fix deprecations**: Address all deprecation warnings +4. **Test features**: Verify all features work as expected +5. **Check analytics**: Confirm events are tracking correctly + +### After migration + +1. **Monitor errors**: Watch for crashes or errors in production +2. **Verify revenue**: Ensure purchases are processing correctly +3. **Check analytics**: Confirm data is flowing to analytics platforms +4. **Update documentation**: Document any custom changes made +5. **Plan next steps**: Consider adopting new features + +## Common migration tasks + +### Updating initialization + +Most migrations require updating SDK initialization: + +```swift +// Old (example) +Helium.configure(apiKey: "your-api-key") + +// New (example) +Helium.configure( + apiKey: "your-api-key", + options: HeliumOptions() +) +``` + +### Updating method calls + +Method signatures may change: + +```swift +// Old (example) +Helium.shared.presentPaywall() + +// New (example) +Helium.shared.presentPaywall(completion: { result in + // Handle result +}) +``` + +### Updating event tracking + +Event tracking APIs may be updated: + +```swift +// Old (example) +Helium.shared.logEvent("custom_event") + +// New (example) +Helium.shared.trackEvent("custom_event", properties: [:]) +``` + +## Breaking changes + +Migration guides clearly identify breaking changes: + +- **API changes**: Method signatures or parameters changed +- **Behavior changes**: Functionality works differently +- **Removed features**: Features no longer supported +- **New requirements**: Additional setup or configuration needed + +## Deprecation timeline + +Helium follows a deprecation policy: + +1. **Deprecation announced**: Feature marked as deprecated +2. **Grace period**: Feature continues to work (typically 6-12 months) +3. **Removal**: Feature removed in next major version + +Always address deprecation warnings promptly. + +## Getting help + +If you encounter issues during migration: + +1. **Check migration guide**: Ensure all steps were completed +2. **Review changelog**: Look for additional context +3. **Search documentation**: Look for related topics +4. **Contact support**: Email support@tryhelium.com with details + +## Version compatibility + +### SDK versions + +Ensure SDK versions are compatible: + +- iOS SDK 4.x requires iOS 13.0+ +- Android SDK 4.x requires Android 5.0+ (API 21+) +- React Native SDK requires React Native 0.64+ +- Flutter SDK requires Flutter 2.0+ + +### Platform versions + +Check platform requirements: + +- Xcode version for iOS +- Android Studio version for Android +- Node version for React Native +- Dart version for Flutter + +## Rollback plan + +If migration causes issues: + +1. **Revert code**: Return to previous version +2. **Restore dependencies**: Downgrade SDK version +3. **Redeploy**: Push previous working version +4. **Investigate**: Determine root cause +5. **Plan retry**: Address issues before retrying migration + +## Next steps + +Select your migration guide: + +- [iOS 3.x to 4.x](/migrations/ios-3-to-4) +- [iOS 2.x to 3.x](/migrations/ios-2-to-3) +- [Android 0.x to 4.x](/migrations/android-0-to-4) diff --git a/primitives/audiences.mdx b/primitives/audiences.mdx new file mode 100644 index 0000000..9f0e945 --- /dev/null +++ b/primitives/audiences.mdx @@ -0,0 +1,187 @@ +--- +title: "Audiences" +description: "Segment users for targeted experiences" +--- + +An **audience** is a segment of your users defined by a set of rules. Audiences determine _who_ sees what inside a workflow — you can route different user groups to different paywalls, experiments, or suppress the paywall entirely. + +Audiences are used through a workflow's **targeting criteria**, where each rule maps an audience to an action. They're the "who" to the workflow's "what." + +--- + +### **Anatomy of an Audience** + +| Field | Description | +| :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | +| **Name** | A human-readable label (e.g., "US Power Users"). Must be unique within your organization. | +| **Description** | An optional note explaining the segment. | +| **Rules** | A set of conditions that define which users belong to this audience. Rules use AND/OR logic across device, app, and custom user attributes. | + +--- + +### **Rules and Conditions** + +An audience's rules define membership. Each rule tests a **variable** against a **value** using an **operator**. Rules are combined with **AND** or **OR** at the top level. + +#### Variables + +You can target on two categories of attributes: + +- **System variables** — Built-in device and app properties available for all users. Examples: + - `locale.currentCountry `— The user's country + - `deviceInfo.systemVersion` — OS version (supports semver comparisons) + - `applicationInfo.version` — App version (supports semver comparisons) + - `applicationInfo.heliumSdkVersion` — Helium SDK version + - Device type, OS name, language, and more + + +- **Custom user traits** — Properties you pass to the Helium SDK from your app (e.g., subscription_tier, days_since_install, has_completed_onboarding). These are fully custom and scoped to your organization. + +#### Operators + +Available operators depend on the variable type: + +| Type | Operators | +| :-------------------------- | :------------------------------------------ | +| **String** | =, !=, exists, not exists | +| **Number** | =, !=, \>, \<, \>=, \<=, exists, not exists | +| **Boolean** | =, exists, not exists | +| **Datetime** | \>, \<, \>=, \<=, exists, not exists | +| **Semver** (app/OS version) | =, !=, \>, \<, \>=, \<=, exists, not exists | + +Multi-value selection is supported for `=` and `!=` on string variables (e.g., country is one of US, CA, GB). + +#### Example Rules + +- **US users on iOS 17+**: `locale.currentCountry = US AND deviceInfo.systemVersion >= 17.0` + + +- **Power users**: `custom.days_since_install > 30 AND custom.sessions_count > 50` + + +- **Non-subscribers**: `custom.subscription_tier not exists` + +--- + +### **The "All Users" Audience** + +Helium has a special system audience called **All Users**. It matches every user unconditionally. + +- Stored as a real audience record with empty rules `({ operator: "AND", rules: [], isAllUsers: true })`. + + +- Created automatically the first time you scope an experiment to "All Users." + + +- Hidden from the standard audiences list — it only appears when selecting audiences for experiments or workflow targeting. + + +- In analytics queries, it translates to no filtering. + +You don't need to create it manually. When you select "All Users" during experiment creation, Helium handles it for you. + +--- + +### **Lifecycle of an Audience** + + + + Audiences can be created in several ways: + + - **From the Targeting page** — Use the audience builder with the full rule editor. + + + - **From experiment creation** — The audience selector lets you create a new audience inline while setting up an A/B test. + + When creating an audience, names are validated for uniqueness (case-insensitive) and rules are stored in both a legacy format and JSON Logic format for compatibility. + + + Once created, audiences are available in two places: + + - **Workflow targeting criteria** — Map an audience to a paywall, experiment, or "no paywall" action. Rules are evaluated in order; the first matching audience wins. + + + - **Experiment scoping** — Scope an experiment to a specific audience so only matching users are enrolled. + + An audience can be used across multiple workflows and experiments simultaneously. + + + Each audience shows real-time metrics computed from your analytics data: + + | Metric | Description | + | :--------------------- | :------------------------------------------------------------------------------ | + | **Unique users (7d)** | Distinct users matching this audience in the last 7 days. | + | **Paywall opens (7d)** | Number of paywall open events from users in this audience over the last 7 days. | + + These metrics are computed on-demand from ClickHouse — they're not cached or pre-aggregated, so they reflect current data. During experiment creation, Helium shows an "estimated exposed users per day" based on these metrics to help you gauge experiment reach. + + + You can update an audience's **name**, **description**, and **rules** at any time. Changes apply to all workflows and experiments referencing that audience. Keep in mind that changing rules alters who qualifies — if you narrow the rules, some users currently seeing a paywall may stop matching. + + + An audience can only be deleted if it is **not currently referenced by any workflow's targeting criteria**. If it's in use, you'll need to remove it from the workflow first. Deletion is permanent. + + + +--- + +### **Stripe Targeting Constraints** + +If a workflow or experiment includes paywalls with Stripe products, the audience must meet specific targeting requirements. "All Users" does **not** qualify for Stripe targeting because Stripe paywalls only work for a subset of users (Apple Pay enabled, US App Store). You'll need to create a more targeted audience that filters for eligible users. + +Helium validates this automatically and shows a warning if Stripe targeting constraints aren't met. + +--- + +### **Integration with Workflows** + +Audiences connect to workflows through **targeting criteria** — an ordered list of rules on the workflow: + +1. For each rule: "If the user is in **[audience]**, then **[show paywall / run experiment / show nothing]**." + + +1. Rules are evaluated top-to-bottom. The first matching audience determines the outcome. + + +1. Users who don't match any audience rule see the workflow's **control paywall**. + +This means audience order matters. A broad audience (like "All Users") placed first would catch everyone before more specific audiences get evaluated. + +--- + +### **Key Rules** + +- Audience names are **unique per organization** (case-insensitive). + + +- Rules use **AND/OR** logic with system variables and custom user traits. + + +- The **"All Users"** audience is a system audience — it's created automatically and hidden from the standard list. + + +- Audiences **cannot be deleted** while referenced by a workflow. + + +- Metrics are computed **in real-time** from ClickHouse (7-day window). + + +- Changes to audience rules take effect **immediately** across all workflows and experiments using that audience. + + +- Audiences can be **AI-generated** from natural language descriptions. + +--- + +### **Common Patterns** + +| Pattern | Description | +| :---------------------- | :-------------------------------------------------------------------------------------------------------- | +| **Country targeting** | locale.currentCountry = US — Show a US-specific paywall with USD pricing. | +| **Version gating** | applicationInfo.version \>= 3.0 — Only show a new paywall design to users on the latest app version. | +| **New user onboarding** | custom.days_since_install \<= 3 — Aggressive trial offer for users in their first few days. | +| **Power user upsell** | custom.sessions_count \> 50 AND custom.subscription_tier not exists — Target engaged free users. | +| **Platform split** | deviceInfo.osName = iOS vs. deviceInfo.osName = Android — Different paywall designs per platform. | +| **All Users fallback** | Use the "All Users" audience as a catch-all at the bottom of targeting criteria for a default experience. | + +--- \ No newline at end of file diff --git a/primitives/experiments.mdx b/primitives/experiments.mdx new file mode 100644 index 0000000..5600eeb --- /dev/null +++ b/primitives/experiments.mdx @@ -0,0 +1,154 @@ +--- +title: "Experiments" +description: "Run A/B tests to optimize conversion rates" +--- + +An **experiment** is an A/B test that compares two or more paywall variants to determine which one performs best. Experiments live within a workflow and can be scoped to specific audiences and locales, allowing you to test different monetization strategies on different user segments simultaneously. + +--- + +### **Anatomy of an Experiment** + +| Field | Description | +| :---------------------- | :-------------------------------------------------------------------------------------------------------- | +| **Experiment Name** | A human-readable label for the test (e.g., "Annual vs Monthly Q1 2025"). | +| **Hypothesis** | An optional description of what you're testing and why. | +| **Experiment Type** | The test methodology: abcd (standard A/B/C/D test), aa (validation test), or bandit (multi-armed bandit). | +| **Status** | The current state: running, scheduled, or stopped. | +| **Paywall Variants** | Two or more paywalls being tested, each with a traffic allocation percentage. | +| **Allocations** | The percentage of traffic routed to each variant (must total 100%). | +| **Control & Treatment** | Exactly one variant is marked as the **control** (your baseline). All others are **treatments**. | +| **Locale** | An optional locale scope (e.g., en_US). A global experiment applies to all locales. | +| **Start / End Date** | When the experiment begins and when it's scheduled to end. | + +--- + +### **Variants and Allocations** + +Each experiment contains two or more **variants** — paywall designs you're comparing. Every variant has: + +- A **paywall** — the actual paywall UI shown to users in that variant +- A **percentage allocation** — the share of eligible traffic this variant receives +- A **variant type** — either control (the baseline) or treatment (the challenger) + +Allocations must total exactly **100%**. When a user qualifies for the experiment, they're randomly assigned to a variant based on these percentages and consistently see that same variant on subsequent triggers. + +You can **update allocations on a running experiment**. When you do, Helium creates an **experiment version** — a snapshot of the allocation state. This lets you ramp up a promising treatment or wind down an underperformer without stopping the test entirely. + +--- + +### **Lifecycle of an Experiment** + + + + Experiments are created from within a workflow's detail page. The creation flow has four steps: + + 1. **Basic Info** — Name your experiment and state your hypothesis. + 1. **Target Audience** — Choose which audience this experiment applies to. You can select "All Users," pick an existing audience, or create a new one. + 1. **Paywalls & Allocations** — Select the paywall variants, mark one as control, and set traffic percentages. + 1. **Review** — Confirm your configuration before launching. + + Experiments start **immediately** upon creation — there is no draft state. + + + While an experiment is running: + + - Users are **allocated to variants** based on the configured percentages. + - Each user is **consistently bucketed** — they see the same variant every time the trigger fires. + - You can **view results** on the experiment detail page, including per-variant metrics. + - You can **adjust allocations** on the fly. Changing allocations creates a new experiment version for clean cohort tracking. + - You can **add or remove paywall variants** from a running experiment. + + + When you stop an experiment, you decide what happens to the audience that was enrolled: + + | Option | Behavior | + | :-------------------------------- | :----------------------------------------------------------------------------------------------------------------------- | + | **Route to a specific paywall** | Replace the experiment with a static paywall for that audience. Typically used to lock in the winner. | + | **Fall through to next audience** | Remove the audience's targeting rule entirely, letting the user match the next applicable rule (or the control paywall). | + + Stopping an experiment updates the workflow's targeting criteria automatically. The experiment's status changes to stopped and a stopped_at timestamp is recorded. + + + Stopped experiments and their data remain available for analysis. You can filter and group metrics by experiment to compare variant performance across revenue, conversion rate, and other metrics. + + + Deleting an experiment removes it and its locale associations. A database constraint prevents deleting a **running** experiment — you must stop it first. Deletion is permanent. + + + +--- + +### **Overlap and Conflict Rules** + +The database enforces several constraints to prevent conflicting experiments: + +- **One running experiment per workflow + locale.** You can't have two active experiments competing for the same locale within the same workflow. +- **Global experiments block all others.** A global experiment cannot overlap in time with any other experiment, regardless of locale or workflow. +- **Same-locale overlap protection.** Two experiments in the same workflow and locale cannot have overlapping date ranges if both are running or scheduled. + +If you try to create an experiment that violates these rules, you'll see an overlap conflict error. + +--- + +### **Audience Scoping** + +Experiments are connected to audiences through the workflow's **targeting criteria**. When you create an experiment and select an audience: + +- Helium adds a targeting rule to the parent workflow: "For this audience, run this experiment." +- Only users who match the audience see the experiment. +- Users outside the audience follow the workflow's other rules (or fall through to the control paywall). + +If you select **All Users**, Helium creates (or reuses) a system "All Users" audience and maps it to the experiment. + +Audiences that are already mapped to another experiment in the same workflow are excluded from selection, preventing double-enrollment. + +--- + +### **Experiment Versions** + +Every time you change an experiment's allocations, Helium creates an **experiment version** — an immutable snapshot of the allocation state at that point in time. This enables: + +- Clean cohort analysis (users allocated under version 1 vs. version 2) +- Allocation change history +- Accurate attribution even when you ramp traffic mid-experiment + +--- + +### **Stripe Paywall Constraints** + +If any paywall variant in your experiment uses Stripe products, additional rules apply: + +- **Stripe paywalls cannot be the control variant.** Stripe paywalls only work for a subset of users (Apple Pay enabled, US store), so using one as the control would bias results. +- **Audience must have Stripe-compatible targeting.** If you include a Stripe paywall, the experiment's audience must be configured to target only eligible users. + +These constraints are validated during experiment creation and will block you if not satisfied. + +--- + +### **Key Rules** + +- Experiments start **immediately** — there is no draft state. +- Allocations must total exactly **100%**. +- Exactly **one variant** must be marked as the control. +- Only **published paywalls** can be used as variants. +- **Running experiments cannot be deleted** — stop them first. +- Only **one running experiment per workflow + locale** is allowed at a time. +- **Global experiments** cannot overlap with any other experiment. +- Changes to allocations are **versioned** for clean cohort tracking. +- Stopping an experiment automatically updates the parent workflow's targeting criteria. + +--- + +### **Common Patterns** + +| Pattern | Description | +| :----------------------- | :------------------------------------------------------------------------------------------------------------ | +| **Simple A/B test** | Two paywalls (control vs. treatment), 50/50 split, scoped to all users. The classic test. | +| **Multi-variant test** | Three or more paywalls with custom allocations (e.g., 40% control, 30% treatment A, 30% treatment B). | +| **Audience-scoped test** | Run an experiment only for a specific user segment, like "users on day 3+" or "users in the US." | +| **Ramp-up** | Start with 90/10 control/treatment, then gradually increase the treatment allocation as confidence grows. | +| **Locale-specific test** | Run different experiments per locale — test annual pricing in the US while testing monthly pricing in Europe. | +| **AA test** | Show the same paywall to both groups to validate your instrumentation and statistical methodology. | + +--- \ No newline at end of file diff --git a/primitives/paywalls.mdx b/primitives/paywalls.mdx new file mode 100644 index 0000000..601c8b5 --- /dev/null +++ b/primitives/paywalls.mdx @@ -0,0 +1,225 @@ +--- +title: "Paywalls" +description: "Create and customize subscription paywalls" +--- + +A **paywall** is a fullscreen UI that you design in Helium and display to users in your app. It's built with React, rendered in a WebView, and can be fully customized — layout, copy, images, animations, products, and translations. Paywalls are the visual surface of your monetization strategy; workflows and experiments decide _when_ to show them, but the paywall is _what_ the user sees. + +--- + +### **Anatomy of a Paywall** + +| Field | Description | +| :------------------ | :---------------------------------------------------------------------------------------------------------- | +| **Paywall Name** | A human-readable label (e.g., "Annual Upsell — Dark Mode"). Max 100 characters. | +| **Description** | A short summary of the paywall's purpose. Max 250 characters. | +| **Product Buckets** | The products offered on this paywall — each bucket maps to a plan (monthly, annual, etc.) across platforms. | + +--- + +### **How Paywalls Are Rendered** + +Paywalls are **bundled into a single web file** and rendered inside a native WebView: + +1. Your app's SDK receives a trigger and resolves it to a workflow. +2. The SDK loads this bundle in a **WKWebView** (iOS) or **WebView** (Android). +3. The paywall communicates back to the native app via a bridge — handling subscribe, dismiss, restore purchases, and product selection events. + +This architecture means paywalls support anything a web page can: CSS animations, custom fonts, Lottie animations, responsive layouts, and dynamic product data. And your paywall can interact directly with the native functions of your mobile frameworks. + +--- + +### **Creating a Paywall** + +Paywalls can be created in four ways: + +| Method | Description | +| :-------------------- | :------------------------------------------------------------------------------------------------ | +| **From scratch** | Start with a blank canvas and build your UI in the code editor. | +| **From a template** | Pick a pre-built starter template and customize it. | +| **From a screenshot** | Upload a screenshot of a paywall you like — Helium's AI generates React code matching the design. | +| **From Figma** | Paste a Figma URL — Helium converts the design into a working paywall. | + +All creation methods follow the same flow: + +1. Enter a **name** and **description**. +2. Select **product buckets** (and choose initial iOS/Android/Stripe products). +3. Helium generates the initial files and opens the editor. + +--- + +### **The Paywall Editor** + +The editor is a live coding environment powered by **Sandpack** (CodeSandbox's embeddable bundler). You edit React code on the left and see a real-time preview on the right. + +#### Editable files + +| File | What you edit | +| :-------------------- | :--------------------------------------------------------------------------------------------- | +| **App.tsx** | The paywall's UI — layout, styling, components, animations. This is the main file you work in. | +| **paywallConfig.tsx** | Initial product selection logic, scroll settings, platform-specific behavior. | +| **localizations.ts** | Translation strings for every locale. (See [Localization] for the full localization system.) | + +#### Auto-generated files (read-only in most cases) + +| File | Purpose | +| :----------------------- | :----------------------------------------------------------------------------------------------------------- | +| **products.tsx** | Product prices, offers, and metadata. Regenerated when you change territory, products, or offer settings. | +| **index.html** | The HTML shell — includes font imports and global setup. | +| **withPaywallState.tsx** | A higher-order component that provides paywall state (subscribing, restoring, product selection) to your UI. | + +#### Preview features + +- **Device selector** — Preview on different phone models and sizes. + + +- **Platform toggle** — Switch between iOS and Android to see platform-specific products. + + +- **Territory picker** — Preview localized prices for any App Store or Play Store territory. + + +- **Offer simulation** — Toggle intro offer eligibility to preview trial and promotional pricing. + +--- + +### **Versioning** + +Every paywall has a **version history**. Each save creates a new version, building a timeline of changes. + +#### Version statuses + +| Status | Meaning | +| :------------ | :----------------------------------------------------------------------------------------------------- | +| **Draft** | A saved version that hasn't been published yet. Only visible in the editor. | +| **Published** | The live version served to users via the SDK. Only one published version exists per paywall at a time. | +| **Archived** | A previously published version that was superseded. Kept for history and rollback. | + +#### How it works + +- Every save (manual or autosave) creates a new **draft** version. + + +- The version stores all paywall files, product bucket references, font references, and a CRDT snapshot. + + +- Each version tracks its **parent version**, forming a version tree. + + +- You can browse version history and see who saved each version and when. + +--- + +### **Saving** + +Paywalls support both manual and automatic saving: + +| Trigger | Behavior | +| :------------------------- | :---------------------------------------------------------------------------------- | +| **Manual save** | Click "Save" or use the keyboard shortcut. Creates a draft version immediately. | +| **Autosave (interval)** | Periodic saves while you're editing. Debounced to avoid excessive version creation. | +| **Autosave (new paywall)** | Automatic first save when you create a new paywall. | + +Each save **bundles the code** into a deployable web file. This means your draft is always previewable — bundling doesn't wait for publish. + +--- + +### **Publishing** + +Publishing makes a draft version live. When you publish: + +1. **Validation** — Helium checks for issues like hardcoded prices (prices should come from product data, not be typed into the UI). +2. **Status update** — The draft becomes published. The previously published version becomes archived. +3. **SDK sync** — The bundle_url on the paywall record is updated so the SDK fetches the new version. +4. **History entry** — A record is added to paywall_publish_history with who published and when. + +Publishing is **instant** — once published, the next SDK fetch gets the new bundle. There's no deploy step or propagation delay. + +--- + +### **Products and Buckets** + +Paywalls don't reference raw product IDs. Instead, they reference **product buckets** — cross-platform containers that hold iOS, Android, and Stripe products for a single logical offering. + +- A paywall's `product_bucket_ids` lists which buckets it uses. + + +- The editor resolves buckets into a `products.tsx` file with localized pricing for the selected territory. + + +- When the SDK loads the paywall, it receives the product references and handles purchasing natively. + +--- + +### **How Paywalls Connect to Workflows** + +Paywalls are used in workflows in three ways: + +- **Control paywall** — Every workflow has a default paywall shown when no targeting rules match. + + +- **Audience targeting** — A workflow rule can map an audience directly to a specific paywall. + + +- **Experiment variants** — Paywalls serve as variants in A/B tests, each receiving a share of traffic. + +Only **published paywalls** can be assigned to workflows and experiments. Draft-only paywalls won't appear in selection lists. + +--- + +### **Second-Try Paywalls** + +A paywall can optionally have a **second-try paywall** — a follow-up paywall shown if the user dismisses the first one. This is configured via paywall settings field and allows for a softer re-engagement attempt (e.g., a discounted offer after the user declines the full-price paywall). + +Second-try paywalls can either appear after a paywall dismiss or after a subscription cancel event. + +--- + +### **Localization** + +Paywalls have a full localization system with per-locale translations, territory-aware pricing, and right-to-left layout support. Translation keys are defined and managed through the editor. Localizely support is available for localization management as well. + +See [Localization] for the complete localization documentation. + +--- + +### **Key Rules** + +- Paywall names can be **up to 100 characters**. + + +- Only **published paywalls** can be used in workflows and experiments. + + +- Only **one published version** exists per paywall at a time. + + +- Publishing runs **validation** — hardcoded prices and other issues can block publish. + + +- Paywalls **cannot be deleted** if they're referenced by a workflow. + + +- Every save **bundles the code** — drafts are always previewable. + + +- Product data is **auto-generated** from buckets — don't hardcode prices in your UI. + + +- Paywalls render in a **WebView** — they support standard web features (CSS, fonts, animations, Lottie). + +--- + +### **Common Patterns** + +| Pattern | Description | +| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | +| **Single-plan paywall** | One product bucket, one CTA. Clean and focused conversion. | +| **Multi-plan paywall** | Two or three product buckets (monthly, annual, lifetime) with a plan selector. The most common layout. | +| **Free trial emphasis** | Highlight the trial period prominently. Use introOfferEligible from product data to conditionally show trial messaging. | +| **Feature comparison** | List free vs. premium features with a comparison table. Common for productivity apps. | +| **Seasonal promotion** | Duplicate an existing paywall, update the copy and styling for a holiday sale, run it as an experiment. | +| **Second-try offer** | Main paywall at full price; second-try paywall with a discount or extended trial for users who dismiss. | +| **Localized paywall** | Same layout, different copy per locale. Prices are automatically localized from product buckets; translate the UI text in localizations.ts. | + +--- \ No newline at end of file diff --git a/primitives/paywalls/localization.mdx b/primitives/paywalls/localization.mdx new file mode 100644 index 0000000..48f68f5 --- /dev/null +++ b/primitives/paywalls/localization.mdx @@ -0,0 +1,226 @@ +--- +title: "Localization" +description: "Localize paywalls for different languages and regions" +--- + +Helium's localization system lets you translate your paywall's text into any language, preview territory-specific pricing, and manage translations at scale — with AI auto-translation, manual editing, and third-party sync via Localizely. + +Localization in Helium has two distinct layers: **copy** (translated UI text) and **pricing** (territory-specific product prices and currencies). They work independently — you can localize copy without changing pricing territory, and vice versa. + +--- + +### **How It Works** + +Every paywall has a localizations file that maps **translation keys** to **per-locale values**. + +At runtime, the SDK provides the user's preferred languages, and Helium resolves the best available translation using a waterfall: + +1. **Exact locale match** (e.g., es-MX) +2. **Base language match** (e.g., es from es-MX) +3. **Universal fallback** (the key — your source language) +4. **Inline fallback** + +--- + +### **Translation Keys** + +Translation keys are identifiers that connect your UI code to translated values. They originate from your paywall. + +#### How keys are created + +- **Automatic extraction** — When you create a paywall, Helium's AI analyzes your App.tsx and wraps localizable strings in translate() calls, generating stable keys. + + +- **Manual** — You can add translate("my_key", "fallback") calls directly in your code. + + +- **From the translation editor** — Add keys through the localization UI and reference them in code. + +#### Key format + +Keys are plain strings, typically `snake_case`: `hero_title`, `cta_subscribe`, `feature_list_item_1`. Auto-generated keys may include a hash suffix for uniqueness (e.g., `unlock_premium_a3f2b1`). + +--- + +### **Managing Translations** + +The **localization editor** is a sidebar/modal in the paywall editor with a full set of tools: + +| Feature | Description | +| :---------------------- | :-------------------------------------------------------------------------------------------------------- | +| **Translation table** | Edit values per key, per locale. Inline editing with immediate preview. | +| **Language tabs** | Switch between locales. Add or remove languages. | +| **Search** | Find keys by name or value. | +| **AI auto-translate** | Translate all keys to a new locale (or retranslate existing ones) using AI. | +| **Translation context** | Provide context to the AI (e.g., "This is a fitness app targeting young adults") for better translations. | +| **Unused key cleanup** | Detect and remove keys that are no longer referenced in App.tsx. | +| **Export** | Export translations as CSV or .strings files. | +| **Status tracking** | Each value is tagged with its source: base, helium (AI), user (manual), or localizely (synced). | + +--- + +### **AI Auto-Translation** + +Helium can auto-translate all keys to any supported locale using an LLM: + +1. Select a target locale (or translate to all added languages at once). +2. Helium sends the source values plus any translation context you've provided. +3. The AI returns translations, which are marked with status helium. +4. You can review and edit any auto-translated value — editing changes the status to user. + +Auto-translation is powered by Gemini and processes up to 100 keys per batch. It uses your source locale (typically English) as the input. + +AI is also used during **paywall creation** — when you create a paywall from scratch or a template, Helium can automatically identify localizable strings in your code, wrap them in translate() calls, and generate the initial localizations. + +**You can also simply prompt the editor to localize your paywall into whatever language is necessary!** + +--- + +### **Localizely Integration** + +For teams with existing translation workflows, Helium integrates with **Localizely** (a translation management platform): + +- **Sync overrides** — Localizely translations can override Helium values per key and locale. + + +- **Status tracking** — Values from Localizely are marked with status localizely and can be locked to prevent accidental edits. + + +- **Key linking** — Map Helium translation keys to Localizely keys. + + +- **Filter** — Filter the translation table to show only Localizely-managed or non-Localizely keys. + +--- + +### **Translation Metadata** + +Every translation value tracks its **source status**: + +| Status | Meaning | +| :--------- | :------------------------------------------------- | +| base | The original source value (your default language). | +| helium | Auto-translated by Helium's AI. | +| user | Manually edited by a team member. | +| localizely | Synced from Localizely. | + +Metadata is stored per key, per locale — so one key might have base for English, helium for Spanish, and user for German (if you manually corrected the AI's output). + +--- + +### **Territory and Pricing** + +**Territory controls product pricing. Locale controls UI text.** These are separate concepts: + +- **Locale** — Determines which translation values to display (e.g., es shows Spanish copy). + + +- **Territory** — Determines which price points to display (e.g., USA shows USD prices, JPN shows JPY). + +#### How pricing localization works + +1. Each product has **price points** per territory (from App Store Connect, Google Play, or Stripe). + + +1. When you select a territory in the editor, Helium resolves the correct price, currency, and currency symbol for that territory. + + +1. The products.tsx file is regenerated with localized pricing data. + + +1. Your paywall accesses prices through product data — never hardcode price strings. + +#### Territory picker + +The editor's context panel includes a territory selector. Changing it: + +- Updates all product prices to that territory's pricing. + + +- Updates offer eligibility (intro offers may vary by territory). + + +- Validates that the territory is available for the selected platform (iOS and Android use different territory code formats). + +The default territory is **USA**. If a product doesn't have pricing for the selected territory, Helium falls back to the closest available territory. + +--- + +### **Previewing Locales in the Editor** + +The paywall editor has a **locale selector** that lets you preview any language in real time: + +1. Select a locale from the dropdown (e.g., es, de, ja). +2. The editor sends the locale and translations to the preview iframe via postMessage. +3. The LocalizationProvider re-renders the paywall with the selected locale's translations. +4. You see exactly what a Spanish-speaking user (or German, Japanese, etc.) would see — with the correct copy and formatting. + +Changes made in the translation editor are reflected in the preview immediately, without saving. + +--- + +### **Locale-Specific Experiments** + +Separate from UI localization, Helium supports **per-locale experiments** at the workflow level. A workflow can run different A/B tests for different locales — for example, testing annual pricing in en users while testing monthly pricing for es users. + +This is configured through the workflow's locale-experiment mappings, not through the paywall's localization system. See [Experiments] and [Workflows] for details. + +--- + +### **SDK Runtime Behavior** + +When a paywall loads in your app: + +1. The SDK provides heliumContext.locale.preferredLanguages — the user's device language preferences (e.g., ["es-MX", "es", "en"]). + + +1. The paywall's LocalizationProvider receives this and resolves translations using the waterfall (exact locale → base language → fallback). + + +1. Product prices are resolved based on the user's **territory** (separate from language), using heliumContext.locale.currentCurrency and related fields. + +This means a user in Mexico with their phone set to Spanish sees: + +- **Copy** in es-MX (or es if no Mexico-specific translations exist) + + +- **Prices** in MXN from the Mexico App Store territory + +--- + +### **Key Rules** + +- The "" (empty string) locale is the **universal fallback** — always populate it with your source language values. + + +- Translation keys must match between App.tsx and localizations.ts — use the unused key cleanup to keep them in sync. + + +- **Don't hardcode prices** in your UI text — use product data from products.tsx. Hardcoded prices will block publishing. + + +- Territory and locale are **independent** — a user can have Spanish copy with USD pricing (e.g., a Spanish speaker in the US). + + +- AI translations are a starting point — always review auto-translated values for quality, especially for marketing copy. + + +- Each translation value tracks its **source status** (base, helium, user, localizely) for auditability. + + +- Locale-specific experiments are a **workflow-level** feature, not a paywall-level feature. + +--- + +### **Common Patterns** + +| Pattern | Description | +| :----------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **English-only paywall** | No localization needed. Use translate() calls anyway so you can add languages later without code changes. | +| **Top 5 languages** | Add English, Spanish, French, German, and Japanese. Use AI auto-translate for the initial pass, then review. | +| **Regional variants** | Add both es (generic Spanish) and es-MX (Mexico-specific). The SDK picks the best match based on the user's device. | +| **Price in copy** | Don't write "Only \$9.99/month" — instead use translate("price_cta") with the price injected from product data. This ensures prices are always correct per territory. | +| **Localizely workflow** | Manage translations in Localizely, sync to Helium. Localizely values override AI translations and are locked from accidental edits. | +| **Translation context** | Set context like "Premium fitness app targeting millennials" — the AI uses this for tone and terminology when translating. | + +--- \ No newline at end of file diff --git a/primitives/paywalls/product-buckets.mdx b/primitives/paywalls/product-buckets.mdx new file mode 100644 index 0000000..f150cb1 --- /dev/null +++ b/primitives/paywalls/product-buckets.mdx @@ -0,0 +1,196 @@ +--- +title: Product buckets +description: Dynamically manage product offerings in paywalls +--- + +# Product buckets + +Product buckets allow you to dynamically control which products appear in your paywalls without requiring app updates. + +## What are product buckets? + +Product buckets are named groups of products that can be updated remotely. Instead of hardcoding specific products in your paywalls, you reference a bucket, and Helium determines which products to show. + +## Benefits + +### No app updates required + +Change product offerings without: +- Submitting new app versions +- Waiting for app review +- Forcing users to update + +### Dynamic pricing + +Easily: +- Run promotional campaigns +- Test different price points +- Adjust offerings by region or audience + +### Simplified management + +- Update products in one place +- Consistent across all platforms +- Centralized control + +## Creating product buckets + +In the Helium dashboard: + +1. Navigate to **Product Buckets** +2. Click **Create Bucket** +3. Name your bucket (e.g., "premium_products") +4. Add products to the bucket +5. Set priority order + +## Using product buckets + +### In paywalls + +Assign a product bucket instead of specific products: + +1. Edit your paywall +2. In product selection, choose **Product Bucket** +3. Select your bucket +4. Products will be dynamically loaded + +### In code + +Reference products by bucket: + +```swift +// iOS example +Helium.shared.presentPaywall(productBucket: "premium_products") +``` + +## Product selection logic + +When a paywall uses a product bucket, Helium: + +1. Fetches the current products in the bucket +2. Filters by platform (iOS/Android) +3. Filters by availability +4. Returns products in priority order + +## Bucket strategies + +### Single bucket + +Use one bucket for all users: + +``` +premium_products: + - monthly_subscription + - annual_subscription +``` + +### Audience-specific buckets + +Create different buckets for different audiences: + +``` +premium_new_users: + - monthly_trial + - annual_discount + +premium_existing_users: + - monthly_standard + - annual_standard +``` + +### Regional buckets + +Customize offerings by region: + +``` +premium_us: + - monthly_9_99 + - annual_99_99 + +premium_eu: + - monthly_8_99 + - annual_89_99 +``` + +## Updating buckets + +Changes to product buckets take effect immediately: + +1. Edit the bucket in the dashboard +2. Add, remove, or reorder products +3. Save changes +4. Next paywall load will use updated products + +## Fallback handling + +Always include product buckets in your fallback bundle to ensure offline functionality. + +## Best practices + +### Naming conventions + +Use clear, descriptive names: +- `premium_products` not `bucket1` +- `trial_offers` not `temp` +- `regional_us` not `us` + +### Product ordering + +Order products strategically: +- Most popular first +- Highest value first +- Or based on your conversion data + +### Testing + +Test bucket changes before rolling out: +- Use test audiences +- Verify products load correctly +- Check pricing displays properly + +### Documentation + +Document your bucket strategy: +- What each bucket contains +- Which audiences see which buckets +- When to use each bucket + +## Common patterns + +### Seasonal promotions + +``` +summer_sale: + - monthly_discounted + - annual_discounted +``` + +Switch paywalls to use `summer_sale` bucket during promotion, then switch back. + +### A/B testing + +``` +variant_a: + - monthly_standard + - annual_standard + +variant_b: + - monthly_premium + - annual_premium +``` + +Use experiments to test which bucket converts better. + +### Upgrade paths + +``` +basic_to_premium: + - premium_monthly + - premium_annual + +premium_to_pro: + - pro_monthly + - pro_annual +``` + +Show different buckets based on user's current subscription tier. diff --git a/primitives/product-buckets.mdx b/primitives/product-buckets.mdx new file mode 100644 index 0000000..2c19dce --- /dev/null +++ b/primitives/product-buckets.mdx @@ -0,0 +1,222 @@ +--- +title: "Product buckets" +description: "Dynamically manage product offerings across your app" +--- + +A **product bucket** is a cross-platform container that groups equivalent products from different stores into a single, reusable unit. Instead of managing separate iOS, Android, and Stripe product IDs across every paywall, you define a bucket once — "Annual Premium," for example — and assign it platform-specific products. Paywalls then reference buckets, and Helium resolves the right product for each user's platform at runtime. + +--- + +### **Why Buckets?** + +Without buckets, every paywall would need to independently track product IDs for every platform. Buckets solve this by creating a single abstraction: + +- **One bucket = one logical product** across all platforms. + + +- Change a product ID in the bucket, and every paywall using it updates automatically. + + +- Paywalls reference buckets, not raw product IDs — keeping paywall configuration clean and portable. + +--- + +### **Anatomy of a Product Bucket** + +| Field | Description | +| :----------------- | :------------------------------------------------------------------------------------------- | +| **Bucket Name** | A human-readable label (e.g., "Annual Premium"). Must be unique within your organization. | +| **Description** | An optional note about the product. | +| **Bucket Type** | The product category: subscription, consumable, managed, or mixed. Defaults to subscription. | +| **Product Map** | A per-platform mapping of product references (iOS, Android, Stripe). | +| **Platform Count** | How many platforms have a product assigned (0–3). | + +--- + +### **The Product Map** + +The product map is the core of a bucket. It holds one product reference per platform: + +| Platform | Fields | Example | +| :---------- | :----------------------- | :----------------------------------------------------- | +| **iOS** | product_uuid | Maps to an App Store Connect product | +| **Android** | product_uuid, basePlanId | Maps to a Google Play product + subscription base plan | +| **Stripe** | product_uuid, price_id | Maps to a Stripe product + specific price | + +A bucket doesn't need all three platforms populated — a bucket with only an iOS product is perfectly valid. You add platform support as you expand. + +--- + +### **Platform Isolation Rule** + +**Stripe products and app store products (iOS/Android) cannot coexist in the same bucket.** + +This is a hard constraint. Stripe paywalls serve a fundamentally different audience (web, Apple Pay) than native app store paywalls, so mixing them in one bucket would create ambiguity at resolution time. If you need both, create separate buckets: + +- Annual Premium — iOS + Android products + + +- Annual Premium (Stripe) — Stripe product + +--- + +### **Lifecycle of a Product Bucket** + + + + When you create a bucket, you provide: + + - A **name** (must be unique per organization) + + + - At least **one product** on any platform + + + - An optional **description** + + The API validates that you've provided at least one product and that you're not mixing Stripe with iOS/Android. + + + Paywalls reference buckets through a product_bucket_ids array. A single paywall can use **multiple buckets** — for example, a paywall offering both an annual and monthly plan would reference two buckets. + + Bucket assignment happens during: + + - **Paywall creation** — Select buckets in the product selection step. + + + - **Paywall editing** — Add, remove, or reorder buckets on an existing paywall. + + When a paywall is published, the bucket references are snapshotted onto the paywall version. + + + You can add, remove, or change individual platform products within a bucket at any time: + + - **Add a product** — Attach a new platform's product (e.g., adding Android support to an iOS-only bucket). + + + - **Remove a product** — Detach a platform's product from the bucket. + + + - **Move a product** — Transfer a product from one bucket to another. + + Changes propagate to all paywalls using that bucket. + + + From the bucket details view, you can see which paywalls currently reference a given bucket. This is useful before making changes — you can understand the blast radius. + + + Buckets can be deleted individually or in batch. Deleting a bucket removes it from any paywall's bucket list. If a paywall relied solely on that bucket, it will have no products until you reassign. + + + +--- + +### **Products and Pricing** + +Each platform product within a bucket carries its own pricing and offer information, pulled from the respective store: + +#### iOS (App Store Connect) + +- Base price and territory-specific pricing + + +- **Introductory offers**: free trial, pay-as-you-go, pay-up-front + + +- **Promotional offers** and **offer codes** + + +- Offer eligibility is simulated per-territory in the paywall editor + +#### Android (Google Play) + +- Base plan pricing with regional configurations + + +- Per-base-plan offers: free trials, percentage discounts, fixed-amount discounts + + +- basePlanId is required for subscriptions (in-app purchases use a sentinel value) + +#### Stripe + +- Price points tied to a specific `price_id` + + +- Free trials via `trial_period_days` on the price + + +- Stripe products require a `price_id` — the product UUID alone isn't enough + +--- + +### **How Buckets Work in the Paywall Editor** + +When editing a paywall, the bucket system drives the product preview: + +1. The editor loads the paywall's assigned buckets. + + +1. Based on the selected **territory** and **platform** in the preview, Helium resolves the right product from each bucket. + + +1. A `products.tsx` file is generated with localized prices, offer details, and product metadata. + + +1. The live preview renders with real pricing data for the selected territory. + +This means you can preview exactly what a user in Japan on iOS would see, versus a user in Germany on Android — all driven by the same bucket configuration. + +--- + +### **Duplicate Detection** + +Helium prevents the same product from appearing in multiple buckets on a paywall. Before publishing, a duplicate check runs across all buckets assigned to the paywall: + +- Same iOS product UUID in two buckets — blocked. + + +- Same Android product UUID + base plan in two buckets — blocked. + + +- Same Stripe product UUID + price ID in two buckets — blocked. + +If duplicates are detected, you'll be prompted to resolve the conflict before publishing. + +--- + +### **Key Rules** + +- Bucket names are **unique per organization**. + + +- Every bucket must have **at least one product** on any platform. + + +- **Stripe and iOS/Android products cannot share a bucket** (platform isolation). + + +- A paywall can reference **multiple buckets** (one per logical product offering). + + +- Changes to a bucket **propagate to all paywalls** using it. + + +- **Duplicate products across buckets** on the same paywall are blocked at publish time. + + +- Android subscriptions require a `basePlanId`; Stripe products require a `price_id`. + +--- + +### **Common Patterns** + +| Pattern | Description | +| :------------------------- | :------------------------------------------------------------------------------------------------------------------------ | +| **Single-product paywall** | One bucket ("Monthly Premium") assigned to one paywall. The simplest setup. | +| **Multi-tier paywall** | Two or three buckets ("Monthly," "Annual," "Lifetime") on a single paywall, letting users choose a plan. | +| **Cross-platform product** | One bucket with iOS, Android, and Stripe products — same logical offering, resolved per platform. | +| **Stripe-only bucket** | A separate bucket for Stripe products, used on paywalls that serve web or Apple Pay users. | +| **Shared across paywalls** | The same "Annual Premium" bucket referenced by multiple paywalls. Update the product once, every paywall gets the change. | + +--- \ No newline at end of file diff --git a/primitives/triggers.mdx b/primitives/triggers.mdx new file mode 100644 index 0000000..4c7658d --- /dev/null +++ b/primitives/triggers.mdx @@ -0,0 +1,95 @@ +--- +title: "Triggers" +description: "Define events that initiate paywall presentation" +--- + +A **trigger** is a named placement in your app where you want to present a paywall. Think of it as a specific moment or screen in your user journey — like after onboarding, when a user hits a feature gate, or when they open settings. + +When the Helium SDK encounters a trigger in your app code, it resolves the trigger to a **workflow**, which determines which paywall (or A/B experiment) to display. + +--- + +### **Anatomy of a Trigger** + +Each trigger has three fields: + +| Field | Description | +| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Trigger Name** | A human-readable label for the trigger (e.g., "Post Onboarding"). Editable after creation. | +| **Trigger Key** | A programmatic identifier used in your SDK integration (e.g., post_onboarding). Only letters, numbers, and underscores. **Cannot be changed after creation.** | +| **Description** | An optional note describing when or where this trigger fires. | + +--- + +### **Lifecycle of a Trigger** + + + + Triggers can be created in two ways: + + - **During onboarding** — The "Wire up your paywall" flow creates a trigger, a default workflow, and connects them to your first paywall in one step. + - **From a workflow** — On any workflow's detail page, you can add triggers from the Triggers section. Create new ones inline, or attach existing unassigned triggers. + + When you create a trigger, you choose a **trigger key**. This is the identifier you'll use in your app's code, so pick something descriptive and stable — it can't be renamed later. The trigger name and description can be updated at any time. + + + A trigger must be linked to exactly **one workflow**. A workflow can have **many triggers**, but each trigger can only belong to a single workflow at a time. This constraint is enforced automatically — if you try to assign a trigger that's already in use by another workflow, you'll see an error. + + This means multiple entry points in your app (e.g., feature_gate, settings_upsell, post_onboarding) can all route to the same paywall experience, while each trigger still gets its own analytics. + + + Once created and associated with a workflow, use the trigger key in your SDK integration: + + _// iOS example_ + + Helium.trigger("post_onboarding") + + When this code runs, the SDK sends the trigger key to Helium, which resolves it to the associated workflow and returns the correct paywall (or experiment variant) to display. + + + Triggers appear throughout Helium's analytics. You can **filter** and **group by** trigger to understand how each entry point performs — for example, comparing conversion rates between post_onboarding and feature_gate. Trigger attribution flows through to paywall sessions and subscription data. + + + You can update a trigger's **name** and **description** at any time from the trigger's detail page. The **trigger key is immutable** — if you need a different key, create a new trigger and reassign it to the workflow. + + + Triggers can be detached from one workflow and attached to another. When you remove a trigger from a workflow, that trigger's key will immediately stop serving that workflow's paywalls. + + + A trigger can only be deleted if it is **not currently associated with any workflow**. If it's in use, you'll be shown which workflow(s) reference it and prompted to remove the association first. Deletion is permanent. + + + +--- + +### **Reserved Trigger Keys** + +Helium reserves a small set of trigger keys for system-managed workflows: + +| Key | Purpose | +| :----------------------- | :-------------------------------- | +| hlm_ios_default_flbk | iOS default fallback workflow | +| hlm_android_default_flbk | Android default fallback workflow | + +These cannot be used for custom triggers. + +--- + +### **Key Rules** + +- Each trigger belongs to **exactly one workflow** at a time. +- One workflow can have **multiple triggers**. +- Trigger keys are **unique per organization** and **immutable** after creation. +- Trigger keys can only contain **letters, numbers, and underscores**. +- A trigger must be **unassociated from all workflows** before it can be deleted. + +--- + +### **Common Patterns** + +| Pattern | Example Key | When It Fires | +| :-------------- | :-------------------- | :---------------------------------------------- | +| Post-onboarding | post_onboarding | Right after a new user completes onboarding | +| Feature gate | feature_gate_ai_tools | When a free user taps a premium feature | +| Settings upsell | settings_upgrade | From an "Upgrade" button on the settings screen | +| Session-based | session_start_day_3 | On app open after the user's 3rd day | \ No newline at end of file diff --git a/primitives/workflows.mdx b/primitives/workflows.mdx new file mode 100644 index 0000000..d05e972 --- /dev/null +++ b/primitives/workflows.mdx @@ -0,0 +1,120 @@ +--- +title: "Workflows" +description: "Orchestrate complex paywall presentation logic" +--- + +A **workflow** is the decision engine that sits between a trigger and a paywall. When a trigger fires in your app, the Helium SDK resolves it to a workflow, which then evaluates a set of rules to decide what to show the user — a specific paywall, an A/B experiment, or nothing at all. + +Workflows are the central configuration object in Helium. They tie together triggers, paywalls, experiments, audiences, and locales into a single, manageable unit. + +--- + +### **Anatomy of a Workflow** + +| Field | Description | +| :--------------------- | :------------------------------------------------------------------------------------------- | +| **Workflow Name** | A human-readable label, unique within your organization (e.g., "Onboarding Upsell"). | +| **Description** | An optional note describing the workflow's purpose. | +| **Control Paywall** | The default paywall shown when no other targeting rules match. Every workflow must have one. | +| **Triggers** | One or more triggers that route to this workflow from your app. | +| **Targeting Criteria** | Audience-based rules that determine what to show different user segments. | +| **Experiments** | A/B tests running within this workflow, comparing different paywalls. | +| **Locale Experiments** | Per-locale experiment assignments, allowing different tests for different regions. | + +--- + +### **How a Workflow Resolves** + +When a trigger fires, the SDK asks Helium: "What should I show?" The workflow answers by evaluating its configuration: + +1. **Check targeting criteria** — Does the user match any audience-based rule? +1. **If a rule matches** — Show that audience's configured paywall or enroll the user in an experiment. If the rule is "no paywall," dismiss silently. +1. **If no rule matches** — Fall through to the **control paywall**. + +This means a single workflow can serve entirely different experiences to different user segments — a premium upsell to power users, a gentle nudge to casual users, and an A/B test for everyone else. + +--- + +### **Lifecycle of a Workflow** + + + + When you create a workflow, you provide: + + - A **name** (must be unique within your organization) + - A **control paywall** (the default paywall to show — must be a published paywall) + - An optional **description** + + During onboarding, Helium creates a "Default Workflow" automatically when you set up your first trigger. You can also create workflows manually from the Workflows page. + + + A workflow needs at least one trigger to be reachable from your app. You can attach multiple triggers to a single workflow — for example, both post_onboarding and feature_gate can route to the same workflow. Each trigger can only belong to one workflow at a time. + + + Targeting criteria are audience-based rules that let you customize what different users see. Each rule maps an **audience** to an **action**: + + | Action Type | Behavior | + | :------------- | :-------------------------------------------------------------------------------- | + | **Paywall** | Show a specific paywall to users in this audience. | + | **Experiment** | Enroll users in this audience into an A/B test. | + | **No Paywall** | Don't show anything — the trigger silently resolves without presenting a paywall. | + + Rules are evaluated in order. If a user matches multiple audiences, the first matching rule wins. Users who don't match any rule see the **control paywall**. + + + Experiments are A/B tests that live within a workflow. Each experiment: + + - Compares two or more paywall variants with configurable traffic allocations + - Can be scoped to a specific audience via targeting criteria + - Has a lifecycle of its own: **scheduled** → **running** → **stopped**/**completed** + - Can be scoped to specific **locales**, so you can run different tests in different regions + + When you stop an experiment, you choose what happens next — route that audience to the winning paywall, fall back to the control, or pass through to the next matching rule. + + + You can update a workflow's **name**, **description**, **control paywall**, and **targeting criteria** at any time. Changes take effect immediately — there is no draft/publish cycle for workflows. Keep in mind: + + - Changing the control paywall instantly changes what unmatched users see. + - Updating targeting criteria reorders or replaces audience rules on the fly. + + + Deleting a workflow **cascades** — all trigger associations and locale-experiment mappings tied to this workflow are removed automatically. The triggers themselves are not deleted; they become unassigned and can be reattached to another workflow. Experiments associated with the workflow are also affected. + + Deletion is permanent and takes effect immediately. Any triggers still referencing this workflow in your app code will fail to resolve, so make sure to remove or reassign them first. + + + +--- + +### **Relationships** + +- **Triggers → Workflow**: Many-to-one. Multiple triggers can point to one workflow. +- **Workflow → Control Paywall**: One-to-one. Every workflow has exactly one default paywall. +- **Workflow → Targeting Criteria**: One-to-many. A workflow can have multiple audience-based rules. +- **Workflow → Experiments**: One-to-many. A workflow can have multiple A/B tests (active, stopped, or scheduled). +- **Workflow → Locale Experiments**: One-to-many. Different experiments can run per locale within the same workflow. + +--- + +### **Key Rules** + +- Workflow names are **unique per organization**. +- Every workflow must have a **control paywall** — it's the guaranteed fallback. +- Only **published paywalls** can be used as a control paywall or in experiments. +- Targeting criteria are evaluated **in order** — the first matching audience wins. +- Deleting a workflow **cascades** to trigger associations and locale mappings, but does not delete the triggers or experiments themselves. +- Changes to workflows are **live immediately** — there is no staging step. + +--- + +### **Common Patterns** + +| Pattern | Description | +| :------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Simple paywall** | One trigger, one workflow, one control paywall. The simplest setup — every user sees the same paywall. | +| **A/B test** | One workflow with an experiment. Split traffic between paywall variants to find the best performer. | +| **Audience segmentation** | Targeting criteria route different audiences to different paywalls. Power users see a premium offer; new users see a trial offer. | +| **Locale-based testing** | Different experiments per locale. Test an annual plan in the US while testing a monthly plan in Europe. | +| **Multi-trigger funnel** | Multiple triggers (e.g., feature_gate, settings_upgrade, day_3_prompt) all pointing to the same workflow. Shared paywall logic with per-trigger analytics. | + +--- \ No newline at end of file diff --git a/sdk-quickstarts/android.mdx b/sdk-quickstarts/android.mdx new file mode 100644 index 0000000..ae1e050 --- /dev/null +++ b/sdk-quickstarts/android.mdx @@ -0,0 +1,387 @@ +--- +title: "Android SDK" +--- + +> **Migrating from v0?** See the [Android 0 → 4 migration guide](/migrations/android-0-to-4). + +**Requirements:** Kotlin 2.0.0+ · Java 8+ · Min SDK 23 · Compile SDK 35 + +--- + +## 1. Install the SDK + +#### Add repositories + +Ensure `mavenCentral()` and `google()` are present in your `settings.gradle.kts`: + +```kotlin +// settings.gradle.kts + +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +``` + +> If you don't have a `dependencyResolutionManagement` block, add `google()` and `mavenCentral()` to your `pluginManagement { repositories { ... } }` block instead. + +#### Add the dependency + +```kotlin +// app/build.gradle.kts + +dependencies { + implementation("com.tryhelium.paywall:core:4.0.0") +} +``` + +--- + +## 2. Initialize Helium + +Initialize as early as possible in your app's lifecycle — before any paywall is shown. + +Your API key is available in the [Helium dashboard → Profile](https://app.tryhelium.com/profile). + + + + ```kotlin + import com.tryhelium.paywall.core.Helium + + class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + Helium.initialize( + context = this, + apiKey = "YOUR_API_KEY", + environment = HeliumEnvironment.PRODUCTION, + ) + } + } + ``` + + + ```kotlin + import com.tryhelium.paywall.core.Helium + + class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Helium.initialize( + context = this, + apiKey = "YOUR_API_KEY", + environment = HeliumEnvironment.PRODUCTION, + ) + } + } + ``` + + + +> `HeliumEnvironment` accepts `PRODUCTION` or `SANDBOX`. When in doubt, use `PRODUCTION` — Helium automatically treats debug builds as sandbox. + +--- + +## 3. Show a Paywall + +Before calling `presentPaywall`, make sure you have a trigger and workflow configured in the [Helium dashboard](https://app.tryhelium.com/workflows). + +```kotlin +Helium.presentPaywall( + trigger = "premium", + onPaywallNotShown = { reason -> + // Called when the paywall could not be displayed + } +) +``` + + + + + The trigger name configured in the Helium dashboard. + + + _(Optional)_ Presentation options for this paywall. + + ```kotlin + data class PaywallPresentationConfig( + // Activity to present from. Auto-tracked by the SDK if not provided. + val fromActivityContext: Activity? = null, + // Custom traits to pass to the paywall. + val customPaywallTraits: HeliumUserTraits? = null, + // Skip the paywall if the user is already entitled to a product in it. + val dontShowIfAlreadyEntitled: Boolean = false, + // Disable the system back button from closing the paywall. + val disableSystemBackNavigation: Boolean = false, + // Controls how the paywall animates in. + val presentationStyle: HeliumPresentationStyle = HeliumPresentationStyle.SLIDE_UP, + // Hide system status bars (immersive fullscreen mode). + val fullscreen: Boolean = false + ) + ``` + + + _(Optional)_ Called when the user is entitled to a product — either via a new purchase or an existing entitlement. + + + _(Optional)_ Event listener for paywall lifecycle events. See [Paywall Events](#paywall-events). + + + _(Highly recommended)_ Called whenever the paywall cannot be displayed. If `config.dontShowIfAlreadyEntitled` is `true` and `onEntitled` is not provided, `onPaywallNotShown(AlreadyEntitled)` is called instead. + + + + +--- + +## Recommended Setup + +The following steps are optional but strongly recommended for production apps. + +### Identify Users + +User identification is optional. It improves targeting accuracy and event attribution in external analytics. Identify users **before** calling `Helium.initialize` to ensure consistency from the first session. + +```kotlin +// Set a stable user ID +Helium.identity.userId = "your-user-id" + +// Set user traits for targeting and analytics +Helium.identity.setUserTraits(HeliumUserTraits( + traits = mapOf( + "hasOnboarded" to HeliumUserTraitsArgument.BoolParam(true), + "accountAge" to HeliumUserTraitsArgument.IntParam(30) + ) +)) + +// Use addUserTraits() to merge rather than replace existing traits +Helium.identity.addUserTraits(...) +``` + +--- + +### Paywall Events + +Helium emits events throughout the paywall lifecycle. You can handle them per-presentation or globally. + +#### Per-presentation handlers + +Pass a `PaywallEventHandlers` instance when calling `presentPaywall`, or register it with `addPaywallEventListener`. Passing a `LifecycleOwner` is recommended — the listener is removed automatically when the lifecycle is destroyed. + +```kotlin +class MyActivity : AppCompatActivity() { + private val paywallEventHandlers = PaywallEventHandlers( + onOpen = { event -> print("Opened: ${event.paywallName}") }, + onClose = { event -> print("Closed: ${event.paywallName}") }, + onDismissed = { event -> print("Dismissed: ${event.paywallName}") }, + onPurchaseSucceeded = { event -> print("Purchase succeeded: ${event.paywallName}") }, + onCustomPaywallAction = { event -> print("Custom action: ${event.actionName}") }, + onAnyEvent = { event -> /* fires for all of the above */ } + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Listener is removed automatically when this Activity is destroyed + Helium.shared.addPaywallEventListener(this, paywallEventHandlers) + } +} +``` + +> If you don't pass a `LifecycleOwner`, call `removeHeliumEventListener()` manually to avoid memory leaks. + +#### Global event listener + +Implement `HeliumEventListener` for centralized event handling across all paywalls: + +```kotlin +import com.tryhelium.paywall.core.event.* + +class MyActivity : AppCompatActivity(), HeliumEventListener { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Helium.shared.addPaywallEventListener(this, this) + } + + override fun onHeliumEvent(event: HeliumEvent) { + when (event) { + is PaywallOpen -> { /* handle open */ } + is PaywallClose -> { /* handle close */ } + // handle other event types + } + } +} +``` + +You can also forward all events to an [existing analytics provider](/guides/third-party-analytics). + +--- + +### Fallback Paywalls + +Set up fallbacks to handle the rare case where a paywall fails to load. This is **strongly recommended** before going to production. + +Follow the [fallback bundle guide](/guides/fallback-bundle) once you have a production paywall ready. + +--- + +### Checking Entitlements + +Check entitlement status before showing a paywall to avoid showing it to users who are already subscribed. + + +```kotlin Using dontShowIfAlreadyEntitled +Helium.presentPaywall( + trigger = "my_trigger", + config = PaywallPresentationConfig( + dontShowIfAlreadyEntitled = true, + ), + onEntitled = { + // User is already entitled or just completed a purchase + }, + onPaywallNotShown = { reason -> } +) +``` + +```kotlin Checking status manually +val hasActiveSubscription = Helium.entitlements.hasAnyActiveSubscription() + +if (hasActiveSubscription) { + // Grant access to premium content +} else { + // Show paywall +} +``` + + + + | Method | Description | + |--------|-------------| + | `hasAnyEntitlement()` | Returns `true` if the user has purchased any subscription or non-consumable. | + | `hasAnyActiveSubscription()` | Returns `true` if the user has any active subscription. | + | `hasEntitlementForPaywall(trigger: String)` | Checks if the user is entitled to any product in a specific paywall. Returns `null` if paywall config hasn't loaded yet. | + + +--- + +## Advanced + +### Consumables + +If your app sells consumable products (e.g. coins, credits, tokens), register their product IDs so Helium knows to consume them after purchase: + +```kotlin +Helium.config.consumableIds = setOf("coins_100", "gems_50") +``` + +> This only applies when using the default `PlayStorePaywallDelegate`. If you're using `RevenueCatPaywallDelegate`, configure consumption in RevenueCat. If you're using a custom delegate, you're responsible for consuming purchases yourself. + +--- + +### RevenueCat Integration + +By default, Helium manages purchases directly. If you're already using RevenueCat, use the `RevenueCatPaywallDelegate` to keep RevenueCat in control. + +#### Install + +Add the RevenueCat module to `app/build.gradle.kts`: + +```kotlin +dependencies { + implementation("com.tryhelium.paywall:core:4.0.0") + implementation("com.tryhelium.paywall:revenue-cat:4.0.0") +} +``` + +#### Configure + +Set the delegate **before** calling `Helium.initialize`, and make sure RevenueCat is already initialized: + +```kotlin +// Initialize RevenueCat first, then: +Helium.config.heliumPaywallDelegate = RevenueCatPaywallDelegate() +``` + +#### Keeping appUserID in sync + +If you [change the RevenueCat `appUserID`](https://www.revenuecat.com/docs/customers/identifying-customers#logging-in-after-configuration), sync the value to Helium: + +```kotlin +Helium.identify.revenueCatAppUserId = Purchases.sharedInstance.appUserID +``` + +--- + +### Custom Purchase Handling + +By default, Helium handles purchases for you. This section is for apps that need custom purchase logic. + +> **Tip:** If you only need to augment the built-in logic, subclass `PlayStorePaywallDelegate` or `RevenueCatPaywallDelegate` and call `super` on any methods you override. + +To fully replace purchase handling, implement `HeliumPaywallDelegate`: + +```kotlin +interface HeliumPaywallDelegate { + // Required — execute a purchase given product details. + suspend fun makePurchase( + productDetails: ProductDetails, + basePlanId: String?, + offerId: String?, + ): HeliumPaywallTransactionStatus + + // Optional — restore existing purchases. Return true on success. + suspend fun restorePurchases(): Boolean + + // Optional — called for all Helium events. + fun onHeliumEvent(event: HeliumEvent) +} +``` + +--- + +### Additional Methods + + + In most cases you don't need this — Helium shows a loading indicator automatically if the paywall hasn't finished downloading. + + `downloadStatus` is a Kotlin `Flow` that emits `HeliumConfigStatus` states: + + ```kotlin + lifecycleScope.launch { + Helium.shared.downloadStatus.collect { status -> + when (status) { + is HeliumConfigStatus.NotYetDownloaded -> { } + is HeliumConfigStatus.Downloading -> { } + is HeliumConfigStatus.DownloadFailure -> { } + is HeliumConfigStatus.DownloadSuccess -> { } + } + } + } + ``` + + + + ```kotlin + Helium.hidePaywall() // Hide the current paywall + Helium.hideAllPaywalls() // Hide all displayed paywalls + ``` + + + + Resets Helium so `initialize` can be called again. Useful after changing user traits that affect paywall targeting. + + ```kotlin + Helium.resetHelium() + ``` + \ No newline at end of file diff --git a/sdk-quickstarts/fallbacks.mdx b/sdk-quickstarts/fallbacks.mdx new file mode 100644 index 0000000..7e6a80f --- /dev/null +++ b/sdk-quickstarts/fallbacks.mdx @@ -0,0 +1,59 @@ +--- +title: "Fallbacks" +description: "Handle offline scenarios and network failures gracefully" +--- + +A **fallback paywall** is a safety net. If the Helium SDK can't load the intended paywall — due to a slow connection, a timeout, or the user going offline — it shows a fallback instead. This ensures your users always see a paywall, even in imperfect network conditions. + +--- + +### **How It Works** + +1. You **download a fallback bundle** from the Workflows page in the Helium dashboard. +2. You **include the bundle in your app** and pass it to the SDK during initialization. +3. If the SDK can't fetch or display the live paywall in time, it shows the fallback. + +The fallback is a fully functional paywall — users can still subscribe, restore purchases, and dismiss. It just happens to be pre-bundled with the app rather than fetched from the server. + +--- + +### **Configuring Fallbacks** + +From the Workflows page, click **Download Fallbacks**. You'll configure: + +| Setting | Description | +| :----------------------------------- | :------------------------------------------------------------------------------------------------------ | +| **iOS default fallback** | The paywall shown on iOS when no trigger-specific fallback matches. | +| **Android default fallback** | The paywall shown on Android when no trigger-specific fallback matches. | +| **Per-trigger fallbacks** (optional) | Override the default for specific triggers — useful if different triggers show very different paywalls. | + +The download produces a `helium-fallbacks.json` file that you add to your app's assets. + +--- + +### **Monitoring Fallback Rate** + +Helium tracks your **Fallback Rate** — the percentage of paywall sessions that served a fallback instead of the live paywall. You'll find it in the Monitoring section of your metrics. + +A healthy fallback rate is **as close to 0% as possible**. If it's elevated, the most common fixes are: + +| Lever | What it does | +| :------------------------------ | :----------------------------------------------------------------------------------------------------- | +| **Call initialize() earlier** | Start the SDK sooner (e.g., at app launch) so paywalls are pre-fetched before the user triggers one. | +| **Increase the loading budget** | Give the SDK more time to download the paywall before falling back. The default is 7 seconds. | +| **Update your fallback bundle** | Re-download and ship an updated fallback when you change your paywalls, so the fallback stays current. | + +--- + +### **Key Points** + +- Fallbacks are **fully functional** — subscriptions work normally. + + +- Fallback sessions are **tracked separately** in analytics so they don't skew your paywall metrics. + + +- Keep your fallback bundle **up to date** — re-download it when you publish significant paywall changes. + + +- A low fallback rate means the SDK is loading paywalls successfully before users need them. \ No newline at end of file diff --git a/sdk-quickstarts/feature-availability.mdx b/sdk-quickstarts/feature-availability.mdx new file mode 100644 index 0000000..94d9db5 --- /dev/null +++ b/sdk-quickstarts/feature-availability.mdx @@ -0,0 +1,54 @@ +--- +title: "Feature Availability" +description: "Platform-specific feature support across Helium SDKs" +--- + +_Last updated on: 2/2/26_ + +| Feature | iOS | Android | ReactNative | Flutter | +| :------------------------------------------------------ | :-: | :-----: | :---------: | :-----: | +| **Purchase Handling** | | | | | +| Default purchase delegate | ✅ | ✅ | ✅ | ✅ | +| Custom purchase delegate | ✅ | ✅ | ✅ | ✅ | +| RevenueCat delegate | ✅ | ✅ | ✅ | 🚫 | +| **Helium Events** | | | | | +| Listen for events via delegate | ✅ | ✅ | ✅ | ✅ | +| Add global event listener | ✅ | ✅ | 🚫 | 🚫 | +| Paywall event handlers | ✅ | ✅ | ✅ | ✅ | +| Custom paywall actions | ✅ | ✅ | ✅ | ✅ | +| **Paywall Options** | | | | | +| Second try flow | ✅ | ✅ | ✅ | ✅ | +| "Should Show" for presented paywall | ✅ | ✅ | ✅ | ✅ | +| Dark mode support | ✅ | ✅ | ✅ | ✅ | +| Disable scroll | ✅ | ✅ | ✅ | ✅ | +| Automatic safe area support | ✅ | 🚫 | ✅ | ✅ | +| Intro offers | ✅ | ✅ | ✅ | ✅ | +| **Presentation Options** | | | | | +| Modal presentation | ✅ | ✅ | ✅ | ✅ | +| Modal presentation callbacks | ✅ | ✅ | ✅ | ✅ | +| Embedded view | ✅ | ✅ | 🚫 | ✅ | +| Embedded view callbacks | ✅ | ✅ | 🚫 | ✅ | +| **Loading State** | | | | | +| For modal presentation | ✅ | ✅ | ✅ | ✅ | +| For embedded view | ✅ | ✅ | 🚫 | 🚫 | +| Loading budget customizable | ✅ | ✅ | ✅ | ✅ | +| Loading budget per trigger | ✅ | ✅ | ✅ | ✅ | +| **Fallbacks** | | | | | +| Fallbacks support | ✅ | ✅ | ✅ | ✅ | +| onFallback callback with presentUpsell | ✅ | ✅ | ✅ | 🚫 | +| forceShowFallback flag | ✅ | 🚫 | ✅ | ✅ | +| **Entitlements** | | | | | +| Option to check entitlements for paywall before showing | ✅ | ✅ | ✅ | ✅ | +| hasAnySubscription/hasAnyEntitlement helpers | ✅ | ✅ | ✅ | ✅ | +| Entitlement persistence | ✅ | 🚫 | 🚫 | 🚫 | +| Cache per-trigger entitlement | ✅ | 🚫 | 🚫 | 🚫 | +| **Testing** | | | | | +| QR code scan | ✅ | ✅ | ✅ | ✅ | +| **Logging** | | | | | +| Logging levels | ✅ | ✅ | 🚫 | 🚫 | +| **Analytics** | | | | | +| Custom user traits | ✅ | ✅ | ✅ | ✅ | +| Custom paywall traits | ✅ | ✅ | ✅ | ✅ | +| Revenue tracking | ✅ | ✅ | ✅ | ✅ | +| **Experimentation** | | | | | +| Allocations | ✅ | ✅ | ✅ | ✅ | \ No newline at end of file diff --git a/sdk-quickstarts/flutter.mdx b/sdk-quickstarts/flutter.mdx new file mode 100644 index 0000000..5dcf5ff --- /dev/null +++ b/sdk-quickstarts/flutter.mdx @@ -0,0 +1,334 @@ +--- +title: "Flutter SDK" +--- + +**Requirements:** Flutter 3.24.0+ · iOS 15+ + +--- + +## 1. Install the SDK + + +```bash CLI +flutter pub add helium_flutter +``` + +```yaml pubspec.yaml +dependencies: + helium_flutter: ^3.1.3 +``` + + +If you added it manually to `pubspec.yaml`, run: +```bash +flutter pub get +``` + +#### Enable Swift Package Manager (recommended) + +```bash +flutter upgrade +flutter config --enable-swift-package-manager +``` + +See the [Flutter SPM documentation](https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-app-developers) for details. CocoaPods still works if you prefer it. + +#### Set minimum iOS version + +In `ios/Podfile`, ensure the minimum deployment target is set: + +``` +platform :ios, '15.0' +``` + +If you still see iOS version errors after this, follow Flutter's guide on [setting the minimum OS version in Xcode](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). + +--- + +## 2. Initialize Helium + +Call `initialize` in `main.dart` before `runApp`. Your API key is available in the [Helium dashboard → Profile](https://app.tryhelium.com/profile). + +```dart +import 'package:helium_flutter/helium_flutter.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + final heliumFlutter = HeliumFlutter(); + await heliumFlutter.initialize( + apiKey: 'YOUR_API_KEY', + ); + + runApp(const MyApp()); +} +``` + + + + + Your Helium API key from [Account Settings](https://app.tryhelium.com/profile). + + + _(Optional)_ Global listener for paywall events. See [Paywall Events](#paywall-events). + + + _(Optional)_ Custom purchase handler. Defaults to Helium's built-in purchase logic. See [RevenueCat](#revenuecat-integration) and [Custom Purchase Handling](#custom-purchase-handling). + + + _(Optional)_ Widget to display if the paywall fails to load. See [Fallback Paywalls](#fallback-paywalls). + + + _(Optional)_ Path to a local fallback bundle. See [Fallback Paywalls](#fallback-paywalls). + + + _(Optional)_ Controls the loading budget (in seconds) and whether to show a loading state. See [Fallback Paywalls](#fallback-paywalls). + + + _(Optional)_ A stable user ID for attribution and analytics forwarding. See [Identify Users](#identify-users). + + + _(Optional)_ User traits for targeting, personalization, and dynamic paywall content. See [Identify Users](#identify-users). + + + _(Optional)_ RevenueCat only. Pass the RevenueCat `appUserID` here. Initialize RevenueCat before calling `initialize`. See [RevenueCat](#revenuecat-integration). + + + + +--- + +## 3. Show a Paywall + +Before calling `presentUpsell`, make sure you have a trigger and workflow configured in the [Helium dashboard](https://app.tryhelium.com/workflows). + +```dart +ElevatedButton( + onPressed: () { + HeliumFlutter().presentUpsell( + context: context, + trigger: 'your-trigger-name', + ); + }, + child: const Text('Show Premium Features'), +) +``` + +> **Do not** call `presentUpsell` inside `Widget build()` — this can cause unpredictable behavior. + +--- + +## Recommended Setup + +The following steps are optional but strongly recommended for production apps. + +### Identify Users + +User identification is optional. It improves targeting accuracy and event attribution in external analytics. Set user identity during `initialize` for consistency from the first session. + + +```dart Set during initialize +await heliumFlutter.initialize( + apiKey: 'YOUR_API_KEY', + customUserId: 'your-user-id', + customUserTraits: { + 'hasOnboarded': true, + 'plan': 'free', + }, +); +``` + +```dart Update after initialize +await heliumFlutter.overrideUserId( + newUserId: 'your-user-id', + traits: { + 'hasOnboarded': true, + 'plan': 'free', + }, +); +``` + + +--- + +### Paywall Events + +Helium emits events throughout the paywall lifecycle. You can handle them per-presentation or globally. + +#### Per-presentation handlers + +Pass `eventHandlers` to `presentUpsell`: + +```dart +HeliumFlutter().presentUpsell( + trigger: 'my_paywall', + context: context, + eventHandlers: PaywallEventHandlers( + onOpen: (event) => log('Opened: ${event.triggerName}'), + onClose: (event) => log('Closed: ${event.triggerName}'), + onDismissed: (event) => log('Dismissed: ${event.triggerName}'), + onPurchaseSucceeded: (event) => log('Purchased: ${event.triggerName}'), + onAnyEvent: (event) { + // Fires for all of the above. + // Note: specific handlers (e.g. onOpen) and onAnyEvent both fire for the same event. + }, + ), +); +``` + +#### Global event listener + +Implement `HeliumCallbacks` and pass it to `initialize` to handle events across all paywalls without passing handlers at each call site: + +```dart +class MyHeliumCallbacks implements HeliumCallbacks { + @override + Future onPaywallEvent(HeliumPaywallEvent event) async { + log('${event.type} - trigger: ${event.triggerName}'); + } +} +``` + +```dart +await heliumFlutter.initialize( + apiKey: 'YOUR_API_KEY', + callbacks: MyHeliumCallbacks(), +); +``` + +You can also forward all events to an [existing analytics provider](/guides/third-party-analytics). + +--- + +### Fallback Paywalls + +Set up fallbacks to handle the rare case where a paywall fails to load. This is **strongly recommended** before going to production. + +Follow the [fallback bundle guide](/guides/fallback-bundle) once you have a production paywall ready. + +--- + +### Checking Entitlements + +Check entitlement status before showing a paywall to avoid showing it to users who are already subscribed. + + +```dart Using dontShowIfAlreadyEntitled +heliumFlutter.presentUpsell( + trigger: 'my_paywall', + context: context, + dontShowIfAlreadyEntitled: true, +); +``` + +```dart Checking status manually +final hasActiveSubscription = await heliumFlutter.hasAnyActiveSubscription(); + +if (hasActiveSubscription) { + // Grant access to premium content +} else { + heliumFlutter.presentUpsell( + trigger: 'my_paywall', + context: context, + eventHandlers: PaywallEventHandlers( + onPurchaseSucceeded: (event) { + // Grant access to premium content + }, + ), + ); +} +``` + + + + | Method | Returns | Description | + |--------|---------|-------------| + | `hasAnyActiveSubscription()` | `Future` | Returns `true` if the user has any active subscription (including non-renewable). | + | `hasAnyEntitlement()` | `Future` | Returns `true` if the user has any entitlement. | + | `hasEntitlementForPaywall(trigger)` | `Future` | Returns `true` if the user is entitled to any product in a specific paywall. Returns `null` if the paywall hasn't loaded yet. | + + +--- + +## Advanced + +### RevenueCat Integration + +By default, Helium manages purchases directly. If you're already using RevenueCat, use the `helium_revenuecat` package to keep RevenueCat in control. + +#### Install + +```bash +flutter pub add helium_revenuecat +``` + +#### Configure + +Initialize RevenueCat first, then pass `RevenueCatPurchaseDelegate` to `heliumFlutter.initialize`: + +```dart +import 'package:helium_revenuecat/helium_revenuecat.dart'; + +await heliumFlutter.initialize( + apiKey: 'YOUR_API_KEY', + purchaseDelegate: RevenueCatPurchaseDelegate(), + revenueCatAppUserId: await Purchases.appUserID, +); +``` + +#### Keeping appUserID in sync + +If you [change the RevenueCat `appUserID`](https://www.revenuecat.com/docs/customers/identifying-customers#logging-in-after-configuration), sync the value to Helium: + +```dart +HeliumFlutter().setRevenueCatAppUserId(await Purchases.appUserID); +``` + +--- + +### Custom Purchase Handling + +By default, Helium handles purchases for you. This section is for apps that need custom purchase logic. + +Implement `HeliumPurchaseDelegate` and pass it to `initialize`: + +```dart +abstract class HeliumPurchaseDelegate { + // Required — execute a purchase given a product ID. + Future makePurchase(String productId); + + // Optional — restore existing purchases. Return true on success. + Future restorePurchases(); +} +``` + +--- + +### Additional Methods + + + In most cases you don't need this — Helium shows a loading indicator automatically if the paywall hasn't finished downloading. + + Subscribe to the `downloadStatus` stream to observe changes: + + ```dart + HeliumFlutter.downloadStatus.listen((status) { + print('Download status: ${status.name}'); + }); + ``` + + Possible values: `notDownloadedYet` · `inProgress` · `downloadSuccess` · `downloadFailure` + + For a simple boolean check: + ```dart + final loaded = await heliumFlutter.paywallsLoaded(); + ``` + + + + Resets Helium so `initialize` can be called again. Useful after changing user traits that affect paywall targeting. + + ```dart + await HeliumFlutter().resetHelium(); + ``` + \ No newline at end of file diff --git a/sdk-quickstarts/i-os.mdx b/sdk-quickstarts/i-os.mdx new file mode 100644 index 0000000..459eb20 --- /dev/null +++ b/sdk-quickstarts/i-os.mdx @@ -0,0 +1,434 @@ +--- +title: "iOS SDK" +--- + +_Helium requires_ **_iOS 15+_** and **_Xcode 14+_** + +--- + +## 1. Install the SDK + + + + 1. In Xcode, go to your project's **Package Dependencies**. + 2. Click **\+** and enter the package URL: + + ```text + https://github.com/cloudcaptainai/helium-swift.git + ``` + 3. Set **Dependency Rule** to **Up to Next Major Version** (recommended). + 4. Click **Add Package**, then add the **Helium** product to your app's main target. + + + Add to your `Podfile`: + + ```ruby + pod 'Helium', '~> 4.0' + ``` + + Then run: + + ```bash + pod install + ``` + + + +--- + +## 2. Initialize Helium + +Initialize as early as possible in your app's lifecycle — before any paywall is shown. + +Your API key is available in the [Helium dashboard → Profile](https://app.tryhelium.com/profile). + + + + ```swift + import Helium + + @main + struct MyApp: App { + init() { + Helium.shared.initialize(apiKey: "YOUR_API_KEY") + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } + } + ``` + + + ```swift + import Helium + + class SceneDelegate: UIResponder, UIWindowSceneDelegate { + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + Helium.shared.initialize(apiKey: "YOUR_API_KEY") + } + } + ``` + + + ```swift + import Helium + + @UIApplicationMain + class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + Helium.shared.initialize(apiKey: "YOUR_API_KEY") + return true + } + } + ``` + + + +--- + +## 3. Show a Paywall + +Before calling `presentPaywall`, make sure you have a trigger and workflow configured in the [Helium dashboard](https://app.tryhelium.com/workflows). + +```swift +Helium.shared.presentPaywall( + trigger: "premium" +) { paywallNotShownReason in + // Called when the paywall could not be displayed +} +``` + + + + + The trigger name configured in the Helium dashboard. + + + _(Optional)_ Presentation options for this paywall. + + ```swift + struct PaywallPresentationConfig { + // View controller to present from. Defaults to the current top view controller. + var presentFromViewController: UIViewController? = nil + + // Custom traits to pass to the paywall. + var customPaywallTraits: [String: Any]? = nil + + // Skip the paywall if the user is already entitled to a product in it. + var dontShowIfAlreadyEntitled: Bool = false + + // Maximum time to show a loading state before falling back. + // Pass zero or a negative value to disable the loading state. + var loadingBudget: TimeInterval = DEFAULT_LOADING_BUDGET + } + ``` + + + _(Optional)_ Handlers for paywall lifecycle events. See [Paywall Events](#paywall-events). + + + _(Optional)_ Called when the user is entitled to a product — either via a new purchase or an existing entitlement. + + + Called whenever the paywall cannot be displayed. If `config.dontShowIfAlreadyEntitled` is `true` and `onEntitled` is not provided, `onPaywallNotShown(.alreadyEntitled)` is called instead. + + + + +--- + +## Recommended Setup + +The following steps are optional but strongly recommended for production apps. + +### Identify Users + +User identification is optional. It improves targeting accuracy and event attribution in external analytics. If you're unsure whether you need it, you likely don't — but it's easy to add. + +Identify users **before** calling `Helium.shared.initialize` to ensure consistency from the first session. + +```swift +// Set a stable user ID +Helium.identify.userId = "your-user-id" + +// Set user traits for targeting and analytics +Helium.identify.setUserTraits(HeliumUserTraits(["hasOnboarded": true])) + +// Use addUserTraits() instead if you want to merge rather than replace existing traits +Helium.identify.addUserTraits(HeliumUserTraits(["plan": "free"])) +``` + + + If your app uses an `appAccountToken` for purchases, share it with Helium so it can be applied to paywall purchases. + + ```swift + if let token = UUID(uuidString: "your-app-account-token") { + Helium.identify.appAccountToken = token + } + ``` + + +--- + +### Paywall Events + +Helium emits events throughout the paywall lifecycle. You can handle them per-presentation or globally. + +#### Per-presentation handlers + +Pass a `PaywallEventHandlers` instance when calling `presentPaywall`: + +```swift +Helium.shared.presentPaywall( + trigger: "post_onboarding", + eventHandlers: PaywallEventHandlers() + .onOpen { event in + print("Opened: \(event.triggerName)") + } + .onClose { event in + print("Closed: \(event.triggerName)") + } + .onDismissed { event in + print("Dismissed: \(event.triggerName)") + } + .onPurchaseSucceeded { event in + print("Purchase succeeded: \(event.triggerName)") + } + .onCustomPaywallAction { event in + print("Custom action '\(event.actionName)': \(event.params)") + } + .onAnyEvent { event in + // Fires for every paywall event. + // Note: if you also set a specific handler (e.g. onOpen), + // both handlers will fire. + } +) { paywallNotShownReason in } +``` + +#### Global event listener + +Use a global listener to handle events across all paywalls without passing handlers at each call site. + +> **Important:** Listeners are held **weakly**. If you don't keep a strong reference to your listener object, it will be deallocated immediately and no events will fire. + +```swift +// ❌ Incorrect — listener is released immediately +Helium.shared.addHeliumEventListener(MyListener()) + +// ✅ Correct — retain the listener via a singleton or stored property +class MyHeliumEventListener: HeliumEventListener { + static let shared = MyHeliumEventListener() + + func onHeliumEvent(event: any HeliumEvent) { + print("Helium event: \(event.toDictionary())") + } +} + +Helium.shared.addHeliumEventListener(MyHeliumEventListener.shared) +``` + +To remove a listener: + +```swift +Helium.shared.removeHeliumEventListener(MyHeliumEventListener.shared) +``` + +You can also forward all events to an [existing analytics provider](/guides/third-party-analytics). + +--- + +### Fallback Paywalls + +Set up fallbacks to handle the rare case where a paywall fails to load. This is **strongly recommended** before going to production. + +Follow the [fallback bundle guide](/guides/fallback-bundle) once you have a production paywall ready. + +--- + +### Checking Entitlements + +Check entitlement status before showing a paywall to avoid showing it to users who are already subscribed. + + + +```swift Using dontShowIfAlreadyEntitled +Helium.shared.presentPaywall( + trigger: "my_trigger", + config: PaywallPresentationConfig( + dontShowIfAlreadyEntitled: true + ), + onEntitled: { + // User is already entitled or just completed a purchase + } +) { paywallNotShownReason in } +``` + + +```swift Checking status manually +let hasActiveSubscription = await Helium.entitlements.hasAnyActiveSubscription() + +if hasActiveSubscription { + // Grant access to premium content +} else { + // Show paywall +} +``` + + + + + | Method | Description | + | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | + | `hasAny()` | Returns `true` if the user has purchased any subscription or non-consumable. | + | `hasAnyActiveSubscription()` | Returns `true` if the user has any active subscription. | + | `hasEntitlementForPaywall(trigger:considerAssociatedSubscriptions:)` | Checks if the user is entitled to any product in a specific paywall. Returns `nil` if paywall config hasn't loaded yet. | + | `hasActiveEntitlementFor(productId:)` | Checks entitlement for a specific product ID. | + | `hasActiveSubscriptionFor(productId:)` | Checks for an active subscription by product ID. | + | `hasActiveSubscriptionFor(subscriptionGroupID:)` | Checks for an active subscription in a subscription group. | + | `purchasedProductIds()` | Returns all product IDs the user currently has access to. | + | `activeSubscriptions()` | Returns details on all active auto-renewing subscriptions. | + | `subscriptionStatusFor(productId:)` | Returns detailed status (subscribed, expired, grace period, etc.) for a product. | + | `subscriptionStatusFor(subscriptionGroupID:)` | Returns detailed status for a subscription group. | + + +--- + +## Advanced + +### RevenueCat Integration + +By default, Helium manages purchases directly. If you're already using RevenueCat, use the `HeliumRevenueCat` package instead to keep RevenueCat in control. + +#### Install HeliumRevenueCat + + + + 1. In Xcode, go to **Package Dependencies**. + 2. Click **\+** and enter: + + ```text + https://github.com/cloudcaptainai/helium-swift-revenuecat.git + ``` + 3. Add the **HeliumRevenueCat** product to your app's main target. + + + `HeliumRevenueCat` depends on [purchases-ios-spm](https://github.com/RevenueCat/purchases-ios-spm), not [purchases-ios](https://github.com/RevenueCat/purchases-ios). If you're currently using `purchases-ios` with SPM, switch to `purchases-ios-spm` to avoid build conflicts. + + + + Replace `Helium` in your `Podfile` with: + + ```ruby + pod 'Helium/RevenueCat', '~> 4.0' + ``` + + Then run: + + ```bash + pod install + ``` + + + +#### Configure + +Set the `RevenueCatDelegate` **before** calling `Helium.shared.initialize`: + +```swift +import HeliumRevenueCat // omit if using CocoaPods — `import Helium` is sufficient + +Helium.config.purchaseDelegate = RevenueCatDelegate( + revenueCatApiKey: "YOUR_REVENUECAT_API_KEY" // optional — see note below +) +``` + +> If you omit `revenueCatApiKey`, initialize RevenueCat yourself **before** instantiating `RevenueCatDelegate`. + +#### Keeping appUserID in sync + +If you [`change the RevenueCat appUserID`](https://www.revenuecat.com/docs/customers/identifying-customers#logging-in-after-configuration), sync the value to Helium: + +```swift +Helium.identify.revenueCatAppUserId = Purchases.shared.appUserID +``` + +--- + +### Custom Purchase Handling + +By default, Helium handles purchases for you. This section is for apps that need custom purchase logic. + +> **Tip:** If you only need to augment the built-in logic, subclass `StoreKitDelegate` or `RevenueCatDelegate` and call `super` on any methods you override. + +To fully replace purchase handling, implement `HeliumPurchaseDelegate`: + +```swift +public protocol HeliumPurchaseDelegate: AnyObject { + // Required — execute a purchase given a product ID. + func makePurchase(productId: String) async -> HeliumPaywallTransactionStatus + + // Optional — restore existing purchases. Return true on success. + func restorePurchases() async -> Bool + + // Optional — called for all Helium events. + func onPaywallEvent(_ event: HeliumEvent) +} +``` + +Reference implementations: [StoreKitDelegate](https://github.com/cloudcaptainai/helium-swift/blob/main/Sources/Helium/HeliumCore/StoreKitDelegate.swift) · [RevenueCatDelegate](https://github.com/cloudcaptainai/helium-swift/blob/main/Sources/HeliumRevenueCat/HeliumRevenueCat.swift) + +--- + +### Additional Methods + + + In most cases you don't need this — Helium shows a loading indicator automatically if the paywall hasn't finished downloading. + + ```swift + // Returns a HeliumFetchedConfigStatus value + Helium.shared.getDownloadStatus() + + public enum HeliumFetchedConfigStatus: String, Codable, Equatable { + case notDownloadedYet + case inProgress + case downloadSuccess + case downloadFailure + } + + // Simpler boolean check + Helium.shared.paywallsLoaded() + ``` + + + + Use this to verify a paywall is ready before attempting to display it. + + ```swift + Helium.shared.getPaywallInfo(trigger: "my_trigger") + // Returns PaywallInfo? + + public struct PaywallInfo { + public let paywallTemplateName: String + // false only if targeting or workflow config prevents display + public let shouldShow: Bool + } + ``` + + + + ```swift + Helium.shared.hidePaywall() // Hide the current paywall + Helium.shared.hideAllPaywalls() // Hide all displayed paywalls + ``` + + + + Resets Helium so `initialize` can be called again. Useful after changing user traits that affect paywall targeting. + + ```swift + Helium.resetHelium() + ``` + \ No newline at end of file diff --git a/sdk-quickstarts/overview.mdx b/sdk-quickstarts/overview.mdx new file mode 100644 index 0000000..b900c6e --- /dev/null +++ b/sdk-quickstarts/overview.mdx @@ -0,0 +1,44 @@ +--- +title: "SDK Quickstart" +description: "Get started with Helium SDKs across all platforms" +--- + +## Supported platforms + + + + Native iOS SDK for Swift and Objective-C + + + Native Android SDK for Kotlin and Java + + + Cross-platform SDK for React Native apps + + + Cross-platform SDK for Flutter apps + + + +## Common features + +All SDKs support: + +- Paywall presentation +- Event tracking and analytics +- Fallback handling for offline scenarios +- StoreKit testing (iOS) and test environments (Android) +- Third-party analytics integrations + +## Installation + +Each platform has specific installation instructions. Select your platform above to view detailed setup steps. + +## Next steps + +After installing the SDK: + +1. Configure your API keys +2. Present your first paywall +3. Set up event tracking +4. Test with sandbox environments \ No newline at end of file diff --git a/sdk-quickstarts/react-native.mdx b/sdk-quickstarts/react-native.mdx new file mode 100644 index 0000000..1ced113 --- /dev/null +++ b/sdk-quickstarts/react-native.mdx @@ -0,0 +1,415 @@ +--- +title: "React Native SDK" +--- + +Helium supports two SDK packages depending on your setup: + +| | Package | Platforms | +|---|---|---| +| **Expo 52+** | `expo-helium` | iOS + Android | +| **Older Expo / Bare** | `@tryheliumai/paywall-sdk-react-native` | iOS only | + +> We support Expo 49+ but recommend **Expo 53+**. + +--- + +## 1. Install the SDK + + +```bash Expo 52+ +npx expo install expo-helium +``` + +```bash Older Expo / Bare +npm install @tryheliumai/paywall-sdk-react-native +# or +yarn add @tryheliumai/paywall-sdk-react-native + +# Bare only — install native dependencies +npx react-native link @tryheliumai/paywall-sdk-react-native +``` + + +> **Expo users:** Helium uses native code, so you must use a [development build](https://docs.expo.dev/develop/development-builds/introduction/). Expo Go will not work. +> ```bash +> npx expo run:ios # or npx expo run:ios --device +> ``` + +--- + +## 2. Initialize Helium + +Call `initialize` early in your app's lifecycle, typically in your root component. Your API key is available in the [Helium dashboard → Profile](https://app.tryhelium.com/profile). + + +```tsx Expo 52+ +import { initialize } from 'expo-helium'; + +function App() { + useEffect(() => { + void initialize({ apiKey: 'YOUR_API_KEY' }); + }, []); +} +``` + +```tsx Older Expo / Bare +import { initialize } from '@tryheliumai/paywall-sdk-react-native'; + +function App() { + useEffect(() => { + void initialize({ apiKey: 'YOUR_API_KEY' }); + }, []); +} +``` + + +--- + +## 3. Show a Paywall + +Before calling `presentUpsell`, make sure you have a trigger and workflow configured in the [Helium dashboard](https://app.tryhelium.com/workflows). + + +```tsx Expo 52+ +import { presentUpsell } from 'expo-helium'; + +function YourComponent() { + return ( +