Documentation
SDKs

Android

Use the FounderHQ Android SDK.

Capture screens, app lifecycle, and your own events from a Kotlin app.

Before you start

You need a publishable key (fhq_pk_...) for the brand you want to measure. See Getting started.

Nothing is sent anywhere. A publishable key is saved in this browser. A secret key is kept only until you reload.

Install

dependencies {
    implementation("com.getfounderhq:events:1.3.0")
    // Navigation Compose screens
    implementation("com.getfounderhq:events-compose:1.3.0")
}

The SDK needs minSdk 24 and Java 17.

Initialize

Create one client in your Application, and share it.

class MyApplication : Application() {
    lateinit var events: FounderHQEvents

    override fun onCreate() {
        super.onCreate()
        events = FounderHQEvents(this, "fhq_pk_XXXX")
    }
}

Pass a config when you want to change the defaults:

events = FounderHQEvents(
    this,
    "fhq_pk_XXXX",
    FounderHQEventsConfig(captureScreens = true),
)

The client registers activity lifecycle callbacks, captures activity screens and application lifecycle, and records the session start.

Capture events

events.capture("trial.started", mapOf("plan" to "growth"))

capture waits for startup to finish, then queues the event. Call readyForCapture() when you want to wait for startup on its own.

Record a deep link when your app opens from one:

events.captureDeepLink(url)

captureDeepLink drops the query and fragment, and keeps the campaign parameters it recognizes. After you read Play Install Referrer data, pass it on:

events.captureInstallReferrer(mapOf("utm_source" to "google_play"))

Properties on every event

register adds properties to every later event. registerOnce does the same but keeps a value that is already set. unregister removes one.

events.register(mapOf("plan" to "pro"))
events.unregister("plan")

Track screens

Activity screens are captured for you. Call events.screen("Pricing") anywhere else.

With Navigation Compose, add the observer in the composable that owns your NavHost, beside the NavHost and not inside its builder:

import com.founderhq.events.compose.FounderHQNavigationObserver

FounderHQNavigationObserver(navController = navController, events = events)

It records the current route, and skips a repeat of the route the app is already on.

Each screen includes these properties:

PropertyValue
$screen_nameActivity title, Compose route, or the name passed to screen()
$screen_idA new identifier for this screen view
$prefers_color_scheme"dark" or "light" from Android's current appearance

Appearance is read at capture time from the resumed activity's configuration, or the application's when no activity is resumed. Undefined or unavailable appearance is omitted. Other events do not automatically receive this property.

Adding properties to automatic events

Use autoProperties to add properties to $screen, $autocapture, and $rageclick:

val config = FounderHQEventsConfig(
    autoProperties = { context ->
        mapOf(
            "section" to (context.screenName ?: "unknown"),
            "control" to context.elementDescription?.get("view_class"),
        )
    },
)

FounderHQAutoPropertiesContext contains event, nullable screenName, element (the interactive Android View that owns the tap, which may be an ancestor of the touched view), and elementDescription (the same facts as $elements[0]). Both element fields are null for screens. The callback runs for automatic activity screens, Navigation Compose screens, every enabled screen() call, and each captured tap or rage tap. Return a map or null. If it throws, the event still sends without callback properties, and the SDK logs one warning per client.

Lifecycle, session, identity, push, and your own capture() calls do not call this hook, even when you pass a reserved event name. Remote config updates keep your callback. autoProperties runs outside the SDK lock: on the UI thread for taps, automatic activity screens, and Navigation Compose screens. An explicit screen() runs it on the calling thread (normally the UI thread). Keep it fast and non-blocking.

For taps, attach properties to a view or a container:

import com.founderhq.events.setFhqProperties

productCard.setFhqProperties(mapOf("product_id" to "sku_1"))
buyButton.setFhqProperties(mapOf("action" to "buy"))

A tap on the view or anything inside it carries these as top-level properties. The declarative walk starts at the touched view, even when element is an ancestor that owns the tap, and continues through every ancestor. The nearest valid value wins. Keys stay as given; snake_case is the convention. Pass null to clear a view's properties. The android:tag="fhq-no-capture" marker still skips a whole subtree.

Later values win in this order: automatic and registered properties → autoProperties → view properties → explicit screen(name, properties) values. Both new sources use one set of limits:

  • Keys must have 1–64 characters and must not start with $.
  • Values must be strings, finite numbers, or booleans. Strings are trimmed and cut to at most 200 UTF-16 code units without splitting a surrogate pair. Other values are dropped.
  • At most 20 extra keys survive per event. View properties come first, nearest view first, then callback properties. Each map keeps its iteration order. The first valid value for a key wins; invalid values do not use a slot.

