← Documentation

Mobile and desktop apps

One Analisa app, one site key: your website, your iOS and Android apps and your desktop app (Electron, Tauri or native) report to the same place, and every report can be filtered by platform.

What is available today

  • Web: the @racoelho/analytics SDK (script tag or npm). Its events are tagged platform: web automatically.
  • Any app, right now: the HTTP ingestion protocol below. One POST with JSON — it works from Swift, Kotlin, Dart, React Native, Electron or a backend.
  • React Native / Expo: the @racoelho/analytics-react-native SDK implements this protocol for you: screens (React Navigation and Expo Router), sessions, app lifecycle, crashes, offline queue and retries. Install instructions are in Install → React Native in the app dashboard.
  • Electron / Tauri: the @racoelho/analytics-desktop SDK does the same for desktop apps (Windows, macOS and Linux): screens, sessions, app lifecycle, crashes and offline queue. Install instructions are in Install → Electron/Tauri.
  • Android (Kotlin): the io.useanalisa:analytics SDK covers screens (Jetpack Compose Navigation or Activities), sessions, app lifecycle, crashes and offline queue. Install instructions are in Install → Android (Kotlin).
  • Flutter: the analisa_analytics package covers iOS, Android, Windows, macOS and Linux from Dart: screens (NavigatorObserver), sessions, app lifecycle, crashes and offline queue. Install instructions are in Install → Flutter.
  • iOS / Swift (coming soon): a native Swift SDK that does the same. Until then, use the HTTP protocol (or Flutter / React Native).

Turn on app ingestion

  1. Open Apps → your app → Settings in the dashboard.
  2. Under Mobile and desktop apps, tick Receive events from apps (iOS, Android, desktop).
  3. Optional: list your bundle ids / package names (e.g. com.yourcompany.app). When the list is not empty, only requests carrying one of them (header X-Analisa-App-Id or context.app.id) are accepted.
  4. Send events with your app's site_key and no Origin header. Web traffic keeps using the allowed-origins check.

What you see in the dashboard

  • Platform filter next to the period picker (All, Web, iOS, Android, Windows, macOS, Linux): it applies to every report and is remembered as you navigate.
  • Platforms card on the dashboard with sessions and visitors per platform, shown when more than one platform has data.
  • App versions: users, sessions, errors and crash-free sessions per version over time, plus how many users are already on the latest version.
  • Pages and screens: apps record screens as page views with a route-like path (/product/detail), so Pages, Page flow, Funnels and Goals work for apps too.
  • Devices: OS version and device model for app traffic.
  • Errors: crashes and errors grouped like on the web, with platform and app version, filterable by version.

About app keys and abuse

A site key inside a mobile or desktop binary is public: anyone can extract it. App ingestion and the app-id allow list are spam mitigation of the same strength as the Origin check on the web — they stop accidental reuse and drive-by junk, not a determined attacker. Never ship your secret key in an app; it is for server-side calls only.


Ingestion protocol v1 (specification)

The full contract SDKs implement: endpoint, payload, context fields, event catalog (screens, lifecycle, crashes, identify), identity and sessions, batching and retries.

This is the wire contract between any Analisa SDK (web, React Native/Expo, Electron/Tauri, Flutter, Swift, Kotlin, or your own HTTP client) and the ingestor. One app in the dashboard has one site key, and that same key receives events from the website and from the mobile and desktop apps. The dashboard then filters and groups everything by platform.

The key words MUST, SHOULD and MAY are used as in RFC 2119.

  • Status: v1, stable. New optional fields may be added; nothing here will change meaning.

1. Endpoint

POST {endpoint}/v1/i

{endpoint} defaults to https://function-bun-production-3339.up.railway.app, so the full default URL is https://function-bun-production-3339.up.railway.app/v1/i. SDKs MUST let the developer override the full URL (self-hosting, proxies).

There is a single endpoint for every event type. There is no separate identify, screen or batch endpoint.

2. Request

