HELPERG (opens in a new tab) Ecosystem

WebmasterID logoWebmasterID

Docs

Install the mobile SDK (iOS, Android, Flutter)

Installation guide for the WebmasterID mobile SDKs: prerequisites, registering an iOS or Android app in the dashboard, the one-line Flutter install, native Swift Package Manager and Gradle, the first run with consent and identity, verifying the first event, optional purchases, Codemagic, troubleshooting and updates.

Last updated: 2026-10-07

This guide takes a customer from nothing to the first event on the dashboard, on iOS and Android. The main path is Flutter; native Swift (Swift Package Manager) and native Kotlin or Java (Gradle) follow in their own sections. Everything here is what was built and accepted for this release — the versions, the commands and the examples are the ones that were run, and the examples compile against the published API. Other frameworks (React Native, Unity, .NET) are not supported.

1. Prerequisites

  • A WebmasterID workspace with service access: an active Pro ($499/month), Agency ($1,199/month) or Business plan, or a legacy Free account (created before 2026-10-07, 14:21 UTC). Without it the dashboard will not register an app, and the service answers 403 to its events. Plans: https://dashboard.webmasterid.com/settings/billing.
  • No GitHub access of any kind. The SDKs install from public channels: the Flutter package from pub.dev, the iOS frameworks from a public Swift package, the Android libraries from a public Maven repository. No account, token, deploy key or private repository is involved, and none must be added to your build.
  • Flutter 3.47.0 or newer (Dart 3.13), with Swift Package Manager enabled (it is, by default, since Flutter 3.44). Verified with Flutter 3.47.0 and 3.47.6.
  • iOS: Xcode 26.0.1 and 26.6 verified; apps run on iOS 15.0 and newer. The frameworks are built with Xcode 26.0.1 and need no CocoaPods.
  • Android: minSdk 24 (Android 7.0), JDK 17, Android Gradle Plugin 8.0 or newer (verified with 8.11.1 (native apps) and 9.1.0 (the Flutter template)). Native Kotlin apps: Kotlin 2.1.21 verified. Native Java apps: Java 17 sources verified.

2. Register the app in the dashboard

  1. Sign in at https://dashboard.webmasterid.com. In the sidebar open iOS apps or Android apps. (Registering needs the Owner, Admin or Editor role.)
  2. Fill in Register an app: a display name; the Bundle identifier (iOS, reverse-DNS such as com.example.app) or the Package name (Android — the applicationId of your build); the Environment: Development, TestFlight (iOS only) or Production. The identifier cannot be changed after registration.
  3. Click Register app. The app's card shows Public property ID — ap_ followed by 16 characters — with a Copy button. That is the one value your app needs.

Registering collects nothing. The card reads “SDK not installed — no events have been received for this property” until the first event arrives.

3. Install

Flutter (iOS and Android)

One line in pubspec.yaml, then flutter pub get. No Git URL, no path, no dependency override:

name: webmasterid_consumer
description: A minimal customer app that installs the WebmasterID Flutter SDK exactly as the installation guide says.
publish_to: none
version: 1.0.0+1

environment:
  sdk: ^3.13.0
  flutter: ">=3.47.0"

dependencies:
  flutter:
    sdk: flutter
  # The WebmasterID SDK, from pub.dev. No Git URL, no path, no override.
  webmasterid_flutter: ^0.3.0
  # Where this app keeps the consent decision between launches.
  shared_preferences: ^2.3.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^6.0.0

flutter:
  uses-material-design: true
  • iOS: the plugin's Swift package depends on the public WebmasterID Swift package by URL, pinned to exactly 1.2.0; Xcode downloads two XCFrameworks and checks their SHA-256. There is no CocoaPods podspec: a project that turned Swift Package Manager off (enable-swift-package-manager: false) must turn it back on.
  • Android: the plugin adds https://webmasterid.com/sdk/maven to your build, restricted to the com.webmasterid group, and pins the Android SDK 0.2.0. Set minSdk = 24. If your settings.gradle(.kts) uses RepositoriesMode.FAIL_ON_PROJECT_REPOS, add that repository there (section 7 of troubleshooting shows the line).

Native iOS — Swift Package Manager

Xcode: File → Add Package Dependencies…, paste https://github.com/PetroTitan/webmasterid-mobile-sdk.git, choose version 1.2.0, and add the WebmasterID product to your app target — WebmasterIDStoreKit only if your app sells something. In a Package.swift:

