💰 RevenueCat Guide - In-App Purchases and Subscriptions

Index

  1. Introduction to RevenueCat
  2. Initial Setup
  3. Key Concepts
  4. SDK Implementation
  5. Create a Paywall
  6. Verify Subscriptions
  7. Restore Purchases
  8. Advanced Patterns
  9. Testing
  10. 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

  1. Ve a RevenueCat Dashboard
  2. Crea un proyecto nuevo
  3. 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
  4. 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érminoDescripción
ProductUn producto de compra (suscripción, consumible, etc.)
EntitlementPermiso/acceso que otorga una compra
OfferingGrupo de packages para mostrar al usuario
PackageWrapper de un producto con metadata adicional
CustomerInfoEstado 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:

  1. Usa EXPO_PUBLIC_REVENUECAT_TEST_API_KEY en desarrollo
  2. Las compras se simulan instantáneamente
  3. Puedes probar todos los flujos (compra, cancelación, restauración)

Probar en Sandbox

Para iOS:

  1. Configura usuarios de prueba en App Store Connect
  2. Usa EXPO_PUBLIC_REVENUECAT_IOS_API_KEY
  3. Las suscripciones se renuevan cada 3-5 minutos

Para Android:

  1. Configura license testers en Google Play Console
  2. Usa EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY
  3. 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