By the end of this lesson, you will:
In-app purchases (IAP) are the revenue engine of mobile apps. Apps like Candy Crush, Headspace, and Calm generate millions through IAP. But implementation is tricky: server validation, receipt verification, edge cases.
Google Play Billing manages the complexity: secure payments, currency conversion, family sharing. When integrated correctly, purchases feel seamless. Users tap "Buy," authenticate, and instantly unlock features. Let's build that experience.
Install the In-App Purchase library:
npm install react-native-iap
Configure Android (android/app/build.gradle):
dependencies {
implementation 'com.android.billingclient:billing:6.0.1'
}
Initialize billing:
import {
initConnection,
endConnection,
flushFailedPurchasesCachedAsPendingAndroid,
} from 'react-native-iap';
import { useEffect } from 'react';
export default function BillingSetup() {
useEffect(() => {
setupBilling();
return () => {
endConnection();
};
}, []);
const setupBilling = async () => {
try {
await initConnection();
console.log('Billing connection established');
// Clear pending purchases (Android-specific)
await flushFailedPurchasesCachedAsPendingAndroid();
} catch (error) {
console.error('Billing setup error:', error);
}
};
}
Steps to configure products:
Go to Play Console -> Your App -> Monetize -> In-app products
Create Product:
premium_monthly (unique identifier)Product Types:
Retrieve available products from Play Store:
import { getProducts } from 'react-native-iap';
import { useState } from 'react';
const PRODUCT_IDS = ['remove_ads', 'premium_unlock', 'coin_pack_100'];
export default function ProductFetcher() {
const [products, setProducts] = useState([]);
const loadProducts = async () => {
try {
const items = await getProducts({ skus: PRODUCT_IDS });
console.log('Available products:', items);
setProducts(items);
// Product structure:
// {
// productId: 'remove_ads',
// title: 'Remove Ads',
// description: 'Enjoy ad-free experience',
// price: '$2.99',
// currency: 'USD',
// localizedPrice: '$2.99',
// }
} catch (error) {
console.error('Load products error:', error);
}
};
return { products, loadProducts };
}
Start the purchase flow:
import { requestPurchase } from 'react-native-iap';
import { Alert } from 'react-native';
export const purchaseProduct = async (productId) => {
try {
console.log('Requesting purchase:', productId);
const purchase = await requestPurchase({ skus: [productId] });
console.log('Purchase successful:', purchase);
return purchase;
} catch (error) {
if (error.code === 'E_USER_CANCELLED') {
console.log('User cancelled purchase');
} else if (error.code === 'E_ITEM_ALREADY_OWNED') {
Alert.alert('Already Purchased', 'You already own this item');
} else if (error.code === 'E_DEVELOPER_ERROR') {
Alert.alert('Configuration Error', 'Please contact support');
} else {
console.error('Purchase error:', error);
Alert.alert('Purchase Failed', 'Please try again');
}
return null;
}
};
Handle purchase events:
import {
purchaseUpdatedListener,
finishTransaction,
PurchaseError,
} from 'react-native-iap';
import { useEffect } from 'react';
export default function PurchaseListener({ onPurchaseSuccess }) {
useEffect(() => {
const purchaseUpdateSubscription = purchaseUpdatedListener(
async (purchase) => {
console.log('Purchase update:', purchase);
const { transactionReceipt, productId, purchaseToken } = purchase;
try {
// 1. Verify purchase with your backend
const isValid = await verifyPurchaseWithServer(
productId,
purchaseToken,
transactionReceipt
);
if (isValid) {
// 2. Grant access to feature
await unlockFeature(productId);
// 3. Acknowledge purchase
await finishTransaction({ purchase, isConsumable: false });
// 4. Notify UI
onPurchaseSuccess(productId);
console.log('Purchase completed successfully');
} else {
throw new Error('Purchase verification failed');
}
} catch (error) {
console.error('Purchase processing error:', error);
Alert.alert('Purchase Error', 'Could not verify purchase');
}
}
);
return () => {
purchaseUpdateSubscription.remove();
};
}, [onPurchaseSuccess]);
}
Verify purchases on your backend (critical for security):
// Backend: Node.js/Express example
const { google } = require('googleapis');
const verifyPurchase = async (req, res) => {
const { productId, purchaseToken } = req.body;
try {
// Authenticate with Google Play
const auth = new google.auth.GoogleAuth({
keyFile: 'path/to/service-account-key.json',
scopes: ['https://www.googleapis.com/auth/androidpublisher'],
});
const androidPublisher = google.androidpublisher({
version: 'v3',
auth,
});
// Verify the purchase
const response = await androidPublisher.purchases.products.get({
packageName: 'com.yourapp.package',
productId: productId,
token: purchaseToken,
});
const purchase = response.data;
// Check purchase state
if (purchase.purchaseState === 0) {
// 0 = purchased, 1 = canceled
res.json({ valid: true, purchase });
} else {
res.json({ valid: false, reason: 'Purchase cancelled' });
}
} catch (error) {
console.error('Verification error:', error);
res.status(500).json({ valid: false, error: error.message });
}
};
Let users restore previous purchases:
import { getAvailablePurchases, finishTransaction } from 'react-native-iap';
import { Alert } from 'react-native';
export const restorePurchases = async () => {
try {
console.log('Restoring purchases...');
const purchases = await getAvailablePurchases();
if (purchases.length === 0) {
Alert.alert('No Purchases', 'No previous purchases found');
return [];
}
// Process each purchase
for (const purchase of purchases) {
await unlockFeature(purchase.productId);
await finishTransaction({ purchase, isConsumable: false });
}
Alert.alert('Success', `Restored ${purchases.length} purchase(s)`);
return purchases;
} catch (error) {
console.error('Restore error:', error);
Alert.alert('Restore Failed', 'Could not restore purchases');
return [];
}
};
Handle consumable items (coins, credits):
import { requestPurchase, finishTransaction } from 'react-native-iap';
export const purchaseConsumable = async (productId, amount) => {
try {
const purchase = await requestPurchase({ skus: [productId] });
// Verify with backend
const isValid = await verifyPurchaseWithServer(
productId,
purchase.purchaseToken,
purchase.transactionReceipt
);
if (isValid) {
// Grant consumable item
await grantCoins(amount);
// IMPORTANT: Mark as consumed so user can buy again
await finishTransaction({ purchase, isConsumable: true });
return true;
}
return false;
} catch (error) {
console.error('Consumable purchase error:', error);
return false;
}
};
Track purchase status in app:
import AsyncStorage from '@react-native-async-storage/async-storage';
const PURCHASES_KEY = '@user_purchases';
export const savePurchase = async (productId) => {
try {
const purchases = await getPurchases();
if (!purchases.includes(productId)) {
purchases.push(productId);
await AsyncStorage.setItem(PURCHASES_KEY, JSON.stringify(purchases));
}
} catch (error) {
console.error('Save purchase error:', error);
}
};
export const getPurchases = async () => {
try {
const data = await AsyncStorage.getItem(PURCHASES_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
return [];
}
};
export const hasPurchased = async (productId) => {
const purchases = await getPurchases();
return purchases.includes(productId);
};
export const clearPurchases = async () => {
await AsyncStorage.removeItem(PURCHASES_KEY);
};
Use Google Play Console test accounts:
export const TestingGuide = {
// 1. Add test account in Play Console
addTestAccount: () => {
// Settings → License Testing
// Add email address
},
// 2. Use test mode during development
testMode: {
productIds: ['android.test.purchased', 'android.test.canceled'],
behavior: 'Static responses, no real charges',
},
// 3. Create internal test track
internalTrack: () => {
// Release → Testing → Internal testing
// Add testers email addresses
},
// 4. Use license testing accounts
licenseTesting: () => {
// These accounts can make test purchases
// Purchases complete instantly
// No real money charged
},
};
Handle common IAP errors:
export const handlePurchaseError = (error) => {
const errorHandlers = {
E_USER_CANCELLED: () => {
console.log('User cancelled purchase');
// No alert needed, user intentionally cancelled
},
E_ITEM_ALREADY_OWNED: () => {
Alert.alert(
'Already Purchased',
'You already own this item. Would you like to restore purchases?',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Restore', onPress: () => restorePurchases() },
]
);
},
E_NETWORK_ERROR: () => {
Alert.alert(
'Network Error',
'Please check your connection and try again'
);
},
E_DEVELOPER_ERROR: () => {
Alert.alert(
'Configuration Error',
'Please contact support. Error code: DEV'
);
},
E_BILLING_UNAVAILABLE: () => {
Alert.alert(
'Billing Unavailable',
'In-app purchases are not available on this device'
);
},
default: () => {
Alert.alert('Purchase Failed', 'Please try again later');
},
};
const handler = errorHandlers[error.code] || errorHandlers.default;
handler();
};
Complete IAP implementation:
import { useState, useEffect } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
ScrollView,
ActivityIndicator,
Alert,
} from 'react-native';
import {
initConnection,
endConnection,
getProducts,
requestPurchase,
purchaseUpdatedListener,
finishTransaction,
getAvailablePurchases,
} from 'react-native-iap';
const PRODUCT_IDS = ['remove_ads', 'premium_unlock'];
export default function IAPStoreScreen() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [purchasing, setPurchasing] = useState(null);
const [ownedProducts, setOwnedProducts] = useState([]);
useEffect(() => {
setupIAP();
return () => {
endConnection();
};
}, []);
const setupIAP = async () => {
try {
await initConnection();
await loadProducts();
await loadOwnedProducts();
// Listen for purchase updates
const subscription = purchaseUpdatedListener(async (purchase) => {
const { productId } = purchase;
try {
await finishTransaction({ purchase, isConsumable: false });
await savePurchase(productId);
await loadOwnedProducts();
Alert.alert('Success', 'Purchase completed!');
setPurchasing(null);
} catch (error) {
console.error('Purchase update error:', error);
Alert.alert('Error', 'Could not complete purchase');
}
});
return () => subscription.remove();
} catch (error) {
console.error('IAP setup error:', error);
Alert.alert('Error', 'Could not initialize store');
} finally {
setLoading(false);
}
};
const loadProducts = async () => {
try {
const items = await getProducts({ skus: PRODUCT_IDS });
setProducts(items);
} catch (error) {
console.error('Load products error:', error);
}
};
const loadOwnedProducts = async () => {
const purchases = await getPurchases();
setOwnedProducts(purchases);
};
const handlePurchase = async (productId) => {
setPurchasing(productId);
try {
await requestPurchase({ skus: [productId] });
} catch (error) {
handlePurchaseError(error);
setPurchasing(null);
}
};
const handleRestore = async () => {
try {
const purchases = await getAvailablePurchases();
if (purchases.length === 0) {
Alert.alert('No Purchases', 'No previous purchases found');
return;
}
for (const purchase of purchases) {
await savePurchase(purchase.productId);
}
await loadOwnedProducts();
Alert.alert('Success', `Restored ${purchases.length} purchase(s)`);
} catch (error) {
Alert.alert('Error', 'Could not restore purchases');
}
};
if (loading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.loadingText}>Loading store...</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
<Text style={styles.title}>Upgrade</Text>
{products.map((product) => {
const owned = ownedProducts.includes(product.productId);
const isPurchasing = purchasing === product.productId;
return (
<View key={product.productId} style={styles.productCard}>
<View style={styles.productInfo}>
<Text style={styles.productTitle}>{product.title}</Text>
<Text style={styles.productDescription}>
{product.description}
</Text>
</View>
{owned ? (
<View style={styles.ownedBadge}>
<Text style={styles.ownedText}>✓ Owned</Text>
</View>
) : (
<TouchableOpacity
style={[
styles.purchaseButton,
isPurchasing && styles.purchaseButtonDisabled,
]}
onPress={() => handlePurchase(product.productId)}
disabled={isPurchasing}
>
{isPurchasing ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.purchaseButtonText}>
{product.localizedPrice}
</Text>
)}
</TouchableOpacity>
)}
</View>
);
})}
<TouchableOpacity style={styles.restoreButton} onPress={handleRestore}>
<Text style={styles.restoreButtonText}>Restore Purchases</Text>
</TouchableOpacity>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
padding: 20,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: '#666',
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 20,
},
productCard: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
marginBottom: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
productInfo: {
flex: 1,
marginRight: 16,
},
productTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 4,
},
productDescription: {
fontSize: 14,
color: '#666',
},
purchaseButton: {
backgroundColor: '#007AFF',
borderRadius: 8,
paddingHorizontal: 20,
paddingVertical: 12,
minWidth: 80,
alignItems: 'center',
},
purchaseButtonDisabled: {
opacity: 0.6,
},
purchaseButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
ownedBadge: {
backgroundColor: '#4CAF50',
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 8,
},
ownedText: {
color: '#fff',
fontSize: 14,
fontWeight: 'bold',
},
restoreButton: {
marginTop: 12,
padding: 16,
alignItems: 'center',
},
restoreButtonText: {
color: '#007AFF',
fontSize: 16,
},
});
| Pitfall | Solution |
|---|---|
Forgetting finishTransaction() |
Always acknowledge purchases, or they'll be refunded |
| No backend verification | Always verify purchases server-side for security |
| Testing in debug mode | Use release build with test accounts for accurate testing |
| Not handling restores | Implement restore functionality for user trust |
| Ignoring edge cases | Handle network errors, cancellations, already owned |
requestPurchase() to initiate purchase flowpurchaseUpdatedListener()finishTransaction() to acknowledge purchasesIn the next lesson, we'll explore Subscriptions, implementing recurring revenue with subscription management, grace periods, and cancellation handling.