dependencies: [
    .package(
        url: "https://github.com/PetroTitan/webmasterid-mobile-sdk.git",
        from: "1.2.0"
    )
],
targets: [
    .target(
        name: "MyApp",
        dependencies: [
            .product(
                name: "WebmasterID",
                package: "webmasterid-mobile-sdk"
            )
        ]
    )
]

Native Android — Gradle (Kotlin or Java)

The repository, in settings.gradle.kts:

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://webmasterid.com/sdk/maven") }
    }
}

The dependency, in app/build.gradle.kts:

// app/build.gradle.kts
dependencies {
    implementation("com.webmasterid:webmasterid-android:0.2.0")
}

The same in Groovy (settings.gradle / app/build.gradle):

// settings.gradle
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url 'https://webmasterid.com/sdk/maven' }
    }
}
// app/build.gradle
dependencies {
    implementation 'com.webmasterid:webmasterid-android:0.2.0'
}

The library merges exactly one permission into your manifest, INTERNET. Its only external dependency is the Kotlin standard library.

4. The first run

At every launch, in this order: restore the consent decision your app stored → start the SDK with it → restore your own session → confirm who it is (identify) or end the session an earlier launch left behind (resetIdentity) → only then send events. The complete lib/main.dart of a minimal app that does exactly this (it is the app this guide was verified with):

// A minimal customer app for the WebmasterID Flutter SDK.
//
// It does, in this order, what the installation guide says every app must do
// at launch: restore the consent decision, start the SDK with it, restore the
// app's own session, confirm who it is (or end the session an earlier launch
// left behind), and only then send events.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:webmasterid_flutter/webmasterid_flutter.dart';

/// The app property's PUBLIC id, from the dashboard (iOS apps / Android apps).
/// It ships inside the app and addresses your property; it is not a secret and
/// it authenticates nothing. Never put a WebmasterID server key in an app.
const String appPropertyId = String.fromEnvironment(
  'WMID_APP_PROPERTY_ID',
  defaultValue: 'ap_xxxxxxxxxxxxxxxx',
);

/// Leave empty for production. Set only for an acceptance run against an
/// endpoint WebmasterID gave you for testing (https://host[:port]).
const String endpoint = String.fromEnvironment('WMID_ENDPOINT');

/// Where this app keeps the person's consent decision between launches. The
/// SDK never stores it: an app restores the decision at every launch.
class ConsentStore {
  static const _key = 'webmasterid.consent';

  Future<WebmasterIDConsent?> load() async {
    final prefs = await SharedPreferences.getInstance();
    final name = prefs.getString(_key);
    if (name == null) return null;
    for (final consent in WebmasterIDConsent.values) {
      if (consent.name == name) return consent;
    }
    return null;
  }

  Future<void> save(WebmasterIDConsent consent) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_key, consent.name);
  }
}

/// The app's own notion of who is signed in: an opaque account key, or null.
/// A real app restores this from its auth layer; this one keeps it locally.
class Session {
  static const _key = 'app.accountKey';

  Future<String?> restore() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getString(_key);
  }

  Future<void> signIn(String accountKey) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_key, accountKey);
  }

  Future<void> signOut() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove(_key);
  }
}

final consentStore = ConsentStore();
final session = Session();

/// The launch order. Everything the SDK needs happens here, once per process.
Future<WebmasterID> startWebmasterID() async {
  // 1. The decision this app stored — null if the person has not decided yet.
  final WebmasterIDConsent? consent = await consentStore.load();

  // 2. One client, started with that decision. Analytics only: no purchase
  //    collector is created, so no store framework is involved at runtime.
  final webmasterID = await WebmasterID.initialize(
    appPropertyId: appPropertyId,
    consent: consent,
    purchases: false,
    endpoint: endpoint.isEmpty ? null : Uri.parse(endpoint),
  );

  // 3. YOUR session, restored the way your app restores it.
  final accountKey = await session.restore();

  // 4. Confirm who it is — or end the session an earlier launch left behind.
  if (accountKey != null) {
    try {
      await webmasterID.identify(accountKey);
    } on PlatformException catch (e) {
      // iOS before the device's first unlock: the Keychain is not reachable,
      // nothing was registered — identify again later. Anything else is real.
      if (e.code != 'keychain_unavailable') rethrow;
    }
  } else if ((await webmasterID.diagnostics())?.deliveryHold ==
      'identityNotRestored') {
    await webmasterID.resetIdentity();
  }
  return webmasterID;
}

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const ConsumerApp());
}

