Skip to content

Android SDK ​

Linkrunner MMP SDK for Android: install attribution, deferred deep linking, in-app events and revenue tracking.

Prefer to let your AI coding agent do this? Install the Linkrunner skill and Claude Code, Cursor, GitHub Copilot, or Windsurf will wire up the SDK and deep links for you:

bash
npx @linkrunner/skills add android

Then ask your agent to "integrate Linkrunner". See Linkrunner Agent Skills.

Requirements ​

  • Android 5.0 (API level 21) or higher
  • Android Studio Flamingo (2022.2.1) or newer
  • Gradle 8.0+

Installation ​

Step 1: Gradle Setup ​

Add the Linkrunner SDK to your app's build.gradle file:

gradle
dependencies {
    implementation 'io.linkrunner:android-sdk:5.0.0'
}

Make sure you have the Maven Central repository in your project's settings.gradle file:

gradle
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}

Step 2: Required Permissions ​

Add the following permissions to your AndroidManifest.xml file:

xml
<!-- Required for the SDK to make network requests -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

Note: The AD_ID permission (<uses-permission android:name="com.google.android.gms.permission.AD_ID" />) is already included in the SDK and is required for collecting device identifiers (GAID). If your app participates in Designed for Families, you should revoke AAID and disable AAID collection. See the Disabling AAID Collection section for more details.

Step 3: Backup Configuration ​

The SDK provides backup rules to exclude Shared Preferences data from backup. This prevents the retention of the Linkrunner install ID during reinstallation, ensuring accurate detection of new installs and re-installs.

This backup configuration works similarly for all SDKs (React Native, Flutter, and native Android). The same Android backup rules apply regardless of which SDK you're using.

Adding Backup Rules to Your App ​

Add to your AndroidManifest.xml:

xml
<application
    android:fullBackupContent="@xml/linkrunner_backup_descriptor"
    android:dataExtractionRules="@xml/linkrunner_backup_rules">
    <!-- Your app content -->
</application>
  • android:fullBackupContent - Used for Android 6-11
  • android:dataExtractionRules - Used for Android 12+

Merging Backup Rules ​

If you already have your own backup rules specified (e.g., android:fullBackupContent="@xml/my_backup_descriptor" or android:dataExtractionRules="@xml/my_rules"), then manually add the following rules to your existing files:

For legacy backup (Android 6-11) in res/xml/my_backup_descriptor:

xml
<full-backup-content>
    <!-- Exclude LinkRunner SDK SharedPreferences from legacy backup -->
    <exclude domain="sharedpref" path="io.linkrunner.sdk_prefs"/>
</full-backup-content>

For modern backup (Android 12+) in res/xml/my_backup_rules.xml:

xml
<data-extraction-rules>
    <cloud-backup>
        <!-- Exclude LinkRunner SDK SharedPreferences from cloud backup -->
        <exclude domain="sharedpref" path="io.linkrunner.sdk_prefs"/>
    </cloud-backup>
    <device-transfer>
        <!-- Exclude LinkRunner SDK SharedPreferences from device transfer -->
        <exclude domain="sharedpref" path="io.linkrunner.sdk_prefs"/>
    </device-transfer>
</data-extraction-rules>

Step 4: Encrypted SharedPreferences ​

SDK Version Requirement: Starting from linkrunner-android v3.8.1, the SDK automatically encrypts the credentials it stores in SharedPreferences. No additional configuration is required. Upgrade to v3.8.1 or above to get this behavior by default.

Values written by the SDK are encrypted at rest, with a hardware-protected key generated on the device and stored in the Android Keystore. The key never leaves the device and is not bundled with the SDK.

If you are upgrading from an earlier version, the SDK will transparently migrate any existing plaintext entries to the encrypted store on the next read after the upgrade. No code changes are needed on your side.

Step 5: Revoking the AD_ID Permission (Optional) ​

According to Google's Policy, apps that target children must not transmit the Advertising ID.

To revoke the AD_ID permission, use SDK version 3.5.0 and above. Children apps targeting Android 13 (API 33) and above must prevent the permission from getting merged into their app by adding a revoke declaration to their Manifest. Use the setDisableAaidCollection() and isAaidCollectionDisabled() functions to disable AAID collection programmatically:

AndroidManifest.xml

xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- Remove AD_ID permission that comes from the SDK -->
    <uses-permission
        android:name="com.google.android.gms.permission.AD_ID"
        tools:node="remove" />

    <!-- Your other permissions -->
</manifest>

Make sure to add xmlns:tools="http://schemas.android.com/tools" to your manifest tag to use the tools:node="remove" attribute. If you disable AAID collection, you should also remove the AD_ID permission from your manifest to fully comply with Google Play's Family Policy requirements.

