# Free Trial & Paid Conversion (AI Trainer)

Turn non-subscribed users into paying customers by letting them **experience the product before they ever see your paywall**. With a single launch flag (`isSubscribed: false`), the AI Trainer runs a complete free tier for you:

1. **Full onboarding.** The trainer interviews the user: goals, body parts, injuries, schedule, equipment. Every answer streams to your app as events, so you learn who this user is even if they never pay.
2. **A real camera fitness assessment.** Up to two guided exercises (a 30-second squat test and a 30-second push-up test) with live motion tracking: rep counting, form scoring, and a fitness level verdict. This is the "wow" moment. The user has now *used* the product, not read about it.
3. **A personalized plan preview.** The trainer shows the exact structure of the first workout it would build (phases, minutes, "every exercise set to your level") next to a **Create my workout** button, and a **locked chat input** that cycles through prompts a subscriber could ask ("Adjust the workout structure", "Analyze my last workout", "Make it easier on my knees").
4. **The conversion moment.** Tapping **Create my workout** or the locked input fires one event, `open_subscription_flow`. You overlay your paywall. On purchase, the parked workout generates immediately: no relaunch, no second tap.

Free users who have invested five minutes of answers, performed a tracked assessment, and seen their own plan one tap away convert at a far higher rate than users hitting a paywall cold. And because KinesteX never shows a paywall and never processes payments, **your app owns the entire purchase flow and 100% of the pricing**.

**The funnel at a glance:**

```
Onboarding wizard (free)                 → trainer_profile_onboarding per step
        │
        ▼
Camera fitness assessment (free)         → trainer_assessment_completed
        │   real motion tracking: reps, form score, fitness level
        ▼
Readiness check-in (free)
        │
        ▼
Workout structure preview + locked chat  ← the free tier's edge
        │   user taps "Create my workout" or the locked input
        ▼
open_subscription_flow                   → your paywall, overlaid on top
        │   you post { "subscription_result": "purchased" }
        ▼
Parked workout generates immediately, chat unlocks
```