class ConsumerApp extends StatelessWidget {
  const ConsumerApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'WebmasterID consumer',
      home: HomeScreen(),
    );
  }
}

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
  WebmasterID? _webmasterID;
  WebmasterIDDiagnostics? _diagnostics;
  String? _accountKey;
  final List<String> _log = [];

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _start();
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  Future<void> _start() async {
    try {
      final webmasterID = await startWebmasterID();
      final accountKey = await session.restore();
      setState(() {
        _webmasterID = webmasterID;
        _accountKey = accountKey;
      });
      _say('SDK started for $appPropertyId'
          '${endpoint.isEmpty ? '' : ' → $endpoint'}');
      await _refresh();
    } catch (e) {
      _say('start failed: $e');
    }
  }

  // The SDK observes nothing on its own: the app forwards the lifecycle, and
  // going to the background delivers what is queued.
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    final webmasterID = _webmasterID;
    if (webmasterID == null) return;
    switch (state) {
      case AppLifecycleState.resumed:
        webmasterID.applicationDidBecomeActive();
      case AppLifecycleState.paused:
        webmasterID.applicationDidEnterBackground();
      default:
        break;
    }
  }

  Future<void> _setConsent(WebmasterIDConsent consent) async {
    final webmasterID = _webmasterID;
    if (webmasterID == null) return;
    await consentStore.save(consent);
    await webmasterID.setConsent(consent);
    _say('consent → ${consent.name}');
    await _refresh();
  }

  Future<void> _signIn() async {
    final webmasterID = _webmasterID;
    if (webmasterID == null) return;
    // Your own opaque account key — never an e-mail address, which is refused.
    const accountKey = 'demo-account-1';
    await session.signIn(accountKey);
    try {
      await webmasterID.identify(accountKey);
      _say('identified as $accountKey');
    } on PlatformException catch (e) {
      _say('identify refused: ${e.code}');
    }
    setState(() => _accountKey = accountKey);
    await _refresh();
  }

  Future<void> _signOut() async {
    final webmasterID = _webmasterID;
    if (webmasterID == null) return;
    await session.signOut();
    await webmasterID.resetIdentity();
    setState(() => _accountKey = null);
    _say('signed out — a new identity period');
    await _refresh();
  }

  /// One screen view and one tap, delivered now. Whether the server took them
  /// is in diagnostics (acknowledged), not in the return values.
  Future<void> _sendTestEvent() async {
    final webmasterID = _webmasterID;
    if (webmasterID == null) return;
    final queuedView = await webmasterID.screenView('Home');
    final queuedTap =
        await webmasterID.ctaTap(cta: 'send_test_event', screen: 'Home');
    final delivered = await webmasterID.flush();
    _say('screen_view queued=$queuedView, cta_tap queued=$queuedTap, '
        'flush made progress=$delivered');
    await _refresh();
  }

  Future<void> _refresh() async {
    final webmasterID = _webmasterID;
    if (webmasterID == null) return;
    final diagnostics = await webmasterID.diagnostics();
    if (!mounted) return;
    setState(() => _diagnostics = diagnostics);
    if (diagnostics != null) {
      // One line a log reader can grep for.
      debugPrint('WMID_CONSUMER consent=${diagnostics.consent?.name} '
          'queued=${diagnostics.queuedEvents} '
          'acknowledged=${diagnostics.acknowledged} '
          'status=${diagnostics.lastStatusCategory} '
          'hold=${diagnostics.deliveryHold} '
          'identityStorage=${diagnostics.identityStorage}');
    }
  }

  void _say(String line) {
    debugPrint('WMID_CONSUMER $line');
    if (!mounted) return;
    setState(() => _log.insert(0, line));
  }

  @override
  Widget build(BuildContext context) {
    final d = _diagnostics;
    return Scaffold(
      appBar: AppBar(title: const Text('WebmasterID consumer')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Text('Property: $appPropertyId'),
          Text('Signed in as: ${_accountKey ?? 'nobody'}'),
          const SizedBox(height: 12),
          const Text('1. Consent (the person decides; the app stores it)'),
          Wrap(spacing: 8, children: [
            FilledButton(
              onPressed: () => _setConsent(WebmasterIDConsent.analyticsAllowed),
              child: const Text('Allow analytics'),
            ),
            OutlinedButton(
              onPressed: () => _setConsent(WebmasterIDConsent.restricted),
              child: const Text('Restricted'),
            ),
            OutlinedButton(
              onPressed: () => _setConsent(WebmasterIDConsent.disabled),
              child: const Text('Disable'),
            ),
          ]),
          const SizedBox(height: 12),
          const Text('2. Session'),
          Wrap(spacing: 8, children: [
            FilledButton(onPressed: _signIn, child: const Text('Sign in')),
            OutlinedButton(onPressed: _signOut, child: const Text('Sign out')),
          ]),
          const SizedBox(height: 12),
          const Text('3. Events'),
          FilledButton(
            onPressed: _sendTestEvent,
            child: const Text('Send test event'),
          ),
          const SizedBox(height: 16),
          const Text('Diagnostics', style: TextStyle(fontWeight: FontWeight.bold)),
          if (d == null)
            const Text('not started')
          else ...[
            Text('consent: ${d.consent?.name ?? 'not decided'}'),
            Text('queued: ${d.queuedEvents}  acknowledged: ${d.acknowledged}  '
                'attempted: ${d.attempted}'),
            Text('last status: ${d.lastStatusCategory}  '
                'delivery hold: ${d.deliveryHold}'),
            Text('identity storage: ${d.identityStorage}'),
          ],
          const SizedBox(height: 16),
          const Text('Log', style: TextStyle(fontWeight: FontWeight.bold)),
          for (final line in _log) Text(line),
        ],
      ),
    );
  }
}

