# Unolink > Unolink gives a mobile app one link that sends iPhone visitors to the App Store, Android visitors to Google Play and everyone else to the app's website. Each ad, post, creator or QR code can get its own link, so you can see which one brought installs, finished onboarding, saw the paywall and paid. Free plan available, no card required. Unolink is for indie developers and small app teams who want to know which marketing works without a full mobile measurement platform such as Branch or AppsFlyer. Key facts: - One smart link per app (go.unolink.dev/yourapp) with up to five destinations: App Store, Google Play, Mac App Store, Microsoft Store and a website. Known bots and link previews are never sent to a store and never counted. - Campaign links and QR codes: one path per ad, post, channel or flyer, each with its own numbers. - Analytics without code: clicks and daily unique visits by campaign, country, referrer, platform, browser, OS and utm_source, in your own time zone. - Works as the one link in a TikTok or Instagram bio and still reaches both stores. - Works before launch: an app can start with only its website (a waitlist or beta page) and no store link. Every visitor goes to the website and clicks are counted per link; once the store links are added, the same links and QR codes send people to the right store. - Landing pages before the store (pitch, rating, store button), with up to three versions to test (Indie and up). - A developer page that lists all your apps (Indie and up). - With the SDK (Pro and up): installs, onboarding steps, paywall views, sign-ups, trials and purchases per link, plus funnels, per-install journeys and a plain-language report. Purchases can come straight from RevenueCat. - Open-source SDKs for iOS, Android, React Native/Expo and Flutter. - Privacy: no cookies, no ad IDs, no fingerprinting, and IP addresses are never stored. iPhone installs are matched to their link without fingerprinting. On Android the app can open on the right screen after an install from a link (deferred deep links). - Plans: Free, Indie ($6 a month), Pro ($19) and Studio ($49); annual billing gives two months free. Unlimited redirects on every plan. Custom domains are coming soon. - Website and docs in English, French, Spanish, German, Dutch, Portuguese (Brazil), Italian and Russian. ## Product - [Homepage](https://unolink.dev): how it works, with demos of every feature - [Pricing](https://unolink.dev/pricing): plans and full comparison - [Docs](https://unolink.dev/docs): setup guides - [Changelog](https://unolink.dev/changelog) ## Comparisons - [Branch and AppsFlyer alternatives for indie apps](https://unolink.dev/blog/branch-appsflyer-alternatives-for-indie-apps) - [Unolink vs Branch](https://unolink.dev/blog/unolink-vs-branch) - [Unolink vs AppsFlyer OneLink](https://unolink.dev/blog/unolink-vs-appsflyer-onelink) - [Firebase Dynamic Links alternatives](https://unolink.dev/blog/firebase-dynamic-links-alternatives) - [Unolink vs Linktree](https://unolink.dev/blog/unolink-vs-linktree) - [Unolink vs Bitly](https://unolink.dev/blog/unolink-vs-bitly) - [Unolink vs onelink.to](https://unolink.dev/blog/unolink-vs-onelink-to) ## Guides - [One link for the App Store and Google Play](https://unolink.dev/blog/one-link-for-app-store-and-google-play) - [Track app installs without an MMP](https://unolink.dev/blog/track-app-installs-without-an-mmp) - [Which post brought paying users](https://unolink.dev/blog/which-post-brought-paying-users) - [RevenueCat attribution](https://unolink.dev/blog/revenuecat-attribution) - [Onboarding and paywall funnel metrics](https://unolink.dev/blog/onboarding-paywall-funnel-metrics) - [Deferred deep linking explained](https://unolink.dev/blog/deferred-deep-linking-explained) - [QR code for app download](https://unolink.dev/blog/qr-code-for-app-download) - [Link in bio for your app](https://unolink.dev/blog/link-in-bio-for-your-app) - [App landing page checklist](https://unolink.dev/blog/app-landing-page-checklist) ## Unolink SDK (for AI coding assistants) The rest of this file is the complete SDK reference, so a coding assistant can add Unolink to an app. Unolink SDK shows which link or ad brings installs, onboarding, paywall views, sign-ups, trials and purchases for a mobile app, and (Android) opens the app on the right screen after an install from a link. Open-source SDKs for Android, iOS, React Native/Expo and Flutter, version 0.3.3. Requires the Pro plan or higher. Human docs: https://unolink.dev/docs/sdk Source: https://github.com/unolink-dev/unolink-sdk ## Rules for assistants - Call configure exactly once, as early as possible, with the app's SDK key (`ulk_…`, from Unolink → the app → Settings → API keys). The key is publishable: it ships inside the app. - Add one call at each funnel moment: onboarding finished, any paywall shown, account created. - If the app uses RevenueCat: do NOT add purchase or trialStarted calls. With SDK 0.3.2+, call connectRevenueCat once at launch, right after configure: it keeps the user ID equal to RevenueCat's own app user ID, guests included (iOS Unolink.connectRevenueCat { Purchases.isConfigured ? Purchases.shared.appUserID : nil }, Android Unolink.connectRevenueCat { Purchases.sharedInstance.appUserID }, React Native Unolink.connectRevenueCat(Purchases), Flutter Unolink.connectRevenueCat(() => Purchases.appUserID, addListener: Purchases.addCustomerInfoUpdateListener, removeListener: Purchases.removeCustomerInfoUpdateListener)). On iOS and Android also call Unolink.revenueCatUserChanged() after every Purchases.logIn and Purchases.logOut; on React Native and Flutter it's normally not needed (the ID is read again on every customer info update and when the app comes back to the foreground). Use connectRevenueCat instead of setUserId: don't call setUserId as well, anywhere (not next to Purchases.logIn or logOut either). Older SDKs: call setUserId with RevenueCat's own app user ID (iOS Purchases.shared.appUserID, Android Purchases.sharedInstance.appUserID, React Native await Purchases.getAppUserID(), Flutter await Purchases.appUserID) after Purchases.configure, after every Purchases.logIn and Purchases.logOut, and on every launch, also for guests. - On "continue as guest" (guest mode, no account), call signUpSkipped() (0.3.2+; React Native 0.3.1+), not signUp. - Installing: if CocoaPods' pod install fails with an encoding error (Ruby 4, or a locale that isn't UTF-8), run LANG=en_US.UTF-8 pod install. Don't use npm install --legacy-peer-deps: it rewrites package-lock.json and drops optional peer dependencies. - Change only what Unolink needs (don't remove or rewrite other code), and show the diff before applying it. - Never put names, emails, phone numbers or other personal data in event properties. - Debug builds should be marked as test devices (debug option). Don't hard-code a different key or configure twice. - Deferred deep links are Android only. On iOS, don't build deep-link-after-install logic around Unolink. ## Android (Kotlin/Java, minSdk 23) Install (app/build.gradle.kts, Maven Central): dependencies { implementation("dev.unolink:unolink:0.3.3") } Configure in Application.onCreate() (create an Application class and set android:name=".MyApp" on in AndroidManifest.xml if there is none): import dev.unolink.Unolink class MyApp : Application() { override fun onCreate() { super.onCreate() Unolink.configure(this, "ulk_…") } } Options: Unolink.configure(this, "ulk_…", UnolinkOptions(logLevel = LogLevel.DEBUG, debug = true, debugInstallReferrer = null)) - debug: null (default) = true for debuggable builds. Marks a test device. - logLevel: NONE, ERROR, WARN (default), INFO, DEBUG. Logcat tag "Unolink". - debugInstallReferrer: debug builds only, simulated Google Play referrer, e.g. "unolink_click_id=clk_…&unolink_deep_link=%2Fpromo". Read on first launch only. Calls: Unolink.onboardingCompleted() Unolink.paywallViewed("onboarding") // placement Unolink.paywallSkipped("onboarding") Unolink.signUp("email") // method; a new account only Unolink.signIn("google") // signed in to an account they already had (0.3.0+) Unolink.signUpSkipped() // went on as a guest, without an account (0.3.2+) Unolink.trialStarted(productId = "pro_yearly") // skip if RevenueCat is connected Unolink.purchase(BigDecimal("49.99"), "USD", productId = "pro_yearly") // skip if RevenueCat is connected Unolink.purchaseCancelled(productId = "pro_yearly") Unolink.purchaseFailed(productId = "pro_yearly", reason = "card_declined") Unolink.track("level_up", mapOf("level" to 3)) Unolink.connectRevenueCat { Purchases.sharedInstance.appUserID } // with RevenueCat, once (0.3.2+) Unolink.revenueCatUserChanged() // after Purchases logIn / logOut (0.3.2+) Unolink.setUserId(userId) // without RevenueCat; null on logout Unolink.setDeferredDeepLinkListener { link -> // once per install, main thread // link.deepLink: String?, link.params: Map, link.linkSlug: String?, link.clickId: String link.deepLink?.let { navigate(it) } Unolink.consumeDeferredDeepLink() } // Link behind onboarding or sign-in (0.3.3+): when that's done, and on every launch after that until it's consumed, Unolink.getPendingDeferredDeepLink()?.let { link -> // kept on the device until consumed, across restarts, up to 30 days link.deepLink?.let { navigate(it) } Unolink.consumeDeferredDeepLink() // once opened } Unolink.getInstallId(); Unolink.flush(); Unolink.setEnabled(false) Java: Unolink.configure(this, "ulk_…"); Unolink.purchase(new BigDecimal("49.99"), "USD"); ## iOS (Swift/Objective-C, iOS 13+) Install: Swift Package Manager, https://github.com/unolink-dev/unolink-sdk, "Up to Next Major" from 0.3.3, library "Unolink" on the app target. Privacy manifest included. If the project also uses CocoaPods and pod install fails with an encoding error (Ruby 4, or a locale that isn't UTF-8): LANG=en_US.UTF-8 pod install. Configure in the @main App's init() (or AppDelegate didFinishLaunching). Pass debug: true from DEBUG builds (the library can't detect it): import Unolink @main struct MyApp: App { init() { #if DEBUG Unolink.configure(apiKey: "ulk_…", options: UnolinkOptions(debug: true)) #else Unolink.configure(apiKey: "ulk_…") #endif } var body: some Scene { WindowGroup { ContentView() } } } Options: UnolinkOptions(logLevel: .debug, debug: true). logLevel: .none, .error, .warn (default), .info, .debug; console prefix "[Unolink]". Calls: Unolink.onboardingCompleted() Unolink.paywallViewed(placement: "onboarding") Unolink.paywallSkipped(placement: "onboarding") Unolink.signUp(method: "apple") // a new account only Unolink.signIn(method: "apple") // signed in to an account they already had (0.3.0+) Unolink.signUpSkipped() // went on as a guest, without an account (0.3.2+) Unolink.trialStarted(productId: "pro_yearly") // skip if RevenueCat is connected Unolink.purchase(amount: product.price, currency: "USD", productId: product.id) // Decimal; skip if RevenueCat is connected Unolink.purchaseCancelled(productId: product.id) Unolink.purchaseFailed(productId: product.id, reason: "payment_failed") Unolink.track("level_up", properties: ["level": 3]) Unolink.connectRevenueCat { Purchases.isConfigured ? Purchases.shared.appUserID : nil } // with RevenueCat, once (0.3.2+) Unolink.revenueCatUserChanged() // after Purchases logIn / logOut (0.3.2+) Unolink.setUserId(userId) // without RevenueCat; nil on logout Unolink.installId; Unolink.flush(); Unolink.setEnabled(false) Objective-C: [Unolink configureWithApiKey:@"ulk_…" options:[UNLOptions new]]; [Unolink paywallViewedWithPlacement:@"onboarding" properties:nil]; Deep links: Unolink.onDeferredDeepLink(_:) exists for shared code, but on iPhone the app is never told which link was tapped, so it isn't called. Deferred deep links are Android only. (The dashboard still matches iPhone installs to their link, without fingerprinting.) ## React Native / Expo (React Native 0.79+, new architecture) Install. Needs native code: an Expo development build, prebuild or EAS Build. Does NOT work in Expo Go. npx expo install @unolink/react-native # then: npx expo prebuild, or eas build npm install @unolink/react-native # bare React Native, then: cd ios && pod install If pod install fails with an encoding error (Ruby 4, or a locale that isn't UTF-8), run it as LANG=en_US.UTF-8 pod install. Don't use npm install --legacy-peer-deps: it rewrites package-lock.json and drops optional peer dependencies. Configure once at the top level (app/_layout.tsx with Expo Router, or index.js): import { Unolink } from "@unolink/react-native" Unolink.configure("ulk_…", { debug: __DEV__ }) // options: debug (default __DEV__ from 0.3.1), logLevel ("none"|"error"|"warn"|"info"|"debug"), baseUrl // 0.3.1+: calls made before configure wait and are sent after it; Unolink.isConfigured() says whether it's running. // No config plugin is needed. Rebuild (prebuild / pod install) after installing: it has native code. Calls: useOnboardingStep("goal", { index: 0 }) // in each onboarding screen component (import { useOnboardingStep }) Unolink.onboardingStep("goal", { index: 0, flow: "v2" }) // the same without the hook; index and flow optional Unolink.onboardingSkipped("goal") Unolink.onboardingCompleted() Unolink.paywallViewed("onboarding") // placement: "onboarding", "launch", "feature_gate", "settings" or your own Unolink.paywallSkipped("onboarding") Unolink.signUp("apple") // a new account only Unolink.signIn("apple") // signed in to an account they already had (0.3.0+) Unolink.signUpSkipped() // went on as a guest, without an account (0.3.1+) Unolink.trialStarted("pro_yearly") // skip if RevenueCat is connected Unolink.purchase("49.99", "USD", "pro_yearly") // amount as string; skip if RevenueCat is connected Unolink.purchaseCancelled("pro_yearly") Unolink.purchaseFailed("pro_yearly", "payment_failed") Unolink.track("level_up", { level: 3 }) Unolink.connectRevenueCat(Purchases) // with RevenueCat (react-native-purchases), once (0.3.2+); returns a disconnect function // Unolink.revenueCatUserChanged() is normally not needed here: the ID is read again on customer info updates and in the foreground Unolink.setUserId(userId) // without RevenueCat; null on logout const sub = Unolink.addDeferredDeepLinkListener((link) => { // Android only; returns a subscription if (link.deepLink) router.push(link.deepLink) Unolink.consumeDeferredDeepLink() }) sub.remove() // Link behind onboarding or sign-in (0.3.1+): when that's done, and on every launch after that until it's consumed, // 0.3.3+: kept on the device until consumed, across restarts, up to 30 days (0.3.1-0.3.2: this launch only) const link = Unolink.getPendingDeferredDeepLink() if (link?.deepLink) { router.push(link.deepLink); Unolink.consumeDeferredDeepLink() } // deepLink: a path, or a full URL Unolink.getInstallId(); Unolink.flush(); Unolink.setEnabled(false) // Jest (0.3.1+): jest.mock("@unolink/react-native", () => require("@unolink/react-native/jest")) ## Flutter (Flutter 3.22+, Android and iOS) Install: flutter pub add unolink If CocoaPods fails in the iOS build with an encoding error (Ruby 4, or a locale that isn't UTF-8), run LANG=en_US.UTF-8 pod install in the ios folder. Configure in main(): import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:unolink/unolink.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await Unolink.configure('ulk_…', debug: kDebugMode); // also: logLevel: UnolinkLogLevel.debug, baseUrl runApp(const MyApp()); } Calls: Unolink.onboardingCompleted(); Unolink.paywallViewed(placement: 'onboarding'); Unolink.paywallSkipped(placement: 'onboarding'); Unolink.signUp(method: 'apple'); // a new account only Unolink.signIn(method: 'apple'); // signed in to an account they already had (0.3.0+) Unolink.signUpSkipped(); // went on as a guest, without an account (0.3.2+) Unolink.trialStarted(productId: 'pro_yearly'); // skip if RevenueCat is connected Unolink.purchase('49.99', 'USD', productId: 'pro_yearly'); // skip if RevenueCat is connected Unolink.purchaseCancelled(productId: 'pro_yearly'); Unolink.purchaseFailed(productId: 'pro_yearly', reason: 'payment_failed'); Unolink.track('level_up', {'level': 3}); Unolink.connectRevenueCat(() => Purchases.appUserID, // with RevenueCat, once (0.3.2+) addListener: Purchases.addCustomerInfoUpdateListener, removeListener: Purchases.removeCustomerInfoUpdateListener); // Unolink.revenueCatUserChanged() is normally not needed here: the ID is read again on customer info updates and on resume Unolink.setUserId(userId); // without RevenueCat; null on logout Unolink.setDeferredDeepLinkListener((link) { // Android only if (link.deepLink != null) router.go(link.deepLink!); Unolink.consumeDeferredDeepLink(); }); // Link behind onboarding or sign-in (0.3.3+): when that's done, and on every launch after that until it's consumed, final link = await Unolink.getPendingDeferredDeepLink(); // kept on the device until consumed, across restarts, up to 30 days if (link?.deepLink != null) { router.go(link!.deepLink!); await Unolink.consumeDeferredDeepLink(); } await Unolink.getInstallId(); Unolink.flush(); Unolink.setEnabled(false); ## Events Wire names (what the dashboard shows): | Call | Event name | Funnel step | |---|---|---| | onboardingStep(step, index?, flow?) | onboarding_step (properties step, index, flow only) | Onboarding tab (not a funnel step), free | | onboardingSkipped(step) | onboarding_skipped | Onboarding tab (not a funnel step), free | | onboardingCompleted | onboarding_completed | Onboarding | | paywallViewed(placement) | paywall_viewed (property placement) | Paywall | | paywallSkipped(placement) | paywall_skipped | optional | | signUp(method) | sign_up (property method) | Sign-up | | signIn(method) | sign_in (property method) | Signed in (returning users) | | signUpSkipped() (0.3.2+; older: track("sign_up_skipped")) | sign_up_skipped | Continued as guest (not a drop-off) | | trialStarted(productId) | trial_started (property product_id) | Trial | | purchase(amount, currency, productId) | purchase (revenue: decimal amount + 3-letter currency) | Paid | | purchaseCancelled / purchaseFailed | purchase_cancelled / purchase_failed | optional | | track(name, properties) | your name | optional | | automatic | first_open, session_start, app_background | install, sessions | - Custom event names: start with a letter; letters, digits, _ and .; at most 64 characters. - Properties: at most 20 per event; strings (≤200 chars), numbers or booleans; 4 KB total. No personal data. - Funnels are made per app in the dashboard (Funnel → New funnel / Edit), each with 1-8 steps, custom events included. By default a step counts whenever it happens; a funnel can be set to count steps only in its order. - Onboarding screens: call onboardingStep("") when each onboarding screen appears (SwiftUI .onAppear, Compose LaunchedEffect, React useOnboardingStep / useEffect, Flutter initState), not from its button; onboardingSkipped("") on Skip; onboardingCompleted() at the end. step and flow follow the event-name rules and must never hold user data. Up to 100 per install, 40 different screens. Needs SDK 0.2.0+; older SDKs: track("onboarding_step", { step }). Only the onboarding's own screens: the paywall and sign-up screen send paywallViewed/paywallSkipped and signUp, even inside the same flow; a paywall closed without buying is paywallSkipped, never onboardingSkipped. onboardingCompleted goes right after the last onboarding screen, before the paywall or sign-up that follows (not at the end of the whole flow). - Accounts: signUp only when a new account is created; signIn when someone signs in to an account they already had (returning: reinstall, new phone, after sign-out, a reset password, an email that was already registered). Never both for the same action. sign_up_skipped when someone goes on as a guest. Journeys can filter new, returning and guest. - Guests: with RevenueCat, connectRevenueCat (0.3.2+) reports RevenueCat's app user ID each time it changes, the guest's anonymous ID and then the account's (older SDKs: call setUserId with it each time it changes). If the app reports only the account ID, a guest's purchase joins only with RevenueCat's next event for that customer (e.g. the first renewal). Call signUpSkipped() (0.3.2+; React Native 0.3.1+; older: track("sign_up_skipped")) when someone taps "continue as guest". Every ID an install reported counts, so purchases made as a guest, their renewals and RevenueCat transfers stay with the install. A guest who signs up later sends signUp then. - Counting: funnels count people, not events: each step counts installs that sent it at least once, in any order unless the funnel is set to in-order. Repeats (a paywall on every launch, onboardingCompleted again) count once per person but each counts toward the quota. - Paywalls: paywallViewed every time one appears, with its placement (Funnel splits by placement); not for subscribers who see none. paywallSkipped when closed without buying (close button, swipe down, back), not while a purchase waits for approval (Ask to Buy, 3-D Secure). - Test devices: debug builds (pass debug), TestFlight and Xcode builds on iPhone (0.3.0+), and Android release builds installed with adb or Android Studio (0.3.0+) are test devices automatically, and so is any install with a sandbox purchase. Google Play testing tracks can't be detected on the phone: pass debug: true in those builds. - Deferred deep links (Android): if the link's screen sits behind onboarding or sign-in, open it once onboarding is done: read getPendingDeferredDeepLink(), navigate to its deepLink, then call consumeDeferredDeepLink(). From 0.3.3 (Android, React Native, Flutter) the link waits on the device until it's consumed, even if the app is closed in between, for up to 30 days after the install, so don't store it yourself (no AsyncStorage or SharedPreferences holder); getPendingDeferredDeepLink() returns it on every launch until then. Listeners are called once per install. Always call consumeDeferredDeepLink() once the link is opened. (React Native 0.3.1-0.3.2 keep it for the current launch only.) deepLink is the link's path ("/promo/3for2") or a full URL. - Quota: automatic events and onboarding screens are free. Every other event the app sends (standard or custom) counts toward the monthly allowance: Pro 1M, Studio 5M. Nothing is blocked over it; above twice the allowance custom events are refused, standard events are always kept. ## Links in ads Use the link (one per ad, e.g. go.unolink.dev/yourapp/meta-ads) as the ad's website URL in a traffic or website campaign (button "Learn more" or similar). App-install campaigns ("Install now", "Download") open the store directly and skip the link. ## RevenueCat Late payments always count: every event belongs to its install, funnels and the Report never cut off, and the Report shows paid within 1/7/30 days, median days to pay and who came back after a quiet week. setUserId is the app's own user ID (stored only as a per-app HMAC), not fingerprinting and not ATT tracking: it only joins the app's own payments to its own installs. Connect in Unolink → the app → Settings → RevenueCat (webhook). RevenueCat then reports trials, purchases, renewals, cancellations and refunds, and app calls to purchase/trialStarted are ignored. Join them to the install with connectRevenueCat once at launch (0.3.2+), instead of setUserId; older SDKs: setUserId(RevenueCat's appUserID) after Purchases.configure, logIn, logOut and on every launch. Also for guests. Unolink stores only a per-app one-way hash of the ID. ## Test devices and debugging - Test devices: Android debuggable builds automatically; iOS with UnolinkOptions(debug: true); React Native with debug: __DEV__ (the default from 0.3.1); Flutter with debug: kDebugMode. RevenueCat sandbox purchases too. Dashboards hide them unless "Include test devices" is on (Funnel, Journeys). - Logs: the SDK only logs warnings and errors by default. Set logLevel to debug and look for "Unolink" in Logcat / the Xcode console. A 401 means a wrong or revoked key; a 403 means the plan has no SDK. - Events are sent in batches every 15 s while the app is open and when it goes to the background; flush() sends now. - Android deep links can be tested with UnolinkOptions(debugInstallReferrer = "unolink_click_id=clk_01JA0000000000000000000000&unolink_deep_link=%2Fpromo") in a debug build (first launch only), or with a real install via Google Play internal testing. ## Privacy - No fingerprinting, no IDFA or Google Advertising ID, no App Tracking Transparency prompt. - A random install ID stored in the app's own storage; app/OS/SDK version, phone or tablet, language. IP addresses are never stored. - User IDs are stored only as a per-app one-way hash. - App Store privacy label: product interaction and purchase history, used for analytics, not linked to identity, no tracking. - Never send names, emails or other personal data in event properties. ## Where data shows up Unolink dashboard → the app: Funnel (installs reaching each step, per link), Journeys (each install as an anonymous code, from link to purchase), Report (findings from fixed rules on the app's own numbers).