These limits do not change explicit screen() or capture() properties. The SDK does not capture Compose taps or dead clicks. events-compose only observes navigation, so it has no Modifier.fhqProperties.

beforeSend

Use beforeSend to change or drop a finished event:

val config = FounderHQEventsConfig(
    beforeSend = { event ->
        if (event.optString("event") == "debug_action") {
            null
        } else {
            event.getJSONObject("properties").remove("internal_note")
            event
        }
    },
)

The callback receives an org.json.JSONObject after all properties have been merged. Return that object or another event object, or null to drop it. A throw also drops the event. $mobile_purchase_prepared and $mobile_purchase_claim bypass the hook and keep the SDK-built event so purchase attribution cannot be silently dropped or reshaped.

New $identify events synthesized after an identity-rotation directive do run through the hook. The hook can see an identify it already transformed once, so make transformations idempotent. Ordinary delivery retries reuse the queued snapshot.

For hook results, the SDK rebuilds and snapshots only uuid, event, distinct_id, timestamp, properties, session_id, window_id, and options.process_person_profile. All other top-level and options keys, including cookieless_mode, are stripped. Validation follows these rules:

  • event is trimmed and must not be empty. Keeping the original event's exact name is allowed, including internal names. A renamed event must pass the public event-name check after trimming: at most 200 characters, and no $ prefix unless it is a reserved name apps may send.
  • uuid must use the RFC 4122 8-4-4-4-12 hexadecimal form, version 1–8, and variant 8, 9, a, or b (case-insensitive).
  • distinct_id is trimmed, must have 1–400 characters, and must pass the SDK's illegal-distinct-ID check. The trimmed name and ID are queued.
  • timestamp must be a real calendar instant in RFC 3339 form: YYYY-MM-DDTHH:MM:SS, optional fractional seconds, then Z or ±HH:MM. Whole seconds are valid; impossible dates and missing zones are rejected.
  • properties must be a JSON object and options.process_person_profile must be a boolean. Invalid required fields drop the event.
  • Optional session_id is kept only when it is a UUIDv7 with variant 8–b. Optional window_id is kept only when it is a string of at most 200 characters. Invalid optional fields are removed; the event is kept.

Without a hook, SDK-built events are queued untouched. Changing automatic identity or session events can affect those features.

beforeSend runs synchronously under the SDK's lock, on the thread that builds the event: the caller for explicit capture(), screen(), identity, and push calls; the UI thread for activity lifecycle and screen events (including Navigation Compose); the SDK executor for captured taps, rage taps, and initialization events. A synthesized identity retry runs on the thread processing flush() (its caller for an explicit flush, or the SDK's background thread for automatic delivery). Session events run on the thread of the capture that created them. The hook must be fast, must not block, and must not call back into the SDK.

Identify a contact

events.identify("user_42", mapOf("email" to "jane@acme.com"))

Change identity and account in one call:

events.identify(
    "user_42",
    mapOf("email" to "jane@acme.com"),
    FounderHQAccountContext("workspace_123"),
)

Update contact properties on their own with setPersonProperties(set, setOnce). With the default IDENTIFIED_ONLY mode, changes made before identify stay on the device and go out with the identify call.

Call reset() on logout. It clears the identity and the account. When a push token is registered, it first removes the device from the person who signs out.

Accounts

events.setAccount("workspace_123", mapOf("plan" to "growth"))
events.setAccountProperties(mapOf("seats" to 12))
events.clearAccount() // resetAccounts() does the same

setAccount also takes a FounderHQAccountContext, which carries key, properties, and contextToken. Set account in the config when the first automatic event must already carry it. An account change rotates the account span only. The person session and queued events are untouched. See Accounts and groups.

You callWhat happens
optIn()Capture is on
optOut()Capture stops, and the queue is cleared
isOptedOut()Returns true while capture is off

Set optOutByDefault = true in the config to start silent until the person agrees. See Consent and privacy.

The SDK never collects advertising identifiers.

Purchases

The client can tie a Play Billing or RevenueCat purchase to the person who made it.

MethodWhat it does
purchaseAttribution()Returns the identifiers to pass to the store or to RevenueCat
preparePurchase(source, completion)Records intent before checkout, and returns the purchase context
applyPurchaseContext(builder, prepared)Puts the context on your Play Billing flow
observePurchase(purchase, prepared)Reports the purchase that followed
claimSubscription(purchase, confirmation)Moves future revenue of an existing subscription

The full flow, including what each store needs, is on Mobile purchase claims.

Push notifications

Register the device so FounderHQ Sequences can send it a push. The SDK has no Firebase dependency, never asks for the notification permission, and never gets a token itself.

