docs
Documentation menu

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)

Heads up
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.
Note
No dependency to install — paste one file. Create 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):

kotlin
// 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):

kotlin
// 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).

kotlin
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()
    }
}
Note
Register the custom 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.

Heads up
The drop-in does not request the runtime notification permission. Fetching an FCM token does not need it, but on Android 13+ a notification will not be shown unless the user has granted POST_NOTIFICATIONS. Declare the permission and request it yourself (see the Android notification-permission guide):
xml
<!-- AndroidManifest.xml — Android 13+ needs this for Pushlane notifications to be shown -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
Note
Everything is a no-op before 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.

Heads up
Pass 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.

kotlin
// 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.

kotlin
// 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
))
Note
Re-call whenever a trait changes (e.g. after a subscription upgrade).

#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.

kotlin
// 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")
Note
What the drop-in emits on its own: 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.

Heads up
On Android the drop-in cannot wire this for you — you call it, in two places. FCM has no notification-tap callback: when the user taps, Android just launches your Activity and the push data arrives in the launch 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.
kotlin
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.

Note
Wired forward — not live today. Pushlane's sender is APNs-only, so an Android device does not receive a Pushlane push yet and there is nothing to open. Add these two calls now and opens start being attributed the day FCM delivery ships (see Push delivery — what works today).

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.

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.

kotlin
// 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

Note

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.

CheckWhat it means
SDK detectedAt least one event has reached the ingest worker
Events instrumentedDistinct event names from your app appear in the catalogue
Device connectedAn FCM token was registered — the device is in your Audience (push delivery to Android is not live yet)
Note
All three checks use live backend data — a check turns green only when the data is actually in the store. No mocks.

#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.

Building flows →

#Build from source (advanced)

Heads up
Optional — most apps should use the drop-in above. 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:
kotlin
// settings.gradle.kts — add mavenLocal() while the artifact is not on Maven Central
dependencyResolutionManagement {
    repositories {
        mavenLocal()
        google()
        mavenCentral()
    }
}
kotlin
// 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):

kotlin
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+):

kotlin
// 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):

kotlin
// 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:

xml
<!-- AndroidManifest.xml — remove Pushlane's default service if your app already declares one -->
<service android:name="com.pushlane.sdk.PushlaneMessagingService" tools:node="remove" />
kotlin
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:

kotlin
// 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:

kotlin
PushlaneValue.Str("hello")          // String
PushlaneValue.IntVal(42L)           // Long integer
PushlaneValue.DoubleVal(3.14)       // Double
PushlaneValue.BoolVal(true)         // Boolean
PushlaneValue.Arr(listOf(…))        // Array of PushlaneValue

Auto-emitted events (native SDK only — no call needed):

EventSourceWhen
app_openPushlane.start()Cold launch — emitted once per process
session_startedPushlane.start() (automatic)Foreground return after ≥30 s in background
openedPushlane.handleNotificationOpen(intent)User tapped a Pushlane push notification
receivedPushlaneMessagingService (automatic)Push landed on device — before any tap
push_registration_failedPushlane (automatic)FCM refused to register the device