Subscription Gating (AI Trainer)
Gate AI Trainer workout generation behind your app's subscription while everything else (onboarding, the free fitness assessment, Q&A) stays available. KinesteX never shows a paywall and never processes payments — your app owns the subscription screen and the purchase flow. The trainer only enforces the gate and tells you when the user hits it.
The flow at a glance:
1. Mount the trainer with isSubscribed in customParams (from your live entitlements state). Missing/true = subscribed; only an explicit false gates generation.
2. When a non-subscriber taps Generate workout, generation is parked and the SDK emits open_subscription_flow. Present your subscription flow on top of the trainer view — don't dismiss it; the pending generation is waiting underneath.
3. Every time your flow closes, post exactly one subscription_result back into the view — "purchased" (the parked workout generates immediately, no second tap) or "dismissed" (the request is dropped; the trainer resumes and the free assessment stays reachable). Send it from every exit path: purchase success, close button, swipe-down, back gesture, purchase failure.
The event:
1{ "type": "open_subscription_flow", "source": "generate_workout", "date": "06 07 2026 14:52:10" }This event is an instruction, not a status — present your subscription flow now. It fires for new users (after onboarding) and returning users (after the readiness check-in) alike, and can also fire when the KinesteX backend rejects a generation mid-session (see backend-managed subscriptions below).
Edge cases:
| Scenario | What to do |
| App killed during the purchase flow | Pass the fresh isSubscribed: true in customParams at next mount. The pending generation is not persisted across launches — the user taps Generate again. |
| Purchase fails or is refunded mid-flow | Send "dismissed" — the user can retry from the same Generate button. |
| Duplicate messages | Idempotent — the first result wins; repeats are ignored. |
| Status unknown at launch | Omit isSubscribed (treated as subscribed). If your entitlements check later resolves to active, post { "subscription_result": "purchased" } — a pending generation resumes, otherwise future generations are simply unlocked. |
No host app listening? Standalone/link integrations can pass subscriptionReturnUrl in the launch config instead — the trainer redirects the browser there instead of posting open_subscription_flow. Your subscription page then relaunches KinesteX with the same userId and isSubscribed: true. Accepted values: https:// URLs or app deep links (e.g. clientapp://subscribe); script-executing schemes are rejected.
Backend-managed subscriptions: for server-side enforcement your backend can store the user's status with KinesteX and it becomes the source of truth, overriding the launch flag — see Session Auth & Managed Subscriptions.
Launch checklist:
isSubscribedpassed incustomParamson every mount, from your live entitlements stateopen_subscription_flow→ your subscription screen, overlaid on top of the trainer view- Every exit path of your flow sends
subscription_result="purchased"or"dismissed" - Verified end-to-end: blocked generate → overlay → sandbox purchase +
"purchased"→ workout generates without relaunch; closed overlay +"dismissed"→ trainer resumes
Minimum SDK versions: Swift KinesteXAIKit ≥ 1.1.4 (adds the workoutAction binding), Kotlin KinesteX-SDK-Kotlin ≥ 2.0.6.
1// Set this binding to post real-time actions into the trainer.
2@State private var trainerAction: [String: Any]? = nil
3
4kit.createTrainerChatView(
5 user: UserDetails(age: 32, height: 178, weight: 76, gender: .Male, lifestyle: .Active), // or nil
6 style: IStyle(style: "dark"),
7 isLoading: $isLoading,
8 customParams: [
9 "isSubscribed": SubscriptionManager.shared.isActive
10 ],
11 workoutAction: $trainerAction, // KinesteXAIKit ≥ 1.1.4
12 onMessageReceived: handleMessage
13)1private func handleMessage(_ message: KinestexMessage) {
2 switch message {
3 case .exit_kinestex:
4 showKinesteX = false
5 case .custom_type(let data):
6 guard let type = data["type"] as? String else { return }
7 if type == "open_subscription_flow" {
8 presentSubscriptionFlow()
9 }
10 default:
11 break
12 }
13}
14
15func presentSubscriptionFlow() {
16 let paywall = PaywallViewController()
17 paywall.onPurchaseCompleted = { [weak self] in
18 self?.trainerAction = ["subscription_result": "purchased"] // generation resumes
19 paywall.dismiss(animated: true)
20 }
21 paywall.onDismissed = { [weak self] in
22 self?.trainerAction = ["subscription_result": "dismissed"] // trainer resumes
23 }
24 present(paywall, animated: true)
25}