I was reviewing metrics for apps shipped on both iOS and Android. iOS gives me installs, revenue and funnels through the App Store Connect API. Android had been a blank for months. My notes said:
Play Developer Reporting API is disabled in the project (403) → needs checking in the Play Console UI
So Android sat in the "unverified" column and I made decisions from iOS alone. Looking at accounts, that was a dangerous place to be. Here's the provider breakdown of 264 signups over 28 days. (That 264 has its own trap → Every app had exactly 264 new users)
google 128 · apple 112 · email 24More than half of all accounts are not iOS. I was setting priorities while blind to half of them.
A question: is there a cell on your dashboard marked "inaccessible"? When did you last reproduce that verdict yourself?
Four characters: _rev_
I walked the access paths from scratch. The Reporting API really is blocked.
403 Google Play Developer Reporting API has not been used in project <projectNumber>
before or it is disabledThat has to be enabled in the Cloud Console; no API call gets around it. But the Play Console has another path — the older one that drops reports into a GCS bucket. My old notes had the bucket name.
pubsite_prod_rev_<developerAccountId> → 404 The specified bucket does not existThat 404 was the entire basis for "Android is not accessible via API." I tried a few more name candidates.
pubsite_prod_rev_<developerAccountId> 404
pubsite_prod_<developerAccountId> 200 ←
play_prod_rev_<developerAccountId> 404It's the one without _rev_. What I'd believed for months was a permissions wall was a fragment of a name. The service account had access the entire time. The key was already on disk; it just needed the devstorage.read_only scope.
Opening the bucket:
earnings/ financial-stats/ reviews/ sales/ stats/
stats/installs/ stats/ratings/ stats/store_performance/
installs_<package>_<YYYYMM>_{overview,country,device,language,os_version,app_version,carrier}.csvI got two more things wrong building the pipeline
The CSVs are UTF-16LE with a BOM. Read them as UTF-8 and you get NUL-laced garbage.
text = r.content.decode("utf-16", errors="replace").lstrip("")Trap 1 — summing a snapshot column over a month inflates it 30×. One CSV mixes two different kinds of column.
Active Device Installs ← a snapshot for that date (devices currently installed)
Install events ← flow for that dayI +='d everything at first. Active Device Installs = 523 came out; the real value on the last day of that month was 37. That's 30 daily snapshots added together. No error, just a plausibly large number. Snapshots now take the month's last value; flows get summed.
Trap 2 — parsing filenames by index breaks. installs_<pkg>_<YYYYMM>_<dim>.csv looks like a split-on-_-and-count job. It collapses on store_performance — the kind itself contains _, so the package comes out as performance_com.ootssu.plotta. You have to strip the prefix as a string.
stem = Path(name).stem[len(kind) + 1:] # <pkg>_<YYYYMM>_<dim>
pkg, month = stem.rsplit("_", 2)[0], stem.rsplit("_", 2)[1]The numbers this produced
Map app, Android:
| Month | Installs | Uninstalls | Uninstall rate |
|---|---|---|---|
| 2026-07 | 44 | 22 | 50% |
| 2026-08 (partial) | 13 | 9 | 69% |
The same app's iOS uninstall rate is 12%. 4×. Meanwhile store-listing conversion is comparable — Play visitors 551 → acquisitions 34 (6.2%); iOS page views 1,056 → installs 85 (8.0%). So Android isn't small because it converts badly: it gets half the traffic, and the people who take it delete it far faster.
Real demand for this app wasn't iOS 85. It was iOS 85 + Android 44 = 129. Which matches what the account data had been telling me: half of it is non-iOS.
Only two packages had data in Play. The rest were never published there.
Three things to check right now
- Next to every constant in your notes (bucket names, endpoints, IDs), is there a command that verifies it? If not, that constant will become a wall.
- Pick one cell marked "inaccessible" and hit it again today. It may be spelling, not auth.
- Check whether your aggregation script sums snapshot columns the same way as flow columns. Getting this wrong raises no error.
The honest part
Months of decisions were made on half the data. I didn't go back and revise them — I only redrew the current picture.
The real lesson is this: my notes contained a wrong bucket name, and I kept re-confirming "impossible" from those notes. Recording failures is a good habit, but recording a wrong constant builds a wall. Names in particular need the verification command stored alongside them.
Even with the data, the Reporting API is still off. Retention cohorts don't come out of bucket CSVs.
The entire Android charge history that bucket finally revealed — two charges, $1.44 net — is written up in the 113-day ledger post; the file listing itself was the answer.
Find one line in your own notes that says "impossible." Is it actually impossible, or is it a name you got wrong once?