For more information, see Google Play Services documentation.

Importing in Kotlin/Java ​

After installation, you can import the SDK in your Kotlin or Java files:

kotlin
// Kotlin
import io.linkrunner.sdk.LinkRunner
java
// Java
import io.linkrunner.sdk.LinkRunner;

Initialization (Required) ​

Initialize the Linkrunner SDK in your application, typically in your Application class or main activity:

You can find your project token here.

Note: This method returns a void. To get attribution data and deeplink information, use the getAttributionData method.

kotlin
import android.app.Application
import io.linkrunner.sdk.LinkRunner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

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

        // Initialize LinkRunner with SDK signing
        CoroutineScope(Dispatchers.IO).launch {
            try {
                LinkRunner.getInstance().init(
                    context = applicationContext,
                    token = "YOUR_PROJECT_TOKEN",
                    secretKey = "YOUR_SECRET_KEY", // Optional: Required for SDK signing
                    keyId = "YOUR_KEY_ID", // Optional: Required for SDK signing
                    debug = true // Optional: Enable debug mode for development (defaults to false)
                )

                println("LinkRunner initialized successfully")
            } catch (e: Exception) {
                println("Exception during initialization: ${e.message}")
            }
        }
    }
}

SDK Signing Parameters (Optional) ​

For enhanced security, the LinkRunner SDK requires the following signing parameters during initialization:

  • secretKey: A unique secret key used for request signing and authentication
  • keyId: A unique identifier for the key pair used in the signing process
  • debug (optional): Boolean flag to enable debug mode for development (defaults to false)

You can find your project token, secret key, and key ID here.

Setting the Customer User ID ​

Use setCustomerUserId to attach your own user identifier to the device right after init. Once set, the identifier is stored securely on-device and automatically included in every event you track, so you never have to pass it on each trackEvent call.

Call it as early as the user's ID is available. This guarantees every event carries a user_id from the very first event, and is especially useful for existing users who were already onboarded before this feature shipped.

Available from Android SDK v3.9.1.

Best practice: set the Customer User ID as early as possible. The user_id is only attached to events tracked after it's set, and is not applied retroactively. Use a stable, unique identifier from your own system (for example your internal user ID or a UUID) rather than an email address or other PII.

kotlin
import io.linkrunner.sdk.LinkRunner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

private fun setCustomerUserId() {
    CoroutineScope(Dispatchers.IO).launch {
        val result = LinkRunner.getInstance().setCustomerUserId("f47ac10b-58cc-4372-a567-0e02b2c3d479") // Your unique customer user ID (e.g. a UUID)
        result
            .onSuccess { println("Customer user id set") }
            .onFailure { println("Failed to set customer user id: ${it.message}") }
    }
}

The identifier is stored encrypted on-device and persists across app restarts. Calling setCustomerUserId again with a different identifier updates the stored value; passing the same identifier is a no-op. signup(), and setUserData() called with an id, also update it.

User Identification (Required) ​

Call the signup method as soon as the user is identified, whether through signup or login. This is the moment Linkrunner ties the install (and any future events) to a user identifier.

It is strongly recommended to use the integrated platform's identify function to set a persistent user_id once it becomes available (typically after signup or login).

If the platform's identifier function is not called, you must provide a user identifier for Mixpanel, PostHog, and Amplitude integration.

  • mixpanelDistinctId for Mixpanel
  • posthogDistinctId for PostHog
  • amplitudeDeviceId for Amplitude
kotlin
private fun onSignup() {
    CoroutineScope(Dispatchers.IO).launch {
        try {
            val userData = UserDataRequest(
                id = "123", // Required: User ID
                name = "John Doe", // Optional
                phone = "9876543210", // Optional
                email = "user@example.com", // Optional
                mixpanelDistinctId = "mixpanel_distinct_id", // Optional - Mixpanel Distinct ID
                amplitudeDeviceId = "amplitude_device_id", // Optional - Amplitude User ID
                posthogDistinctId = "posthog_distinct_id", // Optional - PostHog Distinct ID
                userCreatedAt = "2024-01-01T00:00:00Z", // Optional
                isFirstTimeUser = true, // Optional
            )

            LinkRunner.getInstance().signup(
                userData = userData,
                additionalData = mapOf("custom_field" to "custom_value") // Optional: Any additional data
            )
            println("Signup successful")
        } catch (e: Exception) {
            println("Exception during signup: ${e.message}")
        }
    }
}

