Boolean Build

ATT prompt never appeared: fixing an App Tracking Transparency rejection

Apple reviewed the build on iOS 27 and wrote: the app uses the AppTrackingTransparency framework, but the permission request could not be located. The code called requestTrackingAuthorization at launch. The prompt still never showed. Both statements were true at once.

This is the failure mode nobody documents, because it looks like a missing implementation when it is actually a timing bug. If your build links AppTrackingTransparency and Apple still says the prompt is absent, read the order of operations before you rewrite anything.

What Apple actually sent

“The app uses the AppTrackingTransparency framework, but we are unable to locate the App Tracking Transparency permission request when reviewed on iOS 27.0.”

Two paths follow: prove the prompt appears with a screen recording on a physical device, or remove the tracking declaration if the app does not track. Before choosing, check whether your call is even reaching the system.

Why a launch-time request returns nothing

On a cold start, a request fired from a module-scope effect can run while the app is still becoming active. The system answers undetermined without presenting anything. The call resolves, your await completes, and no UI ever appeared.

If your ads SDK initialises on the next line, the sequencing bug becomes a compliance problem: the app starts collecting before the user has answered. That is what a reviewer sees — a framework in the binary and a prompt that never appears.

// The shape of the bug: resolves, shows nothing, then ads start anyway
useEffect(() => {
  requestTrackingPermissionsAsync();   // app may not be active yet
  mobileAds().initialize();            // runs regardless of the result
}, []);

The fix is a sequence

Wait for the app to be active, read the current status, and only ask if it is still undetermined. Retry with backoff, because the first request can be dropped while the window is becoming key. Initialise ads last.

import { AppState, Platform } from "react-native";
import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync }
  from "expo-tracking-transparency";

function waitForActive(timeoutMs = 4000): Promise<void> {
  if (AppState.currentState === "active") return Promise.resolve();
  return new Promise((resolve) => {
    const sub = AppState.addEventListener("change", (s) => {
      if (s === "active") { sub.remove(); resolve(); }
    });
    setTimeout(() => { sub.remove(); resolve(); }, timeoutMs);
  });
}

async function ensureATT(): Promise<void> {
  if (Platform.OS !== "ios") return;
  for (let attempt = 0; attempt < 3; attempt++) {
    const current = await getTrackingPermissionsAsync();
    if (current.status !== "undetermined") return;      // already answered
    if (!current.canAskAgain) return;
    const result = await requestTrackingPermissionsAsync();
    if (result.status !== "undetermined") return;       // prompt presented
    await new Promise((r) => setTimeout(r, 600 * (attempt + 1)));
  }
}

Then the order in your root layout: waitForActive() → short settle → ensureATT() → mobileAds().initialize(). Nothing collects before the answer, and the prompt has an active window to attach to.

Check the binary, not the source

Before you blame the code, confirm what you actually shipped. The compiled JS bundle tells you whether the call was even in the archive:

APP=build/YourApp.xcarchive/Products/Applications/YourApp.app
grep -c "requestTrackingPermissionsAsync" "$APP/main.jsbundle"
/usr/libexec/PlistBuddy -c "Print :NSUserTrackingUsageDescription" "$APP/Info.plist"

A non-zero count plus a usage-description string means the implementation shipped and the problem is ordering. A zero means the build predates your fix — which happens more often than anyone admits, because archives are easy to reuse.

Replying to App Review

Name the mechanism, not the apology. The reply that works states the cause, the change, and what the recording shows:

Apple asks for the recording to be captured on a physical device, from a fresh install or after resetting tracking permissions, and attached in the Notes field of App Review Information. Simulators are not enough here — the reviewer knows the difference.

When removing tracking is the better call

If the app does not personalise ads, deleting the request and clearing the tracking declaration in App Store Connect is a legitimate path — and only the Account Holder or Admin can change privacy information. Be honest about what that costs: without ATT, ad requests are non-personalised, which lowers eCPM. If your revenue per thousand impressions is already in the low single digits, the personalisation you are protecting is worth very little, and the rejection loop costs more than the delta.

Related: the iOS 27 UIScene crash that hits builds compiled with the same SDK, ad frequency that survives App Review, and the pre-upload rejection checklist.