pieowl

How to add analytics to Android apps with the HTTP API

Track events from your Android app in Kotlin with the Pieowl HTTP API. No SDK needed, just a small helper and OkHttp.

Pieowl TeamIntermediate · 10 min · 8 Oct 2026
Guide · Android · HTTP API

This guide shows you how to send events from an Android app to Pieowl in Kotlin. You'll track events, identify users and record screen views.

Pieowl has no Kotlin or Android SDK. You don't need one. Tracking is one HTTP request to the Pieowl API, so a small helper does the job. Native apps need a client secret, because they don't send a browser origin that Pieowl can check.

Prerequisites

Add an HTTP client

This guide uses OkHttp, a common HTTP client for Android. Any client works. Add it to your app's build.gradle.kts:

dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

Your app also needs internet access. Make sure your AndroidManifest.xml has this line:

<uses-permission android:name="android.permission.INTERNET" />

Add a tracking helper

Every call is a POST to https://api.pieowl.com/track with a JSON body. Two headers prove the request is yours: pieowl-client-id and pieowl-client-secret. The body always has a type and a payload.

Add this file to your project. It is your own code, so change it as you like.

Analytics.kt
import android.os.Build
import android.util.Log
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap

object Analytics {
    var apiUrl = "https://api.pieowl.com/track"
    var clientId = ""
    var clientSecret = ""

    @Volatile
    var profileId: String? = null
    val globalProperties = ConcurrentHashMap<String, Any>()

    private val http = OkHttpClient()
    private val jsonType = "application/json".toMediaType()

    // Tells Pieowl which device this is. Without these,
    // events look like they come from a server.
    private fun deviceProperties(): Map<String, Any> = mapOf(
        "__os" to "Android",
        "__osVersion" to Build.VERSION.RELEASE,
        "__device" to "mobile", // use "tablet" on tablets
        "__brand" to Build.MANUFACTURER,
        "__model" to Build.MODEL,
    )

    private fun send(type: String, payload: JSONObject) {
        val body = JSONObject()
            .put("type", type)
            .put("payload", payload)
            .toString()
            .toRequestBody(jsonType)

        val request = Request.Builder()
            .url(apiUrl)
            .header("pieowl-client-id", clientId)
            .header("pieowl-client-secret", clientSecret)
            .post(body)
            .build()

        // enqueue() runs the request on a background thread
        http.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {
                Log.w("Analytics", "Request failed", e)
            }

            override fun onResponse(call: Call, response: Response) {
                response.use {
                    if (!it.isSuccessful) Log.w("Analytics", "HTTP ${it.code}")
                }
            }
        })
    }

    fun track(name: String, properties: Map<String, Any> = emptyMap()) {
        val props = JSONObject(deviceProperties() + globalProperties + properties)
        val payload = JSONObject().put("name", name).put("properties", props)
        profileId?.let { payload.put("profileId", it) }
        send("track", payload)
    }

    fun identify(
        profileId: String,
        firstName: String? = null,
        lastName: String? = null,
        email: String? = null,
        properties: Map<String, Any> = emptyMap(),
    ) {
        this.profileId = profileId
        val payload = JSONObject()
            .put("profileId", profileId)
            .put("properties", JSONObject(properties))
        firstName?.let { payload.put("firstName", it) }
        lastName?.let { payload.put("lastName", it) }
        email?.let { payload.put("email", it) }
        send("identify", payload)
    }

    fun increment(property: String, value: Int = 1) {
        val id = profileId ?: return
        send(
            "increment",
            JSONObject().put("profileId", id).put("property", property).put("value", value),
        )
    }

    fun screenView(screen: String, properties: Map<String, Any> = emptyMap()) {
        track("screen_view", properties + ("__path" to screen))
    }

    fun clear() {
        profileId = null
    }
}

The __os, __osVersion, __device, __brand and __model properties fill in the device columns in your dashboard.

Set it up at launch

Set your keys in your Application class, so the helper is ready before the first event.