Create the client in Application.onCreate, as in Initialize, not in an activity. Firebase can start your app in a new process only to give it a new token, and no activity exists then. The SDK supports one process: do not put the messaging service, or other code that calls the SDK, in a separate android:process.

FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
    events.registerPushToken(token)
}

// In your FirebaseMessagingService
override fun onNewToken(token: String) {
    events.registerPushToken(token)
}
MethodWhat it does
registerPushToken(token, provider, appId, environment, enabled, permission)Stores the token. provider defaults to FounderHQPushProvider.FCM
setPushEnabled(enabled)Your app's own push switch for this device. false stops pushes; the device stays registered
unregisterPushToken()Removes the device from the person and forgets the token
capturePushNotificationOpened(intent, properties)Reports the open of a FounderHQ notification and returns the link, or null. Sends nothing for any other intent
capturePushNotificationOpened(payload, properties)The same, for a FounderHQPushPayload
FounderHQPushPayload.from(data)Reads messageId, link, and notificationKey from RemoteMessage.data, a Bundle, or an Intent
dismissPushNotification(key)Cancels this app's notifications with that notification key (or message ID). Returns how many
handlePushMessage(data)Removes a notification when FounderHQ asks from your server. Returns true for such a message, false for every other. See Replace or remove a notification

The SDK always stores the token. A device is registered only for an identified person: the SDK sends the registration after identify and until reset(), and sends nothing for a guest. While a person is identified, the SDK sends the registration again on every app start, on a new token, and after setPushEnabled. A registerPushToken call that changes nothing sends no second registration in the same app start, so you can call it on every launch.

With the same provider, a call that gives no appId or no environment keeps the stored value. Thus onNewToken can pass only the token.

reset() removes the device from the person who signs out. It keeps the token on the phone and clears the setPushEnabled switch. An identify for a different person without a reset() also clears the switch. If the phone is offline, the SDK keeps the removal and sends it first on the next app start, with the time of the sign-out. If the same person signs in again before the removal goes out, the SDK drops the removal and the device stays. While the person is opted out, registrations wait, but a removal is still sent.

The SDK does not store or send a token that FounderHQ refuses. It logs a warning with the reason, and never the token.

The SDK reads whether notifications are on for your app, without a prompt, and sends authorized or denied with the registration. Before your app asks on Android 13 and later, the system reports them off, so the SDK sends denied. Pass permission to set the state yourself.

Android gives a notification tap to your app, so report the open from the activity that handles it:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // Not after a rotation: the same intent comes back then.
    if (savedInstanceState == null) reportPushOpen(intent)
}

// The activity was already open when the person tapped.
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    reportPushOpen(intent)
}

private fun reportPushOpen(intent: Intent?) {
    val link = events.capturePushNotificationOpened(intent)
    if (link != null) openLink(link)
}

The SDK reads two extras, fhqOutboundMessageId and fhqLink, and nothing else. It sends the first as $push_message_id and returns the second. It never opens the link. An intent with neither extra is not a FounderHQ notification: the SDK sends nothing and returns null, so you can pass every intent.

The call never throws. If Android cannot read the extras (an intent from another app can hold a class your app does not know), the SDK sends nothing and returns null. It does the same when the person starts the app from Recents: Android gives the old notification intent again, but the person did not tap again.

A push that arrives in the foreground

FounderHQ sends a notification part and a data part. The result depends on the state of your app:

  • Background or closed: the system tray shows the notification. A tap starts your launcher activity, and the data keys are String extras on its intent. FounderHQ sets no click_action, so the tap always goes to the launcher activity.
  • Foreground: Firebase calls onMessageReceived and shows nothing. Your app must build the notification. Put the two FounderHQ keys on its content intent as String extras, then the open is reported the same way.
class AppMessagingService : FirebaseMessagingService() {
    private val events get() = (application as MyApplication).events

    // Firebase can call this in a new process, with no activity.
    override fun onNewToken(token: String) {
        events.registerPushToken(token)
    }

