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://feynman.clickstream.com', // shared collector for mobile keys
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. Mobile properties have no tracking domain of their own, so the endpoint is the shared collector https://feynman.clickstream.com; that is exactly what the dashboard's own React Native install snippet emits. 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.
Configuration defaults
| Option | Default | Notes |
|---|---|---|
platform | 'react-native' | Or 'ios' / 'android'. Sent as device.clientPlatform. |
batchSize | 20 | Buffered events before an automatic flush. May exceed 25 — the wire request is always split into the collector's 25-event batches. |
flushIntervalMs | 10_000 | 0 disables timer flushes. |
sessionTimeoutMs | 1_800_000 (30 min) | Idle window before a new session id is minted. |
maxRetries | 5 | Immediate retry attempts per flush. Exhausted events stay queued. |
maxQueueSize | 1_000 | Overflow threshold. |
queueOverflowPolicy | 'retain' | Lossless by default. 'drop-oldest' / 'drop-newest' are explicit, reported data-loss policies. |
consent | { analytics: true, identityResolution: true } | Start with { analytics: false } on a consent-gated property. |
appName / appVersion | 'ClickStreamApp' / '0.0.0' | Used to synthesize device.userAgent. userAgent overrides it verbatim. |
viewportWidth / viewportHeight | 0 | Logical screen size for device.viewport. |
onDelivery(report) and onDeliveryDiagnostic(diagnostic) give you collector-reconciled counts (received / accepted / duplicates / dropped / rejected) and content-free failure and queue-overflow records; the same diagnostics are retained in a bounded ring you can read with getDeliveryDiagnostics() and clear with clearDeliveryDiagnostics(). No event payload ever appears in a diagnostic.
Track screens, taps, and events
import { cs } from './clickstream';
// Screen view — `name` becomes page.path (a free string under the relaxed
// non-web schema). Only `title` and `url` are read from props.
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' });
Where your props actually go. The custom-event pipeline has five deliverable fields, so tap() and trackEvent() fold props into them:
action(string) →actionvalue(number) →value- everything else → JSON-encoded into
label, truncated at 4096 characters
So cs.tap('add_to_cart', { value: 1, sku: 'ACME-123' }) sends value: 1 and label: '{"sku":"ACME-123"}'. Nothing is silently lost, but nothing lands in a queryable field of its own either.
screen() is different: it reads only props.title (screen title) and props.url (an optional custom-scheme deep link such as myapp://checkout). Any other prop on a screen() call is dropped — send it as a separate trackEvent() if you need it.
The tracking methods (screen, tap, trackEvent, identify, setConsent) are fire-and-forget: they return immediately and never throw 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. flush(), reset(), and ready() return promises — await cs.ready() resolves once every queued enqueue has settled, which is what makes cs.screen(...); await cs.flush(); deterministic.
Identify a user
Hashing runs on-device (pure-JS SHA-256 + MD5). When identityResolution consent is denied, no identity value is sent at all — not the raw email or phone, not the hashes, and none of the CRM traits; the event still carries the visitor and session ids, and nothing else from identify(). Consent is snapshotted when you call identify(), so a later grant cannot retroactively release a value supplied while denied.
cs.identify('user@example.com', {
phone: '+1 (415) 555-1234', // normalized to E.164, then SHA-256 hashed
customerId: 'user_12345',
orderId: 'ord_998',
});
IdentifyTraits: phone, userId, customerId, accountId, crmContactId, orderId. Both arguments are optional — identify(undefined, { customerId }) is valid when you have a CRM id but no email.
The pure-JS hashing helpers are exported if you want to pre-hash off the main path: import { sha256Hex, md5Hex, normalizePhone } from '@clickstreamhq/react-native'.
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();
setConsent({ analytics: false }) is destructive on purpose: it discards every unsent event, including the persisted offline queue. Flush first if you need those events delivered. Setting identityResolution: false instead re-writes the queued events in place, stripping identity values before they can leave the device.
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, getVisitorOrNull } from '@clickstreamhq/react-native/signals';
wireSignals(cs); // once, near app start (after createClickStream)
async function decideExperience() {
const visitor = await getVisitorOrNull(); // never throws — null on any failure
if (!visitor || visitor.pending || visitor.stale || visitor.coverageMode === 'degraded') {
showDefaultExperience();
return;
}
if (!visitor.bot.isBot && visitor.scores.frustration >= 60) {
showHelpShortcut();
} else if (!visitor.bot.isBot && visitor.scores.conversionReadiness >= 70) {
showHighIntentAction();
} else {
showDefaultExperience();
}
}
getVisitorOrNull() swallows every failure and returns null; getVisitor() rejects instead (SignalsNotConfiguredError, SignalsRateLimitError, SignalsPlanUpgradeError, SignalsRequestError). Prefer the former in app code so a Signals outage can never break navigation.
Track before you read — Signals reads what the tracker writes. The first read after an app start commonly returns pending: true; that is the visitor initializing, not an error.
wireSignals(tracker, options?) configures the Signals client from the tracker's own key and endpoint and installs live resolvers, so session rotation and reset() are picked up without re-wiring. Pass { apiKey } / { endpoint } only when your read credential differs from the ingest one; unwireSignals() tears the client down.
The /signals subpath re-exports the full Signals surface — configure, reset, isConfigured, getVisitor, getVisitorOrNull, onVisitor, subscribeVisitor, onVisitorRealtime, waitFor, the helper predicates, the four error classes, applySignals / effects, and the types — so you import from one place. The effects helpers query the DOM at call time and are harmless no-ops in Hermes; in React Native use plain run callbacks that drive component state. 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://feynman.clickstream.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([
"_eid": UUID().uuidString, // retain this event object across retries
"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([
"_eid": UUID().uuidString,
"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://feynman.clickstream.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("_eid", java.util.UUID.randomUUID().toString()) // persist until 202
.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("_eid", java.util.UUID.randomUUID().toString()) // persist until 202
.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 optional on the native path — the web contract that requires them is only re-imposed when the platform iswebor absent. Send them anyway: they are what device and app-version reporting are built from, and@clickstreamhq/react-nativealways does.device.clientPlatformdeclares the surface. On acs_mob_live_*key you can omit it — the collector stampsreact-nativefrom the key posture. On acs_srv_live_*key it stampsserver. It is not spoofable in the other direction: a website key that declaresioshas the field stripped before validation._eid(or a request-levelIdempotency-Keyheader) is required in production. Without one the collector answers400 idempotency_required, because it cannot tell a retry from two identical events. Persist the value with the queued event and reuse it on every retry — that is what makes a replay a duplicate instead of a double count. Max 128 characters.
A minimal accepted native screen view:
{
"_eid": "elp1_7a_01JXYZ",
"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://feynman.clickstream.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. Three things a native client should handle:
sessionIdis mandatory on Hobby — without it the read returns400 session_id_required. On paid plans it is optional but still worth sending: with it the collector reads one live-session partition and bills one Signals Coverage unit; without it, one unit per partition.pending: trueon the first read is normal — the event you just sent has not been scored yet. Don't treat it as "not a visitor".coverageMode: 'degraded'arrives with HTTP 200, carrying a zeroed placeholder context. Check it before you personalize, or a month-end budget stop reads as a visitor with no intent.
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. The request-header-inconsistency signal is skipped for the same reason: a native client sends none of the browser headers it looks for.
What still applies on the native path: Cloudflare Bot Management (including the verified-bot override) and automation evidence reported by the SDK. Behavioral heuristics built on browser interactions are skipped rather than misapplied — dead-click detection has no meaning for app taps, and hesitation scoring keys off scroll depth that a native session never reports.
Be honest about what you get back. There is no native behavioral scorer today. A native or server session is pinned to a neutral behavioral human-confidence of 50, because the behavioral model is built from mouse, scroll, and DOM signals a native SDK cannot emit. That has a visible consequence in Signals: a native visitor's behavioralClass comes from the bot score alone, and the confidence-driven bands you would see on the web do not fire. bot.isBot, bot.category, and scores.* are still populated — treat behavioralClass on native as coarser than on web rather than as a per-session behavioral verdict.
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, and the collector strips a native clientPlatform declared under a website key before validation.
Limits and expectations
- The native path does not produce browser DOM replay; build app heatmaps from your own tap/scroll coordinates.
- Metering is the same as the web: a
screen()(a non-botpageview) counts as a billable pageview; bot-classified events count against Signals Coverage; taps, custom events, andidentifyare accepted-event volume with no meter of their own. Signals reads bill coverage per partition read. - The mobile key ships inside your app binary and can be extracted from it. That is expected — it is why the key is scoped to one property, gets its own rate-limit bucket (the dashboard mints it at half the plan's web rate, floor 50 req/s), and can be revoked without touching your website key or any other property. Never ship an admin or server key in an app.
- For a property configured to require consent, initialize the tracker with
consent.analytics: falseand callsetConsent({ analytics: true, ... })only after the native consent flow grants analytics. The collector independently drops missing or false analytics consent on strict properties before any billable or identity side effect. - 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.