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
- 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 withaso_wk_). Never invent a key or leave the placeholder in code. - 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). - Wire it up as in §5.1:
configure(writeKey:)once at launch,EventTracker.flushInBackground()when the app moves to background, andapp_openedat launch and on every return from background. - Add
track(...)calls for each event in §6 whose flow exists in this app. Skip events whose flow does not exist. - Add
identifyandresetonly if the app has its own sign-in (§5.2). - 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
identifyonly 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
- Your app creates a random anonymous ID on first launch and keeps it.
- Every action you care about becomes an event: a name, a time, optional properties and some device context.
- Events are queued on the device and sent in batches. If the device is offline, they wait and are sent later.
- 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.
- 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
- Open the App Store Operator dashboard → Apps → your app → Events → Setup.
- Copy the write key (it starts with
aso_wk_). - Test it from a terminal (macOS or Linux). The command creates a new
event_idand the current time on every run, so you can run it again:
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.
- Setup → Rotate key. Any member of the workspace can do this, and it is recorded.
- Put the new key in your app and ship an update.
- 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
401and the reference code drops their events. People on those builds are not counted until they update. - 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.
- 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
{
"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:
{
"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 (seeevent_id).rejected: events that were not stored and must not be retried unchanged.clamped: events whoseoccurred_atwas out of range and was replaced by the server's receive time. They are stored and counted inaccepted.
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
{
"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.
{ "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).currentAnonymousIdreturns it. - Has an on/off switch,
setEnabled(_:), remembered across launches and on by default. Turning it off deletes the IDs and every unsent event, andtrackdoes 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'sRetry-Aftersays. 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,testflightorapp_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.
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
}
}
#endif5.1 Wire it up
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
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.currentAnonymousIdIf 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.
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_viewedwithplacement: "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_Viewedandpaywall_viewedare 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 orresetis called. It is not the IDFA or any other identifier shared with other apps.user_id, only if you callidentify.- 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 theiranonymous_id. Auser_idalso covers everyanonymous_idlinked to it. For users without an account, your app can show theanonymous_idfromEventTracker.shared.currentAnonymousId, for example on a support screen (§5.2). Read it before callingsetEnabled(false)orreset(): 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, thensetEnabled(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 itsanonymous_idonly 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, readingcurrentAnonymousIdfirst.
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 senduser_idand 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'sPrivacyInfo.xcprivacymust listNSPrivacyAccessedAPICategoryUserDefaultswith reasonCA92.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
- Run the app, trigger a few events, then send it to background (that sends the queue).
- Dashboard → your app → Events → Live: your events appear within seconds.
- 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.swiftis 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 textYOUR_WRITE_KEYappears nowhere in the project. -
EventTracker.flushInBackground()runs when the app moves to background. -
app_openedis 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
trackcall sends a name, email, phone number, precise location, IDFA or text the user typed. - If the app has accounts:
identifyis called after sign-in with the app's own user ID, andresetafter 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
UserDefaultsentry inPrivacyInfo.xcprivacy, the app's privacy policy and any consent requirement. The app is not in the Kids Category.