Only the monthly plan. Digital content used inside the app has to go through StoreKit or Play Billing; physical goods and real-world services are forbidden from using it.
Store billing: subscriptions, permanent unlocks, in-game currency, removing ads: anything that only exists inside your app. The fee is 15–30%: Play takes 15% on subscriptions and on a developer's first $1M a year, Apple 15% for small businesses and for subscriptions after their first year
Your own processor: physical goods, shipping, tickets, rides, food delivery and services a human performs; both stores reject apps that push those through in-app billing
Also outside the store: donations to a registered nonprofit
The exceptions move: US court rulings and the EU's Digital Markets Act allow an external checkout link or alternative billing in some storefronts, so keep that mapping in server config rather than in a developer's memory
AndroidMiddle
In-App Purchases & Subscriptions
StoreKit 2, Play Billing, entitlements, server validation, restores, offers, refunds, testing
Questions closed
0 / 23
- 0Review
Questions
Connect one BillingClient, fetch ProductDetails with queryProductDetailsAsync, open the sheet with launchBillingFlow, receive the result in PurchasesUpdatedListener, then verify the token on your server and acknowledge or consume it.
Connect: startConnection is asynchronous: call nothing until onBillingSetupFinished reports OK. Billing Library 8 adds enableAutoServiceReconnection() so a dropped service reconnects on the next call
Query: the price comes from ProductDetails (oneTimePurchaseOfferDetails.formattedPrice), never from your own strings; the product must be active in Play Console
Launch: launchBillingFlow only opens the sheet; its result says whether the sheet appeared, not whether the user paid
Result: the purchase arrives in onPurchasesUpdated, possibly as PENDING; grant only PURCHASED, after your server has verified the token
Settle: acknowledge or consume within three days, and sweep with queryPurchasesAsync on every resume for purchases the listener missed
Library version: use the current major (8 or newer); Play rejects updates built on old majors, and SkuDetails-era samples no longer compile
A subscription is the product users own, a base plan sets its billing period, price and renewal type, and an offer is a trial or discount attached to one base plan. Each base plan and offer the user can buy reaches the app as a SubscriptionOfferDetails with its own offerToken.
Subscription: one product id, say premium, that holds the benefits
Base plan: monthly or yearly, auto-renewing or prepaid; moving between base plans is an upgrade or a downgrade
Offer: a free trial, an intro price or a developer-determined discount, built from pricingPhases (say 7 days free, then 3 months at half price, then the full price); Play checks the offer's eligibility rule, such as "never had this subscription", before it lists the offer
**offerToken**: what launchBillingFlow actually charges; the base plan's own entry has offerId == null
Consume the coins only after your server credits them, ask the store for the unlock instead of storing it, and read the subscription's state from your backend.
Consumable, the coins: call consumeAsync (Play) or finish() (StoreKit) only after the balance is credited; consume first and crash and the coins are gone, never consume and the user cannot buy that pack again
The balance lives on your server: a local counter is unrecoverable on reinstall
Non-consumable, the unlock: the store owns that fact permanently, through Transaction.currentEntitlements on iOS and queryPurchasesAsync on Play, so the client asks instead of remembering; on Play, acknowledge it within three days or it is refunded
Auto-renewing, the plan: renewals, cancellations and refunds happen on the store's servers while your app is closed, so a cached expiry date is already suspect
Rule of thumb: the client persists nothing the store or your server can be asked for
A Purchase is PURCHASED, PENDING or UNSPECIFIED_STATE; acknowledged is a separate flag, isAcknowledged. Play automatically refunds and revokes a purchase that is not acknowledged or consumed within three days of becoming PURCHASED.
**PENDING: the user chose cash or a slow payment method; no money has moved, so grant nothing and acknowledge nothing; the three-day clock has not started
PURCHASED: paid: verify the token on your server, write the entitlement, then acknowledge
How to acknowledge**: consumeAsync for a consumable (it acknowledges too), acknowledgePurchase for a non-consumable and for a new or replaced subscription; plain renewals need nothing. The server can do it instead with purchases.products.acknowledge or purchases.subscriptions.acknowledge
Why the rule exists: acknowledgement is your statement that the content was delivered; without it a crash between charge and grant would leave a user charged for nothing
Common mistake: acknowledging in the client callback before the server grant lands: a dropped request then leaves an acknowledged purchase and no premium
Apple is different: an unfinished transaction is never refunded; StoreKit redelivers it on every launch until you call finish()
startConnection is asynchronous, so the query runs before the service is connected and fails with SERVICE_DISCONNECTED. On top of that, the client lives in one screen with an empty listener, so purchase results have nowhere to land.
Wait for setup: query only after onBillingSetupFinished reports OK, or wrap the connection in a suspend function the repository awaits
Expect disconnects: the Play service can drop at any time; Billing Library 8 has enableAutoServiceReconnection(), older versions reconnect with backoff from onBillingServiceDisconnected
One client per app: create it in an app-scoped repository, such as a Hilt @Singleton, not in each ViewModel: a purchase can complete after the paywall is gone
The empty listener: { _, _ -> } drops every purchase result; that listener is where purchases arrive
End what you scope: a client tied to something shorter than the app needs endConnection() when it dies, or it leaks the service binding
OK from launchBillingFlow only means the payment sheet opened, so anyone who opens it and cancels gets premium. It also misses every purchase that does not come back through this button: pending payments, a process killed mid-purchase, and purchases started outside the app.
Wrong signal: the outcome arrives later in PurchasesUpdatedListener, and only a server-verified PURCHASED purchase is a sale
Pending: cash and slow payment methods return PENDING and complete hours later, when this screen is long gone
A killed process: the app died between the sheet and the callback; the purchase exists, the grant never ran
Outside the button: promo codes redeemed in the Play Store, and on iOS promoted purchases and Ask to Buy approvals
The real entry point: an app-scoped listener plus a queryPurchasesAsync sweep on every resume; on iOS, Transaction.updates started at launch. The paywall starts a purchase, the listener completes it
No verification: a local flag with no server check is the is_premium problem again: lost on reinstall and never revoked on refund
A boolean cannot express the four things an entitlement really is (whose it is, until when, from which platform and in what state), so it fails paying customers long before anyone tries to cheat it.
Reinstall or a new phone: the flag is gone, and a paid-up subscriber is looking at your paywall
Cancelled, expired or refunded: the store never calls your app, so the flag stays true and a lapsed user keeps premium for free
Grace period: the truth is "paid, but the card failed"; a boolean cannot ask them to fix it, so the subscription dies silently
What to read instead: one entitlement record from your backend with productId, status, expiresAt, willRenew and source, refreshed on launch, on foreground and after any purchase
Offline policy: honour a cached active entitlement for a bounded window, and never grant a new one while offline
The server exchanges the identifier for the store's current view of that purchase. A signature only proves the payload came from the store when it was issued, not that it is still valid or that it belongs to this user.
Apple: call the App Store Server API for that originalTransactionId with an ES256 JWT, and verify the signed transactions (JWS) in the response against Apple's root certificates
Google: call purchases.subscriptionsv2.get with a service account and read subscriptionState and the expiry time
The Play trap: on an upgrade or resubscribe Play issues a new token with linkedPurchaseToken pointing at the old one; ignore it and one paying user becomes two subscriptions
A signature is a snapshot: it cannot know about a refund or chargeback after it was signed, and a check on the device runs on hardware the user controls, so it can be patched out
Replay: one leaked transaction posted from a hundred devices is a hundred grants, unless the store id has a unique constraint
Both stores push lifecycle events to an endpoint you register: App Store Server Notifications V2, and Play's Real-time Developer Notifications over Pub/Sub. Treat each one as a trigger to re-fetch the state, not as the state.
Apple: one HTTPS URL per environment; the signed JWS body carries DID_RENEW, DID_FAIL_TO_RENEW, EXPIRED or REFUND. Return 200 fast and work afterwards
Google: RTDN publishes a purchase token and a type, deliberately not the state; delivery is at-least-once and unordered, so duplicates are normal traffic
Dedupe on the store's id: notificationUUID or messageId in a processed table with a unique constraint, committed with the entitlement in one transaction
Never apply a delta: write the absolute state you just fetched, guarded by a monotonic field so a late event cannot overwrite a newer one
Reconcile: sweep subscriptions expiring soon: a dropped notification is either free premium or a locked-out customer
Check which Google account the Play Store is signed into and what type of product it was: restore reads what that store account owns, not what your app account paid for. Android has no restore API, only queryPurchasesAsync plus a re-sync with your backend.
What restore is on Play: queryPurchasesAsync for INAPP and SUBS returns the active purchases of the account signed into the Play Store; send the tokens to your server, which verifies them and returns the entitlement
A different Google account: a phone with several accounts buys with whichever one Play has selected; another account restores nothing, and no code fixes that
Another of your accounts: the server may reject a token already bound to another of your accounts; say so instead of "nothing to restore"
Consumables never come back: consumed coins vanish from the store's view, which is why the balance lives on your server
Nor does the other platform: an iOS purchase never appears on Android; that needs your own account link
Not the history call: queryPurchaseHistoryAsync listed expired and consumed purchases too, was never proof of ownership, and is gone in Billing Library 8
iOS: Transaction.currentEntitlements already lists the unlock; a Restore button calls AppStore.sync(), which can ask for a password, so never call it on launch
Always show an outcome: including "nothing to restore on this account"; a silent no-op is the main source of restore tickets
Through a voidedPurchaseNotification in RTDN and, as a safety net, the Voided Purchases API (purchases.voidedpurchases.list). Play never touches your database, so the backend revokes the entitlement or claws back the consumable credit itself.
What counts as voided: refunds issued by Play or from your console, chargebacks from the bank, and cancelled orders; voidedSource and voidedReason say which
Subscriptions: a refund with revocation ends access in Play and sends SUBSCRIPTION_REVOKED, so re-fetching subscriptionsv2.get gives the expired state; a refund without revocation leaves access in place, and that was support's decision
Consumables: the coins were already credited and maybe spent, so your server decides: subtract the balance, let it go negative, or flag the account
Poll as well: notifications can be dropped; read voidedpurchases.list daily, which covers the last 30 days, and reconcile by purchaseToken and orderId
Refund abuse: buy, spend, refund, repeat; count voided purchases per account and stop granting on credit to repeat offenders
Apple: a REFUND server notification and a revocationDate on the transaction; StoreKit also drops it from currentEntitlements on the device
The first of your accounts that claims the store purchase owns it: the store account owns the payment, your account owns the entitlement, and a unique constraint on the purchase token on your server enforces the link.
Bind at purchase: .appAccountToken(uuid) on StoreKit, setObfuscatedAccountId() on Play. Apple returns appAccountToken on the transaction and in notifications; Play returns the obfuscated id in Purchase.accountIdentifiers and in the subscriptionsv2.get response, but not in RTDN
They are hints, not locks: they exist only if you set them, so older purchases and redeemed codes have none, and a client can send any user id
Enforce on the server: a unique constraint on originalTransactionId or the purchase token, pointing at exactly one user id; an insert-only claim returns the existing owner
A second claim gets a conflict: show "already active on another account" with a way back into it, and make transfers a logged support action
Family Sharing: a family member's transaction has ownershipType == .familyShared; a rule written for the buyer alone rejects legitimate users
first() takes whatever entry Play listed first, which can be the base plan or an offer on another base plan, and a user who is not eligible for the trial has no trial entry at all. Play charges exactly the offerToken you pass, so the copy promised a trial the token never contained.
Eligibility belongs to the store: Play filters subscriptionOfferDetails by each offer's rule (new customers only, upgrade, developer-determined), so a lapsed user simply gets no trial entry
Pick the offer on purpose: match basePlanId and an offer tag you set in Play Console, or a first pricing phase with priceAmountMicros == 0, and fall back to the base plan (offerId == null) deliberately
Render from what you picked: the trial length and prices come from that offer's pricingPhases, so the copy cannot promise what the token does not hold
Apple: one introductory offer per subscription group per Apple Account; check isEligibleForIntroOffer before showing it. Promotional offers need a signature from your server, never a key inside the app
Your own limits: eligibility follows the store account, so a "one trial per user" rule of yours lives on your server, keyed by your user id
Buy the yearly base plan with a replacement mode: inside one subscription only CHARGE_FULL_PRICE (charge the yearly price now) or WITHOUT_PRORATION (switch now, pay the new price at renewal) are allowed. Play issues a new purchase whose linkedPurchaseToken points at the old one.
Mode matters: a plain purchase within one subscription uses the default replacement mode from Play Console; to override it, or to switch between different subscription products, pass the mode: WITH_TIME_PRORATION (the default), CHARGE_PRORATED_PRICE (only if the price per unit of time rises), CHARGE_FULL_PRICE, WITHOUT_PRORATION, DEFERRED (switch at renewal, the usual downgrade)
Server side: follow linkedPurchaseToken, retire the old row, move the entitlement to the new token and acknowledge the new purchase (the three-day rule applies again); skip the retire step and the old token keeps granting until it expires
Library version: since Billing Library 8.1 the mode goes into SubscriptionProductReplacementParams on the product; SubscriptionUpdateParams.setSubscriptionReplacementMode is deprecated
Apple: products in one subscription group switch without extra parameters: an upgrade applies at once with a prorated refund, a downgrade at the next renewal
Between renewed and gone, both stores run a recovery window (billing retry, an optional grace period, and on Play an account hold), and access continues only during grace.
Apple states: .subscribed, .inGracePeriod, .inBillingRetryPeriod, .expired, .revoked; grace is opt-in in App Store Connect, and without it the user loses access at the first failed renewal while Apple keeps retrying in billing retry
Play states: ACTIVE, IN_GRACE_PERIOD, ON_HOLD, PAUSED, CANCELED, EXPIRED; grace keeps access for up to 30 days, then account hold revokes it while Play keeps retrying
In grace: keep access and show a dismissible banner that deep-links to the fix, such as AppStore.showManageSubscriptions(in:) or BillingClient.showInAppMessages
In billing retry, on hold, expired: no access; show a fix-payment prompt (retry, hold) or a resubscribe paywall (expired), and keep their data
Common mistake: treating CANCELED as EXPIRED: a cancelled subscriber keeps full access until the expiry date. Model this as a state machine, never a boolean
Start from the order id on the receipt: both stores let your server turn it into the real purchase, so the fix is a server-side lookup and a grant, never "please buy it again".
Google: the receipt shows a GPA.… order id; orders.get in the Play Developer API returns that order with its purchaseToken, then subscriptionsv2.get or products.get returns the state
Apple: the emailed receipt carries an order ID; GET /inApps/v1/lookup/{orderId} on the App Store Server API returns the signed transactions behind it
Check the store account first: the most common "I paid and got nothing" is a purchase under a different Google or Apple account, and no grant of yours fixes that
Why it stuck: the claim never reached your backend, it threw on an unknown product id after a rename, or the token is already bound to another of their accounts
Dead-letter every failed claim: keyed by the store identifier, retried on a schedule, with an alarm on the queue's age, plus a daily sweep for charges that have no entitlement row
Goodwill grants get their own table: edit the store-derived row instead and the next renewal notification silently overwrites the fix
price is a Double, and 19.99 * 100 evaluates to 1998.9999999999998; toLong() drops the fraction instead of rounding, so the gateway charges 1998 cents, one cent less than the screen showed.
Why: binary floating point cannot store 19.99 exactly, the multiplication keeps the error, and toLong() truncates toward zero; 0.29 becomes 28 cents and 4.35 becomes 434
The fix: keep money as a Long in minor units from the API response to the payment request, so sums and multiplications are exact; for tax, discounts or currency conversion use BigDecimal with an explicit RoundingMode, never Double
Minor units depend on the currency: JPY has 0 decimals, KWD has 3; take them from Currency.getInstance(code).defaultFractionDigits instead of a hard-coded 100
Better still: let the server compute the amount from its own catalogue and send it only SKUs and quantities, so a stale or tampered client price is never charged. Play's own APIs also model money as integers: priceAmountMicros is a Long
Catch it: unit tests with prices like 19.99, 0.29 and 4.35, and a review rule (or a custom detekt check) that bans Double and Float in money types
You edit the base plan price; new buyers pay it at once, existing subscribers stay in a legacy cohort until migrated. A 30% rise is opt-in by default: each subscriber must accept before renewal or Play cancels them. Opt-out exists only where Play allows it.
Legacy cohorts: existing subscribers keep the old price until you end that cohort, and you may leave them there indefinitely
Opt-in: the user must accept in Play before the first charge at the new price; whoever ignores it is cancelled at that renewal, which shows up as churn
Opt-out: only in eligible countries and within Play's size and frequency limits; users get 30 or 60 days of notice, and an in-app notice from you is required; they renew at the new price unless they cancel
What your server sees: autoRenewingPlan.priceChangeDetails on the line item from subscriptionsv2.get, plus price-change RTDN. On the client, Billing Library 9 added in-app messaging for opt-in increases; launchPriceChangeConfirmationFlow was removed in Billing Library 6
Never render your own price: formattedPrice is localized for the storefront and follows the change, while a hard-coded string disagrees with the charge
Apple: a rise within Apple's yearly limits renews after notice; a larger one needs consent, shown through a StoreKit Message with reason .priceIncreaseConsent
In your own database, keyed by your own user id. A store transaction is only evidence; the entitlement row it produces is the single thing every client reads.
Store credentials in their own table: a unique key per originalTransactionId, purchase token or Stripe subscription id, each pointing at a user; a user can accumulate several, and the entitlement is their union
Three feeds, one row: App Store notifications, Play RTDN and Stripe webhooks all converge there, and every handler is idempotent because all three retry
**Clients read GET /entitlements**: currentEntitlements and queryPurchasesAsync are the offline fallback and the reconciliation input, never the answer
Double billing: resolve entitlement before rendering a paywall; when it is active elsewhere show a management hint, because you cannot cancel an Apple subscription from Android
Trust the store's timestamps: never the device clock, and cache the verdict with a short TTL so a flight does not lock a paying user out
Every retry sends a fresh UUID as the idempotency key, so the gateway sees each attempt as a new payment. When the first call charged the card and only the response was lost, the retry charges it a second time.
A timeout is not a failure: an IOException after the request left the phone means the outcome is unknown: the gateway may have charged and the response died in a tunnel, a read timeout or a backgrounded app
How the key works: the gateway (Stripe's Idempotency-Key header, for example) stores the result of the first request under the key and returns that same result for every repeat; a new key per attempt switches the protection off
The fix: create the key once per payment, save it with the order before the first call (order.paymentKey), and reuse it on every retry, after a process restart and on a second tap of "Pay"; the server also refuses a second charge for the same order id
When retries run out: the result is unknown, not failed: show "checking your payment" and ask your server for the order status instead of offering "Pay again"
Catch it: a test with a fake gateway that charges and then throws on the first response, asserting exactly one charge
With license testers in Play Console, a build from a testing track and Play's test payment methods; test subscriptions renew on an accelerated clock, so a month takes 5 minutes. They miss real bank retry timing, tax and real-money edge cases.
License testers: accounts listed in Play Console pay with test instruments instead of real cards; the app needs a build uploaded to an internal, closed or open track with the same package name and signing. A license-tester purchase that is not acknowledged within 3 minutes is refunded automatically
Test instruments: always approves, always declines, slow cards that approve or decline after a few minutes, and a card that approves and then charges back; the slow ones are how you reach PENDING, a decline at renewal is how you reach grace and account hold, the last one drives your revocation path
Accelerated renewals: a monthly test subscription renews every 5 minutes and a yearly one every 30, up to six times, then it cancels
Play Billing Lab: Google's tester app: switch the Play country, repeat a free trial, force a subscription into grace or hold, and test a price change for a tester account
Backend and units: send a test notification from Play Console to check the Pub/Sub wiring, and keep BillingClient behind your own interface so the entitlement state machine is unit-testable
What none of it reproduces: real bank retry timing, real tax and real-money edge cases (fraud holds, multi-account households, regional payment methods). Keep test orders out of revenue by reading the API's test marker (testPurchase on subscriptionsv2, purchaseType on one-time products), not a build flag
iOS: a StoreKit configuration file and SKTestSession locally, then sandbox accounts with their own accelerated renewals
You cannot remote-configure a price (prices live in the stores), so a price test is a server-assigned choice between product ids that already exist, plus a purchase record that remembers which arm produced it.
Assign on the server: sticky per user, returning the product ids and offer tokens to fetch plus the variant id; bucketing on the client makes the arm unverifiable at readout
Render the store's price: displayPrice or pricingPhases.formattedPrice; a price string from your own config is a lie in half the storefronts
Ship a default config in the binary: a paywall that cannot render without a network earns nothing on a cold start
Every arm's products must be live: approved in both stores before the flag flips, and product ids are permanent: you delete an experiment, not its products
Attribution: record the variant next to the store identifier, and attribute every renewal to the arm that produced the original transaction; revenue events come from server notifications, not the client
The readout: cohort by subscription start, report proceeds net of the store's cut, and wait a full billing cycle before believing it