Every fix uncovered the next layer. The last one was not in the code at all — it was in when iOS agrees to show the ATT prompt.
An iOS app that shows ads deals with two separate permissions, and they do not substitute for each other:
Apple’s rule looks simple: the system prompt has to come first. It took a month and three rejections to satisfy it.
The rejection said the app showed a “custom prompt” instead of using ATT. This was the screen:
Our app wants to stay free for you. Ads help support our business. Tap “Allow” on the next screen to give permission to show ads that are more relevant to you.
That is precisely the pattern Apple forbids: telling the user to tap Allow. But I searched for the string and could not find it in the code — not in the source, not in the compiled binary. It was being pulled at runtime by Google’s UMP SDK from the AdMob console.
The fix went into the console, not the code: under AdMob → Privacy & messaging → App Tracking Transparency, the “IDFA explainer” message was removed from the app’s targeting. UMP fetches that message on every launch instead of bundling it, so no new build was needed — the moment the message stopped being served, the existing build stopped showing that screen.
⚠️ The other two cards (European regulations / US state regulations) were left alone: the first is a legal requirement in the EEA, the second is a privacy notice. Only the IDFA explainer was the violation.
The fix was verified in a US-region simulator: straight from the location permission to the system ATT dialog, nothing in between. Clean.
Apple cited the same clause again. But the screenshot attached to the rejection no longer showed the IDFA explainer — it showed Google’s TCF form. So the console fix had worked, and the problem had simply moved.
Apple’s review device was in the EEA. The order there was:
_ensureConsent() → GDPR/TCF form ← Apple counts this as a "custom prompt"
_ensureAttPermission() → system ATT dialog
MobileAds.initialize()
The first fix was verified in a US-region simulator, and there the consent flow returns immediately without showing anything — the GDPR form never appeared in that run, so the ordering problem was invisible.
Testing region-dependent behaviour in the wrong region is the same as not testing it. A clean result is not, by itself, evidence.
The order was flipped to ATT → UMP → SDK. Removing the UMP form was never an option — it is legally required in the EEA and our published privacy policy explicitly promises it; removing it would have been a false declaration. The order was the only variable we had.
Measured in an EEA-simulated run:
[ads] ATT result: notDetermined ← with the user answering NOTHING
...20s later: the consent flow timed out
The mechanism: iOS only shows the ATT prompt while the app is resumed. At launch, with our own location permission dialog in front, the app is inactive, and requestTrackingAuthorization() returns notDetermined instantly, without showing anything.
The code took that for “asked” and moved on, opening the UMP form; the ATT dialog then landed on top of it. What the user — and the reviewer — saw was UMP → ATT again, the exact order that had been rejected.
await that waited for the wrong thingThere was an await, but it was not awaiting what we thought. The call was saying “I cannot ask right now”, not “I asked”, and both came back as the same value.
The fix: a helper that waits until the app is in the foreground, and up to three retries when the prompt returns without being shown. 🔴 The wait budget and the retry counter were kept separate — in the first attempt they were shared, and a long-lived location dialog burned all three retries on waiting.
This time Apple cited a different clause, which meant the progress was real:
…the app shows a GDPR prompt that asks permission to track after the user has already selected “Ask App Not to Track”…
Google’s TCF form asks, on its first line, for consent to “Personalised advertising and content”. Opening that form after the user has said “do not track” is, in Apple’s reading, asking for a denied permission a second time.
The fix branches on the ATT answer:
| ATT answer | UMP form | Ad request |
|---|---|---|
| Allow | shown in the EEA | personalised |
| Ask App Not to Track | not shown | non-personalised; in the EEA, not requested at all without consent |
| Android / pre-iOS 14 | unchanged | unchanged |
Every undecided state — denied, restricted, an unanswered notDetermined, an exception — is treated as “no tracking”. When in doubt, the restrictive branch wins.
Something far worse turned up while chasing this: the canRequestAds value that carries the consent state was being read and only written to the log. The ad SDK and three separate ad requests ran independently of it.
Which means ads were being requested for EEA users who had refused consent — against Google’s own integration requirement and against GDPR, and precisely what Apple’s “there should be no tracking activity until the user grants permission” is aimed at. All four load paths now gate on that flag.
If the consent state cannot be resolved (first launch with no network, or the flow throws), no ads are shown at all for that session. Losing a session of revenue was chosen over requesting an ad without consent.
⚠️ This needs a monitoring point: if the consent configuration ever becomes “required everywhere”, revenue goes to zero silently and it is indistinguishable from a code bug. The diagnosis reads off one log line.