Run it with your property's id: flutter run --dart-define=WMID_APP_PROPERTY_ID=ap_…, tap Allow analytics, then Send test event.

  • Not decided (the state after initialize without a decision): nothing is queued and nothing is sent. The SDK writes one local file — its own identity record, holding a random session id — and nothing else.
  • analyticsAllowed: events are sent with a random installation id (created now, per install) and, after identify, your account key.
  • restricted: events are sent with a session id only — no installation id, no account key; the stored key is deleted. This is reduced identification, not anonymity: a session id, a country derived by the server from the connection, and exact times remain.
  • disabled: nothing is sent; the queue, the installation id and the account key on the device are deleted.
  • The SDK never stores the decision. Your app stores it and passes it to initialize(consent:) at every launch; setConsent changes it later.

Persistent identifiers, restart, unavailable storage

  • Installation id: 128 random bits, per install, created only under analyticsAllowed; deleted under restricted and disabled; replaced by resetIdentity. It is not the advertising id and not the vendor id — the SDK reads neither.
  • Session id: random, rotates after 30 minutes of inactivity. Sent in every state that sends.
  • Account key: the opaque id your app passes to identify — never an e-mail address (refused) or anything typed by a person. The server stores it only as a keyed hash.
  • After a restart, an account key stored by an earlier launch labels nothing until identify with the same key confirms it in the new process. Delivery waits for that confirmation for at most 30 seconds from the moment the decision is in force; after the window, events go out without a user, and nothing is bound to them later. So restore your session inside the launch sequence, and call resetIdentity only when you know nobody is signed in.
  • Unavailable storage: on iOS, identify without your own token throws keychain_unavailable before the device's first unlock — nothing was registered; call it again later. On both platforms a failed write of the account key is reported in diagnostics (identityStorage), and events then carry no key rather than a guessed one.

Native iOS and Android: the same sequence

Swift — the client, consent, lifecycle, identity and events:

import WebmasterID

// This identifier is PUBLIC. It ships inside your app binary and
// anyone can read it out — it addresses your property, it does not
// authenticate anything. Never put a WebmasterID server secret in
// an app.
let client = WebmasterIDClient(
    configuration: try WebmasterIDConfiguration(
        appPropertyID: "ap_xxxxxxxxxxxxxxxx",
        consent: .notDetermined
    )
)
// Nothing is collected, queued or stored until you decide.
await client.setConsent(.analyticsAllowed)  // events + pseudonymous ids
await client.setConsent(.restricted)        // events only, no stable ids
await client.setConsent(.disabled)          // stop AND delete what was kept
// SwiftUI. The SDK observes nothing on its own and swizzles nothing,
// so your app forwards the lifecycle.
@Environment(\.scenePhase) private var scenePhase

