Measured on Expo SDK 56, Android targetSdk 36.
I wired in-app subscriptions into an app this week. The client code was mostly written already; what remained was connecting the store to the billing provider. That took a full day, and three releases were rejected along the way. None of the blockers were code defects.
The first thing to break in-app subscriptions was a permission
The thing that blocked in-app subscriptions had nothing to do with billing. The day before, I had shipped a small feature: read the capture time of a photo or video the user picks from their library, and place the entry at the moment it actually happened. Add yesterday’s photo today, it still lands on yesterday.
Photos carry EXIF, so that part was free. Videos don’t, so I pulled in a media-library package. That package added six permissions to the manifest. I never looked.
The next day the store rejected the release:
All developers requesting access to the photo and video permissions are required to tell Google Play about the core functionality of their app.
Fine, fill in the declaration. Except the form wasn’t in the console.
The store surfaces that task once it detects the permission in an uploaded bundle, and our uploads were being rejected before that point. Chicken and egg.
Changing tracks didn’t help. Internal testing failed identically. So did the flag that skips review submission. And then the finding that settled it: the developer API has no endpoint for app-content declarations at all. Tracks, bundles, listings, products, testers are all scriptable. Declarations are console-only, by a human.
I filled it in. It was approved. The next release was rejected anyway:
Apps targeting Android 13+ may request these permissions only if the system picker is technically insufficient for core functionality. Remove them from all version codes.
That criticism was correct. We were already using the system picker. The only thing it couldn’t give us was a video’s capture timestamp. For that one field, we were asking for the whole library.
I removed the package. Six permissions disappeared and the release went through. The cost was one timestamp for videos; photos still work through EXIF.
The replacement turned out to be strictly better. Android’s MediaMetadataRetriever reads the creation date from a single content URI you were already handed, and needs no permission at all. It costs a native module. Had I found that first, three releases would not have bounced.
I misread “this index is not necessary”
The same day, a different feature needed a query spanning documents across many collections. I added the index definition, deployed, and got:
this index is not necessary, configure using single field index controls
I read “not necessary” and deleted the entry. The deploy passed.
Days later the query died. The UI showed a generic “can’t load this right now.” Running the identical query server-side is what surfaced it:
FAILED_PRECONDITION: requires a COLLECTION_GROUP_ASC index
The message never meant “you don’t need this.” It meant “this belongs in single-field settings, not in composite indexes.” Automatic single-field indexes are scoped to the collection; collection-group queries need that scope opened explicitly.
The error wasn’t lying. I read the first clause and stopped.
A string in two places will drift
The rest of the billing work had exactly one shape: a string written in one system has to match a string in another, and nothing tells you when it doesn’t.
- Product identifiers must contain
monthoryearfor the client and the webhook to classify the billing period, and those identifiers are immutable once created. - The entitlement identifier must match character for character between the dashboard and the app config. Get it wrong and the purchase succeeds while the paywall stays up. Money leaves, nothing unlocks.
- If the product group isn’t marked as the current/default one, the offerings list comes back empty. Products exist, prices exist, the screen shows nothing.
None of these throw. A key is absent, or a condition quietly fails, and execution continues.
Two service accounts, similar names, opposite directions
Setting up real-time notifications tripped me once more.
A message queue needs publish rights granted, but granted to a store-owned service account, not one from my project. Meanwhile my own service account needs read access to that same queue, because the billing provider uses my credentials to enumerate topics and populate a dropdown.
When that permission is missing, the dropdown is simply empty. No explanation.
Probing with the credentials directly is what made it obvious:
topics.list: 403 User not authorized to perform this action
That single line carries far more information than “autocomplete isn’t working.”
What I’d do differently
(The step-by-step version of this setup is in a full Play Store checklist.)
Read the manifest after adding a package. A one-line feature can drag in six permissions, and you find out at review time, months later.
When a string lives in two systems, write something that counts it. (I wrote about a related failure in what makes an LLM call cacheable: a partial cache silently corrupted an aggregate because two places disagreed.) I have a check comparing quota values between the app and the server; those two have never drifted since.
When something is blocked, run the same query server-side. A client can’t distinguish “no permission” from “no index” from “no data”. It fails the same way for all three. Running it where rules don’t apply separates them in seconds.
What I still don’t know
I haven’t built the MediaMetadataRetriever path yet. I’ve read the docs; I haven’t seen it return a value.
And I don’t know whether this wiring actually takes money. Products and entitlements are configured, but no purchase has completed end to end, and until one does on a real device, that’s an assumption rather than a result.