Adding Mediation Partners Quietly Starved My iOS Attribution — Reconciling SKAdNetwork IDs Across Four Apps
I added mediation partners but iOS revenue barely moved — the cause was missing SKAdNetwork IDs in Info.plist. Here is how I reconciled SKAdNetworkItems across four apps, using an Antigravity agent as the matcher while keeping the revenue decisions by hand.
The week I added three Bidding-type partners to my mediation stack, iOS requests went up but revenue barely moved. The same change lifted Android cleanly, so for a while I assumed it was just thin inventory. What didn't add up was the asymmetry: in the AdMob dashboard every network was responding — the slots were being filled — yet eCPM stayed sluggish on iOS only.
The cause wasn't inventory. It was measurement. I had never registered the new partners' SKAdNetworkID values in the SKAdNetworkItems array of Info.plist, so installs coming through those networks were invisible to iOS attribution. When measurement thins out, optimization has nothing to learn from, bids don't climb, and eCPM stalls. The ads serve, but the revenue doesn't follow — a confusingly quiet failure.
Running four iOS apps as an indie developer (wallpaper and relaxation titles at Dolice), this kind of silent decay is the opponent I fear most. A crash sets off Crashlytics; a missing SKAdNetwork registration throws no error at all — revenue just fails to grow. This is the record of how I isolated it and built a routine to keep Info.plist aligned across all four apps, using an Antigravity agent as the matcher.
Suspect Measurement, Not Inventory, When "Filled but Flat"
The first thing I checked was that match rates for each network showed up in the AdMob mediation report, yet the optimized (Bidding) networks contributed far less than expected — on iOS only. The gap versus Android was the clue. Android has no OS-level attribution registration like SKAdNetwork, so adding an adapter measures it immediately. If only iOS lags, the iOS-specific measurement path — SKAdNetwork — is the natural suspect.
The isolation order goes like this. First confirm the ad is actually serving (is there an impression?) using Ad Inspector. Then, if it serves but optimization won't converge, suspect whether installs from that network are being measured at all. SKAdNetwork only delivers a postback (the install notification) when the ad network's ID is pre-registered in the app's Info.plist. Without it, installs through that network become "nobody's win," and bid optimization gets no fuel.
Symptom
Common misdiagnosis
Actual cause
New partner's eCPM flat on iOS only
Thin inventory / low-CPM market
SKAdNetworkID unregistered, so measurement is missing
Responses arrive but optimization won't learn
Learning period too short
Postbacks never arrive, so optimization has no fuel
Effect is uneven across four apps
Different audiences per app
Info.plist registrations drifted between apps
Why SKAdNetworkItems Is the "Fuel" for Optimization
SKAdNetworkItems is the list of ad-network identifiers you enumerate in Info.plist. One entry looks like this:
<key>SKAdNetworkItems</key><array> <dict> <key>SKAdNetworkIdentifier</key> <string>cstr6suwn9.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>ludvb6z3bs.skadnetwork</string> </dict> <!-- ... continue with each mediation partner's ID ... --></array>
Only networks whose ID is registered here can receive install postbacks through StoreKit. Adding a mediation partner effectively means "calling a new ad network," so if that network's ID isn't in Info.plist, you get a half-working state: delivery works, measurement doesn't.
The crucial point is that adding the AdMob adapter (the Pod or SPM package) and updating SKAdNetworkItems are two separate actions. Adding the adapter starts delivery. To start measurement, you must hand-add the network's published SKAdNetworkID to Info.plist. Because the two are decoupled, "adapter added but ID forgotten" drift is especially easy across multiple apps.
My four apps share nearly identical ad setups, so they should have carried the same ID set. In reality, small drifts had accumulated — one app had Liftoff's ID while another didn't. Edit Info.plist by hand four times and you'll skip one about once. That was the real shape of the "uneven decay."
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why adding adapters without updating SKAdNetworkItems in Info.plist quietly starves iOS attribution and depresses eCPM
✦A reconciliation script that diffs each network's public ID against what is registered, plus the exact agent prompt to drive it
✦A division of labor — humans decide which IDs, the agent does the tedious matching — enforced by a pre-build gate
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Always Source IDs from Primary Docs — Never Let the Agent Invent Them
This is where I was most careful in the operational design. You must not guess SKAdNetworkID values. Each one must be the exact published string from the network, and if something autocompletes a similar-looking string, you'll register a non-existent ID and break measurement.
So I set a rule. Humans fix the source of truth for the IDs; the agent only matches and detects diffs. Concretely, I keep each mediation partner's published SKAdNetworkID — plus Google's published list of AdMob partner IDs — as a canonical file. The agent's only job is to reconcile each app's Info.plist against that canon. If it ever proposes an ID not in the canon, that proposal is rejected by definition.
I kept the canon as plain YAML, with source URLs in comments so a human can verify which ID maps to which network and adapter.
# skadnetwork_canonical.yaml# Source: each network's official docs + AdMob's partner ID list# (URLs kept in internal notes; always copy values from primary sources)networks: admob: ["cstr6suwn9.skadnetwork"] applovin: ["ludvb6z3bs.skadnetwork"] unity_ads: ["4dzt52r2t5.skadnetwork"] inmobi: ["pwa73g5rt2.skadnetwork"] liftoff: ["gta9lk7p23.skadnetwork"]# enabled: the partners actually turned on per app (a human decides this)apps: beautiful_wallpapers: [admob, applovin, unity_ads, inmobi, liftoff] ukiyoe_wallpapers: [admob, applovin, unity_ads, inmobi, liftoff] relaxing_healing: [admob, applovin, unity_ads, inmobi, liftoff] law_of_attraction: [admob, applovin, unity_ads, inmobi, liftoff]
The contents of enabled — which networks to turn on — is a revenue decision, so I keep it in human hands. The agent only touches the mechanical check of whether Info.plist matches this configuration.
The Reconciliation Script — Diff Four Info.plists Against the Canon
The matching itself fits in a short script. Pull the currently registered IDs from each Info.plist, compare against what the canon requires, and report what's missing and what's extra.
#!/usr/bin/env python3# reconcile_skadnetwork.py — diff SKAdNetworkItems against the canonimport plistlib, sys, yamlCANON = yaml.safe_load(open("skadnetwork_canonical.yaml"))NET_IDS = CANON["networks"]def registered_ids(plist_path: str) -> set[str]: with open(plist_path, "rb") as f: data = plistlib.load(f) items = data.get("SKAdNetworkItems", []) return {i["SKAdNetworkIdentifier"].lower() for i in items if "SKAdNetworkIdentifier" in i}def required_ids(app_key: str) -> set[str]: ids = set() for net in CANON["apps"][app_key]: ids.update(s.lower() for s in NET_IDS[net]) return idsdef main(mapping: dict[str, str]) -> int: exit_code = 0 for app_key, plist in mapping.items(): have = registered_ids(plist) need = required_ids(app_key) missing = sorted(need - have) # networks whose measurement is missing extra = sorted(have - need) # IDs not in the canon = unknown provenance status = "OK" if not missing and not extra else "DRIFT" print(f"[{status}] {app_key}: missing={missing} extra={extra}") if missing or extra: exit_code = 1 return exit_codeif __name__ == "__main__": # app_key -> path to Info.plist MAPPING = { "beautiful_wallpapers": "apps/BeautifulWallpapers/Info.plist", "ukiyoe_wallpapers": "apps/UkiyoeWallpapers/Info.plist", "relaxing_healing": "apps/RelaxingHealing/Info.plist", "law_of_attraction": "apps/LawOfAttraction/Info.plist", } sys.exit(main(MAPPING))
missing is networks whose measurement is absent; extra is unknown-provenance IDs not in the canon. Leave the latter alone and old hand-added or mistyped IDs linger forever. On my first run, three of four apps showed missing, and one showed extra from a duplicate. It was the moment the accumulation of manual edits became visible as a diff.
The script returns pass/fail via exit code, so it drops straight into the pre-build gate below. Whether you treat extra as a warning or a failure is your call on strictness. My policy is "leave no unknown IDs," so I fail on it.
Make the Agent Report the Diff with Evidence Before It "Fixes" Anything
With the reconciliation results in hand, I used the Antigravity agent to actually align Info.plist. But letting it edit straight away invites it to be "helpful" and add IDs that aren't in the canon. So I split the instruction into two stages.
# Instruction to the agent (gist)Premise:- The only source of truth is skadnetwork_canonical.yaml. Never add or propose any SKAdNetworkID not present there. Do not "fill in" new IDs from anywhere.Task (step 1, report only):- Run reconcile_skadnetwork.py and show each app's missing / extra in a table.- Map each missing entry to the network it corresponds to in the canon.- Do not edit any file at this stage.Task (step 2, after my approval):- Append only the approved missing IDs to each Info.plist's SKAdNetworkItems.- List extra (unknown) IDs as deletion candidates; confirm removal with me.- Re-run reconcile_skadnetwork.py and paste the output showing all apps OK.Done criteria:- Don't say "fixed it" — show the re-run OK output as evidence.
This "report → approve → apply → re-verify with evidence" shape is the line that lets the agent take over the tedious, error-prone matching while I keep the revenue-relevant decisions. The agent is fast at the step-1 matching and the step-2 mechanical append. What's left for me is deciding enabled and giving the final yes/no on deleting extra.
What mattered most in practice was putting "do not invent IDs" in the very first line. Without it, the agent kindly offers "this network is probably this ID." In a domain like SKAdNetwork, where a single wrong character breaks things, that helpfulness was the most dangerous part.
Turn It into a Pre-Build Gate So It Never Quietly Starves Again
Aligning once doesn't help if the same drift returns the next time you add a partner. So I made the reconciliation a pre-build gate. Run it in an Xcode Run Script phase or as a CI pre-build step, and stop the build when it reports DRIFT.
# Pre-build step (CI or an Xcode Run Script phase)set -euo pipefailpython3 reconcile_skadnetwork.py || { echo "❌ SKAdNetworkItems has drifted from the canon. Check the reconcile output." exit 1}echo "✅ SKAdNetworkItems matches the canon across all four apps"
The point of gating is to promote SKAdNetwork registration from "fix it when you notice" to "the build won't pass while it's drifted." Precisely because measurement decay never turns red, it's worth putting a machine on watch. It's the same instinct as letting Crashlytics stop crashes — put a guard on the quiet revenue leaks too.
While you're touching SKAdNetwork, it's also safer to check NSPrivacyAccessedAPITypes (the privacy manifest) for consistency. Ad measurement is an area where a mismatch between declaration and implementation can bounce you in review, so I treat adding IDs and revisiting the declaration as parts of the same task.
After I finished aligning, the optimized networks' contribution on iOS drifted toward Android's gains over a few days. Not a dramatic reversal — just the missing measurement returning, learning resuming. Yet for running multiple apps over the long haul as an indie developer, sealing these quiet leaks is the kind of improvement that pays off most.
If you run mediation across several apps too, start by reconciling one Info.plist against a canon. If missing shows up, your measurement is probably a little starved. Thanks for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.