# SDK start order, identity, releases and offline delivery

The app-side practices that decide whether attribution and analytics reports can be trusted. Vendor API names were checked against live docs on 2026-10-17 for the SDK versions those pages cover. Confirm the version the app actually ships before copying a call.

## Contents

- First-launch sequence
- Combining the CMP answer with ATT
- Sizing the ATT wait
- Passing consent to each SDK
- Identity at login and logout
- Acquisition source in product analytics
- Server-to-server events
- Release versioning
- Offline queues
- Test devices and debug builds
- First-launch acceptance paths

## First-launch sequence

Write this sequence for the app and check it against debug logs with timestamps:

```text
1. Static defaults, in Info.plist and the merged Android manifest, so they
   apply before the first send: analytics_storage and ad_storage denied
   if the app serves any consent region; ad_user_data and
   ad_personalization set to eu_consent_policy or denied.
2. App launches. Read the stored CMP answer, the user's region (from the
   CMP's region check) and the current ATT status.
3. Initialise analytics and MMP SDKs. Debug logging on only in debug
   builds, set before any other SDK call so the full log is captured.
4. If the app will show the ATT prompt: configure the MMP's ATT wait
   before starting it, with the timeout from "Sizing the ATT wait".
5. Pass the consent state to the MMP, then start it at the vendor's
   documented lifecycle point.
6. Outside consent regions, grant consent at runtime as soon as the region
   is known. Inside, show the CMP when there is no stored answer.
7. Show the ATT prompt at the planned moment, only while the app is
   active, and only if the CMP answer allows it (see the next section).
8. On each answer: update consent in every SDK and record the ATT status
   as a user property or event parameter.
9. On login: set the user ID in every SDK (see identity section).
```

Why static defaults come first: Google says no consent mode values are set by default. Anything Firebase sends before the first `setConsent` call carries no denial, so a runtime-only default can let `first_open` and `session_start` go out as if granted. `analytics_storage` has no region-aware static value, so the trade is explicit: a global denied default sends the first events outside consent regions as denied until the runtime grant; a granted default sends without consent inside them. Choose denied and grant as early as the region is known.

One store binary ships worldwide. Do not plan "regional builds" of the consent flow; use the static plus runtime pattern above.

Vendor lifecycle points, as documented on the checked date:

- AppsFlyer iOS: call `start` in `applicationDidBecomeActive`, or from a `UIApplicationDidBecomeActiveNotification` observer when the app uses scene delegates. Call `waitForATTUserAuthorization(timeoutInterval:)` before `start`, and only if the app will call `requestTrackingAuthorization`.
- AppsFlyer Android: call `init` and then `start` in `Application.onCreate`. To wait for consent, start later from an Activity and pass the Activity context; AppsFlyer warns that without it the SDK is not triggered and attribution and events are lost.
- Adjust iOS: `attConsentWaitingInterval` delays sending for up to 360 seconds; if the user closes the app before the timeout or an answer, the timer restarts on the next open. Some older Adjust pages state a lower cap, so confirm against the SDK version in use.
- Other SDKs: find the equivalent start point and ATT or consent wait in the vendor's current docs. Do not assume the same names.

## Combining the CMP answer with ATT

Default rule, with the legal basis confirmed by the privacy owner and recorded in `decisions.md`:

- Treat a denial in either the CMP or ATT as no tracking: no IDFA use and no ad data sharing for that user.
- If the CMP answer denies ad consent, skip the ATT prompt. The prompt is one-shot, so asking after a denial spends it on an answer that cannot be used. In the EU from iOS 27.2 the next chance comes a year after the answer.
- If ATT is authorized but the CMP denies ads, the CMP answer governs what the SDKs send.
- Record both answers on first launch so reports can split coverage by each.

## Sizing the ATT wait

- Instrument production: `att_prompt_requested` (only when the prior status was `notDetermined` and the app was active) and `att_status_changed`, each with seconds since first open.
  - `att_status_changed` fires only on `authorized` or `denied`. A completion that returns `notDetermined` after an Additional Information tap (EU, iOS 27.2 and later) is logged as `att_additional_info_tapped`, and the follow-up request is part of the same prompt.
  - Add `att_prompt_kind` (`first` or `eu_reprompt`), so year-later EU re-prompts never enter the first-prompt opt-in rate or the timing percentile.
- Compute the 90th percentile of time from first launch to the ATT answer from production cohorts, and report beside it the share of users who left before the prompt. Users who leave are not in the percentile, so a low p90 with a high early-exit share is not a pass.
- New app or new placement with no data: use the vendor's documented default or example until two weeks of production data exist. AppsFlyer's iOS page gives 60 seconds for a prompt at launch and 120 seconds after a tutorial of about two minutes as examples.
- Set the MMP wait to at least that 90th percentile, within the vendor's documented maximum where one exists (Adjust: 360 seconds). Where none is documented (AppsFlyer documents none), set the product's own ceiling and record it in `decisions.md`.
- If the 90th percentile exceeds the maximum or ceiling, move the prompt earlier. A wait that expires before the prompt sends first-launch data without the ATT answer; a prompt after a long tutorial that many users abandon loses the chance to ask.
- The prompt is one-shot on every OS and in every region; from iOS 27.2, EU users can be asked again only a year after their last answer. Place the prompt after a value moment, never at cold start.
- If the app will never prompt, do not configure an ATT wait at all.
- Re-measure whenever onboarding changes. Record the prompt placement version so the opt-in rate can be compared across versions.

