Naming: Google Pay vs Google Play Billing
| Term | What it is | When you use it |
|---|---|---|
| Google Play Billing | In-app purchases & subscriptions sold inside an Android app via Google Play | Digital goods, Premium tiers, unlockable features (required by Google for most digital content) |
| Google Pay | Wallet / card wallet for one-time payments (often via Stripe / PaymentRequest) | Physical goods, services, web checkout β not the same as Play subscriptions |
| Google payments profile | Merchant / tax / payout setup inside Play Console | Required before you can sell anything on Play β often mislabeled βGoogle Payβ |
This guide covers Google Play Billing (store subscriptions). If you need a Google Pay button for one-time charges, see Appendix A.
Table of contents
- Architecture overview
- Prerequisites checklist
- Play Console setup
- RevenueCat dashboard setup
- Product ID conventions (Android)
- Install client dependency
- Environment variables
- Server: public config endpoint
- Server: sync + webhook
- Client: types
- Client: RevenueCat service layer
- Client: device store lock
- Client: ownership alerts
- Client: purchase hook (full flow)
- Client: restore purchases
- Client: change plan (monthly β yearly)
- Client: trial eligibility
- Client: identify user on login / logout
- Client: manage / cancel subscription
- UI / UX rules
- End-to-end purchase sequence
- Error handling matrix
- Testing (license testers)
- Production gotchas
- iOS parallel (optional)
- File layout template
- Copy-paste checklists
- Appendix A β Google Pay (Wallet) vs this guide
- Appendix B β Glossary
1. Architecture overview
βββββββββββββββ purchaseProduct ββββββββββββββββββββ
β React Nativeβ ββββββββββββββββββββββββΊ β Google Play β
β + RevenueCatβ ββββββββββββββββββββββββ β Billing Library β
β SDK β CustomerInfo ββββββββββ¬ββββββββββ
ββββββββ¬ββββββββ β
β POST /billing/sync β receipt
βΌ βΌ
βββββββββββββββ webhook (HMAC) ββββββββββββββββββββ
β Your API β βββββββββββββββββββββββββ β RevenueCat β
β (source of β REST validation β cloud β
β truth for β βββββββββββββββββββββββββΊ ββββββββββββββββββββ
β entitlements)β
βββββββββββββββ
Rules of thumb
- Play owns money. Your app never stores card numbers.
- RevenueCat validates receipts and sends webhooks.
- Your backend is source of truth for βis this app user Premium?β after sync/webhook.
- Client SDK is trusted for UI (show/hide paywall) but not for authorizing sensitive APIs alone β always check server entitlement for gated actions.
2. Prerequisites checklist
- [ ] Google Play Developer account (one-time fee)
- [ ] App created in Play Console with package name (e.g.
com.example.myapp) - [ ] Payments profile verified (tax + merchant) β can take 2β5 business days
- [ ] Bank account + tax forms for payouts (before first real earnings)
- [ ] RevenueCat account + project
- [ ] Backend that can expose HTTPS webhook URL
- [ ] Expo / RN app that ships as Play-installed build for real Billing tests (sideload often fails Billing)
3. Play Console setup
3.1 Payments profile
- Play Console β Settings β Payments profile
- Create / verify merchant profile (country, individual or organization)
- Complete tax info (e.g. W-8BEN for non-US) before first US sale
- Add bank account for payouts
- Confirm Order management is accessible
3.2 License testers (free test purchases)
- Play Console β Settings β License testing
- Add Gmail accounts that will test
- Those accounts must also be opted into your Internal testing track later
3.3 Create subscriptions
- Monetize β Subscriptions β Create subscription
- Create one subscription product per cadence you sell, e.g.:
- Product:
premium_monthlywith base planmonthly - Product:
premium_yearlywith base planyearly
- Product:
- For each base plan: price, billing period, free trial / intro offer
- Activate products (draft products are invisible to testers)
3.4 App signing
Production purchases require Play App Signing enabled before real money flows.
4. RevenueCat dashboard setup
- Create a project (one project for sandbox + production is fine; purchases are tagged sandbox vs production)
- Add Android app with the same package name as Play (
com.example.myapp) - Upload Play service account JSON so RevenueCat can validate receipts
- Products β Import from Google Play
- Create an entitlement e.g.
premium - Attach monthly + yearly products to that entitlement
- Create an offering (e.g.
default) with packages if you use offerings UI - Copy keys:
- Android public SDK key (
goog_β¦) - iOS public key if needed (
appl_β¦) -
Secret API key for server (
sk_β¦)
- Android public SDK key (
- Configure webhook β your
POST https://api.example.com/v1/billing/revenuecat/webhookwith HMAC secret
5. Product ID conventions (Android)
Google Play uses subscriptionId:basePlanId when talking to Billing / RevenueCat:
| Plan | Example product ID |
|---|---|
| Monthly | premium_monthly:monthly |
| Yearly | premium_yearly:yearly |
Keep these IDs identical across:
- Play Console
- RevenueCat products
- Server env /
/billing/config - Client purchase calls
Never hard-code product IDs only in the app β serve them from the backend so you can rotate without shipping a binary.
6. Install client dependency
npx expo install react-native-purchases
# or
npm install react-native-purchases
Typical version family: react-native-purchases 10.x.
No separate βGoogle Payβ native module is required. Play Billing permission / Billing Library is pulled in by the Purchases SDK at native build time.
Expo: use a dev client / EAS build, not Expo Go, for real purchases.
7. Environment variables
Server (recommended)
# Webhook HMAC from RevenueCat dashboard
REVENUECAT_WEBHOOK_HMAC_SECRET=whsec_replace_me
# Server-side REST
REVENUECAT_SECRET_API_KEY=sk_replace_me
REVENUECAT_PROJECT_ID=projreplace_me
# Public SDK keys (safe to return to clients)
REVENUECAT_PUBLIC_IOS_API_KEY=appl_replace_me
REVENUECAT_PUBLIC_ANDROID_API_KEY=goog_replace_me
# Catalog
REVENUECAT_ENTITLEMENT_ID=premium
REVENUECAT_OFFERING_ID=default
REVENUECAT_PRO_MONTHLY_PRODUCT_ID=premium_monthly:monthly
REVENUECAT_PRO_YEARLY_PRODUCT_ID=premium_yearly:yearly
Client
Prefer no hard-coded RevenueCat keys in the app binary. Fetch from:
GET /billing/config (public / skip-auth is OK for public keys only).
8. Server: public config endpoint
Expose everything the client needs to configure the SDK and show prices:
GET /v1/billing/config
Example JSON response:
{
"revenueCat": {
"iosApiKey": "appl_β¦",
"androidApiKey": "goog_β¦",
"entitlementId": "premium",
"offeringId": "default"
},
"plans": {
"free": {
"tier": "free",
"label": "Free",
"priceLabel": "$0",
"features": {},
"limits": {}
},
"pro": {
"tier": "pro",
"label": "Premium",
"entitlementId": "premium",
"monthly": {
"priceLabel": "$4.99/mo",
"cadence": "monthly",
"productId": "premium_monthly:monthly"
},
"yearly": {
"priceLabel": "$39.99/yr",
"cadence": "yearly",
"productId": "premium_yearly:yearly"
},
"features": {},
"limits": {}
}
}
}
9. Server: sync + webhook
9.1 Client sync (fast path after purchase)
POST /v1/billing/sync
Authorization: Bearer <user JWT>
Body:
{
"appUserId": "<same id used in Purchases.logIn>",
"entitlement": "premium",
"productId": "premium_monthly:monthly",
"store": "play_store",
"currentPeriodEnd": "2026-08-19T00:00:00.000Z"
}
Server responsibilities:
- Verify the caller is authenticated
- Prefer verifying against RevenueCat REST / CustomerInfo for that
appUserId - Upsert subscription row for this user
- If receipt already linked to another user β return 409 with clear message
- Invalidate any session/bootstrap cache for that user
9.2 Webhook (source of truth for renewals / cancels)
POST /v1/billing/revenuecat/webhook
- Verify
X-RevenueCat-Webhook-Signature(HMAC over raw body) - Idempotent: store event IDs so retries are safe
- Handle at least:
INITIAL_PURCHASE,RENEWAL,PRODUCT_CHANGE,CANCELLATION,EXPIRATION,BILLING_ISSUE,UNCANCELLATION - Only grant access when the configured entitlement is present/active
- Invalidate session caches after each processed event
10. Client: types
export type BillingConfigResponse = {
revenueCat: {
iosApiKey: string | null;
androidApiKey: string | null;
entitlementId: string;
offeringId: string;
};
plans: {
free: { /* β¦ */ };
pro: {
entitlementId: string;
monthly: { priceLabel: string; cadence: string; productId?: string };
yearly: { priceLabel: string; cadence: string; productId?: string };
// β¦
};
};
};
export type BillingSyncPayload = {
appUserId: string;
entitlement: string;
productId?: string | null;
store?: string | null;
currentPeriodEnd?: string | null;
};
export type BillingPurchaseResult = {
productId: string | null;
store: string | null;
currentPeriodEnd: string | null;
isActive: boolean;
willRenew: boolean | null;
};
11. Client: RevenueCat service layer
Create a single module (e.g. src/features/billing/services/purchases.ts) that owns:
| Concern | Function |
|---|---|
| Configure once |
ensureConfigured(config) β Purchases.configure({ apiKey })
|
| Identify user |
logIn(appUserId) / logOut()
|
| Purchase | purchaseProduct(productId) |
| Change plan (Android) |
purchaseSubscriptionOption + proration |
| Restore | restorePurchases() |
| Local entitlement |
getCustomerInfo() β entitlements.active[id]
|
| Manage UI | showManageSubscriptions() |
| Trial eligibility | platform-specific (see Β§17) |
| Errors | map RC codes β typed errors |
11.1 Custom errors
export class PurchaseUnavailableError extends Error {} // cancel / missing key
export class PaymentPendingError extends Error {} // family approval
export class AlreadyPurchasedError extends Error {} // store already owns sub
11.2 Map purchase errors
import { PURCHASES_ERROR_CODE } from 'react-native-purchases';
function rethrowPurchaseError(err: unknown): never {
const code = (err as { code?: PURCHASES_ERROR_CODE }).code;
if (code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
throw new PurchaseUnavailableError('Purchase cancelled.');
}
if (code === PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
throw new PaymentPendingError('Payment pending.');
}
if (
code === PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR ||
code === PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR ||
code === PURCHASES_ERROR_CODE.RECEIPT_IN_USE_BY_OTHER_SUBSCRIBER_ERROR
) {
throw new AlreadyPurchasedError('Subscription already owned on this store account.');
}
throw err;
}
11.3 Configure + identify
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
import { Platform } from 'react-native';
let sdkConfigured = false;
let loggedInAppUserId: string | null = null;
function platformApiKey(config: BillingConfigResponse): string | null {
if (Platform.OS === 'ios') return config.revenueCat.iosApiKey;
if (Platform.OS === 'android') return config.revenueCat.androidApiKey;
return null;
}
export async function ensureConfigured(config: BillingConfigResponse): Promise<void> {
if (sdkConfigured) return;
if (await Purchases.isConfigured().catch(() => false)) {
sdkConfigured = true;
return;
}
const apiKey = platformApiKey(config);
if (!apiKey) throw new PurchaseUnavailableError('API key missing for this platform.');
Purchases.setLogLevel(__DEV__ ? LOG_LEVEL.DEBUG : LOG_LEVEL.INFO);
Purchases.configure({ apiKey });
sdkConfigured = true;
}
export async function logInPurchases(appUserId: string): Promise<void> {
if (!appUserId || loggedInAppUserId === appUserId) return;
await Purchases.logIn(appUserId);
loggedInAppUserId = appUserId;
}
export async function logOutPurchases(): Promise<void> {
try {
if (sdkConfigured && loggedInAppUserId) await Purchases.logOut();
} catch {
// logOut on anonymous user can throw β ignore
}
loggedInAppUserId = null;
}
export async function configurePurchases(config: BillingConfigResponse, appUserId: string) {
await ensureConfigured(config);
await logInPurchases(appUserId);
}
Important: configure with API key once per process. Switch users with logIn, not by re-calling configure with a new identity.
11.4 Purchase
export async function purchaseProProduct(
productId: string,
entitlementId = 'premium',
): Promise<BillingPurchaseResult> {
try {
const { customerInfo, productIdentifier } = await Purchases.purchaseProduct(productId);
return mapPurchaseResult(productIdentifier, customerInfo, productId, entitlementId);
} catch (err) {
rethrowPurchaseError(err);
}
}
11.5 Connectivity probe (optional but useful)
Before opening the store sheet:
export async function checkConnection(): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
const res = await fetch('https://clients3.google.com/generate_204', {
method: 'HEAD',
signal: controller.signal,
});
clearTimeout(timeoutId);
return res.status === 204;
} catch {
return false;
}
}
12. Client: device store lock
Problem: Monthly and yearly are separate Play products. With RevenueCat βKeep with original App User IDβ, a second app login on the same Google account can sometimes buy the other SKU while the receipt stays on the first user.
Mitigation: Persist a short-lived device lock in AsyncStorage after any successful Premium detection:
type StorePremiumLock = {
productIds: string[];
expiresAt: string; // ISO
updatedAt: string;
};
- Write lock after purchase / restore / active entitlement
- Before purchase: if lock active OR CustomerInfo already owns configured SKUs β block with βAlready subscribed on Google Playβ
- Auto-clear when
expiresAtpasses
13. Client: ownership alerts
Provide two alerts:
- Already subscribed on Google Play β store account owns Premium linked to another app login
- Subscription on another account β server returned 409 on sync
Both should offer a button that opens:
https://play.google.com/store/account/subscriptions
(iOS parallel: https://apps.apple.com/account/subscriptions)
Detect store ownership errors via RevenueCat codes listed in Β§11.2.
14. Client: purchase hook (full flow)
Recommended order inside purchase(plan):
- Guard: already Premium on server β navigate to Manage, do not open sheet
- Guard: purchase already in progress β return
- Network check β alert if offline
- Resolve
productIdfrom/billing/configfor monthly/yearly -
configurePurchases(config, userId) - Restore first (soft-fail unless AlreadyPurchased)
- Read local entitlement β if active, sync + go Manage (skip sheet)
- Store lock / CustomerInfo owns SKU β ownership alert
- Stop spinner (store UI owns the wait), call
purchaseProProduct - On success β
POST /billing/sync - Sync 409 β cross-account alert
- Sync fails but local entitlement active β toast βPremium is confirmingβ¦β β Manage
- Success β Welcome / upgrade-success screen
Spinner UX
- Show spinner for app-owned work only
- While Play sheet is open: keep CTA disabled, hide spinner so it does not spin behind the modal
Pseudo-code outline
async function purchase(plan: 'monthly' | 'yearly') {
if (alreadyPro) return navigateManage();
if (busy) return;
if (!(await checkConnection())) return alertOffline();
const productId = plan === 'yearly' ? yearlyId : monthlyId;
await configurePurchases(config, userId);
// restore β local entitlement β store lock checksβ¦
// then:
setSpinner(false);
const result = await purchaseProProduct(productId, entitlementId);
await syncBilling({
appUserId: userId,
entitlement: entitlementId,
productId: result.productId,
store: 'play_store',
currentPeriodEnd: result.currentPeriodEnd,
});
navigateUpgradeSuccess();
}
15. Client: restore purchases
const info = await Purchases.restorePurchases();
// map entitlement β sync payload β POST /billing/sync
UI cases:
| Result | UX |
|---|---|
| Entitlement active | Sync β Manage or success |
| Nothing found | βNo purchases foundβ |
| Store unreachable | βRestore failed β check connectionβ |
| Sync 409 | Cross-account alert |
16. Client: change plan (monthly β yearly)
Android (required)
Google Playβs manage-subscriptions UI cannot switch base plans. You must run an in-app replacement purchase:
import { PRORATION_MODE, PRODUCT_CATEGORY } from 'react-native-purchases';
// upgrade monthly β yearly
prorationMode = PRORATION_MODE.IMMEDIATE_WITH_TIME_PRORATION;
// downgrade yearly β monthly
prorationMode = PRORATION_MODE.DEFERRED;
const products = await Purchases.getProducts([newProductId], PRODUCT_CATEGORY.SUBSCRIPTION);
const option = products[0]?.defaultOption ?? products[0]?.subscriptionOptions?.[0];
await Purchases.purchaseSubscriptionOption(option, {
oldProductIdentifier: oldProductId,
prorationMode,
});
Fallback if no subscription option: purchaseStoreProduct(product, { oldProductIdentifier, prorationMode }).
iOS
Products in the same subscription group β purchaseProduct(newProductId) and StoreKit handles replacement.
17. Client: trial eligibility
Never promise βStart free trialβ unless you know the store account is eligible.
| Platform | How |
|---|---|
| iOS | Purchases.checkTrialOrIntroductoryPriceEligibility(ids) |
| Android |
getProducts β if any option has freePhase, user is eligible (Play only returns eligible offers) |
If status is unknown, treat as ineligible for CTA copy (safer).
18. Client: identify user on login / logout
| Event | Action |
|---|---|
| Session bootstrap / login |
configure + logIn(userId) early |
| Logout | logOut() |
| Purchase / restore / manage | Call configure+logIn as a safe prelude |
Use your stable backend user id as RevenueCat appUserId so webhooks and sync line up.
19. Client: manage / cancel subscription
- Prefer
Purchases.showManageSubscriptions() - Fallback deep link: Play subscriptions URL
- Tell users clearly: cancel / payment method changes happen in Google Play, not inside your app settings alone
20. UI / UX rules
- Trust badge copy: βBilled through Google Playβ (not βGoogle Payβ)
- One primary CTA on paywall plan screen
- Disable CTA while purchase pending
- User cancel β silent return (no error toast)
- Family / pending payment β dedicated βApproval Request Sentβ alert
- After purchase, unlock Premium only after sync or show confirming state if local entitlement is already true
21. End-to-end purchase sequence
sequenceDiagram
participant User
participant App
participant RC as RevenueCat SDK
participant Play as Google Play Billing
participant API as Your Backend
participant WH as RevenueCat Webhook
User->>App: Tap Subscribe
App->>App: Guards + restore + lock checks
App->>RC: purchaseProduct(sku)
RC->>Play: Present billing sheet
User->>Play: Confirm
Play-->>RC: Receipt
RC-->>App: CustomerInfo (entitlement active)
App->>API: POST /billing/sync
WH-->>API: INITIAL_PURCHASE (async)
API-->>App: Plan = premium
App->>User: Upgrade success
22. Error handling matrix
| Situation | User sees | App does |
|---|---|---|
| User cancels sheet | Nothing | Silent return |
| Offline | Connection alert | Abort |
| Already Premium (server) | β | Open Manage |
| Already owned on Play | Already subscribed alert | Abort / restore path |
| Sync 409 | Another account alert | Abort |
| Payment pending | Approval request alert | Abort |
| Generic purchase failure | Purchase failed alert | Abort |
| Sync fails, local entitlement OK | Toast βconfirmingβ¦β | Open Manage |
| SKU not found / not activated | Purchase failed | Check Play + RC product mapping |
23. Testing (license testers)
Setup
- License tester Gmail on device
- Opt into Internal testing track
- Install from Play opt-in link (not random sideload)
- Force-stop Play Store β clear cache β reopen (stale catalog)
Happy path
- Open paywall β Subscribe
- Sheet shows (Test) label
- Complete purchase
- RevenueCat Customer view shows entitlement
- App unlocks Premium
- Restore after reinstall works
- Cancel in Play β webhook β entitlement drops on refresh
Accelerated renewals
License tester renewals are accelerated (e.g. ~5 minutes β 1 month). Use this to test renewal webhooks β not real calendar months.
First SKU delay
Brand-new products can fail for 2β6 hours while Play propagates. Retry later.
24. Production gotchas
- [ ] Payments profile + tax + bank before real payouts
- [ ] Play App Signing on
- [ ] Products Active in Play Console
- [ ] Service account JSON uploaded to RevenueCat
- [ ] Webhook HMAC verified; raw body not re-serialized
- [ ] Public keys served from
/config, not committed secrets - [ ] Never run real (nonβlicense-tester) purchases from a debug build against production SKUs
- [ ] Monthlyβyearly change uses Android replacement purchase, not manage sheet
- [ ] 409 handling for shared Google accounts / multi-login
- [ ] Device store lock for Keep-with-original edge cases
- [ ]
usesCleartextTraffic: falseand HTTPS API in production builds
25. iOS parallel (optional)
Same architecture; differences:
| Topic | Android | iOS |
|---|---|---|
| API key | goog_β¦ |
appl_β¦ |
| Store string | play_store |
app_store |
| Product IDs | sub:basePlan |
App Store product id |
| Plan change | Proration / replacement | Same subscription group |
| Trial API |
freePhase on options |
Intro eligibility API |
| Manage URL | Play subscriptions | App Store subscriptions |
26. File layout template
src/
features/
billing/
api/
billing.api.ts # getConfig, getMe, sync
hooks/
useBillingQueries.ts # React Query wrappers
lib/
storePremiumLock.ts # device lock
premiumLifecycle.ts # map plan β UI state
services/
purchases.ts # RevenueCat wrapper
monetization/
hooks/
useProPurchase.ts
useProRestore.ts
useChangePremiumPlan.ts
usePaywallTrialEligibility.ts
lib/
storeOwnershipAlerts.ts
paywall.ts
screens/
PaywallScreen.tsx
PlanComparisonScreen.tsx
ChangePlanScreen.tsx
UpgradeSuccessScreen.tsx
server/
modules/billing/
billing.route.js
revenuecat.controller.js
revenuecat.service.js
revenuecat.schema.js
subscription.model.js
27. Copy-paste checklists
New project β day 1
- [ ] Play app + package name locked
- [ ] Start payments profile verification
- [ ] Create RevenueCat project + Android app
- [ ] Add
react-native-purchases - [ ] Add
/billing/config,/billing/sync, webhook routes
Before first test purchase
- [ ] Subscriptions created + activated
- [ ] Products imported + entitlement mapped in RC
- [ ] Service account connected
- [ ] License testers added
- [ ] Internal track build installed from Play
Before production
- [ ] Tax + payout methods
- [ ] Webhook live + HMAC
- [ ] E2E: buy, restore, cancel, renew (accelerated)
- [ ] 409 / already-owned UX verified
- [ ] Plan change (upgrade + downgrade) verified on Android
Appendix A β Google Pay (Wallet) vs this guide
Use this guide when selling digital Premium inside an Android app.
Use Google Pay (Wallet) when:
- Charging for physical goods / services
- Integrating Stripe / Braintree / etc. with a Google Pay button
- Not selling a Play-managed subscription
Typical Google Pay stack (out of scope here):
-
@stripe/stripe-react-nativeβGooglePay/ Payment Sheet - Or native
PaymentsClient/ Wallet API with gateway tokenization
Do not mix the two for the same digital entitlement β Googleβs policies generally require Play Billing for digital in-app unlocks.
Appendix B β Glossary
| Term | Meaning |
|---|---|
| SKU / product ID | Store catalog identifier |
| Base plan | Android billing period + price under a subscription |
| Entitlement | Logical access flag in RevenueCat (e.g. premium) |
| Offering | Packaged set of products shown to a user cohort |
| App User ID | Your user id passed to Purchases.logIn
|
| CustomerInfo | RC snapshot of entitlements + subscriptions |
| Proration | How Play charges when switching plans mid-cycle |
| License tester | Gmail that gets free β(Test)β purchases |
| Keep with original | RC transfer behavior when the same store receipt hits a new App User ID |
Quick reference β minimum viable integration
- Play subscriptions + payments profile
- RevenueCat products β entitlement
premium -
npm i react-native-purchases -
GET /billing/configβ public keys + product IDs -
configure+logIn(userId) -
purchaseProduct(id)βPOST /billing/sync - Webhook for renewals / expiration
- Restore + manage deep link
- License-tester E2E before launch
Top comments (0)