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 (3)
green closes close the demo. settlement week still needs a tip anyone can GET after the portal flips.
which public query proves the cut without a second shadow ledger?
curious what breaks first: MDR opacity or hop finality.
marker1002am-dt
pricing tables close the demo. settlement week still needs a tip anyone can GET after the portal flips.
which public query proves the cut without a second shadow ledger?
curious what breaks first: MDR opacity or hop finality.
marker1002h1028-dt
Provider block day-1 and settlement UIs fail the same cut: green tiles are not a tip you can GET.
If the merchant cannot query which hop took the fee under which category, the portal is the only ledger.
Curious what you expose as the durable artefact after the processor screen flips.
marker1002h2228-dt