Mobile Apps
ClickStream tracks native mobile apps as a first-class surface. A native app does not have a DOM, browser cookies, CSS selectors, or a page URL, so it does not run the browser pixel. Instead, a Mobile app property issues a dedicated key and every event declares its platform.
There are two things that make mobile work, both landed in the platform:
- A dedicated mobile key (
cs_mob_live_*). In Einstein you create a property of type Mobile app (no URL, no DNS). It mints a key whose collector config is markedkeyType: 'mobile', which is exempt from the browser Origin/Referer gate. That is why a native app can callPOST /v1/eventsdirectly — there is no spoofedOriginheader and no fake web URL anywhere in this page. - A
device.clientPlatformfield. Every event carriesdevice.clientPlatformset to'ios','android', or'react-native'. The collector reads it to relax the page schema (a screen name instead of an http URL) and to branch bot/feature scoring onto the native path.
Do not reuse a website's shared browser key in a native app. Website keys are domain-gated and require a browser
Origin; a native request with one will be rejected. Create a Mobile app property and use thecs_mob_live_*key it gives you.
Which install to use
| Stack | Use |
|---|---|
| React Native / Expo | @clickstreamhq/react-native — the supported package. |
| Native iOS (Swift) | Direct REST with URLSession. |
| Native Android (Kotlin) | Direct REST with OkHttp. |
| Flutter / other | Direct REST against POST /v1/events with a cs_mob_live_* key and device.clientPlatform. |
| Mobile web / in-app webview | The normal browser install with a website key — these have a DOM. |
What works on the native path
| Capability | Native app | Notes |
|---|---|---|
| Screen views | Yes | Send pageview with a screen-name page.path. |
| Taps / custom events | Yes | Send custom events for taps and app actions. |
| Identity events | Yes | Hash email/phone on-device; send identify. |
| Signals reads | Yes | Read VisitorContext for the same visitor id after events arrive. |
| Bot / automation classification | Yes (native semantics) | Driven by CF Bot Management + on-device evidence, not browser UA heuristics. See Native bot semantics. |
| DOM replay / web heatmaps | No | There is no DOM. Use tap coordinates for app-specific maps. |
| Edge capture for answer engines | No | Edge capture is a website-edge feature. |
React Native + Expo
@clickstreamhq/react-native is a Hermes-safe tracker: no crypto.subtle, no window/document, no DOM. Identity hashing is pure JS, persistence goes through an AsyncStorage adapter, and sessions rotate on AppState foreground transitions plus a wall-clock idle check. Every event it sends carries device.clientPlatform (default 'react-native') and authenticates with your mobile key.
pnpm add @clickstreamhq/react-native @react-native-async-storage/async-storage
Create one tracker for the app lifetime:
// clickstream.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState } from 'react-native';
import { createClickStream } from '@clickstreamhq/react-native';
export const cs = createClickStream({
apiKey: 'cs_mob_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', // dashboard Mobile app key
endpoint: 'https://t.example.com', // your first-party collector domain
storage: AsyncStorage,
appStateProvider: AppState,
appName: 'Acme',
appVersion: '2.1.0',
});
apiKey and endpoint are both required — a native app has no sensible default for either. AsyncStorage already matches the StorageAdapter interface, so you pass it directly; omit it and the SDK uses an in-memory store (fine for tests, data lost on restart). Pass AppState and the session re-checks its 30-minute idle window every time the app returns to the foreground.
Track screens, taps, and events
import { cs } from './clickstream';
// Screen view — `name` becomes page.path (a free string under the relaxed
// non-web schema). props.title overrides the screen title.
cs.screen('CheckoutScreen', { title: 'Checkout' });
// Nested route names are fine:
cs.screen('Settings/Notifications');
// A tap — emitted as a custom event with category 'tap'.
cs.tap('add_to_cart', { value: 1, sku: 'ACME-123' });
// Any custom event.
cs.trackEvent('promo_viewed', { campaign: 'spring_sale' });
Every public method is fire-and-forget — it returns immediately and never throws into app code. Events buffer to an offline queue (persisted through the storage adapter) and flush in collector-capped batches of 25 with bounded retry, so events survive an app restart or a flaky network.
Identify a user
Hashing runs on-device (pure-JS SHA-256 + MD5). Raw email/phone are forwarded only when identityResolution consent is granted; the hashes are always safe to send.
cs.identify('user@example.com', {
phone: '+1 (415) 555-1234', // normalized to E.164, then SHA-256 hashed
customerId: 'user_12345',
orderId: 'ord_998',
});
Consent and reset
// Halt all collection (e.g. before consent is granted, or on opt-out):
cs.setConsent({ analytics: false });
// Re-enable, keeping identity resolution gated separately:
cs.setConsent({ analytics: true, identityResolution: true });
// Forget the device on logout — clears visitor id, session, and queue,
// then mints a fresh anonymous visitor id.
await cs.reset();
Reading Signals in React Native
Signals ships pre-wired on the /signals subpath. wireSignals(cs) configures the Signals client against the tracker's mobile key + endpoint and resolves the visitor/session ids straight off the tracker — no cookie, no DOM.
import { cs } from './clickstream';
import { wireSignals, getVisitor } from '@clickstreamhq/react-native/signals';
wireSignals(cs); // once, near app start (after createClickStream)
async function decideExperience() {
try {
const visitor = await getVisitor();
if (!visitor.bot.isBot && visitor.scores.frustration >= 60) {
showHelpShortcut();
} else if (!visitor.bot.isBot && visitor.scores.conversionReadiness >= 70) {
showHighIntentAction();
} else {
showDefaultExperience();
}
} catch {
showDefaultExperience(); // always fail open
}
}
The /signals subpath re-exports the full Signals surface (getVisitor, getVisitorOrNull, onVisitor, waitFor, helpers, types), so you import from one place. See the Signals API for the VisitorContext shape and helper reference.
Native iOS (Swift)
For native iOS, send events directly to POST /v1/events with URLSession. Use the mobile key, set device.clientPlatform: "ios", and use a screen name as page.path — no http URL is required.
import Foundation
import CryptoKit
enum ClickStream {
static let endpoint = "https://t.example.com"
static let apiKey = "cs_mob_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" // mobile key
static let userAgent = "AcmeApp/2.1.0 CFNetwork iOS/17.5"
// Persist once per install; rotate the session after 30 min idle.
static let visitorId = persistentVisitorId() // your Keychain-backed id
static var sessionId = currentSessionId()
static func send(_ event: [String: Any]) {
guard let url = URL(string: "\(endpoint)/v1/events"),
let body = try? JSONSerialization.data(withJSONObject: ["events": [event]]) else { return }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Mobile key is provenance-exempt — no Origin header.
req.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
req.httpBody = body
URLSession.shared.dataTask(with: req).resume()
}
static func screen(_ name: String, title: String) {
send([
"type": "pageview",
"visitorId": visitorId,
"sessionId": sessionId,
"timestamp": Int(Date().timeIntervalSince1970 * 1000),
"page": ["path": name, "title": title], // screen name — no URL
"device": [
"userAgent": userAgent,
"viewport": ["width": 390, "height": 844],
"clientPlatform": "ios"
]
])
}
static func identify(email: String, customerId: String) {
let normalized = email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let hem = SHA256.hash(data: Data(normalized.utf8))
.map { String(format: "%02x", $0) }.joined()
send([
"type": "identify",
"visitorId": visitorId,
"sessionId": sessionId,
"timestamp": Int(Date().timeIntervalSince1970 * 1000),
"page": ["path": "Account", "title": "Account"],
"device": ["userAgent": userAgent, "viewport": ["width": 390, "height": 844], "clientPlatform": "ios"],
"hem": hem,
"identity": ["visitorId": visitorId, "sessionId": sessionId, "hem": hem, "customerId": customerId]
])
}
}
For production, wrap send() with a small offline queue and flush when connectivity returns; keep each batch at 25 events or fewer.
Native Android (Kotlin)
On Android, use OkHttp. Same contract: mobile key, device.clientPlatform: "android", screen name as page.path, no Origin header.
import okhttp3.*
import org.json.JSONArray
import org.json.JSONObject
import java.security.MessageDigest
object ClickStream {
private const val ENDPOINT = "https://t.example.com"
private const val API_KEY = "cs_mob_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" // mobile key
private const val USER_AGENT = "AcmeApp/2.1.0 OkHttp Android/15"
private val JSON = "application/json".toMediaType()
private val http = OkHttpClient()
// Persist once per install; rotate the session after 30 min idle.
var visitorId: String = persistentVisitorId()
var sessionId: String = currentSessionId()
private fun send(event: JSONObject) {
val body = JSONObject().put("events", JSONArray().put(event))
val req = Request.Builder()
.url("$ENDPOINT/v1/events")
.addHeader("Content-Type", "application/json")
.addHeader("X-API-Key", API_KEY) // provenance-exempt — no Origin
.post(body.toString().toRequestBody(JSON))
.build()
http.newCall(req).enqueue(object : Callback {
override fun onFailure(call: Call, e: java.io.IOException) {}
override fun onResponse(call: Call, response: Response) { response.close() }
})
}
private fun device(): JSONObject = JSONObject()
.put("userAgent", USER_AGENT)
.put("viewport", JSONObject().put("width", 412).put("height", 915))
.put("clientPlatform", "android")
fun screen(name: String, title: String) {
send(JSONObject()
.put("type", "pageview")
.put("visitorId", visitorId)
.put("sessionId", sessionId)
.put("timestamp", System.currentTimeMillis())
.put("page", JSONObject().put("path", name).put("title", title)) // screen name — no URL
.put("device", device()))
}
fun identify(email: String, customerId: String) {
val normalized = email.trim().lowercase()
val hem = MessageDigest.getInstance("SHA-256")
.digest(normalized.toByteArray())
.joinToString("") { "%02x".format(it) }
send(JSONObject()
.put("type", "identify")
.put("visitorId", visitorId)
.put("sessionId", sessionId)
.put("timestamp", System.currentTimeMillis())
.put("page", JSONObject().put("path", "Account").put("title", "Account"))
.put("device", device())
.put("hem", hem)
.put("identity", JSONObject()
.put("visitorId", visitorId).put("sessionId", sessionId)
.put("hem", hem).put("customerId", customerId)))
}
}
The native event shape
The relaxed non-web schema applies whenever device.clientPlatform is 'ios', 'android', or 'react-native':
page.urlis optional. If you do send one, it may use a custom scheme (myapp://checkout) — it does not have to be an http(s) URL.page.pathis a free string: a screen name or route such asCheckoutScreenorSettings/Notifications.page.titleis still required.device.userAgentanddevice.viewportare required;device.clientPlatformdeclares the surface.
A minimal accepted native screen view:
{
"type": "pageview",
"visitorId": "cs_visitor_abc",
"sessionId": "cs_session_xyz",
"timestamp": 1713797640000,
"page": { "path": "CheckoutScreen", "title": "Checkout" },
"device": {
"userAgent": "AcmeApp/2.1.0 CFNetwork iOS/17.5",
"viewport": { "width": 390, "height": 844 },
"clientPlatform": "ios"
}
}
There is no
Originheader anywhere on the native path, and no fabricatedhttps://example.com/...page URL. The mobile key's provenance exemption is what makes that work — see API keys + auth.
Reading Signals from a native app (REST)
After the app has sent at least one event in the current session, read the same VisitorContext the web libraries use. Pass the persisted visitor id and the current session id; authenticate with the mobile key and send no Origin:
GET https://t.example.com/v1/signals/cs_visitor_abc?sessionId=cs_session_xyz
X-API-Key: cs_mob_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json
The response is the VisitorContext shape documented in the Signals API. Always fail open: if the network is down, Signals must not break navigation, checkout, login, or content rendering.
Native bot semantics
Native and server clients are exempt from the browser UA-scraper and datacenter-IP bot heuristics. A native HTTP library identifies itself with a user agent like OkHttp/4.x or CFNetwork and originates from carrier/cloud IP ranges — on the browser path those look like scraper or hosting traffic, but under a mobile key they are expected and do not force a bot classification.
For native traffic, isBot is driven by Cloudflare Bot Management plus on-device automation evidence only, and a dedicated native confidence path scores taps, screen transitions, form fills, and dwell time.
The exemption is gated on key posture and declared platform, not on the user agent. A copied website (browser) key cannot escape detection by spoofing a native UA — a library UA on a browser key still forces a bot classification. Native exemption applies only when the request authenticates with a mobile/server key (or declares a native clientPlatform under such a key).
Limits and expectations
- The native path does not produce browser DOM replay; build app heatmaps from your own tap/scroll coordinates.
- Native app events count against plan limits and Signals Coverage like other accepted traffic.
- Do not embed admin secrets in the app. Use the
cs_mob_live_*key from a Mobile app property in Einstein. - Keep batches at 25 events or fewer; the
@clickstreamhq/react-nativepackage handles batching and offline retry for you.
See Also
- Install — pick a platform (website / mobile / server).
- Event schema —
device.clientPlatform, the relaxed page rules, and Swift/Kotlin examples. - API keys + auth — mobile/server key types and the provenance exemption.
- Signals API — the
VisitorContextshape and helpers. - Rate limits — per-key caps.