# App Store Operator Events: integration guide for iOS (Swift)

> **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:** iOS developers adding event tracking to a native Swift app (SwiftUI or UIKit), and the AI coding agents they hand this document to. It is self-contained: an agent can complete the integration from this file alone.

---

## For AI coding agents: read this first

You are adding event tracking to an iOS 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.** Copy the file in [§5](#5-reference-implementation) into the **main app target** as `EventTracker.swift`, unchanged. Do not add it to app extensions (widgets, share, notification extensions).
3. **Wire it up** as in [§5.1](#51-wire-it-up): `configure(writeKey:)` once at launch, `EventTracker.flushInBackground()` 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.swift` (batching, retries, endpoint, queue). If it does not fit the app, report why instead of rewriting it.
- Do not add a third-party dependency.
- 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 or boolean. No nested objects or arrays ([§4.3](#43-event-object)).
- 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 `Info.plist`.

---

## 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 iOS for the reference code | iOS 15 (Swift concurrency, `URLSession` async API) |
| 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 file, no dependencies, iOS 15+, builds in Swift 5 and Swift 6 language modes. Add it to the **main app target** as `EventTracker.swift` and use it unchanged.

What it does:

- Creates a random anonymous ID with the first tracked event and keeps it (in `UserDefaults`). `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 on disk, 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.
- Debug builds only: prints rejected and clamped events, and dropped batches, to the Xcode console as `[EventTracker] …`. Release builds print nothing.
- Marks each event as `debug`, `testflight` or `app_store` (StoreKit, iOS 16+), so test runs stay out of your funnels.
- `flushInBackground()` asks iOS for extra time, so the last events are sent even as the app is suspended.

`track` works before `configure`; events wait 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 keep their own storage, so each would get its own anonymous ID and count as a separate person. Track from the main app only.

```swift
import Foundation
import StoreKit
#if canImport(UIKit)
import UIKit
#endif

/// Sends events to App Store Operator. Docs: https://app-store-operator.com/docs/events/ios
public actor EventTracker {
    public static let shared = EventTracker()

    public enum Value: Codable, Sendable, ExpressibleByStringLiteral, ExpressibleByIntegerLiteral,
        ExpressibleByFloatLiteral, ExpressibleByBooleanLiteral {
        case string(String), int(Int), double(Double), bool(Bool)

        public init(stringLiteral value: String) { self = .string(value) }
        public init(integerLiteral value: Int) { self = .int(value) }
        public init(floatLiteral value: Double) { self = .double(value) }
        public init(booleanLiteral value: Bool) { self = .bool(value) }

        public init(from decoder: Decoder) throws {
            let c = try decoder.singleValueContainer()
            if let v = try? c.decode(Bool.self) { self = .bool(v) }
            else if let v = try? c.decode(Int.self) { self = .int(v) }
            else if let v = try? c.decode(Double.self) { self = .double(v) }
            else { self = .string(try c.decode(String.self)) }
        }

        public func encode(to encoder: Encoder) throws {
            var c = encoder.singleValueContainer()
            switch self {
            case .string(let v): try c.encode(v)
            case .int(let v): try c.encode(v)
            case .double(let v): try c.encode(v)
            case .bool(let v): try c.encode(v)
            }
        }
    }

    private struct Context: Codable, Sendable {
        let appVersion, build, osVersion, deviceModel, locale: String
        var environment: String?
        enum CodingKeys: String, CodingKey {
            case appVersion = "app_version", build, osVersion = "os_version",
                 deviceModel = "device_model", locale, environment
        }
    }

    private struct Event: Codable, Sendable {
        let eventId, name, anonymousId: String
        let userId: String?
        let occurredAt: String
        let properties: [String: Value]
        var context: Context
        enum CodingKeys: String, CodingKey {
            case eventId = "event_id", name, anonymousId = "anonymous_id", userId = "user_id",
                 occurredAt = "occurred_at", properties, context
        }
    }

    private struct Batch: Encodable { let events: [Event] }

    private let endpoint = URL(string: "https://api.app-store-operator.com/v1/events")!
    private let maxBatch = 100
    private let maxQueue = 1_000
    private let flushAt = 20
    private let anonymousIdKey = "aso.events.anonymousId"
    private let userIdKey = "aso.events.userId"
    private let disabledKey = "aso.events.disabled"
    private let storeURL: URL
    private let context: Context
    private let timestamp: ISO8601DateFormatter

    private var writeKey: String?
    private var anonymousId: String?
    private var isEnabled: Bool
    private var userId: String?
    private var queue: [Event]
    private var isFlushing = false
    private var timer: Task<Void, Never>?
    private var retryAt: Date?
    private var backoff: TimeInterval = 0
    private var environment: String?

    private init() {
        let defaults = UserDefaults.standard
        isEnabled = !defaults.bool(forKey: disabledKey)
        anonymousId = defaults.string(forKey: anonymousIdKey) // created on the first tracked event
        userId = defaults.string(forKey: userIdKey)

        let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        storeURL = dir.appendingPathComponent("aso-events-queue.json")
        queue = (try? JSONDecoder().decode([Event].self, from: Data(contentsOf: storeURL))) ?? []

        let info = Bundle.main.infoDictionary ?? [:]
        let os = ProcessInfo.processInfo.operatingSystemVersion
        var system = utsname()
        uname(&system)
        let model = withUnsafeBytes(of: &system.machine) { raw in
            String(decoding: raw.prefix { $0 != 0 }, as: UTF8.self)
        }
        context = Context(
            appVersion: info["CFBundleShortVersionString"] as? String ?? "",
            build: info["CFBundleVersion"] as? String ?? "",
            osVersion: "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)",
            deviceModel: model,
            locale: Locale.current.identifier,
            environment: nil // filled in when sent, see resolveEnvironment()
        )

        timestamp = ISO8601DateFormatter()
        timestamp.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
    }

    /// Call once at launch.
    public func configure(writeKey: String) {
        self.writeKey = writeKey
        guard timer == nil else { return }
        timer = Task {
            while !Task.isCancelled {
                try? await Task.sleep(nanoseconds: 30_000_000_000)
                await self.flush()
            }
        }
    }

    /// The ID of this install, or nil 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.
    public var currentAnonymousId: String? { 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.
    public func setEnabled(_ enabled: Bool) {
        isEnabled = enabled
        let defaults = UserDefaults.standard
        defaults.set(!enabled, forKey: disabledKey)
        guard !enabled else { return }
        anonymousId = nil
        userId = nil
        queue.removeAll()
        defaults.removeObject(forKey: anonymousIdKey)
        defaults.removeObject(forKey: userIdKey)
        try? FileManager.default.removeItem(at: storeURL)
    }

    /// Records an event. Cheap; never blocks on the network. Does nothing while tracking is off.
    public func track(_ name: String, _ properties: [String: Value] = [:]) {
        guard isEnabled else { return }
        let anonymousId = anonymousId ?? newAnonymousId()
        queue.append(Event(
            eventId: UUID().uuidString,
            name: name,
            anonymousId: anonymousId,
            userId: userId,
            occurredAt: timestamp.string(from: Date()),
            properties: properties,
            context: context
        ))
        if queue.count > maxQueue { queue.removeFirst(queue.count - maxQueue) }
        persist()
        if queue.count >= flushAt { Task { await self.flush() } }
    }

    /// Call after the user signs in to your app. Use your own stable user ID, never an email.
    public func identify(userId: String) {
        guard isEnabled else { return }
        self.userId = userId
        UserDefaults.standard.set(userId, forKey: userIdKey)
    }

    /// Call on sign-out: later events belong to a new anonymous person.
    public func reset() {
        userId = nil
        anonymousId = nil
        UserDefaults.standard.removeObject(forKey: userIdKey)
        UserDefaults.standard.removeObject(forKey: anonymousIdKey)
    }

    private func newAnonymousId() -> String {
        let id = UUID().uuidString
        anonymousId = id
        UserDefaults.standard.set(id, forKey: anonymousIdKey)
        return id
    }

    /// Sends queued events. Does nothing while waiting out a retry delay.
    public func flush() async {
        guard isEnabled, !isFlushing, let writeKey, !queue.isEmpty else { return }
        if let retryAt, retryAt > Date() { return }
        isFlushing = true
        defer { isFlushing = false }
        let environment = await resolveEnvironment()

        while !queue.isEmpty {
            var batch = Array(queue.prefix(maxBatch))
            for i in batch.indices where batch[i].context.environment == nil {
                batch[i].context.environment = environment
            }
            var request = URLRequest(url: endpoint)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.setValue(writeKey, forHTTPHeaderField: "X-Write-Key")
            request.httpBody = try? JSONEncoder().encode(Batch(events: batch))

            guard let (data, response) = try? await URLSession.shared.data(for: request) else {
                delayRetry(nil) // offline: keep the queue
                return
            }
            guard isEnabled else { return } // turned off while sending: the queue is already gone
            let http = response as? HTTPURLResponse
            let status = http?.statusCode ?? 0
            if status == 429 || status >= 500 {
                let retryAfter = http?.value(forHTTPHeaderField: "Retry-After").flatMap(TimeInterval.init)
                delayRetry(status == 429 ? retryAfter : nil) // keep the queue
                return
            }
            retryAt = nil
            backoff = 0
            #if DEBUG
            report(status: status, body: data)
            #endif
            // 2xx: stored. Other 4xx: retrying the same batch cannot succeed.
            let sent = Set(batch.map(\.eventId))
            queue.removeAll { sent.contains($0.eventId) }
            persist()
        }
    }

    /// Waits `seconds` when the server says so, otherwise 30 s, 60 s, 120 s… up to 10 minutes.
    private func delayRetry(_ seconds: TimeInterval?) {
        backoff = min(max(backoff * 2, 30), 600)
        retryAt = Date().addingTimeInterval(seconds ?? backoff)
    }

    private func persist() {
        try? JSONEncoder().encode(queue).write(to: storeURL, options: .atomic)
    }

    #if DEBUG
    /// Debug builds only: prints dropped batches and rejected or clamped events to the Xcode console.
    private func report(status: Int, body: Data) {
        let json = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] ?? [:]
        guard (200..<300).contains(status) else {
            print("[EventTracker] HTTP \(status), batch dropped: \(json)")
            return
        }
        for key in ["rejected", "clamped"] {
            if let items = json[key] as? [Any], !items.isEmpty { print("[EventTracker] \(key): \(items)") }
        }
    }
    #endif

    /// "debug", "testflight" or "app_store". Release builds ask StoreKit (iOS 16+); on iOS 15 it stays unknown.
    private func resolveEnvironment() async -> String? {
        if let environment { return environment }
        #if DEBUG
        environment = "debug"
        #else
        if #available(iOS 16.0, macOS 13.0, *), let result = try? await AppTransaction.shared {
            switch result.unsafePayloadValue.environment {
            case .production: environment = "app_store"
            case .sandbox: environment = "testflight"
            case .xcode: environment = "debug"
            default: break
            }
        }
        #endif
        return environment
    }
}

#if canImport(UIKit)
extension EventTracker {
    /// Sends the queue when the app moves to background, asking iOS for time to finish.
    @MainActor public static func flushInBackground() {
        let task = BackgroundTask()
        Task {
            await shared.flush()
            task.end()
        }
    }
}

@MainActor private final class BackgroundTask {
    private var id = UIBackgroundTaskIdentifier.invalid

    init() {
        id = UIApplication.shared.beginBackgroundTask { [weak self] in self?.end() }
    }

    func end() {
        guard id != .invalid else { return }
        UIApplication.shared.endBackgroundTask(id)
        id = .invalid
    }
}
#endif
```

### 5.1 Wire it up

```swift
import SwiftUI

@main
struct MyApp: App {
    @Environment(\.scenePhase) private var scenePhase
    @State private var wasInBackground = false

    init() {
        Task {
            await EventTracker.shared.configure(writeKey: "YOUR_WRITE_KEY")
            await EventTracker.shared.track("app_opened", ["source": "cold"])
        }
    }

    var body: some Scene {
        WindowGroup { ContentView() }
            .onChange(of: scenePhase) { _, phase in // iOS 17+; on iOS 15–16 use `{ phase in }`
                switch phase {
                case .background:
                    wasInBackground = true
                    EventTracker.flushInBackground()
                case .active where wasInBackground:
                    wasInBackground = false
                    Task { await EventTracker.shared.track("app_opened", ["source": "warm"]) }
                default:
                    break
                }
            }
    }
}
```

**UIKit:** in `application(_:didFinishLaunchingWithOptions:)`, call `configure` and track `app_opened` with `source: "cold"` inside `Task { … }` as above. In `sceneDidEnterBackground(_:)` (or `applicationDidEnterBackground(_:)` without scenes), call `EventTracker.flushInBackground()`. In `sceneWillEnterForeground(_:)` (or `applicationWillEnterForeground(_:)`), track `app_opened` with `source: "warm"`.

`app_opened` is sent on returning from background only, not on every `.active`: system alerts, Control Center and Face ID also make the app inactive and active again.

### 5.2 Track events

```swift
await EventTracker.shared.track("paywall_viewed", ["placement": "onboarding"])

// From synchronous code (button actions, delegates):
Task { await EventTracker.shared.track("onboarding_completed", ["duration_s": 42]) }

// After sign-in / sign-out in your app:
await EventTracker.shared.identify(userId: user.id)
await EventTracker.shared.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.
await EventTracker.shared.setEnabled(userAllowsAnalytics)

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

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.

Literals (`"onboarding"`, `42`, `39.99`, `true`) convert to property values on their own. For variables, wrap them: `.string(name)`, `.int(count)`, `.double(amount)`, `.bool(flag)`.

**Purchases with StoreKit 2.** Track purchases in the purchase flow, where the user taps buy. Do not track them in `Transaction.updates`, which also delivers renewals and purchases from other devices.

```swift
import StoreKit

func buy(_ product: Product, startsTrial: Bool) async {
    let base: [String: EventTracker.Value] = ["product_id": .string(product.id)]
    await EventTracker.shared.track("purchase_started", base)
    do {
        switch try await product.purchase() {
        case .success(.verified):
            await EventTracker.shared.track("purchase_completed", base.merging([
                "price": .double(NSDecimalNumber(decimal: product.price).doubleValue),
                "currency": .string(product.priceFormatStyle.currencyCode),
                "trial": .bool(startsTrial),
            ]) { $1 })
            if startsTrial { await EventTracker.shared.track("trial_started", base) }
        case .userCancelled:
            await EventTracker.shared.track("purchase_failed", base.merging(["reason": "cancelled"]) { $1 })
        case .success(.unverified):
            await EventTracker.shared.track("purchase_failed", base.merging(["reason": "failed"]) { $1 })
        case .pending:
            break // Ask to Buy or extra approval: not completed yet, track nothing
        @unknown default:
            break
        }
    } catch {
        await EventTracker.shared.track("purchase_failed", base.merging(["reason": "failed"]) { $1 })
    }
}
```

`startsTrial` is whatever your paywall already knows: true when this purchase starts a free trial. If the app buys through RevenueCat or another library, put the same calls around its purchase method.

## 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 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 build environment.
- The country, derived from the request's network location (see [§7.3](#73-ip-address-and-country)).

**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 `EventTracker.shared.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.shared.setEnabled(false)` at launch, before anything else, until the user agrees, then `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 reads and writes `UserDefaults`, an API Apple requires a reason for. Your app's `PrivacyInfo.xcprivacy` must list `NSPrivacyAccessedAPICategoryUserDefaults` with reason `CA92.1` (many apps already do), and its collected data types should match your label.

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 app, 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 as `debug`, `testflight` or `app_store`. By default the dashboard shows `app_store` events and events with no environment; to see your test runs, switch the environment filter to **All builds**. On iOS 15, release builds cannot tell TestFlight from the App Store, so their events count as App Store events. 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; 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 Debug build the Xcode 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** |
| 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) |

## 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.

- [ ] `EventTracker.swift` is in the main app target only (no extensions), unchanged from §5, and the app builds.
- [ ] `configure(writeKey:)` runs once at launch with this app's real key; the text `YOUR_WRITE_KEY` appears nowhere in the project.
- [ ] `EventTracker.flushInBackground()` runs when the app moves to background.
- [ ] `app_opened` is tracked at launch (`source: "cold"`) and on every return from background (`source: "warm"`).
- [ ] 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, not in `Transaction.updates`.
- [ ] 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 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 `UserDefaults` entry in `PrivacyInfo.xcprivacy`, the app's privacy policy and any consent requirement. The app is not in the Kids Category.
