Why AppsFlyer shows organic installs when paid campaigns run
Our mobile measurement partner reported nearly every install as organic while we were paying for install campaigns on two major networks. The media-source field was never filled once. The cause was not one bug: 6 faults, 3 in app configuration and 3 in our own attribution code, each independently prevented a paid install from being matched or dropped the record entirely.
The expensive part was not any single fault. It was that six independent failures produced a plausible number, and that number survived on two separate dashboards long enough to influence channel decisions. This post covers the failure categories, how we isolated each one, why neighboring measurements failed the same way, and the validation we now run before an attribution change can ship.
The short version
- Nearly every recorded install read organic across a month of paid spend, so channel decisions were being made on a structurally wrong number.
- 6 separate faults contributed: an unset platform app identifier, a missing Android advertising ID permission, an incomplete ad network identifier list, a callback contract mismatch, an anonymization call tied to sign-in state, and a dropped late result.
- The decisive fault was 1 SDK call: the app anonymized every user before sign-in, so no advertising identifier was collected on first launch.
- A callback contract mismatch meant an entire build parsed nothing from the conversion callback, and every test still passed.
How install attribution actually resolves
Attribution answers one question for marketing: which channel brought this install. A measurement partner matches an ad click to an install through several independent methods, and being precise about them matters, because treating them as one mechanism is how teams misdiagnose this problem.
The common methods are an advertising identifier match, an install referrer, Apple's SKAdNetwork postbacks on iOS, and self-reporting network integrations where the network reports the match itself. They are not equivalent and they do not fail together. An unavailable advertising identifier does not by itself force an install to be organic, because a referrer or a self-reporting network can still resolve it. Our incident is what happens when enough paths break at once that none can.
When no method resolves, the vendor does not raise an error. It labels the install organic.
Two shapes of measurement failure: an empty dashboard gets questioned, while a plausible wrong number gets acted on
A system that fails toward a plausible value is more dangerous than one that fails loudly.
That default is what made this expensive. An empty dashboard gets questioned within a day. A dashboard saying paid campaigns produced nearly nothing while organic growth is strong reads like a finding, and budget moves on it. Our product analytics and the vendor's own dashboard agreed with each other, so two tools confirmed the same wrong answer.
Configuration faults
Three faults lived in configuration rather than code. None threw an exception, and none appeared in a debug build, because attribution runs only in release builds with consent granted.
An unset platform app identifier stopped one platform's SDK
The iOS SDK requires the store numeric app identifier to initialize. Ours was unset in the environment file, so that SDK never started while Android continued reporting. Half the platform mix produced no attribution, and nothing in the logs explained why. The example environment file now documents that an empty value disables attribution on that platform, and the release check refuses to build without it.
A missing permission zeroed the Android advertising ID
For apps targeting Android 13 or later, the manifest must declare the advertising ID permission or the identifier reads as all zeros. Ours, ported from an older app, did not. One caveat for anyone checking their own: a dependency can contribute this permission through manifest merging, so its absence from your source manifest is not proof it is missing from the merged result. Confirm against the merged output.
A zeroed identifier removes the identifier-match path. On its own that would not force an organic label, but combined with the other faults it closed one more route to a resolution.
An incomplete ad network identifier list
Apple signs SKAdNetwork install postbacks only for networks listed in the source app's property list. Ours carried one identifier attributed to a network by internal convention that no public source confirms, and omitted a published identifier for another. That makes SKAN validation impossible for the affected network, which is sufficient to leave those installs unattributed though not the only possible cause. We added the published identifiers, flagged the unverified entry, and set the postback copy endpoint. These compile into the binary, so a wrong list costs a full release cycle.
Install attribution pipeline from ad click to recorded source, showing the six fault categories that prevented paid installs from resolving
Each red box prevented or discarded a match. The amber box is the vendor's silent organic default.
Code faults
Fixing configuration did not move the number. The remaining 3 faults were in our own attribution adapter, and each passed review and tests.
The callback contract did not match what we parsed
The Flutter plugin delivers conversion data under a different top-level key than the native SDK uses for the same structure, and our parser read the native one. On every launch, on both platforms, the parse found nothing and returned early without a log line.
The distinction matters: the SDK was not failing to send install events. It delivered them, and our adapter discarded them before anything downstream could record a source. Tests were green because they covered the helpers below the parse, the gap we wrote about in making mobile apps more reliable with better testing. Key naming differs by plugin version, so verify against the version you ship rather than against a blog post.
Anonymization was tied to sign-in state instead of consent
This was the fault that held the number at organic after everything else was fixed. Our bind-user method called the SDK's anonymization switch whenever no user was signed in, and attribution begins before anyone signs in. Every fresh install was therefore anonymized at first launch. Conceptually:
text
# Before: a signed-out launch anonymized the install
bindUser(userId):
if userId is absent:
setAnonymized(true) # suppresses identifier collection at first launch
return
setAnonymized(false) # also silently reversed a revoked consent
setCustomerUserId(userId)
# After: binding only binds. Consent alone controls anonymization.
bindUser(userId):
if userId is absent: return
setCustomerUserId(userId)Two things deserve care. Anonymization is a privacy control, not an attribution switch, and consent must remain the only input that decides it. And it does not disable every attribution path uniformly: the vendor documents that it removes or hashes identifying information, and some methods can still resolve. In our integration it removed the identifier path at first launch, which is precisely when the install event is sent, and the vendor's guidance is to anonymize only after that event. The fix also closed the mirror defect, since signing in no longer re-enabled attribution for someone who had revoked consent.
We established this on a registered test device before changing code. With a test link producing a full referrer, the vendor's integration test reported the referrer detected and the identifier not collected, then returned an organic conversion. With the permission granted and the identifier client present, the anonymization call was the only remaining explanation.
A late first-launch result was discarded
The conversion callback carries a first-launch flag, and we recorded attribution only when it was true. If the first result arrived late, because the device was offline or the app was killed, the SDK replayed it on a later launch with the flag false, and the install never received a source. We now record from the first conversion result received, once per install, guarded by a stored key, and write it as set-once properties so a repeat is harmless.
The six faults side by side
| Fault category | Where it lived | Effect on the data | Fix |
|---|---|---|---|
| Unset platform app identifier | Environment config | One platform's SDK never started | Value required; release build refuses an empty one |
| Missing advertising ID permission | Android manifest | Identifier reads all zeros, removing one match path | Permission declared, verified on the merged manifest |
| Incomplete ad network identifiers | iOS property list | SKAN validation impossible for one network | Published identifiers added; postback copy endpoint set |
| Callback contract mismatch | Attribution adapter | Delivered events discarded before recording | Pure, tested parser built against the shipped plugin version |
| Anonymization tied to sign-in | Attribution adapter | Identifier not collected at first launch | Only consent controls anonymization |
| Late result discarded | Attribution adapter | Install never receives a source | Record the first result, once per install |
Why adjacent measurements failed the same way
Install attribution was not the only number affected. Three neighboring measurements had the same silent failure shape, which is the real pattern worth taking away.
Three adjacent measurements that failed silently in the same way as install attribution
None raised an error. Each produced a number that looked usable.
Uninstall measurement. The vendor measures uninstalls with a silent push and watches for delivery failure, and the token it needs differs by platform: the APNs device token on iOS, the messaging token on Android. Our iOS path supplied the wrong one, and because the plugin decodes whatever string it is given as APNs bytes, the registration succeeded structurally and measured nothing. We now send each platform's correct token, suppress the probe push before our notification pipeline surfaces it, and keep the dashboard credentials on the release checklist.
The checkout funnel. A checkout-started event fired twice per purchase and before add-to-cart, so any funnel built on it read in the wrong order with one step inflating itself. Events now map to standard names in the order a buyer encounters them, and the order control holds a busy state so one tap sends one.
Campaign parameters. Marketing asked why UTM tags were not appearing. Scope this one carefully, because the answer depends on the attribution product and link type rather than being universal. In our integration and link configuration, attribution resolved on the vendor's native media-source and campaign parameters, and the UTM values we set did not reach the raw export we were reading. Our links now carry the native parameters, the app copies UTM values into product analytics as install properties, and in-app share links carry their own media source so a friend's share stops counting as organic. Check your own link type against current vendor documentation before concluding the parameters are ignored.
Validation we run now
Every fault failed toward a plausible value instead of an error, so the guards target that property directly rather than any individual bug.
- Parse vendor callbacks in a pure, tested function built against the shape the shipped plugin version actually delivers, and log the "nothing to read" branch rather than returning silently.
- Run the vendor's integration test on a release build for every attribution change, and require identifier collected and a non-organic test install before merge.
- Treat attribution configuration as release configuration. Missing app identifiers, permissions or network identifiers block the build, not a dashboard review a week later.
- Compare the organic share against spend. If paid channels are spending and the organic share stays near total, treat the measurement as broken until proven otherwise.
Every vendor sits behind one adapter, which is why all 6 changes stayed inside the attribution seam, the pattern in the engineering principles behind scalable Flutter apps. It is the same lesson as designing reliable Terraform workflows: configuration the build does not check will drift quietly. More patterns sit in the software category.
Why measurement reliability is an engineering responsibility
When attribution is wrong in a direction that looks like good news, the error does not surface as an incident. It surfaces as a budget reallocated toward a channel that was never performing. Data quality is not a marketing concern that happens to touch our code: the adapter, the manifest, the property list and the release check are all ours, and so is the number that comes out the other end.
Hoomanely builds connected pet-care products that help a pet parent understand the animal in front of them. Every household that pairs an EverBowl™ with our app arrives through some channel, and reliable attribution lets us learn which channels bring households that stay with the product rather than those that merely install it. You can read more about EverBowl at hoomanely.com.
The honest limit: users who decline tracking on iOS are attributed only through Apple's aggregate reports, so for them we see channel performance in totals and never per person. EverBowl does not diagnose; the value is the record.
Conclusion
If your measurement partner shows nearly every install as organic while paid campaigns run, assume the measurement is broken before assuming the campaigns are. Work outward from configuration to code: the platform app identifier, the advertising ID permission on the merged manifest, the ad network identifier list, then when your code anonymizes, what shape your callback parser expects from the version you ship, and whether a late first-launch result is recorded. Run the vendor's integration test on a release build before any attribution change ships.
The broader point outlasts the vendor. A system is not reliable because it returns data. It is reliable when the data it returns can be trusted.
Behavior described here reflects the SDK and plugin versions we shipped and the link configuration we used. Verify against your own versions and current vendor documentation before applying any of it.
FAQs
Why does AppsFlyer show installs as organic during paid campaigns?
It labels an install organic when none of its attribution methods resolve a source. Common contributors are an SDK that never initialized, an unavailable or zeroed advertising identifier, anonymization applied before the install event is sent, or missing ad network identifiers on iOS. Check each on a release build, since debug builds often skip attribution entirely.
When should anonymization be called in a mobile attribution SDK?
Only when the user has declined attribution consent, and the vendor advises calling it after the install event is sent. Anonymizing earlier can suppress identifier collection at exactly the moment attribution depends on it. Tie it to your consent state, never to sign-in state.
Which token does uninstall measurement need?
It differs by platform: the APNs device token on iOS and the messaging token on Android. Supplying the wrong one can register successfully and still measure nothing, because the plugin decodes the string it is given without validating its origin.
Can iOS users who decline tracking be attributed per person?
No. Those installs are attributed only through SKAdNetwork postbacks, which are aggregated and delayed, so at the user level they read as organic or unattributed. Read iOS channel performance from the aggregate reports instead.
References
- AppsFlyer, anonymizing users and how it affects attribution
- AppsFlyer Flutter plugin, advanced API: uninstall token and conversion data
- AppsFlyer developer docs, uninstall measurement
- Google Play Console Help, advertising ID and the AD_ID permission
- Android Developers, merge multiple manifest files
- Apple Developer, NSAdvertisingAttributionReportEndpoint for SKAN postback copies