.onChange(of: scenePhase) { phase in
    Task {
        switch phase {
        case .active:     await client.applicationDidBecomeActive()
        case .background: await client.applicationDidEnterBackground()
        default: break
        }
    }
}
// After an authenticated login. Pass YOUR opaque account key —
// an email address is refused, not hashed.
try await client.identify(externalUserID: "<your-account-key>")

// On logout or an account switch.
await client.resetIdentity()
try await client.track(.screenView, context: .init(screen: "BookingDetail"))
try await client.track(.ctaTap, context: .init(screen: "BookingDetail", ctaID: "book_now"))

// Deliver what is queued now. Anything undelivered stays on disk
// and is retried later.
await client.flush()

Kotlin:

// One client per process. Consent starts NotDetermined: nothing is collected yet.
val sdk = WebmasterID.initialize(applicationContext, "ap_xxxxxxxxxxxxxxxx")
// The person's decision. Under RESTRICTED no installation id and no user id are sent.
sdk.setConsent(Consent.ANALYTICS_ALLOWED)
// Your own opaque account key — never an email — and the obfuscated id you
// also pass to Google Play Billing (setObfuscatedAccountId).
sdk.identify("acct_9f2b71", GooglePlayAccountId.obfuscated("acct_9f2b71"))
// Logout: queued events for the previous person are delivered with no user.
sdk.resetIdentity()
sdk.screenView("BookingDetail")
sdk.ctaTap("BookingDetail", "book_now")
sdk.track(EventName.BOOKING_COMPLETED, EventContext.screen("Confirmation"))

Java:

// One client per process. Consent starts NotDetermined: nothing is collected yet.
WebmasterID sdk = WebmasterID.initialize(getApplicationContext(), "ap_xxxxxxxxxxxxxxxx");
sdk.setConsent(Consent.ANALYTICS_ALLOWED);
sdk.identify("acct_9f2b71", GooglePlayAccountId.obfuscated("acct_9f2b71"));
sdk.resetIdentity(); // logout
sdk.screenView("BookingDetail");
sdk.ctaTap("BookingDetail", "book_now");
sdk.track(EventName.BOOKING_COMPLETED, EventContext.builder().screen("Confirmation").build());

On Android the SDK fills the app version and build, the OS major version, the locale and the time zone from the device; on iOS your app passes them in the event context if it wants them recorded. On Android delivery also happens automatically a few seconds after an event and when the app goes to the background; on iOS your app calls flush() or forwards the lifecycle as above.

5. Verify the integration

Three different things, in order — do not confuse them:

  1. The SDK is initialized: diagnostics() returns an object and consent shows your decision. This proves nothing about the server.
  2. The server accepted the event: acknowledged went up and lastStatusCategory is success. A refused status is a 403: the property is not accepting events (wrong id, another environment, archived, or no active plan).
  3. The event is on the dashboard: reload the app's page under iOS apps or Android apps. The card changes from “SDK not installed” to Receiving events, with “Last event: <time, UTC> · Events in the last 30 days: N”. The page is not cached: what you see is what the server stored.

Property and environment: the id in your app must be the Public property ID on the card you are looking at. Each environment has its own id and its own card. Events from a Development property never appear on the Production card.

What the diagnostics fields mean:

  • consent — The decision in force: analyticsAllowed, restricted, disabled — or null while nothing has been decided.
  • deliveryHold — Why nothing is leaving the device: consentNotDecided (no decision yet), identityNotRestored (an earlier launch stored an account key and the SDK is waiting, at most 30 seconds, for identify or resetIdentity), none.
  • queuedEvents — Events on the device not yet acknowledged by the server. After a successful flush: 0.
  • acknowledged — Events the server has accepted in this process. This is the number that means "the server took it".
  • attempted — Requests sent in this process.
  • lastStatusCategory — The last server answer: success, clientError (a 400 — the batch was dropped), refused (a 403 — the property is not accepting events; delivery stops until the next launch), rateLimited (429), serverError (5xx — retried with backoff), transportError (no connection — retried), none.
  • retryState / retryInSeconds — Whether a retry is scheduled, and when.
  • identityStorage — Whether the account key could be stored: notWritten, written, or failed with a category (unavailable, locked, encoding, unknown). After a failure events carry no account key — nothing is guessed.
  • droppedOversized / droppedExpired / droppedForCapacity — Events the SDK gave up on: a single event the server will never take, events older than 7 days, or the queue's capacity (1000 events, 512 KiB).

