KovaleeSDK is an efficient iOS framework, that's packed with tools specifically for tracking user behavior, handling user purchases, and smoothly integrating ads.
We all know that integrating tracking tools can feel like solving a Rubik's cube. The hurdles of Apple's restrictions and the hoops we jump through with third-party frameworks make the process complex and time-consuming.
We've tackled this issue head-on at Kovalee. KovaleeSDK is our answer - a framework designed to handle the heavy lifting, automating many of the typically tedious steps and wrapping much of the tracking functionality into the SDK.
KovaleeSDK is split into multiple modules, distributed through SPM, each one taking care of a different area.
- KovaleeAttribution
- KovaleePurchases
- KovaleeRemoteConfig
- KovaleeTikTok
The package can be simply installed as a dependency from XCode.
- Add a package by selecting
File→Add Packages…in Xcode’s menu bar. - Search for the Kovalee iOS SDK using the repo's URL:
https://github.com/cotyapps/Kovalee-iOS-SDK - Set the Dependency Rule to be
Up to Next Major Version. - Select Add Package.
And you are good to go.
| Platform | Minimum target |
|---|---|
| iOS | 15.0+ |
To properly work, this Package needs a set of configurations files that should be created by the team @Kovalee.
If you are interested in using this package for your iOS app, feel free to get in contact.
Initialize the SDK in your AppDelegate or app entry point. Use the .development environment during development for debug logging, and .production for release builds:
UIKit (AppDelegate):
import KovaleeSDK
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_: UIApplication,
willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
#if DEBUG
let configuration = Configuration(environment: .development, logLevel: .debug)
#else
let configuration = Configuration(environment: .production)
#endif
Kovalee.initialize(configuration: configuration)
return true
}
}SwiftUI:
import SwiftUI
import KovaleeSDK
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}KovaleeSDK supports Amplitude Session Replay, which lets you capture and replay user sessions to better understand user behavior in your app.
To enable Amplitude Session Replay, set enableAmplitudeSessionReplay to true in your Configuration:
Kovalee.initialize(
configuration: Configuration(
environment: .production,
enableAmplitudeSessionReplay: true
)
)Session Replay is disabled by default. When enabled, the SDK automatically adds the AmplitudeSwiftSessionReplayPlugin to the Amplitude instance, and session replays will be available in your Amplitude dashboard.
KovaleeSDK supports TikTok Events API through the KovaleeTikTok module, allowing you to track app events and send them to TikTok for ad attribution and optimization.
-
Add the
KovaleeTikToklibrary to your app target in Xcode:- Select your app target → General → Frameworks, Libraries, and Embedded Content → + → select
KovaleeTikTok
- Select your app target → General → Frameworks, Libraries, and Embedded Content → + → select
-
Add your TikTok credentials to
KovaleeKeys.json:{ "tiktok": { "appId": "YOUR_APP_ID", "tiktokAppId": "YOUR_TIKTOK_APP_ID", "accessToken": "YOUR_ACCESS_TOKEN" } } -
Add
import KovaleeTikTokalongsideKovaleeSDKwhere you initialize the SDK:import KovaleeSDK import KovaleeTikTok
TikTok will be automatically initialized during Kovalee.initialize() when the keys are present. No additional setup call is needed.
If you don't have TikTok integration keys, reach out to the Kovalee team to get them configured for your app.
// Track an event
Kovalee.trackTikTokEvent("purchase", properties: ["value": 9.99, "currency": "USD"])
// Identify a user
Kovalee.identifyTikTokUser(externalId: "user_123", email: "user@example.com")
// Logout
Kovalee.logoutTikTok()
// Enable/disable tracking
Kovalee.setTikTokTrackingEnabled(false)
// Flush queued events
Kovalee.flushTikTokEvents()KovaleeSDKUI presents RevenueCat-hosted (remote) paywalls with all Kovalee tracking wired automatically — page_view_paywall, the full payment_* lifecycle (including restore failure), and the value-carrying ad-SDK conversions:
import KovaleeSDKUI
import RevenueCat
MainView()
.kovaleePaywall(
isPresented: $showPaywall, // gate on !isUserPremium at the call site
offering: offering, // nil → the RC "current" offering
source: "progress"
) { customerInfo in
isUserPremium = true
} onDismiss: { unlocked in // purchased OR restored an entitlement
if unlocked { isUserPremium = true }
}The paywall renders the template configured for the offering in the RevenueCat dashboard, so design changes ship without app updates. Prefer this over hand-wiring RevenueCatUI's presentPaywallIfNeeded — hand-rolled callbacks historically missed the ad-SDK conversions entirely.
Full guide — API, restore semantics, migration from hand-rolled paywalls: Documentation/PAYWALLS.md.
Every completed subscription purchase can fire value-carrying conversion events to TikTok (StartTrial/Subscribe) and Firebase (GA4 purchase, real charges only) — in one consent-gated dispatch, fired exactly once per purchase.
A purchase must reach the dispatch through exactly one of:
- Native purchases —
Kovalee.purchase(...)/Kovalee.purchaseSubscription(...)fire it automatically. - RevenueCat remote paywalls — present them with the
.kovaleePaywall(isPresented:offering:source:)modifier (KovaleeSDKUI); it wirespage_view_paywall, the wholepayment_*lifecycle, and the conversions automatically. - Custom RevenueCatUI wiring — capture the package in
purchaseStarted, then callKovalee.trackSubscriptionConversion(package:transaction:)inpurchaseCompleted.
The Firebase purchase event requires "analyticsEnabled": true in the firebase block of KovaleeKeys.json.
Full guide — events, per-sink requirements, pitfalls, and how to verify an integration: Documentation/PURCHASE_TRACKING.md.
Since 2.6.5, KovaleeAttribution includes Adjust's Google ODM module. To turn it on in your app:
- Update the SDK to 2.6.5 or newer. If your app pins
firebase-ios-sdkto 12.x, allow 13.x. - In
Info.plist, never setGOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGEtofalse. - Ask for tracking with
Kovalee.promptTrackingAuthorization().
To check it works: after a fresh install, adj_google_odm_info_processed is 1 in the app's UserDefaults.
KovaleeSDKUI ships a turnkey subscription-to-lifetime flow: detect an eligible subscription — a trial about to expire, or an established subscriber active past a threshold (see Eligibility conditions) → present a RevenueCat-hosted upsell paywall (typically lifetime) → on purchase, route the user to Apple's Manage Subscriptions sheet so they can turn off auto-renewal on the original subscription (Apple does not allow developer-side cancellation).
The host app never sees subscription state, paywall internals, or the manage-subscriptions sheet — it provides configuration and (optionally) receives the final outcome.
-
Add the
KovaleeSDKUIlibrary to your app target. It depends onRevenueCatUI, which is fetched automatically. -
Build a
Configuration:import KovaleeSDKUI let upsellConfig = SubscriptionUpsell.Configuration( offeringId: "lifetime_upsell", // RevenueCat offering to present trigger: .yearly, // watch for an expiring yearly trial triggerWithin: 48 * 3600, // …expiring within 48h (default: 48h, optional) storageKey: "trial_to_lifetime_v1", // namespace for the show-once flag condition: nil, // see Eligibility conditions (default: .trialExpiring(within: triggerWithin)) showCloseButton: false, // overlay an X if your paywall has no dismiss UI (default: false) cancelPromptStyle: nil // per-flow override; nil = shared KovaleeUI.configuration.style (default: nil) )
offeringId,trigger, andstorageKeyare required; everything else has a default.triggercan be.yearly,.monthly,.weekly,.anySubscription, or.productIdentifiers([...])for explicit product IDs.
What makes a matching subscription eligible is controlled by the optional condition parameter:
| Condition | Fires when |
|---|---|
.trialExpiring(within:) |
An active trial entitlement expires within the given window. This is the default — omitting condition is equivalent to .trialExpiring(within: triggerWithin). |
.activeLongerThan(_:) |
An active, non-trial subscription was first purchased more than the given interval ago. Use it to re-engage established subscribers — e.g. upselling a long-time yearly subscriber to lifetime. |
// Re-engage subscribers who have been paying yearly for 90+ days
let upsellConfig = SubscriptionUpsell.Configuration(
offeringId: "lifetime_upsell",
trigger: .yearly,
storageKey: "established_to_lifetime_v1",
condition: .activeLongerThan(90 * 24 * 3600)
)In both cases trigger scopes which products are considered.
SwiftUI — drop the modifier on your root view. It runs the check on appear and on every foreground; the show-once flag (keyed by storageKey) keeps it idempotent.
ContentView()
.checkSubscriptionUpsell(configuration: upsellConfig) { outcome in
// .notTriggered | .dismissed | .purchased
}UIKit — call from the view controller that should host the paywall:
SubscriptionUpsell.checkAndPresentIfNeeded(
configuration: upsellConfig,
from: self
) { outcome in
// …
}Deep link / marketing re-engagement — bypass the eligibility-condition and show-once gates and present unconditionally. The built-in handler matches <scheme>://upsell (custom schemes only — http/https are refused):
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url,
let windowScene = scene as? UIWindowScene,
let root = windowScene.keyWindow?.rootViewController else { return }
if SubscriptionUpsell.handleDeepLink(url, configuration: upsellConfig, from: root) {
return
}
// …your other deep-link handlers
}Add an ?offering=<id> query item to target a specific paywall, overriding configuration.offeringId for that link only — e.g. <scheme>://upsell?offering=Default_2 to email a harder-discount offering. Every other configuration field is preserved; without the param the configured offering is used.
Or present directly:
SubscriptionUpsell.presentNow(configuration: upsellConfig, from: presenter)The post-purchase "Lifetime unlocked" / cancel-prompt screens derive their look from the shared KovaleeUI.configuration.style — the same KovaleeUIStyle the feedback sheets use — so they match your app out of the box. On top of the shared colors, KovaleeUIStyle carries upsell-only fonts and the two hero SF Symbols:
KovaleeUI.configuration.style = KovaleeUIStyle(
backgroundColor: .black,
primaryColor: .white, // title
secondaryColor: .white.opacity(0.8), // body
ctaColor: .yellow, // hero icon + primary button
confirmationIcon: "crown.fill", // "Lifetime unlocked" hero icon
cancelPromptIcon: "bell.slash.fill" // "One last step" hero icon
)The primary button's text color is derived automatically for contrast against ctaColor. Per-flow overrides take precedence via Configuration.cancelPromptStyle.
The SDK's DebugView exposes a Subscription Upsell section (visible when Enable Debug Mode is on) with:
- Force Trigger on Launch — behaves as if a matching trial entitlement existed, regardless of subscription state. Gated to debug/TestFlight builds.
- Reset Shown State — clears the show-once flag so the flow can fire again.
- Preview Congrats Screen — opens the post-purchase screen directly to validate theming.
- Present Paywall — lists every RevenueCat offering (the active one marked (current)) and force-presents its upsell paywall immediately, bypassing eligibility and the show-once gate. Always shows a close button so the paywall is dismissable regardless of the offering's template.
| Outcome | Meaning |
|---|---|
.notTriggered |
No entitlement met the eligibility condition (no expiring trial / subscription not active long enough), the flow already ran for this storageKey, or the offering / network lookup failed. |
.dismissed |
The user closed the paywall without purchasing. |
.purchased |
The upsell was purchased. Fires after the congrats screen is dismissed. |
KovaleeSDKUI ships two ready-made feedback flows (iOS 17+). Metadata (OS/app version, RevenueCat & Amplitude ids, subscription status) is filled automatically from the SDK.
| Flow | What it collects | Backend (Firebase callable) |
|---|---|---|
| Founder | Free-form message + email + phone | writeToSheet |
| Features | Multi-choice survey → optional notes → confirmation | sendForm |
import KovaleeSDKUIAll feedback content, styling, and choices are configured once through KovaleeUI.configuration — typically in App.init or AppDelegate.didFinishLaunching. This is the single entry point; the call sites stay configuration-free.
KovaleeUI.configuration.appIcon = Image("AppIcon")
KovaleeUI.configuration.feedbackChoices = ["Daily journal", "Streaks", "Community challenges", "Other"]
KovaleeUI.configuration.style = .myAppStyle // shared by feedback + upsell
KovaleeUI.configuration.founderFeedbackText = FeedbackText(
cta: "Send feedback",
title: "Hey, I'm the founder.",
introText: "Tell me what to build next.",
imageName: "founder-photo", // an asset in YOUR app bundle
successText: "Thanks!"
)
KovaleeUI.configuration.featureFeedbackText = FeatureFeedbackText(
choicesTitle: "What should we build next?",
choicesSubtitle: "Pick the features you'd use most.",
notesTitle: "Anything else?",
notesSubtitle: "Tell us what would make the app better.",
notesPlaceholder: "Type here…",
confirmationTitle: "Feedback received",
confirmationMessage: "Thanks for helping shape the app."
)
featureFeedbackTextis required before presenting the features survey —showFeatures()triggers an assertion failure in debug builds (and is a no-op in release) if it hasn't been set.
Hold a FeedbackCoordinator, attach .userFeedback(coordinator:), and call showFounder() / showFeatures(). The call sites take no content — everything is read from KovaleeUI.configuration:
struct HomeView: View {
@State private var feedback = FeedbackCoordinator()
var body: some View {
content
.userFeedback(coordinator: feedback)
}
func contactSupport() {
feedback.showFounder()
}
func askWhatsNext() {
feedback.showFeatures(
onChoicesButtonTapped: { /* analytics */ },
onNotesActionTapped: { /* analytics */ }
)
}
}The feedback sheets and the subscription-upsell post-purchase screens share a single KovaleeUIStyle. All fields are defaulted, so .default works out of the box — set KovaleeUI.configuration.style to theme them:
extension KovaleeUIStyle {
static let myAppStyle = KovaleeUIStyle(
backgroundColor: Color("background"),
primaryColor: .white, // titles
secondaryColor: .white.opacity(0.8), // subtitle / body text
secondaryBackgroundColor: Color("card"), // fields & unselected chips
ctaColor: Color("accent"), // CTA + selected chip
selectedColor: .white, // selected chip label
unselectedColor: .white, // unselected chip label
buttonCornerRadius: 16,
symbol: nil // optional SF Symbol on the founder CTA
)
}The SDK's DebugView exposes an In-App Feedback section (visible when Enable Debug Mode is on, iOS 17+) with Preview Founder Feedback and Preview Feature Survey buttons that present each flow using the current KovaleeUI.configuration.
See Documentation/FEEDBACK.md for the full reference, the UIKit examples, and styling details.