Android Quickstart
Kotlin SDK with FCM — configure, identify, and track on Android.
#Prerequisites
- Android Studio with a device or emulator running API 23 (Android 6) or above
- A Firebase project for FCM push delivery (free tier is fine)
- Your Pushlane Tenant ID, Ingest URL, and write-key — copy them from Settings → Install SDK in the dashboard
#1. Add the Pushlane client (no Gradle dependency)
com.pushlane.sdk:pushlane is not published on Maven Central. Don't add implementation("com.pushlane.sdk:pushlane:0.1.0") to your Gradle file — it will not resolve. Use the zero-dependency drop-in below (recommended). If you specifically need the full native SDK, build it from source into mavenLocal — see Build from source at the end of this page.Pushlane.kt in your app and paste the client below. It uses only HttpURLConnection (the Android stdlib) plus FirebaseMessaging (already present if your app does push). Your AI agent can scaffold this exact file via the Pushlane MCP (get_install_instructions).Paste this into Pushlane.kt, then change the package com.yourapp.pushlane line at the top to your app's package (and import Pushlane from that package everywhere below):
// Pushlane — drop-in client for Android. No Pushlane artifact to install.
// Paste this into your app (e.g. Pushlane.kt). HTTP uses HttpURLConnection (stdlib);
// push registration uses FirebaseMessaging (already present if your app does push).
//
// NOTIFICATION OPENS: FCM gives no tap callback — the payload arrives in your Activity's
// launch Intent. Call Pushlane.handleNotificationOpen(intent) in BOTH onCreate and
// onNewIntent (safe on every launch; an Intent without a Pushlane payload is ignored).
// Honest limit: Pushlane's sender is APNs-only today, so Android does not receive
// Pushlane pushes yet — this is wired forward.
//
// ANONYMOUS USERS: identify() is OPTIONAL. If your app has no sign-up (or the user
// has not signed in yet), Pushlane mints ONE stable anonymous id per install and
// persists it in SharedPreferences, so events AND the push token still reach the
// server. Pass your Context to configure() to enable it.
package com.yourapp.pushlane
import android.content.Context
import android.os.Handler
import android.os.Looper
import com.google.firebase.messaging.FirebaseMessaging
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
import kotlin.concurrent.thread
import kotlin.math.min
import kotlin.math.pow
import kotlin.random.Random
object Pushlane {
private const val PREFS_FILE = "com.loop.sdk.prefs"
private const val ANON_KEY = "com.loop.sdk.anonymousId"
private var tenantId: String? = null
private var key: String? = null
private var apiBase: String = "https://loop-ingest.loop-push.workers.dev"
private var pushEnv: String = "production"
private var explicitId: String? = null
private var appContext: Context? = null
private var lastToken: String? = null
private var lastPlatform: String? = null
private var warnedNoStore = false
fun configure(tenantId: String, publishableKey: String, apiBase: String? = null, pushEnvironment: String = "production", context: Context? = null) {
this.tenantId = tenantId
this.key = publishableKey
if (!apiBase.isNullOrEmpty()) this.apiBase = apiBase
this.pushEnv = pushEnvironment
this.appContext = context?.applicationContext
}
/** OPTIONAL — anonymous users work without it. Call it when a real user id exists. */
fun identify(userId: String) {
if (userId.isEmpty() || explicitId == userId) return
explicitId = userId
rebindDevice() // the device follows the user
}
/** Logout: forget the account AND mint a fresh anonymous identity for this device. */
fun reset() {
explicitId = null
anonId(mintFresh = true)
rebindDevice()
}
private fun warnNoStore() {
if (warnedNoStore) return
warnedNoStore = true
// NEVER silent: without a Context an anonymous id would be re-minted on every cold
// start, which would fragment flows and inflate your billable user count. So we
// stay inert for anonymous users and say exactly how to fix it.
android.util.Log.w("Pushlane", "no Context passed to configure(): anonymous users are NOT tracked. Pass context = applicationContext. Users you identify() explicitly are unaffected.")
}
/** The persisted anonymous id, minted on first run. Null when no Context was given. */
private fun anonId(mintFresh: Boolean = false): String? {
val ctx = appContext ?: run { warnNoStore(); return null }
return try {
val prefs = ctx.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)
val existing = if (mintFresh) null else prefs.getString(ANON_KEY, null)
if (!existing.isNullOrEmpty()) existing
else {
val minted = "anon_" + UUID.randomUUID().toString()
prefs.edit().putString(ANON_KEY, minted).apply()
minted
}
} catch (e: Exception) {
warnNoStore()
null
}
}
/** The id every call uses: an explicit identify() wins, else the anonymous id. */
private fun currentUserId(): String? = explicitId ?: anonId()
// Re-post the known device token under the CURRENT id (login / logout). The server
// re-attributes a device token to the identity that registered it last.
private fun rebindDevice() {
val token = lastToken ?: return
val platform = lastPlatform ?: return
val tid = tenantId ?: return
val uid = currentUserId() ?: return
val body = JSONObject().put("tenantId", tid).put("externalId", uid).put("deviceToken", token).put("pushEnvironment", pushEnv).put("platform", platform)
post("/v1/register", body)
}
/** Persistent user attributes — feeds {{ name | fallback }} personalisation at send time. */
fun setAttributes(attributes: Map<String, Any?>) {
val tid = tenantId ?: return
val uid = currentUserId() ?: return
val body = JSONObject()
.put("tenantId", tid)
.put("externalId", uid)
.put("attributes", JSONObject(attributes))
post("/v1/attributes", body)
}
private fun hex(n: Int): String {
val sb = StringBuilder()
repeat(n) { sb.append(Random.nextInt(16).toString(16)) }
return sb.toString()
}
private fun post(path: String, body: JSONObject, extra: Map<String, String>? = null) {
val tid = tenantId ?: return
thread {
var base = apiBase
while (base.length > 1 && base.endsWith("/")) base = base.substring(0, base.length - 1)
for (attempt in 0..4) {
try {
val conn = URL(base + path).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
key?.let { conn.setRequestProperty("Authorization", "Bearer " + it) }
extra?.forEach { (k, v) -> conn.setRequestProperty(k, v) }
conn.outputStream.use { it.write(body.toString().toByteArray()) }
val code = conn.responseCode
conn.disconnect()
if (code in 200..299) return@thread
if (code < 500 && code != 429) {
// non-transient: drop, but SAY SO (a silent 4xx once hid a register
// rejection for weeks). Never throws into the host app.
android.util.Log.w("Pushlane", path + " rejected with HTTP " + code + " (dropped)")
return@thread
}
} catch (e: Exception) { /* retry */ }
if (attempt < 4) Thread.sleep(min(pow(2.0, attempt.toDouble()).toLong() * 500L, 30000L))
}
}
}
// Best-effort device locale for the event context (sent raw; the server normalises).
private fun deviceLocale(): String? = try {
java.util.Locale.getDefault().toString()
} catch (e: Exception) {
null
}
fun track(name: String, properties: Map<String, Any?> = emptyMap()) {
val tid = tenantId ?: return
val uid = currentUserId() ?: return
val tp = "00-" + hex(32) + "-" + hex(16) + "-01"
val ctx = JSONObject().put("sdk", "pushlane-dropin-android").put("sdkVersion", "1.3.0")
deviceLocale()?.let { if (it.isNotEmpty()) ctx.put("locale", it) } // server normalises + drives language
val body = JSONObject()
.put("eventId", UUID.randomUUID().toString())
.put("tenantId", tid).put("externalId", uid).put("name", name)
.put("properties", JSONObject(properties))
.put("occurredAt", System.currentTimeMillis())
.put("context", ctx).put("traceparent", tp)
post("/v1/events", body, mapOf("traceparent" to tp))
}
fun registerForPush() {
val tid = tenantId ?: return
// No id => no Context passed to configure() (see warnNoStore) => nothing to bind.
val uid = currentUserId() ?: return
FirebaseMessaging.getInstance().token
.addOnSuccessListener { token ->
// platform "fcm": registered honestly — Android/FCM delivery is not live
// yet (Pushlane's sender is APNs-only for now).
lastToken = token
lastPlatform = "fcm"
val body = JSONObject().put("tenantId", tid).put("externalId", uid).put("deviceToken", token).put("pushEnvironment", pushEnv).put("platform", "fcm")
post("/v1/register", body)
}
.addOnFailureListener {
// SAY SO. A silent failure here is indistinguishable from "the app never
// called registerForPush() on this path" — and that ambiguity is exactly what
// makes a low reachable-user count impossible to diagnose.
track("push_registration_failed", mapOf("reason" to "token_unavailable"))
}
}
// -- Notification opens ------------------------------------------------------
// Pushlane stamps message_id / flow_id / node_id into every push it sends. Echoing
// message_id back on an "opened" event is the ONLY thing that ties an open to the
// exact notification that caused it (open rate = attributed opens / sends). An open
// with no message_id, or under any other event name, is invisible to the metric.
//
// FCM gives no tap callback: when a user taps a notification, Android launches your
// Activity and the push data lands in the launch Intent's extras. So YOU must call
// this, in BOTH places (a cold start goes through onCreate, a warm tap through
// onNewIntent):
//
// override fun onCreate(savedInstanceState: Bundle?) {
// super.onCreate(savedInstanceState)
// Pushlane.handleNotificationOpen(intent)
// }
// override fun onNewIntent(intent: Intent) {
// super.onNewIntent(intent)
// Pushlane.handleNotificationOpen(intent)
// }
//
// HONEST LIMIT: Pushlane's sender is APNs-only today, so an Android device receives
// no Pushlane push yet. This is wired so it works the day FCM delivery ships.
private val interactionKeys = listOf("message_id", "flow_id", "node_id")
private val seenOpens = java.util.Collections.synchronizedSet(mutableSetOf<String>())
/** Call from onCreate AND onNewIntent. Safe on every launch: an Intent with no
* Pushlane payload is ignored, so an ordinary app launch logs nothing. */
fun handleNotificationOpen(intent: android.content.Intent?) {
val extras = intent?.extras ?: return
val props = mutableMapOf<String, Any?>()
for (k in interactionKeys) {
val v = extras.getString(k)
if (!v.isNullOrEmpty()) props[k] = v
}
// No message_id => not one of ours (or unattributable) => stay silent rather than
// logging an "open" for every cold start of the app.
val mid = props["message_id"] as? String ?: return
if (!seenOpens.add(mid)) return
track("opened", props)
}
fun setMarketingConsent(optedIn: Boolean) {
val uid = currentUserId() ?: return
val body = JSONObject().put("externalId", uid).put("category", "marketing").put("action", if (optedIn) "opt_in" else "opt_out")
post("/v1/consent", body)
}
fun start() { track("app_open") }
}
The drop-in reads the device token from FirebaseMessaging.getInstance().token (FCM), so you still need Firebase. Follow the Firebase Android setup guide to create a project and download google-services.json, add the Google Services Gradle plugin, then place google-services.json in your app module folder (next to build.gradle.kts):
// app/build.gradle.kts
plugins {
id("com.google.gms.google-services") version "4.4.1"
}#2. Configure the client
Call Pushlane.configure and Pushlane.start in your Application.onCreate, before any Activity runs. That is the whole integration — nothing here requires a logged-in user. The drop-in's configure takes tenantId, publishableKey, an optional apiBase / pushEnvironment, and context — pass applicationContext so anonymous users get a persisted id (see below).
import com.yourapp.pushlane.Pushlane // ← the package you pasted Pushlane.kt into
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
Pushlane.configure(
tenantId = "ten_YOUR_TENANT_ID",
publishableKey = "lpk_live_YOUR_KEY", // from Settings → Install SDK
// apiBase defaults to Pushlane's ingest URL — pass it only if you self-host.
context = applicationContext // needed for anonymous users
)
// Emit app_open once per process.
Pushlane.start()
// Fetch the FCM device token and register it. Works right now — no
// account needed. Takes no arguments.
Pushlane.registerForPush()
}
}Application class in AndroidManifest.xml by adding android:name=".MyApp" to the <application> tag if you have not already done so.#3. Register for push
The drop-in's registerForPush() takes no arguments: it fetches the FCM token and registers the device with Pushlane. It works from the first launch — no account needed.
POST_NOTIFICATIONS. Declare the permission and request it yourself (see the Android notification-permission guide):<!-- AndroidManifest.xml — Android 13+ needs this for Pushlane notifications to be shown -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />Pushlane.configure, and only before it. track, registerForPush and setAttributes all work from the first launch.#Anonymous users (apps without sign-up)
Pushlane.identify is optional. If you never call it, the drop-in mints one stable anonymous id per install (anon_…) and persists it to SharedPreferences, so a user who never creates an account still gets a device token, still emits events, and still enters flows.
context = applicationContext to configure. Without a Context there is nowhere to persist the id, so the drop-in refuses to track anonymous users and logs [pushlane] no persistent storage found — it will not invent a new id on every launch, because that would fragment every flow and inflate your billable user count. Users you identify() explicitly are unaffected.The id lives in the com.loop.sdk.prefs preferences file under com.loop.sdk.anonymousId.
#Identify signed-in users
If your app does have accounts, call Pushlane.identify when the session resolves. It takes over from the anonymous id and re-registers the device token under the real one. Calling it twice with the same id does nothing.
// ONLY if your app has accounts. Call it when auth resolves — the device
// token you already registered moves onto this id automatically.
Pushlane.identify(currentUser.id)
// If you also use RevenueCat, pass the SAME id to Purchases.logIn(). That id
// is what links a purchase to this user.
// On logout: forget the account and start a fresh anonymous identity.
Pushlane.reset()#Set user attributes
Call Pushlane.setAttributes to persist user traits that power {{ first_name | friend }} personalisation tokens in your notification copy. It works for anonymous users too.
// After Pushlane.identify — persist traits used in notification personalisation.
// Powers {{ first_name | friend }} tokens in your notification copy.
Pushlane.setAttributes(mapOf(
"first_name" to user.firstName, // String
"plan" to "growth", // String
"trial_days_left" to 7L // Long / number
))#4. Track events
Track custom events with Pushlane.track. In the drop-in, properties are a plain Map<String, Any?> — the backend coerces values against your event catalogue, so never coerce client-side.
// Properties are a plain Map<String, Any?> — no wrapper types in the drop-in.
Pushlane.track("purchase_completed", mapOf(
"plan" to "pro",
"amount" to 9.99,
"first" to true
))
// No-properties variant:
Pushlane.track("app_rated")app_open (when you call Pushlane.start()) and push_registration_failed with a reason property (token_unavailable) when FCM refuses to hand over a token — a failed registration says so instead of failing silently. It emits opened only when you call Pushlane.handleNotificationOpen(intent) — see Track notification opens below. Unlike the native SDK it never emits received or session_started — instrument everything else you care about explicitly with Pushlane.track. Do not track app_open yourself elsewhere (that would double-count), and rename any reserved name such as message_sent (see Events & catalogue).#Track notification opens
Open rate is attributed, not counted: Pushlane stamps message_id, flow_id and node_id into every push it sends, and only an opened event that echoes message_id back can be matched to the notification that caused it. An open recorded under any other event name, or without message_id, is invisible to the metric — it is not counted as a miss, it simply cannot be tied to a send.
Intent's extras. So you must call Pushlane.handleNotificationOpen(intent) in both onCreate (the tap cold-started the app) and onNewIntent (the app was already running). Wire only one and half your opens are never recorded.import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.yourapp.pushlane.Pushlane // ← the package you pasted Pushlane.kt into
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Cold start: the tap launched the app.
Pushlane.handleNotificationOpen(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Warm tap: the app was already running.
Pushlane.handleNotificationOpen(intent)
}
}Do this in every Activity a Pushlane push can launch. The call is safe on every launch: an Intent carrying no Pushlane payload is ignored, so an ordinary app start logs nothing — and the same notification is only ever counted once, so a cold start that also hits onNewIntent does not double-count.
The drop-in does not emit received (the push landing on the device, before any tap). That needs a FirebaseMessagingService declared in your manifest — more than a single pasteable file. The native SDK below ships one.
#5. Consent (opt-out model)
Pushlane is opt-out by default — a registered device token alone is sufficient for delivery. Only call setMarketingConsent when the user makes an explicit choice in your settings UI.
// Pushlane is opt-out by default — call this only when the user makes an
// explicit choice in your settings UI.
// User turned marketing notifications OFF:
Pushlane.setMarketingConsent(false)
// User turned them back ON:
Pushlane.setMarketingConsent(true)#Push delivery — what works today
Pushlane.track works over HTTP on every platform — your events and audiences flow into Pushlane immediately.
Pushlane's sender is APNs, so end-to-end push delivery is proven on iOS. On Android the client registers an FCM token (platform: "fcm") — the device appears in your Audience and attribution works, but push delivery to Android is not live yet: sends to FCM-only users are suppressed with the logged reason fcm_delivery_unsupported (visible in Logs), never silently dropped. FCM also has no provisional (silent) permission mode equivalent to iOS.
#6. Verify the integration
Build and run your app on a device or emulator. In the Pushlane dashboard, go to Settings → Install SDK and watch the live verification panel — it polls your backend every few seconds and lights up as events and device registrations arrive.
| Check | What it means |
|---|---|
| SDK detected | At least one event has reached the ingest worker |
| Events instrumented | Distinct event names from your app appear in the catalogue |
| Device connected | An FCM token was registered — the device is in your Audience (push delivery to Android is not live yet) |
#Next step
The client is wired. Open the Pushlane builder, create a flow with a push step, and send a test notification to your registered device.
#Build from source (advanced)
com.pushlane.sdk:pushlane is not published on Maven Central, so the native SDK is only available if you build it locally. Clone the Pushlane repository and run ./gradlew :pushlane:publishToMavenLocal, then add mavenLocal() to your repositories:// settings.gradle.kts — add mavenLocal() while the artifact is not on Maven Central
dependencyResolutionManagement {
repositories {
mavenLocal()
google()
mavenCentral()
}
}// app/build.gradle.kts — resolves ONLY from your local mavenLocal build.
dependencies {
implementation("com.pushlane.sdk:pushlane:0.1.0")
}The native SDK offers a richer API than the drop-in: a typed PushlaneValue sealed class, a bundled PushlaneMessagingService, notification-tap tracking, and auto-emitted events. Its surface differs from the drop-in shown above.
Configure it in Application.onCreate (note configure takes a Context here):
import com.pushlane.sdk.Pushlane
import com.pushlane.sdk.PushlaneValue
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// The native SDK configure DOES take a Context (unlike the drop-in).
Pushlane.configure(
context = this,
apiBase = "https://ingest.pushlane.io",
tenantId = "ten_YOUR_TENANT_ID",
publishableKey = "lpk_live_YOUR_KEY"
)
// Emits app_open once per process; registers a lifecycle observer for session_started.
Pushlane.start()
}
}Identify and register for push — registerForPush takes the current Activity so Android can show the POST_NOTIFICATIONS dialog (Android 13+):
// The native registerForPush takes an Activity so it can show the
// POST_NOTIFICATIONS permission dialog (Android 13+).
Pushlane.identify(currentUser.id)
Pushlane.registerForPush(this)Track notification taps from every Activity a Pushlane push can launch — both cold launch (onCreate) and foreground taps (onNewIntent):
// Native SDK: call from every Activity a Pushlane push can launch.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Pushlane.handleNotificationOpen(intent) // cold-launch tap → emits "opened"
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
Pushlane.handleNotificationOpen(intent) // foreground tap → emits "opened"
}If your app already declares its own FirebaseMessagingService, remove Pushlane's default service via a manifest merger rule and forward manually:
<!-- AndroidManifest.xml — remove Pushlane's default service if your app already declares one -->
<service android:name="com.pushlane.sdk.PushlaneMessagingService" tools:node="remove" />class MyMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
Pushlane.onNewToken(token) // forward token to Pushlane
// … your own handling …
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
Pushlane.onMessageReceived(remoteMessage) // records "received" event
// … your own handling …
}
}Track events with the typed PushlaneValue sealed class:
// Native SDK: property values use the PushlaneValue sealed class (explicit types).
Pushlane.track("purchase_completed", mapOf(
"plan" to PushlaneValue.Str("pro"),
"amount" to PushlaneValue.DoubleVal(9.99),
"first" to PushlaneValue.BoolVal(true)
))All available PushlaneValue variants:
PushlaneValue.Str("hello") // String
PushlaneValue.IntVal(42L) // Long integer
PushlaneValue.DoubleVal(3.14) // Double
PushlaneValue.BoolVal(true) // Boolean
PushlaneValue.Arr(listOf(…)) // Array of PushlaneValueAuto-emitted events (native SDK only — no call needed):
| Event | Source | When |
|---|---|---|
app_open | Pushlane.start() | Cold launch — emitted once per process |
session_started | Pushlane.start() (automatic) | Foreground return after ≥30 s in background |
opened | Pushlane.handleNotificationOpen(intent) | User tapped a Pushlane push notification |
received | PushlaneMessagingService (automatic) | Push landed on device — before any tap |
push_registration_failed | Pushlane (automatic) | FCM refused to register the device |