To enable remarketing and reattribution, you need to capture deep links and pass them to the Linkrunner SDK. This allows Linkrunner to detect returning users who open the app via a deep link.

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

        // Cold start deeplink
        handleDeeplinkFromIntent(intent)
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)

        // Warm start deeplink
        handleDeeplinkFromIntent(intent)
    }

    private fun handleDeeplinkFromIntent(intent: Intent) {
        intent.data?.let { uri ->
            lifecycleScope.launch {
                LinkRunner.getInstance().handleDeeplink(uri.toString())
            }
        }
    }
}

Linkrunner sends the updated deeplink back after processing. For Linkrunner campaign links, use the returned deeplink as the resolved destination instead of the original tracking URL.

json
{
    "deeplink": "https://app.yourdomain.com/product/123"
}

Getting Attribution Data ​

Using Amplitude for install or pre-login onboarding reports? Send attribution from your app before tracking those events. The automatic signup integration does not add campaign properties to earlier events.

To get attribution data and deeplink information for the current installation, use the getAttributionData function:

kotlin
val attributionDataResult = LinkRunner.getInstance().getAttributionData()
attributionDataResult.onSuccess { attributionData ->
    println("Attribution data: $attributionData")
    // Attribution data includes:
    // - deeplink: The deep link URL that led to app installation
    // - campaignData: Campaign information
    // - gaid: Google Advertising ID used for the install
}
kotlin
data class AttributionData(
    val deeplink: String?,
    val campaignData: CampaignData,
    val gaid: String?,  // Google Advertising ID, null when not available
    val idfa: String?   // Always null on Android
)

data class CampaignData(
    val id: String,
    val name: String,
    val adNetwork: String?,
    val type: String,
    val installedAt: String,
    val storeClickAt: String?,
    val groupName: String,
    val assetName: String,
    val assetGroupName: String,
    val adNetworkCampaignId: String?, // Ad network campaign ID
    val adSetId: String?,             // Ad set ID
    val adSetName: String?,           // Ad set name
    val adCreativeId: String?,        // Ad creative ID
    val adCreativeName: String?       // Ad creative name
)

Example response:

json
{
    "deeplink": "https://app.yourdomain.com/product/123",
    "campaignData": {
        "id": "camp_123",
        "name": "Summer Sale 2024",
        "type": "INORGANIC",
        "adNetwork": "META",
        "installedAt": "2024-03-20T10:30:00Z",
        "storeClickAt": "2024-03-20T10:29:45Z",
        "groupName": "Summer Sale Group",
        "assetName": "Banner Ad 1",
        "assetGroupName": "Product Catalog",
        "adNetworkCampaignId": "120214682829390250",
        "adSetId": "120214682829640250",
        "adSetName": "Productivity",
        "adCreativeId": "120214682926100250",
        "adCreativeName": "Static_2"
    },
    "gaid": "38400000-8cf0-11bd-b23e-10b96e40000d",
    "idfa": null
}

gaid is null when the GAID is not available, for example when limited ad tracking is on or GAID collection is disabled. Requires Android SDK 4.2.0 or later.

Setting User Data ​

Call setUserData to send user details and the IDs your product analytics tools use for this user. You can call it right after init, before the user logs in: id is optional for setUserData. Pass id once the user is logged in.

When you pass a tool's ID and that integration is configured in Linkrunner, Linkrunner sends this install's attribution to that tool for this user, the same attribution it sends after signup (for example, lr_campaign and lr_ad_network on the Mixpanel profile). This applies to installs attributed to a campaign. If the install is not attributed yet, Linkrunner sends the properties once attribution completes. Supported IDs:

ToolField
MixpanelmixpanelDistinctId
AmplitudeamplitudeDeviceId
PostHogposthogDistinctId
MoEngagemoengageUserId
BrazebrazeDeviceId
Google Analytics 4gaAppInstanceId and gaSessionId
NetcorenetcoreDeviceGuid

setUserData is not a replacement for signup. Call signup as soon as the user is identified (signup or login). signup still requires id.

Calling setUserData without id requires Android SDK 4.3.0 or later. On earlier versions id is required.

Right after init, before the user logs in:

kotlin
private fun setAnalyticsIds(mixpanelDistinctId: String) {
    CoroutineScope(Dispatchers.IO).launch {
        val result = LinkRunner.getInstance().setUserData(
            UserDataRequest(mixpanelDistinctId = mixpanelDistinctId)
        )
        result.onFailure { error -> println("Error setting user data: ${error.message}") }
    }
}

After the user logs in:

kotlin
private fun setUserData() {
    CoroutineScope(Dispatchers.IO).launch {
        val userData = UserDataRequest(
            id = "123", // Your customer user ID
            name = "John Doe", // Optional
            phone = "9876543210", // Optional
            email = "user@example.com", // Optional
            mixpanelDistinctId = "mixpanel_distinct_id" // Optional
        )

        val result = LinkRunner.getInstance().setUserData(userData)

        result.onSuccess {
            println("User data set successfully")
        }.onFailure { error ->
            println("Error setting user data: ${error.message}")
        }
    }
}