## Passing consent to each SDK

Google Analytics for Firebase:

- Four consent types: `analytics_storage`, `ad_storage`, `ad_user_data`, `ad_personalization`. No consent values are set by default.
- iOS defaults in `Info.plist`: `GOOGLE_ANALYTICS_DEFAULT_ALLOW_ANALYTICS_STORAGE`, `GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGE`, `GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_USER_DATA`, `GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS`.
- Android defaults as `<meta-data>` in the manifest: `google_analytics_default_allow_analytics_storage`, `google_analytics_default_allow_ad_storage`, `google_analytics_default_allow_ad_user_data`, `google_analytics_default_allow_ad_personalization_signals`.
- `eu_consent_policy` can be set as the default for `ad_user_data` and `ad_personalization` only (not `ad_storage` or `analytics_storage`), on iOS in `Info.plist` and on Android in the manifest. It denies them only where the EU User Consent Policy applies.
- Update at runtime with `setConsent`. Each call changes only the types passed, overrides the default and persists across launches. Withdrawing analytics or ad storage consent deletes user properties, including the ad personalization setting, so restore it with `setConsent`.
- Collection on or off is a separate switch (`FIREBASE_ANALYTICS_COLLECTION_ENABLED` on iOS, `firebase_analytics_collection_enabled` on Android, then `setAnalyticsCollectionEnabled`). Do not use it as a substitute for consent mode.

AppsFlyer (DMA consent):

- With a TCF v2.2 or later CMP: call `enableTCFDataCollection(true)` at initialisation and call `start` only after the CMP has stored the user's choice.
- Without a CMP: build an `AppsFlyerConsent` with `isUserSubjectToGDPR`, `hasConsentForDataUsage`, `hasConsentForAdsPersonalization` and `hasConsentForAdStorage`, and pass it to `setConsentData` before `start`. Only parameters passed explicitly are registered.
- Verify in debug logs: the outgoing request contains `consent_data` with a `tcf` or `manual` key.

Other MMPs and SDKs have their own consent and third-party sharing APIs. Look them up live and add them to the sequence.

## Identity at login and logout

- One user ID for all SDKs: the backend's opaque account ID. Google's rule for the Analytics user ID is that it must not let a third party identify the person; an email address is the example it rules out.
- IDs are not retroactive. Firebase says adding a user ID does not change earlier events. AppsFlyer says that if `start` runs before `setCustomerUserId`, the install is not linked to the customer user ID.
- AppsFlyer Android SDK V7: set the customer user ID inside the session-ready listener before `start`, and re-apply it on every cold start, because V7 does not persist setter values between sessions. `waitForCustomerUserId` and `setCustomerIdAndLogSession` were removed in V7. Older SDK versions had a wait mode that held back or dropped activity until an ID arrived; never use a wait like that unless every user logs in before any measured action.
- Anonymous first: the install is anonymous, the account ID arrives at sign-up or login, and the join happens in the warehouse on the MMP install ID or analytics app instance ID recorded at that moment.
- Logout: clear the user ID in analytics, MMP and messaging SDKs. On shared devices the next login must not inherit the previous user.

## Acquisition source in product analytics

Onboarding and retention by acquisition cohort need the acquisition source on the product analytics profile. Pick one path and write it in the measurement map:

- Pass the analytics ID to the MMP so its partner integration sends attribution to the analytics tool. Example: Adjust's Mixpanel module accepts only callbacks that carry the Mixpanel distinct ID, passed as the `mixpanel_uuid` session partner parameter before the Adjust SDK starts. Check your MMP's page for its analytics partner.
- Or join MMP raw data to analytics in the warehouse on the MMP install ID or analytics app instance ID recorded at first launch and login.
- QA: send one attributed test install and confirm it sits on the same profile or `user_id` as that user's later events. If the IDs do not join, campaign cohorts show installs with no later activity and retention by network looks worse than it is.
- On iOS, this covers only users with a user-level source (ATT authorized, AdServices, link-attributed). Show that coverage share beside every funnel.

## Server-to-server events

