+1 (415) 843-4662

Analytics, consent and attribution in Ionic apps: ATT, Consent Mode, AdAttributionKit and a gate that runs first

Every Ionic app we are asked to review has analytics in it. Perhaps a third of them can actually explain, in front of a regulator or an App Store reviewer, which SDK fires before consent, what identifiers leave the device, and why the install numbers in the ad dashboard do not match the numbers in the product dashboard. That gap is where the expensive surprises live: a Data Safety form that does not match the traffic, an ATT prompt shown at the wrong moment and answered "no" by 80% of users, and attribution that quietly stopped working when Apple moved from SKAdNetwork to AdAttributionKit.

This tutorial builds the version we ship for clients: a consent gate that runs before any SDK initialises, correct App Tracking Transparency and Consent Mode wiring on Capacitor, attribution that works under both Apple's and Google's current rules, and an event schema that survives more than one quarter.

The mental model: three different questions

Teams conflate three things. Keep them separate or the implementation will never be defensible.

  1. Product analytics — what screens and flows do users use? Can be done with first-party, non-tracking identifiers and, in most jurisdictions, is the easiest case to justify.
  2. Consent — do you have a legal basis (GDPR/ePrivacy, and now the DMA for gatekeeper-linked data) for the storage and the transmission? This is a legal gate implemented as a technical gate.
  3. Attribution — which ad campaign produced this install or purchase? This is platform-mediated: AdAttributionKit / SKAdNetwork on iOS, Play Install Referrer and the Google Play Billing signals on Android. It is not solved by reading an advertising ID.

Apple's rule is the one people get wrong most often: the ATT prompt is required when you access the IDFA or correlate user/device data with data from other companies for advertising or data-broker purposes. Plain in-app product analytics with your own identifier generally does not require the prompt, but pulling in a third-party SDK that shares device data for ads does. Read your SDK's documentation, not its marketing page.

1. A consent gate that runs before anything else

The mistake is initialising the analytics SDK in main.ts and asking for consent on screen three. By then the SDK has already written storage and sent a session start. Gate the initialisation, not just the event calls.

// src/consent/store.ts
import { Preferences } from '@capacitor/preferences';

export type ConsentState = {
  version: number;              // bump when purposes change -> re-ask
  analytics: boolean;
  ads: boolean;                 // personalised advertising / cross-company data
  crash: boolean;               // diagnostics
  decidedAt: string | null;
};

const KEY = 'consent.v1';
export const CURRENT_VERSION = 3;

const DENY_ALL: ConsentState = {
  version: CURRENT_VERSION,
  analytics: false,
  ads: false,
  crash: false,
  decidedAt: null,
};

export async function loadConsent(): Promise<ConsentState> {
  const { value } = await Preferences.get({ key: KEY });
  if (!value) return DENY_ALL;
  const parsed = JSON.parse(value) as ConsentState;
  // A purposes change invalidates the old decision.
  return parsed.version === CURRENT_VERSION ? parsed : DENY_ALL;
}

export async function saveConsent(next: Omit<ConsentState, 'version' | 'decidedAt'>) {
  const state: ConsentState = {
    ...next,
    version: CURRENT_VERSION,
    decidedAt: new Date().toISOString(),
  };
  await Preferences.set({ key: KEY, value: JSON.stringify(state) });
  await applyConsent(state);
  return state;
}

applyConsent is the only place allowed to start or stop an SDK:

// src/consent/apply.ts
import type { ConsentState } from './store';

let analyticsReady = false;

export async function applyConsent(c: ConsentState) {
  if (c.analytics && !analyticsReady) {
    const { initAnalytics } = await import('../analytics/client');  // lazy: no code runs until granted
    await initAnalytics();
    analyticsReady = true;
  }
  if (!c.analytics && analyticsReady) {
    const { shutdownAnalytics } = await import('../analytics/client');
    await shutdownAnalytics();     // flush nothing, clear local ids
    analyticsReady = false;
  }

  const { FirebaseAnalytics } = await import('@capacitor-firebase/analytics');
  await FirebaseAnalytics.setConsent?.({
    analyticsStorage: c.analytics,
    adStorage: c.ads,
    adUserData: c.ads,
    adPersonalization: c.ads,
  });
}

