In-App Purchases

Low PriorityAsked in ~35% of senior interviews

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 typeUse caseBehaviour
buyConsumableCoins, gems, lives — bought and used upCan be bought again
buyNonConsumable"Pro" upgrade, ad removalOnce per Apple/Google ID
buyConsumable (auto-renewing)SubscriptionsManaged 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

GoalApproach
Non-renewable purchase (pro upgrade)buyNonConsumable + server verify + persistent flag
In-game coinsbuyConsumable + server credit + completePurchase immediately
SubscriptionbuyNonConsumable + server subscription state + periodic check
Restore on new devicerestorePurchases() → re-verify each → unlock entitlements
Subscription expiredServer is the source of truth; trust no client cache
Promo / family sharingHandle 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

  1. 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.

  2. What's the difference between buyConsumable and buyNonConsumable? buyConsumable is for items the user uses up (coins, lives) — buyable repeatedly. buyNonConsumable is 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.

  3. 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.

  4. 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.