Setting CleverTap ID ​

Use the setAdditionalData method to set CleverTap ID:

kotlin
private fun setIntegrationData() {
    CoroutineScope(Dispatchers.IO).launch {
        try {
            val integrationData: IntegrationData = IntegrationData(clevertapId="YOUR_CLEVERTAP_ID")

            val result = LinkRunner.getInstance().setAdditionalData(integrationData)

            result.onSuccess {
                println("CleverTap ID set successfully")
            }.onFailure { error ->
                println("Error setting CleverTap ID: ${error.message}")
            }
        } catch (e: Exception) {
            println("Exception setting CleverTap ID: ${e.message}")
        }
    }
}

Revenue Tracking ​

Revenue is stored for all users, including organic ones. Payments from users with no matching click are stored without campaign attribution. Pass your user ID as userId so each payment is linked to the user who paid. You can verify your events are being captured on the Events page.

Tracking Payments ​

A payment is an in-app event with revenue. Send it with trackEvent, and put amount, paymentId, and currency in eventData. The event name becomes the payment type in reports and mappings, so you can use any name, such as purchase or SUBSCRIPTION_RENEWED. Requires Android SDK 5.0.0 or later. For examples for every case and how payments reach ad networks, see the full Track payments guide.

This example sends every payment key. amount, paymentId, currency, and userId are required for a payment.

kotlin
import io.linkrunner.sdk.LinkRunner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

private fun trackPurchase() {
    CoroutineScope(Dispatchers.IO).launch {
        val result = LinkRunner.getInstance().trackEvent(
            eventName = "purchase",            // Payment name, stored as the payment type in reports and mappings
            eventData = mapOf(
                "amount" to 1499.0,            // Required for a payment: total the user paid, a finite number >= 0
                "paymentId" to "order_123",    // Required for a payment: your order or transaction ID, used to deduplicate
                "currency" to "INR",           // Required for a payment: 3-letter ISO 4217 code
                "userId" to "user_123",        // Required for a payment: your user ID. "" uses the user saved by signup or setUserData
                "status" to "PAYMENT_COMPLETED", // Optional: defaults to PAYMENT_COMPLETED (see statuses below)
                // Optional: any other keys stay as event data. Meta and Google read these product fields
                "content_ids" to listOf("sku_1", "sku_2"),
                "contents" to listOf(
                    mapOf("id" to "sku_1", "quantity" to 1, "item_price" to 999),
                    mapOf("id" to "sku_2", "quantity" to 1, "item_price" to 500)
                ),
                "content_type" to "product",
                "num_items" to 2,
                "order_id" to "order_123"
            ),
            eventId = "evt_order_123"          // Optional: your own ID for this event
        )

        result.onSuccess {
            println("Payment event queued")
        }.onFailure { error ->
            // An invalid payment returns IllegalArgumentException here, and nothing is sent
            println("Error tracking payment: ${error.message}")
        }
    }
}

Payment keys in eventData ​

KeyTypeRequiredDescription
amountDouble or IntYesTotal the user paid. Must be a finite number, 0 or greater. This is the revenue value.
paymentIdStringYesYour order or transaction ID. Must not be blank. Used to recognize the same payment when it arrives again.
currencyStringYes3-letter ISO 4217 currency code, such as INR or USD. Case-insensitive, sent in uppercase.
userIdStringYesYour ID for the user who paid. If you pass an empty string, the SDK uses the user saved by signup or setUserData.
statusStringNoPAYMENT_INITIATED, PAYMENT_COMPLETED (default), PAYMENT_FAILED, or PAYMENT_CANCELLED.
Any other keyAnyNoStays in the event data, such as product details for ecommerce events.

How trackEvent decides:

  • amount, paymentId, and currency are all in eventData: the event is a payment. The SDK checks the payment keys and sends them as payment fields.
  • None of them, or amount or currency without paymentId: the event is a normal custom event, exactly as before. An amount or currency key on its own is ordinary event data.
  • paymentId with amount or currency missing: trackEvent returns a failed Result with an IllegalArgumentException with amount, paymentId and currency must be sent together, and nothing is sent.
  • A negative or non-numeric amount, a blank paymentId, a currency that is not 3 letters, or an unknown status: trackEvent returns a failed Result with an IllegalArgumentException, and nothing is sent.

Payment status ​

status is a string. io.linkrunner.sdk.models.PaymentStatus lists the same four values if you prefer an enum.