6. Purchases (optional)

Analytics does not require purchases. The example above passes purchases: false and creates no store collector. Add purchases only if your app sells something, and read this section first.

StoreKit 2 (iOS)

The SDK forwards Apple's signed transaction, unread, to WebmasterID, which verifies it against Apple's certificates. A purchase is attributed to a user only when the appAccountToken Apple signed into the transaction equals the one registered with identify; otherwise the payment is recorded with no user. The SDK never finishes a transaction and never grants an entitlement — both stay your app's. In Swift:

// OPTIONAL. Add this second product only if your app sells something —
// the core never links StoreKit, so an analytics-only app does not
// have to account for it in App Store review.
.product(
    name: "WebmasterIDStoreKit",
    package: "webmasterid-mobile-sdk"
)
import WebmasterIDStoreKit

// The collector ATTACHES to the client you already built. Consent and
// identity stay owned by that one client — there is no second place to
// set them, and no way for the two to disagree about who the user is.
let storeKit = try await WebmasterIDStoreKit.attached(to: client)

// One Transaction.updates listener. Calling this twice is a no-op.
await storeKit.start()
// One random UUID per ACCOUNT, stored with the account — never derived
// from an e-mail or other personal data. The SAME token goes to identify
// and to StoreKit; Apple signs it into the transaction, and only a
// matching signed token lets the purchase be claimed for this user.
try await client.identify(
    externalUserID: account.accountKey,
    appAccountToken: account.appAccountToken
)
let result = try await product.purchase(
    options: [.appAccountToken(account.appAccountToken)]
)
if case let .success(verification) = result {
    // Apple's signed transaction, for SERVER-side verification.
    await storeKit.submit(verification)

    // ⚠ YOU finish the transaction, after granting the entitlement.
    // This SDK never calls finish().
    if case let .verified(transaction) = verification {
        await grantEntitlement(for: transaction)
        await transaction.finish()
    }
}

In Flutter, initialize(purchases: true) (the default) links StoreKit; identify(accountKey, appAccountToken: …) registers the token, which WebmasterIDPurchases.purchaseParam passes to in_app_purchase as applicationUserName, and attach submits the purchased or restored transaction. The package README documents each call.

Google Play Billing (Android)

The SDK carries the purchase token as evidence for the server to verify with Google; it never acknowledges or consumes a purchase and carries no amount. Your app owns the Billing Library, the acknowledgement and the entitlement. The obfuscated account id you give Billing is how a purchase is matched to the identity registered with identify:

dependencies {
    implementation("com.webmasterid:webmasterid-googleplay:0.2.0")
}
// Attached to the SAME client. Never acknowledges or consumes a purchase, and carries no amount.
val evidence = GooglePlayEvidence.attach(
    sdk.client,
    GooglePlayEvidence.Configuration(packageName, AndroidStorage.storage(applicationContext, "ap_xxxxxxxxxxxxxxxx")),
).get()
// From your Billing Library PurchasesUpdatedListener: the token is EVIDENCE the
// server verifies with Google. The obfuscated id is read back from the purchase.
evidence.submit(
    purchase.purchaseToken,
    purchase.products.first(),
    GooglePlayProductKind.PRODUCT,
    purchase.accountIdentifiers?.obfuscatedAccountId,
)

7. Codemagic

A minimal codemagic.yaml for the app above: analyze, test, an Android release APK with R8, and an iOS Simulator build. It signs nothing, uploads nothing and holds no secret. Three different accesses are involved, and only the first is Codemagic's business:

  • Your app's repository: Codemagic needs read access to it, as for any app.
  • Release signing and store upload: your certificates, profiles and keystores, in your own release workflow — not in this one.
  • Downloading the SDK: anonymous. No WebmasterID GitHub token, deploy key or repository access exists for customers, and none must be added to Codemagic.