- S2S events need the install identifier the MMP issued (for AppsFlyer, the `appsflyer_id`). Store `user_id`, platform, app ID, MMP install ID and `first_seen_at`, and write the rule for which install a backend renewal is sent against (for example, the install that started the subscription). "Most recent login" silently moves attribution when the user changes devices.
- Send a backend event only after the install ID has been observed from the SDK on the device, and only after the MMP has had time to process the install. The app has the AppsFlyer ID from first launch, before AppsFlyer has processed the install.
  - AppsFlyer's mobile S2S article says S2S events that arrive before install processing finishes (which it says generally takes 20 to 30 seconds or more) may be marked as unattributed organic, and recommends a short delay for events right after install.
  - Rule: hold S2S events that happen in the first minutes after install for longer than that processing time, or until the SDK's conversion data callback has returned for that install. Record the delay in `decisions.md`.
  - Other MMPs: ask how they treat an event that arrives before the install is processed, and set the delay from the answer.
- Store occurrence time and delivery time for every backend event. A late event can take its arrival time instead of its occurrence time, which moves renewals and refunds into the wrong cohort day after a queue backlog.
  - AppsFlyer, as documented: an S2S event keeps its `eventTime` only when it arrives on the day of the event or the next day before 02:00 UTC. Later arrivals, and events without `eventTime`, take the arrival time. An event at 23:00 UTC has 3 hours.
  - Size the alert from the cut-off, not from a fixed queue age. For AppsFlyer: alert when any event from the previous UTC day is still unsent at 01:00 UTC.
  - Other MMPs: read the current S2S timestamp rule and size the alert the same way.
- Validate the payload before sending: reject missing install IDs, malformed amounts and unknown currencies into a visible retry queue rather than sending empty values.

## Release versioning

Keep one release record per app version:

```yaml
app_version: "4.12.0"
build: "41200"
platforms: [ios, android]
released_at: "2026-10-01"
sdk_versions:
  mmp: "x.y.z"
  analytics: "x.y.z"
conversion_value_updater: "mmp_sdk"
conversion_value_schema: "acme_fit_v3"
cv_schema_changed_at: "2026-09-15T09:00:00Z"
consent_flow_version: "cmp_v2"
att_prompt_placement: "att_v3_goal_setting_end"
mmp_config_changes: "none"
tests_passed: "release-qa-2026-09-29"
```

- Send `app_version` on every event. GA4 for Firebase also logs `app_update` with the previous version as a parameter.
- SKAdNetwork and AdAttributionKit postbacks do not carry your schema version. The cut-over depends on who updates conversion values:
  - App code updates them: the schema ships in a release. Record the first app version that sends it and the date its adoption passed your cut-over share, and decode older cohorts with the older mapping.
  - The MMP SDK updates them: the schema usually lives in the MMP dashboard and is fetched remotely, so a change can reach every installed version from the moment it is saved. The cut-over is the dashboard change timestamp; record it as `cv_schema_changed_at`. Mark cohorts whose conversion windows overlap the change as mixed, and freeze schema changes during budget tests. Verify in the MMP's conversion value documentation how it decodes postbacks around a change.
- Re-test identity, consent and ATT flows on every SDK major upgrade. Identity APIs change between major versions.

## Offline queues

- AppsFlyer Android, as documented: events are cached when no 200 response arrives, up to 40 events; events beyond that are discarded until the next successful response. Cached events are re-sent one per request. Events logged before `start` are cached until `start` runs.
- Other SDKs have their own limits. Look them up and write the limit into the tracking plan.
- Money and account facts come from the backend, never from a device queue.
- Event IDs and timestamps for queued events: use the tracking-plan-builder skill's mobile and offline reference if installed. Otherwise give each queued event a client-generated event ID and its occurrence time, and check each destination's late-event cut-off.

## Test devices and debug builds

- Debug logging off in release builds. AppsFlyer warns that debug logs can leak sensitive information.
- Device setup differs per test type. AppsFlyer example: the SDK Integration Tests install flow uses a registered test device; the re-attribution test needs an unregistered device and the documented retargeting setup. Find your MMP's equivalent for each test before running it.
- Keep QA revenue out of the ledger, the MMP and analytics:
  - App Store Server Notifications V2 carry `environment`, `Sandbox` or `Production`. TestFlight also uses the sandbox.
  - Google Play `purchases.subscriptionsv2` responses include a `testPurchase` object only for test purchases.
  - Filter on both before sending revenue anywhere, and confirm the field names live.
- Before each QA run, record devices, builds and the time window; afterwards, reconcile those records against attribution and revenue outputs. Exclude test traffic with flags, never by deleting rows.

## First-launch acceptance paths

These are also the minimum MMP integration QA when no dedicated QA skill is installed. Run each on a clean install of the release candidate. The evidence for each is a debug log excerpt with timestamps showing SDK start, the ATT and CMP answers, and the first outgoing request with its consent payload:

- ATT authorized at the prompt.
- ATT denied.
- ATT not determined: user leaves before the prompt.
- Early exit during onboarding, then reopen.
- Consent region, consent denied, then granted later.
- Login on first launch, and login on a later day.
- Logout, then a different user logs in on the same device.
- Offline first launch, then reconnect.
- One backend event sent S2S in the first minutes after install, held until the processing delay passed, recorded against the right install and not as organic.
- One attributed test install that lands on the same analytics profile as that user's later events.
