App Store Operator
Markdown for coding agents

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 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: 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 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).
  6. Finish with §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). If the app is in the App Store Kids Category, stop and tell the developer: this tracker must not be added (§7.8). If the app asks users for consent to analytics, ask the developer whether tracking must wait for it (§7.5).
  • Property values are flat: string, number or boolean. No nested objects or arrays (§4.3).
  • 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:
Terminal
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_ids; 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; 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 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). 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: [email protected].

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: app version, build, OS version, device model, locale and build environment.
  • The country, derived from the request's network location (see §7.3).

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). 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.
  • 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). 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, 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.

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 and Privacy manifest files.

7.8 Apps for children

Apple's App 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)
Event missing, others present Rejected. In a Debug build the Xcode console shows [EventTracker] rejected: … with the code (§4.5)
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.