Complete Guide to Flutter In-App Purchases with RevenueCat: From App Store Rejection to Approval
A comprehensive step-by-step guide to implementing in-app purchases in Flutter using RevenueCat. Learn how to configure App Store Connect, Google Play Console, and integrate RevenueCat SDK to handle subscriptions without server-side complexity.

Complete Guide to Flutter In-App Purchases with RevenueCat
TL;DR
- Native in-app purchase implementation requires complex server-side receipt validation
- App Store Review tests with sandbox environment even on production builds
- RevenueCat handles all receipt validation, subscription management, and edge cases
- You keep your custom paywall UI - RevenueCat just handles the backend
- Free up to $2,500/month in revenue, then 1% fee
- This guide covers App Store Connect, Google Play Console, and RevenueCat setup end-to-end
If you've ever had an app rejected by Apple with the dreaded message about receipt validation and sandbox environments, you know the pain. After multiple rejections on my nutrition tracking app Nutrify, I decided to document the complete solution using RevenueCat.
Why RevenueCat Instead of Native Implementation?
Before diving into the implementation, let's understand why RevenueCat is often the better choice.
The Native Implementation Problem
When you use Flutter's in_app_purchase package, you need to:
- Validate receipts server-side - Apple requires this for security
- Handle sandbox vs production - App Store Review uses sandbox even on production builds
- Implement webhook handlers - For subscription renewals, cancellations, refunds
- Manage subscription state - Track expiration dates, grace periods, billing retries
- Support both platforms - iOS and Android have completely different APIs
Here's what Apple's rejection message typically looks like:
"When validating receipts on your server, your server needs to be able to handle a production-signed app getting its receipts from Apple's test environment. The recommended approach is for your production server to always validate receipts against the production App Store first. If validation fails with the error code 'Sandbox receipt used in production,' you should validate against the test environment instead."
The RevenueCat Solution
RevenueCat abstracts all of this:
Your App → RevenueCat SDK → RevenueCat Servers → Apple/Google APIs
↓
Your app just asks:
"Is this user premium?"
You get:
- Automatic receipt validation (sandbox/production handled for you)
- Subscription state management
- Cross-platform unified API
- Dashboard for debugging issues
- Webhook support if you need server sync
- Free up to $2,500/month MTR
Part 1: App Store Connect Setup (iOS)
Before touching any code, you need to configure your products in App Store Connect.
Step 1.1: Verify Paid Apps Agreement
This is the most commonly missed step. Your in-app purchases won't work without it.
- Go to App Store Connect
- Click Business in the top navigation
- Select the Agreements tab
- Look for Paid Apps row
- If status is not "Active", click View and Agree to Terms
- Complete the banking and tax information if prompted
Important: Only the Account Holder can sign agreements. If you're not the Account Holder, contact them to complete this step.
Step 1.2: Create Your App (if not already created)
- Go to Apps in App Store Connect
- Click the + button → New App
- Fill in:
- Platform: iOS
- Name: Your app name
- Primary Language: Your language
- Bundle ID: Select from dropdown (must match your Xcode project)
- SKU: A unique identifier (e.g.,
com.yourcompany.yourapp)
- Click Create
Step 1.3: Create In-App Purchase Products
Navigate to your app → Monetization → In-App Purchases (or Subscriptions for auto-renewable).
For Auto-Renewable Subscriptions:
- Click Manage under Subscriptions
- Click + to create a new Subscription Group
- Name your group (e.g., "Premium Access")
- Click Create
Now add products to the group:
-
Click + next to your subscription group
-
Fill in:
- Reference Name: Internal name (e.g., "Monthly Premium")
- Product ID: Unique identifier (e.g.,
yourapp_monthly)
Important: Product IDs cannot be changed or reused after creation. Choose wisely.
-
Click Create
-
Configure the subscription:
- Subscription Duration: 1 Month, 1 Year, etc.
- Subscription Prices: Click + to add pricing
- Select base country
- Enter price
- Optionally auto-generate prices for other countries
- App Store Localization: Add display name and description for each language
- Review Information: Add screenshot of subscription in use
-
Repeat for each subscription tier (Annual, Lifetime, etc.)
Example Product Configuration:
| Reference Name | Product ID | Duration | Price (US) |
|---|---|---|---|
| Monthly Premium | nutrify_monthly | 1 Month | $9.99 |
| Annual Premium | nutrify_annual | 1 Year | $24.99 |
| Lifetime Access | nutrify_lifetime | Lifetime | $49.99 |
Step 1.4: Configure Free Trials (Optional)
For subscriptions with free trials:
- Select your subscription product
- Under Subscription Prices, click your price
- Click Create Introductory Offer
- Configure:
- Type: Free Trial
- Duration: 3 Days, 1 Week, etc.
- Eligibility: All eligible customers
Step 1.5: Submit Products for Review
In-app purchases must be submitted with your app binary, but you can prepare them:
- Ensure all products have status Ready to Submit
- Each product needs:
- Display name and description
- At least one price point
- Screenshot (for review)
- Products will be reviewed when you submit your app
Step 1.6: Create Sandbox Test Account
For testing without real charges:
- Go to Users and Access in App Store Connect
- Click Sandbox in the left sidebar
- Click + under Sandbox Testers
- Fill in test account details:
- First Name, Last Name
- Email (must be unique, not a real Apple ID)
- Password
- Country/Region
- Click Create
Tip: Use email aliases like
yourname+sandbox1@gmail.comfor easy management.
Step 1.7: Generate App Store Connect API Key (for RevenueCat)
RevenueCat needs API access to verify receipts:
- Go to Users and Access → Integrations (or Keys)
- Click + under "In-App Purchase"
- Name it (e.g., "RevenueCat")
- Click Generate
- Download the .p8 file immediately - you can only download it once
- Note down:
- Issuer ID: Shown at the top
- Key ID: Shown next to your key
Part 2: Google Play Console Setup (Android)
Step 2.1: Create Your App
- Go to Google Play Console
- Click Create app
- Fill in app details and declarations
- Click Create app
Step 2.2: Create In-App Products
Navigate to Monetize → Products → Subscriptions
- Click Create subscription
- Fill in:
- Product ID: Must match iOS (e.g.,
nutrify_monthly) - Name: Display name
- Product ID: Must match iOS (e.g.,
- Click Create
- Add a Base plan:
- Base plan ID: (e.g.,
monthly-plan) - Auto-renewing: Yes
- Billing period: Monthly
- Price: Set pricing by region
- Base plan ID: (e.g.,
- Click Activate for the base plan
Repeat for each subscription tier.
Step 2.3: Create Service Account for RevenueCat
- Go to Setup → API access
- Click Link next to Google Cloud Project (or create one)
- Under Service accounts, click Create new service account
- Follow the link to Google Cloud Console
- In Cloud Console:
- Click Create Service Account
- Name: "RevenueCat"
- Click Create and Continue
- Skip granting access, click Done
- Click on the created service account
- Go to Keys tab → Add Key → Create new key
- Select JSON → Create
- Download the JSON file
- Back in Play Console, click Grant access for the service account
- Set permissions:
- Account permissions: None needed
- App permissions: Select your app → Admin (all permissions)
- Click Invite user → Send invitation
Note: It can take up to 24-48 hours for the service account permissions to propagate.
Part 3: RevenueCat Dashboard Setup
Step 3.1: Create RevenueCat Account
- Go to RevenueCat
- Click Get Started (free)
- Create account with email or GitHub
Step 3.2: Create a Project
- In the RevenueCat dashboard, click + New Project
- Name your project (e.g., "Nutrify")
- Click Create Project
Step 3.3: Configure iOS App
- In your project, go to Apps in the left sidebar
- Click + New App
- Select App Store (iOS)
- Fill in:
- App Name: Your iOS app name
- Bundle ID: Your app's bundle identifier (e.g.,
com.yourcompany.nutrify)
- Click Save
Now connect to App Store Connect:
- Click on your iOS app
- Go to App Store Connect API section
- Enter:
- Issuer ID: From Step 1.7
- Key ID: From Step 1.7
- Private Key: Upload the .p8 file from Step 1.7
- Click Save
Alternatively, use App-Specific Shared Secret:
- In App Store Connect, go to your app → General → App Information
- Under App-Specific Shared Secret, click Manage
- Generate a secret and copy it
- Paste in RevenueCat's App-Specific Shared Secret field
Step 3.4: Configure Android App
- Click + New App again
- Select Play Store (Android)
- Fill in:
- App Name: Your Android app name
- Package Name: Your app's package (e.g.,
com.yourcompany.nutrify)
- Click Save
Connect to Google Play:
- Click on your Android app
- Go to Service Account Credentials section
- Upload the JSON key file from Step 2.3
- Click Save
Step 3.5: Create Entitlements
Entitlements represent what features users unlock.
- Go to Entitlements in the left sidebar
- Click + New Entitlement
- Configure:
- Identifier:
premium(you'll reference this in code) - Description: "Full access to all features"
- Identifier:
- Click Save
Step 3.6: Create Products
Products map to your App Store/Play Store products.
- Go to Products in the left sidebar
- Click + New Product
- For each product:
- Identifier: Match your store product ID (e.g.,
nutrify_monthly) - App: Select iOS or Android
- Store Product ID: Same as identifier
- Identifier: Match your store product ID (e.g.,
- Click Save
- Click Attach to entitlement → Select
premium
Create products for both iOS and Android, matching product IDs.
Step 3.7: Create Offerings
Offerings group products for display in your app.
- Go to Offerings in the left sidebar
- Click + New Offering
- Configure:
- Identifier:
default - Description: "Default offering"
- Identifier:
- Click Save
- Click + New Package
- For each subscription tier:
- Identifier: Use standard identifiers or custom
$rc_monthly- Monthly$rc_annual- Annual$rc_lifetime- Lifetime
- Product: Select your product
- Identifier: Use standard identifiers or custom
- Click Save
Step 3.8: Get API Keys
- Go to API Keys in the left sidebar
- Note down:
- Public iOS API Key:
appl_xxxxxxxx - Public Android API Key:
goog_xxxxxxxx
- Public iOS API Key:
Security Note: These are public keys - safe to include in your app. Never use your secret key in client code.
Part 4: Flutter Implementation
Now for the actual code. We'll integrate RevenueCat while keeping your existing paywall UI.
Step 4.1: Add Dependencies
Update your pubspec.yaml:
dependencies: purchases_flutter: ^8.1.0
Run:
flutter pub get
Step 4.2: iOS Configuration
Update ios/Podfile:
platform :ios, '13.0' # RevenueCat requires iOS 13+
Add StoreKit capability in Xcode:
- Open
ios/Runner.xcworkspacein Xcode - Select your target → Signing & Capabilities
- Click + Capability → Add In-App Purchase
Step 4.3: Android Configuration
Update android/app/build.gradle:
android { compileSdkVersion 34 // or higher defaultConfig { minSdkVersion 21 // RevenueCat minimum } }
Add billing permission in android/app/src/main/AndroidManifest.xml:
<manifest> <uses-permission android:name="com.android.vending.BILLING" /> <!-- ... --> </manifest>
Step 4.4: Create RevenueCat Service
Create lib/core/services/revenuecat_service.dart:
import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:purchases_flutter/purchases_flutter.dart'; class RevenueCatService { // Replace with your actual API keys static const String _appleApiKey = 'appl_YOUR_IOS_KEY'; static const String _googleApiKey = 'goog_YOUR_ANDROID_KEY'; // Entitlement ID from RevenueCat dashboard static const String entitlementId = 'premium'; static bool _isConfigured = false; /// Initialize RevenueCat - call this once at app startup static Future<void> initialize() async { if (_isConfigured) return; await Purchases.setLogLevel(LogLevel.debug); // Remove in production PurchasesConfiguration configuration; if (Platform.isIOS) { configuration = PurchasesConfiguration(_appleApiKey); } else if (Platform.isAndroid) { configuration = PurchasesConfiguration(_googleApiKey); } else { throw UnsupportedError('Platform not supported'); } await Purchases.configure(configuration); _isConfigured = true; debugPrint('RevenueCat: Initialized successfully'); } /// Login user - call after authentication static Future<void> login(String userId) async { try { await Purchases.logIn(userId); debugPrint('RevenueCat: Logged in user $userId'); } catch (e) { debugPrint('RevenueCat: Login error - $e'); } } /// Logout user - call on sign out static Future<void> logout() async { try { await Purchases.logOut(); debugPrint('RevenueCat: Logged out'); } catch (e) { debugPrint('RevenueCat: Logout error - $e'); } } /// Check if user has active premium subscription static Future<bool> isPremium() async { try { final customerInfo = await Purchases.getCustomerInfo(); final entitlement = customerInfo.entitlements.all[entitlementId]; return entitlement?.isActive ?? false; } catch (e) { debugPrint('RevenueCat: Error checking premium status - $e'); return false; } } /// Get current customer info static Future<CustomerInfo> getCustomerInfo() async { return await Purchases.getCustomerInfo(); } /// Get available offerings (products to display) static Future<Offerings?> getOfferings() async { try { final offerings = await Purchases.getOfferings(); return offerings; } catch (e) { debugPrint('RevenueCat: Error fetching offerings - $e'); return null; } } /// Purchase a package static Future<PurchaseResult> purchasePackage(Package package) async { try { final customerInfo = await Purchases.purchasePackage(package); final isPremium = customerInfo.entitlements.all[entitlementId]?.isActive ?? false; return PurchaseResult( success: isPremium, customerInfo: customerInfo, ); } on PurchasesErrorCode catch (e) { if (e == PurchasesErrorCode.purchaseCancelledError) { return PurchaseResult( success: false, userCancelled: true, ); } return PurchaseResult( success: false, error: e.toString(), ); } catch (e) { return PurchaseResult( success: false, error: e.toString(), ); } } /// Restore previous purchases static Future<RestoreResult> restorePurchases() async { try { final customerInfo = await Purchases.restorePurchases(); final isPremium = customerInfo.entitlements.all[entitlementId]?.isActive ?? false; return RestoreResult( success: isPremium, customerInfo: customerInfo, ); } catch (e) { return RestoreResult( success: false, error: e.toString(), ); } } /// Listen to customer info updates static void addCustomerInfoUpdateListener( void Function(CustomerInfo) listener, ) { Purchases.addCustomerInfoUpdateListener(listener); } } /// Result of a purchase attempt class PurchaseResult { final bool success; final bool userCancelled; final String? error; final CustomerInfo? customerInfo; PurchaseResult({ required this.success, this.userCancelled = false, this.error, this.customerInfo, }); } /// Result of a restore attempt class RestoreResult { final bool success; final String? error; final CustomerInfo? customerInfo; RestoreResult({ required this.success, this.error, this.customerInfo, }); }
Step 4.5: Initialize at App Startup
In your main.dart or splash screen:
import 'package:your_app/core/services/revenuecat_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // Initialize RevenueCat early await RevenueCatService.initialize(); runApp(MyApp()); }
Step 4.6: Login User After Authentication
In your auth flow, after successful login:
// After user signs in await RevenueCatService.login(user.uid);
And on logout:
// When user signs out await RevenueCatService.logout();
Step 4.7: Create Subscription Provider
Create lib/ui/providers/subscription_provider.dart:
import 'package:flutter/foundation.dart'; import 'package:purchases_flutter/purchases_flutter.dart'; import 'package:your_app/core/services/revenuecat_service.dart'; class SubscriptionProvider extends ChangeNotifier { bool _isLoading = false; bool get isLoading => _isLoading; bool _isPremium = false; bool get isPremium => _isPremium; String? _errorMessage; String? get errorMessage => _errorMessage; Offerings? _offerings; Offerings? get offerings => _offerings; // Convenience getters for packages Package? get monthlyPackage => _offerings?.current?.availablePackages .firstWhere( (p) => p.packageType == PackageType.monthly, orElse: () => _offerings!.current!.availablePackages.first, ); Package? get annualPackage => _offerings?.current?.availablePackages .where((p) => p.packageType == PackageType.annual) .firstOrNull; Package? get lifetimePackage => _offerings?.current?.availablePackages .where((p) => p.packageType == PackageType.lifetime) .firstOrNull; SubscriptionProvider() { _init(); } Future<void> _init() async { await Future.wait([ checkPremiumStatus(), fetchOfferings(), ]); // Listen for subscription changes RevenueCatService.addCustomerInfoUpdateListener((customerInfo) { _isPremium = customerInfo.entitlements.all['premium']?.isActive ?? false; notifyListeners(); }); } Future<void> checkPremiumStatus() async { _isPremium = await RevenueCatService.isPremium(); notifyListeners(); } Future<void> fetchOfferings() async { _isLoading = true; _errorMessage = null; notifyListeners(); _offerings = await RevenueCatService.getOfferings(); if (_offerings?.current == null) { _errorMessage = 'No subscription plans available'; } _isLoading = false; notifyListeners(); } Future<bool> purchase(Package package) async { _isLoading = true; _errorMessage = null; notifyListeners(); final result = await RevenueCatService.purchasePackage(package); _isLoading = false; if (result.success) { _isPremium = true; notifyListeners(); return true; } if (!result.userCancelled) { _errorMessage = result.error ?? 'Purchase failed'; } notifyListeners(); return false; } Future<bool> restorePurchases() async { _isLoading = true; _errorMessage = null; notifyListeners(); final result = await RevenueCatService.restorePurchases(); _isLoading = false; if (result.success) { _isPremium = true; notifyListeners(); return true; } _errorMessage = result.error ?? 'No purchases to restore'; notifyListeners(); return false; } }
Step 4.8: Update Your Paywall UI
Here's how to use the provider in your existing paywall:
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:your_app/ui/providers/subscription_provider.dart'; class PaywallScreen extends StatelessWidget { Widget build(BuildContext context) { return Consumer<SubscriptionProvider>( builder: (context, provider, child) { if (provider.isLoading) { return Center(child: CircularProgressIndicator()); } if (provider.errorMessage != null) { return Center(child: Text(provider.errorMessage!)); } return Column( children: [ // Your existing UI... // Monthly option if (provider.monthlyPackage != null) SubscriptionOption( title: 'Monthly', price: provider.monthlyPackage!.storeProduct.priceString, onTap: () => provider.purchase(provider.monthlyPackage!), ), // Annual option if (provider.annualPackage != null) SubscriptionOption( title: 'Annual', price: provider.annualPackage!.storeProduct.priceString, onTap: () => provider.purchase(provider.annualPackage!), ), // Lifetime option if (provider.lifetimePackage != null) SubscriptionOption( title: 'Lifetime', price: provider.lifetimePackage!.storeProduct.priceString, onTap: () => provider.purchase(provider.lifetimePackage!), ), // Restore button TextButton( onPressed: provider.restorePurchases, child: Text('Restore Purchases'), ), ], ); }, ); } }
Step 4.9: Check Premium Status Throughout App
Anywhere you need to check subscription status:
// Using provider final isPremium = context.read<SubscriptionProvider>().isPremium; // Or directly final isPremium = await RevenueCatService.isPremium(); // Gate features if (isPremium) { // Show premium feature } else { // Show paywall or upgrade prompt }
Part 5: Testing
Step 5.1: iOS Sandbox Testing
- On your test device, go to Settings → App Store
- Scroll down and tap Sandbox Account
- Sign in with your sandbox tester credentials
- Run your app and test purchases - you won't be charged
Tip: In sandbox, subscriptions renew rapidly:
- 1 week = 3 minutes
- 1 month = 5 minutes
- 1 year = 1 hour
Step 5.2: Android Testing
- In Play Console, go to Setup → License testing
- Add your test email addresses
- Build and deploy to a test track (internal testing)
- Test purchases - license testers won't be charged
Step 5.3: Verify in RevenueCat Dashboard
- Go to Customers in RevenueCat dashboard
- Search for your test user ID
- You should see:
- Transaction history
- Active entitlements
- Subscription status
Step 5.4: Test Edge Cases
Before submitting, test:
- New purchase flow
- Restore purchases (on fresh install)
- Cancel and resubscribe
- Upgrade from monthly to annual
- App behavior when subscription expires
- Offline behavior
Part 6: Webhook Integration (Optional)
If you need to sync subscription status to your backend (e.g., Firestore), set up webhooks.
Step 6.1: Create Webhook Endpoint
Example for Next.js/Vercel:
// app/api/revenuecat/webhook/route.ts import { NextRequest, NextResponse } from 'next/server' export async function POST(request: NextRequest) { // Verify authorization const authHeader = request.headers.get('authorization') if (authHeader !== `Bearer ${process.env.REVENUECAT_WEBHOOK_SECRET}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const event = await request.json() console.log('RevenueCat webhook:', event.type) const userId = event.app_user_id const isActive = ['INITIAL_PURCHASE', 'RENEWAL', 'UNCANCELLATION'].includes( event.type ) // Update your database await updateUserSubscription(userId, { active: isActive, productId: event.product_id, expiresAt: event.expiration_at_ms ? new Date(event.expiration_at_ms) : null, eventType: event.type, }) return NextResponse.json({ received: true }) } async function updateUserSubscription(userId: string, data: any) { // Your database update logic // e.g., Firestore, Supabase, etc. }
Step 6.2: Configure in RevenueCat
- Go to Integrations in RevenueCat dashboard
- Click Webhooks
- Enter your webhook URL
- Add authorization header value
- Select events to receive
- Click Save
Troubleshooting Common Issues
"No products available"
- Verify products are in Ready to Submit status in App Store Connect
- Check product IDs match exactly (case-sensitive)
- Ensure Paid Apps Agreement is signed
- Wait 15-30 minutes after creating products
"Purchase not working in review"
- App Store Review uses sandbox - ensure your app handles this
- With RevenueCat, this is automatic
- Check RevenueCat dashboard for any errors
"Restore not finding purchases"
- User must be signed into same Apple/Google account
- For testing, clear sandbox purchase history:
- iOS: Settings → App Store → Sandbox → Manage → Clear Purchase History
- Check RevenueCat dashboard for customer transaction history
Products showing wrong price
- RevenueCat fetches prices from stores - verify in App Store Connect
- Clear app cache and restart
- Check you're using
storeProduct.priceStringnot hardcoded prices
Checklist Before Submission
- Paid Apps Agreement signed in App Store Connect
- All products in "Ready to Submit" status
- Products configured in RevenueCat with correct IDs
- Entitlement created and products attached
- Offering created with packages
- API keys added to your app
- Tested new purchase in sandbox
- Tested restore purchases
- Tested subscription expiration behavior
- Removed debug logging in production
Conclusion
RevenueCat removes the complexity of in-app purchase implementation while letting you keep full control of your UI. The key benefits:
- No server-side code needed for receipt validation
- Automatic sandbox/production handling - App Store Review just works
- Unified API for iOS and Android
- Great debugging tools in the dashboard
- Free up to $2,500/month in tracked revenue
The setup takes about 1-2 hours versus days for a custom implementation, and you get battle-tested code that's been through thousands of App Store reviews.
Resources
- RevenueCat Documentation
- Flutter SDK Reference
- App Store Connect Help
- Google Play Billing
- Sample Project
Have questions? Found this helpful? Let me know on Twitter.


