@ridwan-retainer/paywall
v0.1.2
Published
Revenue-first monetization layer using RevenueCat for in-app purchases and subscriptions
Downloads
282
Maintainers
Readme
@ridwan-retainer/paywall
A complete, production-ready monetization solution for React Native apps using RevenueCat. Implements in-app purchases, subscriptions, paywalls, and entitlement management with a developer-friendly API.
📋 Table of Contents
- Features
- Installation
- Quick Start
- Core Concepts
- API Reference
- Complete Examples
- Testing
- Best Practices
- Troubleshooting
✨ Features
- 💰 RevenueCat Integration: Full SDK integration with cross-platform support
- 🎨 Pre-built Components: Ready-to-use paywall, purchase buttons, and subscription gates
- 🔐 Entitlement Management: Automatic entitlement checking and caching
- ♻️ Restore Purchases: Built-in restore functionality with error handling
- 📦 Offering Management: Easy access to products and packages
- 🎯 Feature Gates: Control feature access based on subscriptions
- ⚡ Performance: Optimized caching and minimal re-renders
- 🪝 React Hooks: Modern hooks-based API
- 📝 TypeScript: Full type safety
- ✅ Well-tested: Comprehensive test coverage
📦 Installation
npm install @ridwan-retainer/paywallPeer Dependencies
npm install react-native-purchases \
react-native-purchases-ui \
@react-native-async-storage/async-storage \
react \
react-nativePlatform Setup
iOS Setup
- Install CocoaPods dependencies:
cd ios && pod installConfigure App Store Connect:
- Create your app in App Store Connect
- Set up in-app products and subscriptions
- Submit for review
Add StoreKit configuration (for testing in Xcode):
- File → New → File → StoreKit Configuration File
- Add your products for local testing
Android Setup
Configure Google Play Console:
- Create your app in Google Play Console
- Set up in-app products and subscriptions
- Add test users for testing
Add billing permission in
android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="com.android.vending.BILLING" />RevenueCat Setup
- Create RevenueCat account: https://app.revenuecat.com/signup
- Create a project and configure your app
- Link App Store/Google Play:
- iOS: Add App Store Connect API Key
- Android: Add Google Play Service Account JSON
- Set up products:
- Create offerings
- Add products to offerings
- Configure entitlements
- Get API keys:
- Settings → API Keys
- Copy iOS and Android keys
🚀 Quick Start
1. Configure Environment Variables
Create a .env file:
EXPO_PUBLIC_REVENUECAT_IOS=your_ios_api_key
EXPO_PUBLIC_REVENUECAT_ANDROID=your_android_api_key2. Initialize RevenueCat
import { useRevenueCatInitialization } from '@ridwan-retainer/paywall';
import { useEffect } from 'react';
function App() {
const { isInitialized, error } = useRevenueCatInitialization({
appUserID: 'user_123', // Optional: your user ID
});
if (error) {
console.error('RevenueCat initialization failed:', error);
}
return isInitialized ? <YourApp /> : <LoadingScreen />;
}3. Display a Paywall
import { Paywall } from '@ridwan-retainer/paywall';
function PaywallScreen() {
return (
<Paywall
onPurchaseCompleted={({ customerInfo }) => {
console.log('Purchase successful!', customerInfo);
// Navigate away or show success message
}}
onDismiss={() => {
console.log('Paywall dismissed');
}}
onPurchaseError={({ error }) => {
console.error('Purchase failed:', error);
}}
/>
);
}4. Gate Features
import { SubscriptionGate } from '@ridwan-retainer/paywall';
function PremiumFeature() {
return (
<SubscriptionGate>
<Text>This is premium content!</Text>
<PremiumFeatureContent />
</SubscriptionGate>
);
}🧠 Core Concepts
Offerings and Packages
Offerings are collections of products (packages) you want to sell. Create them in RevenueCat dashboard.
Packages are individual subscription or purchase options (e.g., Monthly, Annual).
import { useCurrentOffering } from '@ridwan-retainer/paywall';
function ProductList() {
const { offering, isLoading } = useCurrentOffering();
if (isLoading) return <Loading />;
return (
<View>
{offering?.availablePackages.map((pkg) => (
<Text key={pkg.identifier}>
{pkg.product.title} - {pkg.product.priceString}
</Text>
))}
</View>
);
}Entitlements
Entitlements represent access to features (configured in RevenueCat dashboard).
import { useEntitlement } from '@ridwan-retainer/paywall';
function PremiumButton() {
const { hasEntitlement, isLoading } = useEntitlement('premium');
return (
<Button disabled={!hasEntitlement}>
{hasEntitlement ? 'Premium Feature' : 'Upgrade to Access'}
</Button>
);
}Customer Info
Customer Info contains all subscription and purchase data for a user.
import { useCustomerInfo } from '@ridwan-retainer/paywall';
function SubscriptionStatus() {
const { customerInfo, isLoading } = useCustomerInfo();
const activeSub = customerInfo?.activeSubscriptions[0];
return (
<Text>
Status: {activeSub ? 'Subscribed' : 'Free'}
</Text>
);
}📚 API Reference
Initialization
useRevenueCatInitialization(config?: RevenueCatConfig)
Initialize RevenueCat SDK. Call once at app startup.
import { useRevenueCatInitialization } from '@ridwan-retainer/paywall';
function App() {
const { isInitialized, error } = useRevenueCatInitialization({
appUserID: 'user_123', // Optional: Custom user ID
observerMode: false, // Optional: Observer mode (default: false)
useAmazon: false, // Optional: Use Amazon store (default: false)
});
return isInitialized ? <Main /> : <Splash />;
}Returns:
{
isInitialized: boolean;
error: Error | null;
}initializeRevenueCat(apiKey: string, config?: RevenueCatConfig): Promise<void>
Programmatic initialization (alternative to hook).
import { initializeRevenueCat } from '@ridwan-retainer/paywall';
await initializeRevenueCat('rcb_abc123', {
appUserID: 'user_123',
});Hooks
useOfferings()
Get all available offerings.
import { useOfferings } from '@ridwan-retainer/paywall';
function OfferingsList() {
const { offerings, currentOffering, isLoading, error } = useOfferings();
if (isLoading) return <Loading />;
if (error) return <Error message={error.message} />;
return (
<View>
{offerings?.all && Object.values(offerings.all).map((offering) => (
<OfferingCard key={offering.identifier} offering={offering} />
))}
</View>
);
}Returns:
{
offerings: PurchasesOfferings | null;
currentOffering: PurchasesOffering | null;
isLoading: boolean;
error: Error | null;
refresh: () => Promise<void>;
}useCurrentOffering()
Get the current (default) offering.
import { useCurrentOffering } from '@ridwan-retainer/paywall';
function SubscriptionPlans() {
const { offering, isLoading } = useCurrentOffering();
return (
<View>
{offering?.availablePackages.map((pkg) => (
<PlanCard key={pkg.identifier} package={pkg} />
))}
</View>
);
}Returns:
{
offering: PurchasesOffering | null;
isLoading: boolean;
error: Error | null;
refresh: () => Promise<void>;
}useCustomerInfo()
Get customer subscription information.
import { useCustomerInfo } from '@ridwan-retainer/paywall';
function AccountScreen() {
const { customerInfo, isLoading, refresh } = useCustomerInfo();
const isPremium = customerInfo?.entitlements.active['premium']?.isActive;
const expirationDate = customerInfo?.entitlements.active['premium']?.expirationDate;
return (
<View>
<Text>Status: {isPremium ? 'Premium' : 'Free'}</Text>
{expirationDate && (
<Text>Expires: {new Date(expirationDate).toLocaleDateString()}</Text>
)}
<Button onPress={refresh}>Refresh</Button>
</View>
);
}Returns:
{
customerInfo: CustomerInfo | null;
isLoading: boolean;
error: Error | null;
refresh: () => Promise<void>;
}useEntitlement(entitlementId: string)
Check if user has a specific entitlement.
import { useEntitlement } from '@ridwan-retainer/paywall';
function PremiumFeatureButton() {
const { hasEntitlement, isLoading, entitlementInfo } = useEntitlement('premium');
if (isLoading) return <Loading />;
return (
<Button disabled={!hasEntitlement}>
{hasEntitlement ? 'Access Premium' : 'Upgrade Required'}
</Button>
);
}Returns:
{
hasEntitlement: boolean;
entitlementInfo: PurchasesEntitlementInfo | null;
isLoading: boolean;
error: Error | null;
}usePurchase()
Handle purchase flow.
import { usePurchase } from '@ridwan-retainer/paywall';
function BuyButton({ package: pkg }) {
const { purchase, isPurchasing, error } = usePurchase();
const handlePurchase = async () => {
try {
const result = await purchase(pkg);
console.log('Purchase successful:', result);
// Show success message
} catch (err) {
console.error('Purchase failed:', err);
// Show error message
}
};
return (
<Button onPress={handlePurchase} disabled={isPurchasing}>
{isPurchasing ? 'Processing...' : `Buy ${pkg.product.priceString}`}
</Button>
);
}Returns:
{
purchase: (pkg: PurchasesPackage) => Promise<PurchaseResult>;
isPurchasing: boolean;
error: PurchaseError | null;
}useRestorePurchases()
Restore previous purchases.
import { useRestorePurchases } from '@ridwan-retainer/paywall';
function RestoreButton() {
const { restore, isRestoring, error } = useRestorePurchases();
const handleRestore = async () => {
try {
const result = await restore();
if (result.customerInfo.activeSubscriptions.length > 0) {
Alert.alert('Success', 'Purchases restored!');
} else {
Alert.alert('No Purchases', 'No purchases found to restore.');
}
} catch (err) {
Alert.alert('Error', 'Failed to restore purchases');
}
};
return (
<Button onPress={handleRestore} disabled={isRestoring}>
{isRestoring ? 'Restoring...' : 'Restore Purchases'}
</Button>
);
}Returns:
{
restore: () => Promise<RestoreResult>;
isRestoring: boolean;
error: RestoreError | null;
}usePaywall()
Access paywall controller for programmatic control.
import { usePaywall } from '@ridwan-retainer/paywall';
function CustomPaywall() {
const { showPaywall, dismissPaywall } = usePaywall();
const handleShowPaywall = () => {
showPaywall({
onPurchaseCompleted: (info) => {
console.log('Purchase completed:', info);
dismissPaywall();
},
onDismiss: () => {
console.log('Paywall dismissed');
},
});
};
return <Button onPress={handleShowPaywall}>Upgrade</Button>;
}Components
<Paywall />
Full-screen RevenueCat paywall UI.
import { Paywall } from '@ridwan-retainer/paywall';
<Paywall
onPurchaseStarted={({ packageBeingPurchased }) => {
console.log('Starting purchase:', packageBeingPurchased);
}}
onPurchaseCompleted={({ customerInfo, storeTransaction }) => {
console.log('Purchase completed!');
navigation.goBack();
}}
onPurchaseError={({ error }) => {
Alert.alert('Purchase Failed', error.message);
}}
onPurchaseCancelled={() => {
console.log('User cancelled purchase');
}}
onRestoreCompleted={({ customerInfo }) => {
Alert.alert('Success', 'Purchases restored!');
}}
onRestoreError={({ error }) => {
Alert.alert('Error', 'Restore failed');
}}
onDismiss={() => {
navigation.goBack();
}}
/>Props:
{
onPurchaseStarted?: (data: { packageBeingPurchased: PurchasesPackage }) => void;
onPurchaseCompleted?: (data: { customerInfo: CustomerInfo; storeTransaction: any }) => void;
onPurchaseError?: (data: { error: PurchasesError }) => void;
onPurchaseCancelled?: () => void;
onRestoreCompleted?: (data: { customerInfo: CustomerInfo }) => void;
onRestoreError?: (data: { error: PurchasesError }) => void;
onDismiss?: () => void;
}<SubscriptionGate />
Show content only to subscribers.
import { SubscriptionGate } from '@ridwan-retainer/paywall';
<SubscriptionGate>
<PremiumContent />
</SubscriptionGate>
// Automatically redirects non-subscribers to paywall<PaywallGate />
Control feature access with modal paywall.
import { PaywallGate } from '@ridwan-retainer/paywall';
<PaywallGate
entitlementId="premium"
onAccessGranted={() => {
console.log('User now has access!');
}}
>
<View>
<Text>Premium Feature</Text>
<Button title="Click to Access" />
</View>
</PaywallGate>Props:
{
entitlementId: string;
children: ReactNode;
onAccessGranted?: () => void;
}<PurchaseButton />
Pre-styled purchase button.
import { PurchaseButton } from '@ridwan-retainer/paywall';
function SubscriptionPlan({ package: pkg }) {
return (
<PurchaseButton
package={pkg}
title={`Subscribe for ${pkg.product.priceString}`}
onSuccess={() => {
Alert.alert('Welcome!', 'You are now subscribed!');
}}
onError={(error) => {
Alert.alert('Error', error.message);
}}
style={{ backgroundColor: '#007AFF', padding: 16 }}
/>
);
}Props:
{
package: PurchasesPackage;
onSuccess?: () => void;
onError?: (error: Error) => void;
title?: string;
style?: ViewStyle;
}<RestoreButton />
Pre-styled restore purchases button.
import { RestoreButton } from '@ridwan-retainer/paywall';
<RestoreButton
onSuccess={() => {
Alert.alert('Success', 'Purchases restored!');
}}
onError={(error) => {
Alert.alert('Error', error.message);
}}
title="Restore Purchases"
style={{ padding: 12 }}
/>Props:
{
onSuccess?: () => void;
onError?: (error: Error) => void;
title?: string;
style?: ViewStyle;
}<PaywallFooter />
Displays legal text required for App Store compliance.
import { PaywallFooter } from '@ridwan-retainer/paywall';
<PaywallFooter
termsUrl="https://yourapp.com/terms"
privacyUrl="https://yourapp.com/privacy"
/>Managers
For advanced use cases, you can use managers directly:
offeringsManager
import { offeringsManager } from '@ridwan-retainer/paywall';
// Fetch offerings
const offerings = await offeringsManager.fetchOfferings();
// Get cached offerings
const cached = offeringsManager.getCachedOfferings();customerInfoManager
import { customerInfoManager } from '@ridwan-retainer/paywall';
// Get customer info
const info = await customerInfoManager.getCustomerInfo();
// Check entitlement
const hasAccess = await customerInfoManager.hasEntitlement('premium');purchaseManager
import { purchaseManager } from '@ridwan-retainer/paywall';
// Make purchase
const result = await purchaseManager.purchasePackage(package);restoreManager
import { restoreManager } from '@ridwan-retainer/paywall';
// Restore purchases
const result = await restoreManager.restorePurchases();💡 Complete Examples
Example 1: Subscription Paywall Screen
import React from 'react';
import { View, Text, ScrollView, StyleSheet } from 'react-native';
import {
useCurrentOffering,
PurchaseButton,
RestoreButton,
PaywallFooter,
} from '@ridwan-retainer/paywall';
function SubscriptionScreen() {
const { offering, isLoading } = useCurrentOffering();
if (isLoading) {
return <LoadingSpinner />;
}
if (!offering) {
return <ErrorView message="No plans available" />;
}
return (
<ScrollView style={styles.container}>
<Text style={styles.title}>Upgrade to Premium</Text>
<Text style={styles.subtitle}>
Unlock all features and support development
</Text>
<View style={styles.features}>
<FeatureRow icon="✓" text="Unlimited access" />
<FeatureRow icon="✓" text="Priority support" />
<FeatureRow icon="✓" text="Remove ads" />
<FeatureRow icon="✓" text="Cloud sync" />
</View>
{offering.availablePackages.map((pkg) => (
<View key={pkg.identifier} style={styles.plan}>
<Text style={styles.planTitle}>{pkg.product.title}</Text>
<Text style={styles.planPrice}>{pkg.product.priceString}</Text>
<PurchaseButton
package={pkg}
title="Subscribe"
style={styles.button}
onSuccess={() => {
navigation.goBack();
}}
/>
</View>
))}
<RestoreButton
title="Restore Purchases"
style={styles.restoreButton}
/>
<PaywallFooter
termsUrl="https://yourapp.com/terms"
privacyUrl="https://yourapp.com/privacy"
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20 },
title: { fontSize: 28, fontWeight: 'bold', textAlign: 'center' },
subtitle: { fontSize: 16, color: '#666', textAlign: 'center', marginTop: 8 },
features: { marginVertical: 24 },
plan: { marginVertical: 12, padding: 16, borderWidth: 1, borderRadius: 8 },
button: { backgroundColor: '#007AFF', padding: 16, borderRadius: 8, marginTop: 12 },
restoreButton: { marginTop: 16, padding: 12 },
});Example 2: Feature Gating
import { useEntitlement } from '@ridwan-retainer/paywall';
function AdvancedSettings() {
const { hasEntitlement, isLoading } = useEntitlement('premium');
if (isLoading) return <Loading />;
return (
<View>
<SettingItem title="Basic Setting" />
<SettingItem title="Another Setting" />
{hasEntitlement ? (
<>
<SettingItem title="Premium Setting 1" />
<SettingItem title="Premium Setting 2" />
<SettingItem title="Advanced Options" />
</>
) : (
<UpgradePrompt
message="Unlock advanced settings with Premium"
onPress={() => navigation.navigate('Subscription')}
/>
)}
</View>
);
}Example 3: Usage-Based Limits
import { useCustomerInfo } from '@ridwan-retainer/paywall';
import { useState, useEffect } from 'react';
function ExportFeature() {
const { customerInfo } = useCustomerInfo();
const [exportCount, setExportCount] = useState(0);
const isPremium = customerInfo?.entitlements.active['premium']?.isActive;
const maxExports = isPremium ? Infinity : 3;
const canExport = exportCount < maxExports;
const handleExport = async () => {
if (!canExport && !isPremium) {
navigation.navigate('Subscription');
return;
}
await performExport();
setExportCount(prev => prev + 1);
};
return (
<View>
{!isPremium && (
<Text>{maxExports - exportCount} exports remaining</Text>
)}
<Button
title="Export"
onPress={handleExport}
disabled={!canExport && !isPremium}
/>
</View>
);
}🧪 Testing
Test Purchases
iOS
- Use Sandbox test users (App Store Connect → Users and Access → Sandbox Testers)
- Sign in with test account on device (Settings → App Store → Sandbox Account)
- Make purchases (won't charge real money)
Android
- Add test users in Google Play Console
- Use license testing or internal testing track
- Make purchases with test account
Test in Development
import { initializeRevenueCat } from '@ridwan-retainer/paywall';
// Enable debug logging
await initializeRevenueCat(API_KEY, {
appUserID: `test_user_${Date.now()}`, // Unique test user
});Mock for Unit Tests
jest.mock('@ridwan-retainer/paywall', () => ({
useCustomerInfo: () => ({
customerInfo: {
entitlements: {
active: {
premium: { isActive: true },
},
},
},
isLoading: false,
}),
}));✅ Best Practices
1. Check Subscription Status Regularly
// On app open or resume
useEffect(() => {
customerInfoManager.getCustomerInfo({ forceRefresh: true });
}, []);2. Handle Errors Gracefully
const { purchase, error } = usePurchase();
useEffect(() => {
if (error) {
if (error.userCancelled) {
// User cancelled, no need to show error
return;
}
Alert.alert('Purchase Error', getUserFriendlyErrorMessage(error));
}
}, [error]);3. Provide Restore Option
Always show a "Restore Purchases" button for users who reinstalled the app.
4. Cache Appropriately
The library caches offerings and customer info automatically, but you can force refresh:
const { refresh } = useCustomerInfo();
// Force refresh on pull-to-refresh
<ScrollView
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refresh} />
}
>5. Test Both Platforms
Subscription behavior differs between iOS and Android. Test thoroughly on both.
6. Follow App Store Guidelines
- Show clear pricing
- Include terms and privacy links
- Provide restore functionality
- Handle subscription management
🐛 Troubleshooting
Issue: "Unable to find offerings"
Solution: Check RevenueCat dashboard configuration:
- Verify offerings are created
- Check products are added to offerings
- Ensure API keys are correct
Issue: "Invalid product identifiers"
Solution: Product IDs must match exactly between:
- App Store Connect / Google Play Console
- RevenueCat dashboard
- Your code
Issue: Purchases not working in production
Solution:
- Use production API keys (not sandbox)
- Verify app is live in stores
- Check RevenueCat integration status
- Test with real Apple/Google account
Issue: "Could not find customer"
Solution: Initialize RevenueCat before making any calls:
const { isInitialized } = useRevenueCatInitialization();
if (!isInitialized) {
return <Loading />;
}Issue: Subscription status not updating
Solution: Force refresh customer info:
const { refresh } = useCustomerInfo();
await refresh();🤝 Contributing
Contributions welcome! Please submit Pull Requests.
📄 License
MIT © Ridwan Hamid
