Pushlanedocs
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 — 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):

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. The drop-in's configure takes just tenantId and publishableKey (an optional apiBase / pushEnvironment) — no Context.

kotlin
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()
    }
}
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. 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.

kotlin
// 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()
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
Pushlane.track and Pushlane.registerForPush are no-ops until you have called both Pushlane.configure and Pushlane.identify. On logout, call Pushlane.reset():
kotlin
// 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.

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
The drop-in emits 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).

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

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 — you can receive a push
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