Mobile applications cannot keep a GraphJSON workspace API key secret. A user can inspect the binary, device storage, and network traffic.
Every mobile implementation should send a small public event envelope to an endpoint you control:
iOS, Android, or React Native app
│
│ public first-party event contract
▼
your mobile collection endpoint
├─ authenticate or rate-limit
├─ allowlist events and properties
├─ derive trusted account fields
├─ attach the GraphJSON API key
└─ deliver to GraphJSON
The examples below are reference patterns, not official GraphJSON SDKs. Adapt persistence, background execution, and consent behavior to the supported operating-system versions in your application.
Use one portable envelope#
{
"event": "report_exported",
"event_id": "evt_01J...",
"occurred_at": 1785081600,
"anonymous_id": "anon_01J...",
"user_id": "usr_42",
"session_id": "ses_01J...",
"app_version": "4.8.0",
"build_number": "842",
"platform": "ios",
"schema_version": 1,
"properties": {
"report_type": "retention",
"row_count": 18420
}
}
Rules:
event_idis created once and survives retriesoccurred_atis whole Unix seconds from the device occurrence time- IDs are opaque and contain no email address
- event names and properties are allowlisted
- app version and build are attached centrally
- the queue stores the original envelope
Your server converts the envelope into the GraphJSON logging request and uses occurred_at as the request timestamp.
Classify events by durability#
| Class | Examples | Delivery policy |
|---|---|---|
| Critical analytical fact | Purchase confirmed, subscription changed | Server-originated where possible; durable ledger or outbox |
| Important product event | Onboarding completed, report exported | Durable local queue, bounded retry |
| Best-effort interaction | Screen viewed, tooltip opened | Bounded queue; may expire deliberately |
| Diagnostic | Queue dropped events, sync failed | Separate controlled event or operational log |
A client-side tap is not proof that a payment, upload, or subscription succeeded. Emit authoritative facts from the server that completed them.
Persist before sending#
A durable queue record should contain:
event_id
serialized envelope
created_at
attempt_count
next_attempt_at
expires_at
last_error_class
Required operations:
enqueue atomically
claim a bounded batch
mark attempt
delete only after accepted
schedule retry
expire permitted low-value events
count drops
Do not use an unbounded array in preferences storage. Use the platform database already approved by the application, such as SQLite or an equivalent durable store.
iOS transport pattern#
import Foundation
struct AnalyticsEnvelope: Codable {
let event: String
let eventId: String
let occurredAt: Int
let anonymousId: String
let userId: String?
let sessionId: String
let appVersion: String
let buildNumber: String
let platform: String
let schemaVersion: Int
let properties: [String: JSONValue]
enum CodingKeys: String, CodingKey {
case event
case eventId = "event_id"
case occurredAt = "occurred_at"
case anonymousId = "anonymous_id"
case userId = "user_id"
case sessionId = "session_id"
case appVersion = "app_version"
case buildNumber = "build_number"
case platform
case schemaVersion = "schema_version"
case properties
}
}
enum JSONValue: Codable {
case string(String)
case number(Double)
case bool(Bool)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else {
self = .string(try container.decode(String.self))
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let value): try container.encode(value)
case .number(let value): try container.encode(value)
case .bool(let value): try container.encode(value)
}
}
}
Transport one claimed batch to your endpoint:
func sendBatch(_ events: [AnalyticsEnvelope]) async throws {
var request = URLRequest(
url: URL(string: "https://app.example.com/api/mobile-events")!
)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(["events": events])
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw AnalyticsError.deliveryRejected
}
}
Delete queue records only after the endpoint returns success. Use an OS-supported background task to request flush time, but assume the operating system can suspend or terminate the app before it runs.
Do not wait for analytics delivery before completing navigation.
Android transport pattern#
Define the envelope:
@kotlinx.serialization.Serializable
data class AnalyticsEnvelope(
val event: String,
@kotlinx.serialization.SerialName("event_id")
val eventId: String,
@kotlinx.serialization.SerialName("occurred_at")
val occurredAt: Long,
@kotlinx.serialization.SerialName("anonymous_id")
val anonymousId: String,
@kotlinx.serialization.SerialName("user_id")
val userId: String? = null,
@kotlinx.serialization.SerialName("session_id")
val sessionId: String,
@kotlinx.serialization.SerialName("app_version")
val appVersion: String,
@kotlinx.serialization.SerialName("build_number")
val buildNumber: String,
val platform: String = "android",
@kotlinx.serialization.SerialName("schema_version")
val schemaVersion: Int = 1,
val properties: Map<String, String>
)
Use a durable database table with a unique index on event_id. Schedule a constrained background worker:
val request = OneTimeWorkRequestBuilder<AnalyticsFlushWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
"analytics-flush",
ExistingWorkPolicy.KEEP,
request
)
The worker:
- claims a bounded batch
- posts it to the first-party endpoint
- deletes accepted rows
- returns
Result.retry()for transient failures - quarantines permanent validation failures
Do not schedule one background job per event.
React Native transport pattern#
Create the envelope centrally:
type AnalyticsEnvelope = {
event: string;
event_id: string;
occurred_at: number;
anonymous_id: string;
user_id?: string;
session_id: string;
app_version: string;
build_number: string;
platform: "ios" | "android";
schema_version: 1;
properties: Record<string, string | number | boolean>;
};
function createEvent(
event: string,
properties: AnalyticsEnvelope["properties"]
): AnalyticsEnvelope {
return {
event,
event_id: createUuid(),
occurred_at: Math.floor(Date.now() / 1000),
anonymous_id: getAnonymousId(),
user_id: getSignedInUserId(),
session_id: getSessionId(),
app_version: getAppVersion(),
build_number: getBuildNumber(),
platform: Platform.OS,
schema_version: 1,
properties
};
}
In this example, createUuid() comes from your app's approved
cryptographically secure UUID library. Do not generate event IDs with a
timestamp or an incrementing counter.
Persist the queue through an approved durable storage library. Protect updates with a single queue module so simultaneous app events do not overwrite each other.
Flush:
async function deliver(events: AnalyticsEnvelope[]) {
const response = await fetch(
"https://app.example.com/api/mobile-events",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events })
}
);
if (!response.ok) {
throw new Error(`analytics delivery failed: ${response.status}`);
}
}
An AppState transition can request a flush, but it does not guarantee completion. Persist first.
Build the first-party endpoint#
The endpoint accepts the portable envelope, not arbitrary GraphJSON requests:
const allowedEvents = new Set([
"screen_viewed",
"onboarding_completed",
"report_exported"
]);
function validate(input: any) {
if (!allowedEvents.has(input.event)) {
throw new Error("unsupported event");
}
if (typeof input.event_id !== "string" || input.event_id.length > 100) {
throw new Error("invalid event_id");
}
if (!Number.isInteger(input.occurred_at)) {
throw new Error("invalid occurred_at");
}
}
The server should:
- cap body and batch size
- authenticate signed-in users where possible
- rate-limit anonymous devices
- derive trusted
user_idandaccount_id - overwrite platform fields that can be derived from trusted context
- remove forbidden properties
- send at most 50 events per GraphJSON bulk request
- return one clear accepted/rejected result
Never accept a client-selected GraphJSON collection or API key.
Handle clock errors#
Device clocks can be wrong.
Record:
{
"occurred_at": 1785081600,
"client_sent_at": 1785081660
}
At the server, compare receipt time with client time. Define a safety policy for implausibly old or future timestamps:
- reject
- quarantine
- or clamp with an explicit
timestamp_correctedfield
Do not silently rewrite every offline event to receipt time.
Consent changes#
Centralize consent:
consent denied
→ do not enqueue
→ remove locally queued events no longer permitted
→ clear analytics identity where policy requires it
consent granted
→ begin new permitted collection
Do not collect first and decide whether to send later if policy forbids the collection itself.
Identity transitions#
Before sign-in:
{
"anonymous_id": "anon_01J...",
"user_id": null
}
At sign-in or signup:
{
"event": "signup_completed",
"anonymous_id": "anon_01J...",
"user_id": "usr_42"
}
GraphJSON does not merge identities. Your event contract and SQL decide whether pre-login history joins to the authenticated person.
On sign-out, decide whether the anonymous ID is retained, rotated, or cleared. Make the rule consistent across platforms.
Release-aware analytics#
Attach:
- app version
- build number
- platform
- schema version
- feature or experiment assignment where relevant
Mobile versions can remain active for months. Do not assume a schema migration reaches every device immediately.
Write queries that support the active schema window, and use Release impact analytics to compare behavior and failure rates by release.
Test matrix#
For every platform:
- clean install
- upgrade from the oldest supported version
- offline launch and later reconnect
- app backgrounded during delivery
- process terminated with queued events
- duplicate retry
- expired low-value event
- malformed property
- consent denied, granted, and withdrawn
- anonymous-to-authenticated transition
- device clock far ahead and behind
- server returns
400,429, and500
Verify:
- same
event_idafter retry - original timestamp after offline delivery
- no API key in binary or network request
- no disallowed event enters the queue
- accepted rows disappear from local storage
- permanent failures do not retry forever
Launch checklist#
- GraphJSON key exists only on the server.
- Public event contract is allowlisted and versioned.
- Important events enter durable local storage.
- Queue is bounded by age and size.
- Event IDs and timestamps survive retries.
- Background flushing is opportunistic, not assumed.
- Critical business facts are server-originated.
- Consent controls collection and persistence.
- Identity transitions are explicit.
- Version and build are attached centrally.
- All supported platforms pass the offline and upgrade matrix.
Continue with Collect browser and mobile events safely, Reliable event delivery, and Environments and instrumentation testing.