# WebmasterID consumer app — a Codemagic build with no secret of any kind.
#
# What Codemagic needs: read access to THIS repository. Nothing else — no
# WebmasterID GitHub token, no deploy key, no access to any WebmasterID
# repository: the SDK installs anonymously from pub.dev (the Flutter package),
# from the public Swift package (iOS) and from https://webmasterid.com/sdk/maven
# (Android). Release signing and store upload are a separate workflow of your
# own; this one signs nothing and uploads nothing.
#
# MANUAL ONLY: no `triggering` section, so no push, tag or pull request starts
# it. Start it from the Codemagic UI.
workflows:
  webmasterid-consumer:
    name: WebmasterID consumer — analyze, test, Android release APK (R8), iOS Simulator
    instance_type: mac_mini_m2
    max_build_duration: 60
    environment:
      flutter: 3.47.6
      xcode: "26.6"
      java: 17
      vars:
        # Your property's PUBLIC id. It is not a secret; it ships in the app.
        WMID_APP_PROPERTY_ID: "ap_xxxxxxxxxxxxxxxx"
        # Empty = production. Only an acceptance run sets a test endpoint here.
        WMID_ENDPOINT: ""
    scripts:
      - name: No WebmasterID credential on this machine (names only, never values)
        script: |
          set -euo pipefail
          found="$(env | sed -nE 's/^([A-Z0-9_]*(SSH_KEY|GITHUB_TOKEN|GH_TOKEN|_PAT|PRIVATE_KEY)[A-Z0-9_]*)=.*/\1/p')"
          [ -z "$found" ] || { echo "credential-like variables present: $found"; exit 1; }
          keys="$(git config --global --name-only --get-regexp 'credential|insteadof' 2>/dev/null || true)"
          [ -z "$keys" ] || { echo "git credential or URL rewrite configured: $keys"; exit 1; }
          for f in "$HOME/.netrc" "$HOME/.git-credentials" "$HOME/.m2/settings.xml"; do
            [ ! -e "$f" ] || { echo "credential file present: $f"; exit 1; }
          done
          echo "no WebmasterID credential: the SDK must install anonymously"
      - name: Install — exactly the installation guide's one pubspec line
        script: |
          set -euo pipefail
          flutter --version
          flutter pub get
          grep -A1 '"webmasterid_flutter"' pubspec.lock
      - name: Analyze and test
        script: |
          set -euo pipefail
          flutter analyze
          flutter test
      - name: Android — release APK with R8
        script: |
          set -euo pipefail
          flutter build apk --release \
            --dart-define=WMID_APP_PROPERTY_ID="$WMID_APP_PROPERTY_ID" \
            --dart-define=WMID_ENDPOINT="$WMID_ENDPOINT"
      - name: iOS — Simulator build (no signing)
        script: |
          set -euo pipefail
          xcodebuild -version
          flutter build ios --simulator --no-codesign \
            --dart-define=WMID_APP_PROPERTY_ID="$WMID_APP_PROPERTY_ID" \
            --dart-define=WMID_ENDPOINT="$WMID_ENDPOINT"
      - name: What was resolved (the proof that no private source was used)
        script: |
          set -euo pipefail
          echo "--- webmasterid_flutter from pub.dev:"; grep -B1 -A4 '^  webmasterid_flutter:' pubspec.lock
          echo "--- iOS SDK (public Swift package, pinned by the plugin):"
          find ios -name Package.resolved -exec cat {} \; | grep -A4 webmasterid-mobile-sdk || true
          echo "--- Android SDK (public Maven):"
          find "$HOME/.gradle/caches" -path '*com.webmasterid*' -name '*.pom' 2>/dev/null | head -5 || true
    artifacts:
      - build/app/outputs/flutter-apk/app-release.apk
      - build/app/outputs/mapping/release/mapping.txt
      - build/ios/iphonesimulator/Runner.app
      - pubspec.lock

Start it from the Codemagic UI (there is no trigger on purpose). The last step prints what was resolved: webmasterid_flutter from pub.dev, the iOS package pinned by the plugin, the Android libraries from the public Maven repository.

8. Troubleshooting and updates

