💰 RevenueCat Guide - In-App Purchases and Subscriptions
Index
- Introduction to RevenueCat
- Initial Setup
- Key Concepts
- SDK Implementation
- Create a Paywall
- Verify Subscriptions
- Restore Purchases
- Advanced Patterns
- Testing
- Launch Checklist
Introduction to RevenueCat
RevenueCat is a platform that simplifies implementing in-app purchases and subscriptions. It handles:
- Receipt validation with App Store and Play Store
- Real-time subscription status
- Revenue analytics
- Webhooks for subscription events
- Test Store for development without real accounts
Advantages
- Single SDK for iOS, Android and Web
- No backend needed to validate purchases
- Dashboard with revenue metrics
- Test Store for fast development
Initial Setup
1. Installation
npx expo install react-native-purchases
2. Variables de Entorno
Necesitas 3 API keys (una por tienda):
# Test Store (desarrollo y web)
EXPO_PUBLIC_REVENUECAT_TEST_API_KEY=rcb_xxxxx
# iOS App Store (producción)
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=appl_xxxxx
# Android Play Store (producción)
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=goog_xxxxx
3. Obtener API Keys
- Ve a RevenueCat Dashboard
- Crea un proyecto nuevo
- Agrega 3 apps:
- Test Store (tipo: RC Billing) → Para desarrollo
- App Store (tipo: App Store) → Para iOS producción
- Play Store (tipo: Play Store) → Para Android producción
- Copia las Public API Keys de cada app
4. Configurar en el Dashboard
Para cada app, configura:
Productos:
- Crea productos con identificadores (ej:
monthly_premium,yearly_premium) - Define precios (Test Store permite precios personalizados)
Entitlements:
- Crea entitlements (ej:
premium,pro) - Son los "permisos" que otorgan los productos
Offerings:
- Agrupa productos en offerings (ej:
default) - Cada offering contiene packages (ej:
$rc_monthly,$rc_annual)
Conceptos Clave
Jerarquía de RevenueCat
Project
└── Apps (Test Store, App Store, Play Store)
└── Products (monthly_premium, yearly_premium)
└── Entitlements (premium)
└── Offerings (default)
└── Packages ($rc_monthly, $rc_annual)
Términos Importantes
| Término | Descripción |
|---|---|
| Product | Un producto de compra (suscripción, consumible, etc.) |
| Entitlement | Permiso/acceso que otorga una compra |
| Offering | Grupo de packages para mostrar al usuario |
| Package | Wrapper de un producto con metadata adicional |
| CustomerInfo | Estado actual de suscripciones del usuario |
Implementación del SDK
Inicialización
// lib/revenuecat.ts
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
import { Platform } from 'react-native';
function getRevenueCatAPIKey(): 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 async function initializeRevenueCat(userId?: string) {
const apiKey = getRevenueCatAPIKey();
if (!apiKey) {
console.error('RevenueCat API key not found');
return;
}
if (__DEV__) {
Purchases.setLogLevel(LOG_LEVEL.DEBUG);
}
await Purchases.configure({
apiKey,
appUserID: userId, // Opcional: identificar usuario
});
console.log('RevenueCat initialized');
}
Contexto de Suscripciones
// contexts/SubscriptionContext.tsx
import createContextHook from '@nkzw/create-context-hook';
import { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Purchases, {
CustomerInfo,
PurchasesOffering,
PurchasesPackage,
} from 'react-native-purchases';
import { initializeRevenueCat } from '@/lib/revenuecat';
interface SubscriptionState {
isInitialized: boolean;
isPremium: boolean;
customerInfo: CustomerInfo | null;
currentOffering: PurchasesOffering | null;
}
export const [SubscriptionProvider, useSubscription] = createContextHook(() => {
const queryClient = useQueryClient();
const [isInitialized, setIsInitialized] = useState(false);
// Inicializar RevenueCat
useEffect(() => {
const init = async () => {
try {
await initializeRevenueCat();
setIsInitialized(true);
} catch (error) {
console.error('Error initializing RevenueCat:', error);
}
};
init();
}, []);
// Query para CustomerInfo
const customerInfoQuery = useQuery({
queryKey: ['customerInfo'],
queryFn: async () => {
const info = await Purchases.getCustomerInfo();
return info;
},
enabled: isInitialized,
staleTime: 1000 * 60 * 5, // 5 minutos
});
// Query para Offerings
const offeringsQuery = useQuery({
queryKey: ['offerings'],
queryFn: async () => {
const offerings = await Purchases.getOfferings();
return offerings;
},
enabled: isInitialized,
staleTime: 1000 * 60 * 60, // 1 hora
});
// Escuchar cambios en CustomerInfo
useEffect(() => {
if (!isInitialized) return;
const listener = Purchases.addCustomerInfoUpdateListener((info) => {
queryClient.setQueryData(['customerInfo'], info);
});
return () => {
listener.remove();
};
}, [isInitialized, queryClient]);
// Verificar si es premium
const isPremium = useCallback(() => {
const info = customerInfoQuery.data;
if (!info) return false;
// Verificar entitlement "premium"
return info.entitlements.active['premium'] !== undefined;
}, [customerInfoQuery.data]);
// Mutation para comprar
const purchaseMutation = useMutation({
mutationFn: async (packageToPurchase: PurchasesPackage) => {
const { customerInfo } = await Purchases.purchasePackage(packageToPurchase);
return customerInfo;
},
onSuccess: (customerInfo) => {
queryClient.setQueryData(['customerInfo'], customerInfo);
},
});
// Mutation para restaurar
const restoreMutation = useMutation({
mutationFn: async () => {
const customerInfo = await Purchases.restorePurchases();
return customerInfo;
},
onSuccess: (customerInfo) => {
queryClient.setQueryData(['customerInfo'], customerInfo);
},
});
// Identificar usuario (después de login)
const identifyUser = useCallback(async (userId: string) => {
try {
const { customerInfo } = await Purchases.logIn(userId);
queryClient.setQueryData(['customerInfo'], customerInfo);
return customerInfo;
} catch (error) {
console.error('Error identifying user:', error);
throw error;
}
}, [queryClient]);
// Logout
const logoutUser = useCallback(async () => {
try {
const customerInfo = await Purchases.logOut();
queryClient.setQueryData(['customerInfo'], customerInfo);
return customerInfo;
} catch (error) {
console.error('Error logging out user:', error);
throw error;
}
}, [queryClient]);
return {
// Estado
isInitialized,
isLoading: customerInfoQuery.isLoading || offeringsQuery.isLoading,
isPremium: isPremium(),
customerInfo: customerInfoQuery.data ?? null,
currentOffering: offeringsQuery.data?.current ?? null,
allOfferings: offeringsQuery.data?.all ?? {},
// Acciones
purchase: purchaseMutation.mutateAsync,
restore: restoreMutation.mutateAsync,
identifyUser,
logoutUser,
refetch: () => {
customerInfoQuery.refetch();
offeringsQuery.refetch();
},
// Estados de mutations
isPurchasing: purchaseMutation.isPending,
isRestoring: restoreMutation.isPending,
purchaseError: purchaseMutation.error,
};
});
// Hook helper para verificar entitlement específico
export function useEntitlement(entitlementId: string): boolean {
const { customerInfo } = useSubscription();
return customerInfo?.entitlements.active[entitlementId] !== undefined;
}
Agregar Provider
// app/_layout.tsx
import { SubscriptionProvider } from '@/contexts/SubscriptionContext';
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<SubscriptionProvider>
<ThemeProvider>
<RootLayoutNav />
</ThemeProvider>
</SubscriptionProvider>
</QueryClientProvider>
);
}
Crear un Paywall
Componente de Paywall
// components/Paywall.tsx
import React from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { PurchasesPackage } from 'react-native-purchases';
import { useSubscription } from '@/contexts/SubscriptionContext';
import { useToast } from '@/contexts/ToastContext';
import { Colors, Spacing, FontSizes, BorderRadius } from '@/constants/colors';
import { Check, Crown, X } from 'lucide-react-native';
import { Button } from './Button';
interface PaywallProps {
onClose?: () => void;
onSuccess?: () => void;
}
export function Paywall({ onClose, onSuccess }: PaywallProps) {
const {
currentOffering,
isLoading,
isPurchasing,
isRestoring,
purchase,
restore,
} = useSubscription();
const { showToast } = useToast();
const [selectedPackage, setSelectedPackage] = React.useState<PurchasesPackage | null>(null);
// Seleccionar paquete por defecto (annual si existe)
React.useEffect(() => {
if (currentOffering?.availablePackages) {
const annual = currentOffering.availablePackages.find(
p => p.packageType === 'ANNUAL'
);
setSelectedPackage(annual || currentOffering.availablePackages[0]);
}
}, [currentOffering]);
const handlePurchase = async () => {
if (!selectedPackage) return;
try {
await purchase(selectedPackage);
showToast('¡Compra exitosa! Disfruta de Premium', 'success');
onSuccess?.();
} catch (error: any) {
if (error.userCancelled) {
// Usuario canceló, no mostrar error
return;
}
showToast(error.message || 'Error al procesar la compra', 'error');
}
};
const handleRestore = async () => {
try {
const info = await restore();
if (info.entitlements.active['premium']) {
showToast('¡Compras restauradas!', 'success');
onSuccess?.();
} else {
showToast('No se encontraron compras previas', 'info');
}
} catch (error: any) {
showToast(error.message || 'Error al restaurar', 'error');
}
};
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.primary} />
</View>
);
}
const packages = currentOffering?.availablePackages || [];
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
{onClose && (
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<X size={24} color={Colors.text} />
</TouchableOpacity>
)}
<View style={styles.iconContainer}>
<Crown size={48} color={Colors.warning} />
</View>
<Text style={styles.title}>Desbloquea Premium</Text>
<Text style={styles.subtitle}>
Accede a todas las funcionalidades sin límites
</Text>
</View>
{/* Features */}
<View style={styles.featuresContainer}>
{[
'Acceso ilimitado a todo el contenido',
'Sin anuncios',
'Funciones exclusivas',
'Soporte prioritario',
'Sincronización en la nube',
].map((feature, index) => (
<View key={index} style={styles.featureRow}>
<Check size={20} color={Colors.success} />
<Text style={styles.featureText}>{feature}</Text>
</View>
))}
</View>
{/* Packages */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.packagesContainer}
>
{packages.map((pkg) => {
const isSelected = selectedPackage?.identifier === pkg.identifier;
const isAnnual = pkg.packageType === 'ANNUAL';
return (
<TouchableOpacity
key={pkg.identifier}
style={[
styles.packageCard,
isSelected && styles.packageCardSelected,
]}
onPress={() => setSelectedPackage(pkg)}
>
{isAnnual && (
<View style={styles.bestValueBadge}>
<Text style={styles.bestValueText}>Mejor valor</Text>
</View>
)}
<Text style={[
styles.packageTitle,
isSelected && styles.packageTitleSelected,
]}>
{pkg.product.title}
</Text>
<Text style={[
styles.packagePrice,
isSelected && styles.packagePriceSelected,
]}>
{pkg.product.priceString}
</Text>
<Text style={styles.packagePeriod}>
{pkg.packageType === 'ANNUAL' ? '/año' :
pkg.packageType === 'MONTHLY' ? '/mes' :
pkg.packageType === 'WEEKLY' ? '/semana' : ''}
</Text>
{isAnnual && (
<Text style={styles.savingsText}>
Ahorra 50%
</Text>
)}
</TouchableOpacity>
);
})}
</ScrollView>
{/* Actions */}
<View style={styles.actionsContainer}>
<Button
title={isPurchasing ? 'Procesando...' : 'Continuar'}
onPress={handlePurchase}
loading={isPurchasing}
disabled={!selectedPackage || isPurchasing}
variant="gradient"
size="large"
/>
<TouchableOpacity
onPress={handleRestore}
disabled={isRestoring}
style={styles.restoreButton}
>
<Text style={styles.restoreText}>
{isRestoring ? 'Restaurando...' : 'Restaurar compras'}
</Text>
</TouchableOpacity>
</View>
{/* Legal */}
<Text style={styles.legalText}>
La suscripción se renovará automáticamente. Puedes cancelar en cualquier
momento desde la configuración de tu cuenta.
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.background,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
alignItems: 'center',
paddingTop: Spacing.xxl,
paddingHorizontal: Spacing.lg,
},
closeButton: {
position: 'absolute',
top: Spacing.lg,
right: Spacing.lg,
padding: Spacing.sm,
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: Colors.warning + '20',
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.md,
},
title: {
fontSize: FontSizes.xxl,
fontWeight: '700' as const,
color: Colors.text,
marginBottom: Spacing.xs,
},
subtitle: {
fontSize: FontSizes.md,
color: Colors.textSecondary,
textAlign: 'center',
},
featuresContainer: {
padding: Spacing.lg,
gap: Spacing.sm,
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
featureText: {
fontSize: FontSizes.md,
color: Colors.text,
},
packagesContainer: {
paddingHorizontal: Spacing.lg,
gap: Spacing.md,
},
packageCard: {
width: 140,
padding: Spacing.md,
borderRadius: BorderRadius.lg,
borderWidth: 2,
borderColor: Colors.border,
backgroundColor: Colors.surface,
alignItems: 'center',
},
packageCardSelected: {
borderColor: Colors.primary,
backgroundColor: Colors.primary + '10',
},
bestValueBadge: {
position: 'absolute',
top: -10,
backgroundColor: Colors.success,
paddingHorizontal: Spacing.sm,
paddingVertical: 2,
borderRadius: BorderRadius.sm,
},
bestValueText: {
fontSize: FontSizes.xs,
color: Colors.textInverse,
fontWeight: '600' as const,
},
packageTitle: {
fontSize: FontSizes.sm,
color: Colors.textSecondary,
marginBottom: Spacing.xs,
},
packageTitleSelected: {
color: Colors.primary,
},
packagePrice: {
fontSize: FontSizes.xl,
fontWeight: '700' as const,
color: Colors.text,
},
packagePriceSelected: {
color: Colors.primary,
},
packagePeriod: {
fontSize: FontSizes.sm,
color: Colors.textTertiary,
},
savingsText: {
fontSize: FontSizes.xs,
color: Colors.success,
fontWeight: '600' as const,
marginTop: Spacing.xs,
},
actionsContainer: {
padding: Spacing.lg,
gap: Spacing.md,
},
restoreButton: {
alignItems: 'center',
padding: Spacing.sm,
},
restoreText: {
fontSize: FontSizes.sm,
color: Colors.primary,
},
legalText: {
fontSize: FontSizes.xs,
color: Colors.textTertiary,
textAlign: 'center',
paddingHorizontal: Spacing.lg,
paddingBottom: Spacing.lg,
},
});
Pantalla de Paywall
// app/paywall.tsx
import { View, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { Paywall } from '@/components/Paywall';
import { Stack } from 'expo-router';
export default function PaywallScreen() {
const router = useRouter();
return (
<View style={styles.container}>
<Stack.Screen options={{ headerShown: false }} />
<Paywall
onClose={() => router.back()}
onSuccess={() => router.back()}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
Verificar Suscripciones
En Componentes
function PremiumFeature() {
const { isPremium } = useSubscription();
const router = useRouter();
if (!isPremium) {
return (
<View style={styles.lockedContainer}>
<Text>Esta función requiere Premium</Text>
<Button
title="Desbloquear"
onPress={() => router.push('/paywall')}
/>
</View>
);
}
return <ActualPremiumContent />;
}
HOC para Contenido Premium
// components/withPremium.tsx
import React from 'react';
import { useSubscription } from '@/contexts/SubscriptionContext';
import { useRouter } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';
import { Button } from './Button';
export function withPremium<P extends object>(
WrappedComponent: React.ComponentType<P>,
options?: { showPaywall?: boolean }
) {
return function PremiumWrapper(props: P) {
const { isPremium, isLoading } = useSubscription();
const router = useRouter();
if (isLoading) {
return null; // o un loading spinner
}
if (!isPremium) {
if (options?.showPaywall) {
return (
<View style={styles.container}>
<Text style={styles.title}>Contenido Premium</Text>
<Text style={styles.description}>
Actualiza a Premium para acceder a esta función
</Text>
<Button
title="Ver planes"
onPress={() => router.push('/paywall')}
variant="primary"
/>
</View>
);
}
return null;
}
return <WrappedComponent {...props} />;
};
}
const styles = StyleSheet.create({
container: {
padding: 24,
alignItems: 'center',
gap: 16,
},
title: {
fontSize: 20,
fontWeight: '600',
},
description: {
fontSize: 14,
color: '#666',
textAlign: 'center',
},
});
Hook para Feature Flags basados en Suscripción
// hooks/useFeatureAccess.ts
import { useMemo } from 'react';
import { useSubscription } from '@/contexts/SubscriptionContext';
interface FeatureAccess {
canAccessPremiumContent: boolean;
canRemoveAds: boolean;
canUseCloudSync: boolean;
canExportData: boolean;
maxItemsAllowed: number;
}
export function useFeatureAccess(): FeatureAccess {
const { isPremium, customerInfo } = useSubscription();
return useMemo(() => {
// Usuario premium tiene acceso a todo
if (isPremium) {
return {
canAccessPremiumContent: true,
canRemoveAds: true,
canUseCloudSync: true,
canExportData: true,
maxItemsAllowed: Infinity,
};
}
// Usuario gratuito tiene acceso limitado
return {
canAccessPremiumContent: false,
canRemoveAds: false,
canUseCloudSync: false,
canExportData: false,
maxItemsAllowed: 10,
};
}, [isPremium, customerInfo]);
}
Restaurar Compras
Botón de Restaurar en Settings
// En la pantalla de Settings
import { useSubscription } from '@/contexts/SubscriptionContext';
function SettingsScreen() {
const { restore, isRestoring, isPremium } = useSubscription();
const { showToast } = useToast();
const handleRestore = async () => {
try {
const info = await restore();
if (info.entitlements.active['premium']) {
showToast('¡Compras restauradas exitosamente!', 'success');
} else {
showToast('No se encontraron compras anteriores', 'info');
}
} catch (error: any) {
showToast('Error al restaurar compras', 'error');
}
};
return (
<View>
{/* ... otras settings ... */}
<SettingsRow
icon="RefreshCw"
title="Restaurar compras"
subtitle={isPremium ? 'Ya tienes Premium activo' : 'Recupera tus compras anteriores'}
onPress={handleRestore}
loading={isRestoring}
disabled={isRestoring}
/>
</View>
);
}
Patrones Avanzados
Sincronizar con Usuario Autenticado
// Cuando el usuario hace login
async function handleLogin(userId: string) {
const { identifyUser } = useSubscription();
try {
// Autenticar con tu backend
await authLogin(credentials);
// Identificar en RevenueCat
await identifyUser(userId);
// Ahora las compras están vinculadas al usuario
} catch (error) {
console.error('Error:', error);
}
}
// Cuando el usuario hace logout
async function handleLogout() {
const { logoutUser } = useSubscription();
try {
// Logout de tu backend
await authLogout();
// Logout de RevenueCat (genera anonymous ID)
await logoutUser();
} catch (error) {
console.error('Error:', error);
}
}
Verificar Suscripción en el Backend
// backend/lib/revenuecat.ts
const REVENUECAT_API_KEY = process.env.REVENUECAT_SECRET_API_KEY;
const BASE_URL = 'https://api.revenuecat.com/v1';
export async function getSubscriberInfo(appUserId: string) {
const response = await fetch(
`${BASE_URL}/subscribers/${appUserId}`,
{
headers: {
Authorization: `Bearer ${REVENUECAT_API_KEY}`,
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch subscriber info');
}
return response.json();
}
export async function isUserPremium(appUserId: string): Promise<boolean> {
const info = await getSubscriberInfo(appUserId);
const entitlements = info.subscriber?.entitlements || {};
return Object.values(entitlements).some(
(ent: any) => ent.expires_date === null || new Date(ent.expires_date) > new Date()
);
}
Webhooks de RevenueCat
// backend/webhooks/revenuecat/route.ts
import { Hono } from 'hono';
const app = new Hono();
app.post('/webhook', async (c) => {
const event = await c.req.json();
// Verificar autenticación del webhook
const authHeader = c.req.header('Authorization');
if (authHeader !== `Bearer ${process.env.REVENUECAT_WEBHOOK_SECRET}`) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { event: eventType, app_user_id, product_id } = event;
switch (eventType) {
case 'INITIAL_PURCHASE':
// Nueva suscripción
await handleNewSubscription(app_user_id, product_id);
break;
case 'RENEWAL':
// Renovación
await handleRenewal(app_user_id, product_id);
break;
case 'CANCELLATION':
// Cancelación
await handleCancellation(app_user_id);
break;
case 'EXPIRATION':
// Expiración
await handleExpiration(app_user_id);
break;
default:
console.log('Unhandled event:', eventType);
}
return c.json({ received: true });
});
export default app;
Testing
Test Store
El Test Store de RevenueCat permite probar compras sin configurar App Store Connect o Google Play Console:
- Usa
EXPO_PUBLIC_REVENUECAT_TEST_API_KEYen desarrollo - Las compras se simulan instantáneamente
- Puedes probar todos los flujos (compra, cancelación, restauración)
Probar en Sandbox
Para iOS:
- Configura usuarios de prueba en App Store Connect
- Usa
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY - Las suscripciones se renuevan cada 3-5 minutos
Para Android:
- Configura license testers en Google Play Console
- Usa
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY - Las suscripciones tienen tiempos reducidos
Debug con Logs
// En desarrollo
if (__DEV__) {
Purchases.setLogLevel(LOG_LEVEL.DEBUG);
}
// Ver información del usuario actual
const info = await Purchases.getCustomerInfo();
console.log('Customer Info:', JSON.stringify(info, null, 2));
console.log('Active Entitlements:', Object.keys(info.entitlements.active));
Checklist de Lanzamiento
Antes de Publicar
-
Test Store configurado y funcionando
- Productos creados con precios
- Entitlements configurados
- Offerings con packages
- Probado en web preview
-
App Store (iOS) configurado
- App creada en App Store Connect
- Productos in-app creados
- Shared secret configurado en RevenueCat
- API key de iOS agregada al proyecto
-
Play Store (Android) configurado
- App creada en Google Play Console
- Productos in-app creados
- Service account configurado en RevenueCat
- API key de Android agregada al proyecto
-
Código listo
- Paywall implementado
- Verificación de suscripción funciona
- Restaurar compras funciona
- Estados de carga y error manejados
-
Probado en todas las plataformas
- Web (Test Store)
- iOS Simulator (Test Store)
- Android Emulator (Test Store)
- iOS físico (Sandbox)
- Android físico (Test)
-
Legal
- Términos de servicio enlazados
- Política de privacidad enlazada
- Texto de renovación automática visible
- Botón de restaurar compras accesible
Variables de Entorno Requeridas
# Requeridas para producción
EXPO_PUBLIC_REVENUECAT_TEST_API_KEY=rcb_xxxxx
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=appl_xxxxx
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=goog_xxxxx
# Opcional para backend (webhooks)
REVENUECAT_SECRET_API_KEY=sk_xxxxx
REVENUECAT_WEBHOOK_SECRET=whsec_xxxxx