Two details that matter in review:

  • The analytics client is a dynamic import. If the module constructs an SDK at import time — many do — a static import means it runs regardless of your gate.
  • Consent Mode expects the four signals (analytics_storage, ad_storage, ad_user_data, ad_personalization) to be set before the first event, including the automatic first_open. Set defaults to denied in the native config (FIREBASE_ANALYTICS_COLLECTION_ENABLED = NO in Info.plist, firebase_analytics_collection_enabled = false in the Android manifest) and enable after the user decides.

Bootstrap in the app shell, not in a page:

// src/main.ts
import { loadConsent } from './consent/store';
import { applyConsent } from './consent/apply';

const consent = await loadConsent();
await applyConsent(consent);      // deny-all on first launch: nothing initialises
render(<App initialConsent={consent} />);

2. The ATT prompt: ask late, ask once, ask with context

AppTrackingTransparency gives you one shot per install. Prompting on the splash screen is the reliable way to get a denial. Prompt when the user is already in a flow where the benefit is obvious, and show your own explanation screen first.

import { AppTrackingTransparency } from 'capacitor-plugin-app-tracking-transparency';
import { Capacitor } from '@capacitor/core';

export async function requestTrackingIfNeeded(): Promise<'authorized' | 'denied' | 'n/a'> {
  if (Capacitor.getPlatform() !== 'ios') return 'n/a';

  const { status } = await AppTrackingTransparency.getStatus();
  if (status !== 'notDetermined') {
    return status === 'authorized' ? 'authorized' : 'denied';
  }

  // Our own pre-prompt sheet has already been accepted by the caller.
  const res = await AppTrackingTransparency.requestPermission();
  return res.status === 'authorized' ? 'authorized' : 'denied';
}

Rules we hold clients to:

  • NSUserTrackingUsageDescription must describe the actual benefit. Vague strings get rejected.
  • Do not gate app functionality on authorisation, and do not re-prompt with a custom nag after denial — both are rejection reasons.
  • If ATT is denied, ads consent is false on iOS no matter what your own consent sheet said. Treat the platform answer as the ceiling:
const att = await requestTrackingIfNeeded();
await saveConsent({ analytics: true, ads: sheet.ads && att === 'authorized', crash: true });

3. Attribution without an advertising ID

Stop trying to read IDFA/GAID for install attribution. The current mechanics:

iOS — AdAttributionKit. Postbacks are sent by the system to your registered endpoint; no user-level identifier and no ATT prompt required for the basic conversion signal. Your app's job is small: register conversion values at the right moments. There is no first-party Capacitor plugin at time of writing, so this is a ten-line bridge (see our custom-plugin tutorial for the scaffolding):

// ios/App/App/AttributionPlugin.swift
import Capacitor
import AdAttributionKit

@objc(AttributionPlugin)
public class AttributionPlugin: CAPPlugin {
  @objc func updateConversion(_ call: CAPPluginCall) {
    let value = call.getInt("value") ?? 0
    let coarse: CoarseConversionValue = value >= 20 ? .high : (value >= 10 ? .medium : .low)
    Task {
      do {
        try await Postback.updateConversionValue(value, coarseConversionValue: coarse, lockPostback: false)
        call.resolve()
      } catch { call.reject("postback failed: \(error.localizedDescription)") }
    }
  }
}

Plan the conversion-value schedule before you write the code: you get a small number of postback windows, so map them to business milestones (registered, first key action, first purchase) rather than to arbitrary screen views. On older OS versions keep the SKAdNetwork path (SKAdNetwork.updatePostback...) behind an availability check; both can coexist during the transition.

Android — Play Install Referrer. One native call, no consent needed for the referrer string itself, and it survives Play Store redirects:

val client = InstallReferrerClient.newBuilder(context).build()
client.startConnection(object : InstallReferrerStateListener {
  override fun onInstallReferrerSetupFinished(code: Int) {
    if (code == InstallReferrerClient.InstallReferrerResponse.OK) {
      val r = client.installReferrer
      bridgeToJs(r.installReferrer, r.referrerClickTimestampSeconds)
    }
    client.endConnection()
  }
  override fun onInstallReferrerServiceDisconnected() {}
})

