Intempt Docs
Developer DocsSDK

Android SDK

Kotlin SDK for tracking user events, managing identities, and handling consent in Android applications.

Android SDK

The Intempt Android SDK is a Kotlin library for tracking user events, managing user identification, and handling consent in Android applications.

Requirements

Minimum SDKAPI 23 (Android 6.0)
Compile SDKAPI 35
LanguageKotlin or Java - the public API is usable from both

Installation

Add the dependency to your build.gradle.kts:

dependencies {
    implementation("com.intempt.sdk:intempt-android:3.0.0")
}

Or Groovy:

dependencies {
    implementation 'com.intempt.sdk:intempt-android:3.0.0'
}

Configuration

Create an intempt-config.json file in your app's src/main/assets/ folder. The file has two objects, auth and options:

{
  "auth": {
    "INTEMPT_API_KEY": "your-key-id.your-key-secret",
    "INTEMPT_SOURCE_ID": "your-source-id",
    "INTEMPT_ORGANIZATION_ID": "your-org",
    "INTEMPT_PROJECT_ID": "your-project"
  },
  "options": {
    "isLoggingEnabled": false,
    "isTouchEnabled": true,
    "isTextCaptureEnabled": true,
    "isAutoCaptureEnabled": true,
    "isQueueEnabled": true,
    "useIpAddressForGeolocation": true,
    "itemsInQueue": 5,
    "timeBuffer": 5000
  }
}

auth

FieldTypeDescription
INTEMPT_API_KEYstringAPI key, in id.secret form
INTEMPT_SOURCE_IDstringSource ID for data transmission
INTEMPT_ORGANIZATION_IDstringOrganization identifier
INTEMPT_PROJECT_IDstringProject identifier

options

Every option is optional; the defaults below apply when a key is absent.

FieldTypeDefaultDescription
isLoggingEnabledbooleanfalseDebug logging to logcat under the Intempt tag
isTouchEnabledbooleantrueTouch event auto-capture
isTextCaptureEnabledbooleantrueText capture in events
isAutoCaptureEnabledbooleantrueScreen and interaction auto-capture
isQueueEnabledbooleantrueEvent batching
useIpAddressForGeolocationbooleantrueWhether Intempt may derive geo from the request IP - see Geolocation
itemsInQueueint5Queued events that trigger a flush
timeBufferlong5000Milliseconds between batch sends

The API key is embedded in your app's assets and is extractable from any installed APK. Use a key scoped to ingestion only.

Initialization

Call initialize() in your Application class onCreate() method. It returns Boolean - true when the SDK is running, false when it could not start:

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        if (!Intempt.initialize(this)) {
            // The SDK is disabled and every call is a no-op. The most common cause is a missing
            // or incomplete intempt-config.json in src/main/assets.
        }
    }
}

initialize() never throws. A failure inside it is caught and reported through the return value rather than propagating into your app, and every subsequent SDK call becomes a logged no-op.

Intempt.isInitialized reports the same state later.

Credentials at runtime

assets/intempt-config.json is the documented path for a plain Android app, and it is not the only one. Pass credentials directly when they are resolved at runtime - a per-tenant white-label build, or a React Native / Flutter bridge that cannot ship a native asset file on its users' behalf:

Intempt.initialize(
    this,
    IntemptCredentials(
        apiKey = "<id>.<secret>",
        organizationId = "your-org",
        projectId = "your-project",
        sourceId = "your-source",
    ),
)

Runtime credentials win per field; the asset file fills in anything you leave out. Call credentials.problems() first if you want to know what is wrong before initializing - it returns an empty list when the credentials are usable, and never quotes the key back at you.

Named instances

More than one instance can run, each bound to its own project. initialize() with a name returns the instance:

val eu = Intempt.initialize(this, euCredentials, instanceName = "eu")
eu?.track("Signed up")

Intempt.mainInstance()          // the "default" instance
Intempt.instance("eu")          // by name

Instances are isolated on disk as well as in memory - separate preferences, event queue and consent log - so a second instance cannot inherit the first's profile or send its events under the wrong credentials. Every static on Intempt addresses "default"; a single-project app never needs this.

From Java

The whole public API is callable from Java in static form:

public class MyApplication extends Application {
    @Override public void onCreate() {
        super.onCreate();
        if (!Intempt.initialize(this)) {
            // disabled
        }
        Intempt.identify("user@example.com");
    }
}

Opting in and out

optIn() / optOut()