StatusUse it whenEffect
PAYMENT_INITIATEDThe user started paying, such as opening the checkout or payment sheet.Stored for reports and analytics. Not added to revenue and not sent to ad networks.
PAYMENT_COMPLETED (default)The charge succeeded.Added to revenue and paying users. Sent to ad networks when the event name is mapped.
PAYMENT_FAILEDThe charge was declined or errored.Stored for reports and analytics. Not added to revenue and not sent to ad networks.
PAYMENT_CANCELLEDThe user backed out before paying, or you refunded it. Send it with the same event name, paymentId, amount, and currency as the completed payment.Stored for reports and analytics. The payment no longer counts as revenue and nothing is sent to ad networks.

Linkrunner keeps one payment per paymentId, event name, and status, so a retry with the same values is recorded once. See Payment status for a full lifecycle example.

The event name is stored exactly as you send it. If you used one of the old payment types, pass it in uppercase exactly as before, such as FIRST_PAYMENT or DEFAULT, so your existing mappings and reports keep working. A different spelling such as first_payment is a new payment type. Any other name, such as purchase, is also stored as sent, and you need to add a mapping for it in the dashboard before it reaches ad networks.

capturePayment and removePayment are deprecated. They still work in 5.0.0 and show a deprecation warning, and they will be removed in a future major version. Use trackEvent with the payment keys instead. Apps on Android SDK 4.x keep working unchanged. To move, see Migrate payments to in-app events.

Tracking Custom Events ​

From Android SDK v3.9.0, custom events automatically include the user_id you set during signup() / setUserData(). The SDK stores this identifier securely on-device and attaches it to every trackEvent call, so you no longer need to pass it manually. Events tracked before signup are sent without a user_id.

Events are stored for all users, including organic ones. Events from users with no matching click are stored without campaign attribution. Call .signup so events are linked to a user. You can verify your events are being captured on the Events page. To record revenue, add the payment keys to eventData.

Track custom events in your app:

kotlin
private fun trackEvent() {
    CoroutineScope(Dispatchers.IO).launch {
        try {
            val result = LinkRunner.getInstance().trackEvent(
                eventName = "purchase_initiated", // Event name
                eventData = mapOf( // Optional: Event data
                    "product_id" to "12345",
                    "category" to "electronics"
                ),
                eventId = "order_12345" // Optional: Your own unique event identifier
            )

            result.onSuccess {
                println("Event tracked successfully")
            }.onFailure { error ->
                println("Error tracking event: ${error.message}")
            }
        } catch (e: Exception) {
            println("Exception tracking event: ${e.message}")
        }
    }
}

Parameters for LinkRunner.getInstance().trackEvent ​

  • eventName: String (required) - Name of the event to track
  • eventData: Map<String, Any> (optional) - Key-value pairs for additional event data, including Meta ecommerce properties. Add amount, paymentId, currency, and userId to make it a payment. See Tracking Payments.
  • eventId: String (optional) - Your own unique identifier for the event, useful for deduplication and correlating with your backend

Revenue Sharing with Ad Networks ​

Send revenue to ad networks such as Google Ads and Meta as a payment. Meta and Google receive the value and currency from the payment amount and currency, so send the order total as amount. Linkrunner does not add up item prices.

Put the payment keys and the product details in eventData. Meta receives content_ids, contents, content_type, num_items, and order_id. Google receives content_ids as item IDs.

kotlin
private fun trackPurchase() {
    CoroutineScope(Dispatchers.IO).launch {
        LinkRunner.getInstance().trackEvent(
            eventName = "purchase",
            eventData = mapOf(
                "amount" to 149.99, // Order total, sent to ad networks as the value
                "paymentId" to "order_12345",
                "currency" to "USD",
                "userId" to "user_123",
                "content_ids" to listOf("product_123"),
                "num_items" to 1,
                "order_id" to "order_12345"
            )
        ).onFailure { error ->
            println("Invalid payment: ${error.message}")
        }
    }
}

An amount in eventData is revenue only when paymentId and currency are there too. On its own it is ordinary event data. Revenue is shared only when Send Revenue is turned on for the event's mapping in the dashboard. See Meta Ads and Google Ads.

Ecommerce Events ​

Minimum SDK Version: Ecommerce Event Manager requires linkrunner-android v3.6.0 or above. Please ensure your SDK is updated before using this feature.

If you are tracking Ecommerce events to sync with Meta or Google, you must format your eventData to include the required fields. You also need to map your custom event to the standard commerce event in the Linkrunner Dashboard.

For detailed explanations of the required fields like content_ids, contents, and value, refer to our Meta Commerce Manager documentation or Google Commerce Manager documentation.

Add To Cart Example ​

Use the trackEvent method to send an AddToCart event:

