Anything unlocked and used inside the app (the premium plan) must use the store's billing. Physical goods and services used outside the app (the photo book, a class at the studio) must not: they go through Stripe, Apple Pay or Google Pay.
Digital means store billing: subscriptions, unlocks, coins and in-app content; Apple's guideline 3.1.1 and Google Play's Payments policy both require it, and a hidden web checkout gets the app rejected or removed
Physical means your own processor: the book is shipped and the class happens in a room; Apple's guideline 3.1.3(e) forbids in-app purchase for them, and flutter_stripe or the pay package takes the payment
The tricky middle: Apple counts a live class streamed to a group as digital, so it needs in-app purchase, but lets a real-time one-to-one session (a trainer on video) use other payment methods
Regional exceptions: US storefront apps may link to a web checkout and the EU allows alternative payments under the DMA, each with its own rules and fees; they add a path, they do not remove store billing
FlutterMiddle
In-App Purchases in Flutter
in_app_purchase and RevenueCat, product types, the purchase stream, restores, subscription states, trials, server notifications, store rules, testing
Questions closed
0 / 18
- 0Review
Questions
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. Each type changes what you must store, restore and track.
Consumable: once consumed, the store forgets it, so your server keeps the balance; Restore never brings coins back. It is bought with buyConsumable
Non-consumable: the store remembers it forever, so the app must offer Restore Purchases; it is bought with buyNonConsumable
Auto-renewable subscription: access depends on time, so you track expiry, renewals, failed payments and refunds, which happen while the app is closed and reach your server as notifications
Non-renewing subscription (App Store, such as a season pass): the store does not track the period, so your server keeps the end date; Play's closest match, a prepaid plan, does track it
Who decides the type: Apple fixes it per product in App Store Connect; on Google Play a one-time product is consumable only because your code consumes it, so coins bought with buyNonConsumable stay owned and cannot be bought again
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.
**purchaseStream first**: every purchase and restore arrives on this broadcast stream (on iOS also transactions left unfinished last time), and an update with no listener is lost, so subscribe in main(), not on the paywall
**queryProductDetails**: takes ids from App Store Connect and Play Console and returns ProductDetails with a store-formatted price, plus notFoundIDs for ids the store does not know
**buyNonConsumable**: its bool only says the purchase flow started; whether the user paid comes later as a PurchaseStatus on the stream
Each status: pending (wait, grant nothing), purchased or restored (verify on your server, then grant), error, canceled
**completePurchase**: finishes the iOS transaction and acknowledges on Android; call it after the grant, never while pending. Play refunds a purchase left unacknowledged for three days
The store does not know those products for this app, this build or this account yet. It is almost always setup rather than code: an unsigned agreement, a product that is not ready, a mismatched bundle id or package name, or an app Play has never seen.
The ids: case-sensitive and per app; a typo, or an id from the other store, lands in notFoundIDs without an error
App Store Connect: the Paid Apps agreement must be active, with banking and tax filled in; each product needs a price and a localization; the bundle id must match an app in App Store Connect and have the In-App Purchase capability
Google Play: an app with the same applicationId must have been uploaded to Play Console at least once (internal testing is enough), the product must be active, and the device's Play account must be a license tester (then even a sideloaded debug build works) or have installed the build from Play; a build with an applicationIdSuffix such as .debug is a different app to Play
**isAvailable() is false: there is no store to ask (a phone without Google services) or purchases are restricted; show an "unavailable" state, not an empty list
StoreKit files**: a StoreKit configuration file loads products on the simulator, but they exist only in Xcode; working there and failing in TestFlight points back at App Store Connect
The only purchase listener lives on the paywall and dies with it, and on Android nothing ever delivers that purchase again. Play never receives an acknowledgement, so after three days it refunds the user and revokes the purchase.
How it gets lost: the user pays, then the process dies before grant and completePurchase finish (swiped away, or killed behind the bank's confirmation app); on the next launch nobody listens until the paywall opens
Android does not resend: the plugin only forwards Play's purchase callback, so an unfinished purchase comes back only when you ask with queryPastPurchases() or restorePurchases(); Play's guidance is to query at launch and on resume
iOS hides the bug: StoreKit replays unfinished transactions when a listener attaches, so on iPhone the purchase unlocks late, when the paywall opens again, and nothing is refunded
Also lost on Android: a pending purchase that clears hours later, because the broadcast stream drops events nobody listens to
The fix: one PurchaseService started in main() that listens for the life of the app and, on Android, picks up unacknowledged purchases at launch; make the server grant idempotent, since a purchase can now arrive twice
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. Show a calm waiting state, grant nothing, and handle the real result whenever it arrives, even days later.
Where it comes from: on iOS, Ask to Buy in Family Sharing or a purchase that needs more action from the buyer; on Google Play, delayed methods such as cash at a convenience store
Grant nothing, complete nothing: there is no paid transaction to finish yet, and on Android pendingCompletePurchase is already true, so check the status, not the flag; Play's three-day acknowledgement window starts only when the purchase becomes PURCHASED
The UI: no error and no endless spinner, just "Waiting for approval", with the paywall released so the user can carry on
The result comes later, often after a relaunch, so the app-level listener (and Android's purchase query at launch) delivers it, not the paywall
A decline can be silent: a declined Ask to Buy sends nothing at all, so let the waiting state expire, at the next launch for example
Testing: Play's "Slow test card, approves after a few minutes" and Ask to Buy in a StoreKit configuration file
The request says "add 500" and nothing about which purchase it pays for, so the server cannot tell a retry from a new purchase. A timed-out request that had in fact succeeded is sent again, or the store redelivers the unfinished purchase, and every copy adds 500.
Two ways to duplicate: the response was lost after the server committed, so the retry interceptor resends; or completePurchase never ran, so the purchase returns on the next launch or restore
The fix, idempotency on the store's id: send the purchase token; the server verifies it with Apple or Google and records the grant under a unique key (transactionId on the App Store, the purchaseToken itself on Play, never orderId, which promo-code purchases lack), so a repeat changes nothing
The server decides the amount from productId: a client that sends coins: 500 can just as well send 50,000
Consume after the grant: with the default autoConsume: true the Android plugin consumes the pack before your handler runs, so a failed grant leaves a paid purchase with nothing behind it; pass autoConsume: false on Android (the iOS plugin asserts true) and consume once the server answers
Show the server's balance: a client-side balance += 500 drifts from the truth on the first retry
Restore re-reads what the store account already owns and sends it through the same verify-and-grant path as a new purchase. Apple requires a way to do it (guideline 3.1.1), because a user on a new iPhone or after a reinstall must not pay twice.
What comes back: non-consumables and active subscriptions arrive on purchaseStream as PurchaseStatus.restored; consumed coins never do, so their balance lives on your server
iOS: a Restore button on the paywall and in settings; call the StoreKit addition's sync() (StoreKit's AppStore.sync()) only from that tap, since it can ask for the Apple Account password
Android: Play has no button rule, but users expect the purchase back after a reinstall, so query owned purchases at launch; a button calling restorePurchases() still helps support
Nothing found is a normal result: the stream then gets an empty list, not a restored event, so do not wait for one; ask your server and say which store account was checked
Keep it even with accounts: the purchase belongs to the Apple or Google account, which may have bought it under another login, so your sign-in does not replace restore
Why reviewers reject: they look for the option on the paywall, and a paid app without it is a frequent 3.1.1 rejection
It asks your server, which has heard every renewal and refund from the stores, and caches the answer with its expiry date. Offline, it keeps showing premium until that cached date passes: the user has paid for that period.
**purchaseStream is not a query: it reports transactions as they happen; a quiet stream at launch means nothing happened, not that the user owns nothing
Do not restore on every launch**: restorePurchases() replays every owned item through your handler and your server; keep it for the Restore button
Cache the answer, not a flag: store active, productId and expiresAt; a bare isPremium = true never expires, so a cancelled subscription stays premium on that phone forever
Refresh at launch, on return from background, after a purchase or restore, and when a push says the entitlement changed; every screen reads one repository
Offline is a trade-off: moving the clock back buys a little extra access, which costs less than locking a paying user out on a plane; the next online check corrects it
With RevenueCat: Purchases.getCustomerInfo() returns a cached copy and refreshes it for you
RevenueCat replaces the server half you would otherwise build: purchase verification with both stores, server notifications, subscription states and one entitlement across platforms. You pay a revenue share above a free tier, and a vendor sits between you and your purchase data.
**With in_app_purchase: a thin layer over StoreKit and Play Billing; verification, renewals, refunds, grace periods and cross-device access are your backend's job
What RevenueCat adds**: CustomerInfo with active entitlements, offerings that change the paywall's products from a dashboard without a release, experiments, webhooks, revenue charts, and Stripe web purchases in the same entitlement
Entitlements over products: the app checks entitlements.active['pro'], so a new price or a yearly product does not touch that check
Cost: free up to $2,500 of monthly tracked revenue, then 1% of it; their uptime and SDK releases become your dependency, and your purchase history lives in their system, so pipe their webhooks into your own database from day one
When the plugin is enough: only one-time unlocks, a team that already runs a billing backend, or purchase data that must stay on your servers
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". The amount actually charged appears nowhere.
**What rawPrice is**: a bare number in currencyCode, the currency of the user's App Store or Play country, not of the phone's locale and not of your price list
**The billed amount is price, already formatted by the store in the right currency and style for that storefront
A derived price needs both fields**: NumberFormat.simpleCurrency with name: currencyCode and the user's locale gives "5,00 €" and "¥583"; label it as approximate and keep it smaller than the billed amount, because App Review (guideline 3.1.2) rejects paywalls where a computed per-month figure outshines what is charged
The Android trap: for a subscription the plugin returns one entry per offer, and price is that offer's first phase; pick the trial's entry and rawPrice is 0, so the label reads "Just $0.00 a month". Take the regular price from the base plan's entry (offerId == null)
No hard-coded prices in copy or remote config: they differ by country and change with taxes and exchange rates
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 paywall promises a trial only when that check says yes.
App Store: the trial is the subscription's introductory offer; an Apple Account gets one per subscription group, so a second product with a trial in that group gives no second trial. StoreKit applies it at purchase, and isIntroductoryOfferEligible in in_app_purchase_storekit tells you in advance
Google Play: the trial is an offer on a base plan with a rule such as "never had any subscription"; queryProductDetails returns one ProductDetails per offer the user qualifies for, and buying that entry sends its offerToken
Find the offer by tag (trial-7d, set in Play Console), not by list order or a zero price
Developer-determined offers: Play returns them to everyone and checks nothing, so use them only when your server decides
Say what happens after: the price after the trial, when it starts and how to cancel belong on the paywall; both stores reject a "Start free" button that hides them
Gaming it: a new Apple or Google account gets a new trial; if that matters, also record trials per account on your server
The store keeps trying to charge the card for up to 60 days. During a grace period the user keeps access and the app asks them to fix the payment; after it, access stops while the retries go on; a successful retry brings access back, otherwise the subscription expires.
App Store: billing retry lasts up to 60 days, and the opt-in Billing Grace Period (3, 16 or 28 days) keeps access during its first days; the server gets DID_FAIL_TO_RENEW (subtype GRACE_PERIOD), GRACE_PERIOD_EXPIRED, then DID_RENEW or EXPIRED
Google Play: a grace period (on by default), then account hold, 60 days in total by default: SUBSCRIPTION_IN_GRACE_PERIOD, SUBSCRIPTION_ON_HOLD, then SUBSCRIPTION_RECOVERED or SUBSCRIPTION_EXPIRED
Grace period: premium stays on, with a banner ("Your payment failed, update it to keep Pro") that opens the store's subscription settings
Retry or hold: premium is off, and a screen explains why and offers to fix the payment instead of selling a new plan; Android's purchase query does not return a held subscription, so only your server knows
Recovered or expired: back to normal, or the regular paywall; do not cut access during a grace period: both stores say the user keeps it until the period ends
Store-drawn prompts: Play's in-app messaging (showInAppMessages in the Android plugin) and StoreKit's billing-issue sheet
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. The app cannot be the reporter: it may not run for weeks, and when it does, it runs code the user controls.
Apple: your HTTPS endpoint receives a signedPayload JWS; verify its certificate chain against Apple's root, then act on notificationType and subtype (DID_RENEW, EXPIRED, REFUND)
Google: the message carries only a purchaseToken and a type such as SUBSCRIPTION_RENEWED, so call purchases.subscriptionsv2.get for the real state; refunds and chargebacks arrive as voided purchase notifications
Why not the app: renewals happen on the store's servers, Apple's support grants refunds, users cancel in system settings, and your Android app and website need the result too
Duplicates: both stores resend a message you did not answer with 2xx (Apple up to five times over six days), so make the handler idempotent (Apple's notificationUUID) and treat each message as "re-read the state", not as a delta
Missed messages: reconcile with Apple's notification history endpoint and a periodic re-check of active subscriptions through each store's API
Then reach the device: update the entitlement, then send a silent push or let the app refresh on resume
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. None of them reproduces real banks, real refunds or production timing.
StoreKit configuration file: products defined in Xcode, no App Store Connect needed; select it in the Runner scheme (Run, Options) and launch from Xcode. It can force errors, Ask to Buy and refunds, but its transactions are signed locally, so your server's check with Apple rejects them
Sandbox: a sandbox Apple Account from App Store Connect buys the real products for free, renewals are accelerated, and notifications go to your sandbox URL
TestFlight: real products, free for testers; a subscription renews once a day, up to six times; the closest to production on iOS
Play license testers: added in Play Console, they pay with test cards ("always approves", "always declines", slow cards for pending); renewals take minutes and notifications still fire. Their builds can be sideloaded, even debug ones, as long as the applicationId matches the app in Play Console
What none of them reproduces: real declines and 3-D Secure, refunds decided by Apple's support, real grace period lengths, and the first review, where a new in-app purchase is submitted with an app version
The purchase lives in Apple's system, but the entitlement has to live on your server, keyed by your account id. The iOS purchase is tied to that account when it is made, and Android and the web ask your server, because Google Play has never heard of it.
Tying the purchase to the account
Pass a UUID account id as applicationUserName: StoreKit 2 stores it as appAccountToken (any other string is dropped silently) and Play as obfuscatedAccountId; Apple's signed transactions and notifications carry it, so the server knows whose renewal it is
The server verifies the purchase, writes an entitlement row (source: app_store, originalTransactionId, expiresAt) and keeps it current from notifications
What each platform shows
Android: Restore finds nothing, which is correct; the app shows premium from your server and, instead of a Buy button, "Managed in the App Store on your iPhone", so nobody pays twice
Web: the same entitlement; a Stripe subscription bought on the web writes the same table, so both apps see it
Cancelling and refunds happen only in the store that sold it, so "Manage subscription" links by source
Edge cases
One Apple Account, two of your accounts: a second login restoring the same purchase needs a rule (move it, or refuse with a message), enforced on the server by originalTransactionId
Bought while signed out: there is no account to attach it to; ask for sign-in before the paywall, or attach it at the first sign-in
Sell it as a plan change, never as a second purchase. On iOS both plans sit in one subscription group and StoreKit swaps them; on Android you pass the current purchase in ChangeSubscriptionParam with a replacement mode, and Play credits or charges the difference.
App Store
One group means one active plan. Level order decides timing: a move to a higher level is an upgrade that starts at once, with a prorated refund of the unused month; the same level with a longer duration is a crossgrade that starts at the next renewal
Rank yearly above monthly if it should start now; your server receives DID_CHANGE_RENEWAL_PREF with subtype UPGRADE, and the same originalTransactionId carries on
Google Play
The replacement mode decides the money. Between two base plans of one subscription Play accepts only chargeFullPrice (charge the year now, carry the unused time over) and withoutProration (switch now, charge the yearly price at the next billing date), and uses the Play Console default when you pass none
Between two subscription products you can also use withTimeProration (the unused month becomes extra time) or deferred (switch at the next renewal); chargeProratedPrice needs a higher price per unit of time, which a discounted yearly plan does not have
The change creates a new purchase token whose linkedPurchaseToken points to the old one: move the entitlement to it and stop honouring the old token, or the user counts as two subscribers; acknowledge the new purchase as usual
In the app
Without changeSubscriptionParam, buyNonConsumable is a fresh purchase: with monthly and yearly as separate Play products, the user pays for both
Before the tap, show when the yearly plan starts and what is charged today
A store price belongs to a product and a country, never to a user, so every tested price is its own product id. The server assigns each user a variant once, the paywall renders the layout and products that variant names, and the result is measured from verified transactions, not from taps.
Why one product per price
App Store Connect and Play Console price a product per storefront for everyone; a change moves every new buyer at once, and raising it for existing subscribers needs notice and often their consent
So create pro_monthly_499 and pro_monthly_699 (one subscription group on iOS, so nobody holds both); both stay live after the test, so every buyer keeps renewing at their price
A per-user price exists only as an offer on top of the list price (an Apple promotional offer, a developer-determined offer on Play), which tests a discount, not a price
In the app
A data-driven paywall: the server or Remote Config returns {variant, layout, productIds}, the app queries those ids, and a new variant ships without a release
Assignment is sticky per account and made before the first paywall view, with a control group; every paywall event carries the variant
On the server
Store the variant with each verified transaction and compare conversion, trial-to-paid, refunds and renewals per variant; a cheaper price that converts better can still earn less by month three
RevenueCat offerings and Experiments, or Firebase A/B Testing on Remote Config, cover most of this
In the EU, a price personalised by automated decision-making must be disclosed (Play's setIsOfferPersonalized)