Intempt.optOut()   // stop capturing, and discard what is already queued
Intempt.optIn()    // resume

optOut() discards queued events, not just future ones. Setting a flag alone would leave events captured before the objection to upload after it. Consent records are deliberately preserved - they are the evidence of the user's decision.

hasOptedOut() / isOptedIn()

if (Intempt.isOptedIn()) {
    Intempt.track("Button Click")
}

Both return false when the SDK is not initialized.


Autocapture

Autocapture instruments the view layer: screen views, taps and control changes, without you writing a track() call for each one.

Nothing is installed until you start it. Hooking a host app's UI because someone called initialize() is not something an SDK should do on its own. initialize() starts autocapture only when intempt-config.json sets isAutoCaptureEnabled - that file is you asking for it in writing.

Intempt.autocapture.start()
Intempt.autocapture.stop()
Intempt.autocapture.isRunning()

start() and stop() return Boolean - false means it was already in that state. Both are safe to call repeatedly.

Choosing what it captures

Intempt.autocapture.configure(
    AutocaptureOptions(
        screenViews = true,
        controlInteractions = true,
        captureText = false,
    ),
)
Intempt.autocapture.start()

Or pass options straight to start():

Intempt.autocapture.start(AutocaptureOptions(screenViews = true, controlInteractions = false))
OptionDefaultWhat it captures
screenViewstrueActivity and fragment screen views and exits
controlInteractionstrueTaps and control changes on supported widgets
captureTexttrueWhether a captured interaction may carry the widget's text

captureText = false still records that a field changed - it stops recording what it changed to. For a single view, use doNotCaptureText() instead.

Password fields are masked whatever you set here.


Automatic events

Lifecycle facts the SDK knows without instrumentation. Separate from autocapture, and worth keeping separate: these are a handful of events a day, autocapture is one per interaction.

Intempt.automaticEvents = AutomaticEventsOptions(
    sessions = true,
    versionChanges = true,
    appStateChanges = false,
)
OptionDefaultEmits
sessionsonSession start and end, carrying device facts as user attributes
versionChangesoffApplication Installed / Application Updated, once per version
appStateChangesoffApplication Opened / Application Backgrounded, on every transition

Two of the three are off by default on purpose. appStateChanges fires on every foreground and background transition, which is the highest-volume automatic event the SDK has - turn it on when you want it, not by accident.


Delivery

Events are queued to disk and sent in batches. The queue survives process death, so an event accepted by track() is not lost if the app is killed before it uploads.

flush()

Sends whatever is queued now instead of waiting for the timer or the size trigger.

Intempt.flush { delivered ->
    Log.d("Intempt", "$delivered events accepted by the server")
}

The callback receives the count the server accepted, and runs on the delivery worker thread - post to the main thread yourself if it touches UI. It is called even when nothing was delivered (offline, empty queue, a batch that had to be retried), so awaiting it cannot hang on a failure.

flushInterval

Intempt.flushInterval = 30   // seconds
Intempt.flushInterval = 0    // disable the timer

Seconds, not milliseconds. 0 disables the timer, leaving flush() and the queue's bulk-upload limit as the only triggers.

The SDK also flushes when the app goes to the background, so a short session's last events don't wait for the next launch.


Errors

Every capture method returns Boolean - whether the event was accepted into the queue. That tells you whether, not why. The error listener tells you why.

Intempt.setErrorListener { error ->
    Log.w("Intempt", "refused: $error")
}
CaseMeans
OptedOutThe user has opted out. The most common reason a call returns false
ForbiddenEventNameThe event title is reserved by the platform
InvalidPropertyValueA NaN or infinity in an attribute, at the named key
MissingIdentityA required identifier was blank
MalformedApiKeyThe key is not <id>.<secret>. Reports the length, never the key
MissingConfigurationA blank orgId, projectId or sourceId
EncodingFailedThe payload would not serialize
TerminalThe server rejected the batch and a retry will not succeed
RetryableTransient. Carries the server's Retry-After in milliseconds when it sent one
TransportThe network layer failed before a status came back
StorageUnavailableThe queue could not persist the event, so it is lost rather than delayed
ServerThe server rejected the request and said why

The listener runs on whichever thread the failure happened on - the caller's for a refused track(), the delivery worker's for a transport failure.

No error case ever contains the API key.


Typed attribute values

Attribute and data maps take IntemptValue, not String. A number stays a number all the way to the platform, so segments and journey conditions compare what you meant:

