App Store Operator
Markdown for coding agents

App Store Operator Events: integration guide for Flutter

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: Flutter developers adding event tracking to the iOS build of a Flutter app, and the AI coding agents they hand this document to. It is self-contained: an agent can complete the integration from this file alone. App Store Operator covers iOS apps for now: the tracker sends nothing from Android, web or desktop builds (Android support is planned).


For AI coding agents: read this first

You are adding event tracking to a Flutter app so its owner can build funnels in App Store Operator. The app's iOS build will send events to POST https://api.app-store-operator.com/v1/events with the header X-Write-Key. On every other platform the tracker does nothing.

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. Add the four packages listed in §5 with flutter pub add, then copy the file in §5 into the app as lib/event_tracker.dart, unchanged. If the iOS app has native extensions (widgets, share, notification extensions), do not track from them.
  3. Wire it up as in §5.1: in main(), after WidgetsFlutterBinding.ensureInitialized(), call configure(writeKey: ...) once and track app_opened; then call flush() when the app moves to background, and track app_opened again 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 lib/event_tracker.dart (batching, retries, endpoint, queue, the iOS-only check). If it does not fit the app, report why instead of rewriting it.
  • Do not add any third-party dependency other than the four packages in §5 (path_provider, shared_preferences, package_info_plus, device_info_plus). If the app already uses one of them, keep it; flutter pub add reports a version conflict if there is one.
  • Do not make the tracker run on Android, web or desktop. App Store Operator counts iOS installs only for now.
  • 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, int, double, bool or null. No nested maps or lists (§4.3); the tracker drops any other value.
  • 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.


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 versions for the reference code Dart 3.11 and Flutter 3.41 (required by the package versions in §5); iOS 13. Sends events from iOS only
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 Dart file. Add it to the app as lib/event_tracker.dart and use it unchanged. It needs four packages, and no others:

Terminal
flutter pub add path_provider shared_preferences package_info_plus device_info_plus

flutter pub add picks the latest versions. The code was tested with these (Flutter 3.44, Dart 3.12):

YAML
dependencies:
  path_provider: ^2.1.6
  shared_preferences: ^2.5.6
  package_info_plus: ^10.2.2
  device_info_plus: ^13.3.0
Package Used for
path_provider The queue file, in the app support directory
shared_preferences The anonymous ID, the user ID and the on/off setting (UserDefaults on iOS)
package_info_plus app_version and build
device_info_plus os_version and device_model

Requests go through HttpClient from dart:io, so no HTTP package is needed.

What it does:

  • iOS only. On Android, web and desktop every method returns at once and does nothing: no ID, no file, no request. App Store Operator covers iOS apps for now, and this keeps Android installs out of your App Store funnels. Android support is planned.
  • Creates a random anonymous ID (a version 4 UUID from Random.secure()) with the first tracked event and keeps it (in shared_preferences). 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 file 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 (kDebugMode): prints rejected and clamped events, dropped batches, and property values it dropped, to the Flutter console with debugPrint as [EventTracker] …. Release builds print nothing.
  • Marks events from debug and profile builds as debug, so test runs stay out of your funnels. Release builds carry the environment you pass to configure, or none (see Test builds below).
  • track returns at once and never waits for the network. configure, track, identify, reset and setEnabled take effect in the order you call them, so you do not need to await them for that.

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. If your iOS app has native extensions (widgets, share, notification extensions), do not track from them. Extensions keep their own storage, so each would get its own anonymous ID and count as a separate person. Track from the Flutter app only.

Test builds. A Flutter release build cannot tell TestFlight from the App Store without native code. configure therefore takes an optional environment, "testflight" or "app_store" (any other value is ignored, and debug and profile builds are always debug). The §5.1 code reads it from a build-time define:

Terminal
flutter build ipa --dart-define=ASO_ENV=testflight

Without the define, release events carry no environment and the dashboard counts them as App Store events, TestFlight testers included. Apple releases the same build you uploaded to TestFlight, so a build made with ASO_ENV=testflight keeps that mark if you later release it to the App Store, and its App Store users then disappear from the dashboard's default view. Use the define only for builds that will never be released, for example a separate internal testing build; otherwise leave it out.

Dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';

import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

/// Sends events to App Store Operator. Docs: https://app-store-operator.com/docs/events/flutter
///
/// iOS only: on Android, web and desktop every method does nothing.
class EventTracker {
  EventTracker._();

  static final EventTracker shared = EventTracker._();

  static final Uri _endpoint = Uri.parse('https://api.app-store-operator.com/v1/events');
  static const int _maxBatch = 100;
  static const int _maxQueue = 1000;
  static const int _flushAt = 20;
  static const String _anonymousIdKey = 'aso.events.anonymousId';
  static const String _userIdKey = 'aso.events.userId';
  static const String _disabledKey = 'aso.events.disabled';
  static const Set<String> _environments = {'debug', 'testflight', 'app_store'};

  static final bool _supported = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
  static final Random _random = Random.secure();

  late final Future<void> _ready = _load(); // started by the first call
  final HttpClient _http = HttpClient()..connectionTimeout = const Duration(seconds: 30);

  SharedPreferencesWithCache? _prefs;
  File? _store;
  Map<String, String> _context = const {};
  String? _writeKey;
  String? _environment;
  String? _anonymousId;
  String? _userId;
  bool _enabled = true;
  List<Map<String, Object?>> _queue = [];
  bool _flushing = false;
  Timer? _timer;
  DateTime? _retryAt;
  int _backoff = 0; // seconds
  Future<void>? _writing;
  bool _dirty = false;
  bool _warned = false;

  /// Call once at launch, after `WidgetsFlutterBinding.ensureInitialized()`.
  ///
  /// Debug and profile builds are marked `debug`. Release builds are marked with [environment]
  /// (`testflight` or `app_store`) when given, otherwise they carry no environment.
  void configure({required String writeKey, String? environment}) {
    if (!_isSupported()) return;
    _writeKey = writeKey;
    if (!kReleaseMode) {
      _environment = 'debug';
    } else if (environment != null && _environments.contains(environment)) {
      _environment = environment;
    }
    _timer ??= Timer.periodic(const Duration(seconds: 30), (_) => flush());
    unawaited(_ready);
  }

