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).
+
+
+
+
+ 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.
+
+
+ 
+
+ 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!)
+
+
+
+
+ 
+
+
+
+
+ 
+
+
+
+ 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 (
+
+
+
+
+
+ The trigger name configured in the Helium dashboard.
+
+
+ _(Optional)_ Handlers for open, close, dismiss, purchase, open-fail, and custom action [events](/guides/helium-events).
+
+
+ _(Optional)_ Custom key/value pairs passed to the paywall.
+
+
+ _(Optional)_ If `true`, skips the paywall when the user already has an entitlement for a product in it.
+
+
+ _(Optional)_ Called on purchase success or restore. Also called instead of showing the paywall when `dontShowIfAlreadyEntitled` is `true` and the user is already entitled.
+
+
+ _(Optional)_ Called if both the desired paywall and its fallback fail to show. Uncommon, but worth handling. See [Fallback Paywalls](/guides/fallback-bundle).
+
+
+
+
+---
+
+## 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.
+
+
+```tsx Set during initialize
+await initialize({
+ apiKey: 'YOUR_API_KEY',
+ customUserId: 'your-user-id',
+ customUserTraits: {
+ hasOnboarded: true,
+ plan: 'free',
+ },
+});
+```
+
+```tsx Update after initialize
+import { setCustomUserId } from 'expo-helium';
+
+setCustomUserId('your-user-id');
+```
+
+
+---
+
+### Paywall Events
+
+Helium emits events throughout the paywall lifecycle. You can handle them per-presentation or globally.
+
+#### Per-presentation handlers
+
+Pass `eventHandlers` to `presentUpsell`:
+
+```tsx
+presentUpsell({
+ triggerName: 'my_paywall',
+ eventHandlers: {
+ onOpen: (event) => console.log('Opened:', event.type),
+ onClose: (event) => console.log('Closed:', event.type),
+ onDismissed: (event) => console.log('Dismissed:', event.type),
+ onPurchaseSucceeded: (event) => console.log('Purchased:', event.type),
+ onOpenFailed: (event) => console.log('Open failed:', event.type),
+ onCustomPaywallAction: (event) => console.log('Custom action:', event.type),
+ 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
+
+Pass `onHeliumPaywallEvent` to `initialize` to handle events across all paywalls without passing handlers at each call site:
+
+```tsx
+await initialize({
+ apiKey: 'YOUR_API_KEY',
+ onHeliumPaywallEvent: (event) => {
+ switch (event.type) {
+ case 'paywallOpen':
+ break;
+ case 'purchaseSucceeded':
+ // Handle successful purchase
+ break;
+ }
+ },
+});
+```
+
+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.
+
+
+```tsx Using dontShowIfAlreadyEntitled
+presentUpsell({
+ triggerName: 'my_paywall',
+ dontShowIfAlreadyEntitled: true,
+ onEntitled: () => {
+ // User is already entitled or just completed a purchase
+ },
+});
+```
+
+```tsx Checking status manually
+const hasActiveSubscription = await hasAnyActiveSubscription();
+
+if (hasActiveSubscription) {
+ // Grant access to premium content
+} else {
+ presentUpsell({
+ triggerName: 'my_paywall',
+ eventHandlers: {
+ onPurchaseSucceeded: (event: PurchaseSucceededEvent) => {
+ // Grant access to premium content
+ },
+ },
+ });
+}
+```
+
+
+
+ | Method | Returns | Description |
+ |--------|---------|-------------|
+ | `hasAnyActiveSubscription()` | `Promise` | Returns `true` if the user has any active subscription (including non-renewable). |
+ | `hasAnyEntitlement()` | `Promise` | Returns `true` if the user has any entitlement. |
+ | `hasEntitlementForPaywall(trigger)` | `Promise` | Returns `true` if the user is entitled to any product in a specific paywall. Returns `undefined` if the paywall hasn't loaded yet. |
+
+
+---
+
+## Advanced
+
+### RevenueCat Integration
+
+By default, Helium manages purchases directly. If you're already using RevenueCat, pass `createRevenueCatPurchaseConfig` to `initialize` to keep RevenueCat in control.
+
+Make sure RevenueCat is installed ([Expo](https://www.revenuecat.com/docs/getting-started/installation/expo) · [React Native](https://www.revenuecat.com/docs/getting-started/installation/reactnative)) and initialized via `Purchases.configure()` **before** calling `Helium.initialize`.
+
+
+```tsx Expo 52+
+import { initialize } from 'expo-helium';
+import { createRevenueCatPurchaseConfig } from 'expo-helium/src/revenuecat';
+
+await initialize({
+ apiKey: 'YOUR_API_KEY',
+ purchaseConfig: createRevenueCatPurchaseConfig(),
+ revenueCatAppUserId: await Purchases.getAppUserID(),
+});
+```
+
+```tsx Older Expo / Bare
+import { initialize } from '@tryheliumai/paywall-sdk-react-native';
+import { createRevenueCatPurchaseConfig } from '@tryheliumai/paywall-sdk-react-native/src/revenuecat';
+
+// Option A: let Helium initialize RevenueCat
+await initialize({
+ apiKey: 'YOUR_API_KEY',
+ purchaseConfig: createRevenueCatPurchaseConfig({ apiKey: 'YOUR_REVENUECAT_API_KEY' }),
+ revenueCatAppUserId: await Purchases.getAppUserID(),
+});
+
+// Option B: initialize RevenueCat yourself first, then omit apiKey
+await initialize({
+ apiKey: 'YOUR_API_KEY',
+ purchaseConfig: createRevenueCatPurchaseConfig(),
+ revenueCatAppUserId: await Purchases.getAppUserID(),
+});
+```
+
+
+#### 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:
+
+```tsx
+import { setRevenueCatAppUserId } from 'expo-helium';
+
+setRevenueCatAppUserId(await Purchases.getAppUserID());
+```
+
+---
+
+### Custom Purchase Handling
+
+By default, Helium handles purchases for you. This section is for apps that need custom purchase logic.
+
+Pass `createCustomPurchaseConfig` to `initialize`:
+
+
+```tsx Expo 52+ (separate iOS / Android handlers)
+import { createCustomPurchaseConfig } from 'expo-helium';
+
+await initialize({
+ apiKey: 'YOUR_API_KEY',
+ purchaseConfig: createCustomPurchaseConfig({
+ makePurchaseIOS: async (productId) => {
+ // Your iOS purchase logic
+ return { status: 'purchased' };
+ },
+ makePurchaseAndroid: async (productId) => {
+ // Your Android purchase logic
+ return { status: 'purchased' };
+ },
+ restorePurchases: async () => true,
+ }),
+});
+```
+
+```tsx Older Expo / Bare (single handler)
+import { createCustomPurchaseConfig } from '@tryheliumai/paywall-sdk-react-native';
+
+await initialize({
+ apiKey: 'YOUR_API_KEY',
+ purchaseConfig: createCustomPurchaseConfig({
+ makePurchase: async (productId) => {
+ // Your purchase logic
+ return { status: 'purchased' };
+ },
+ restorePurchases: async () => true,
+ }),
+});
+```
+
+
+`makePurchase` must return one of the following statuses:
+
+```ts
+type HeliumTransactionStatus =
+ 'purchased' | 'failed' | 'cancelled' | 'pending' | 'restored';
+```
+
+---
+
+### Additional Methods
+
+
+ Resets Helium so `initialize` can be called again. Useful after changing user traits that affect paywall targeting.
+
+ ```ts
+ import { resetHelium } from 'expo-helium';
+
+ await resetHelium();
+ ```
+
+
+---
+
+## Troubleshooting
+
+### Verify the package is installed
+
+
+```bash Expo 52+
+[ -d "node_modules/expo-helium" ] \
+ && echo "✅ expo-helium found" \
+ || echo "❌ expo-helium NOT found"
+```
+
+```bash Older Expo / Bare
+[ -d "node_modules/@tryheliumai/paywall-sdk-react-native" ] \
+ && echo "✅ paywall-sdk-react-native found" \
+ || echo "❌ paywall-sdk-react-native NOT found"
+```
+
+
+If not found, reinstall using the commands in [Step 1](#1-install-the-sdk).
+
+### Verify the native pod is installed
+
+
+```bash Expo 52+
+grep -E "Helium" ios/Podfile.lock > /dev/null \
+ && echo "✅ Helium found in Podfile.lock" \
+ || echo "❌ Helium not found in Podfile.lock" \
+&& grep -E "HeliumPaywallSdk" ios/Podfile.lock > /dev/null \
+ && echo "✅ HeliumPaywallSdk found in Podfile.lock" \
+ || echo "❌ HeliumPaywallSdk not found in Podfile.lock"
+```
+
+```bash Older Expo / Bare
+grep -E "Helium" ios/Podfile.lock > /dev/null \
+ && echo "✅ Helium found in Podfile.lock" \
+ || echo "❌ Helium not found in Podfile.lock"
+```
+
+
+If Helium isn't found in `Podfile.lock`, regenerate the native directories and rebuild:
+
+```bash
+npx expo prebuild --clean
+npx expo run:ios # or npx expo run:ios --device
+```
\ No newline at end of file