**Requirements:** the standard [AI Trainer Chat integration](/docs/ai-trainer-chat). For the full flow on native, use Swift `KinesteXAIKit` ≥ 1.1.4 (adds the `workoutAction` binding for posting the purchase result) and Kotlin `KinesteX-SDK-Kotlin` ≥ 2.0.6 (`sendAction`). Platforms without runtime messaging can remount with `isSubscribed: true` instead (see [Unlock after purchase](#unlock-after-purchase)).

## What free users get (and what stays gated)

You control exactly one switch: `isSubscribed`. Everything else is automatic. A missing flag or `true` means fully subscribed; only an explicit `false` enables the free tier.

| Experience | Free user (`isSubscribed: false`) | Subscriber |
|------------|-----------------------------------|------------|
| Onboarding interview (goals, injuries, schedule) | ✅ Full | ✅ Full |
| Camera fitness assessment with motion tracking | ✅ Full, including retests | ✅ Full |
| Assessment results, fitness level, form score | ✅ Full | ✅ Full |
| Readiness check-in | ✅ Full | ✅ Full |
| Personalized workout structure preview | ✅ Shown with **Create my workout** | Skipped (generates right away) |
| Chat input | 🔒 Locked, shows rotating example prompts, tap = conversion event | ✅ Unlocked |
| Workout generation and training | 🔒 Gated behind `open_subscription_flow` | ✅ Full |

Two details worth knowing:

- **The assessment adapts to injuries on its own.** A user who reports knee problems gets a chair squat variant; pressing-chain injuries skip the push-up test. You never have to manage which exercises run. If no test is safe to run, the assessment step is withheld and the user self-reports instead.
- **The locked input is a feature, not a wall.** It cycles through real prompts a subscriber could ask, so the free user sees the breadth of what the chat does before you ask them to pay for it.

## The journey, screen by screen

What the free user actually sees, in order:

1. **Assessment personalization.** After onboarding, the trainer adapts the camera test to the user's profile. The interlude visualizes their own answers (duration, frequency, goal, equipment, focus areas) orbiting while it prepares.
2. **The tracked assessment.** The user performs the tests in front of the camera and lands on the statistics screen: workout score, accuracy score, activity level, and a per-exercise breakdown with reps counted and calories. This is real KinesteX motion tracking, free.
3. **Result confirmation in chat.** Back in the chat, the result becomes an editable card: reps, form percentage, and the computed level (Beginner / Intermediate / Advanced), with **Retest** one tap away.
4. **The preview and the paywall trigger.** The trainer lays out the first workout's structure: phases with per-phase minutes, built from the user's goal and equipment, with the note "Every exercise set to your level." Below it: the **Create my workout** button and the locked chat input. Both routes lead to your subscription flow.

## Launch the trainer for free users

Mount the trainer exactly as in the [standard integration](/docs/ai-trainer-chat#launching), passing your live entitlement state as `isSubscribed`. Pass it on **every** mount so a purchase made elsewhere (web checkout, promo code, family plan) is reflected on the next launch.

Optional but recommended: brand the trainer as your own coach with `aiTrainerName` and `aiTrainerColor` (see [Branding & appearance](/docs/ai-trainer-chat#branding)), so the free preview sells *your* product, not a third-party SDK.

**Mount with the free tier enabled**

_Swift (iOS)_
```swift
// Binding used later to post the purchase result back into the trainer.
@State private var trainerAction: [String: Any]? = nil

kit.createTrainerChatView(
    user: nil,                        // or your UserDetails
    style: IStyle(style: "dark"),
    isLoading: $isLoading,
    customParams: [
        "isSubscribed": SubscriptionManager.shared.isActive, // false = free tier
        // Optional branding, so the preview sells YOUR coach:
        "aiTrainerName": "ARIA+",
        "aiTrainerColor": "#7C4DFF",
    ],
    workoutAction: $trainerAction,    // KinesteXAIKit >= 1.1.4
    onMessageReceived: handleMessage
)
```

_Kotlin (Android)_
```kotlin
val trainerView = KinesteXSDK.createTrainerChatView(
    context = this,
    style = IStyle(style = "dark"),
    customParams = mapOf(
        "isSubscribed" to subscriptionManager.isActive, // false = free tier
        // Optional branding, so the preview sells YOUR coach:
        "aiTrainerName" to "ARIA+",
        "aiTrainerColor" to "#7C4DFF",
    ),
    isLoading = isLoading,
    onMessageReceived = ::handleWebViewMessage,
    permissionHandler = this
)
```

_React Native_
```jsx
const postData: IPostData = {
  key: 'YOUR_API_KEY',
  userId: 'user-123',
  company: 'YOUR_COMPANY',
  customParameters: {
    isSubscribed: subscriptionIsActive, // false = free tier
    // Optional branding, so the preview sells YOUR coach:
    aiTrainerName: 'ARIA+',
    aiTrainerColor: '#7C4DFF',
  },
  style: { style: 'dark' },
};

<KinestexSDK
  data={postData}
  integrationOption={IntegrationOption.AI_TRAINER_CHAT}
  handleMessage={handleMessage}
/>
```

_Flutter_
```dart
KinesteXAIFramework.createTrainerChatView(
  style: IStyle(style: 'dark'),
  isShowKinestex: showKinesteX,
  isLoading: isLoading,
  customParams: {
    "isSubscribed": subscriptionIsActive, // false = free tier
    // Optional branding, so the preview sells YOUR coach:
    "aiTrainerName": "ARIA+",
    "aiTrainerColor": "#7C4DFF",
  },
  onMessageReceived: handleWebViewMessage,
);
```

_HTML / JavaScript_
```html
// All parameters flat on the postData you send after kinestex_loaded.
const postData = {
  key: "YOUR_API_KEY",
  company: "YOUR_COMPANY",
  userId: "user-123",
  integration: "AI_TRAINER_CHAT",
  isSubscribed: subscriptionIsActive, // false = free tier
  // Optional branding, so the preview sells YOUR coach:
  aiTrainerName: "ARIA+",
  aiTrainerColor: "#7C4DFF",
  style: "dark",
};
iframe.contentWindow.postMessage(JSON.stringify(postData), "https://ai.kinestex.com");
```

_React (TypeScript)_
```tsx
const postData: IPostData = {
  key: 'YOUR_API_KEY',
  userId: 'user-123',
  company: 'YOUR_COMPANY',
  customParameters: {
    isSubscribed: subscriptionIsActive, // false = free tier
    // Optional branding, so the preview sells YOUR coach:
    aiTrainerName: 'ARIA+',
    aiTrainerColor: '#7C4DFF',
  },
};

<KinestexSDK
  integrationOption={IntegrationOption.AI_TRAINER_CHAT}
  data={postData}
  handleMessage={handleMessage}
/>
```

## The conversion moments and the event you receive

A free user hits the gate from two places, and the `source` field tells you which:

```json
{ "type": "open_subscription_flow", "source": "generate_workout", "date": "01 09 2026 14:52:10" }
{ "type": "open_subscription_flow", "source": "chat_input",        "date": "01 09 2026 14:52:10" }
```

- `"generate_workout"`: the user tapped **Create my workout** on the preview (or asked a returning-user flow to rebuild their plan). Their readiness answers are parked so the workout can generate the instant they purchase.
- `"chat_input"`: the user tapped the **locked chat input**. The pending generation is parked the same way, so a purchase from here also produces the workout immediately.

Treat the event as an instruction, not a status: **present your subscription flow now, overlaid on top of the trainer view**. Do not dismiss the trainer; the parked generation is waiting underneath. The `source` value is also a strong analytics signal: users arriving via `chat_input` were sold by the chat itself, which tells you the locked-input preview is doing its job.

Both values arrive through the same handler you already have; route them to the same paywall.

**Handle the event**

_Swift (iOS)_
```swift
private func handleMessage(_ message: KinestexMessage) {
    switch message {
    case .exit_kinestex:
        showKinesteX = false
    case .custom_type(let data):
        guard let type = data["type"] as? String else { return }
        if type == "open_subscription_flow" {
            let source = data["source"] as? String // "generate_workout" | "chat_input"
            analytics.track("paywall_shown", ["source": source ?? "unknown"])
            presentSubscriptionFlow()
        }
    default:
        break
    }
}
```

_Kotlin (Android)_
```kotlin
private fun handleWebViewMessage(message: WebViewMessage) {
    when (message) {
        is WebViewMessage.ExitKinestex -> closeTrainer()
        is WebViewMessage.CustomType -> when (message.data["type"] as? String) {
            "open_subscription_flow" -> {
                val source = message.data["source"] as? String // "generate_workout" | "chat_input"
                analytics.track("paywall_shown", mapOf("source" to (source ?: "unknown")))
                presentSubscriptionFlow()
            }
            else -> Unit
        }
        else -> Unit
    }
}
```

_React Native_
```jsx
const handleMessage = (type: string, data: { [key: string]: any }) => {
  switch (type) {
    case 'open_subscription_flow':
      // data.source: "generate_workout" | "chat_input"
      analytics.track('paywall_shown', { source: data.source ?? 'unknown' });
      presentSubscriptionFlow();
      break;
    case 'exit_kinestex':
      closeTrainer();
      break;
  }
};
```

_Flutter_
```dart
void handleWebViewMessage(WebViewMessage message) {
  if (message is ExitKinestex) {
    showKinesteX.value = false;
    return;
  }
  if (message is CustomType) {
    if (message.data['type'] == 'open_subscription_flow') {
      final source = message.data['source'] as String?; // "generate_workout" | "chat_input"
      analytics.track('paywall_shown', {'source': source ?? 'unknown'});
      presentSubscriptionFlow();
    }
  }
}
```

_HTML / JavaScript_
```html
window.addEventListener("message", (event) => {
  if (event.origin !== "https://ai.kinestex.com") return;
  const message = JSON.parse(event.data);
  if (message.type === "open_subscription_flow") {
    // On the raw message, source sits next to type (there is no nested
    // .data object on this event): "generate_workout" | "chat_input"
    analytics.track("paywall_shown", { source: message.source ?? "unknown" });
    openPaywall();
  }
});
```

_React (TypeScript)_
```tsx
const handleMessage = (type: string, payload: Record<string, any>) => {
  switch (type) {
    case 'open_subscription_flow':
      // payload.source: "generate_workout" | "chat_input"
      analytics.track('paywall_shown', { source: payload.source ?? 'unknown' });
      presentSubscriptionFlow();
      break;
    case 'exit_kinestex':
      setShowTrainer(false);
      break;
  }
};
```

## Unlock after purchase

When your subscription flow closes, tell the trainer what happened by posting exactly one `subscription_result`, from **every** exit path (purchase success, close button, swipe-down, back gesture, purchase failure):

- `"purchased"`: the user is unlocked for the rest of the session, the parked workout generates immediately, and the chat input opens up. No relaunch, no second tap.
- `"dismissed"`: the pending request is dropped. The preview stays on screen, the assessment stays reachable, and the user can tap **Create my workout** again later.

The message is idempotent: the first result wins and repeats are ignored, so a double-fired callback does no harm.

**Platforms without runtime messaging** (React Native, Flutter, React TS): remount the trainer with `isSubscribed: true` after a purchase. The confirmed onboarding profile is already persisted, so the user lands back in the chat and taps **Create my workout** once; only the parked one-tap resume is lost. **Standalone / link integrations** with no host app can pass `subscriptionReturnUrl` at launch instead: the trainer redirects the browser there rather than posting the event, and your page relaunches KinesteX with the same `userId` and `isSubscribed: true`.

To enforce the status server-side so clients cannot bypass the gate, register the subscription with the [Trainer API](/docs/trainer-api/trainer-api-subscriptions); the backend then becomes the source of truth and overrides the launch flag.

**Report the outcome**

_Swift (iOS)_
```swift
func presentSubscriptionFlow() {
    let paywall = PaywallViewController()
    paywall.onPurchaseCompleted = { [weak self] in
        self?.trainerAction = ["subscription_result": "purchased"] // parked workout generates
        paywall.dismiss(animated: true)
    }
    paywall.onDismissed = { [weak self] in
        self?.trainerAction = ["subscription_result": "dismissed"] // trainer resumes, preview stays
    }
    present(paywall, animated: true)
}
```

_Kotlin (Android)_
```kotlin
private fun presentSubscriptionFlow() {
    PaywallSheet(
        onPurchaseCompleted = {
            KinesteXWebViewController.getInstance()
                .sendAction("subscription_result", "purchased") // parked workout generates
        },
        onDismissed = {
            KinesteXWebViewController.getInstance()
                .sendAction("subscription_result", "dismissed") // trainer resumes, preview stays
        }
    ).show(supportFragmentManager, "paywall")
}
```

_React Native_
```jsx
// No runtime channel on React Native: remount with the fresh entitlement instead.
const [isSubscribed, setIsSubscribed] = useState(false);

async function presentSubscriptionFlow() {
  const purchased = await openPaywall();
  if (purchased) {
    // Changing isSubscribed remounts the trainer unlocked; the confirmed
    // profile is already persisted, so the user just taps Create my workout.
    setIsSubscribed(true);
  }
}

<KinestexSDK
  key={String(isSubscribed)}
  data={{ ...postData, customParameters: { ...postData.customParameters, isSubscribed } }}
  integrationOption={IntegrationOption.AI_TRAINER_CHAT}
  handleMessage={handleMessage}
/>
```

_Flutter_
```dart
// No runtime channel on Flutter: remount with the fresh entitlement instead.
Future<void> presentSubscriptionFlow() async {
  final purchased = await openPaywall();
  if (purchased) {
    setState(() => isSubscribed = true); // rebuild the trainer view unlocked
  }
}

KinesteXAIFramework.createTrainerChatView(
  style: IStyle(style: 'dark'),
  isShowKinestex: showKinesteX,
  isLoading: isLoading,
  customParams: {"isSubscribed": isSubscribed},
  onMessageReceived: handleWebViewMessage,
);
```

_HTML / JavaScript_
```html
function openPaywall() {
  showMyPaywallOverlay({
    onPurchased: () =>
      iframe.contentWindow.postMessage(
        JSON.stringify({ subscription_result: "purchased" }), // parked workout generates
        "https://ai.kinestex.com"
      ),
    onDismissed: () =>
      iframe.contentWindow.postMessage(
        JSON.stringify({ subscription_result: "dismissed" }), // trainer resumes, preview stays
        "https://ai.kinestex.com"
      ),
  });
}
```

_React (TypeScript)_
```tsx
// No runtime channel on React TS: remount with the fresh entitlement instead.
const [isSubscribed, setIsSubscribed] = useState(false);

async function presentSubscriptionFlow() {
  const purchased = await openPaywall();
  if (purchased) setIsSubscribed(true); // remounts the trainer unlocked
}

<KinestexSDK
  key={String(isSubscribed)}
  integrationOption={IntegrationOption.AI_TRAINER_CHAT}
  data={{ ...postData, customParameters: { ...postData.customParameters, isSubscribed } }}
  handleMessage={handleMessage}
/>
```

## What you learn about users who don't convert

The free tier is also a data funnel. Even a user who dismisses the paywall has handed you a complete fitness profile and a measured assessment, all delivered as events while they onboarded:

- `trainer_profile_onboarding` fires at **every applied wizard step** with that stage's answer (goals, injuries, schedule, equipment), using the same field names as the profile object. Mirror them into your CRM as they arrive.
- `trainer_profile_updated` fires once the questionnaire is confirmed, with the full profile.
- `trainer_assessment_completed` delivers the measured result:

```json
{
  "type": "trainer_assessment_completed",
  "date": "01 09 2026 14:52:10",
  "results": {
    "squats":  { "reps": 21, "level": "intermediate" },
    "pushups": { "reps": null, "level": null }
  },
  "fitnessLevel": "intermediate"
}
```

A slot the injury-adapted plan didn't measure keeps its object shape with `null` fields, so `results.squats.reps` never throws. See [Assessment & profile sync](/docs/ai-trainer-chat#profile-sync) for the full payloads.

That data powers your win-back campaigns: "You measured 21 squats last week. Your Beginner-to-Intermediate plan is one tap away." And because you pass the profile back on the next launch ([pre-filling](/docs/ai-trainer-chat#prefill)), a returning free user resumes exactly where they left off instead of starting over.

## Launch checklist

- `isSubscribed` passed on **every** mount from your live entitlement state (missing or `true` means subscribed; only an explicit `false` enables the free tier)
- `open_subscription_flow` handled for **both** `source` values (`generate_workout` and `chat_input`) and routed to your paywall, overlaid on top of the trainer view
- Every exit path of your paywall posts `subscription_result` = `"purchased"` or `"dismissed"` (Swift/Kotlin/HTML), or remounts with `isSubscribed: true` (React Native / Flutter / React TS)
- Verified end to end: free onboarding → camera assessment runs and reports results → preview + locked input shown → tap fires the event → sandbox purchase. On Swift / Kotlin / HTML, `"purchased"` makes the parked workout generate without a relaunch; on React Native / Flutter / React TS, the remount comes up unlocked and one tap of **Create my workout** generates
- Verified the dismissal path: closed paywall + `"dismissed"` (or a remount still on `isSubscribed: false`) → trainer resumes with the preview still on screen
- Onboarding and assessment events (`trainer_profile_onboarding`, `trainer_profile_updated`, `trainer_assessment_completed`) forwarded to your backend for analytics and win-back campaigns
- Purchases made outside the app (web checkout, promo codes) reflected via `isSubscribed: true` on the next mount, or enforced server-side with [managed subscriptions](/docs/trainer-api/trainer-api-subscriptions)

For the mechanics of the gate itself (edge cases, duplicate messages, app killed mid-purchase), see the [Subscription gating guide](/docs/guides/guide-subscription-gating).

---
Source: https://www.kinestex.com/docs/use-cases/free-trial-conversion · Index: https://www.kinestex.com/llms.txt