2.1 Headers

HeaderRequiredNotes
Content-Type: application/jsonyestext/plain is also accepted (the web SDK uses it to avoid a CORS preflight). Body MUST be UTF-8 JSON either way.
User-AgentrecommendedFree form, e.g. ShopApp/2.3.0 (iOS 17.4; iPhone15,2) analisa-swift/0.1.0. Parsed only as a fallback when context lacks a field.
X-Analisa-App-IdconditionalBundle id / package name. Required when the app restricts allowed_app_ids and the events do not carry context.app.id (see §4). Sending it always is fine.
Accept-LanguageoptionalUsed as locale fallback.
X-CountryoptionalISO 3166-1 alpha-2 guess from the device locale/region. The server prefers its own geo headers.
Authorization: Bearer <secret_key>server-side onlyNever put the secret key in a mobile or desktop binary. Bearer requests are for backends (see §4.3).
Originnever set it manuallyBrowsers set it. Native clients MUST NOT send one (see §4).

No cookies are used or set.

2.2 Body

json
{
  "site_key": "pk_live_abc123",
  "sent_at": 1767225600123,
  "context": { "...": "context shared by every event in the batch (§3)" },
  "batch": [ { "...": "event (§2.3)" } ]
}
FieldTypeRequiredNotes
site_keystringyes (unless Bearer)The app's public key from the dashboard.
sent_atinteger ms epochrecommendedDevice clock at the moment the request is sent (set it on every attempt, including retries). If it differs from the server clock by more than 60 s, the server shifts every event ts in the batch by the difference, so a device with a wrong clock still produces correct times.
contextobjectrecommendedBatch-level context (§3). Applies to every event; an event's own context overrides it key by key (top-level keys: an event context.app replaces the whole batch context.app).
batcharrayyes1 to 1000 events.

2.3 Event

