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 SDK | API 23 (Android 6.0) |
| Compile SDK | API 35 |
| Language | Kotlin 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
| Field | Type | Description |
|---|---|---|
INTEMPT_API_KEY | string | API key, in id.secret form |
INTEMPT_SOURCE_ID | string | Source ID for data transmission |
INTEMPT_ORGANIZATION_ID | string | Organization identifier |
INTEMPT_PROJECT_ID | string | Project identifier |
options
Every option is optional; the defaults below apply when a key is absent.
| Field | Type | Default | Description |
|---|---|---|---|
isLoggingEnabled | boolean | false | Debug logging to logcat under the Intempt tag |
isTouchEnabled | boolean | true | Touch event auto-capture |
isTextCaptureEnabled | boolean | true | Text capture in events |
isAutoCaptureEnabled | boolean | true | Screen and interaction auto-capture |
isQueueEnabled | boolean | true | Event batching |
useIpAddressForGeolocation | boolean | true | Whether Intempt may derive geo from the request IP - see Geolocation |
itemsInQueue | int | 5 | Queued events that trigger a flush |
timeBuffer | long | 5000 | Milliseconds 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 nameInstances 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() // resumeoptOut() 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))| Option | Default | What it captures |
|---|---|---|
screenViews | true | Activity and fragment screen views and exits |
controlInteractions | true | Taps and control changes on supported widgets |
captureText | true | Whether 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,
)| Option | Default | Emits |
|---|---|---|
sessions | on | Session start and end, carrying device facts as user attributes |
versionChanges | off | Application Installed / Application Updated, once per version |
appStateChanges | off | Application 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 timerSeconds, 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")
}| Case | Means |
|---|---|
OptedOut | The user has opted out. The most common reason a call returns false |
ForbiddenEventName | The event title is reserved by the platform |
InvalidPropertyValue | A NaN or infinity in an attribute, at the named key |
MissingIdentity | A required identifier was blank |
MalformedApiKey | The key is not <id>.<secret>. Reports the length, never the key |
MissingConfiguration | A blank orgId, projectId or sourceId |
EncodingFailed | The payload would not serialize |
Terminal | The server rejected the batch and a retry will not succeed |
Retryable | Transient. Carries the server's Retry-After in milliseconds when it sent one |
Transport | The network layer failed before a status came back |
StorageUnavailable | The queue could not persist the event, so it is lost rather than delayed |
Server | The 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
userId | String | Yes | Unique user identifier |
eventTitle | String | No | Custom event name (default: "Identify") |
userAttributes | Map<String, IntemptValue> | No | User properties |
data | Map<String, IntemptValue> | No | Supplementary 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
userId | String | Yes | Primary user identifier |
anotherUserId | String | Yes | Secondary identifier to alias |
Intempt.alias(
userId = "anonymous_123",
anotherUserId = "authenticated_user456"
)Group / Account
group()
Connects a user with a group or business account.
| Parameter | Type | Required | Description |
|---|---|---|---|
accountId | String | Yes | Account/group identifier |
eventTitle | String | No | Custom event label |
accountAttributes | Map<String, IntemptValue> | No | Account 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
eventTitle | String | Yes | Event name |
data | Map<String, IntemptValue> | Yes | Event 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
eventTitle | String | Yes | Event name |
userId | String | No | User identifier |
accountId | String | No | Account identifier |
data | Map<String, IntemptValue> | No | Supplementary event data |
userAttributes | Map<String, IntemptValue> | No | User properties |
accountAttributes | Map<String, IntemptValue> | No | Account 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")),
)Consent Management
consent()
Registers user consent preferences.
| Parameter | Type | Required | Description |
|---|---|---|---|
action | ConsentAction | Yes | ConsentAction.ACCEPT or ConsentAction.REJECT |
validUntil | Long | Yes | Expiration timestamp (Unix) |
email | String | No | User email |
message | String | No | Consent statement |
category | String | No | Consent 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 queuegetProfileId() / 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
feedId | String | Yes | Feed identifier |
count | Int | No | How many to return (default 10) |
fields | List<String> | No | Catalog columns to return (default FeedFields.DEFAULT) |
productId | String? | No | Anchor 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 FCMIntempt 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
| Source | github.com/intempt/intempt-android |
| Sample app | sample/ - a host app that consumes the SDK the way a customer does |
| Changelog | CHANGELOG.md |
| Issues | github.com/intempt/intempt-android/issues |
| Security | Email 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.
Event Tracking (SDK deep-dive)
How Intempt receives events: auto-tracking, the browser SDK's custom event methods, and the server-side Track Data API.
Apple SDK
Track user interactions across iOS, iPadOS, tvOS, macOS and watchOS, deliver APNs push from journeys, and manage configuration from the Intempt console.
