In-App Purchases in React Native: StoreKit 2, Google Play Billing, and the Hard Parts
In-app purchases are among the most deceptively complex integrations in mobile development. The surface area is simple: user taps "Subscribe", money changes hands, user gets access. The implementation involves:
- Two completely different native APIs with different paradigms
- Server-side receipt validation (both Apple and Google require it)
- Subscription lifecycle events: renewals, cancellations, grace periods, billing retries, refunds
- Handling purchase interruptions (payment fails mid-flow, app killed during purchase)
- App Store and Play Store review that will reject almost any attempt to work around their payment systems
This is the complete guide.
The library: react-native-iap or RevenueCat
Build vs buy decision first:
react-native-iap - open source, free, direct access to StoreKit and Google Play Billing. You handle everything: receipt validation, subscription status, entitlements.
RevenueCat SDK - paid (free tier to $2500/mo revenue), abstracts both stores into one unified API, handles receipt validation, subscription status, and analytics. $99-$499/month after the free tier.
For most apps generating under $10k/month from IAP: RevenueCat is worth the cost. It eliminates weeks of backend work and a class of subscription edge-case bugs that will bite you in production.
For large-scale apps or teams who need full control: react-native-iap.
This guide covers both but focuses on react-native-iap for the technical depth.
Setup
npm install react-native-iap
cd ios && pod install
<!-- android/app/build.gradle -->
dependencies {
implementation 'com.android.billingclient:billing:6.2.0'
}
// iOS: no extra setup needed - StoreKit 2 is included in iOS 15+
// For iOS 14 and below: react-native-iap falls back to StoreKit 1
Products and subscriptions: the mental model
Apple and Google categorize in-app purchases differently:
| Type | Apple | |
|---|---|---|
| One-time purchase | Non-consumable / Consumable | One-time product |
| Recurring payment | Auto-renewable subscription | Subscription |
| Credits/coins | Consumable | Consumable |
Consumables (credits, coins, lives): can be purchased multiple times, deliver value on purchase, server must track balance.
Non-consumables (remove ads, unlock feature): purchased once, user owns forever, restored via "Restore Purchases."
Subscriptions: recurring, have tiers, have trial periods, can be cancelled, can fail to renew (billing retry), have grace periods.
Define your products in both App Store Connect and Google Play Console before writing code. The product IDs you define there are what you reference in code:
com.yourapp.premium_monthly → matches App Store + Play Store product ID
com.yourapp.premium_annual
com.yourapp.coin_pack_100
Use identical product IDs on both platforms - it simplifies code significantly.
Initialization
import { initConnection, endConnection, getProducts, getSubscriptions,
requestPurchase, requestSubscription, getPurchaseHistory,
finishTransaction, purchaseErrorListener, purchaseUpdatedListener } from 'react-native-iap';
const PRODUCT_IDS = ['com.yourapp.coin_pack_100', 'com.yourapp.remove_ads'];
const SUBSCRIPTION_IDS = ['com.yourapp.premium_monthly', 'com.yourapp.premium_annual'];
class IAPManager {
private purchaseUpdateSubscription: any;
private purchaseErrorSubscription: any;
async initialize() {
try {
await initConnection();
// Set up purchase listeners BEFORE requesting any purchase
this.purchaseUpdateSubscription = purchaseUpdatedListener(
async (purchase) => {
await this.handlePurchaseUpdate(purchase);
}
);
this.purchaseErrorSubscription = purchaseErrorListener(
(error) => {
console.warn('Purchase error:', error);
}
);
// Check for any pending purchases from previous sessions
await this.processPendingPurchases();
} catch (err) {
console.error('IAP initialization failed:', err);
}
}
dispose() {
this.purchaseUpdateSubscription?.remove();
this.purchaseErrorSubscription?.remove();
endConnection();
}
}
Critical: Set up purchaseUpdatedListener before calling requestPurchase. Purchases can complete asynchronously - if the app is killed during a purchase, the transaction will be delivered the next time the listener is set up.
Fetching products
async function loadProducts() {
try {
const [products, subscriptions] = await Promise.all([
getProducts({ skus: PRODUCT_IDS }),
getSubscriptions({ skus: SUBSCRIPTION_IDS }),
]);
// Products come back in arbitrary order - sort by your preferred order
return {
products: PRODUCT_IDS.map(id => products.find(p => p.productId === id)).filter(Boolean),
subscriptions: SUBSCRIPTION_IDS.map(id => subscriptions.find(s => s.productId === id)).filter(Boolean),
};
} catch (err) {
// No network, no products. Handle gracefully - don't crash the paywall.
console.error('Failed to load products:', err);
return { products: [], subscriptions: [] };
}
}
// Display localized price directly from the product object
// NEVER hardcode prices in your app - they come from the store
function PriceDisplay({ product }) {
return (
<Text>
{product.localizedPrice} / {product.subscriptionPeriodUnitIOS === 'MONTH' ? 'month' : 'year'}
</Text>
);
}
Initiating a purchase
async function purchaseSubscription(productId: string) {
try {
// Android requires offerToken for subscriptions in Billing Library 5+
const subscription = subscriptions.find(s => s.productId === productId);
if (Platform.OS === 'android') {
// Android: must pass offerToken
const offerToken = subscription?.subscriptionOfferDetails?.[0]?.offerToken;
await requestSubscription({
sku: productId,
...(offerToken && { subscriptionOffers: [{ sku: productId, offerToken }] }),
});
} else {
// iOS
await requestSubscription({ sku: productId });
}
} catch (err) {
if (err.code === 'E_USER_CANCELLED') {
// User cancelled - not an error, don't show error UI
return;
}
throw err;
}
}
The purchase handler: the most critical code
async function handlePurchaseUpdate(purchase) {
const { productId, transactionId, transactionReceipt } = purchase;
if (!transactionReceipt) return;
try {
// 1. Validate receipt with YOUR server (which validates with Apple/Google)
// NEVER validate directly from the client - receipt data can be forged
const response = await api.post('/purchases/validate', {
productId,
transactionId,
receipt: transactionReceipt,
platform: Platform.OS,
});
if (response.valid) {
// 2. Grant entitlement to user
await updateUserEntitlements(response.entitlements);
// 3. ALWAYS finish the transaction - this is required
// Not finishing transactions causes the purchase to be re-delivered
// and can result in App Store review rejection
await finishTransaction({ purchase, isConsumable: false });
} else {
// Invalid receipt - could be fraud attempt
await finishTransaction({ purchase, isConsumable: false });
// Don't grant access
}
} catch (err) {
// Don't finish transaction if server validation failed due to network error
// It will be re-delivered on next app open
console.error('Purchase validation failed:', err);
}
}
The finishTransaction rule: Every purchase must be finished (acknowledged). On iOS, unfinished transactions are re-delivered for up to 30 days. On Android (Billing Library 5+), unacknowledged purchases are refunded automatically after 3 days. Finish every transaction exactly once, after validation and entitlement delivery.
Server-side receipt validation
The server receives the receipt from the mobile client and validates it directly with Apple/Google. This is mandatory - receipt validation from the client is insecure.
// Express backend
// Apple validation
async function validateAppleReceipt(receiptData, isSandbox = false) {
const endpoint = isSandbox
? 'https://sandbox.itunes.apple.com/verifyReceipt'
: 'https://buy.itunes.apple.com/verifyReceipt';
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
'receipt-data': receiptData,
'password': process.env.APPLE_SHARED_SECRET, // App-specific shared secret from App Store Connect
'exclude-old-transactions': true,
}),
});
const data = await response.json();
// Status 21007 means "this is a sandbox receipt, use sandbox endpoint"
if (data.status === 21007) {
return validateAppleReceipt(receiptData, true);
}
if (data.status !== 0) {
throw new Error(`Apple receipt validation failed: ${data.status}`);
}
// Extract the most recent in-app purchase info
const latestReceipt = data.latest_receipt_info?.[0];
return {
valid: true,
productId: latestReceipt.product_id,
expiresAt: latestReceipt.expires_date_ms
? new Date(parseInt(latestReceipt.expires_date_ms))
: null,
isInIntroOfferPeriod: latestReceipt.is_in_intro_offer_period === 'true',
cancellationDate: latestReceipt.cancellation_date_ms
? new Date(parseInt(latestReceipt.cancellation_date_ms))
: null,
};
}
// Google validation
const { google } = require('googleapis');
async function validateGooglePurchase(productId, purchaseToken, isSubscription) {
const auth = new google.auth.GoogleAuth({
credentials: JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_JSON),
scopes: ['https://www.googleapis.com/auth/androidpublisher'],
});
const androidPublisher = google.androidpublisher({ version: 'v3', auth });
const packageName = 'com.yourapp';
if (isSubscription) {
const { data } = await androidPublisher.purchases.subscriptionsv2.get({
packageName,
token: purchaseToken,
});
return {
valid: data.subscriptionState === 'SUBSCRIPTION_STATE_ACTIVE',
productId,
expiresAt: data.lineItems?.[0]?.expiryTime
? new Date(data.lineItems[0].expiryTime)
: null,
};
} else {
const { data } = await androidPublisher.purchases.products.get({
packageName,
productId,
token: purchaseToken,
});
// Acknowledge the purchase (required within 3 days)
if (data.acknowledgementState === 0) {
await androidPublisher.purchases.products.acknowledge({
packageName,
productId,
token: purchaseToken,
});
}
return {
valid: data.purchaseState === 0, // 0 = purchased
productId,
};
}
}
Subscription lifecycle: what your server must handle
Apple and Google send server-to-server notifications for subscription events. Implement a webhook endpoint to handle them.
Critical events to handle:
// Apple Server Notifications V2 (via App Store Connect webhook URL)
app.post('/webhooks/apple', async (req, res) => {
const { signedPayload } = req.body;
// Decode and verify the signed JWT from Apple
const payload = await verifyAppleNotification(signedPayload);
const { notificationType, subtype, data } = payload;
switch (notificationType) {
case 'SUBSCRIBED':
// New subscription or resubscription after expiry
await activateSubscription(data.transactionInfo.appAccountToken);
break;
case 'DID_RENEW':
// Successful renewal - extend access
await extendSubscription(data.transactionInfo.appAccountToken,
new Date(data.transactionInfo.expiresDate));
break;
case 'EXPIRED':
// Subscription expired (after all billing retries failed)
await deactivateSubscription(data.transactionInfo.appAccountToken);
break;
case 'DID_FAIL_TO_RENEW':
if (subtype === 'GRACE_PERIOD') {
// In grace period - keep access, show in-app "update payment" prompt
await setGracePeriod(data.transactionInfo.appAccountToken);
}
break;
case 'REFUND':
// Apple issued a refund - revoke access
await deactivateSubscription(data.transactionInfo.appAccountToken);
break;
case 'PRICE_INCREASE':
// User must consent to price increase
// If they don't, subscription will lapse
break;
}
res.sendStatus(200);
});
The grace period is important: when a subscription renewal fails due to a payment issue, Apple (and Google) give the user a grace period (up to 16 days on iOS) before the subscription is considered expired. During this time you should keep access active but show an in-app prompt to update payment details.
Restore purchases
Users who reinstall the app or switch devices need to restore non-consumable purchases and subscriptions.
import { getAvailablePurchases } from 'react-native-iap';
async function restorePurchases() {
try {
const purchases = await getAvailablePurchases();
// Validate each restored purchase with your server
for (const purchase of purchases) {
await handlePurchaseUpdate(purchase);
}
return purchases.length > 0;
} catch (err) {
throw new Error('Restore failed: ' + err.message);
}
}
Apple requires a visible "Restore Purchases" button on your paywall screen. Apps without it get rejected. Place it as a small text link below the main purchase buttons.
Common rejection reasons
"Your app appears to offer paid features without using Apple's IAP" - Any mention of web-based subscriptions, links to your website to subscribe, or external payment for digital content will get rejected. For digital content, IAP is mandatory. Physical goods and services (taxis, food delivery, real-world services) are exempt.
"Your paywall doesn't include a Restore Purchases button" - Add it.
"Your app doesn't clearly describe the subscription terms" - Show price, billing period, and cancellation instructions on the paywall. Include a link to Terms and Privacy Policy.
"Free trial terms are not clearly displayed" - If you offer a trial, display the trial length, what happens when it ends, and the price they'll be charged.
Testing
iOS: Use StoreKit testing in Xcode (no Apple ID needed, instant transactions, controllable renewal intervals) or Sandbox accounts (real Apple IDs in test environment, 1-day subscription = 5 minutes real time).
Android: Use Play Store licensing testers (real purchases that aren't charged) or test product IDs.
// Detect test environment for conditional behavior
const isTestEnvironment = __DEV__ ||
(Platform.OS === 'ios' && receiptData.includes('Sandbox'));
Never ship IAP code without testing: cancelled subscriptions, failed renewals, refunds, and restore purchases. These edge cases are where most bugs hide.
Aunimeda develops mobile applications for iOS and Android - from MVP to production-ready apps with full backend integration.
Contact us to discuss your mobile project. See also: Mobile App Development, Mobile Game Development