kotlin
private fun trackAddToCart() {
    CoroutineScope(Dispatchers.IO).launch {
        LinkRunner.getInstance().trackEvent(
            eventName = "add_to_cart", // Map this custom event to "AddToCart" (Meta) or "add_to_cart" (Google) in the Linkrunner Dashboard. See: /ecommerce-manager/meta-commerce-manager, /ecommerce-manager/google-commerce-manager
            eventData = mapOf(
                "content_ids" to listOf("product_123"),
                "contents" to listOf(
                    mapOf(
                        "id" to "product_123", // Matches content_ids
                        "quantity" to 1,
                        "item_price" to 49.99
                    )
                ),
                "content_type" to "product",
                "currency" to "USD",
                "value" to 49.99,
                "num_items" to 1
            )
        )
    }
}

View Content Example ​

Use the trackEvent method to send a ViewContent event:

kotlin
private fun trackViewContent() {
    CoroutineScope(Dispatchers.IO).launch {
        LinkRunner.getInstance().trackEvent(
            eventName = "view_item", // Map this custom event to "ViewContent" (Meta) or "view_item" (Google) in the Linkrunner Dashboard. See: /ecommerce-manager/meta-commerce-manager, /ecommerce-manager/google-commerce-manager
            eventData = mapOf(
                "content_ids" to listOf("product_123"),
                "contents" to listOf(
                    mapOf(
                        "id" to "product_123", // Matches content_ids
                        "quantity" to 1,
                        "item_price" to 49.99
                    )
                ),
                "content_type" to "product",
                "currency" to "USD",
                "value" to 49.99,
                "num_items" to 1
            )
        )
    }
}

Payment / Purchase Example ​

Use trackEvent with the payment keys and the ecommerce fields in eventData to send a Purchase event:

kotlin
private fun trackPurchase() {
    CoroutineScope(Dispatchers.IO).launch {
        LinkRunner.getInstance().trackEvent(
            eventName = "purchase", // Map this event to "Purchase" (Meta) or "ecommerce_purchase" (Google) in the Linkrunner Dashboard
            eventData = mapOf(
                "amount" to 49.99, // Order total, sent to ad networks as the value
                "paymentId" to "payment_456",
                "currency" to "USD",
                "userId" to "user_123",
                "status" to "PAYMENT_COMPLETED", // Optional, the default
                "content_ids" to listOf("product_123"),
                "contents" to listOf(mapOf("id" to "product_123", "quantity" to 1, "item_price" to 49.99)),
                "content_type" to "product",
                "num_items" to 1,
                "order_id" to "order_abc123" // Required for Purchase events
            )
        ).onFailure { error ->
            println("Invalid payment: ${error.message}")
        }
    }
}

Note: For more information on testing and verifying your ecommerce events, please see our Meta Commerce Manager or Google Commerce Manager guide.

Prefer to let your AI coding agent do this? The Android skill already handles this. It wires setConsent:

bash
npx @linkrunner/skills add android

See Linkrunner Agent Skills.

SDK Version Requirement: Requires Android SDK version 4.1.0 or higher.

If you run Google App Campaigns, report your users' consent choices. Google's App Conversion API treats these values as required whenever they are known, and reads them when it matches your installs, so an install with no consent state attached is harder to attribute. This matters most for users in the European Economic Area, the United Kingdom, and Switzerland.

Set the values with setConsent before you call init, and again whenever the user changes their choice:

kotlin
import io.linkrunner.sdk.models.ConsentStatus
import io.linkrunner.sdk.models.LinkrunnerConsent

LinkRunner.getInstance().setConsent(
    LinkrunnerConsent(
        isEEA = ConsentStatus.GRANTED,
        hasConsentForDataUsage = ConsentStatus.GRANTED,
        hasConsentForAdsPersonalization = ConsentStatus.DENIED
    )
)
ParameterMeaning
isEEAEuropean regulations apply to this user (the EEA, the UK, or Switzerland)
hasConsentForDataUsageThe user agreed to their data being sent to Google for advertising
hasConsentForAdsPersonalizationThe user agreed to their data being used to personalize ads

Each takes ConsentStatus.GRANTED, ConsentStatus.DENIED, or ConsentStatus.UNKNOWN. Anything left UNKNOWN is dropped from the payload rather than reported as a denial, so Linkrunner never reports a choice your user did not make.

Google treats these as required whenever their value is known. hasConsentForDataUsage decides whether Google may use the conversion at all, hasConsentForAdsPersonalization decides whether it may feed audiences and remarketing, and isEEA tells Google which rules apply. Set them from your app's real consent state rather than hardcoding them. For users outside the EEA, the UK, and Switzerland, report isEEA as denied and leave the other two unset. See Send Consent. A missing advertising ID is not a consent signal and is never treated as one.

