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 — zero-dependency drop-in client for Android. No Maven 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).
package com.yourapp.pushlane
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 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 userId: String? = null
fun configure(tenantId: String, publishableKey: String, apiBase: String? = null, pushEnvironment: String = "production") {
this.tenantId = tenantId
this.key = publishableKey
if (!apiBase.isNullOrEmpty()) this.apiBase = apiBase
this.pushEnv = pushEnvironment
}
fun identify(userId: String) { this.userId = userId }
fun reset() { this.userId = null }
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) return@thread
} catch (e: Exception) { /* retry */ }
if (attempt < 4) Thread.sleep(min(pow(2.0, attempt.toDouble()).toLong() * 500L, 30000L))
}
}
}
fun track(name: String, properties: Map<String, Any?> = emptyMap()) {
val tid = tenantId ?: return
val uid = userId ?: return
val tp = "00-" + hex(32) + "-" + hex(16) + "-01"
val ctx = JSONObject().put("sdk", "pushlane-dropin-android").put("sdkVersion", "1.0.0")
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
val uid = userId ?: return
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
val body = JSONObject().put("tenantId", tid).put("externalId", uid).put("deviceToken", token).put("pushEnvironment", pushEnv)
post("/v1/register", body)
}
}
fun setMarketingConsent(optedIn: Boolean) {
val uid = userId ?: 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. The drop-in's configure takes just tenantId and publishableKey (an optional apiBase / pushEnvironment) — no Context.
import com.yourapp.pushlane.Pushlane // ← the package you pasted Pushlane.kt into
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// The drop-in configure takes no Context — just your tenant + key.
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.
)
// Emit app_open once per process.
Pushlane.start()
}
}Application class in AndroidManifest.xml by adding android:name=".MyApp" to the <application> tag if you have not already done so.#3. Identify the user and register for push
Call Pushlane.identify as soon as your auth session resolves — this must happen before registerForPush or any track call. The drop-in's registerForPush() takes no arguments: it fetches the FCM token and registers the device with Pushlane.
// After the user authenticates — identify before registering for push or tracking.
Pushlane.identify(currentUser.id)
// Fetch the FCM device token and register it with Pushlane. Takes no arguments.
Pushlane.registerForPush()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.track and Pushlane.registerForPush are no-ops until you have called both Pushlane.configure and Pushlane.identify. On logout, call Pushlane.reset():// On logout: detach this device from the user.
Pushlane.reset()#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 only (when you call Pushlane.start()). Unlike the native SDK it does not auto-track opened, received, or session_started — instrument the events 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).#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; end-to-end FCM delivery is not yet asserted. 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 — you can receive a push |
#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 |