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)