Android SDK
The Zeotap Android SDK (io.github.zeotap:android-sdk) captures events from your Android app and sends them to the ingest API. It is the Android counterpart of the Browser SDK: the same event model, the same consent model, the same on-device identity hashing and the same wire format — proven identical by a shared conformance suite generated from the browser SDK — plus the things a mobile app needs that a web page does not: a durable offline queue, flush on background, device/app context and app-lifecycle events.
Kotlin, minSdk 23, zero UI, three small runtime dependencies (Kotlin stdlib, coroutines, kotlinx-serialization-json).
Install
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
// app/build.gradle.kts
dependencies {
implementation("io.github.zeotap:android-sdk:0.1.0")
}Optional: add com.google.android.gms:play-services-ads-identifier to your app if you enable collectAdvertisingId — the SDK never bundles it and degrades gracefully when it is absent.
Initialise
Call Zeotap.load once, as early as possible (typically in Application.onCreate). Later calls return the existing instance.
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
val config = Configuration.Builder("https://events.zeotap.com")
.consent(ConsentOptions.Builder().require(true).build())
.trackApplicationLifecycleEvents(true)
.build()
Zeotap.load(this, "YOUR_WRITE_KEY", config)
}
}Replace YOUR_WRITE_KEY with the key from Streams > Event Sources in your workspace. apiHost is required (there is no “same origin” on a device); point it at the ingest host or at a first-party proxy on your own domain.
Configuration
Option names and units match the browser SDK’s LoadOptions, so a React Native app passes one options object to both platforms.
| Option | Type | Default | Description |
|---|---|---|---|
apiHost | string | — | Ingest base URL (required) |
namespace | string | "zeotap" | Storage-key prefix ({namespace}_anonymous_id, …). Shared with the browser SDK so a WebView and the native layer can agree on identity |
flushAt | int | 10 | Send after this many queued events |
flushIntervalMs | long | 5000 | Send every N milliseconds |
debug | boolean | false | Verbose logcat output (tag ZeotapSDK) |
nativeAppId | string | — | Snowflake Native App install id |
consent | ConsentOptions | attach-only | Opt-in gating, defaults, TCF — see below |
integration(name, version) | — | — | Identity of a wrapping integration → context.library.integration (the React Native bridge sets this) |
trackApplicationLifecycleEvents | boolean | false | Emit Application Installed / Updated / Opened / Backgrounded |
collectAdvertisingId | boolean | false | Collect the Google Advertising ID (honours Limit Ad Tracking; requires the Play Services artifact in the host app, or supply the id yourself with setAdvertisingId — see below) |
maxQueueSize | int | 500 | Durable queue capacity; the oldest event is dropped beyond it |
flushOnBackground | boolean | true | Send queued events when the app moves to the background |
logger / storage | — | logcat / SharedPreferences | Inject your own log sink or (e.g. encrypted) key-value store |
Events
Every method is safe to call from any thread; work is posted to the SDK’s own thread in call order. Optional Callbacks fire on that thread with null on success or a Throwable describing why the call was dropped.
Zeotap.track("Order Completed", mapOf("order_id" to "o-1", "total" to 99.5, "currency" to "USD"))
Zeotap.screen("Product", category = "Shop", properties = mapOf("sku" to "A-1"))
Zeotap.identify("user-42", mapOf("plan" to "pro"))
Zeotap.group("acme", mapOf("plan" to "enterprise"))
Zeotap.alias("user-42") // previousId defaults to the anonymous id
Zeotap.flush()
Zeotap.reset() // logout: new anonymous id, identities/consent/queue clearedThere is no page() on Android — screen() is the mobile primitive. Property maps accept strings, numbers, booleans, null, nested maps and lists; whole doubles serialise as integers (1.0 → 1), as in JavaScript.
User identities
Zeotap.setUserIdentities(mapOf(
"email" to "Shopper@Example.com", // → sha256/sha1 × lowercase/uppercase
"cellno" to "91 9999999999", // → cellno {sha256, sha1} + cellno_e164 (cc+number)
"fpuid" to "crm-123", // first-party ids pass through unhashed
))
Zeotap.setUserIdentities(mapOf("email_sha256_lowercase" to "…"), hashed = true) // pre-hashed input
Zeotap.unsetUserIdentities(listOf("email"))Raw email / phone / login values are normalised and hashed on the device and never leave it. The hashed map persists and rides on every later event as context.identities, exactly like the browser SDK. Setting identities is gated by the identify consent signal; unsetting never is.
Consent
ConsentOptions mirrors the browser SDK — see Consent management for the model. With the defaults, events flow immediately and consent is merely attached to payloads. Gating activates with require(true) (opt-in) or default(ConsentDefault.DENIED) (opt-out-by-default).
ConsentOptions.Builder()
.require(true) // buffer up to maxBufferedEvents until consent resolves
.cmpTimeoutMs(10_000) // then fall back to `default`
.cmp(CmpMode.AUTO) // read a CMP's IAB TCF-for-Apps preferences (default when require=true)
.purposesForTracking(listOf(1, 3, 4))
.build()
Zeotap.setConsent(ConsentInput.Parsed(analytics = true, advertising = false))
Zeotap.setConsent(ConsentInput.Tcf("CQ…"))
Zeotap.setConsent(ConsentInput.UsPrivacy("1YNN"))
val state = Zeotap.getConsentState() // resolved, signals, source, gdprAppliesAdvertising id
With collectAdvertisingId = true the SDK resolves the Google Advertising ID itself and honours Limit Ad Tracking — nothing else to do, provided play-services-ads-identifier is in your app.
If your app already has the id (its own lookup, or a mediation SDK), or deliberately ships without that dependency, supply it instead:
Zeotap.setAdvertisingId(gaid, limitAdTracking = false)
Zeotap.setAdvertisingId(null, limitAdTracking = true) // withdraw itA value you supply always wins, and the SDK’s own lookup is then not attempted at all — it is a blocking inter-process call. limitAdTracking = true withholds the id and reports adTrackingEnabled: false, which is the point of reporting it.
collectAdvertisingId still gates everything: it is your single switch for whether an advertising id may be collected, so one call does not override it. When it is false the SDK logs a warning rather than silently ignoring you.
TCF v2 on Android means the IAB TCF for Apps keys your CMP SDK writes to the app’s default SharedPreferences (IABTCF_CmpSdkID, IABTCF_gdprApplies, IABTCF_TCString, IABTCF_PurposeConsents, IABTCF_VendorConsents). With cmp = AUTO the SDK detects a CMP within cmpTimeoutMs, translates purposes and the Zeotap vendor bit (GVL 1469, legacy 301) with the same rules as the browser SDK, attaches the TC string to every event, and re-resolves when the CMP updates the preferences. Resolved decisions persist across launches (persist, expiryDays).
Context
Every event carries, in addition to the browser fields:
| Block | Fields |
|---|---|
context.app | name, version, build, namespace |
context.device | manufacturer, model, type: "android", advertisingId / adTrackingEnabled (only with collectAdvertisingId) |
context.os | name: "Android", version |
context.network | wifi, cellular, carrier |
context.screen | width, height, density |
context.locale, context.timezone, context.userAgent | BCP-47 tag, IANA zone id, the system HTTP agent |
Privacy defaults: no ANDROID_ID, no Wi-Fi IP, no device name, no READ_PHONE_STATE permission.
Lifecycle events
With trackApplicationLifecycleEvents(true) the SDK tracks Application Installed {version, build}, Application Updated {version, build, previous_version, previous_build}, Application Opened {from_background, version, build} and Application Backgrounded. They go through the consent gate like any other event.
Delivery & offline
Events are written to a SQLite queue before the call returns, so nothing is lost if the process dies. A batch is reserved while in flight and acknowledged on a 2xx; a batch still reserved at the next launch is re-sent (at-least-once — downstream dedupes on messageId). Retry policy is the browser SDK’s: 4 attempts with 500/1000/2000 ms back-off on 5xx, 429 and network errors; any other 4xx drops the batch. After exhausted retries the batch stays queued and the SDK backs off (30 s → 5 min) until the network returns or the app comes to the foreground.
Ingest answers 200 even on partial failure. Register an SdkListener to see BatchResult.success / errors, delivery failures and dropped events:
Zeotap.getInstance().addListener(object : SdkListener {
override fun onBatchResult(result: BatchResult) { if (!result.success) Log.w("cdp", result.errors.toString()) }
override fun onEventDropped(type: String, reason: DropReason, message: String) { … }
})ProGuard / R8
Consumer rules ship inside the AAR; no configuration is needed.
Troubleshooting
- 401
write key not found— the key is wrong or revoked; the SDK drops the batch and reportsonDeliveryFailure(401, …). - Events accepted but nothing downstream — a write key in
pending_schemastatus returns 200 and publishes nothing until its schema is reviewed in Streams > Event Sources. - Let’s Encrypt proxies on Android 6/7 — API 23–24 lack the ISRG Root X1 certificate; a first-party proxy on such a certificate fails on those devices while the Zeotap-hosted ingest works.