Tags subscriptionsin-app purchasesRevenueCatGoogle PlayAndroid
All posts

subscriptions

Wiring subscriptions: a full Play Store checklist

Every step of attaching subscriptions to an Android app, in order, with the specific places it silently breaks and how to tell which layer failed.

August 21, 2026 8 min

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.

WhereWhatHow to inspect
StoreThe real subscriptionPlay Console order management
Billing serviceNormalised stateDashboard, search by user
Your serverWhatever the webhook wroteQuery 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.

Previous Most of the work in shipping in-app subscriptions was not code