What is In-App Purchase (IAP)?
In-App Purchase is a service provided by Apple that allows you to sell digital content, services, or subscriptions directly within your Flutter app. Instead of asking a user to pull out their credit card and type in details, they simply “double-click to pay” using the payment method already linked to their Apple ID.
Prerequisites:
- Hardware: A Mac with Xcode installed and a physical iOS device (In-app purchases cannot be fully tested on a Simulator).
- Account: An active Apple Developer Program membership and access to App Store Connect.
- Flutter Package: We will be using the official
in_app_purchaseplugin.
Step 1: Xcode Configuration
ios/Runner.xcworkspace):
- Select Runner in the Project Navigator.
- Go to the Signing & Capabilities tab.
- Click the + Capability button and search for In-App Purchase. Add it.
- Note: If you don’t do this, your app won’t be able to communicate with the App Store protocols.
Step 2: Setting up App Store Connect
- Create a Subscription Group: Think of this as the “container” for your levels (e.g., “Premium_Tier”).
- Configure the Group:
- Add a Localization name for the group.
- Grace Period: If you enable this, remember Apple only allows specific durations: 3, 16, or 28 days.
- Create Individual Subscriptions: (e.g., Monthly, Half-Yearly, Yearly).
- Product ID: Create a clear, unique ID (e.g.,
com.yourapp.monthly_premium). - Pricing: Here is a catch, Apple uses Price Tiers. If your specific price (e.g., $9.87) isn’t in their preset list, you’ll have to choose the closest available tier (like $9.99).
- Availability: Choose the countries where your subscription will be live.
- Review Metadata: You must upload a screenshot of your app’s subscription page (the “Paywall”). If this isn’t provided, your status won’t move to “Ready to Submit,” and the products won’t fetch in your code.
- Product ID: Create a clear, unique ID (e.g.,
- Introductory Offers (Free Trials & Discounts):
- Inside the specific subscription (e.g., Monthly), click the “+” next to Subscription Prices and select Set Up Introductory Offer.
- Free Trial: You can offer a period (e.g., 3 days, 1 week, 1 month) where the user isn’t charged.
- Pay As You Go: Users pay a reduced price for a few periods (e.g., $1.99/month for the first 3 months) before the standard price kicks in.
- Pay Up Front: Users pay a one-time discounted price for a specific duration (e.g., $9.99 for the first 6 months).
- Note: Only new subscribers are typically eligible for these, and Apple handles this eligibility check automatically.
Step 3: Local Testing with StoreKit Configuration
- Create the File: In Xcode, Right-click your project root -> New File -> Search for “StoreKit Configuration File.”
- Syncing: It can automatically fetch your products from App Store Connect if you’re logged in.
- The Scheme Catch: * Go to Product > Scheme > Edit Scheme.
- Under Run > Options, select your new file in the “StoreKit Configuration” dropdown.
- CRITICAL: When you are ready to deploy to TestFlight or Production, you must set this back to “None.” If you leave the StoreKit file active in a build, the purchase flow will fail for real users.
Step 4: Live Fetching (The "Paid Agreement" Way)
in_app_purchase package without needing the local StoreKit file for every test. Code Part :
- I have used in_app_purchase package with version :
in_app_purchase:^3.2.3 - I have used RIVERPOD in my Flutter app, so the code can vary based on state management, coding style and other aspects.
- I have broken this down into the Service Layer (Logic), the Riverpod Layer (State Management), and the UI Implementation.
In_app_purchase.dart :
import 'dart:async';
import 'dart:developer';
import 'package:in_app_purchase/in_app_purchase.dart';
class InAppPurchaseService {
InAppPurchaseService._();
static final InAppPurchaseService instance = InAppPurchaseService._();
static final InAppPurchase _iap = InAppPurchase.instance;
StreamSubscription>? _subscription;
// Cache to track processed IDs and avoid redundant backend calls
final Set _handledPurchaseIds = {};
static const Set iapSubscriptionIds = {
'monthly_subscription',
'quarterly_subscription',
'half_year_subscription',
'yearly_subscription',
};
/// Initializes the IAP connection and checks if the store is available.
Future initialize() async {
final available = await _iap.isAvailable();
log('IAP Availability: $available');
return available;
}
/// Queries App Store Connect for product information based on defined IDs.
Future> fetchProducts() async {
final response = await _iap.queryProductDetails(iapSubscriptionIds);
if (response.error != null) {
throw Exception('Product Query Error: ${response.error!.message}');
}
if (response.productDetails.isEmpty) {
log('No products found. Ensure products are "Ready to Submit" in App Store Connect.');
}
return response.productDetails;
}
/// Launches the native iOS payment sheet for a specific product.
Future buySubscription(ProductDetails product) async {
final param = PurchaseParam(productDetails: product);
await _iap.buyNonConsumable(purchaseParam: param);
}
/// Sets up the stream listener to handle purchase updates (purchased, restored, etc).
void listenToPurchases({
required void Function(PurchaseDetails purchase) onVerified,
required void Function(PurchaseDetails purchase) onError,
}) {
_subscription ??= _iap.purchaseStream.listen(
(purchases) async {
for (final purchase in purchases) {
final purchaseId = purchase.purchaseID ?? '';
// Skip if this specific transaction instance was already handled
if (_handledPurchaseIds.contains(purchaseId)) continue;
switch (purchase.status) {
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
_handledPurchaseIds.add(purchaseId);
// Complete the transaction with Apple before notifying the UI
await _complete(purchase);
onVerified(purchase);
break;
case PurchaseStatus.pending:
log('Purchase Status: Pending');
break;
case PurchaseStatus.error:
onError(purchase);
break;
case PurchaseStatus.canceled:
log('Purchase Status: Canceled');
break;
}
}
},
onError: (e) => log('Purchase Stream Error: $e'),
);
}
/// Signals to the App Store that the delivery of the product is successful.
Future _complete(PurchaseDetails purchase) async {
if (purchase.pendingCompletePurchase) {
await _iap.completePurchase(purchase);
}
}
/// Triggers the native Apple restore flow for existing subscriptions.
Future restorePurchases() async {
await _iap.restorePurchases();
}
/// Clears the local cache of handled IDs. Useful for local testing.
void clearHandledPurchases() {
_handledPurchaseIds.clear();
}
/// Cancels the stream subscription when the service is no longer needed.
void dispose() {
_subscription?.cancel();
_subscription = null;
_handledPurchaseIds.clear();
}
}
Riverpod State Management :
// Provider for the IAP Service instance
final inAppPurchaseServiceProvider = Provider((ref) {
return InAppPurchaseService.instance;
});
// FutureProvider to fetch and cache product details
final iapProductsProvider = FutureProvider>((ref) async {
final iap = ref.read(inAppPurchaseServiceProvider);
await iap.initialize();
return iap.fetchProducts();
});
// Listener to handle the verification logic when a purchase event occurs
final iapListenerProvider = Provider((ref) {
final iap = ref.read(inAppPurchaseServiceProvider);
iap.listenToPurchases(
onVerified: (purchase) async {
final purchaseState = ref.read(iapPurchaseContextProvider);
if (purchaseState.context == null || !purchaseState.context!.mounted) return;
try {
// Refresh the iOS receipt data for the latest transaction info
final iosAddition = InAppPurchase.instance
.getPlatformAddition();
await iosAddition.refreshPurchaseVerificationData();
final rawReceipt = purchase.verificationData.serverVerificationData;
// Backend Verification Logic
// 1. Send rawReceipt to your server
// 2. Server validates with Apple's /verifyReceipt or App Store Server API
// 3. Update local user state based on server response
// If success, navigate to success screen
ref.read(iapLoadingProvider.notifier).hideLoading();
purchaseState.context!.pushNamed(RouteNames.paymentSuccessScreen);
} catch (e) {
log('Verification Error: $e');
ref.read(iapLoadingProvider.notifier).hideLoading();
}
},
onError: (purchase) {
ref.read(iapLoadingProvider.notifier).hideLoading();
},
);
});
Riverpod State Management :
/// Handles the iOS specific purchase flow, including loading indicators
Future _handleIOSPurchase(dynamic plan) async {
final loading = ref.read(iapLoadingProvider.notifier);
loading.showLoading('Processing payment...');
try {
final productId = _getProductIdFromDuration(plan.duration);
final products = await ref.read(iapProductsProvider.future);
final product = products.where((p) => p.id == productId).firstOrNull;
if (product == null) {
loading.hideLoading();
AppSnackBar.showToast(message: 'Product not available');
return;
}
// Set context so the listener knows where to navigate after verification
ref.read(iapPurchaseContextProvider.notifier).state = IAPPurchaseState(
context: context,
planName: plan.displayName,
planPrice: plan.priceUSD,
);
// Clear previous transaction IDs to ensure fresh processing
ref.read(inAppPurchaseServiceProvider).clearHandledPurchases();
// Launch the App Store Payment Sheet
await ref.read(inAppPurchaseServiceProvider).buySubscription(product);
} catch (e) {
loading.hideLoading();
log('Purchase Initiation Error: $e');
}
}
Summary of What These Functions Do:
initialize(): Checks if the device allows in-app purchases (Parental controls/Region).
fetchProducts(): Reaches out to Apple to get the latest prices and titles you configured in App Store Connect.
buySubscription(): Opens the “Double-click to Pay” system dialog.
listenToPurchases(): The most important part; it waits for Apple to say “Payment Success” and then triggers your logic to unlock content.
_complete(): Essential for iOS. It tells Apple you’ve acknowledged the purchase so they can close the transaction and pay you.
Backend Integration & Receipt Validation
Once the iOS payment sheet confirms a successful purchase, you receive a receiptData token. This is the Source of Truth for the transaction.
- Transmission: Your Flutter app sends this receiptData (and the environment type: sandbox or production) to your backend.
- Verification: Your backend acts as a proxy, sending this token to Apple’s verification servers.
- Decoding: The receipt contains encoded information (which can be decoded like a JWT), including the purchase date, expiration date, and product ID.
- Database Sync: Your backend saves these details and maps the user’s account to the correct “Subscription Plan” logic.
- Status Delivery: The frontend then fetches the user’s plan status from your own database to unlock premium features.
Testing with Sandbox Accounts
- Go to App Store Connect > Users and Access > Sandbox.
- Create a new user using a “fake” email address (e.g.,
yourapp_test01@gmail.com).- Pro Tip: This email should not be a registered Apple ID. Use a consistent naming convention, as you will likely need multiple accounts to test different scenarios (like switching from Monthly to Yearly).
- On your physical iOS device, go to Settings > App Store > Sandbox Account (or Developer settings) and sign in with these credentials.
Sandbox Subscription Durations
To make testing efficient, Apple “accelerates” time in the Sandbox environment. A subscription that normally lasts a month will expire in minutes, so you can test renewal logic quickly.
1 Week becomes 3 Minutes
1 Month becomes 5 Minutes
2 Months becomes 10 Minutes
3 Months becomes 15 Minutes
6 Months becomes 30 Minutes
1 Year becomes 1 Hour
Note: After a few auto-renewals (usually 5 – 6 times), the subscription will stop renewing automatically to prevent infinite loops during testing.
Advanced Testing Scenarios
- Cancellations: Cancel the subscription and ensure your app/backend reflects the “Expired” or “Cancelled” status after the sandbox time elapses.
- Plan Changes: Upgrade or downgrade between subscription levels in your group and verify the backend updates the permissions correctly.
- Grace Period & Webhooks: If you have Server-to-Server notifications (Webhooks) set up, Apple will notify your backend the moment a renewal fails or a user cancels. This is essential for keeping your UI in sync without requiring the user to open the app.
- Free Trials: Ensure the “Introductory Price” or “Free Trial” logic only triggers for eligible accounts and that the backend correctly identifies the
isTrialflag.
Conclusion
Integrating iOS In-App Purchases in Flutter is a balancing act between precise App Store Connect configuration and robust code. By handling subscriptions through a dedicated service, utilizing StoreKit for local testing, and always validating receipts on the backend, you ensure a secure and professional user experience.
Final Pro-Tips
- Always Verify: Never trust the device alone; use your backend as the final authority for active plans.
- Status Check: Ensure subscriptions are in “Ready to Submit” status, or they won’t show up in your app.
- Mandatory Restore: Always include a “Restore Purchase” button to avoid App Store rejections.
- Tax/Bank Info: Complete your App Store “Paid Applications Agreement” early, as it can take days to process.