val attributes = IntemptValue.mapOf(
    mapOf("plan" to "pro", "seats" to 5, "trial" to false),
)

IntemptValue.mapOf accepts String, Boolean, the numeric types, List, Array, Map and null, nested to any depth. NaN and infinity are rejected before they reach the wire - they are not JSON, and the gateway rejects the whole batch rather than the one bad value.

Every capture method returns Boolean

true means the event was accepted into the queue. It is not a delivery receipt. false means it will never be sent - opted out, an unrepresentable value, a reserved event name, or storage unavailable. Use setErrorListener to find out which.


User Identification

identify()

Associates user actions with a specific user ID.

ParameterTypeRequiredDescription
userIdStringYesUnique user identifier
eventTitleStringNoCustom event name (default: "Identify")
userAttributesMap<String, IntemptValue>NoUser properties
dataMap<String, IntemptValue>NoSupplementary event data
Intempt.identify(
    userId = "user123",
    eventTitle = "User Registration",
    userAttributes = IntemptValue.mapOf(
        mapOf("email" to "user@example.com", "name" to "John Doe", "seats" to 5),
    ),
    data = IntemptValue.mapOf(mapOf("registrationSource" to "app", "referrer" to "google")),
)

alias()

Links two distinct user identifiers together.

ParameterTypeRequiredDescription
userIdStringYesPrimary user identifier
anotherUserIdStringYesSecondary identifier to alias
Intempt.alias(
    userId = "anonymous_123",
    anotherUserId = "authenticated_user456"
)

Group / Account

group()

Connects a user with a group or business account.

ParameterTypeRequiredDescription
accountIdStringYesAccount/group identifier
eventTitleStringNoCustom event label
accountAttributesMap<String, IntemptValue>NoAccount properties
Intempt.group(
    accountId = "company_abc",
    eventTitle = "Account Created",
    accountAttributes = IntemptValue.mapOf(
        mapOf("name" to "Acme Corp", "plan" to "enterprise", "employees" to 500),
    ),
)

Event Tracking

track()

Records a custom event with associated data.

ParameterTypeRequiredDescription
eventTitleStringYesEvent name
dataMap<String, IntemptValue>YesEvent data
Intempt.track(
    eventTitle = "Purchase Completed",
    data = IntemptValue.mapOf(
        mapOf("orderId" to "order_123", "amount" to 99.99, "currency" to "USD"),
    ),
)

record()

Captures an event with optional user and account context.

ParameterTypeRequiredDescription
eventTitleStringYesEvent name
userIdStringNoUser identifier
accountIdStringNoAccount identifier
dataMap<String, IntemptValue>NoSupplementary event data
userAttributesMap<String, IntemptValue>NoUser properties
accountAttributesMap<String, IntemptValue>NoAccount properties

The parameter order is fixed across every Intempt SDK, and it changed in 3.0: userId now precedes accountId, and data precedes the two attribute maps.

Intempt.record(
    eventTitle = "Feature Used",
    userId = "user123",
    accountId = "account456",
    data = IntemptValue.mapOf(mapOf("feature" to "dashboard", "duration" to 300)),
    userAttributes = IntemptValue.mapOf(mapOf("role" to "admin")),
    accountAttributes = IntemptValue.mapOf(mapOf("plan" to "enterprise")),
)

Registers user consent preferences.

ParameterTypeRequiredDescription
actionConsentActionYesConsentAction.ACCEPT or ConsentAction.REJECT
validUntilLongYesExpiration timestamp (Unix)
emailStringNoUser email
messageStringNoConsent statement
categoryStringNoConsent classification
Intempt.consent(
    action = ConsentAction.ACCEPT,
    validUntil = System.currentTimeMillis() + (365L * 24 * 60 * 60 * 1000),
    email = "user@example.com",
    category = "analytics"
)

Product Tracking

productAdd()

Logs when a product is added to the cart.

Intempt.productAdd(productId = "prod_123", quantity = 2)

productOrdered()

Records products purchased at checkout.

Intempt.productOrdered(listOf(
    Product("prod_123", 2),
    Product("prod_456", 1),
))

productView()

Captures when a product screen is viewed.

Intempt.productView(productId = "prod_123")

Session Management

logOut() and reset()

Both rotate the anonymous profile so the next user of a shared device does not inherit the previous one's identity. They differ in what happens to events already queued:

Intempt.logOut()   // rotate identity, KEEP the queue - those events are still theirs to send
Intempt.reset()    // rotate identity AND discard the queue

getProfileId() / getSessionId()