Consent is stored between launches. Call setConsent again whenever the user's consent state changes, otherwise the previous value keeps being sent after the user has withdrawn it.

There is no extra SDK to add to your Android build. Android installs reach Google through the App Conversion API, which reads the consent signals above.

Enhanced Privacy Controls ​

The SDK offers options to enhance user privacy:

kotlin
// Enable PII (Personally Identifiable Information) hashing
LinkRunner.getInstance().enablePIIHashing(true)

// Check if PII hashing is enabled
val isHashingEnabled = LinkRunner.getInstance().isPIIHashingEnabled()

When PII hashing is enabled, sensitive user data like name, email, and phone number are hashed using SHA-256 before being sent to Linkrunner servers.

Disabling AAID Collection ​

SDK Version Requirement: The AAID collection disable functionality requires Android SDK version 3.5.0 or higher.

The SDK provides options to disable AAID (Google Advertising ID) collection. This is useful for apps targeting children or families to comply with Google Play's Family Policy.

Disabling AAID collection is not recommended unless absolutely necessary. The GAID is a primary signal for Google Ads attribution and install matching, so disabling it reduces attribution accuracy. Only disable it if your app is built for children or families and must comply with Google Play's Family Policy.

Disable AAID Collection ​

To disable AAID collection, call setDisableAaidCollection before SDK initialization:

kotlin
// Disable AAID collection
LinkRunner.getInstance().setDisableAaidCollection(true)

// Check if AAID collection is disabled
val isDisabled = LinkRunner.getInstance().isAaidCollectionDisabled()

When AAID collection is disabled, the SDK will not collect or send the Google Advertising ID (GAID) to Linkrunner servers.

Removing AD_ID Permission ​

If you want to completely remove the AD_ID permission from your app's manifest (for example, for apps targeting children), you can override the SDK's permission declaration in your AndroidManifest.xml. For detailed instructions on revoking the AD_ID permission, including Google's policy requirements for apps targeting children and Android 13+ (API 33+), see the Revoking the AD_ID Permission section above.

Uninstall Tracking ​

Before you begin ​

Here's what you need to know before getting started:

Requirements:

Android ​

Connect Firebase Cloud Messaging (FCM) with Linkrunner

FCM HTTP v1 API

To configure FCM HTTP V1 for uninstalls:

Enable the FCM API:

  1. Go to the FCM console.
  2. Select a project.
  3. Go to Project Overview > Project settings.
  4. Copy the Project ID. This will be required in a later step. Project ID
  5. Go to the Cloud Messaging tab.
  6. Make sure that Firebase Cloud Messaging API (V1) is set to Enabled.

Create a custom role for Linkrunner Uninstall:

  1. Go to the Service accounts tab.
  2. Click Manage service account permissions.
  3. A new browser tab opens in Google Cloud Platform.
  4. In the side menu, select Roles.
  5. Click + Create role.
  6. Enter the following details:
    • Title: Linkrunner Uninstalls
    • ID: lr_uninstalls
    • Role launch stage: General availability
  7. Click + Add permissions.
  8. In Enter property name or value field, enter cloudmessaging.messages.create and select it from the search results. Google Cloud Permission
  9. Check the cloudmessaging.messages.create option and click Add.
  10. Click Create.

Assign Linkrunner the FCM uninstall role:

  1. In the side menu, select IAM.
  2. Open the View by Principals tab.
  3. Click Grant Access.
  4. In Add Principals -> New principals field, enter lr-uninstalls-tracking@lr-uninstalls-tracking.iam.gserviceaccount.com
  5. In Assign Roles -> Select a role field, enter Linkrunner Uninstalls and select it from the search results.
  6. Click Save.

The Linkrunner service account has been assigned the role of Linkrunner Uninstalls.

Linkrunner Dashboard
  1. In Linkrunner, go to Settings > Uninstall Tracking.
  2. Under the Android tab, enter the Firebase Project ID that you copied initially and click Save.

Uninstall Tracking

Integrate with Linkrunner SDK

Follow these instructions to integrate FCM with the Linkrunner SDK:

  1. Set up Firebase Cloud Messaging:

Set up Firebase Cloud Messaging in your Android app. See the Firebase Cloud Messaging documentation for detailed instructions.

  1. Configure your app to provide the device's push token to the Linkrunner SDK.
kotlin
import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.linkrunner.sdk.LinkRunner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class MyFirebaseMessagingService : FirebaseMessagingService() {

    override fun onNewToken(token: String) {
        super.onNewToken(token)
        // Send token to Linkrunner SDK
        CoroutineScope(Dispatchers.IO).launch {
            LinkRunner.getInstance().setPushToken(token)
        }
    }

    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)
        
        // Check if this is an uninstall tracking notification
        if (remoteMessage.data.containsKey("lr-uninstall-tracking")) {
            // Silent notification for uninstall tracking, ignore
            return
        }
        
        // Handle other messages here
    }
}