Diagnose with diagnostics() and the build log. Neither contains the account key, a purchase token or any secret, so both can be shared with support as they are.

  • flutter pub get: version solving failed, or "requires SDK version" — webmasterid_flutter needs Flutter 3.47.0 or newer (Dart 3.13). Fix: Upgrade Flutter (flutter upgrade) and run flutter pub get again.
  • iOS build: no such module 'WebmasterID', or the plugin's package is not resolved — Swift Package Manager is turned off in this Flutter project. The plugin has no CocoaPods podspec. Fix: flutter config --enable-swift-package-manager, then flutter clean and build again. Xcode downloads the two frameworks and checks their SHA-256.
  • Android build: Could not find com.webmasterid:webmasterid-android:0.2.0 — Your settings.gradle(.kts) sets RepositoriesMode.FAIL_ON_PROJECT_REPOS, so the repository the plugin adds is ignored. Fix: Add maven { url = uri("https://webmasterid.com/sdk/maven") } to dependencyResolutionManagement.repositories in settings.gradle(.kts).
  • Android build: uses-sdk:minSdkVersion 21 cannot be smaller than version 24 — The SDK supports Android API 24 (Android 7.0) and newer. Fix: Set minSdk = 24 in android/app/build.gradle(.kts).
  • Nothing arrives; diagnostics show deliveryHold: consentNotDecided — The SDK has no consent decision. That is the SDK working: nothing is collected before the person decides. Fix: Call setConsent(analyticsAllowed) after the person agrees, and pass the stored decision to initialize(consent:) at every launch.
  • Nothing arrives for up to 30 seconds after launch; deliveryHold: identityNotRestored — An earlier launch stored an account key. Delivery waits for your app to confirm it (identify) or end it (resetIdentity). Fix: Restore your session and call identify within the launch sequence; call resetIdentity when nobody is signed in. After the window, events go out without a user.
  • lastStatusCategory: refused — the property is not accepting events (HTTP 403) — The property id is wrong, belongs to another environment, is archived, or the workspace has no active plan. The server does not say which. Fix: Copy the Public property ID from the card of the app and environment you mean; restore an archived app; check /settings/billing. Delivery resumes at the next launch.
  • lastStatusCategory: clientError (HTTP 400) and events disappear — The batch was refused for its content — most often a device clock more than 7 days behind or 10 minutes ahead. A 400 is never retried. Fix: Fix the device clock; the SDK sends occurred_at as the device saw it.
  • identify throws ArgumentError — The account key contained '@' or whitespace, was empty, or was longer than 128 characters. An e-mail address is refused, never hashed. Fix: Pass your own opaque account id.
  • identify throws PlatformException keychain_unavailable (iOS) — The Keychain is not reachable yet — before the device's first unlock after a restart. The SDK registered nothing and kept the previous identity. Fix: Call identify again later (next launch or foreground), or pass your own account-service appAccountToken, which needs no Keychain.
  • initialize throws different_endpoint or consent_conflict — A second initialize in the same process named a different endpoint, or a different stored decision than the one already in force. Fix: Initialize once per process with one endpoint; change consent with setConsent, not by re-initializing.
  • lastStatusCategory: rateLimited (HTTP 429) — Too many requests from this app in a short time. Fix: Nothing to do: the SDK waits for the Retry-After the server sent, then delivers.
  • The card still says "SDK not installed" although acknowledged > 0 — The page shows what the server stored for THAT property; you are looking at another environment's card, or you did not reload. Fix: Reload the page and compare the Public property ID on the card with the one in the app.
  • Events stopped after the subscription lapsed — Service access is checked at ingest: a workspace without an active plan or legacy access gets 403. Fix: Open /settings/billing. Events the apps queued meanwhile are kept on the devices (up to 7 days) and delivered once the property accepts again.

Updating and going back

  • Flutter: flutter pub upgrade webmasterid_flutter. The plugin pins the native SDK versions it was built with; do not override them. To hold a version, pin it exactly: webmasterid_flutter: 0.3.0.
  • Native iOS: change the version rule in Xcode or in Package.swift (exact: holds a version). Native Android: change the version in the dependency line.
  • Published versions never change: a release, its assets and their checksums are immutable, and a fix is a new version. Every published version stays installable, so going back is pinning the previous one. 0.3.0 is the first published version of the Flutter package; iOS 1.2.0 and Android 0.2.0 are the versions it pins.

The package reference for every call is the README of webmasterid_flutter on pub.dev and the README of the public Swift package. For the tracker on websites, see Install the tracker.