Skip to content

App Tracking Transparency

What is App Tracking Transparency and why does it matter for ads?

Section titled “What is App Tracking Transparency and why does it matter for ads?”

Since iOS 14.5, an app must ask permission before accessing the IDFA, and AdMob uses the IDFA for personalised ads and attribution. Without the prompt, iOS withholds the IDFA and every request serves non-personalised ads at materially lower eCPM. The AdMob CMP library does not decide this for the app — it exposes adManager.tracking and leaves the timing to the caller, because the timing is the whole problem.

ATT is the only place where the order of three otherwise unrelated calls determines whether the publisher gets paid for the ad. The consent form has to be presented, the ATT prompt has to be answered, and the first ad request has to be sent. Reversed or skipped, the user is forever non-personalised on that install.

A useful framing: the three calls are the publisher’s three permissions, and iOS is the only platform that requires all of them in a specific order. Android’s equivalent is the AD_ID permission at install time, which is automatic and unguarded. iOS chose to make the same permission a runtime prompt; the result is that the prompt’s moment is the publisher’s decision, and the moment is the entire constraint.

A common mistake: bundling the ATT prompt with the consent form into a single onboarding screen. The consent form is presented by the platform SDK and is modal; the ATT prompt is presented by iOS and is also modal. Showing one on top of the other confuses the user about which decision they are making and is rejected by App Review. The right shape is two distinct moments, each with its own screen and its own rationale, in the order documented above.

The order first, then the why:

adManager.consent.gatherConsent(config)
adManager.tracking.requestAuthorization()
adManager.initialize(config, ConsentMode.InitializeOnlyIfAlreadyAllowed)
Initialization order: UMP consent, then ATT, then initializeCLAUDE.md invariants #5, #11Read this diagram in words

The three reasons, one sentence each. UMP consent comes first because the GDPR / TCF decision determines whether ads may be requested at all. ATT comes next because the IDFA decision must be resolved before any request goes out. And initialization comes last with InitializeOnlyIfAlreadyAllowed so it does not re-present a form the app has already handled.

A practical note on the ConsentMode choice here. The InitializeOnlyIfAlreadyAllowed mode is the right one because the app has already driven the consent flow. Using GatherBeforeInitialize would re-present the UMP form on subsequent initialize calls, which is wrong because the publisher already chose whether to drive the flow. The pattern above is the right one for any iOS app that wants explicit control over the prompt order.

<key>NSUserTrackingUsageDescription</key>
<string>This identifier will be used to deliver personalised ads to you.</string>

This goes in the app target’s Info.plist. The string is shown verbatim in the system prompt so it should say what the user gets rather than what the app wants; App Review rejects vague or misleading strings. ./gradlew :admob-cmp-core:doctorIos checks GADApplicationIdentifier and SKAdNetworkItems but the tracking description is the developer’s responsibility to add. The iOS setup guide has the rest of the iOS configuration.

How do I read the tracking authorization state?

Section titled “How do I read the tracking authorization state?”
when (adManager.tracking.status()) {
AdTrackingAuthorization.Authorized -> Unit // IDFA available
AdTrackingAuthorization.Denied -> Unit // user said no
AdTrackingAuthorization.NotDetermined -> Unit // prompt not shown yet
AdTrackingAuthorization.Restricted -> Unit // blocked by policy or parental controls
AdTrackingAuthorization.NotApplicable -> Unit // Android, always
}

status() is synchronous and cheap, so it is safe to read in a settings screen. requestAuthorization() is suspending and presents the system prompt at most once per install — a second call returns the existing decision rather than re-prompting, which is an OS behaviour rather than a library one. The Restricted value is distinct from Denied: it is set by parental controls or device-management policy, and the user has not been given the choice to decline.

A practical detail: status() does not present UI, so it is safe to call in a LaunchedEffect or a composable’s render path. Some apps gate the first ad call on a status() != NotDetermined check, which is a way to enforce “never show a request before the user has answered”. That check is a stronger form of the order rule on the previous section: it is enforced at the call site, not just by the library’s init flow.

Can I request ATT from an initialization hook?

Section titled “Can I request ATT from an initialization hook?”

For apps that prefer a single gatherConsentAndInitialize call:

AdConfig(
androidAppId = "ca-app-pub-…",
iosAppId = "ca-app-pub-…",
initializationHooks = listOf(
object : AdInitializationHook {
override suspend fun onPhase(phase: AdInitializationPhase, config: AdConfig) {
if (phase == AdInitializationPhase.BeforeMobileAdsInitialize) {
adManager.tracking.requestAuthorization()
}
}
}
),
)

AdInitializationPhase has exactly three values — BeforeConsentRequest, BeforeMobileAdsInitialize and AfterMobileAdsInitialize — and BeforeMobileAdsInitialize runs after the UMP gate and before native GMA initialization, which is precisely the ATT slot. Hooks run exactly once per real native-initialization attempt, so cancelling one initialize() caller can never skip or duplicate a hook.

A note on the hook shape: the body is suspending, so it can call requestAuthorization() directly without launch. The hook itself runs inside the library’s internal nativeInitializationScope, which is a detached scope that survives caller cancellation. That is the design property that makes hooks safe to put critical side-effects in: even if the caller that triggered initialize cancels its coroutine, the hook runs to completion.

A second design property worth knowing: hooks run exactly once per real native-init attempt, not once per initialize call. A second initialize call with the same effective AdConfig identity does not re-run hooks; it just returns the cached result. That is the property that makes hooks safe to use for network-dependent side-effects — a re-run would otherwise mean a second ATT prompt and a second network round-trip.

One short paragraph. Android has no ATT. adManager.tracking is a no-op there and always reports AdTrackingAuthorization.NotApplicable, so the same common-code sequence compiles and runs correctly on both platforms with no expect/actual needed. The requestAuthorization() call is suspending on iOS and resolves immediately on Android, which means hooks that include ATT do not introduce a measurable delay on Android.

A related Android point: Android 13 and above have a similar notification-permission flow, but it is not coupled to ad attribution in the same way. The Android setup guide covers the AD_ID permission, which is the Android side of the same disclosure.