On this page
💰 Payments and Subscriptions
Index
RevenueCat Setup
Products and Entitlements
Purchases Context
Paywall UI
Verify Premium Access
Restore Purchases
Webhooks and Backend
Testing
RevenueCat Setup
Installation
npx expo install react-native-purchases
Environment Variables
# Test Store (desarrollo y web)
EXPO_PUBLIC_REVENUECAT_TEST_API_KEY=rcb_xxxxx
# Producción iOS
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=appl_xxxxx
# Producción Android
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=goog_xxxxx
Initialization
import Purchases, { LOG_LEVEL } from 'react-native-purchases' ;
import { Platform } from 'react-native' ;
function getAPIKey ( ) : string {
if ( __DEV__ || Platform. OS === 'web' ) {
return process. env. EXPO_PUBLIC_REVENUECAT_TEST_API_KEY ! ;
}
return Platform. select ( {
ios: process. env. EXPO_PUBLIC_REVENUECAT_IOS_API_KEY ! ,
android: process. env. EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY ! ,
default : process. env. EXPO_PUBLIC_REVENUECAT_TEST_API_KEY ! ,
} ) ;
}
export function initPurchases ( ) {
const apiKey = getAPIKey ( ) ;
if ( __DEV__) {
Purchases. setLogLevel ( LOG_LEVEL . DEBUG ) ;
}
Purchases. configure ( { apiKey } ) ;
}
initPurchases ( ) ;
Productos y Entitlements
Estructura Recomendada
Entitlements:
├── premium → Acceso completo
└── pro → Funciones adicionales
Products:
├── premium_monthly → $9.99/mes → premium
├── premium_yearly → $79.99/año → premium
└── pro_lifetime → $199.99 → pro
Offerings:
└── default
├── monthly_package → premium_monthly
└── annual_package → premium_yearly
Obtener Offerings
import Purchases from 'react-native-purchases' ;
export async function getOfferings ( ) {
try {
const offerings = await Purchases. getOfferings ( ) ;
if ( offerings. current) {
return {
packages: offerings. current. availablePackages,
monthly: offerings. current. monthly,
annual: offerings. current. annual,
lifetime: offerings. current. lifetime,
} ;
}
return null ;
} catch ( error) {
console . error ( 'Error fetching offerings:' , error) ;
throw error;
}
}
Contexto de Compras
import createContextHook from '@nkzw/create-context-hook' ;
import { useEffect, useState, useCallback } from 'react' ;
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' ;
import { Platform } from 'react-native' ;
import Purchases, {
CustomerInfo,
PurchasesOfferings,
PurchasesPackage,
LOG_LEVEL ,
} from 'react-native-purchases' ;
function getAPIKey ( ) : string {
if ( __DEV__ || Platform. OS === 'web' ) {
return process. env. EXPO_PUBLIC_REVENUECAT_TEST_API_KEY || '' ;
}
return Platform. select ( {
ios: process. env. EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || '' ,
android: process. env. EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || '' ,
default : process. env. EXPO_PUBLIC_REVENUECAT_TEST_API_KEY || '' ,
} ) ;
}
const apiKey = getAPIKey ( ) ;
if ( apiKey) {
if ( __DEV__) {
Purchases. setLogLevel ( LOG_LEVEL . DEBUG ) ;
}
Purchases. configure ( { apiKey } ) ;
}
export const [ PurchasesProvider, usePurchases] = createContextHook ( ( ) => {
const queryClient = useQueryClient ( ) ;
const [ isConfigured, setIsConfigured] = useState ( ! ! apiKey) ;
const customerInfoQuery = useQuery ( {
queryKey: [ 'customerInfo' ] ,
queryFn: async ( ) : Promise < CustomerInfo> => {
return Purchases. getCustomerInfo ( ) ;
} ,
enabled: isConfigured,
staleTime: 1000 * 60 * 5 ,
} ) ;
const offeringsQuery = useQuery ( {
queryKey: [ 'offerings' ] ,
queryFn: async ( ) : Promise < PurchasesOfferings> => {
return Purchases. getOfferings ( ) ;
} ,
enabled: isConfigured,
staleTime: 1000 * 60 * 60 ,
} ) ;
useEffect ( ( ) => {
if ( ! isConfigured) return ;
const listener = Purchases. addCustomerInfoUpdateListener ( ( info) => {
queryClient. setQueryData ( [ 'customerInfo' ] , info) ;
} ) ;
return ( ) => listener. remove ( ) ;
} , [ isConfigured, queryClient] ) ;
const purchaseMutation = useMutation ( {
mutationFn : async ( pkg: PurchasesPackage) => {
const { customerInfo } = await Purchases. purchasePackage ( pkg) ;
return customerInfo;
} ,
onSuccess : ( customerInfo) => {
queryClient. setQueryData ( [ 'customerInfo' ] , customerInfo) ;
} ,
} ) ;
const restoreMutation = useMutation ( {
mutationFn : async ( ) => {
const customerInfo = await Purchases. restorePurchases ( ) ;
return customerInfo;
} ,
onSuccess : ( customerInfo) => {
queryClient. setQueryData ( [ 'customerInfo' ] , customerInfo) ;
} ,
} ) ;
const customerInfo = customerInfoQuery. data;
const offerings = offeringsQuery. data;
const isPremium = useCallback (
( entitlement = 'premium' ) => {
return customerInfo?. entitlements. active[ entitlement] ?. isActive ?? false ;
} ,
[ customerInfo]
) ;
const hasEntitlement = useCallback (
( entitlement: string ) => {
return customerInfo?. entitlements. active[ entitlement] ?. isActive ?? false ;
} ,
[ customerInfo]
) ;
const currentPackages = offerings?. current?. availablePackages ?? [ ] ;
const monthlyPackage = offerings?. current?. monthly ?? null ;
const annualPackage = offerings?. current?. annual ?? null ;
const lifetimePackage = offerings?. current?. lifetime ?? null ;
const loginUser = useCallback ( async ( userId: string ) => {
await Purchases. logIn ( userId) ;
queryClient. invalidateQueries ( { queryKey: [ 'customerInfo' ] } ) ;
} , [ queryClient] ) ;
const logoutUser = useCallback ( async ( ) => {
await Purchases. logOut ( ) ;
queryClient. invalidateQueries ( { queryKey: [ 'customerInfo' ] } ) ;
} , [ queryClient] ) ;
return {
isConfigured,
isLoading: customerInfoQuery. isLoading || offeringsQuery. isLoading,
customerInfo,
offerings,
isPremium,
hasEntitlement,
currentPackages,
monthlyPackage,
annualPackage,
lifetimePackage,
purchase: purchaseMutation. mutateAsync,
isPurchasing: purchaseMutation. isPending,
purchaseError: purchaseMutation. error,
restore: restoreMutation. mutateAsync,
isRestoring: restoreMutation. isPending,
restoreError: restoreMutation. error,
loginUser,
logoutUser,
} ;
} ) ;
export function usePremiumGuard ( ) {
const { isPremium, isLoading } = usePurchases ( ) ;
return {
isPremium: isPremium ( ) ,
isLoading,
requiresPremium: ! isLoading && ! isPremium ( ) ,
} ;
}
Paywall UI
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native' ;
import { Check, Crown, Sparkles } from 'lucide-react-native' ;
import { PurchasesPackage } from 'react-native-purchases' ;
import { Button } from '@/components/Button' ;
import { useTheme } from '@/contexts/ThemeContext' ;
import { usePurchases } from '@/contexts/PurchasesContext' ;
import { useToast } from '@/contexts/ToastContext' ;
const FEATURES = [
'Acceso ilimitado a todo el contenido' ,
'Sin anuncios' ,
'Sincronización en la nube' ,
'Soporte prioritario' ,
'Nuevas funciones primero' ,
] ;
interface Props {
onSuccess? : ( ) => void ;
onClose? : ( ) => void ;
}
export function Paywall ( { onSuccess, onClose } : Props) {
const { colors } = useTheme ( ) ;
const { showToast } = useToast ( ) ;
const {
currentPackages,
monthlyPackage,
annualPackage,
purchase,
restore,
isPurchasing,
isRestoring,
isLoading,
} = usePurchases ( ) ;
const [ selectedPackage, setSelectedPackage] = useState < PurchasesPackage | null > (
annualPackage || monthlyPackage
) ;
const handlePurchase = async ( ) => {
if ( ! selectedPackage) return ;
try {
await purchase ( selectedPackage) ;
showToast ( '¡Compra exitosa!' , 'success' ) ;
onSuccess?. ( ) ;
} catch ( error: any ) {
if ( error. userCancelled) return ;
showToast ( error. message || 'Error en la compra' , 'error' ) ;
}
} ;
const handleRestore = async ( ) => {
try {
const info = await restore ( ) ;
const hasPremium = info. entitlements. active[ 'premium' ] ?. isActive;
if ( hasPremium) {
showToast ( '¡Compras restauradas!' , 'success' ) ;
onSuccess?. ( ) ;
} else {
showToast ( 'No se encontraron compras previas' , 'info' ) ;
}
} catch ( error: any ) {
showToast ( error. message || 'Error restaurando' , 'error' ) ;
}
} ;
const formatPrice = ( pkg: PurchasesPackage) => {
return pkg. product. priceString;
} ;
const formatPeriod = ( pkg: PurchasesPackage) => {
const type = pkg. packageType;
switch ( type) {
case 'MONTHLY' : return '/mes' ;
case 'ANNUAL' : return '/año' ;
case 'LIFETIME' : return ' único' ;
default : return '' ;
}
} ;
const calculateSavings = ( ) => {
if ( ! monthlyPackage || ! annualPackage) return null ;
const monthlyPrice = monthlyPackage. product. price;
const annualPrice = annualPackage. product. price;
const yearlyIfMonthly = monthlyPrice * 12 ;
const savings = Math. round ( ( ( yearlyIfMonthly - annualPrice) / yearlyIfMonthly) * 100 ) ;
return savings > 0 ? savings : null ;
} ;
const savings = calculateSavings ( ) ;
if ( isLoading) {
return (
< View style= { [ styles. container, { backgroundColor: colors. background } ] } >
< Text style= { { color: colors. text } } > Cargando... < / Text>
< / View>
) ;
}
return (
< ScrollView
style= { [ styles. container, { backgroundColor: colors. background } ] }
contentContainerStyle= { styles. content}
>
< View style= { styles. header} >
< View style= { [ styles. iconContainer, { backgroundColor: colors. primaryLight } ] } >
< Crown size= { 48 } color= { colors. primary} / >
< / View>
< Text style= { [ styles. title, { color: colors. text } ] } >
Desbloquea Premium
< / Text>
< Text style= { [ styles. subtitle, { color: colors. textSecondary } ] } >
Obtén acceso completo a todas las funciones
< / Text>
< / View>
< View style= { styles. features} >
{ FEATURES . map ( ( feature, index) => (
< View key= { index} style= { styles. featureRow} >
< View style= { [ styles. checkIcon, { backgroundColor: colors. successLight } ] } >
< Check size= { 16 } color= { colors. success} / >
< / View>
< Text style= { [ styles. featureText, { color: colors. text } ] } >
{ feature}
< / Text>
< / View>
) ) }
< / View>
< View style= { styles. packages} >
{ currentPackages. map ( ( pkg) => {
const isSelected = selectedPackage?. identifier === pkg. identifier;
const isAnnual = pkg. packageType === 'ANNUAL' ;
return (
< Pressable
key= { pkg. identifier}
onPress= { ( ) => setSelectedPackage ( pkg) }
style= { [
styles. packageCard,
{
backgroundColor: isSelected ? colors. primaryLight : colors. surface,
borderColor: isSelected ? colors. primary : colors. border,
} ,
] }
>
{ isAnnual && savings && (
< View style= { [ styles. badge, { backgroundColor: colors. success } ] } >
< Text style= { styles. badgeText} > Ahorra { savings} % < / Text>
< / View>
) }
< View style= { styles. packageInfo} >
< Text style= { [ styles. packageTitle, { color: colors. text } ] } >
{ pkg. product. title}
< / Text>
< Text style= { [ styles. packagePrice, { color: colors. primary } ] } >
{ formatPrice ( pkg) } { formatPeriod ( pkg) }
< / Text>
< / View>
< View
style= { [
styles. radio,
{
borderColor: isSelected ? colors. primary : colors. border,
backgroundColor: isSelected ? colors. primary : 'transparent' ,
} ,
] }
>
{ isSelected && < View style= { styles. radioInner} / > }
< / View>
< / Pressable>
) ;
} ) }
< / View>
< View style= { styles. actions} >
< Button
title= "Continuar"
onPress= { handlePurchase}
loading= { isPurchasing}
disabled= { ! selectedPackage}
icon= { < Sparkles size= { 20 } color= "#fff" / > }
/ >
< Button
title= "Restaurar compras"
variant= "ghost"
onPress= { handleRestore}
loading= { isRestoring}
/ >
< / View>
< Text style= { [ styles. terms, { color: colors. textTertiary } ] } >
Al continuar, aceptas los T érminos de Servicio y Política de Privacidad.
La suscripción se renueva automáticamente a menos que se cancele.
< / Text>
< / ScrollView>
) ;
}
const styles = StyleSheet. create ( {
container: {
flex: 1 ,
} ,
content: {
padding: 24 ,
} ,
header: {
alignItems: 'center' ,
marginBottom: 32 ,
} ,
iconContainer: {
width: 96 ,
height: 96 ,
borderRadius: 48 ,
alignItems: 'center' ,
justifyContent: 'center' ,
marginBottom: 16 ,
} ,
title: {
fontSize: 28 ,
fontWeight: '700' as const ,
marginBottom: 8 ,
} ,
subtitle: {
fontSize: 16 ,
textAlign: 'center' ,
} ,
features: {
marginBottom: 32 ,
} ,
featureRow: {
flexDirection: 'row' ,
alignItems: 'center' ,
marginBottom: 12 ,
} ,
checkIcon: {
width: 28 ,
height: 28 ,
borderRadius: 14 ,
alignItems: 'center' ,
justifyContent: 'center' ,
marginRight: 12 ,
} ,
featureText: {
fontSize: 16 ,
flex: 1 ,
} ,
packages: {
gap: 12 ,
marginBottom: 24 ,
} ,
packageCard: {
flexDirection: 'row' ,
alignItems: 'center' ,
padding: 16 ,
borderRadius: 12 ,
borderWidth: 2 ,
position: 'relative' ,
} ,
badge: {
position: 'absolute' ,
top: - 10 ,
right: 16 ,
paddingHorizontal: 8 ,
paddingVertical: 4 ,
borderRadius: 8 ,
} ,
badgeText: {
color: '#fff' ,
fontSize: 12 ,
fontWeight: '600' as const ,
} ,
packageInfo: {
flex: 1 ,
} ,
packageTitle: {
fontSize: 16 ,
fontWeight: '600' as const ,
marginBottom: 4 ,
} ,
packagePrice: {
fontSize: 18 ,
fontWeight: '700' as const ,
} ,
radio: {
width: 24 ,
height: 24 ,
borderRadius: 12 ,
borderWidth: 2 ,
alignItems: 'center' ,
justifyContent: 'center' ,
} ,
radioInner: {
width: 12 ,
height: 12 ,
borderRadius: 6 ,
backgroundColor: '#fff' ,
} ,
actions: {
gap: 12 ,
marginBottom: 16 ,
} ,
terms: {
fontSize: 12 ,
textAlign: 'center' ,
lineHeight: 18 ,
} ,
} ) ;
Verificar Acceso Premium
En Componentes
function PremiumFeature ( ) {
const { isPremium, isLoading } = usePurchases ( ) ;
const router = useRouter ( ) ;
if ( isLoading) return < Loading / > ;
if ( ! isPremium ( ) ) {
return (
< View style= { styles. locked} >
< Text> Esta función requiere Premium< / Text>
< Button
title= "Ver planes"
onPress= { ( ) => router. push ( '/paywall' ) }
/ >
< / View>
) ;
}
return < ActualFeatureContent / > ;
}
Hook Guard
import { useCallback } from 'react' ;
import { useRouter } from 'expo-router' ;
import { usePurchases } from '@/contexts/PurchasesContext' ;
export function usePremiumFeature ( ) {
const { isPremium, isLoading } = usePurchases ( ) ;
const router = useRouter ( ) ;
const requirePremium = useCallback (
( callback : ( ) => void ) => {
if ( isPremium ( ) ) {
callback ( ) ;
} else {
router. push ( '/paywall' ) ;
}
} ,
[ isPremium, router]
) ;
return {
isPremium: isPremium ( ) ,
isLoading,
requirePremium,
} ;
}
function MyComponent ( ) {
const { requirePremium } = usePremiumFeature ( ) ;
const handleExport = ( ) => {
requirePremium ( ( ) => {
exportData ( ) ;
} ) ;
} ;
return < Button title= "Exportar" onPress= { handleExport} / > ;
}
Restaurar Compras
function RestorePurchasesButton ( ) {
const { restore, isRestoring } = usePurchases ( ) ;
const { showToast } = useToast ( ) ;
const handleRestore = async ( ) => {
try {
const info = await restore ( ) ;
if ( Object. keys ( info. entitlements. active) . length > 0 ) {
showToast ( 'Compras restauradas exitosamente' , 'success' ) ;
} else {
showToast ( 'No se encontraron compras previas' , 'info' ) ;
}
} catch ( error: any ) {
showToast ( error. message || 'Error al restaurar' , 'error' ) ;
}
} ;
return (
< Button
title= "Restaurar compras"
variant= "outline"
onPress= { handleRestore}
loading= { isRestoring}
/ >
) ;
}
Webhooks y Backend
Configurar Webhook en RevenueCat
import { z } from 'zod' ;
import { publicProcedure } from '../../trpc' ;
const webhookSchema = z. object ( {
api_version: z. string ( ) ,
event: z. object ( {
type: z. string ( ) ,
app_user_id: z. string ( ) ,
product_id: z. string ( ) . optional ( ) ,
entitlement_ids: z. array ( z. string ( ) ) . optional ( ) ,
} ) ,
} ) ;
export const revenuecatWebhookProcedure = publicProcedure
. input ( webhookSchema)
. mutation ( async ( { input, ctx } ) => {
const { type, app_user_id, product_id, entitlement_ids } = input. event;
console . log ( 'RevenueCat webhook:' , type, app_user_id) ;
switch ( type) {
case 'INITIAL_PURCHASE' :
case 'RENEWAL' :
await ctx. db. user. update ( {
where: { id: app_user_id } ,
data: {
isPremium: true ,
premiumSince: new Date ( ) ,
currentPlan: product_id,
} ,
} ) ;
break ;
case 'CANCELLATION' :
case 'EXPIRATION' :
await ctx. db. user. update ( {
where: { id: app_user_id } ,
data: {
isPremium: false ,
premiumExpired: new Date ( ) ,
} ,
} ) ;
break ;
case 'BILLING_ISSUE' :
await sendBillingIssueEmail ( app_user_id) ;
break ;
}
return { received: true } ;
} ) ;
Testing
Test Store (Desarrollo)
Simular Compras
async function simulatePurchase ( ) {
if ( ! __DEV__) return ;
const offerings = await Purchases. getOfferings ( ) ;
const testPackage = offerings. current?. monthly;
if ( testPackage) {
await Purchases. purchasePackage ( testPackage) ;
}
}
Verificar en Dashboard
Ir a RevenueCat Dashboard
Buscar el usuario por App User ID
Ver historial de transacciones
Verificar entitlements activos
Checklist de Implementación