  /// The ID of this install, or null before the first tracked event and while tracking is off.
  /// Show it on a support screen so a user can ask for their data to be deleted.
  Future<String?> get currentAnonymousId async {
    if (!_isSupported()) return null;
    await _ready;
    return _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.
  Future<void> setEnabled(bool enabled) async {
    if (!_isSupported()) return;
    await _ready;
    _enabled = enabled;
    unawaited(_prefs?.setBool(_disabledKey, !enabled));
    if (enabled) return;
    _anonymousId = null;
    _userId = null;
    _queue = [];
    unawaited(_prefs?.remove(_anonymousIdKey));
    unawaited(_prefs?.remove(_userIdKey));
    _persist(); // an empty queue deletes the file
  }

  /// Records an event. Cheap; returns at once and never waits for the network.
  /// Does nothing while tracking is off. Property values: String, int, double, bool or null.
  void track(String name, [Map<String, Object?> properties = const {}]) {
    if (!_isSupported()) return;
    final eventId = _uuid();
    final occurredAt = _now();
    final props = _flat(name, properties);
    unawaited(_ready.then((_) {
      if (!_enabled) return;
      _queue.add({
        'event_id': eventId,
        'name': name,
        'anonymous_id': _anonymousId ?? _newAnonymousId(),
        if (_userId != null) 'user_id': _userId,
        'occurred_at': occurredAt,
        'properties': props,
        'context': _context,
      });
      if (_queue.length > _maxQueue) _queue.removeRange(0, _queue.length - _maxQueue);
      _persist();
      if (_queue.length >= _flushAt) unawaited(flush());
    }));
  }

  /// Call after the user signs in to your app. Use your own stable user ID, never an email.
  Future<void> identify(String userId) async {
    if (!_isSupported()) return;
    await _ready;
    if (!_enabled) return;
    _userId = userId;
    unawaited(_prefs?.setString(_userIdKey, userId));
  }

  /// Call on sign-out: later events belong to a new anonymous person.
  Future<void> reset() async {
    if (!_isSupported()) return;
    await _ready;
    _userId = null;
    _anonymousId = null;
    unawaited(_prefs?.remove(_userIdKey));
    unawaited(_prefs?.remove(_anonymousIdKey));
  }

  /// Sends queued events. Does nothing while waiting out a retry delay.
  Future<void> flush() async {
    if (!_isSupported()) return;
    await _ready;
    final writeKey = _writeKey;
    if (!_enabled || _flushing || writeKey == null || _queue.isEmpty) return;
    final retryAt = _retryAt;
    if (retryAt != null && retryAt.isAfter(DateTime.now())) return;
    _flushing = true;
    try {
      while (_queue.isNotEmpty) {
        final batch = _queue.take(_maxBatch).toList();
        final events = [
          for (final event in batch)
            {
              ...event,
              'context': {
                if (_environment != null) 'environment': _environment,
                ...(event['context'] as Map? ?? const {}),
              },
            },
        ];
        final response = await _post(writeKey, jsonEncode({'events': events}));
        if (response == null) {
          _delayRetry(null); // offline: keep the queue
          return;
        }
        if (!_enabled) return; // turned off while sending: the queue is already gone
        final (status, retryAfter, body) = response;
        if (status == 429 || status >= 500) {
          _delayRetry(status == 429 ? double.tryParse(retryAfter ?? '') : null); // keep the queue
          return;
        }
        _retryAt = null;
        _backoff = 0;
        if (kDebugMode) _report(status, body);
        // 2xx: stored. Other 4xx: retrying the same batch cannot succeed.
        final sent = {for (final event in batch) event['event_id']};
        _queue.removeWhere((event) => sent.contains(event['event_id']));
        _persist();
      }
    } finally {
      _flushing = false;
    }
  }

  Future<void> _load() async {
    try {
      final prefs = await SharedPreferencesWithCache.create(
        cacheOptions: const SharedPreferencesWithCacheOptions(
          allowList: {_anonymousIdKey, _userIdKey, _disabledKey},
        ),
      );
      _prefs = prefs;
      _enabled = !(prefs.getBool(_disabledKey) ?? false);
      _anonymousId = prefs.getString(_anonymousIdKey); // created on the first tracked event
      _userId = prefs.getString(_userIdKey);
    } catch (error) {
      _debug('settings unavailable, keeping IDs in memory: $error');
    }

    try {
      final dir = await getApplicationSupportDirectory();
      await dir.create(recursive: true);
      final store = File('${dir.path}/aso-events-queue.json');
      _store = store;
      if (await store.exists()) {
        final saved = jsonDecode(await store.readAsString()) as List<dynamic>;
        _queue = [for (final event in saved) Map<String, Object?>.from(event as Map)];
      }
    } catch (error) {
      _debug('queue file unavailable, keeping events in memory: $error');
    }

    var appVersion = '', build = '', osVersion = '', deviceModel = '';
    try {
      final package = await PackageInfo.fromPlatform();
      appVersion = package.version;
      build = package.buildNumber;
    } catch (_) {}
    try {
      final ios = await DeviceInfoPlugin().iosInfo;
      osVersion = ios.systemVersion;
      deviceModel = ios.utsname.machine;
    } catch (_) {}
    _context = {
      'app_version': appVersion,
      'build': build,
      'os_version': osVersion,
      'device_model': deviceModel,
      'locale': PlatformDispatcher.instance.locale.toString(), // en_US
    };
  }

  Future<(int, String?, String)?> _post(String writeKey, String body) async {
    try {
      return await () async {
        final request = await _http.postUrl(_endpoint);
        request.headers.contentType = ContentType.json;
        request.headers.set('X-Write-Key', writeKey);
        request.add(utf8.encode(body));
        final response = await request.close();
        final text = await response.transform(utf8.decoder).join();
        return (response.statusCode, response.headers.value('retry-after'), text);
      }().timeout(const Duration(seconds: 60));
    } catch (_) {
      return null;
    }
  }

  /// Waits `seconds` when the server says so, otherwise 30 s, 60 s, 120 s… up to 10 minutes.
  void _delayRetry(double? seconds) {
    _backoff = min(max(_backoff * 2, 30), 600);
    final wait = Duration(milliseconds: ((seconds ?? _backoff) * 1000).round());
    _retryAt = DateTime.now().add(wait);
  }

  /// Writes the queue to disk, one write at a time; the last write always has the latest queue.
  void _persist() {
    if (_store == null) return;
    _dirty = true;
    _writing ??= _write();
  }

  Future<void> _write() async {
    while (_dirty) {
      _dirty = false;
      final store = _store!;
      try {
        if (_queue.isEmpty) {
          if (await store.exists()) await store.delete();
        } else {
          final temp = File('${store.path}.tmp');
          await temp.writeAsString(jsonEncode(_queue), flush: true);
          await temp.rename(store.path);
        }
      } catch (_) {}
    }
    _writing = null;
  }

  String _newAnonymousId() {
    final id = _uuid();
    _anonymousId = id;
    unawaited(_prefs?.setString(_anonymousIdKey, id));
    return id;
  }

  /// Keeps String, int, finite double, bool and null values; drops anything else.
  Map<String, Object?> _flat(String name, Map<String, Object?> properties) {
    final result = <String, Object?>{};
    properties.forEach((key, value) {
      if (value == null || value is String || value is int || value is bool ||
          (value is double && value.isFinite)) {
        result[key] = value;
      } else {
        _debug('$name: property "$key" dropped, ${value.runtimeType} is not a flat value');
      }
    });
    return result;
  }

  /// Debug builds only: prints dropped batches and rejected or clamped events to the Flutter console.
  void _report(int status, String body) {
    Object? json;
    try {
      json = jsonDecode(body);
    } catch (_) {}
    if (status < 200 || status >= 300) {
      _debug('HTTP $status, batch dropped: ${json ?? body}');
      return;
    }
    if (json is! Map) return;
    for (final key in ['rejected', 'clamped']) {
      final items = json[key];
      if (items is List && items.isNotEmpty) _debug('$key: $items');
    }
  }

  bool _isSupported() {
    if (_supported) return true;
    if (!_warned) {
      _warned = true;
      _debug('iOS only: tracking is off on ${kIsWeb ? 'web' : defaultTargetPlatform.name}');
    }
    return false;
  }

  static void _debug(String message) {
    if (kDebugMode) debugPrint('[EventTracker] $message');
  }

  static String _now() {
    final now = DateTime.now().millisecondsSinceEpoch;
    return DateTime.fromMillisecondsSinceEpoch(now, isUtc: true).toIso8601String();
  }

  /// A random (version 4) UUID.
  static String _uuid() {
    final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
    bytes[6] = (bytes[6] & 0x0f) | 0x40;
    bytes[8] = (bytes[8] & 0x3f) | 0x80;
    final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
    return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-'
        '${hex.substring(16, 20)}-${hex.substring(20)}';
  }
}

5.1 Wire it up

Dart
import 'dart:async';

import 'package:flutter/widgets.dart';

import 'event_tracker.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  EventTracker.shared.configure(
    writeKey: 'YOUR_WRITE_KEY',
    environment: const String.fromEnvironment('ASO_ENV'), // see "Test builds" in §5
  );
  EventTracker.shared.track('app_opened', {'source': 'cold'});

  var wasInBackground = false;
  // Stays registered for the app's lifetime; no need to keep a reference.
  AppLifecycleListener(
    onHide: () {
      // The app is moving to background: send what is queued.
      wasInBackground = true;
      unawaited(EventTracker.shared.flush());
    },
    onResume: () {
      if (!wasInBackground) return; // alerts, Control Center and Face ID only make it inactive
      wasInBackground = false;
      EventTracker.shared.track('app_opened', {'source': 'warm'});
    },
  );

  runApp(const MyApp());
}

