Implementing commerce features in cross-platform applications often exposes frustrating synchronization gaps between mobile operating systems. If you're building a monetization pipeline using Google Play Billing and Apple's StoreKit, managing store states without crashing requires a structured approach. This guide cuts through common configuration traps, explaining how to set up store products, initialize purchase streams correctly, and handle consumables, non-consumables, and subscriptions inside a production-ready application.
The primary challenge when building mobile commerce lies in managing asynchronous transaction states across different environments. Store billing APIs handle user payments asynchronously, meaning your code must actively listen to global transaction updates rather than relying solely on local button callbacks. Missing this architectural requirement leads to lost purchases, rejected builds during app review, and failed receipt validations.
Choosing the Right Package for Your App
Before writing any code, you need to select the correct package for your project scope. Flutter developers typically choose between the official in_app_purchase package maintained by the Flutter team or wrapper libraries like purchases_flutter (RevenueCat).
The official in_app_purchase library gives you direct control over low-level store interactions. It supports consumables, non-consumables, and auto-renewing subscriptions across both platforms. You handle the transaction lifecycle, local persistence, and receipt verification manually. As reported in latest coverage by Reuters, the results are significant.
Alternatively, third-party wrappers offload receipt validation, subscription status tracking, and analytics to a hosted backend. If you want to avoid building a custom server infrastructure to verify Google Play and App Store receipts, a hosted solution saves significant maintenance overhead. For custom billing systems or lightweight apps with simple non-consumable unlocks, the official plugin remains the standard choice.
Step 1: Configuring Store Consoles
Store setup must happen before any client-side code can query products. Both Google Play Console and App Store Connect require explicit configuration steps.
On iOS, you must complete the Paid Applications agreement in App Store Connect, including banking and tax information, before products will resolve. If these financial forms are incomplete, your app will receive an empty product list without throwing an explicit error code. Next, enable the In-App Purchase capability inside Xcode under your project's Signing & Capabilities tab.
On Android, create your items under Monetize, In-app products. Ensure your app has been uploaded to at least an internal testing track. Google Play will not return product details to a querying app if no binary has ever been uploaded to the console. Product IDs should be identical across both stores where possible to simplify your Dart models. Use clean identifiers like pro_monthly or remove_ads, avoiding pricing information in the ID string.
Step 2: Initializing the Purchase Stream
The most critical mistake developers make is querying products or triggering purchases before listening to the global purchase stream. Transactions can update asynchronously when users restore purchases, complete delayed payments, or finish transactions initiated outside your immediate UI flow.
Initialize your stream listener globally as soon as your app boots up, typically right after WidgetsFlutterBinding.ensureInitialized() in a singleton service.
final InAppPurchase _iap = InAppPurchase.instance;
late final StreamSubscription<List<PurchaseDetails>> _subscription;
Future<void> initializeStore() async {
final bool available = await _iap.isAvailable();
if (!available) return;
_subscription = _iap.purchaseStream.listen(
_onPurchaseUpdated,
onDone: () => _subscription.cancel(),
onError: (Object error) {
// Handle store connection errors here
},
);
await loadProductDetails();
}
Failing to attach this listener early means that if a user restarts the app while a purchase is processing, the completion event is dropped, resulting in lost user entitlements.
Step 3: Querying and Displaying Products
Once your stream is active, fetch product details from the stores using your defined identifiers. Always rely on the localized price string provided by the store rather than formatting raw numbers manually, as this ensures correct currency symbols and regional formatting are displayed.
Future<void> loadProductDetails() async {
const Set<String> _kProductIds = <String>{'pro_monthly', 'remove_ads'};
final ProductDetailsResponse response = await _iap.queryProductDetails(_kProductIds);
if (response.notFoundIDs.isNotEmpty) {
// Handle missing IDs (often caused by unapproved products or unsubmitted builds)
}
List<ProductDetails> products = response.productDetails;
}
If your notFoundIDs list returns items, verify that your package name matches the console configuration and that your build has been uploaded to an active testing track.
Step 4: Executing Purchases and Handling Consumables
When a user taps a buy button, invoke the appropriate purchase method based on product type. Remember that initiating a purchase only launches the native payment sheet; it does not confirm that the transaction succeeded. The actual outcome arrives later through your purchaseStream listener.
void buyProduct(ProductDetails productDetails, bool isConsumable) {
final PurchaseParam purchaseParam = PurchaseParam(productDetails: productDetails);
if (isConsumable) {
_iap.buyConsumable(purchaseParam: purchaseParam, autoConsume: true);
} else {
_iap.buyNonConsumable(purchaseParam: purchaseParam);
}
}
Inside your _onPurchaseUpdated stream handler, you must explicitly complete pending transactions. If a purchase has a status of PurchaseStatus.purchased or PurchaseStatus.restored, deliver the content to your user, and then call completePurchase:
Future<void> _onPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) async {
for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {
if (purchaseDetails.status == PurchaseStatus.pending) {
// Show pending UI
} else {
if (purchaseDetails.status == PurchaseStatus.error) {
// Handle error
} else if (purchaseDetails.status == PurchaseStatus.purchased ||
purchaseDetails.status == PurchaseStatus.restored) {
// Deliver product entitlement
bool valid = await _verifyPurchaseOnBackend(purchaseDetails);
if (valid) {
// Grant access
}
}
if (purchaseDetails.pendingCompletePurchase) {
await _iap.completePurchase(purchaseDetails);
}
}
}
}
Forgetting to call completePurchase on Android leaves the transaction open, causing Google Play to flag the order and refund the user automatically within a few days.
Common Implementation Mistakes to Avoid
Building robust checkout flows requires anticipating edge cases that break standard test environments. Keep these practices in mind to protect user transactions:
- Hardcoding product identifiers: Always fetch product details dynamically from the store console at runtime rather than hardcoding prices or metadata inside your local application state.
- Ignoring receipt verification: Client-side purchase validation can be bypassed on rooted devices. For high-value digital goods, validate transaction receipts via your backend server interacting with the Apple App Store API or Google Play Developer API.
- Testing with production accounts: Always configure sandbox testers on iOS and License Testers on Android to prevent accidental charges during development.
Frequently Asked Questions
Why is my app returning empty product details?
An empty product list usually means your app bundle ID doesn't match the store console, your products are not marked as active, or you haven't uploaded an initial binary to an internal testing track on Google Play. On iOS, missing banking details in the Paid Applications agreement will also cause queries to fail silently.
How do I handle subscription restorations?
Users reinstalling your app expect their previous purchases to persist. Call _iap.restorePurchases() inside your settings screen or app initialization sequence. This pushes past transactions back through your global purchaseStream, allowing you to re-grant active entitlements.
Do I need a backend server to process payments?
For non-consumable unlocks and simple features, local verification can suffice. However, for auto-renewing subscriptions and cross-platform entitlements, a backend server is strongly recommended to securely validate cryptographic purchase tokens with Google and Apple.
Can I test in-app purchases on a physical device without publishing?
Yes. On Android, you must upload an APK or App Bundle to an internal testing track and sign the build with your release keystore. On iOS, you log out of your personal Apple ID in device settings and sign into a designated Sandbox Tester account when prompted during the purchase sheet flow.