# Apparent docs: every page # Quick start Apparent shows how your app is doing in plain words: downloads, people, money, reviews. It takes about 10 minutes: add your app, send the first event, connect a store. ## 1. Add your app Sign in at [app.apparent.fyi](https://app.apparent.fyi), then **Add app**: its name and what it runs on. You get an **app id** and a **key**. The key only adds events, so it's fine to ship inside the app. ## 2. Paste this into your AI The fastest way. In Claude Code, Cursor or Codex, open your app's folder and paste the prompt from the **Add your app** page: it has your id and key in it already. Your AI finds the framework, installs the right SDK, starts it, tracks screens, suggests five events and runs the app. The page turns green with **First event from your app ✓** when it works. Here's the whole prompt, with placeholders: ````text Prompt Add Apparent analytics to this app (your app, on phones). Apparent is already waiting for it. App id: Key: Intake URL: https://in.apparent.fyi Do this, in order: 1. Find out what the app is built with: Expo, React Native, Swift, Kotlin, Flutter or a website. If the repo has more than one app (say iOS and Android), do each one. 2. Install the matching Apparent SDK from the list below. If the package can't be found, stop and tell me. Don't write your own. 3. Start it once, as early as the app starts, with the app id, key and intake URL above. Keep it on in debug builds: Apparent leaves simulators and test phones out of the numbers by itself. 4. Track screens the way the list says for this framework. 5. Read the code and propose these 5 events, each with the file and line where it would go: - sign_up: someone makes an account (skip if the app has no accounts) - paywall_view: the upgrade screen shows - purchase: a purchase goes through - : the one thing people come for, named after it, like scan_completed or habit_completed - _failed: something the person tried didn't work, like purchase_failed or upload_failed Show me the list. When I say yes, add them. Small properties only, never emails, names or other personal data. 6. Run the app (simulator, emulator or browser) and open a couple of screens. Then tell me to look at Apparent: it turns green with "First event from your app". If nothing shows within 2 minutes, look for the request to https://in.apparent.fyi/i in the logs (it answers 200 with "accepted"; 401 means the app id or key is wrong) and fix it. Keep the change small and don't touch anything else. ## The Apparent SDKs ### Expo Looks like: app.json or app.config.* with expo, or expo in package.json Install: npx expo install apparent-expo @react-native-async-storage/async-storage expo-application expo-localization Start it in the root layout (app/_layout.tsx) or App.tsx: ``` import { Apparent } from "apparent-expo"; Apparent.init({ apiUrl: "https://in.apparent.fyi", app: "", key: "" }); ``` Screens: // expo-router, in the root layout: import { useApparentScreens } from "apparent-expo/router"; useApparentScreens(); Events: Apparent.track("sign_up"); ### React Native Looks like: react-native in package.json without expo Install: npm install apparent-expo @react-native-async-storage/async-storage && npx pod-install Start it in index.js or App.tsx: ``` import { Apparent } from "apparent-expo"; Apparent.init({ apiUrl: "https://in.apparent.fyi", app: "", key: "" }); ``` Screens: // React Navigation: import { apparentScreens } from "apparent-expo/navigation"; const ref = useNavigationContainerRef(); Events: Apparent.track("sign_up"); ### Swift Looks like: an .xcodeproj, .xcworkspace or Package.swift with Swift sources Install: Xcode › File › Add Package Dependencies… › https://github.com/AhmedAshour/apparent-swift Start it in the App struct's init, or application(_:didFinishLaunchingWithOptions:): ``` import Apparent Apparent.start(app: "", key: "") ``` Screens: // SwiftUI, on each screen's view: .apparentScreen("Home") Events: Apparent.track("sign_up") ### Kotlin Looks like: build.gradle(.kts) with com.android.application Install: // app/build.gradle.kts implementation("fyi.apparent:apparent-android:0.2.0") Start it in your Application class's onCreate: ``` import fyi.apparent.Apparent Apparent.start(this, app = "", key = "", apiUrl = "https://in.apparent.fyi") ``` Screens: // Compose (apparent-compose), in each screen: TrackScreen("home") Events: Apparent.track("sign_up") ### Flutter Looks like: pubspec.yaml with flutter Install: flutter pub add apparent Start it in main(), before runApp: ``` import 'package:apparent/apparent.dart'; await Apparent.start(app: '', key: '', apiUrl: 'https://in.apparent.fyi'); ``` Screens: // MaterialApp: navigatorObservers: [ApparentNavigatorObserver()], Events: Apparent.track('sign_up'); ### Website Looks like: an HTML site or a web framework (Next.js, Vite, Astro…) Install: Nothing to install: one script tag. Start it in the of every page: ``` ``` Screens: Pages are counted by themselves. Events: apparent("sign_up") ```` ## 3. Or by hand Pick your platform. Each page is one install line, one start line and screens. | Platform | Package | Guide | |---|---|---| | React Native and Expo | apparent-expo | [React Native and Expo](https://apparent.fyi/docs/sdk/expo) | | iOS (Swift) | Apparent (Swift Package) | [Swift](https://apparent.fyi/docs/sdk/swift) | | Android (Kotlin) | fyi.apparent:apparent-android | [Kotlin](https://apparent.fyi/docs/sdk/kotlin) | | Flutter | apparent (pub.dev) | [Flutter](https://apparent.fyi/docs/sdk/flutter) | | Website | the Apparent script | [Website](https://apparent.fyi/docs/sdk/web) | Expo, in short: ```sh npx expo install apparent-expo @react-native-async-storage/async-storage expo-application expo-localization ``` ```ts import { Apparent } from "apparent-expo"; Apparent.init({ apiUrl: "https://in.apparent.fyi", app: "", key: "" }); ``` ## 4. Connect a store Downloads, sales and reviews come from the stores, not the SDK. On the **Connect** page add App Store Connect, Google Play or RevenueCat: each is a read-only key, checked before it's saved. See [Stores and ads](https://apparent.fyi/docs/connect). ## What's next - [Events and users](https://apparent.fyi/docs/events): the five events worth having, and signing people in. - [RevenueCat](https://apparent.fyi/docs/revenuecat): every sale as it happens, joined to what people did. - [Ask your numbers](https://apparent.fyi/docs/ask): ask Apparent from Claude Code, Cursor or the terminal. - [Privacy answers](https://apparent.fyi/docs/privacy): what to fill in on Apple's and Google's forms. > **For AI agents.** Every page is also plain Markdown: add `.md` to its address. All of the docs in one file: [/docs/llms-full.txt](https://apparent.fyi/docs/llms-full.txt). --- # React Native and Expo One package, `apparent-expo`, for Expo (managed or prebuild) and bare React Native. No native code of its own. ## Install Expo: ```sh npx expo install apparent-expo @react-native-async-storage/async-storage expo-application expo-localization ``` Bare React Native: ```sh npm install apparent-expo @react-native-async-storage/async-storage && npx pod-install ``` Only `react-native` and `@react-native-async-storage/async-storage` are required. `expo-application`, `expo-localization`, `react-native-device-info` and `expo-router` are used when the app has them. ## Start it In the root layout (app/_layout.tsx) or App.tsx, once: ```ts import { Apparent } from "apparent-expo"; Apparent.init({ apiUrl: "https://in.apparent.fyi", app: "", key: "" }); ``` Add `disabled: __DEV__` if you don't want development builds counted at all. You usually don't need to: simulators and test phones are left out of the numbers by themselves. ## Screens expo-router, in the root layout: ```ts import { useApparentScreens } from "apparent-expo/router"; useApparentScreens(); ``` React Navigation: ```tsx import { apparentScreens } from "apparent-expo/navigation"; import { NavigationContainer, useNavigationContainerRef } from "@react-navigation/native"; export default function App() { const ref = useNavigationContainerRef(); return {/* … */}; } ``` Any other navigator: `Apparent.screen("settings")`. ## Events and users ```ts Apparent.track("paywall_view", { source: "onboarding" }); Apparent.setProperties({ plan: "free" }); // added to every later event Apparent.identify(user.id); // after sign-in Apparent.reset(); // on sign-out ``` More in [Events and users](https://apparent.fyi/docs/events). ## Purchases ```ts await Apparent.linkRevenueCat(Purchases); // after Purchases.configure ``` See [RevenueCat](https://apparent.fyi/docs/revenuecat). ## Errors and opting out - Crashes and unhandled promise rejections are caught by themselves (release builds). - Errors you catch: `Apparent.captureError(error)`. - Someone declines analytics: `Apparent.optOut()`, and `Apparent.optIn()` to start again. Remembered. - Ask for consent first? Start with `optOutByDefault: true` and call `Apparent.optIn()` once they agree. Nothing is collected before that. - Nothing showing up? In a debug build the SDK prints `Apparent: app id or key is wrong — events are not being saved` once when the app id or key doesn't match. Copy both again from Connect. - Simulators and emulators say so themselves (`simulator` in each batch), so they're never counted, even in a release build. ## Links, notifications and your own filter - **Links.** When a link opens the app, the SDK sends `deep_link_opened` with the link (secrets like `token=` or `code=` replaced by `[redacted]`) and its `utm_source`, `utm_medium` and `utm_campaign`. A campaign link that opens the app on its first day counts as where that person came from. Turn it off with `trackDeepLinks: false`. - **Notifications.** With `expo-notifications` in the app, a tap sends `push_opened` with the `campaign` and `title` you put in the notification's data. Never the text people saw. Turn it off with `trackPushOpens: false`. - **Your own filter.** `beforeSend` sees every event before it is saved. Return it changed, or `null` to drop it: ```ts Apparent.init({ apiUrl: "https://in.apparent.fyi", app: "", key: "", beforeSend: (event) => { if (event.name === "debug_tap") return null; const { email, ...props } = event.props ?? {}; return { ...event, props }; }, }); ``` ## Bare React Native: versions Without `expo-application` or `react-native-device-info` the SDK can't read your app's version. Pass it, or app updates can't be noticed: ```ts import { version } from "./package.json"; Apparent.init({ apiUrl: "https://in.apparent.fyi", app: "", key: "", appVersion: version }); ``` ## What it promises - Every event is saved on the phone within a quarter second, and right away when the app goes to the background or crashes. - Up to 1,000 events wait offline for a week. Nothing is sent twice by mistake: the server ignores repeats. - One request at a time, 10 seconds each, retries that wait longer each time. Automatic events: `first_open`, `app_open`, `app_updated`, `app_background`, `screen_view`, `error`, `deep_link_opened`, `push_opened` (with `expo-notifications`), and `install_source` on Android. --- # Swift (iOS) A Swift Package with no dependencies, for iOS 15+ and macOS 12+. SwiftUI and UIKit. ## Install Xcode › File › Add Package Dependencies… › https://github.com/AhmedAshour/apparent-swift (Up to Next Major Version from 0.2.1, product `Apparent`). In a `Package.swift`: ```swift Package.swift .package(url: "https://github.com/AhmedAshour/apparent-swift", from: "0.2.1"), // and on your target: .product(name: "Apparent", package: "apparent-swift"), ``` ## Start it In the App struct's init, or application(_:didFinishLaunchingWithOptions:): ```swift import Apparent Apparent.start(app: "", key: "") ``` For development builds that should send nothing: `Apparent.start(app: "", key: "", disabled: true)`. You usually don't need it: simulators and TestFlight phones are left out of the numbers by themselves. ## Screens SwiftUI, on each screen's view: ```swift HomeView().apparentScreen("home") ``` UIKit: `Apparent.trackUIKitScreens()` once at launch (a screen per view controller), or `Apparent.screen("settings")` yourself. ## Events and users ```swift Apparent.track("paywall_view", properties: ["source": "onboarding", "price": 4.99]) Apparent.setProperties(["plan": "free"]) // on every later event; nil removes one Apparent.identify(userID: user.id) // after sign-in Apparent.logout() // on sign-out ``` More in [Events and users](https://apparent.fyi/docs/events). ## Purchases With RevenueCat: ```swift Apparent.linkRevenueCat { Purchases.shared.attribution.setAttributes($0) } ``` Without RevenueCat, StoreKit 2: ```swift Apparent.observeStoreKitTransactions() // at launch: renewals, refunds, Ask to Buy Apparent.trackPurchase(try await product.purchase()) // where you sell ``` Each transaction is sent once, as `purchase`, `subscription_renewed` or `refund`. ## Links and notifications ```swift WindowGroup { RootView().apparentDeepLinks() } // deep_link_opened for links that open the app // UNUserNotificationCenterDelegate func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler done: @escaping () -> Void) { Apparent.handleNotificationResponse(response) // push_opened done() } ``` - `deep_link_opened` carries the link with secrets in it (`token`, `code`, `password`, `key`, `session`… parameters) redacted, and its `utm_source`, `utm_medium` and `utm_campaign`. Handle links yourself? Call `Apparent.handleDeepLink(url)` there instead of the modifier. A campaign link that opens the app on its first day counts as where that person came from. - `push_opened` carries only the `campaign` (or `utm_campaign`) and `title` you put in the notification's data, never what it said. One per tap. - Turn either off with `trackDeepLinks: false` / `trackPushOpens: false`. ## Errors and opting out - Crashes (Swift runtime errors, bad memory access, uncaught exceptions) are saved as the app goes down and sent on the next launch. Other crash reporters keep working. - Errors you catch: `Apparent.captureError(error)`. - `Apparent.optOut()` / `Apparent.optIn()`, remembered across launches. - Consent first: `optOutByDefault: true` collects nothing until `Apparent.optIn()`, also on later launches until then. - Nothing showing up? In a debug build the SDK prints `Apparent: app id or key is wrong — events are not being saved` once when the app id or key doesn't match. Copy both again from Connect. - Simulators and emulators say so themselves (`simulator` in each batch), so they're never counted, even in a release build. ## Privacy The package ships a `PrivacyInfo.xcprivacy`: no tracking, no tracking domains. No IDFA, no IDFV, no tracking prompt. The App Privacy answers are on [Privacy answers](https://apparent.fyi/docs/privacy). ## Options Everything else goes in `Apparent.start(ApparentConfiguration(app:key:…))`: | Option | Default | Does | |---|---|---| | `disabled` | `false` | Store and send nothing | | `userID` | `nil` | The signed-in person, if known at launch | | `anonymousID` | `nil` | An id you already have (RevenueCat's app user id), asked for on the first launch only | | `debug` | `false` | Print what is tracked and sent | | `sessionTimeout` | 30 min | Time in the background before a visit ends | | `captureErrors` | `true` | Report crashes on the next launch | | `trackDeepLinks` | `true` | `deep_link_opened` from `.apparentDeepLinks()` / `handleDeepLink` | | `trackPushOpens` | `true` | `push_opened` from `handleNotificationResponse` | | `optOutByDefault` | `false` | Nothing until `optIn()` | | `beforeSend` | `nil` | `{ event in … }`: change `event.name` / `event.properties`, or return `nil` to drop it. Sees every event, automatic ones too. If it throws, the event is kept | A `start` without an app id or a key is ignored, so a later correct one still works. --- # Kotlin (Android) For native Android apps, minSdk 24. No Google Play services, no dependencies, about 65 KB. Compose screens come in a second, optional library. ## Install ```kotlin // app/build.gradle.kts implementation("fyi.apparent:apparent-android:0.2.0") ``` With Compose, add the screens library too (same version, also on Maven Central): ```kotlin app/build.gradle.kts implementation("fyi.apparent:apparent-compose:0.2.1") ``` Optional: Play's install referrer library, so Apparent knows which link brought each install. ```kotlin app/build.gradle.kts implementation("com.android.installreferrer:installreferrer:2.2") ``` ## Start it In your Application class's onCreate: ```kotlin import fyi.apparent.Apparent Apparent.start(this, app = "", key = "", apiUrl = "https://in.apparent.fyi") ``` Add `disabled = BuildConfig.DEBUG` if debug builds shouldn't send. Emulators and Google's test phones are left out of the numbers by themselves. ## Screens Compose (the `apparent-compose` library), at the top of each screen: ```kotlin @Composable fun Paywall() { TrackScreen("paywall"); /* … */ } ``` Jetpack Navigation, once: ```kotlin val entry by navController.currentBackStackEntryAsState() TrackNavigation(entry?.destination?.route) // "practice/{topic}" → "practice/[topic]" ``` Without Compose: `Apparent.screen("paywall")`, or `ApparentConfig(…, trackActivities = true)` for one screen per Activity. ## Events and users ```kotlin Apparent.track("paywall_view", mapOf("source" to "onboarding")) Apparent.setProperties(mapOf("plan" to "free")) // on every later event; null removes one Apparent.identify(user.id) // after sign-in Apparent.logout() // on sign-out ``` Calls work from any thread and never block the main thread. More in [Events and users](https://apparent.fyi/docs/events). ## Purchases ```kotlin Apparent.linkRevenueCat { Purchases.sharedInstance.setAttributes(it) } // with RevenueCat Apparent.trackPurchase("pro_yearly", 29.99, "EUR") // Play Billing, without RevenueCat ``` ## Links and notifications The link (App Link or your own scheme) or Firebase notification tap that launches an Activity is sent on its own, as `deep_link_opened` and `push_opened`. For the ones that bring a running Activity back, pass the Intent on: ```kotlin override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) Apparent.handleIntent(intent) } ``` Notifications you show yourself (a Firebase message while the app is open, a local notification): `Apparent.trackPushOpened(message.data, message.messageId)` when it's tapped. Links you route yourself: `Apparent.trackDeepLink(url)`. - Secrets in a link (`token`, `code`, `password`, `key`, `auth`, `session`, `sig`…) are sent as `[redacted]`, and `utm_source`, `utm_medium`, `utm_campaign` come out as their own fields. A campaign link that opens the app on its first day counts as where that person came from. - `push_opened` carries only `campaign` (or `utm_campaign`) and `title` from the notification's **data**, never what it said. - Off: `ApparentConfig(…, trackDeepLinks = false, trackPushOpens = false)`. ## Your own filter `beforeSend` sees every event before it is saved. Return it changed, or `null` to drop it: ```kotlin Apparent.start(this, ApparentConfig(app = "einb", key = "…", beforeSend = { event -> if (event.name == "debug_tap") null else event.copy(properties = event.properties?.minus("email")) })) ``` If it throws, the event is kept as it was. ## Errors and opting out - Uncaught exceptions are saved before the app goes down and sent on the next launch. - Errors you catch: `Apparent.captureError(e)`. - `Apparent.optOut()` / `Apparent.optIn()`, remembered. - Ask for consent first? Start with `ApparentConfig(…, optOutByDefault = true)` and call `Apparent.optIn()` once they agree. Nothing is collected before that. - Nothing showing up? In a debug build the SDK prints `Apparent: app id or key is wrong — events are not being saved` once when the app id or key doesn't match. Copy both again from Connect. - Simulators and emulators say so themselves (`simulator` in each batch), so they're never counted, even in a release build. ## Good to know - The SDK never reads the advertising id, so you don't need the `AD_ID` permission. - Its state lives in `noBackupFilesDir`: a phone restored from a backup counts as a new install. - R8 rules come with the library. - It never throws into your app: a `start` without an app id or key is ignored with a Logcat warning, and odd property values (dates, big numbers, lists) are turned into plain ones. - Data safety answers: [Privacy answers](https://apparent.fyi/docs/privacy). --- # Flutter Pure Dart, no native code of its own. iOS and Android. ## Install ```sh flutter pub add apparent ``` Android release builds need the internet permission (Flutter's template only adds it for debug): ```xml android/app/src/main/AndroidManifest.xml ``` ## Start it In main(), before runApp: ```dart import 'package:apparent/apparent.dart'; await Apparent.start(app: '', key: '', apiUrl: 'https://in.apparent.fyi'); ``` No need to wait for it. Add `disabled: kDebugMode` if debug builds shouldn't send. ## Screens ```dart MaterialApp(navigatorObservers: [ApparentNavigatorObserver()], routes: {...}); ``` Each page route is sent by its name (`/settings/account` → `settings/account`, `/` → `home`). Dialogs, sheets and unnamed routes are skipped. With go_router, pass it in `GoRouter(observers: [...])`. Your own names: `ApparentNavigatorObserver(screenName: (route) => …)`, or `Apparent.screen('checkout')`. ## Events and users ```dart Apparent.track('paywall_view', {'source': 'onboarding'}); Apparent.setProperties({'plan': 'pro'}); // on every later event; null removes one Apparent.identify('user-42'); // after sign-in Apparent.logout(); // on sign-out ``` More in [Events and users](https://apparent.fyi/docs/events). ## Purchases ```dart await Apparent.linkRevenueCat((attrs) => Purchases.setAttributes(attrs)); ``` ## Errors and opting out - Flutter errors and uncaught Dart errors are sent as `error` events. Your own handlers (Crashlytics, Sentry) still run. - Native iOS and Android crashes are not caught. - Errors you catch: `Apparent.captureError(e, stack: s)`. - `Apparent.optOut()` / `Apparent.optIn()`, remembered. - Ask for consent first? Start with `optOutByDefault: true` and call `Apparent.optIn()` once they agree. Nothing is collected before that. - Nothing showing up? In a debug build the SDK prints `Apparent: app id or key is wrong — events are not being saved` once when the app id or key doesn't match. Copy both again from Connect. - Simulators and emulators say so themselves (`simulator` in each batch), so they're never counted, even in a release build. ## Links, notifications and your own filter Flutter can't see links or notification taps by itself, so pass them on from the package you already use. ```dart // app_links: the link the app was launched with, then each later one AppLinks().uriLinkStream.listen(Apparent.trackDeepLink); // firebase_messaging: a tapped notification final first = await FirebaseMessaging.instance.getInitialMessage(); if (first != null) Apparent.trackPushOpened(first.data, id: first.messageId); FirebaseMessaging.onMessageOpenedApp.listen((m) => Apparent.trackPushOpened(m.data, id: m.messageId)); ``` - **Links** are sent as `deep_link_opened` with the link (secrets like `token=` or `code=` replaced by `[redacted]`) and its `utm_source`, `utm_medium` and `utm_campaign`. A campaign link that opens the app on its first day counts as where that person came from. Turn it off with `trackDeepLinks: false`. - **Notifications** are sent as `push_opened` with the `campaign` and `title` you put in the notification's data. Never the text people saw. Turn it off with `trackPushOpens: false`. - **Your own filter.** `beforeSend` sees every event before it is saved. Return it changed, or `null` to drop it: ```dart Apparent.start( app: '', key: '', beforeSend: (event) { if (event.name == 'debug_tap') return null; event.props?.remove('email'); return event; }, ); ``` ## Privacy The package has no `PrivacyInfo.xcprivacy` of its own (its plugins ship theirs), so add Apparent's rows to your app's. See [Privacy answers](https://apparent.fyi/docs/privacy). --- # Website Your app's website, counted next to the app: visitors, where they came from, and who tapped your App Store or Google Play button. No cookies, nothing stored in the browser. ## Add the script Nothing to install: one script tag. In the of every page: ```html ``` Your site's id is on the **Add your app** page. Pages are counted by themselves, single-page apps too. Nothing is sent from `localhost`. ## Store clicks Links to the App Store or Google Play send `store_click` by themselves, with the store. ## Your own events From code: ```js apparent("sign_up") apparent("signup", { plan: "free" }); ``` Or on a button, no code: ```html ``` The attribute names match Umami's, so a site moving from Umami keeps its buttons. --- # Events and users Every Apparent SDK has the same calls with the same words. Examples here are in TypeScript; each SDK page has its own. ## What's tracked by itself | Event | When | |---|---| | `first_open` | The first launch after install. | | `app_open` | Each visit: at launch, and after 30 minutes away. Not for launches by a push or a background job. | | `app_background` | The app goes away, with the seconds it was open. | | `app_updated` | The first launch on a new version. | | `screen_view` | Each new screen, with the one before it. | | `error` | A crash or an error you report. | | `install_source` | Android, first launch: which link brought the install. | ## The five events worth adding - `sign_up`: someone makes an account (skip if the app has no accounts). - `paywall_view`: the upgrade screen shows. - `purchase`: a purchase goes through. - ``: the one thing people come for, named after it, like scan_completed or habit_completed. - `_failed`: something the person tried didn't work, like purchase_failed or upload_failed. Apparent already understands these names: `paywall_view` fills in "From store to sale", names ending in `_failed` or `_error` show as problems, and you pick your main action on the app's page. ## Names and properties ```ts Apparent.track("lesson_complete", { topic: "grammar", minutes: 4, first: true }); ``` - Names are `lowercase_snake`, the thing first and the verb last: `paywall_view`, `purchase_tap`, `lesson_complete`. - Properties are flat: text, numbers, true/false or empty. Anything else is turned into text. Text over 500 characters is cut, at most 40 properties. - Never put emails, names or other personal data in properties. - `setProperties({ plan: "pro" })` adds a property to every later event. Setting it to empty removes it. ## Screens Use stable route names, not titles or ids: `practice/[topic]`, not `Grammar` or `practice/17`. A repeat of the same screen is ignored. ## Signed-in users ```ts Apparent.identify(user.id); // after sign-in Apparent.reset(); // on sign-out (logout() on Swift, Kotlin and Flutter) ``` - Signing in keeps the install's id, so what someone did before signing in stays theirs. - Signing out starts a fresh id, so the next person on the phone is someone new. - Use your own user id, never an email address. ## Opting out `optOut()` stops everything and clears what's waiting; `optIn()` starts again. Both are remembered across launches. Offer it if your app asks for consent. ## Test phones Leave the SDK on in debug builds. Every SDK says when it runs in a simulator or emulator, so those are left out even without a device model. Simulators, emulators, Google's pre-launch phones, Apple's review and phones on a version that isn't in the store yet are recognised and left out of every number. --- # RevenueCat With RevenueCat connected, every sale, trial, renewal and refund shows up as it happens, and each purchase is joined to what that person did in the app. ## 1. Connect it On the **Connect** page, add RevenueCat: 1. Make a secret key: **Project settings › API keys**, version 2. [Open RevenueCat](https://app.revenuecat.com/) 2. Give it read access: Write on **Project configuration** adds sales the minute they happen. Then open your app's row and pick its RevenueCat project. Apparent reads your history once. ## 2. Add the webhook For sales within seconds, the app's row shows a **Webhook URL** and an **Authorization header**. In RevenueCat, **Integrations › Webhooks › Add**, paste both. Each purchase is then its own alert. ## 3. Join purchases to people One line after `Purchases.configure`, so a purchase knows which install made it: | SDK | Call | |---|---| | React Native and Expo | `await Apparent.linkRevenueCat(Purchases)` | | Swift | `Apparent.linkRevenueCat { Purchases.shared.attribution.setAttributes($0) }` | | Kotlin | `Apparent.linkRevenueCat { Purchases.sharedInstance.setAttributes(it) }` | | Flutter | `await Apparent.linkRevenueCat((attrs) => Purchases.setAttributes(attrs))` | It puts the install's id on the RevenueCat customer as `apparent_id`. Call it again after a sign-out. Even simpler: start the SDK with RevenueCat's app user id as the install id (`getAnonymousId` in Expo, `anonymousId` in Kotlin and Flutter), so both sides use one id from the first launch. ## Without RevenueCat - Swift: StoreKit 2, `Apparent.observeStoreKitTransactions()` and `Apparent.trackPurchase(…)`. - Kotlin: `Apparent.trackPurchase("pro_yearly", 29.99, "EUR")` from Play Billing. - Anywhere: `track("purchase", { product, price, currency })`. Store sales still arrive from App Store Connect and Google Play, a day or two late. --- # Stores and ads Downloads, sales and reviews come from your accounts, not the SDK. Each is connected once on the **Connect** page (the plug in the top bar), checked before it's saved, and only ever read. Keys are stored encrypted. The page draws each step for you. Here's what it asks. ## App Store Connect iPhone downloads, sales, reviews, store visits. 1. Make a key: **Users and Access › Integrations › App Store Connect API** › **+**, role **Finance**. [Open API keys](https://appstoreconnect.apple.com/access/integrations/api) 2. Download it: Apple lets you download the .p8 once. 3. Copy two numbers: **Issuer ID** is above the list. Vendor number: **Payments and Financial Reports**. [Open Payments](https://appstoreconnect.apple.com/itc/payments_and_financial_reports) ## Google Play Android installs, sales, crashes, reviews. 1. Make a service account: Add a **JSON key** to it. [Open Google Cloud](https://console.cloud.google.com/iam-admin/serviceaccounts) 2. Invite its email: **Users and permissions**: tick **View app information** and **View financial data**. [Open Play Console](https://play.google.com/console) 3. Copy the reports address: **Download reports › Statistics › Copy Cloud Storage URI**. Google can take a day. ## RevenueCat Every sale, trial, renewal and refund, as it happens. See [RevenueCat](https://apparent.fyi/docs/revenuecat). ## Mixpanel Who used your app and what they did, brought over every 10 minutes. Your history comes along. 1. Add a service account: **Organization settings › Service accounts**, role **Consumer**. [Open Mixpanel](https://mixpanel.com/settings/org) 2. Give it your apps' projects: The secret is shown once. ## Apple Ads Days your ads ran, what they spent and what they brought. Apple's admin login can't make API keys, so you invite a second Apple Account you own with the role **API Account Read Only**, sign in as it, and paste Apparent's public key. The Connect page walks you through it, with drawings. ## Google Ads, AdMob and Search Console Sign in with Google and tick what's asked. Use the account your ads or site are on. Apparent finds your campaigns and your website by itself. ## Then, per app Open the app's row and pick what belongs to it: its App Store id, Play package name, RevenueCat project, Mixpanel project. Ads are found by the store id. > **Store numbers are late.** Apple and Google send each day 1 to 5 days after it happened, so "today" never has downloads. Today shows people from the SDK instead. --- # Ask your numbers Ask Apparent about your apps from your own AI (Claude Code, Cursor, Codex) or the terminal. Same plain words as the page. Read-only: nothing can change your data or your accounts. ## 1. Make a token **Connect › Your agents**: give it a name, like "Claude Code", and **Create a token**. Copy it right away: it's shown once. Revoke it there any time. ## 2. Claude Code ```sh claude mcp add --transport http apparent https://app.apparent.fyi/mcp --header "Authorization: Bearer " ``` Then ask: *how are my apps doing?*, *why did downloads drop yesterday?*, *did the Apple Ads run pay back?* ## Cursor and other MCP clients Add a remote MCP server with this address and header: ```json .cursor/mcp.json { "mcpServers": { "apparent": { "url": "https://app.apparent.fyi/mcp", "headers": { "Authorization": "Bearer " } } } } ``` ## What it can answer | Tool | Answers | |---|---| | `how_are_my_apps` | Every live app's last 7 days in two sentences each. | | `today` | Today so far, against a usual day at this hour. | | `app_story` | One app's story for a period, its numbers and highlights. | | `app_topic` | One part: buyers, problems, versions, countries, sources, reviews… | | `why_did_it_change` | Why a number moved, biggest cause first. | | `ad_campaigns` | When each campaign ran, what it spent, what it brought. | | `timeline` | What happened when: releases, ads, best days, milestones. | | `list_apps` | Your apps and their ids. | ## The command line > **Not released yet.** The `apparent` command will come as `npx apparent`. ```sh apparent login https://app.apparent.fyi apparent # every app, last 7 days apparent 30d # one app: the story, numbers, highlights apparent buyers 90d # one topic apparent why yesterday apparent today ``` Add `--json` for machine-readable answers. For agents and CI, set `APPARENT_TOKEN` (and `APPARENT_URL`) instead of logging in. --- # Privacy answers What the Apparent SDKs collect, and what to answer in the stores' forms. Short version: no advertising ids, no tracking prompt, no IP address kept, nothing shared with anyone. ## What's collected - The events and screens your app tracks, and their properties. - A random id for the install, made by the SDK. Not the IDFA, IDFV, advertising id or Android ID. - Your own user id, only if you call `identify`. - App version, system version, phone model, language, region setting and time zone. - Crashes and errors: message, type, the first lines of the stack. - The country. The server reads the IP address once to find it, then throws the address away. Events are kept for 180 days, then removed. The totals and the list of people stay, so old days don't change. To delete one person, use **Erase a person** on the Connect page (or `apparent erase --yes`): all their installs go, and they are never stored again. Events go to Apparent only. Nothing is sold or shared for ads. Apple's App Tracking Transparency prompt is **not** needed. ## Apple: App Privacy In App Store Connect › **App Privacy**. Every row: **used for tracking: No**. | Data type | Collected | Linked to the person | Purpose | |---|---|---|---| | Usage Data › Product Interaction | Yes | No | Analytics | | Identifiers › Device ID (the random install id) | Yes | No | Analytics | | Diagnostics › Crash Data | Yes | No | Analytics, App Functionality | | Diagnostics › Other Diagnostic Data | Yes | No | Analytics, App Functionality | | Location › Coarse Location (country) | Yes | No | Analytics | | Identifiers › User ID | Only if you call `identify` | Yes | Analytics | | Purchases › Purchase History | Only if you track purchases | No (Yes with `identify`) | Analytics | If you call `identify`, answer **Linked to the person** for every row. The Swift SDK ships its `PrivacyInfo.xcprivacy`. Expo and Flutter apps: add these types to your app's own privacy manifest. ## Google Play: Data safety | Question | Answer | |---|---| | Does your app collect or share user data? | Collects. Not shared: Apparent processes it for you. | | Is all data encrypted in transit? | Yes (HTTPS). | | Can people ask for their data to be deleted? | Yes, through you. Apparent deletes on request. | | App activity › App interactions | Collected, Analytics. | | App info and performance › Crash logs | Collected, Analytics. | | App info and performance › Diagnostics | Collected, Analytics. | | Device or other IDs | Collected, Analytics. | | Personal info › User IDs | Only if you call `identify`. | | Financial info › Purchase history | Only if you track purchases. | | Location | Not collected. | | Required or optional? | Optional if you offer `optOut()`, otherwise required. | No `AD_ID` permission is needed. ## For your privacy policy Copy and adjust: ```text We use Apparent to understand how the app is used. It records which screens you open and what you do in the app, with a random id for this install, your phone model, system version, language and country. It doesn't use advertising ids or keep your IP address, and the data isn't shared with anyone else. ``` Apparent's own policy: [apparent.fyi/privacy](https://apparent.fyi/privacy). --- # Send events from your server Some things happen on your server, not on the phone: a subscription renews, a webhook arrives, a job finishes. Send them to Apparent over HTTP with the same batch the SDKs use. Nothing to install. ## The write key is safe on your server The key (`k`) only lets you add events. It already ships inside your app, so it is not a secret, and keeping it in your server's settings is fine. Find it on the Connect page. ## Send an event ```js Node import { randomUUID } from "node:crypto"; export async function track(userId, name, props = {}) { const now = Date.now(); const res = await fetch("https://in.apparent.fyi/i", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ a: process.env.APPARENT_APP, k: process.env.APPARENT_KEY, v: "server-0.1.0", sa: now, d: { platform: "ios", os: "18.0", app: "1.0.6", locale: "en-US", anon: userId, user: userId }, e: [{ n: name, t: now, s: "server", i: randomUUID().replaceAll("-", ""), p: props }], }), }); if (!res.ok) throw new Error(`Apparent answered ${res.status}`); } ``` ```python Python import os, time, uuid, requests def track(user_id, name, props=None): now = int(time.time() * 1000) r = requests.post("https://in.apparent.fyi/i", timeout=10, json={ "a": os.environ["APPARENT_APP"], "k": os.environ["APPARENT_KEY"], "v": "server-0.1.0", "sa": now, "d": {"platform": "ios", "os": "18.0", "app": "1.0.6", "locale": "en-US", "anon": user_id, "user": user_id}, "e": [{"n": name, "t": now, "s": "server", "i": uuid.uuid4().hex, "p": props or {}}], }) r.raise_for_status() ``` ```sh curl curl https://in.apparent.fyi/i -H 'content-type: application/json' -d '{ "a": "", "k": "", "v": "server-0.1.0", "sa": '"$(date +%s)000"', "d": { "platform": "ios", "os": "18.0", "app": "1.0.6", "locale": "en-US", "anon": "user_8812", "user": "user_8812" }, "e": [{ "n": "subscription_renewed", "t": '"$(date +%s)000"', "s": "server", "i": "'"$(uuidgen | tr -d - | tr A-Z a-z)"'", "p": { "plan": "yearly" } }] }' ``` A good answer is `{"ok":true,"accepted":1,...}`. `401` means the app id or key is wrong. On `408`, `429` or `5xx`, wait and send the same batch again (same `i`). On `401` or other `4xx`, stop. ## Who it was - **A signed-in person:** put your own account id in `user` (or `u` on one event). Apparent counts everyone with the same user id as one person, across all their phones and reinstalls, so this event joins the same person as their app events. Use the same id you pass to `identify()` in the app. - **Send the user id as `anon` too.** People are found through `anon`, so one shared value for every user (like `"server"`) would merge them all into the first person. With `anon` equal to `user` the event lands on that person, and on the same person as their phone events. - An event with no user id counts as its own person, one per `anon`. - A server event is not someone opening the app, so it doesn't make a person "active" on its own. Their app visits do. ## The event id `i` is a random id for each event (32 hex characters, up to 80). Send the same one when you retry: the server keeps one copy per id, so sending twice is safe. For a webhook, a hash of the webhook's own id works well, so a repeated delivery never counts twice. All fields and limits are in the [Intake API](https://apparent.fyi/docs/api). --- # Intake API What every Apparent SDK sends. Use it to write an SDK for another platform, or to send events from a server. ## Send a batch ```http POST https://in.apparent.fyi/i content-type: application/json ``` No auth header: the app id and key go in the body. One request is one batch of up to 100 events from one app on one phone. You can gzip the body with `content-encoding: gzip`; 1 MB is the limit both as sent and unpacked. ```sh curl https://in.apparent.fyi/i -H 'content-type: application/json' -d '{ "a": "", "k": "", "v": "my-sdk-0.1.0", "sa": 1792761605123, "d": { "platform": "ios", "os": "18.0", "app": "1.0.6", "model": "iPhone15,2", "locale": "de-DE", "anon": "9f2c4b7e1a3d4c5b8e6f7a9b0c1d2e3f", "user": null }, "e": [{ "n": "app_open", "t": 1792761600010, "s": "5d1e0a9b7c3f4e2a8b6d0c1f2e3a4b5c", "i": "0a1b2c3d4e5f60718293a4b5c6d7e8f9", "p": { "cold": true } }] }' ``` ## The batch | Field | What it is | |---|---| | `a` | The app's id. | | `k` | The app's write key. It only adds events. | | `v` | Your SDK's name and version. | | `sa` | The phone's clock when this request is sent, in milliseconds. Read again on every retry. | | `d` | The device, below. | | `e` | 1 to 100 events, oldest first. | ## The device: `d` | Field | What to send | |---|---| | `platform` | `"ios"`, `"android"` or `"macos"` (a Mac app, also an iPhone app running on a Mac). iPadOS is `"ios"`. | | `os` | iOS: `"18.0"`. Android: the API level, `"30"`. | | `app` | The store version only, exactly as in the store: `"1.0.6"`. | | `build` | The build number, `"42"`. Optional. | | `model` | The raw hardware id: `"iPhone15,2"`, `"Pixel 8"`, `"Mac14,3"`. In the iOS Simulator, `"arm64"`. | | `simulator` | `true` in the iOS Simulator or an Android emulator. Leave it out on a real phone, never `false`. | | `locale` | The first preferred language, `"de-DE"`. | | `region` | The region setting, `"DE"`. Optional. | | `tz` | The time zone, `"Europe/Berlin"`. Optional. | | `anon` | The install id of the first event: 32 random hex characters, saved on the phone. | | `user` | The first event's user id, or `null`. | Send raw values: Apparent spots simulators, emulators and test phones from them. ## An event: `e[]` | Field | What it is | |---|---| | `n` | Name, `lowercase_snake`, up to 80 characters. | | `t` | When it happened, phone clock, milliseconds. Never changed later. | | `s` | Session id: new at launch and after 30 minutes away. | | `i` | A random id for this event (32 hex), the same on every retry. | | `p` | Properties, flat: text, number, true/false, `null`. Optional. | | `a`, `u` | This event's install or user id, only when it differs from `d`. | ## Answers | Answer | Means | Do | |---|---|---| | `200 {"ok":true,"accepted":4,"duplicates":0,"skipped":0,"dropped":0}` | Stored. `dropped`: events past the 500th, not stored (never happens at 100 a batch). | Remove the batch. | | `413` | Over 1 MB. | Halve the batch, send again. | | `408`, `429`, `5xx`, no answer | Busy or offline. | Keep it, wait, retry: 2 s doubling up to 5 min, or `Retry-After`. | | `401` | Wrong app id or key. | Drop it. Retrying won't help. In a debug build, print once: `Apparent: app id or key is wrong — events are not being saved`. | | other `4xx` | Bad JSON or gzip, a body that isn't one object, `e` that isn't a list, or an encoding other than gzip (`415`). | Drop it. | Sending a batch twice is safe: the server keeps one copy per event id. When unsure, send again. ## Limits 500 events per batch (stay at 100), 60 properties per event, 64 characters per property name, 512 per value, ids up to 128. Ids must be text (or numbers): any other type counts as missing, so an event falls back to the batch's install and user id. Events older than 60 days are skipped. Phones with a wrong clock are corrected with `sa`. ## Check your SDK Every Apparent SDK passes the same 25-rule check against a local intake. Writing your own? Write to hello@apparent.fyi and we'll run it with you. --- # Support Questions, something broken, or something you wish Apparent did: write to **[hello@apparent.fyi](mailto:hello@apparent.fyi)**. Ash, who makes Apparent, reads every message the same day. Bugs come before new features. You can also use **What should we fix or build?** in Settings in the phone app, or the feedback button on any page of the dashboard. ## The phone app - **Sign in:** type your email, then the 6-digit code from the email. No password. - **Look around first:** on the sign-in screen, opens example apps with made-up numbers. No account needed. - **Notifications:** Settings › Good news on this phone. Sales, best days, new countries, 5-star reviews and new crashes. - **Widget:** touch and hold the home screen, add the Apparent widget. It shows the last 7 days across your apps. - **Sign out:** Settings › Sign out. Pushes and the widget stop on that phone. - **Delete your account:** Settings › Delete account, then type your email. Your apps, their numbers and connected accounts are deleted; backups drop the last copy within 30 days. On the web: Connect › Your account. ## Your data What Apparent keeps and why: [Privacy](https://apparent.fyi/privacy). What the SDKs collect and what to answer in the stores' forms: [Privacy answers](https://apparent.fyi/docs/privacy). --- # Changelog What changed in Apparent, its SDKs and these docs. Newest first. Follow it with the [RSS feed](https://apparent.fyi/docs/changelog.xml). ## 2026-10-24 · All four SDKs are out (0.2.1) Tags: SDKs - **Expo and React Native:** `npx expo install apparent-expo` ([npm](https://www.npmjs.com/package/apparent-expo)). - **Swift:** add `https://github.com/AhmedAshour/apparent-swift` in Xcode, from `0.2.1`. - **Kotlin:** `implementation("fyi.apparent:apparent-android:0.2.1")`, plus `apparent-compose` for Compose screens (Maven Central). - **Flutter:** `flutter pub add apparent` ([pub.dev](https://pub.dev/packages/apparent)). The code is open in [apparent-sdks](https://github.com/AhmedAshour/apparent-sdks) and [apparent-swift](https://github.com/AhmedAshour/apparent-swift). ## 2026-10-24 · Swift, Kotlin and Flutter: links, notifications, consent, sturdier Tags: SDKs - **Same features as Expo:** `beforeSend`, `optOutByDefault` (consent remembered), `deep_link_opened` and `push_opened` (only the campaign and title from the data, never the text). Kotlin picks up the launch link and Firebase taps on its own (`Apparent.handleIntent(intent)` for `onNewIntent`); Swift (`Apparent.handleDeepLink`, `handleNotificationResponse`, `.apparentDeepLinks()`) and Flutter (`Apparent.trackDeepLink`, `trackPushOpened`) give you one call to make. - **`Retry-After` waited exactly**, never sooner; a clock change never stretches a wait. - **Sign-in, logout and opt-out saved at once**, so a kill right after keeps them. - **Broken saved state, a stuck disk, odd property values or a bad `start`** never stop the SDK or throw into your app. ## 2026-10-24 · React Native and Expo: links, notifications, consent Tags: SDKs - **Links that open the app** are sent as `deep_link_opened`, secrets redacted. A campaign link (`utm_source`) that opens the app on its first day counts as where that person came from. - **Tapped notifications** are sent as `push_opened` (with `expo-notifications`), with only the campaign and title you put in the data. - **`beforeSend`** lets you change or drop any event before it is saved. - **`optOutByDefault`** collects nothing until `optIn()`, for apps that ask for consent first. ## 2026-10-24 · Send events from your server Tags: Docs A new guide: [send events from your server](https://apparent.fyi/docs/server) with curl, Node and Python. People are now counted by account: the same signed-in person on two phones, or after a reinstall, is one person, and only a visit with the app open makes someone active. ## 2026-10-23 · SDKs: Macs, simulators, a wrong key Tags: SDKs - **Macs** count as their own platform, `macos` (Swift, and iPhone apps running on a Mac). - **Simulators and emulators** say so in every batch, so they stay out of your numbers even without a device model. - **A wrong app id or key** prints one line in debug builds instead of losing events quietly. - **Early events** (tracked in the first milliseconds after start) keep their user and properties. Fixed in all four SDKs. - Flutter passes the same conformance rules as the others. ## 2026-10-23 · Docs Tags: Docs These docs, at apparent.fyi/docs. A quick start that begins with one prompt for your AI, a page per SDK, the privacy answers for both stores, and the intake API. Every page is also Markdown (add `.md`), and all of it is in [llms-full.txt](https://apparent.fyi/docs/llms-full.txt) for agents. ## 2026-10-23 · SDKs for every platform Tags: SDKs One contract for every SDK, checked by the same 25 rules: - **React Native and Expo**: `apparent-expo` 0.2 now works in bare React Native too. Not released yet. - **Swift**: iOS and macOS, SwiftUI screens, StoreKit 2, privacy manifest. Not released yet. - **Kotlin**: Android, Compose screens, Play Billing, install links. Not released yet. - **Flutter**: navigator observer, errors, offline queue. Not released yet. - **Website**: one script tag. Live. Until each is published, its install line is what it will be. This page says when each ships. ## 2026-10-23 · Set up with your AI Tags: App **Add your app** gives you one prompt for Claude Code, Cursor or Codex with your app's id and key in it, then turns green with "First event from your app ✓". Personal tokens let your own AI [ask your numbers](https://apparent.fyi/docs/ask) through MCP, read-only. ---