Irbisa · Cheatsheet

In-App Purchases in Flutter

in_app_purchase and RevenueCat, product types, the purchase stream, restores, subscription states, trials, server notifications, store rules, testing

Middle Developer · 18 questions

  1. 01

    Your app sells a premium plan, a printed photo book and a booked yoga class. Which must go through App Store and Google Play billing, and which can use Stripe?

    Anything unlocked and used inside the app (the premium plan) must use the store's billing.

  2. 02

    What is the difference between consumable, non-consumable and auto-renewable subscription products, and what does each one oblige the app to do?

    A consumable (coins, a hint pack) is used up and can be bought again, a non-consumable (lifetime premium, ad removal) is bought once and owned for good, and a subscription renews every period until cancelled.

  3. 03

    Walk through a purchase with the in_app_purchase plugin: queryProductDetails, buyNonConsumable, purchaseStream and completePurchase. What does each step do?

    Listen to purchaseStream at startup, load products with queryProductDetails, open the store sheet with buyNonConsumable, handle the result when it arrives on the stream, and call completePurchase once the content is granted.

    final iap = InAppPurchase.instance;// In main(), before runApp: an update with no listener is lostvoid startPurchases() => iap.purchaseStream.listen(_onPurchases); Future<ProductDetails?> loadPremium() async {  if (!await iap.isAvailable()) return null; // no store on this device…
  4. 04

    queryProductDetails returns every id in notFoundIDs on a real device. What do you check on each store?

    The store does not know those products for this app, this build or this account yet.

  5. 05

    Find the bug: a purchase made just before the app was killed never unlocks anything, and Google Play refunds it three days later.

    class _PaywallState extends State<Paywall> {  late final StreamSubscription<List<PurchaseDetails>> _sub;  @override  void initState() {    super.initState();    _sub = InAppPurchase.instance.purchaseStream.listen((purchases) async {      for (final p in purchases.where((p) => p.status == PurchaseStatus.purchased)) {        await api.grant(p.verificationData.serverVerificationData);        await InAppPurchase.instance.completePurchase(p);      }    });  }  @override  void dispose() { _sub.cancel(); super.dispose(); }  @override Widget build(BuildContext context) => const PlanList(); // Buy: buyNonConsumable}

    The only purchase listener lives on the paywall and dies with it, and on Android nothing ever delivers that purchase again.

  6. 06

    What does PurchaseStatus.pending mean (Ask to Buy, a cash payment), and what should the paywall do while a purchase stays pending?

    The user finished the store sheet, but no money has moved yet: a parent must approve an Ask to Buy request, or a cash or bank payment has not cleared.

    enum PaywallStatus { idle, buying, waiting, owned } final paywallStatus = ValueNotifier(PaywallStatus.idle); Future<void> onPurchase(PurchaseDetails p) async {  switch (p.status) {…
  7. 07

    Find the bug: after a flaky connection, a user who bought 500 coins once has 1,000 in their balance.

    // Dio has a retry interceptor: a timed-out request is sent again, up to 3 timesFuture<void> onPurchase(PurchaseDetails p) async {  if (p.status != PurchaseStatus.purchased) return;  final res = await dio.post('/wallet/add', data: {'coins': 500});  wallet.balance = res.data['balance'] as int;  await InAppPurchase.instance.completePurchase(p);}

    The request says "add 500" and nothing about which purchase it pays for, so the server cannot tell a retry from a new purchase.

  8. 08

    How do you implement Restore Purchases on iOS and Android, and why does App Review reject a paid app without a restore option?

    Restore re-reads what the store account already owns and sends it through the same verify-and-grant path as a new purchase.

    Future<void> onRestoreTapped() async {  final iap = InAppPurchase.instance;  if (Platform.isIOS) {    try {      // AppStore.sync(): may ask for the Apple Account password, so only on a tap      await iap.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>().sync();…
  9. 09

    How does the app know at launch that the user is still subscribed, and what should it show offline or when your server is down?

    It asks your server, which has heard every renewal and refund from the stores, and caches the answer with its expiry date.

    class EntitlementRepository {  EntitlementRepository(this._api, this._cache);  final Api _api;  final EntitlementCache _cache;   /// First frame, no network: the server's last answer, until its period ends.…
  10. 10

    in_app_purchase or RevenueCat's purchases_flutter: what does RevenueCat take off your hands, and what does it cost you?

    RevenueCat replaces the server half you would otherwise build: purchase verification with both stores, server notifications, subscription states and one entitlement across platforms.

    Future<void> initPurchases(String userId) async {  final config = PurchasesConfiguration(    Platform.isIOS ? 'appl_xxx' : 'goog_xxx', // public SDK keys, safe to ship  )..appUserID = userId; // your account id: the same customer on every platform  await Purchases.configure(config);  Purchases.addCustomerInfoUpdateListener(_apply); // renewals, refunds, restores…
  11. 11

    This paywall builds its price label from rawPrice. What do users outside the US see, and how should the price be shown?

    // yearly: the ProductDetails for 'pro_yearly' from queryProductDetailsWidget yearlyPriceLabel(ProductDetails yearly) {  final perMonth = yearly.rawPrice / 12;  return Text('Just \$${perMonth.toStringAsFixed(2)} a month');}

    Every user sees a dollar sign in front of their own storefront's number: a German subscriber paying 59,99 € a year reads "Just $5.00 a month", a Japanese one paying ¥7,000 reads "Just $583.33 a month".

  12. 12

    How do you offer a 7-day free trial only to users who are eligible for it, on both the App Store and Google Play?

    Set the trial up in each store and let the store decide who gets it: Google Play returns only the offers this user may buy, and on iOS you ask StoreKit before showing trial copy.

    /// The entry to sell with a trial, or null: then the paywall shows no trial copy.Future<ProductDetails?> trialProduct(List<ProductDetails> details) async {  final play = details.whereType<GooglePlayProductDetails>().toList();  if (play.isNotEmpty) {    // Android: one entry per offer, and Play lists only offers this user may buy    return play.where((d) {…
  13. 13

    A subscriber's card expires before renewal. Which states (grace period, billing retry, account hold, expired) do they pass through, and what does the app show in each?

    The store keeps trying to charge the card for up to 60 days.

  14. 14

    Renewals, cancellations and refunds happen while your app is closed. How does your backend hear about them, and why can't the app be the one that reports them?

    Each store calls your server: Apple sends signed App Store Server Notifications V2 to your URL, and Google publishes Real-time Developer Notifications to a Cloud Pub/Sub topic.

  15. 15

    How do you test purchases without spending money: sandbox accounts, StoreKit configuration files, Play license testers, and what will none of them reproduce?

    On iOS, start with a StoreKit configuration file in Xcode, then sandbox accounts and TestFlight against the real products; on Android, add license testers in Play Console and buy the real products.

  16. 16

    A user subscribes on their iPhone, then signs in on your Android app and website. Where does the subscription live, and how does each platform learn about it?

    The purchase lives in Apple's system, but the entitlement has to live on your server, keyed by your account id.

    // accountId: a UUID your backend gives the user at sign-up and never changesFuture<bool> subscribe(ProductDetails plan, String accountId) =>    InAppPurchase.instance.buyNonConsumable(      purchaseParam: PurchaseParam(        productDetails: plan,        // iOS: becomes StoreKit's appAccountToken (dropped silently unless a UUID);…
  17. 17

    How do you move a subscriber from monthly to yearly on both stores, and what happens to the old subscription and the money already paid?

    Sell it as a plan change, never as a second purchase.

    Future<void> switchToYearly(ProductDetails yearly, PurchaseDetails current) async {  final iap = InAppPurchase.instance;  if (current is GooglePlayPurchaseDetails) {    // Android: name the purchase being replaced, or Play may sell a second one    await iap.buyNonConsumable(      purchaseParam: GooglePlayPurchaseParam(…
  18. 18

    Product wants to test three paywall designs and two prices. What has to exist in the app and on the server, and why can't you change a live product's price per user?

    A store price belongs to a product and a country, never to a user, so every tested price is its own product id.