# App Store Operator Events: integration guide for React Native

> **Status:** v1 draft, 2026-10-23. The API described here is planned and not live yet. This line is removed when it ships.
>
> **Who this is for:** React Native developers adding event tracking to the iOS build of a React Native app (bare React Native or Expo), and the AI coding agents they hand this document to. It is self-contained: an agent can complete the integration from this file alone. App Store Operator covers iOS apps for now: on Android the tracker in this guide does nothing (Android support is planned).

---

## For AI coding agents: read this first

You are adding event tracking to the iOS build of a React Native app so its owner can build funnels in App Store Operator. The app will send events to `POST https://api.app-store-operator.com/v1/events` with the header `X-Write-Key`.

**Steps, in order**

1. **Get the write key.** If this document still contains the text `YOUR_WRITE_KEY`, ask the developer for their key (Dashboard → their app → Events → Setup; it starts with `aso_wk_`). Never invent a key or leave the placeholder in code.
2. **Add the tracker.** Install the two packages listed in [§5](#5-reference-implementation), then copy the file in §5 into the app's source as `eventTracker.ts`, unchanged.
3. **Wire it up** as in [§5.1](#51-wire-it-up): `configure(...)` once at launch, `EventTracker.flush()` when the app moves to background, and `app_opened` at launch and on every return from background.
4. **Add `track(...)` calls** for each event in [§6](#6-what-to-track) whose flow exists in this app. Skip events whose flow does not exist.
5. **Add `identify` and `reset`** only if the app has its own sign-in ([§5.2](#52-track-events)).
6. **Finish** with [§9 Done checklist](#9-done-checklist): report every item as done, not applicable, or pending, with the file and line that shows it.

**Rules**

- Do not change the behavior of `eventTracker.ts` (batching, retries, endpoint, queue, the iOS-only check). If it does not fit the app, report why instead of rewriting it.
- Do not add any other third-party dependency: only `@react-native-async-storage/async-storage` and `react-native-device-info`. If the app already has them, keep the versions it has.
- The tracker does nothing on Android by design. Do not add Android-specific code for it or work around the check.
- Do not send directly identifying data: names, emails, phone numbers, precise location, IDFA, or text the user typed ([§7](#7-privacy)). If the app is in the App Store Kids Category, stop and tell the developer: this tracker must not be added ([§7.8](#78-apps-for-children)). If the app asks users for consent to analytics, ask the developer whether tracking must wait for it ([§7.5](#75-your-privacy-policy-and-consent)).
- Property values are flat: string, number, boolean or `null`. No nested objects or arrays ([§4.3](#43-event-object)). The tracker's types enforce this; do not cast around them.
- Use the event names in §6 exactly as written. Ask the developer before naming an event for a flow §6 does not cover.
- Call `identify` only with the app's own stable internal user ID, never an email, phone number or name. If you cannot tell which ID that is, ask.
- **The API is not live yet** while the status line at the top of this file says so. Then build and wire everything, but report the network checks in §9 as pending; a failed request is not a bug in your code.

The write key is public by design (it can only write events for one app), so it may live in source code or in the app's config.

---

## 1. Quick facts

| | |
|---|---|
| Base URL | `https://api.app-store-operator.com/v1` |
| Auth | Header `X-Write-Key: <your write key>` (Dashboard → your app → Events → Setup) |
| Send events | `POST /v1/events` (batch of 1–100) |
| Link a user | Automatic when an event carries both `anonymous_id` and `user_id`; or `POST /v1/identify` |
| Content type | `application/json`, UTF-8 |
| Rate limit | 10,000 events per minute per write key (all installs together) and 300 per minute per install (`anonymous_id`) |
| Minimum for the reference code | React Native 0.76 or Expo SDK 52, iOS build only; TypeScript or JavaScript. Expo needs a development build, not Expo Go |
| Data retention | 13 months |

## 2. How it works

1. Your app creates a random **anonymous ID** on first launch and keeps it.
2. Every action you care about becomes an **event**: a name, a time, optional properties and some device context.
3. Events are queued on the device and sent in **batches**. If the device is offline, they wait and are sent later.
4. When the user signs in to *your* app, you may set a **user ID**. From then on events carry both IDs, and App Store Operator treats the earlier anonymous events and the later signed-in events as one person.
5. In the dashboard you see events arrive live, and you build **funnels** from them: ordered steps such as `onboarding_started → paywall_viewed → purchase_completed`, counted as unique people.

## 3. Get your write key

1. Open the App Store Operator dashboard → **Apps** → your app → **Events** → **Setup**.
2. Copy the write key (it starts with `aso_wk_`).
3. Test it from a terminal (macOS or Linux). The command creates a new `event_id` and the current time on every run, so you can run it again:

```bash
curl -sS https://api.app-store-operator.com/v1/events \
  -H "Content-Type: application/json" \
  -H "X-Write-Key: YOUR_WRITE_KEY" \
  -d '{
    "events": [{
      "event_id": "'"$(uuidgen)"'",
      "name": "integration_test",
      "anonymous_id": "curl-test",
      "occurred_at": "'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'",
      "properties": { "source": "curl" }
    }]
  }'
```

Expected response: `accepted` is `1` and `rejected` is empty. The event appears under **Events → Live** within a few seconds.

Why fresh values: a repeated `event_id` is stored only once, so a second run with the same ID shows nothing new in Live, and a time more than 7 days old is replaced by the time we received it.

### 3.1 Rotating the key

Rotate the key only if it is being abused, for example if someone is sending fake events with it. The key is public, so being visible in your app is not a reason to rotate.

1. Setup → **Rotate key**. Any member of the workspace can do this, and it is recorded.
2. Put the new key in your app and ship an update.
3. The old key keeps working for **30 days**, so your users have time to update. After that, builds that still use the old key get `401` and the reference code drops their events. People on those builds are not counted until they update.
4. If the old key is still being abused, Setup → **Revoke old key now** ends its 30 days at once. From then on, builds that use the old key are not counted. Any member of the workspace can do this, and it is recorded.
5. While an old key is in its 30 days, **Rotate key** is disabled. To rotate again, revoke the old key first or wait until its 30 days end.

## 4. API reference

### 4.1 `POST /v1/events`

Send 1 to 100 events.

**Headers**

| Header | Required | Value |
|---|---|---|
| `Content-Type` | yes | `application/json` |
| `X-Write-Key` | yes | your app's write key |

**Body**

```json
{
  "events": [ <event object>, ... ]
}
```

The whole body must be at most **1 MB**. Unknown fields, in the body or in an event, are ignored.

### 4.2 Response

`200 OK` whenever the batch itself is valid, even if some events were rejected:

```json
{
  "accepted": 2,
  "rejected": [
    { "index": 2, "event_id": "…", "code": "invalid_name", "message": "Event name must match ^[A-Za-z0-9_.:-]{1,64}$" }
  ],
  "clamped": [
    { "index": 0, "event_id": "…" }
  ]
}
```

- `accepted`: events stored, including duplicates that were already stored (see `event_id`).
- `rejected`: events that were not stored and must not be retried unchanged.
- `clamped`: events whose `occurred_at` was out of range and was replaced by the server's receive time. They are stored and counted in `accepted`.

All three fields are always present; `rejected` and `clamped` may be empty.

### 4.3 Event object

| Field | Type | Required | Rules |
|---|---|---|---|
| `event_id` | string (UUID) | yes | Unique per event. Generate it when the event is created, not when it is sent, so a retried batch is not counted twice. |
| `name` | string | yes | `^[A-Za-z0-9_.:-]{1,64}$`. A `$` followed by 1–63 of those characters (e.g. `$session_start`) is reserved for App Store Operator and rejected with `reserved_name`; any other invalid name is `invalid_name`. Use `snake_case` `object_action`, e.g. `paywall_viewed`. |
| `anonymous_id` | string | yes | 1–128 chars. Stable per install. |
| `user_id` | string or null | no | 1–128 chars. Your own stable user ID. Never an email or phone number. |
| `occurred_at` | string | yes | ISO 8601 with timezone, e.g. `2026-10-23T12:00:00.123Z`. Accepted from 7 days in the past to 5 minutes in the future. |
| `properties` | object | no | Flat object, at most 50 keys. Key: 1–64 chars. Value: string (≤ 1024 chars), number, boolean or `null`. **No nested objects or arrays.** |
| `context` | object | no | See below. Recommended: the dashboard filters and breaks funnels down by these fields. |

**`context`**

| Field | Example | Notes |
|---|---|---|
| `app_version` | `"2.4.0"` | `CFBundleShortVersionString` |
| `build` | `"412"` | `CFBundleVersion` |
| `os_version` | `"18.1"` | |
| `device_model` | `"iPhone16,2"` | Hardware identifier, not a marketing name |
| `locale` | `"en_US"` | The device locale, `language_REGION` |
| `environment` | `"app_store"` | `"debug"`, `"testflight"` or `"app_store"`. The dashboard shows only `app_store` events (and events without it) unless you choose otherwise |

Every `context` value is a string of at most 64 characters. A value that breaks this, and any key not in this table, is dropped without rejecting the event.

The server adds `country` from the request's network location. The IP address itself is never stored.

A serialized event must be at most **8 KB**.

**Full example**

```json
{
  "event_id": "0b6f6a3e-3c3e-4d8a-8f6e-1f9b8a2c7d10",
  "name": "purchase_completed",
  "anonymous_id": "C5E1B0B2-4F7A-4E0F-9C1D-2A6B7E8F9012",
  "user_id": "u_81723",
  "occurred_at": "2026-10-23T12:04:31.512Z",
  "properties": {
    "product_id": "pro_yearly",
    "price": 39.99,
    "currency": "USD",
    "trial": true
  },
  "context": {
    "app_version": "2.4.0",
    "build": "412",
    "os_version": "18.1",
    "device_model": "iPhone16,2",
    "locale": "en_US",
    "environment": "app_store"
  }
}
```

### 4.4 `POST /v1/identify` (optional)

Links an anonymous ID to a user ID without sending an event. You rarely need it: any event that carries both IDs does the same. Use it when the link is known on your server.

```json
{ "anonymous_id": "C5E1B0B2-4F7A-4E0F-9C1D-2A6B7E8F9012", "user_id": "u_81723" }
```

Response: `204 No Content`. The first link for an anonymous ID wins; later different user IDs for the same anonymous ID are ignored.

### 4.5 Errors

| Status | `code` | Meaning | Client action |
|---|---|---|---|
| 400 | `invalid_body` | Body is not valid JSON or not `{ "events": [...] }`, or 0 / more than 100 events | Fix the code; do not retry |
| 401 | `invalid_write_key` | Missing, unknown or revoked key | Do not retry; drop the batch and check the key |
| 413 | `payload_too_large` | Body over 1 MB | Send smaller batches |
| 429 | `rate_limited` | Over 10,000 events per minute for the key, or 300 for one `anonymous_id`. Nothing in the request was stored | Keep the batch; retry after `Retry-After` seconds |
| 5xx | `server_error` | Our fault | Keep the batch; retry with backoff |

Error body: `{ "code": "invalid_write_key", "message": "…" }`.

**Per-event rejection codes** (inside a `200` response):

| Code | When |
|---|---|
| `invalid_event` | The item in `events` is not a JSON object |
| `invalid_name` | `name` missing, or not matching `^[A-Za-z0-9_.:-]{1,64}$` |
| `reserved_name` | `name` is `$` followed by 1–63 characters from `[A-Za-z0-9_.:-]` |
| `invalid_id` | `event_id` missing or not a UUID; `anonymous_id` missing or not a 1–128 char string; `user_id` present but not a 1–128 char string or `null` |
| `invalid_timestamp` | `occurred_at` missing, unparseable, or without a timezone. (Out of range is not a rejection: see `clamped`) |
| `invalid_properties` | `properties` not a flat object; a key outside 1–64 chars; a nested object or array; a string over 1,024 chars |
| `too_many_properties` | More than 50 properties |
| `event_too_large` | The serialized event is over 8 KB |

**Retry rule in one line:** retry on network failure, `429` and `5xx`, keeping the same `event_id`s; drop on every other status.

## 5. Reference implementation

One TypeScript file for the iOS build of a bare React Native or Expo app. Add it to the app's source as `eventTracker.ts` and use it unchanged. It needs two packages and nothing else.

**Install the packages**

Bare React Native:

```bash
npm install @react-native-async-storage/async-storage react-native-device-info
npx pod-install
```

Expo:

```bash
npx expo install @react-native-async-storage/async-storage react-native-device-info
npx expo prebuild
```

Both packages contain native code. In Expo they work in a development build or after `npx expo prebuild`, not in Expo Go, which does not include `react-native-device-info`. Rebuild the iOS app after installing them. If the app already uses either package, keep its version: the tracker uses only `getItem`, `setItem` and `removeItem` from `@react-native-async-storage/async-storage` (version 2 or 3) and `getVersion`, `getBuildNumber`, `getSystemVersion` and `getDeviceId` from `react-native-device-info`.

What it does:

- Runs on iOS only. On any other platform (`Platform.OS !== "ios"`) every method does nothing, so Android installs do not mix into App Store funnels.
- Creates a random anonymous ID (UUID v4) with the first tracked event and keeps it (in AsyncStorage). `currentAnonymousId()` returns it.
- Has an on/off switch, `setEnabled(...)`, remembered across launches and on by default. Turning it off deletes the IDs and every unsent event, and `track` does nothing until it is turned on again.
- Saves every event to a queue in AsyncStorage, so events survive the app being closed.
- Sends the queue in batches of up to 100: every 30 seconds, when 20 or more events are waiting, and when you call `flush()`.
- Offline or `5xx`: keeps the queue and waits before trying again: 30 seconds, then 60, 120… up to 10 minutes. `429`: keeps the queue and waits as long as the server's `Retry-After` says. Any other response: removes the batch from the queue.
- Keeps at most 1,000 events; past that, the oldest are dropped.
- Development builds only (`__DEV__`): logs rejected and clamped events, and dropped batches, to the Metro / JS console as `[EventTracker] …`. Release builds log nothing.
- Marks each event from a development build as `debug`, so test runs stay out of your funnels. A release build cannot tell TestFlight from the App Store without native code, so `configure` takes an optional `environment` (`"testflight"` or `"app_store"`) for apps whose build config already knows it ([§5.1](#51-wire-it-up)). Without it, release events carry no environment and the dashboard counts them as App Store events.
- `flush()` sends the queue when the app moves to background. iOS gives the app a few seconds there; anything not sent by then stays in the queue and goes out on the next launch.

`track` works before `configure` and before the stored state has loaded from AsyncStorage; events wait in memory, then in the queue, until a key is set.

**One app, one key.** Each app in the dashboard has its own write key. Use each key only in that app's binary.

**Not in app extensions.** Extensions (widgets, share, notification extensions) keep their own storage, so each would get its own anonymous ID and count as a separate person. Track from the main app's JavaScript only.

```typescript
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
import DeviceInfo from "react-native-device-info";

/** Sends events to App Store Operator. Docs: https://app-store-operator.com/docs/events/react-native */

/** A property value. Flat only: no objects or arrays. */
export type EventValue = string | number | boolean | null;
export type EventProperties = Record<string, EventValue>;

export type EventEnvironment = "testflight" | "app_store";

type Context = {
  app_version: string;
  build: string;
  os_version: string;
  device_model: string;
  locale: string;
  environment?: string;
};

type Event = {
  event_id: string;
  name: string;
  anonymous_id: string;
  user_id: string | null;
  occurred_at: string;
  properties: EventProperties;
  context: Context;
};

const ENDPOINT = "https://api.app-store-operator.com/v1/events";
const MAX_BATCH = 100;
const MAX_QUEUE = 1_000;
const FLUSH_AT = 20;
const FLUSH_INTERVAL_MS = 30_000;
const REQUEST_TIMEOUT_MS = 60_000;
const ANONYMOUS_ID_KEY = "aso.events.anonymousId";
const USER_ID_KEY = "aso.events.userId";
const DISABLED_KEY = "aso.events.disabled";
const QUEUE_KEY = "aso.events.queue";

// App Store Operator covers iOS apps. On every other platform each method does nothing.
const SUPPORTED = Platform.OS === "ios";

class Tracker {
  private writeKey: string | null = null;
  private environment: string | undefined = __DEV__ ? "debug" : undefined;
  private isEnabled = true;
  private anonymousId: string | null = null;
  private userId: string | null = null;
  private queue: Event[] = [];
  private isFlushing = false;
  private timer: ReturnType<typeof setInterval> | null = null;
  private retryAt = 0;
  private backoffSeconds = 0;
  private cachedContext: Context | null = null;

  // Stored state is read once, asynchronously. Calls made before it is loaded wait here, in order.
  private loading: Promise<void> | null = null;
  private isLoaded = false;
  private pending: Array<() => void> = [];
  private writes: Promise<void> = Promise.resolve();
  private queueWriteScheduled = false;

  /**
   * Call once at launch. `environment` is for release builds only: pass "testflight" or "app_store"
   * if your app already knows which one it is. Development builds (`__DEV__`) are always "debug".
   */
  configure(writeKey: string, options: { environment?: EventEnvironment } = {}): void {
    if (!SUPPORTED) return;
    this.writeKey = writeKey;
    if (!__DEV__ && options.environment) this.environment = options.environment;
    void this.load();
    if (this.timer !== null) return;
    this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
  }

  /**
   * The ID of this install, or null before the first tracked event and while tracking is off.
   * Show it on a support screen so a user can ask for their data to be deleted.
   */
  async currentAnonymousId(): Promise<string | null> {
    if (!SUPPORTED) return null;
    await this.load();
    return this.anonymousId;
  }

  /**
   * Turns tracking on or off, and remembers the choice across launches. Tracking is on by default.
   * Turning it off deletes this install's IDs and every event not yet sent; `track` then does nothing.
   * If your app needs the user's consent first, call `setEnabled(false)` at launch until they agree.
   */
  setEnabled(enabled: boolean): void {
    this.whenLoaded(() => {
      this.isEnabled = enabled;
      this.write(() => (enabled ? AsyncStorage.removeItem(DISABLED_KEY) : AsyncStorage.setItem(DISABLED_KEY, "1")));
      if (enabled) return;
      this.anonymousId = null;
      this.userId = null;
      this.queue = [];
      this.write(() => AsyncStorage.removeItem(ANONYMOUS_ID_KEY));
      this.write(() => AsyncStorage.removeItem(USER_ID_KEY));
      this.write(() => AsyncStorage.removeItem(QUEUE_KEY));
    });
  }

  /** Records an event. Cheap; never blocks on the network. Does nothing while tracking is off. */
  track(name: string, properties: EventProperties = {}): void {
    if (!SUPPORTED) return;
    // The ID and time are fixed now, even if stored state is still loading.
    const eventId = uuidV4();
    const occurredAt = new Date().toISOString();
    const props = { ...properties };
    this.whenLoaded(() => {
      if (!this.isEnabled) return;
      const anonymousId = this.anonymousId ?? this.newAnonymousId();
      this.queue.push({
        event_id: eventId,
        name,
        anonymous_id: anonymousId,
        user_id: this.userId,
        occurred_at: occurredAt,
        properties: props,
        context: this.context(),
      });
      if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE);
      this.persistQueue();
      if (this.queue.length >= FLUSH_AT) void this.flush();
    });
  }

  /** Call after the user signs in to your app. Use your own stable user ID, never an email. */
  identify(userId: string): void {
    this.whenLoaded(() => {
      if (!this.isEnabled) return;
      this.userId = userId;
      this.write(() => AsyncStorage.setItem(USER_ID_KEY, userId));
    });
  }

  /** Call on sign-out: later events belong to a new anonymous person. */
  reset(): void {
    this.whenLoaded(() => {
      this.userId = null;
      this.anonymousId = null;
      this.write(() => AsyncStorage.removeItem(USER_ID_KEY));
      this.write(() => AsyncStorage.removeItem(ANONYMOUS_ID_KEY));
    });
  }

  /** Sends queued events. Does nothing while waiting out a retry delay. */
  async flush(): Promise<void> {
    if (!SUPPORTED) return;
    await this.load();
    const writeKey = this.writeKey;
    if (!this.isEnabled || this.isFlushing || writeKey === null || this.queue.length === 0) return;
    if (this.retryAt > Date.now()) return;
    this.isFlushing = true;
    try {
      while (this.queue.length > 0) {
        const batch = this.queue.slice(0, MAX_BATCH);
        const environment = this.environment;
        const events = batch.map((event) =>
          event.context.environment !== undefined || environment === undefined
            ? event
            : { ...event, context: { ...event.context, environment } },
        );

        let response: Response;
        const abort = new AbortController();
        const timeout = setTimeout(() => abort.abort(), REQUEST_TIMEOUT_MS);
        try {
          response = await fetch(ENDPOINT, {
            method: "POST",
            headers: { "Content-Type": "application/json", "X-Write-Key": writeKey },
            body: JSON.stringify({ events }),
            signal: abort.signal,
          });
        } catch {
          this.delayRetry(null); // offline: keep the queue
          return;
        } finally {
          clearTimeout(timeout);
        }
        if (!this.isEnabled) return; // turned off while sending: the queue is already gone

        const status = response.status;
        if (status === 429 || status >= 500) {
          const header = response.headers.get("Retry-After");
          const retryAfter = header === null ? Number.NaN : Number(header);
          this.delayRetry(status === 429 && retryAfter >= 0 ? retryAfter : null); // keep the queue
          return;
        }
        this.retryAt = 0;
        this.backoffSeconds = 0;
        if (__DEV__) await report(status, response);
        // 2xx: stored. Other 4xx: retrying the same batch cannot succeed.
        const sent = new Set(batch.map((event) => event.event_id));
        this.queue = this.queue.filter((event) => !sent.has(event.event_id));
        this.persistQueue();
      }
    } finally {
      this.isFlushing = false;
    }
  }

  /** Waits `seconds` when the server says so, otherwise 30 s, 60 s, 120 s… up to 10 minutes. */
  private delayRetry(seconds: number | null): void {
    this.backoffSeconds = Math.min(Math.max(this.backoffSeconds * 2, 30), 600);
    this.retryAt = Date.now() + (seconds ?? this.backoffSeconds) * 1000;
  }

  private newAnonymousId(): string {
    const id = uuidV4();
    this.anonymousId = id;
    this.write(() => AsyncStorage.setItem(ANONYMOUS_ID_KEY, id));
    return id;
  }

  private context(): Context {
    this.cachedContext ??= {
      app_version: DeviceInfo.getVersion(),
      build: DeviceInfo.getBuildNumber(),
      os_version: DeviceInfo.getSystemVersion(),
      device_model: DeviceInfo.getDeviceId(), // hardware identifier, e.g. "iPhone16,2"
      locale: deviceLocale(),
    };
    return this.cachedContext;
  }

  private whenLoaded(operation: () => void): void {
    if (!SUPPORTED) return;
    if (this.isLoaded) {
      operation();
      return;
    }
    this.pending.push(operation);
    void this.load();
  }

  private load(): Promise<void> {
    this.loading ??= this.readStoredState();
    return this.loading;
  }

  private async readStoredState(): Promise<void> {
    try {
      const [disabled, anonymousId, userId, queue] = await Promise.all([
        AsyncStorage.getItem(DISABLED_KEY),
        AsyncStorage.getItem(ANONYMOUS_ID_KEY),
        AsyncStorage.getItem(USER_ID_KEY),
        AsyncStorage.getItem(QUEUE_KEY),
      ]);
      this.isEnabled = disabled === null;
      this.anonymousId = anonymousId; // created on the first tracked event
      this.userId = userId;
      const stored: unknown = queue === null ? [] : JSON.parse(queue);
      if (Array.isArray(stored)) this.queue = stored as Event[];
    } catch {
      // Unreadable storage: start with an empty queue.
    }
    this.isLoaded = true;
    // Calls made while loading run now, in order; their events go after the stored ones.
    const pending = this.pending;
    this.pending = [];
    for (const operation of pending) operation();
  }

  /** Saves the queue once per batch of changes, after the writes already waiting. */
  private persistQueue(): void {
    if (this.queueWriteScheduled) return;
    this.queueWriteScheduled = true;
    this.write(() => {
      this.queueWriteScheduled = false;
      return AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(this.queue));
    });
  }

  /** Storage writes run one after another, so they land in the order they were made. */
  private write(operation: () => Promise<void>): void {
    this.writes = this.writes.then(operation).catch(() => undefined);
  }
}

/** Development builds only: logs dropped batches and rejected or clamped events to the console. */
async function report(status: number, response: Response): Promise<void> {
  let json: Record<string, unknown> = {};
  try {
    const parsed: unknown = await response.json();
    if (parsed !== null && typeof parsed === "object") json = parsed as Record<string, unknown>;
  } catch {
    // Not JSON: log the status alone.
  }
  if (status < 200 || status >= 300) {
    console.log(`[EventTracker] HTTP ${status}, batch dropped:`, json);
    return;
  }
  for (const key of ["rejected", "clamped"]) {
    const items = json[key];
    if (Array.isArray(items) && items.length > 0) console.log(`[EventTracker] ${key}:`, items);
  }
}

/** A random UUID v4. Hermes has no crypto.randomUUID, so it is built from random bytes. */
function uuidV4(): string {
  const bytes = new Uint8Array(16);
  const crypto = (globalThis as { crypto?: { getRandomValues?: (array: Uint8Array) => Uint8Array } }).crypto;
  if (crypto?.getRandomValues) {
    crypto.getRandomValues(bytes);
  } else {
    for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
  }
  let nibble = 0;
  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
    const byte = bytes[nibble >> 1] ?? 0;
    const value = nibble % 2 === 0 ? byte >> 4 : byte & 0x0f;
    nibble++;
    return (char === "x" ? value : (value & 0x3) | 0x8).toString(16);
  });
}

/** The device locale as `language_REGION`, e.g. "en_US" (or just "en" when there is no region). */
function deviceLocale(): string {
  try {
    const tag = Intl.DateTimeFormat().resolvedOptions().locale; // e.g. "en-US", "zh-Hans-CN"
    const [language = "", ...subtags] = (tag.split("-u-")[0] ?? "").split("-");
    const region = subtags.find((part) => /^([A-Z]{2}|\d{3})$/.test(part));
    return region ? `${language}_${region}` : language;
  } catch {
    return "";
  }
}

export const EventTracker = new Tracker();
```

### 5.1 Wire it up

Configure the tracker and send the cold `app_opened` once per launch, at module scope of the app's root file, so neither runs again on a re-render or twice under `StrictMode`. Bare React Native: `App.tsx`. Expo Router: `app/_layout.tsx`.

```tsx
import { useEffect } from "react";
import { AppState } from "react-native";
import { EventTracker } from "./eventTracker";

// Module scope: runs once per launch.
EventTracker.configure("YOUR_WRITE_KEY");
EventTracker.track("app_opened", { source: "cold" });

export default function App() {
  useEffect(() => {
    let wasInBackground = false;
    const subscription = AppState.addEventListener("change", (state) => {
      if (state === "background") {
        wasInBackground = true;
        void EventTracker.flush();
      } else if (state === "active" && wasInBackground) {
        wasInBackground = false;
        EventTracker.track("app_opened", { source: "warm" });
      }
    });
    return () => subscription.remove();
  }, []);

  return <RootNavigator />;
}
```

`app_opened` is sent on returning from background only, not on every `active`: system alerts, Control Center and Face ID make the app `inactive` and then `active` again without it ever going to background.

In development, Fast Refresh can run the module scope again after you edit that file, which sends an extra cold `app_opened`. Those events are marked `debug` and stay out of the default view.

**Telling TestFlight from the App Store.** If the app's build config already knows which channel a release build is for (for example an environment variable set per EAS build profile or per Xcode scheme), pass it to `configure`. Development builds ignore it and stay `debug`.

```typescript
EventTracker.configure("YOUR_WRITE_KEY", {
  environment: IS_TESTFLIGHT_BUILD ? "testflight" : "app_store",
});
```

`IS_TESTFLIGHT_BUILD` stands for whatever the app's config provides. Without such a value, leave `environment` out.

### 5.2 Track events

```typescript
import { EventTracker } from "./eventTracker";

EventTracker.track("paywall_viewed", { placement: "onboarding" });
EventTracker.track("onboarding_completed", { duration_s: 42 });

// After sign-in / sign-out in your app:
EventTracker.identify(user.id);
EventTracker.reset();

// Consent or an in-app "Share analytics" toggle: call it at launch, before configure and the first track,
// with the user's current choice, and again whenever the choice changes.
EventTracker.setEnabled(userAllowsAnalytics);

// Support screen: show this so a user can ask for their data to be deleted (null if nothing was tracked yet).
const installId = await EventTracker.currentAnonymousId();
```

`track`, `identify`, `reset` and `setEnabled` return at once and can be called from any code, including render handlers and button callbacks. `flush` and `currentAnonymousId` return promises.

If tracking waits for consent, call `setEnabled` before the launch `app_opened` in [§5.1](#51-wire-it-up); while tracking is off, that call does nothing and no ID is created. If the choice is stored asynchronously, still call `setEnabled(false)` first at module scope and `setEnabled(true)` once the stored choice says yes: calls take effect in the order they are made.

Property values are typed `EventValue`: `string | number | boolean | null`. TypeScript rejects objects and arrays.

**Purchases.** Track purchases in the purchase flow, where the user taps buy, around the purchase call of whatever the app uses (react-native-iap, RevenueCat or another library). Do not track them in a listener or callback that also delivers restores, renewals or purchases from other devices. The wrapper below takes the app's purchase call as a function that reports one of four outcomes.

```typescript
import { EventTracker } from "./eventTracker";

export type PurchaseOutcome = "success" | "cancelled" | "pending" | "error";

/**
 * `purchase` is the app's own purchase call, mapped to an outcome:
 * completed and verified → "success"; the user cancelled → "cancelled";
 * Ask to Buy or other deferred approval → "pending"; anything else → "error" (or throw).
 */
export async function buy(
  product: { id: string; price: number; currency: string },
  startsTrial: boolean,
  purchase: () => Promise<PurchaseOutcome>,
): Promise<PurchaseOutcome> {
  const base = { product_id: product.id };
  EventTracker.track("purchase_started", base);
  let outcome: PurchaseOutcome;
  try {
    outcome = await purchase();
  } catch {
    outcome = "error";
  }
  switch (outcome) {
    case "success":
      EventTracker.track("purchase_completed", {
        ...base,
        price: product.price,
        currency: product.currency,
        trial: startsTrial,
      });
      if (startsTrial) EventTracker.track("trial_started", base);
      break;
    case "cancelled":
      EventTracker.track("purchase_failed", { ...base, reason: "cancelled" });
      break;
    case "error":
      EventTracker.track("purchase_failed", { ...base, reason: "failed" });
      break;
    case "pending":
      break; // Ask to Buy or extra approval: not completed yet, track nothing
  }
  return outcome;
}
```

Call it from the buy button with the product the paywall shows and a function that runs the library's purchase method and maps its result:

```typescript
const outcome = await buy(product, startsTrial, async () => {
  const result = await yourPurchaseCall(product.id); // the library's purchase method
  return toPurchaseOutcome(result); // "success" | "cancelled" | "pending" | "error"
});
```

`product.price` is the price as a number and `product.currency` the ISO 4217 currency code (`"USD"`), both from the library's product object. `startsTrial` is whatever your paywall already knows: true when this purchase starts a free trial. Many libraries report a user cancel as a thrown error with a cancel flag or error code; map that to `"cancelled"`, not `"error"`. If the library reports the result only through a purchase listener, track `purchase_completed` only for the purchase the user just started in this flow.

## 6. What to track

Funnels are only as good as the events behind them. Start with the steps below and add more as questions come up.

**Naming rules**

- `snake_case`, `object_action`, past tense: `paywall_viewed`, `purchase_completed`.
- One name per action. Put variations in properties (`paywall_viewed` with `placement: "settings"`), not in the name (`settings_paywall_viewed`).
- Never put IDs, dates or user data in the name.
- Where you want to split a funnel by a property, give it a short list of fixed values (`plan: "yearly"`), not free text. The dashboard shows the top 10 values and groups the rest as "Other".
- Names and property values are case-sensitive: `Paywall_Viewed` and `paywall_viewed` are two different events.

**Recommended starter set**

Track the events whose flow exists in your app; skip the rest. Properties marked *optional* can be left out.

| Event | When | Properties |
|---|---|---|
| `app_opened` | App launch and every return from background (the [§5.1](#51-wire-it-up) wiring sends it) | `source` (`"cold"` launch, `"warm"` return) |
| `onboarding_started` | First onboarding screen shown | |
| `onboarding_step_viewed` | Each onboarding screen, including the first | `step` (number, from 1), `step_name` |
| `onboarding_completed` | Last onboarding step done | `duration_s` *optional* (number) |
| `signup_completed` | Account created in your app | `method` (`"apple"`, `"google"`, `"email"`) |
| `paywall_viewed` | Paywall shown | `placement` (where it was shown: `"onboarding"`, `"settings"`, `"feature_gate"`), `paywall_id` *optional* |
| `purchase_started` | User taps buy | `product_id` |
| `purchase_completed` | Purchase succeeded and verified, in the purchase flow ([§5.2](#52-track-events)). Includes purchases that start a free trial | `product_id`, `price` (number), `currency`, `trial` (boolean) |
| `purchase_failed` | User cancelled, or the purchase failed. Ask to Buy (pending) is neither: track nothing | `product_id`, `reason` (`"cancelled"`, `"failed"`) |
| `trial_started` | Sent **in addition to** `purchase_completed` when that purchase starts a free trial | `product_id` |
| `purchase_restored` | Restore purchases found an active subscription | `product_id` *optional* |
| Your core action | The one thing your app is for. Name it for that action: `scan_completed`, `workout_finished`, `photo_exported` | what describes it |

**What events from the app cannot show.** Renewals, free trials that turn into paid subscriptions, cancellations and refunds happen at Apple, usually while your app is closed. The app never sees them, so they are not in these events, and funnels here end at the purchase or the trial start.

**Example funnels these enable**

| Funnel | Steps | Window | What it answers |
|---|---|---|---|
| First session | `onboarding_started → onboarding_completed → paywall_viewed (placement = onboarding) → purchase_completed` | 1 day | How many new users reach the paywall and buy |
| Onboarding drop-off | `onboarding_step_viewed (step = 1) → (step = 2) → (step = 3) → onboarding_completed` | 1 day | Which onboarding screen loses the most people |
| Paywall conversion | `paywall_viewed → purchase_started → purchase_completed`, broken down by `placement` | 1 hour | Which paywall placement converts best, and whether people drop at the price or at Apple's sheet |
| Activation | `onboarding_completed → scan_completed` (your core action) | 1 day | How many new users get to the point of the app |

## 7. Privacy

This section is guidance, not legal advice. You know what your app sends and where your users are, so the final decisions are yours, ideally with your own counsel.

Privacy contact for everything in this section: `privacy@app-store-operator.com`.

### 7.1 What App Store Operator receives

- Event names and their properties.
- `anonymous_id`: a random UUID the reference code creates with the first event tracked while tracking is on, and keeps in AsyncStorage for that install until tracking is turned off or `reset()` is called. It is not the IDFA or any other identifier shared with other apps.
- `user_id`, only if you call `identify`.
- The device context in [§4.3](#43-event-object): app version, build, OS version, device model, locale and, when known, build environment.
- The country, derived from the request's network location (see [§7.3](#73-ip-address-and-country)).

Only iOS installs send anything: on Android the reference code sends nothing.

**Never send** names, emails, phone numbers, precise location, the IDFA, or free text typed by the user, whether in event names, properties or `user_id`. `user_id` must be your app's own internal ID (for example a database key), not an email, a phone number, or an ID another company assigned.

### 7.2 Roles and service providers

- **Roles.** For the events your app sends, you are the data controller (under KVKK, *veri sorumlusu*): you decide what is collected and why. Nefarius, which runs App Store Operator, processes the events on your behalf as your data processor (under KVKK, *veri işleyen*), to store them and show them to you in the dashboard.
- **What we do not do.** We do not sell event data, use it for advertising, or share it with other customers.
- **Service providers (subprocessors).** Like any hosted service, we rely on providers that handle event data only on our behalf:
  - **Cloudflare**: network delivery and security. Every request from your app passes through it.
  - **Our server hosting provider**: runs the servers and the database where events are stored.
- **Locations.** Cloudflare operates worldwide, so a request may be handled outside your users' country.
- **Data processing agreement (DPA).** If you need a DPA (for example under GDPR Article 28), the current list of service providers with their locations, or details of the safeguards for international transfers, write to the privacy contact above.

### 7.3 IP address and country

Your app's requests reach us through Cloudflare, which needs the IP address to deliver them and looks up the country from it. App Store Operator keeps only the country (no city or region) and never stores or logs the IP address.

### 7.4 Retention and deletion requests

- Events are kept for 13 months, then deleted automatically.
- Deleting one person's events from the dashboard is not available yet. Until it is, when a user asks you to delete their data (for example under GDPR Article 17 or KVKK Article 11), email the privacy contact above from the address of a member of your App Store Operator workspace, with:
  - the app's name as shown in the dashboard;
  - the person's `user_id`, and/or their `anonymous_id`. A `user_id` also covers every `anonymous_id` linked to it. For users without an account, your app can show the `anonymous_id` from `await EventTracker.currentAnonymousId()`, for example on a support screen ([§5.2](#52-track-events)). Read it before calling `setEnabled(false)` or `reset()`: both delete it from the device, and without it we cannot find that install's events.
- We delete every event with those IDs and confirm by email. Send requests promptly: you have your own deadline to answer the user (generally one month under GDPR, 30 days under KVKK).
- Requests from anyone who is not a member of the workspace are not acted on; we refer the person to you.

### 7.5 Your privacy policy and consent

- **Privacy policy.** Your app's privacy policy should say that you collect usage analytics, what data that includes (§7.1), that a service provider (App Store Operator, run by Nefarius) processes it for you, and that it is kept for 13 months.
- **Legal basis and consent.** You choose the legal basis for analytics and whether you need your users' consent first. In the EU and UK, storing or reading an identifier on the device for analytics often requires consent; under KVKK you must at least inform users. If you need consent, call `EventTracker.setEnabled(false)` at launch, before anything else, until the user agrees, then `EventTracker.setEnabled(true)` ([§5.2](#52-track-events)). While tracking is off, no ID or event is stored on the device (only the off setting itself) and nothing is sent. The tracker creates its `anonymous_id` only with the first event tracked while it is on. If the user later withdraws consent, `setEnabled(false)` stops tracking and deletes the IDs and unsent events on the device. It does not delete events already sent: if consent was your only legal basis, or the user asks for deletion, request it through [§7.4](#74-retention-and-deletion-requests), reading `currentAnonymousId()` first.

### 7.6 App Tracking Transparency

App Store Operator does not combine your events with data from other companies' apps, websites or offline sources for advertising or ad measurement, does not share them with data brokers, and does not use the device context to fingerprint devices. On its own, therefore, the integration is not tracking as Apple defines it and does not require the App Tracking Transparency (ATT) prompt.

ATT is decided for your app as a whole, not per integration. If your app, or another SDK in it, links these events or your `user_id` with other companies' data for advertising or ad measurement, or shares them with a data broker, that is tracking: you need the user's ATT permission first, and your privacy label must say the data is used to track. See Apple's [User Privacy and Data Use](https://developer.apple.com/app-store/user-privacy-and-data-use/).

### 7.7 App Store privacy label and privacy manifest

You answer Apple's privacy questions for the whole app, and Apple asks you to include data collected by third-party code you add, which includes this code. For the data App Store Operator receives, the typical answers are:

| Apple data type | Declare when | Purpose |
|---|---|---|
| Usage Data → Product Interaction | Always | Analytics |
| Identifiers → Device ID | Always: `anonymous_id` is a persistent per-install identifier | Analytics |
| Location → Coarse Location | Always: the country is derived from the user's network location. It is coarser than Apple's examples, but declaring it is the safer answer | Analytics |
| Purchases → Purchase History | If you track the purchase events in §6 | Analytics |
| Identifiers → User ID | If you call `identify` or send `user_id` | Analytics |

- **Linked to You.** If you send `user_id`, declare every row above as *Linked to You*: the events are joined to an account ID in your system. If you never send `user_id` and do not connect the events to an account or other identifying data anywhere else, the data is tied only to a random install ID and you may be able to answer *Not Linked to You*. When in doubt, answer *Linked to You*.
- **Tracking.** *Not used to track you*, unless your app tracks as described in §7.6.
- **Privacy manifest.** The reference code is JavaScript and calls no API Apple requires a reason for itself, but the native code of the two packages does, and each package ships its own `PrivacyInfo.xcprivacy` declaring it. In the versions current when this guide was written, `@react-native-async-storage/async-storage` declares file timestamp (`NSPrivacyAccessedAPICategoryFileTimestamp`, `C617.1`), and `react-native-device-info` declares `UserDefaults` (`CA92.1`), file timestamp (`C617.1`), system boot time (`NSPrivacyAccessedAPICategorySystemBootTime`, `35F9.1`) and disk space (`NSPrivacyAccessedAPICategoryDiskSpace`, `85F4.1`). What you still have to check:
  - Archive the app in Xcode and generate its privacy report (Organizer → right-click the archive → **Generate Privacy Report**). It should list those reasons. If it does not, add them to the app's own manifest: in bare React Native, `ios/<YourApp>/PrivacyInfo.xcprivacy` (the React Native template creates it); in Expo, `ios.privacyManifests` in the app config, then prebuild again.
  - The collected data types in the app's own manifest (`NSPrivacyCollectedDataTypes`) should match your label. The packages declare none, because collecting events is what your app does with them.
  - After upgrading either package, look at its `PrivacyInfo.xcprivacy` again: the declared reasons can change.

Apple's definitions: [App privacy details on the App Store](https://developer.apple.com/app-store/app-privacy-details/) and [Privacy manifest files](https://developer.apple.com/documentation/bundleresources/privacy-manifest-files).

### 7.8 Apps for children

Apple's [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) (1.3 and 5.1.4) do not allow third-party analytics in Kids Category apps, except in limited cases where the service collects no identifiable information about children, including device information. App Store Operator receives a persistent install ID, the device model and the country, so **do not add it to an app in the Kids Category**. For other apps aimed at children, check the rules that apply to you (for example COPPA in the US, or GDPR rules on children's consent) with your counsel before integrating.

## 8. Verify and troubleshoot

1. Run the iOS app (simulator or device, a debug build), trigger a few events, then send it to background (that sends the queue).
2. Dashboard → your app → **Events → Live**: your events appear within seconds.
3. **Events → Catalog** lists each event name with its property keys. Check the names and keys are the ones you meant.

**Test builds.** Each app has one write key, used by test builds and App Store builds alike. The reference code marks every event from a development build (`__DEV__`) as `debug`. Release builds, including TestFlight builds, carry the `environment` passed to `configure` ([§5.1](#51-wire-it-up)); without it they carry none and count as App Store events, so TestFlight runs then show in the default view. By default the dashboard shows `app_store` events and events with no environment; to see your development runs, switch the environment filter to **All builds**. The curl test sends no environment, so it shows in the default view; find it by its `anonymous_id`, `curl-test`.

| Symptom | Likely cause |
|---|---|
| Nothing in Live | Wrong key (`401`); `configure` never called; the app ran on Android (the tracker does nothing there); or fewer than 20 events queued and the app has not gone to background yet (wait 30 s) |
| `401 invalid_write_key` | Key typo; the key was rotated more than 30 days ago; or the old key was revoked ([§3.1](#31-rotating-the-key)) |
| Event missing, others present | Rejected. In a development build the Metro / JS console shows `[EventTracker] rejected: …` with the code ([§4.5](#45-errors)) |
| Nothing in Live from a test build | The default view shows App Store events and events with no environment: switch the environment filter to **All builds** |
| App fails at launch with a native module error from AsyncStorage or DeviceInfo (module is `null` or not found) | The iOS app was not rebuilt after installing the packages: run `npx pod-install` and rebuild, or make a new Expo development build. Expo Go cannot run `react-native-device-info` |
| Events arrive late in bursts | Over a rate limit (`429`), or the server or network was down; the reference code keeps them and sends them later |
| Wrong times | Device clock far off: times more than 7 days in the past or 5 minutes in the future are replaced by the time we received the event |
| Same person counted twice in a funnel | `reset()` called when it should not be, or `identify` called with different IDs for one user |
| Funnel step is zero | Step's event name or property filter does not match exactly (names and values are case-sensitive) |
| Extra cold `app_opened` in development | Fast Refresh ran the module scope again ([§5.1](#51-wire-it-up)); development events are marked `debug` |

## 9. Done checklist

The integration is done when every line is true. When you report, give the file and line for each code item; mark an item *not applicable* with the reason, or *pending* if it needs the live API.

- [ ] `@react-native-async-storage/async-storage` and `react-native-device-info` are installed (pods installed, or a new Expo development build), and no other dependency was added.
- [ ] `eventTracker.ts` is in the app's source, unchanged from §5, and the iOS app builds and launches.
- [ ] `configure(...)` runs once at launch, at module scope, with this app's real key; the text `YOUR_WRITE_KEY` appears nowhere in the project.
- [ ] `EventTracker.flush()` runs when `AppState` changes to `background`.
- [ ] `app_opened` is tracked at launch (`source: "cold"`) and on every return from background (`source: "warm"`), not on every `active`.
- [ ] Every §6 event whose flow exists in this app is tracked, with the listed properties. Report each one as `event → file:line`, and list the §6 events you skipped and why.
- [ ] Purchase events are tracked in the purchase flow, around the purchase call, not in a listener that also delivers restores or renewals.
- [ ] Every event name matches `^[A-Za-z0-9_.:-]{1,64}$` and follows the §6 naming rules.
- [ ] No `track` call sends a name, email, phone number, precise location, IDFA or text the user typed.
- [ ] If the app has accounts: `identify` is called after sign-in with the app's own user ID, and `reset` after sign-out. Otherwise neither is called.
- [ ] *Live API:* the §3 curl test returns `accepted: 1`, and events from a run of the iOS app appear in Dashboard → Events → Live.
- [ ] *Developer:* the list of events and properties above was handed to the developer to check, against §7, the App Store privacy label, the required-reason entries in the app's privacy report ([§7.7](#77-app-store-privacy-label-and-privacy-manifest)), the app's privacy policy and any consent requirement. The app is not in the Kids Category.