Both return "" when the SDK is not running.

val profileId = Intempt.getProfileId()
val sessionId = Intempt.getSessionId()

Recommendations

products()

Fetches recommendations from a feed. Must be called from a coroutine. This was recommendation() before 3.0 - same capability, same endpoint, one name across every Intempt SDK.

ParameterTypeRequiredDescription
feedIdStringYesFeed identifier
countIntNoHow many to return (default 10)
fieldsList<String>NoCatalog columns to return (default FeedFields.DEFAULT)
productIdString?NoAnchor product for related-item feeds
lifecycleScope.launch {
    val result = Intempt.products(
        feedId = "5292",
        count = 10,
        fields = listOf("id", "title", "price"),
    )
    result?.let { recommendations ->
        // handle recommendations
    }
}

Always name the fields you need. An unfielded request returns every catalog column, including raw ML embedding vectors - 222,919 bytes against 503 for the same 10 products. That is why the default is deliberately minimal rather than "everything".

The feed only answers for a profile the platform has already ingested, and returns the same "user not found" for a wrong feed id and an unknown profile - a null means one of the two.


Privacy - Protecting Sensitive Views

Use doNotCaptureText() to exclude views containing sensitive information from text capture:

val sensitiveView = findViewById<EditText>(R.id.password_field)
Intempt.doNotCaptureText(sensitiveView)

Experiments & personalization

Not available in the Android SDK. These are intempt.js features and were never backed by this SDK's contract - any earlier example showing Intempt.experiment or Intempt.personalization did not work. Use the web SDK for experiments and personalization.


Geolocation

The SDK does not fetch, store or transmit the device's IP address.

Set useIpAddressForGeolocation in options to control whether Intempt may derive city / region / country from the source IP of the requests it already receives. It defaults to true. Set it to false and no geolocation happens at either end:

{ "options": { "useIpAddressForGeolocation": false } }

Session events carry deviceType, carrier and platform in userAttributes. They no longer carry ipAddress, city, region or country - earlier versions fetched those from a third-party service on every session start.


Debugging and logging

Intempt.Logging.start()          // SDK diagnostics to logcat, tag "Intempt"
Intempt.Logging.stop()
Intempt.Logging.isLoggingEnabled()

Or set isLoggingEnabled in intempt-config.json to have it on from the first line of onCreate(), before you get a chance to call start().

adb logcat -s Intempt Intempt.Messages FCM

Intempt is the SDK, Intempt.Messages is the delivery queue, FCM is push.

The Authorization header is redacted in logs. It used to be printed in full whenever logging was on, which put the ingestion credential in every bug report.

Checking events actually left

Logging on, then watch for the queue posting. A batch that is accepted is deleted from the queue; a batch that fails is kept and retried, so it stays visible.

Intempt.flush { delivered -> Log.d("Intempt", "delivered=$delivered") }

delivered=0 with no error means there was nothing queued. delivered=0 with an error means something was queued and did not go - the error listener says which.


Troubleshooting

initialize() returns false. The most common cause is a missing or incomplete intempt-config.json in src/main/assets. The SDK logs which credentials it could not find. Every call after that is a logged no-op rather than a crash.

Events are accepted but nothing appears. Check Intempt.isOptedIn(). optOut() also discards what was already queued, so events captured before it are gone rather than pending.

track() returns false and you don't know why. Set an error listener. The Boolean says whether; the listener says why.

products() returns null. The feed only answers for a profile the platform has already ingested. On a fresh install the SDK's device-generated profile does not exist server-side yet, so the call fails until events for it have been ingested. The feed returns the same "user not found" for a wrong feed id and an unknown profile, so a null means one of the two.

Autocapture is not producing events. It does not start on its own. Call Intempt.autocapture.start(), or set isAutoCaptureEnabled in the config file. Check Intempt.autocapture.isRunning().

A number arrives as a string. Wrap attributes with IntemptValue.mapOf(...) rather than passing a plain map. See typed attribute values.


Source and releases

Sourcegithub.com/intempt/intempt-android
Sample appsample/ - a host app that consumes the SDK the way a customer does
ChangelogCHANGELOG.md
Issuesgithub.com/intempt/intempt-android/issues
SecurityEmail security@intempt.com. Don't open a public issue

Forbidden Event Titles

These titles are reserved and will throw an error:

auto-track · view page · leave page · change on · click on · submit on · identify · consent

All tracking methods silently return without executing when the user has opted out of tracking.

On this page