json
{
  "id": "7d1f8a0e-4b5c-4c1e-9d0b-2f3a4b5c6d7e",
  "event": "page_view",
  "ts": 1767225599000,
  "anon_id": "c0a8012e-5f4b-4d4e-8f7a-1b2c3d4e5f60",
  "session_id": "0f9e8d7c-6b5a-4f3e-8d2c-1b0a9f8e7d6c",
  "user_id": "user_123",
  "path": "/product/detail",
  "url": "shopapp://product/42",
  "referrer": "",
  "props": { "screen_name": "ProductDetail", "product_id": "42" },
  "context": { "...": "optional per-event overrides (§3)" }
}
FieldTypeRequiredNotes
idstringyes for native SDKsClient-generated unique id, 8–128 chars of [A-Za-z0-9._:-] (UUID v4 recommended). It is the idempotency key: the same id sent twice for the same site key is stored once. Generate it when the event is created, persist it with the queued event and reuse it on every retry.
eventstringyesEvent name, max 128 chars. Reserved names in §5. Use snake_case.
tsinteger ms epochyesWhen the event happened on the device (not when it was sent). Numbers only (a numeric string is tolerated). Events more than 10 min in the future (after the sent_at correction) are stored with the server time.
anon_idstringyesInstall-level anonymous id (§6.1).
session_idstringyesSession id (§6.2).
user_idstring | nullwhen knownYour app's user id after identify (§6.3). Omit or null when anonymous.
pathstringfor page_viewRoute-like path. For apps, the screen path (§5.1). Query string and trailing slash are stripped.
urlstringoptionalFull URL (web) or deep link (apps).
referrerstringoptionalWeb referrer. Apps MAY send the install/deep-link source (e.g. https://google.com, play-store); leave empty otherwise.
utmobjectoptional{ source, medium, campaign, term, content } (strings). Apps MAY fill it from campaign deep links.
propsobjectoptionalEvent properties. Flat JSON values (string, number, boolean, null) recommended; nested values are stored but not filterable in the dashboard. Keep it under ~4 KB per event.
contextobjectoptionalPer-event context overrides (§3).

Legacy web fields (lang, tz, viewport, screen, ua_string) are still accepted; native SDKs use context instead.

2.4 Limits

LimitValueServer response when exceeded
Request body100 KB (MAX_PAYLOAD_BYTES)413 {"ok":false,"error":"payload_too_large"}
Events per request1000413 {"ok":false,"error":"batch_too_large","max":1000}

SDKs SHOULD send at most 50 events per request and split larger queues.

3. Context

context describes where the event came from. Every field is optional; send everything you can read cheaply. The server validates and normalizes it: unknown keys are dropped, enum values are lower-cased and checked, strings are trimmed and truncated, numbers out of range are dropped. A bad field never rejects the event.

json
{
  "platform": "ios",
  "app": { "version": "2.3.0", "build": "451", "id": "com.shop.app" },
  "os": { "name": "iOS", "version": "17.4.1" },
  "device": { "model": "iPhone15,2", "manufacturer": "Apple", "type": "phone" },
  "locale": "pt-BR",
  "tz": "America/Sao_Paulo",
  "screen": { "w": 393, "h": 852, "density": 3 },
  "sdk": { "name": "analisa-swift", "version": "0.1.0" },
  "framework": "swift"
}
FieldType / valuesMaxSource examples
platformweb | ios | android | windows | macos | linux | otherenumiPadOS → ios; Android TV → android; Electron/Tauri → the OS they run on. Aliases accepted: darwin/osx → macos, win32 → windows. Unknown values become other.
app.versionstring64CFBundleShortVersionString, versionName, app.getVersion(), package_info_plus.version
app.buildstring (numbers accepted)64CFBundleVersion, versionCode, buildNumber
app.idstring255Bundle id / package name / Electron appId. Used by allowed_app_ids (§4).
os.namestring64UIDevice.systemName, "Android", "Windows", "macOS", distro name on Linux
os.versionstring64"17.4.1", "14", "10.0.22631", "14.5"
device.modelstring128Machine identifier: utsname.machine (iPhone15,2), Build.MODEL (SM-S918B), hw.model (MacBookPro18,3)
device.manufacturerstring128Apple, Build.MANUFACTURER (samsung)
device.typephone | tablet | desktop | tv | otherenumiPad / Android smallestScreenWidthDp >= 600 → tablet; Electron/Tauri → desktop
localeBCP 47 string35pt-BR, en-US
tzIANA zone64TimeZone.current.identifier, ZoneId.systemDefault()
screen.w, screen.hnumber 0–100000Logical size in points/dp (not physical pixels), portrait or current orientation
screen.densitynumber 0–20UIScreen.scale, displayMetrics.density, devicePixelRatio
sdk.namestring64analisa-react-native, analisa-flutter, analisa-swift, analisa-kotlin, analisa-electron, analisa-tauri, @racoelho/analytics (web)
sdk.versionstring32SDK semver
frameworklower-case slug32web, react-native, expo, flutter, electron, tauri, swift, swiftui, kotlin, compose

How the server derives what it stores:

  • No context.platform: browser requests (allowed Origin) and server requests (Bearer) are web — this keeps every existing web integration working unchanged. Native requests (no Origin) without a platform are stored as other. Native SDKs MUST always send platform.
  • The web SDK sends {"platform":"web","sdk":{...},"framework":"web"} as batch context.
  • For native events the stored OS name is canonicalized to the same names the web UA parser uses (iOS, Android, Windows, macOS, Linux), so web and app traffic group together in the Devices report. The User-Agent header is only a fallback for missing fields.

4. Authentication and abuse control

ClientHow it authenticates
Browser (web SDK)site_key + the browser's Origin, checked against the app's allowed origins (empty list = any origin).
Mobile / desktop appsite_key + no Origin header. Accepted only when the app has "Receive events from apps" (allow_native) turned on in the dashboard settings. If the app also lists allowed app ids (allowed_app_ids), the request MUST carry an app id — X-Analisa-App-Id header and/or context.app.id (batch or event) — and every app id in the request must be in that list.
Desktop webview with a custom scheme (tauri://localhost, app://, file://, null) or Tauri's Windows WebView2 host (http(s)://tauri.localhost)Treated as a native request (rule above). Any other webview served from http(s):// is a web origin: add it to allowed origins, or send from the main process.
Your backendAuthorization: Bearer <secret_key> (no site_key needed). Never ship the secret key in an app.

Be honest about what this protects. A site key inside a mobile or desktop binary is public: anyone can extract it and the app id, and replay requests without an Origin. allow_native and allowed_app_ids are spam mitigation of the same strength as the Origin check on the web (which curl also bypasses): they stop drive-by junk and other apps reusing your key by accident, not a determined attacker. Keep allow_native off for apps that are web-only.

Error responses (JSON, ok: false):

StatuserrorMeaningSDK action
400invalid_payloadBody is not JSON or batch is missing/emptyDrop the batch (it will never succeed), log in debug
400site_key_requiredNo site_key and no BearerDrop, disable sending (misconfiguration)
401unauthorizedBad Bearer secretDrop, disable sending
402quota_exceeded / subscription_inactivePlan limitDrop the batch; pause sending for 1 h
403native_not_allowedNo Origin and allow_native is offDrop, disable sending for this process; log a clear message ("enable 'Receive events from apps' in the dashboard")
403app_id_required / app_id_not_allowedallowed_app_ids check failedDrop, disable sending; log
403origin_not_allowedWeb origin not allowedDrop
403site_inactiveApp paused/disabledDrop, disable sending
404site_not_foundUnknown site keyDrop, disable sending
413payload_too_large / batch_too_largeSee §2.4Split the batch in half and resend; a single event that is still too large is dropped
429, 5xx, network error, timeout—TransientKeep the batch and retry with backoff (§7)

Success: 200 {"ok":true,"received":N}. N can be lower than the batch size (duplicates removed); that is still success — remove the whole batch from the queue.

5. Event catalog

All names below are reserved. Anything else is a custom event (track(name, props)).

5.1 Screen views — page_view

Apps record screens as page_view so Pages ("Pages and screens"), Page flow, Funnels and Goals work the same for web and apps:

json
{ "id": "…", "event": "page_view", "ts": 1767225599000, "anon_id": "…", "session_id": "…",
  "path": "/product/detail", "props": { "screen_name": "ProductDetail" } }
  • path MUST be route-like: starts with /, lower-case, - between words, no ids (/product/detail, not /product/42). If the app has a router with paths (Expo Router, React Navigation linking, go_router, Compose Navigation routes), send the route pattern; otherwise derive it from the screen name: ProductDetail → /product-detail.
  • props.screen_name SHOULD carry the human screen name.
  • Put ids in props (product_id), never in path, to keep the Pages report aggregated.
  • Send one page_view per screen appearance. Do not resend when the same screen re-renders or when the app returns from background to the same screen.

Shortcut: {"event":"screen_view","screen_name":"ProductDetail"} (or props.screen / props.screen_name) is accepted and stored as a page_view with path derived as above when path is absent. New SDKs SHOULD send page_view directly.

5.2 Lifecycle (apps)

EventWhenprops
app_openCold start (process launch), and when the app comes to the foreground and a new session starts (§6.2)launch: "cold" | "warm"; first_launch: true only on the very first launch after install (otherwise omit or false)
app_foregroundEvery transition background → foregroundbackground_ms: time spent in background
app_backgroundEvery transition foreground → backgroundforeground_ms: time since the last foreground/open
app_updateFirst launch after app.version or app.build changed (send it before that launch's app_open)previous_version, previous_build

Order on a cold start after an update: app_update → app_open {launch:"cold"} → first page_view. On the first ever launch: app_open {launch:"cold", first_launch:true} (no app_update). Desktop SDKs map "background" to all windows hidden/minimized (or app deactivated on macOS) and MAY skip app_foreground/app_background when that is not observable.

5.3 Errors and crashes — error

Same shape as the web SDK, so the Errors report groups them:

json
{ "event": "error", "props": {
    "error_type": "NSInvalidArgumentException",
    "error_message": "unrecognized selector sent to instance",
    "error_stack": "0 CoreFoundation …\n1 libobjc.A.dylib …",
    "error_kind": "crash",
    "error_handled": false,
    "error_fingerprint": "a1b2c3d4"
} }
PropNotes
error_typeException class / signal (SIGSEGV, java.lang.NullPointerException, TypeError)
error_messageMessage, max ~1000 chars
error_stackStack trace, max ~8 KB; strip absolute file-system paths and user data
error_kindcrash (process died: uncaught exception or fatal signal), anr (Android not responding), unhandled (uncaught but app survived, e.g. JS/Dart error handler), handled (captureException)
error_handledtrue only for handled
error_fingerprintStable group id: hash of error_type + the first 3 in-app frames without line numbers/addresses, so the same bug groups across builds. Max 64 chars.

Crashes are usually sent on the next launch: persist the crash report to disk in the crash handler (do no network I/O there), and on the next launch enqueue it with the crash's own ts, session_id and context (the version that crashed), before app_open. The dashboard computes crash-free sessions per app version from error_kind = "crash".

5.4 Identify — identify

json
{ "id": "…", "event": "identify", "user_id": "user_123",
  "props": { "email": "ana@shop.com", "plan": "pro", "id_prop": "email" } }
  • user_id (top level) is required. props are traits; id_prop (optional) names the trait the dashboard uses as the person's label.
  • After identify, every following event MUST carry the same user_id (persist it across launches) until reset().
  • reset() (logout): clear user_id, start a new session and keep anon_id.
  • Do not send identify again for the same user_id within 5 minutes.

5.5 Custom events

Any other name: { "event": "add_to_cart", "props": { "product_id": "42", "price": 99.9 } }. The web SDK's helpers (purchase, signup, login, search, …) define recommended names and props; native SDKs SHOULD expose the same helpers with the same names.

6. Identity and sessions

6.1 anon_id

  • UUID v4, generated on first launch, persisted in app storage (UserDefaults, SharedPreferences / DataStore, localStorage in Electron/Tauri, a file in the app data dir).
  • Never derived from hardware ids, advertising ids (IDFA/GAID) or anything that survives reinstall. A reinstall produces a new anon_id — that is intended.
  • Sent on every event.

6.2 session_id

  • UUID v4. A session ends after 30 minutes without events.
  • Before stamping any event, the SDK checks now - last_event_at > 30 min (wall clock); if so (or if there is no session yet) it creates a new session_id. last_event_at is updated for every event and persisted together with session_id, so a process restart within 30 minutes continues the same session.
  • Background → foreground: if the app was in background for more than 30 minutes, the next event starts a new session, and the SDK emits app_open {launch:"warm"} as its first event. Otherwise it emits only app_foreground and the session continues.
  • reset() always starts a new session.
  • Lifecycle events count as activity (an app_background updates last_event_at).

6.3 user_id

Set by identify, persisted, cleared by reset() (§5.4).

SDKs MUST offer setConsent('denied') / optOut() that stops collection, clears the queue and the persisted user_id, and survives restarts. Respect platform tracking settings where they apply. Do not collect IDFA/GAID, IP-derived precise location, contacts or any personal data by default.

7. Delivery: queue, batching, retry

  • Persistent queue. Events are appended to an on-disk queue (file, SQLite, MMKV, localStorage…) so they survive app kills. Cap it at 1000 events; when full, drop the oldest. Drop events older than 7 days at send time.
  • Flush triggers. Flush when the queue reaches 20 events, every 10 s while the app is in the foreground, on app_background (iOS: inside beginBackgroundTask; Android: from ProcessLifecycleOwner ON_STOP, using WorkManager if the send cannot finish), and at launch for anything left from the previous run. Only one request in flight at a time.
  • Batch. Up to 50 events per request (§2.4). Set sent_at on every attempt.
  • Retry. On network error, timeout (use 10 s), 429 or 5xx: keep the batch, retry with exponential backoff min(2^attempt × 1 s, 5 min) with ±20 % jitter. Honor Retry-After when present. Reset the backoff after a success. Because every event has an id, retrying a batch that was in fact stored is harmless.
  • Do not retry other 4xx (§4 table). On 413, split the batch.
  • Offline. Do not attempt sends while the OS reports no connectivity; flush when it returns.
  • Never block the UI thread and never throw into the host app.

8. Privacy guarantees the server applies

  • The client IP is used only for country lookup and stored anonymized (last IPv4 octet / last 80 bits of IPv6 zeroed). Do not send IPs in props.
  • No fingerprinting: the server never combines IP/UA into an identifier.
  • Delete requests (GDPR) work by anon_id / user_id through the dashboard.

9. Example: plain HTTP

bash
curl -X POST https://function-bun-production-3339.up.railway.app/v1/i \
  -H 'Content-Type: application/json' \
  -H 'User-Agent: ShopApp/2.3.0 (Android 14; SM-S918B)' \
  -H 'X-Analisa-App-Id: com.shop.app' \
  -d '{
    "site_key": "pk_live_abc123",
    "sent_at": 1767225600123,
    "context": {
      "platform": "android",
      "app": { "version": "2.3.0", "build": "451", "id": "com.shop.app" },
      "os": { "name": "Android", "version": "14" },
      "device": { "model": "SM-S918B", "manufacturer": "samsung", "type": "phone" },
      "locale": "pt-BR", "tz": "America/Sao_Paulo",
      "screen": { "w": 384, "h": 854, "density": 2.8125 },
      "sdk": { "name": "curl", "version": "1" }, "framework": "kotlin"
    },
    "batch": [
      { "id": "b6f1c9a4-0d0e-4f57-9a3b-6a5f3f7f2b10", "event": "app_open", "ts": 1767225599000,
        "anon_id": "c0a8012e-5f4b-4d4e-8f7a-1b2c3d4e5f60", "session_id": "0f9e8d7c-6b5a-4f3e-8d2c-1b0a9f8e7d6c",
        "props": { "launch": "cold" } },
      { "id": "0b8e7c4f-2a6d-4c51-8f0b-9d3e2a1c5b77", "event": "page_view", "ts": 1767225599500,
        "anon_id": "c0a8012e-5f4b-4d4e-8f7a-1b2c3d4e5f60", "session_id": "0f9e8d7c-6b5a-4f3e-8d2c-1b0a9f8e7d6c",
        "path": "/home", "props": { "screen_name": "Home" } }
    ]
  }'
# → {"ok":true,"received":2}

10. Minimal contract for a new SDK (checklist)

  1. init(siteKey, { endpoint?, debug? }); never throws, never blocks the UI thread.
  2. Persist anon_id; manage session_id with the 30-minute rule and foreground handling (§6).
  3. Build context once per launch (platform, app, os, device, locale, tz, screen, sdk, framework) and send it as batch context; refresh locale/tz/screen on change.
  4. Every event: id (UUID), event, ts, anon_id, session_id, user_id when identified.
  5. API: track(name, props), screen(name, props?) → page_view (§5.1), `identify(userId, traits?, idProp?), reset(), captureException(error, props?), setConsent(...), flush()`.
  6. Automatic: app_open, app_update, app_foreground, app_background (§5.2) and crash capture (§5.3), each switchable off in the config.
  7. Persistent queue, batching, backoff and the response handling table (§4, §7).
  8. No Origin header, no secret key, no personal data by default.
  9. Test against a real ingestor: events appear in the dashboard under the right platform, versions show in "App versions", a forced crash appears in Errors with error_kind: "crash".
Mobile and desktop app analytics · Analisa