I attached subscriptions to an Android app this week. Expo SDK 56, targetSdk 36.
It took a day, and almost none of that was code. Subscriptions turned out to be mostly configuration. It was store configuration and connecting a billing service, and the places it breaks are predictable enough to write down.
Why subscriptions need more than a purchase call
Subscriptions are not one-shot sales. It renews, lapses, and comes back. The state machine is wide:
active · trial · cancelled-but-not-yet-expired · in grace period after a failed charge
· paused · expired · refunded · upgraded · downgraded · resubscribed
Play emits these as events. Collapsing them into a single boolean, “is this person a paying member right now”, is the actual work. Get it wrong in one direction and a paying customer is locked out; get it wrong in the other and you give the app away.
RevenueCat does that collapse. It verifies receipts server-side and exposes one thing: does this user hold the entitlement or not.
⚠️ You don’t have to use it. One platform, one product, and rolling your own against the Play Developer API plus real-time notifications is reasonable. I expect to sell on iOS and web later, and each store speaks a different dialect of the same idea. Normalising that is what you’re paying for.
Prerequisites
Three things must exist before any of this works.
A registered business and a payments profile. Without it the subscription creation screen refuses outright. This has the longest lead time, so start here.
The app published on some track. Internal testing is enough.
A service account. The credentials that let software read your store data. If you already automate releases, reuse that account.
1. Create the products
Play Console → Monetize → Subscriptions → Create subscription
Make two: monthly and yearly.
⚠️ Product IDs are immutable. Deleting one does not free the ID for reuse. Think once more before you commit to a name.
⚠️ Put month or year inside the ID. Downstream code classifies the billing period by looking for that substring. Omit it and you’ll need another mechanism later, except the ID can no longer change.
A product alone sells nothing. You must add a base plan inside it. The product is the container; the base plan is the thing with a price and a cadence.
Product → Add base plan
Billing period: 1 month / 1 year
Price: per region
→ Activate
I only noticed my yearly product had no base plan because the list showed 1 next to monthly and 0 next to yearly. That column is the base plan count.
Use the automatic regional pricing. Enter one price, let Play derive the rest. A straight FX conversion produces prices like $2.15 that no store actually uses.
⚠️ Prices are changeable later, but raising them isn’t cheap. Existing subscribers must be notified and must consent; non-consent cancels them. Don’t anchor too low.
2. Build the notification pipe
The billing service needs to hear about store events. That happens through a Google Cloud message queue: the store drops messages in, the billing service picks them up.
1) Cloud Console → API Library → enable "Cloud Pub/Sub API"
2) Pub/Sub → Topics → Create topic
Two things bit me here, and they point in opposite directions.
The store needs permission to publish, and the identity that needs it is Google’s, not yours:
Topic → Permissions → Add principal
google-play-developer-notifications@system.gserviceaccount.com
Role: Pub/Sub Publisher
⚠️ Putting your own service account here does nothing. Without publisher rights, Play rejects the topic outright.
Your account needs permission to read. The billing service authenticates as you to enumerate topics and populate its dropdown. Missing that, the field is simply empty with no explanation.
IAM → Grant access
your service account
Role: Pub/Sub Editor
⚠️ Subscriber is not enough. The billing service creates a subscription on your topic, which requires editor.
Probing the credentials directly is faster than guessing:
topics.list: 403 User not authorized to perform this action
That one line says far more than “the dropdown is empty.”
Then register the topic with the store:
Play Console → Monetize → Monetization setup
→ Real-time developer notifications → paste topic name → Save
→ Send test notification
A successful test means the pipe is up.
⚠️ To actually read the payload you need a subscription on the topic. Messages published with no subscribers are dropped. If you create one to inspect the test message, delete it afterwards, or it accumulates messages nobody acknowledges.
3. Connect the billing service
RevenueCat → App settings → Google Play
→ upload the service account JSON
→ select the topic
⚠️ Confirm that account’s Play permissions:
Play Console → Users and permissions → the service account
✓ View financial data, orders, and cancellation survey responses
✓ Manage orders and subscriptions
Skip this and you get the worst variant later: purchases succeed while the billing service never learns about them.
Then three objects:
Products. Import the two you created.
Entitlement. The name that means “this person is paid.” Attach both products, so either purchase grants the same access.
⚠️ That identifier must match the string in your app config exactly. One character off and the purchase succeeds while the paywall stays up. Money leaves, nothing unlocks, and nothing throws. You’re reading a key that isn’t there.
Offering. The price list your app displays. Create it, then mark it as the current/default one.
⚠️ Skip that mark and you get products, prices, and an empty screen. This is the single most commonly missed step.
Name the packages Monthly and Annual; client SDKs look them up by those names.
4. Verify in the app
At this point prices should render. Mine didn’t:
This version of the application is not configured for billing.
The cause was the install path. Play billing requires the app to have been installed through Play. I was running a build from a side-channel distribution tool, and its version code had never been uploaded to Play at all.
The fix:
1) Register a license tester so purchases don’t charge real money.
Play Console → Settings → License testing → add your Google account
⚠️ Being the developer account is not sufficient; it must be listed here explicitly.
2) Upload to the internal testing track. No review, live within minutes.
3) Join via the internal test link and install from Play. Uninstall any copy that came from elsewhere first.
Prices appeared immediately after that.
5. Tell your own server
The app queries the billing service directly, so the UI unlocks without any server involvement. Your server, however, still knows nothing.
In my case that mattered immediately. One feature runs an inference call per request, so free and paid members have different monthly caps, and the server enforces them. Without the server knowing, a paying subscriber gets throttled at the free limit while the app confidently displays “Pro”.
So the billing service posts events to us:
RevenueCat → Integrations → Webhooks
URL: https://your-host/api/revenuecat-webhook
Authorization: Bearer some_shared_string
Server environment
RC_WEBHOOK_SECRET = some_shared_string
⚠️ Setting an environment variable requires a redeploy. Values are baked at deploy time; setting one in a dashboard leaves existing deployments unchanged.
I lost ten minutes here. I pushed, waited, and nothing changed, because the commit contained no files under the API directory, so the build was skipped entirely.
Make the failures distinguishable by status code:
500 the env var isn't loaded yet (redeploy needed)
401 it is loaded, the key just doesn't match (working correctly)
6. Three places state lives
Once wired, subscription state exists in three systems, and they can disagree.
| Where | What | How to inspect |
|---|---|---|
| Store | The real subscription | Play Console order management |
| Billing service | Normalised state | Dashboard, search by user |
| Your server | Whatever the webhook wrote | Query the database directly |
⚠️ Search the billing dashboard by your own user id. If you set the billing SDK’s app user id to your internal id at login, both systems refer to the same person by the same name. Without that, correlating them is guesswork.
Test subscriptions renew on an accelerated clock. What is a month in production cycles in minutes, repeats a handful of times, then expires. Renewal and expiry are genuinely testable; you don’t wait a month.
To test cancellation:
Play Store app → profile → Payments & subscriptions → Subscriptions → Cancel
⚠️ Cancelling does not revoke access immediately. Entitlement persists until the paid period ends. If your app locks the moment someone cancels, that’s a bug.
Telling the layers apart
Every blocker in this process shared one property: none of them raised an error that pointed at the cause. A key was absent, or a name didn’t match, and execution continued.
Three habits made it tractable.
(A related failure, where two systems disagreed silently, is in what makes an LLM call cacheable.)
Suspect permissions? Call the API as that identity. When a UI just says “doesn’t work”, the same request from a terminal returns an actual 403.
Suspect the backend? Run the identical query server-side. A client fails the same way for “no permission”, “no index”, and “no data”. Running it where rules don’t apply separates them in seconds.
Give each failure its own status code. The 500-versus-401 split above turned a ten-minute guess into a one-request answer.
What I haven’t done
No purchase has completed end to end. Prices render; nothing has actually been bought and propagated to the server. Until that happens this is a configuration, not a result.
I also haven’t decided how free and paid actually differ. My codebase currently contains two incompatible models: a trial that hard-locks the whole app when it expires, and a permanently free tier with usage caps. Picking one is the next job.