Web-to-app deep links. Most of your real attribution signal comes from your own links, which you already control if you set up Universal Links and App Links. Capture the campaign parameters on first open and send them with the signup event:

import { App } from '@capacitor/app';

App.addListener('appUrlOpen', ({ url }) => {
  const q = new URL(url).searchParams;
  const campaign = {
    source: q.get('utm_source'),
    medium: q.get('utm_medium'),
    campaign: q.get('utm_campaign'),
  };
  if (campaign.source) sessionStorage.setItem('campaign', JSON.stringify(campaign));
});

4. An event schema that does not rot

Free-form track('button clicked') calls turn into an unusable dashboard within two releases. Type the schema, keep it small, and make properties explicit.

// src/analytics/events.ts
export type AppEvent =
  | { name: 'screen_view'; screen: string }
  | { name: 'signup_completed'; method: 'email' | 'apple' | 'google' | 'passkey' }
  | { name: 'subscription_started'; plan: string; trial: boolean }
  | { name: 'search_performed'; resultCount: number }
  | { name: 'sync_failed'; reason: 'offline' | 'conflict' | 'server' };

export type EventName = AppEvent['name'];
// src/analytics/track.ts
import { loadConsent } from '../consent/store';
import type { AppEvent } from './events';

const queue: AppEvent[] = [];

export async function track(event: AppEvent) {
  const c = await loadConsent();
  if (!c.analytics) return;                       // dropped, never buffered to disk
  queue.push(event);
  if (queue.length >= 20) await flush();
}

Three conventions worth enforcing in code review:

  • No PII in properties. Never an email, phone, free-text search string or raw file path. If you need to join to a user, send an opaque internal id.
  • Screen views come from the router, not from each page. In Ionic Angular, subscribe once to Router.events; in Ionic React, use one useIonViewDidEnter-based hook in a layout component. Page-level calls always drift.
  • Errors are events too. sync_failed and payment_failed with a reason code tell you more about retention than any funnel.

5. Make the compliance paperwork match the code

Your declarations are read against your actual traffic, so generate them from the code, not from memory.

  • Apple privacy manifest (PrivacyInfo.xcprivacy) in the app and in each SDK. Third-party SDKs on Apple's list must ship signed manifests; if one does not, you will be blocked at upload. NSPrivacyTracking must be true if any bundled SDK does cross-company tracking, even if your own code does not.
  • Play Data Safety must list every collected type and every "shared with third parties" flag. Run a proxy (Charles, mitmproxy) over a full session with consent granted and again with consent denied, and diff the hostnames. That diff is your evidence.
  • Deletion path. GDPR and both stores expect an in-app route to account and data deletion; make sure it also clears analytics identifiers locally (shutdownAnalytics() above) and sends the provider's delete-user API call.

A verification checklist before you ship

Run this on a physical device, on a fresh install, with a proxy:

  1. Launch the app, decline everything. Expect zero requests to analytics, ads and attribution hostnames. This is the test that fails most often.
  2. Kill and relaunch. Still zero, and no consent sheet loop.
  3. Accept analytics only. Expect your analytics host, still no ad hosts, and Consent Mode signals showing ad_storage: denied in the payload.
  4. Accept everything with ATT authorised. Verify one — not two — ATT prompts over the whole install lifetime.
  5. Bump CURRENT_VERSION. Expect the sheet to reappear and collection to stop until the user decides again.
  6. Check the attribution postback endpoint receives a conversion for a test install, and that the campaign parameters from a deep link arrive on signup_completed.

Snapshot the proxy log for each step and keep it with the release. When a store reviewer or a client's DPO asks, you answer in a minute instead of a week.


Wiring consent, ATT and attribution correctly is a couple of days of work if it is done when the analytics stack goes in, and a painful retrofit once three SDKs are entangled with your bootstrap. If you want senior Ionic consultants to audit what your app is actually sending and fix it, get in touch — an audit of this kind is usually a short, fixed-scope engagement.