// Initialize token on app start
fun initializePushToken() {
    FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
        if (task.isSuccessful) {
            val token = task.result
            CoroutineScope(Dispatchers.IO).launch {
                LinkRunner.getInstance().setPushToken(token)
            }
        }
    }
}

Don't forget to register your service in AndroidManifest.xml:

xml
<service
    android:name=".MyFirebaseMessagingService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

Custom implementations of FCM's onMessageReceived method can unintentionally make uninstall push notifications visible to users, disrupting the intended silent experience. To avoid this, ensure your logic checks if the message contains lr-uninstall-tracking and handles it accordingly, as shown in the code example above.

Function Placement Guide ​

FunctionRequirementWhere to PlaceWhen to Call
LinkRunner.getInstance().initRequiredApplication classOnce when app starts
LinkRunner.getInstance().signupRequiredIdentification flow (signup or login)Once when the user is identified
LinkRunner.getInstance().setCustomerUserIdRequiredApp initialization or identification flowAfter init, as soon as your user ID is available
LinkRunner.getInstance().handleDeeplinkRequiredDeep link entry pointsWhen app is opened via a deep link
LinkRunner.getInstance().getAttributionDataOptionalAttribution data handling flowWhenever the attribution data is needed
LinkRunner.getInstance().setAdditionalDataOptionalIntegration codeWhen third-party integration IDs are available
LinkRunner.getInstance().setUserDataOptionalApp start, authentication or profile update logicRight after init with your analytics IDs, and when user details become available
LinkRunner.getInstance().trackEventOptionalThroughout appWhen specific user actions occur, including ecommerce events
LinkRunner.getInstance().trackEvent with payment keysOptionalPayment processingWhen user makes a payment, including ecommerce purchases
LinkRunner.getInstance().setPushTokenOptionalPush notification setupWhen FCM token is available
LinkRunner.getInstance().setConsentOptionalApp initialization or consent flowBefore init, and again when consent changes
LinkRunner.getInstance().enablePIIHashingOptionalPrivacy settingsWhen you want to enable PII hashing
LinkRunner.getInstance().isPIIHashingEnabledOptionalPrivacy settings or compliance checksWhen you need to check PII hashing status
LinkRunner.getInstance().setDisableAaidCollectionOptionalApp initialization or privacy settingsBefore init, when you need to disable AAID collection
LinkRunner.getInstance().isAaidCollectionDisabledOptionalPrivacy settings or compliance checksWhen you need to check AAID collection status

Complete Example ​

Here's a simplified example showing how to integrate Linkrunner in a native Android app:

You can find your project token here.

kotlin
import android.app.Application
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import io.linkrunner.sdk.LinkRunner
import io.linkrunner.sdk.models.request.UserDataRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

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

        // Initialize LinkRunner with SDK signing
        CoroutineScope(Dispatchers.IO).launch {
            try {
                LinkRunner.getInstance().init(
                    context = applicationContext,
                    token = "YOUR_PROJECT_TOKEN",
                    secretKey = "YOUR_SECRET_KEY", // Required for SDK signing
                    keyId = "YOUR_KEY_ID" // Required for SDK signing
                )
            } catch (e: Exception) {
                println("Error initializing LinkRunner: ${e.message}")
            }
        }
    }
}

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

        // Button to track an event
        findViewById<android.widget.Button>(R.id.trackEventButton).setOnClickListener {
            trackCustomEvent()
        }
    }

    override fun onResume() {
        super.onResume()

        // Set user data if user is logged in
        if (isUserLoggedIn()) {
            setUserData()
        }
    }

    private fun isUserLoggedIn(): Boolean {
        // Your login check logic
        return true
    }

    private fun setUserData() {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val userData = UserDataRequest(
                    id = "123",
                    name = "John Doe",
                    email = "user@example.com"
                )

                LinkRunner.getInstance().setUserData(userData)
            } catch (e: Exception) {
                println("Error setting user data: ${e.message}")
            }
        }
    }

    private fun trackCustomEvent() {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                LinkRunner.getInstance().trackEvent(
                    eventName = "button_clicked",
                    eventData = mapOf("screen" to "main")
                )
            } catch (e: Exception) {
                println("Error tracking event: ${e.message}")
            }
        }
    }
}

Publishing to Google Play or the App Store? See Data the SDK collects for what the SDK sends and the answers for the Play Data safety form and Apple's App Privacy questions.

Next Steps ​

Support ​

If you encounter issues during integration, contact us at support@linkrunner.io.