import android.app.Application

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        Analytics.clientId = "YOUR_CLIENT_ID"
        Analytics.clientSecret = "YOUR_CLIENT_SECRET"
        Analytics.globalProperties["app_version"] =
            packageManager.getPackageInfo(packageName, 0).versionName ?: ""
        Analytics.track("app_opened")
    }
}

Register your Application class in the Android manifest so the system uses it.

<application
    android:name=".MyApplication"
    ...>
</application>

The client secret ships inside your app, and anyone who unpacks the app could read it. Create a separate "Write" client for this app in Settings → API keys. If the secret leaks, delete that client without touching your other apps.

Track events

Call Analytics.track anywhere in your app. Each event has a name and an optional map of properties.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById<Button>(R.id.signupButton).setOnClickListener {
            Analytics.track(
                "button_clicked",
                mapOf(
                    "button_name" to "signup",
                    "button_location" to "hero",
                ),
            )
        }
    }
}

Keep event names the same across your app. snake_case with a verb, like button_clicked, makes your data easier to read later.

If some properties belong on every event, put them in globalProperties. The helper adds them to each event.

Analytics.globalProperties["plan"] = user.plan

Identify users

When a user signs in, call identify. It saves their profile and remembers their ID, so the events that follow are linked to them.

Analytics.identify(
    profileId = user.id,
    firstName = user.firstName,
    lastName = user.lastName,
    email = user.email,
    properties = mapOf("plan" to user.plan),
)

The helper keeps the ID in memory. When the app starts again, call identify again for a signed-in user.

When the user logs out, call clear. New events are then not linked to the previous user.

fun logout() {
    Analytics.clear()
}

To keep a running count on a profile, such as logins or purchases, use increment. The profile must already exist.

Analytics.increment("login_count")

Track screen views

Screen views show you how users move through your app. The helper's screenView sends a screen_view event with the screen name as its path. Then it shows up in your pages and paths reports.

A simple way is a base activity that tracks each screen when it comes to the front.

import androidx.appcompat.app.AppCompatActivity

abstract class BaseActivity : AppCompatActivity() {
    override fun onResume() {
        super.onResume()
        Analytics.screenView(this::class.simpleName ?: "Unknown")
    }
}

For Jetpack Compose, use a LaunchedEffect to track when a screen appears.

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect

@Composable
fun MainScreen() {
    LaunchedEffect(Unit) {
        Analytics.screenView("MainScreen")
    }

    // Your composable content
}

Verify your setup

Run your app in an emulator or on a device and use a few screens and buttons. Open the Pieowl dashboard and check the realtime view.

No events? Filter Logcat by the Analytics tag:

  • HTTP 401 means the Client ID or Client Secret is wrong or missing
  • HTTP 400 means the body is wrong, for example an empty event name
  • Request failed usually means no network, or the INTERNET permission is missing

The Track API reference lists every event type and field.

Next steps

If you build for both iOS and Android, the React Native guide shows how to share one analytics setup. For native iOS, see the Swift guide.

Is there a Kotlin or Android SDK?

No. Pieowl doesn't publish an Android package. Tracking is a single HTTP request, so the small helper in this guide is all you need. You own that code and can extend it.

Why do I need a client secret for native apps?

A browser sends an origin that Pieowl checks against your site settings. A native app does not, so the client secret proves the events are yours. Use a separate client per app so you can remove it if the secret leaks.

Does this work with Jetpack Compose?

Yes. The helper doesn't depend on any UI toolkit. In Compose, use LaunchedEffect to track screen views, as shown above.

Can I track events when the device is offline?

Not with this helper as written. If the request fails, the event is lost. To keep events while offline, save failed payloads (for example with WorkManager) and send them later. Add a __timestamp property with the original time as an ISO 8601 string, so the event keeps its real time.

Does Pieowl help with GDPR?

Pieowl is built for GDPR: no cookies, no stored IPs. Your data is stored in the EU. See the cookieless analytics guide for details.

Your first dashboard in five minutes.

14-day free trial, no card needed. Cloud in the EU. No cookies.