SDK documentation
One package per platform (Swift for Apple, Kotlin for Android), no dependencies, the same events, the same dashboard. The whole integration is one or two lines; everything else is optional. New here? The quickstart walks you from an empty project to seeing yourself on the dashboard in seven checkable steps. This page is the reference.
On this page
How it fits together
1 · The SDK, in your app
Mints a random install id, batches events on disk, sends them over HTTPS with your app's write key. Never the IDFA, never GPS, and no IP is ever stored.
2 · The ingest
api.appglance.app takes JSON events, drops replays, and files them under the app the
key belongs to. Anything that can POST JSON can use it; see the HTTP API.
3 · Where you look
The dashboard, and alerts to wherever you want them: ntfy, Discord or a signed webhook, or native push from the AppGlance app for iPhone.
Install
Xcode → File → Add Package Dependencies… → paste:
https://github.com/AppGlance/appglance-appleVersion 1.2.4 or later, "Up to Next Major". Product AppGlance, one Swift package, no dependencies,
ships its own PrivacyInfo.xcprivacy. In a Package.swift:
.package(url: "https://github.com/AppGlance/appglance-apple", from: "1.2.4").
In your app module's build.gradle.kts:
dependencies {
implementation("app.appglance:appglance:1.2.4")
}Kotlin, minSdk 21, one dependency (androidx.lifecycle:lifecycle-process). The INTERNET
permission comes with the library's manifest. No advertising id, no location, no Play Services.
Nothing else to add: mavenCentral() is already in the repositories block of a default
Android project.
Using an AI coding agent? The quickstart has a paste-in prompt (Swift and Kotlin versions) that adds the SDK, interviews you about screens, paywall and core actions, and implements the events, a funnel link, identify and properties for you. The dashboard's Setup tab generates the same prompt with your key filled in.
Configure
Create your app in the dashboard, copy its write key from the Setup tab, and call configure
once, as early as possible:
import AppGlance
@main
struct MyApp: App {
init() {
AppGlance.configure(apiKey: "glance_live_…")
}
var body: some Scene {
WindowGroup {
RootView()
.trackAppLifecycle() // sessions + the live "active now" presence ping
}
}
}
import app.appglance.AppGlance
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
AppGlance.configure(this, "glance_live_…") // sessions and "active now" are automatic
}
}
UIKit app? Skip the modifier and call AppGlance.setActive(true) / setActive(false) from the
scene delegate's foreground / background callbacks.
No Application subclass yet? Create one and register it in the manifest
(<application android:name=".MyApp">). Sessions come from ProcessLifecycleOwner, so there is nothing to
attach to activities; set trackAppLifecycle = false and call AppGlance.setActive(true/false) if you'd rather drive it.
Safe by default: Debug and Simulator buildsemulator and debuggable builds never send anything by default. TestFlightBeta events are tagged separately, so your production numbers stay clean. The write key only grants write access to your app's own stream, and it can never read anything, which is why it's fine inside a shipped binary.
Testing from Xcode or the SimulatorAndroid Studio or the emulator?
Turn on debug mode:
AppGlance.configure(apiKey: "glance_live_…", debug: true)AppGlance.configure(this, "glance_live_…", debug = BuildConfig.DEBUG).
This build then sends too, and events keep their real simulator / debugemulator / debug
tag, so they show under All in the dashboard and never touch Live, and the SDK narrates to
the console: [AppGlance] ▸ paywall.viewed, ✓ sent 3 events, ✕ HTTP 401, check the write keylogcat
(tag AppGlance): ▸ paywall.viewed, ✓ sent 3, ✕ HTTP 401, check the write key. Without it,
a gated run prints one line telling you it isn't sending and why. Gate it on #if DEBUG and there is nothing to remove before you ship (or just turn it off by hand);BuildConfig.DEBUG is false in release builds, so there is nothing to remove;
isEnabled = false always wins over it.
Track anything
AppGlance.track("paywall.viewed")
AppGlance.track("workout.completed", metadata: ["type": "run"])
// Screens. The cheapest funnel step: records screen.paywall each time the view appears:
PaywallView().trackScreen("paywall") // SwiftUI modifier
AppGlance.trackScreen("paywall") // UIKit, from viewDidAppear
AppGlance.track("paywall.viewed")
AppGlance.track("workout.completed", mapOf("type" to "run"))
// Screens. The cheapest funnel step: records screen.paywall:
LaunchedEffect(Unit) { AppGlance.trackScreen("paywall") } // Compose
override fun onResume() { super.onResume(); AppGlance.trackScreen("paywall") } // Activity / Fragment
New signals appear in the dashboard's Events tab on their own, with no schema and no setup. Any signal can be charted from the
Overview metric picker, used as a funnel step, or wired to a push alert. The SDK also sends install exactly once per
fresh install. Names: lowercase, dot.separated, stable, up to 80 characters. Metadata: string values, at most 20 keys.
Never put personal data in signal names or metadata, because that is what identify is for (below), so it lands
on the user's profile and your privacy answers stay honest.
Adding AppGlance to an app that already has users
Every existing install mints its AppGlance id the first time your new build runs, so without help they would all read as new
users on the day you ship: one spike, with your real arrivals buried in it. They do not. The SDK reports when the app first
arrived - the App Store's record of when this Apple ID first got it, or Android's firstInstallTime - and the
dashboard counts those people as already had it rather than new. The chart's New bar splits into the two, so you
can watch your base arrive and still read your real growth off the same picture.
Nothing to switch on, and nothing added to your privacy answers: it is a date about the app, not about the person, sent once
per install. Already shipped an older AppGlance and watched that spike happen? Upgrading fixes it - every install that has not
sent its date yet backfills on its next session. If your app keeps its own signup date, which reaches further back than either
platform can see, pass it as firstInstalledAt and it wins.
Alerts follow the same split. An install alert to a channel a person reads (push, Discord or ntfy) stays
quiet for people counted as already had it, so shipping the SDK into an established base does not bury your lock screen or
your team's channel in notices about people who are not new. Prefer to hear them anyway? The Alerts tab has a switch, and
pushes are then captioned already had it, never new user. JSON webhooks are not filtered either way: they receive every
install, labelled with an origin of new, pre_existing or unknown, so
your own tooling can make the same call the chart does.
Optional: identify users
By default a user is a random install id and nothing else. If your app has accounts, you can put labels on the install (a name, an email, your own user id, any custom properties) and the dashboard shows them on the feed, makes them searchable in Users, and shows a profile card on the user's page. The install id stays the analytics identity; labels are merged on top of it.
// When someone signs in (or whenever the values change):
AppGlance.identify(id: account.id, email: account.email, name: account.name)
// Anything else about the person, merged with what's there ("" removes a key):
AppGlance.setUserProperties(["plan": "pro", "goal": "10k steps"])
// On sign-out. Forgets the labels, keeps the install id and its history:
AppGlance.reset()
// When someone signs in (or whenever the values change):
AppGlance.identify(id = account.id, email = account.email, name = account.name)
// Anything else about the person, merged with what's there ("" removes a key):
AppGlance.setUserProperties(mapOf("plan" to "pro", "goal" to "10k steps"))
// On sign-out. Forgets the labels, keeps the install id and its history:
AppGlance.reset()
Properties are segments. Whatever you set with setUserProperties
(plan, cohort, goal, any name you like) shows up as pills on the Users tab, and clicking a pill
filters the list to those users (plan = pro: everyone who bought; click one to see their sessions and events). Properties don't
need an email or a name: an anonymous install can carry plan: pro, and it stays "not linked" in Apple's terms.
Cheap by design: calling identify with the same values on every launch sends nothing,
only a change is sent (as a user.identify event carrying the whole merged set, so the server just keeps the latest snapshot).
Up to 20 properties, keys up to 40 characters, values up to 200. user.identify / user.reset are never billable
and never trigger alerts. Reserved keys: $id, $email, $name.
Privacy changes when you do this. The moment an install carries an email or a name, everything about it is linked to a person: your App Store answers gain Contact Info (Email Address, Name) and Identifiers → User ID as Data Linked to You, and Product Interaction moves there too; on Google Play, add Personal info → Name / Email address. Still no tracking, still no ATT prompt. The dashboard's Setup tab previews the label and lists the exact ticks. Never pass anything the person didn't give you, and give users a way to be forgotten. The user page has a Delete this user's data button that erases every event and label for that install.
API surface
Same calls, two spellings. Every call is cheap and non-blocking, applies in call order on one background thread, and is held and
replayed if it comes before configure (the 200 most recent of them, which is more than any launch makes).
| Swift | Kotlin | What it does |
|---|---|---|
| AppGlance.configure(apiKey:debug:) | AppGlance.configure(context, apiKey, debug) | The whole hosted setup. Call once at launch. debug is optional (see above). |
| AppGlance.configure(_:) | AppGlance.configure(context, Configuration) | Full-control variant taking an AppGlance.Configuration (options). |
| AppGlance.track(_:metadata:) | AppGlance.track(name, metadata) | Record an event. Metadata is string → string, ≤ 20 keys. |
| AppGlance.trackScreen(_:metadata:) | AppGlance.trackScreen(name, metadata) | Records screen.<name>. Swift also has the SwiftUI modifier .trackScreen(_:) that fires on appear. |
| AppGlance.identify(id:email:name:properties:) | AppGlance.identify(id, email, name, properties) | Put labels on the current install (all parameters optional). Merged with earlier labels; sent only when something changed. |
| AppGlance.setUserProperties(_:) | AppGlance.setUserProperties(map) | Merge custom properties into the user's labels; an empty string removes a key. Up to 20 keys. |
| AppGlance.reset() | AppGlance.reset() | Forget every label on this install (sign-out). The install id and its history stay. |
| .trackAppLifecycle() | automatic (trackAppLifecycle = true) | Sessions: emits session.start whenever the app comes to the front after more than sessionTimeout away, runs the foreground presence ping, flushes on background. SwiftUI modifier for the root view; on Android the SDK watches ProcessLifecycleOwner itself. |
| AppGlance.setActive(_:) | AppGlance.setActive(bool) | Manual foreground/background signal if you don't use the automatic lifecycle (UIKit apps; Android with trackAppLifecycle = false). |
| AppGlance.flush() | AppGlance.flush() | Force-send queued events now. |
Configuration options
AppGlance.configure(AppGlance.Configuration(
apiKey: "glance_live_…",
heartbeatInterval: 120,
enabledEnvironments: [.appStore] // narrow: production only
))
AppGlance.configure(this, AppGlance.Configuration(
apiKey = "glance_live_…",
enabledEnvironments = setOf(AppEnvironment.PRODUCTION), // narrow: production only
environment = if (BuildConfig.FLAVOR == "beta") AppEnvironment.BETA else null,
heartbeatInterval = 120.seconds,
))
| Option | Default | Notes |
|---|---|---|
| apiKey | required | Your app's write key from the Setup tab (glance_live_…). |
| flushInterval | 10 s | How long a partial batch waits before it's sent. |
| maxBatchSize | 20 | Flush immediately at this many queued events. |
| heartbeatInterval | 60 s | How long the app can be in front with nothing sent before the "still here" presence ping goes out, so a real event counts as presence and a busy app never pings. It powers "active right now" (see the glossary). Never billable. A floor you can raise, not lower: every accepted batch answers with the sparsest cadence your plan needs (four minutes on Free, two on Indie, one on Studio and up), and the SDK uses whichever is larger. |
| sessionTimeout | 300 s | Away longer than this and coming back is a new session (session.start); the dashboard splits sessions on the same 5-minute gap. |
| collectsCountry | true | The device's region setting as a 2-letter code: a locale, never GPS. false = the SDK sends none, same privacy answers. What is stored is decided per app in the dashboard (Setup → Country map): the locale the SDK sends, the country of the connecting address, or nothing. Set it to the address and this option stops mattering, but it becomes Coarse Location on your label. |
| enabledEnvironments | [.appStore, .testFlight]{PRODUCTION, BETA} | Which environments actually send. Simulator and DebugEmulator and debuggable builds never do by default (unless debug). |
| isEnabled | true | Master switch, e.g. behind your own user toggle. Wins over everything, debug mode included. |
| debug | false | Debug mode: this build sends whatever its environment (tag stays real → shows under All, never Live) and the SDK narrates each event and send. Also on the one-line configure. |
| appID / appId | your bundle id / package name | Informational in hosted mode; the key identifies the app. |
| environment (Kotlin) | null (auto) | Android cannot tell a Play testing track from production, so say AppEnvironment.BETA in that build (a flavor is the natural place). Emulator/debuggable are always detected as such. |
| trackAppLifecycle (Kotlin) | true | Automatic sessions via ProcessLifecycleOwner. Off → call setActive yourself. |
| endpoint | hosted ingest | Point the SDK at a different ingest URL. Defaults to our hosted ingest; you only need this if you are running your own. |
Environments on the wire are the platform-neutral names production / beta / emulator /
debug (Apple's appstore / testflight / simulator / debug are accepted too);
the ingest stores them beside each other, so a Play build is "Live" in the dashboard exactly like an App Store build.
Offline & delivery guarantees
Events are written to disk as they are tracked, so a crash loses nothing, then batched and sent oldest-first in slices of 100
and retried automatically, capped at 500 events oldest-out. Every event carries a client-generated id, so a batch that is re-sent
after a lost response is stored once: retries never double-count. Offline, 429 and 5xx keep
the batch for the next try; a 4xx that no retry could fix (a wrong key, a malformed event) drops that slice rather than
wedging the queue. When the app leaves the foreground the SDK flushes under a task assertion, so the send completes
before iOS suspends the processfrom the lifecycle callback, before Android stops the process.
The install id lives in the Keychain, so a delete-and-reinstall counts onceSharedPreferences
(inside Auto Backup, so a reinstall on the same account usually keeps it); install is sent exactly once, first.
Privacy answers for the App Store and Google Play
App Store · App Privacy
Default setup (no identify): declare under "Data Not Linked to You": Identifiers → User ID (the random install id) and Usage Data → Product Interaction, purpose Analytics, tracking No. If you track purchase events, add Purchases → Purchase History. No ATT prompt is required.
With identify: the same types plus Contact Info → Email Address / Name (whichever you pass), all
under "Data Linked to You", tracking still No. Custom properties: declare the real data type if one fits
(Health, Fitness, Purchases…), otherwise Other Data. The dashboard's Setup tab draws a preview of the label from the options you enable
and lists the exact ticks; the SDK ships its own PrivacyInfo.xcprivacy for the default set.
Google Play · Data safety
Default setup: Device or other IDs (the random install id) and App interactions, collected, not shared, encrypted in transit, deletable on request (the dashboard's user page has a delete), purpose Analytics. With identify: add Personal info → Name / Email address. A plan/tier property: Purchase history. Never the advertising id, IP, or precise location. The mapping is the Play version of the same answers the Setup tab generates. Details: privacy policy.
Alerts & webhooks
Dashboard → app → Alerts: choose the signals (new install, purchase, anything you track) and where they go:
ntfy, Discord, or a signed JSON webhook to your own server. Native push reaches the
AppGlance app for iPhone on every plan. Presence pings and user.identify never trigger alerts, and installs from people who
already had the app never notify push, Discord or ntfy unless you switch that on in the
Alerts tab; the JSON webhook always receives them, labelled.
The json format (the default) posts the matched events in full. Each element of events is the
stored record: the per-install user_id, session_id, signal, app version, OS, environment,
country, timestamp and whatever metadata your app attached; an install event also carries
origin (new, pre_existing or unknown), the
already-had-it verdict the charts use. You choose the destination, so you are responsible for
where that data ends up; the discord and ntfy formats send a one-line summary instead.
{
"app": "com.example.daybook", "sent_at": "2026-08-15T10:00:02.000Z",
"user_id": "9A0C…-install-uuid", // present when the batch is one install's
"dashboard": "https://appglance.app/app/#app/…",
"events": [ { /* the full stored event, metadata included */ } ]
}
Webhooks are signed: compute HMAC-SHA256 of the raw request body with the webhook's secret and compare it to the
X-AppGlance-Signature header (sha256=<hex>):
# Node.js
const expected = "sha256=" + crypto.createHmac("sha256", secret)
.update(rawBody).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected),
Buffer.from(req.headers["x-appglance-signature"]));
Any platform: the HTTP API
The SDKs are thin clients over one endpoint, so anything that can make an HTTPS request can send events: Flutter, a server, a
script. POST a JSON array to https://api.appglance.app/v1/events with
Authorization: Bearer <write key>. Up to 500 events / 256 KB per request; reply
202 {"accepted": n, "rejected": m}. Retry on 5xx/429; a 4xx means fix the batch.
[{
"event_id": "6f1c…-uuid", // client-minted; lets us drop a retried duplicate
"session_id": "5b1c…-uuid", // minted at each session start (optional)
"user_id": "9A0C…-install-uuid", // your stable, random per-install id
"signal": "paywall.viewed", // or install / session.start / heartbeat / user.identify
"app_version": "2.4.1", "os_name": "Android", "os_version": "15",
"environment": "production", // production | beta | emulator | debug (or Apple's names)
"country": "US", // optional, ISO-2 from the locale, never GPS. Ignored if the app resolves country from the address instead
"client_ts": "2026-08-15T10:00:00.000Z",
"metadata": {"source": "settings"} // ≤ 20 short string values
}]
Semantics to keep: install once per fresh install; session.start when the app comes to the front after
> 5 quiet minutes; a heartbeat after a minute in front with nothing else sent (never billable; the cadence follows your plan); user.identify with the full
property set when it changes. Only production and beta builds should send by default. Porting the SDK to another platform? The exact
semantics are written down in SDK-PORTING.md; ask us for a copy. Both
shipped SDKs are public and are the reference implementation:
Swift and
Kotlin.
Glossary
- Install id
- A random UUID minted on the device the first time the app runs. It is kept in the Keychain on Apple platforms and in SharedPreferences on Android, so a delete-and-reinstall usually counts once. Not the IDFA or the advertising id, not tied to an Apple ID or Google account. It is the "user" everywhere in the dashboard.
- Session
- One visit: the app comes to the front →
session.start; it stays alive while the app is open; it ends after five quiet minutes. Brief interruptions don't split it. Every event carries its session id, and the feed shows one row per session. - Heartbeat (presence ping)
- The quiet "still here" the SDK sends after a minute in front with nothing else to say (a real event counts as presence, so a busy app never pings; on the Free plan the pause is four minutes, Indie two). It's what "active right now" and session lengths are made of. Never shown as an event, never billable. It is plumbing.
- Event / signal
- Anything named:
install,session.start, or what you track. Events are what you see in the Events tab and what counts toward your plan. - Environment
- Where the build ran: production (App Store / Play), beta (TestFlight / a Play testing track), simulator or emulator, debug. Only the first two send by default; the dashboard's Live / Beta / All filter keeps them apart.
- Labels (user properties)
- What
identifyattaches to an install: name, email, your own user id, custom properties. Optional, linked to the person, shown on the user's page. - Write key
glance_live_…. One per app, write-only, and safe in a shipped binary. Rotate it from the Setup tab.