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:

  1. 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 marked keyType: 'mobile', which is exempt from the browser Origin/Referer gate. That is why a native app can call POST /v1/events directly — there is no spoofed Origin header and no fake web URL anywhere in this page.
  2. A device.clientPlatform field. Every event carries device.clientPlatform set 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 the cs_mob_live_* key it gives you.

Which install to use

StackUse
React Native / Expo@clickstreamhq/react-native — the supported package.
Native iOS (Swift)Direct REST with URLSession.
Native Android (Kotlin)Direct REST with OkHttp.
Flutter / otherDirect REST against POST /v1/events with a cs_mob_live_* key and device.clientPlatform.
Mobile web / in-app webviewThe normal browser install with a website key — these have a DOM.

What works on the native path

CapabilityNative appNotes
Screen viewsYesSend pageview with a screen-name page.path.
Taps / custom eventsYesSend custom events for taps and app actions.
Identity eventsYesHash email/phone on-device; send identify.
Signals readsYesRead VisitorContext for the same visitor id after events arrive.
Bot / automation classificationYes (native semantics)Driven by CF Bot Management + on-device evidence, not browser UA heuristics. See Native bot semantics.
DOM replay / web heatmapsNoThere is no DOM. Use tap coordinates for app-specific maps.
Edge capture for answer enginesNoEdge 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

OptionDefaultNotes
platform'react-native'Or 'ios' / 'android'. Sent as device.clientPlatform.
batchSize20Buffered events before an automatic flush. May exceed 25 — the wire request is always split into the collector's 25-event batches.
flushIntervalMs10_0000 disables timer flushes.
sessionTimeoutMs1_800_000 (30 min)Idle window before a new session id is minted.
maxRetries5Immediate retry attempts per flush. Exhausted events stay queued.
maxQueueSize1_000Overflow 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 / viewportHeight0Logical 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:

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':

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 Origin header anywhere on the native path, and no fabricated https://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:

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

See Also