If main() already does other setup, keep it, and put these calls after WidgetsFlutterBinding.ensureInitialized() and before runApp: the tracker reads its stored IDs and device details through plugins, which need the binding. AppLifecycleListener needs Flutter 3.13 or later. If the app already has a WidgetsBindingObserver, you can use it instead: call flush() on AppLifecycleState.hidden and track the warm app_opened on the next resumed.

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

Background time. iOS gives an app a few seconds after it moves to background, which is usually enough for flush() to finish. A Flutter app cannot ask iOS for more time without native code, so if the app is suspended first, the events stay in the queue file and are sent at the next launch.

5.2 Track events

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

// Variables work the same way: String, int, double, bool or null values.
EventTracker.shared.track('onboarding_step_viewed', {'step': step, 'step_name': stepName});

// After sign-in / sign-out in your app:
await EventTracker.shared.identify(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 (null if nothing was tracked yet).
final 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.

Property values must be String, int, double, bool or null. The tracker drops any other value (a DateTime, an enum, a nested map or list, double.nan) and, in a debug build, prints [EventTracker] … dropped. Convert first: date.toIso8601String(), plan.name.

Purchases. Track purchases in the purchase flow, where the user taps buy. Put this wrapper around your purchase call:

Dart
/// How a purchase ended, as your purchase code reports it.
enum PurchaseOutcome { success, cancelled, pending, failed }

/// Wraps your purchase call. [purchase] runs it and says how it ended.
Future<void> buy({
  required String productId,
  required double price,
  required String currency,
  required bool startsTrial,
  required Future<PurchaseOutcome> Function() purchase,
}) async {
  final base = {'product_id': productId};
  EventTracker.shared.track('purchase_started', base);
  PurchaseOutcome outcome;
  try {
    outcome = await purchase();
  } catch (_) {
    outcome = PurchaseOutcome.failed;
  }
  switch (outcome) {
    case PurchaseOutcome.success:
      EventTracker.shared.track('purchase_completed', {
        ...base,
        'price': price,
        'currency': currency,
        'trial': startsTrial,
      });
      if (startsTrial) EventTracker.shared.track('trial_started', base);
    case PurchaseOutcome.cancelled:
      EventTracker.shared.track('purchase_failed', {...base, 'reason': 'cancelled'});
    case PurchaseOutcome.failed:
      EventTracker.shared.track('purchase_failed', {...base, 'reason': 'failed'});
    case PurchaseOutcome.pending:
      break; // Ask to Buy or extra approval: not completed yet, track nothing
  }
}

purchase is your own function around your store library's purchase method. It returns success when the purchase went through and was verified, cancelled when the user cancelled, pending for Ask to Buy or other approval, and failed otherwise (throwing counts as failed). price (a number, not a formatted string) and currency (an ISO 4217 code such as "USD") come from your product details, and startsTrial is whatever your paywall already knows: true when this purchase starts a free trial.

  • in_app_purchase: buyNonConsumable and buyConsumable only start the purchase; the result arrives on purchaseStream. Make purchase start the purchase and wait for the first update for that product: PurchaseStatus.purchased is success, canceled is cancelled, pending is pending, and error is failed. Price and currency are ProductDetails.rawPrice and currencyCode.
  • RevenueCat (purchases_flutter): its purchase methods return when the purchase succeeds and throw a PlatformException when it does not. Map PurchasesErrorCode.purchaseCancelledError to cancelled, paymentPendingError to pending, and other errors to failed.
  • Any other library: put the same calls around its purchase method.

Do not track purchases from a listener that receives every transaction, such as your in_app_purchase purchaseStream handler: it also delivers restored purchases (PurchaseStatus.restored), renewals and purchases that finish at a later launch, so they would be counted as new purchases.

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

The reference code sends events from the iOS build only; Android, web and desktop builds send nothing.

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

7.2 Roles and service providers

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

7.3 IP address and country

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

7.4 Retention and deletion requests

  • Events are kept for 13 months, then deleted automatically.
  • Deleting one person's events from the dashboard is not available yet. Until it is, when a user asks you to delete their data (for example under GDPR Article 17 or KVKK Article 11), email the privacy contact above from the address of a member of your App Store Operator workspace, with:
    • the app's name as shown in the dashboard;
    • the person's user_id, and/or their anonymous_id. A user_id also covers every anonymous_id linked to it. For users without an account, your app can show the anonymous_id from await EventTracker.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. Apple requires a stated reason for some APIs, in your code and in every plugin. For this integration:
    • shared_preferences keeps the IDs and the on/off setting in UserDefaults. Its iOS plugin ships its own privacy manifest that declares UserDefaults (NSPrivacyAccessedAPICategoryUserDefaults), so you do not add an entry for the tracker's use.
    • The queue file is written with dart:io; Flutter's engine ships its own privacy manifest for the file APIs it uses.
    • device_info_plus (13.3.0) also reads the device's free disk space when the tracker asks for the OS version and model. The tracker does not use that value, and the plugin's manifest declares no reason for it. If App Store Connect reports a missing reason for NSPrivacyAccessedAPICategoryDiskSpace (ITMS-91053) after you upload, add that category to your app's PrivacyInfo.xcprivacy with the reason from Apple's list that matches your app's use, or ask the plugin's maintainers.
    • Still yours to check: your app's own PrivacyInfo.xcprivacy (Flutter does not create one; add it to the Runner target in Xcode) should list collected data types that match your label, and App Store Connect's email after each upload lists any missing reasons for the whole app, other plugins included.

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 on an iOS simulator or device (flutter run), 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.

Android, web and desktop builds send nothing. In a debug build the console shows [EventTracker] iOS only: tracking is off on android (or the platform's name) once.

Test builds. Each app has one write key, used by test builds and App Store builds alike. The reference code marks events from debug and profile builds (flutter run) as debug, and release builds with the environment from --dart-define=ASO_ENV=... (testflight or app_store), or with none. 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. A release build without the define cannot tell TestFlight from the App Store, so its events, TestFlight testers' included, count as App Store events (§5). The curl test sends no environment, so it shows in the default view; find it by its anonymous_id, curl-test.

Symptom Likely cause
Nothing in Live Wrong key (401); configure never called; the app is running on Android or another platform that is not iOS; 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 Flutter console shows [EventTracker] rejected: … with the code (§4.5)
A property missing from an event Its value was not a String, int, double, bool or null. In a debug build the console shows [EventTracker] … dropped
[EventTracker] settings unavailable or queue file unavailable in the console The tracker ran before WidgetsFlutterBinding.ensureInitialized(), or the app was not rebuilt after flutter pub add (stop it and run it again: a hot restart does not load new plugins). IDs and events are then kept in memory only
Nothing in Live from a test build The default view shows App Store events and events with no environment: switch the environment filter to All builds
App Store users missing from the default view The released build was made with --dart-define=ASO_ENV=testflight; build the next release without it
Events arrive late in bursts Over a rate limit (429), or the server or network was down, or the app was suspended before flush() finished; 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.

  • path_provider, shared_preferences, package_info_plus and device_info_plus are in pubspec.yaml, and no other dependency was added for tracking.
  • lib/event_tracker.dart is unchanged from §5 (iOS-only check included), flutter analyze reports no errors, and the app builds for iOS (flutter build ios --no-codesign).
  • configure(writeKey: ...) runs once in main(), after WidgetsFlutterBinding.ensureInitialized() and before runApp, with this app's real key; the text YOUR_WRITE_KEY appears nowhere in the project.
  • EventTracker.shared.flush() 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 (§5.2), not in a listener such as purchaseStream that also delivers restores.
  • Every event name matches ^[A-Za-z0-9_.:-]{1,64}$ and follows the §6 naming rules, and every property value is a String, int, double, bool or null.
  • 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 app's PrivacyInfo.xcprivacy, the app's privacy policy and any consent requirement, and to decide whether any build uses --dart-define=ASO_ENV (§5). The app is not in the Kids Category.