FLUTTER PAIN ---Flutter in_app_purchase on Android: purchase successful (receipt received) but purchaseStream delivers error — verifyIAP never called
We're using in_app_purchase: ^3.2.3 (in_app_purchase_android: 0.4.0+10) on Flutter. On Android, when a user completes a purchase:
Google Play processes it correctly (test receipt email received, order ID generated, "Test card, always approves" shown in sandbox)
The app briefly shows "Purchase didn't complete. If you cancelled, just try again."
The tier remains unchanged
Our backend Cloud Function (verifyIAP) is never invoked — confirmed via GCP logs
On the second attempt: "You already own this item" — confirming Google Play considers the purchase complete but unacknowledged.
Our purchase stream handler:
void _onPurchaseUpdate(List<PurchaseDetails> purchases) {
for (final purchase in purchases) {
if (purchase.productID != _pendingProductId) {
if (purchase.status != PurchaseStatus.pending) {
_iap.completePurchase(purchase);
}
continue;
}
switch (purchase.status) {
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
_handlePurchased(purchase);
case PurchaseStatus.canceled:
_iap.completePurchase(purchase);
_resolvePending(IAPResult.cancelled);
case PurchaseStatus.error:
_iap.completePurchase(purchase);
_resolvePending(IAPResult.storeError);
case PurchaseStatus.pending:
break;
}
}
}
The error message "Purchase didn't complete. If you cancelled, just try again." maps to IAPResult.storeError.
Question: On Android, can a completed Google Play purchase arrive on the purchaseStream as PurchaseStatus.error rather than PurchaseStatus.purchased? If so, under what conditions? Or is there another mechanism by which the stream event would be dropped before reaching our handler?
What we've already ruled out:
The didChangeAppLifecycleState cancellation path is guarded with if (Platform.isAndroid) return;
The 30-second timeout did not fire (error appeared quickly)
_pendingProductId mismatch is possible but unclear why it would be null at time of stream delivery
iOS works correctly with the same code
Any and all help highly appreciated! Thanks
We solved it finally...
Two mechanisms:
1/ didChangeAppLifecycleState fires on billing overlay dismiss (this is your bug)
The Play billing overlay briefly pauses the app. If your lifecycle observer has iOS-style cancellation logic, it clears _pendingProductId to null before purchaseStream delivers the purchased event. The event arrives, nothing matches, it's discarded. Fix:
void didChangeAppLifecycleState(AppLifecycleState state) {
if (Platform.isAndroid) return;
// iOS-only cancellation logic below...
}
2/ A real purchase can arrive as PurchaseStatus.error
itemAlreadyOwned is delivered as PurchaseStatus.error with an empty productID. If your mismatch guard checks productID == _pendingProductId first, the empty string fails it and the event is silently dropped before reaching your error handler. Check for itemAlreadyOwned + empty productID before the mismatch branch and call restorePurchases() — the unacknowledged token surfaces as PurchaseStatus.restored with the real product ID.
iOS is unaffected by both.
Megaphone