In-App Purchases
4 min read
Platform Integration
1. App loads products → queryProductDetails([ids])
2. User taps "Buy" → buyNonConsumable / buyConsumable
3. OS shows native payment sheet
4. Result arrives on purchaseStream
├── purchased → VERIFY SERVER-SIDE → deliver content → completePurchase
├── restored → VERIFY → deliver if entitled → completePurchase
├── error → show user error → (no completePurchase)
├── pending → show "processing" UI (e.g., parental approval)
└── canceled → silent
| Product type | Use case | Behaviour |
|---|---|---|
buyConsumable | Coins, gems, lives — bought and used up | Can be bought again |
buyNonConsumable | "Pro" upgrade, ad removal | Once per Apple/Google ID |
buyConsumable (auto-renewing) | Subscriptions | Managed via stores; check entitlement |
Code in action
class PurchaseService {
PurchaseService(this._verify);
final IAPVerifier _verify;
final _iap = InAppPurchase.instance;
StreamSubscription<List<PurchaseDetails>>? _sub;
static const _productIds = {'premium_monthly', 'premium_yearly', 'remove_ads'};
Future<void> init() async {
if (!await _iap.isAvailable()) return;
_sub = _iap.purchaseStream.listen(_onUpdate);
}
Future<List<ProductDetails>> products() async {
final res = await _iap.queryProductDetails(_productIds);
if (res.error != null) throw PurchaseException(res.error!.message);
return res.productDetails;
}
Future<void> buy(ProductDetails p) async {
final param = PurchaseParam(productDetails: p);
await _iap.buyNonConsumable(purchaseParam: param); // or buyConsumable
}
Future<void> restore() => _iap.restorePurchases();
Future<void> _onUpdate(List<PurchaseDetails> purchases) async {
for (final p in purchases) {
switch (p.status) {
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
final ok = await _verify.verify(p); // SERVER-SIDE
if (ok) await _deliver(p.productID);
break;
case PurchaseStatus.pending:
// show "processing" UI; OS will emit again
continue;
case PurchaseStatus.error:
_showError(p.error);
break;
case PurchaseStatus.canceled:
break;
}
if (p.pendingCompletePurchase) {
await _iap.completePurchase(p); // CRITICAL
}
}
}
void dispose() => _sub?.cancel();
}
Receipt verification (server-side, always)
Apple — verify receipts via App Store Server API with your server's shared secret. Google — verify the purchase token via the Play Developer API using a service account.
// Pseudocode — Dart side
class IAPVerifier {
Future<bool> verify(PurchaseDetails p) async {
final res = await api.post('/iap/verify', body: {
'platform': Platform.isIOS ? 'apple' : 'google',
'productId': p.productID,
'purchaseToken': p.verificationData.serverVerificationData,
});
return res['valid'] == true;
}
}
Never deliver content based on client-side validation alone. Both Apple and Google receipts can be forged or replayed.
Strategy table
| Goal | Approach |
|---|---|
| Non-renewable purchase (pro upgrade) | buyNonConsumable + server verify + persistent flag |
| In-game coins | buyConsumable + server credit + completePurchase immediately |
| Subscription | buyNonConsumable + server subscription state + periodic check |
| Restore on new device | restorePurchases() → re-verify each → unlock entitlements |
| Subscription expired | Server is the source of truth; trust no client cache |
| Promo / family sharing | Handle restored purchases as the same as purchased |
Common mistakes to avoid
❌ Not calling completePurchase
Purchase remains pending; OS retries on every app launch; users get charged
multiple times in rare cases. Always call it.
❌ Client-side receipt validation
"If response.purchased == true, unlock" — bypassable with a debugger.
✅ Server-side verification with the store's API.
❌ Forgetting the "Restore Purchases" button
App Store policy requires it for non-consumables. Without it, your app gets rejected.
❌ Trusting StoreKit testing == production
StoreKit sandbox renews subscriptions every few minutes for testing.
Don't assume production timing.
❌ Treating pending purchases as failures
Pending happens (parental approval, slow card). UI must communicate "processing".
❌ Losing the purchase if the app is killed mid-transaction
The stream re-emits unfinished purchases on next launch. Handle them in init().
❌ Showing localized prices manually
ProductDetails.price is pre-localized. Show it as-is.
Interview follow-ups
-
Why is server-side receipt verification non-negotiable? Client-side validation can be bypassed with a debugger, a modified binary, or replay attacks. Servers can validate against Apple/Google's APIs, check for replay, and persist entitlements server-side — the only source of truth that can't be tampered with.
-
What's the difference between
buyConsumableandbuyNonConsumable?buyConsumableis for items the user uses up (coins, lives) — buyable repeatedly.buyNonConsumableis for one-time unlocks (pro, ad removal) — Apple/Google enforce one-per-account. Subscriptions on Apple are technically non-consumable; on Google they have their own flow. -
How do you handle subscription state changes (renewal, cancellation, refund)? Server-side. Apple and Google send server-to-server notifications (App Store Server Notifications, Real-time Developer Notifications) when subscriptions change. Your server updates the user's entitlement; clients re-check on app open or via push.
-
What's the "Restore Purchases" requirement? Apple's App Store Guidelines require apps that sell non-consumables to provide a way for users to restore their purchases on a new device. Call
restorePurchases()from a button; for each restored purchase, verify and unlock again. Without this, expect App Store rejection.
How helpful was this content?
Please sign in to rate this article.