    // Firebase calls this for a push that arrives while your app is in the
    // foreground, and for a removal FounderHQ sends from the server.
    override fun onMessageReceived(message: RemoteMessage) {
        // A removal shows nothing: the SDK cancels the notification it names.
        if (events.handlePushMessage(message.data)) return
        val notification = message.notification ?: return
        val launch = packageManager.getLaunchIntentForPackage(packageName) ?: return
        launch.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
        // The same two String extras that the system tray gives.
        message.data["fhqOutboundMessageId"]?.let { launch.putExtra("fhqOutboundMessageId", it) }
        message.data["fhqLink"]?.let { launch.putExtra("fhqLink", it) }
        // The tag the system tray gives a FounderHQ push: a later push with
        // the same key replaces this one, and dismissPushNotification finds it.
        val key = FounderHQPushPayload.from(message.data).notificationKey
        val tap = PendingIntent.getActivity(
            this, key.hashCode(), launch,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
        )
        // Needs your notification channel and the notification permission.
        NotificationManagerCompat.from(this).notify(
            key,
            0,
            NotificationCompat.Builder(this, CHANNEL_ID)
                .setSmallIcon(R.drawable.ic_notification)
                .setContentTitle(notification.title)
                .setContentText(notification.body)
                .setContentIntent(tap)
                .setAutoCancel(true)
                .build(),
        )
    }
}

The full setup is on Push.

Config

FounderHQEventsConfig takes these values.

ValueTypeDefaultWhat it does
hostStringhttps://i.getfounderhq.comWhere events are sent
flushAtInt20Sends a batch once this many events are queued
flushIntervalSecondsLong10Sends a batch on this timer. 0 turns the timer off
personProfilesPersonProfilesIDENTIFIED_ONLYWhen contact property changes are applied
optOutByDefaultBooleanfalseStarts opted out
captureLifecycleBooleantrueApp opened and backgrounded events
captureScreensBooleantrueActivity screen events
captureSessionsBooleantrueSession start events
captureInstallUpdatesBooleantrueApp installed and updated events
remoteConfigBooleantrueReads capture settings you set in FounderHQ
accountFounderHQAccountContext?nullInstalls account context before the first event
purchasePrepareTimeoutMillisLong3000How long preparePurchase waits before checkout goes ahead offline
captureElementInteractionsBooleanfalseTaps and rage taps. See Element interactions
capturePushNotificationOpenedBooleantrueRecords the notification taps your app reports
tracingHeadersList<String>?nullHostnames whose requests carry the session id. Add FounderHQOkHttpInterceptor(events) to your OkHttp client for this to take effect
maxQueueSizeInt1000Events kept offline before the oldest are dropped
eventTtlMillisLong86400000How long an unsent event may wait
maxRetriesInt5Times a failed event is retried
autoProperties((FounderHQAutoPropertiesContext) -> Map<String, Any?>?)?nullAdds properties to screens and captured taps
beforeSend((JSONObject) -> JSONObject?)?nullChanges or drops finished events except purchase and push device controls
debugBooleanfalseLogs what the SDK drops or refuses

FounderHQEventsDependencies replaces the clock, UUID source, storage, transport, and platform facts. Use it in tests.

Sending

MethodReturnsWhat it does
flush()BooleanSends queued events now, and tells you whether the queue drained
close()—Flushes, stops the executors, and unregisters the lifecycle callbacks
getDistinctId()StringThe current identity
getSessionId()StringThe current session

FounderHQEvents implements AutoCloseable.

Element interactions

Set captureElementInteractions to true. The SDK then records $autocapture when someone taps a control, and $rageclick when they tap the same control three times in a second. Both are off by default.

Each event carries the control and up to four of its parents.

StoredNot stored
The view class, resource entry name, and content descriptionAnything a person typed
The path through the view hierarchyTap coordinates
The label a Button shows, or the selected tab of a TabLayoutThe contents or hint of an EditText

A tap on an EditText records nothing at all, and a plain TextView never contributes its words. Give a view the tag android:tag="fhq-no-capture" to skip it and everything inside it.

Labels are cut to 255 characters. Numbers that look like a card number or a social security number are removed from them.

Only a tap counts. A finger that moves further than the platform's touch slop, a second finger, or a cancelled touch means the gesture was a scroll. Lifting a finger over a row at the end of a scroll records nothing.

Remote config

The client reads your capture settings from FounderHQ at startup, caches them in its storage, and applies the cached copy on the next launch.

KeyWhat it turns on and off
capture_sessionsSession start events
capture_screensScreen events
capture_lifecycleApp opened and backgrounded events
autocaptureTaps and rage taps
capture_rageclicksRage taps only. Taps are still recorded

Your settings in FounderHQ win. They can turn capture off for every install, and on for an app that shipped with the wrong value. You cannot rebuild an app people have already installed, so the dashboard decides. The values in your config apply until your settings arrive.

Events already queued under a key that turns off are dropped before they leave the device.

Call refreshRemoteConfig() to fetch them again while the app runs. Call applyRemoteConfig(remote) to apply settings you supply yourself. Set remoteConfig = false in the config to skip the request.

Next

Read the protocol reference for the wire format, the reserved event names, and the campaign properties a deep link can carry.

AI agent or LLM? Read this page as markdown

On this page