Irbisa · Cheatsheet
In-App Purchases & Subscriptions
StoreKit 2, Play Billing, entitlements, server validation, restores, offers, refunds, testing
Your app sells a monthly plan, a physical hoodie and a bookable haircut: which of those has to go through the store's own billing?
Walk through a one-time purchase with Play Billing Library: which calls happen, and in what order?
val billing = BillingClient.newBuilder(context) .setListener { result, purchases -> // every purchase result lands here if (result.responseCode == BillingResponseCode.OK) purchases?.forEach(::reportToServer) } .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) .enableAutoServiceReconnection() // Billing Library 8+…How is a Google Play subscription structured (subscription, base plan, offer), and what is an
offerToken?A user buys 500 coins, a lifetime ad-free unlock and a monthly plan: what does each of those three oblige the client to do differently?
// Play has two product types (INAPP, SUBS); "consumable" is your decision, not the store'sprivate val consumableIds = setOf("coins_500", "coins_2000") suspend fun settle(purchase: Purchase) { // PENDING (cash or slow payment method) is not money yet: never grant, never settle if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) return…What states can a Play Billing purchase be in, and why must you acknowledge it within three days?
override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) { if (result.responseCode != BillingResponseCode.OK) return purchases?.forEach { p -> when (p.purchaseState) { Purchase.PurchaseState.PURCHASED -> scope.launch { settle(p) } Purchase.PurchaseState.PENDING -> showAwaitingPayment() // no grant, no acknowledge…The paywall shows prices only some of the time, and purchases go missing. What is wrong with this code?
class PaywallViewModel(app: Application) : AndroidViewModel(app) { val prices = MutableStateFlow<List<ProductDetails>>(emptyList()) private val billing = BillingClient.newBuilder(app) .setListener { _, _ -> } .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) .build() init { billing.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(result: BillingResult) {} override fun onBillingServiceDisconnected() {} }) billing.queryProductDetailsAsync(productQuery()) { _, result -> prices.value = result.productDetailsList } }}This button unlocks premium when
launchBillingFlowreturnsOK. What does it get wrong, and which real purchases does it miss?fun onBuyClicked(activity: Activity, lifetime: ProductDetails) { val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf( BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(lifetime) .build())) .build() val result = billingClient.launchBillingFlow(activity, params) if (result.responseCode == BillingResponseCode.OK) { prefs.edit { putBoolean("is_premium", true) } }}The app decides what to unlock from
is_premiuminSharedPreferences; set piracy aside: what breaks for honest paying users?// What the client is allowed to knowdata class Entitlement( val productId: String?, val status: Status, // ACTIVE, GRACE, ON_HOLD, EXPIRED, NONE val expiresAt: Instant?, val willRenew: Boolean,…The client hands your backend a purchase identifier: what does the server do with it, and why isn't verifying the signature on device enough?
// Backend. The client is a courier: it carries an identifier and asserts nothing.suspend fun claimPlay(userId: UserId, productId: String, token: String): Entitlement { val sub = withContext(Dispatchers.IO) { // the Google client blocks play.purchases().subscriptionsv2().get(packageName, token).execute() } val entitlement = entitlements.upsert(…Renewals, refunds and cancellations all happen while your app is closed: how does the backend hear about them, and how does the handler survive duplicates?
// Play RTDN. The message is a doorbell: it says which token changed, nothing more.suspend fun onRtdn(message: PubsubMessage) { if (processed.contains(message.messageId)) return ack(message) // at-least-once delivery val n = Json.decodeFromString<DeveloperNotification>(message.data.toStringUtf8()) val voided = n.voidedPurchaseNotification…A user reinstalls, taps Restore, and the lifetime unlock does not come back, though Play says they own it. Where do you look?
suspend fun restore(): RestoreOutcome { val tokens = buildList { for (type in listOf(ProductType.INAPP, ProductType.SUBS)) { val result = billing.queryPurchasesAsync( QueryPurchasesParams.newBuilder().setProductType(type).build() )…A user gets a refund through Google Play. How does your backend find out, and what do you take back?
A user paid through the store, then signs into a different account in your app and expects the subscription to follow. Who gets the entitlement, and how is it enforced?
fun buy(activity: Activity, details: ProductDetails, offerToken: String, userId: String) { val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf( BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(details) .setOfferToken(offerToken)…The paywall copy says "7 days free", yet some users are charged on day one. What is wrong with this code?
fun buyMonthly(activity: Activity, details: ProductDetails) { val offer = details.subscriptionOfferDetails!!.first() val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf( BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(details) .setOfferToken(offer.offerToken) .build())) .build() billing.launchBillingFlow(activity, params)}How do you move a Play subscriber from the monthly to the yearly plan, and what happens to the old purchase?
fun changePlan( activity: Activity, yearly: ProductDetails, offerToken: String, current: Purchase,) {…A subscriber's card expires mid-cycle. What states do they pass through before losing access, and what should the app show in each?
// One vocabulary for both stores; the UI never sees a store-specific stringenum class SubState { ACTIVE, CANCELLED_ACTIVE, GRACE, HOLD, PAUSED, EXPIRED } fun fromPlay(s: SubscriptionPurchaseV2): SubState = when (s.subscriptionState) { "SUBSCRIPTION_STATE_ACTIVE" -> if (s.lineItems.first().autoRenewingPlan?.autoRenewEnabled == true) SubState.ACTIVE…A user emails you a receipt: they were charged, the app still shows the paywall, and a relaunch changed nothing. How do they get their content?
Find the bug: the checkout screen shows $19.99, but the customer's card is charged $19.98.
data class CartItem(val sku: String, val price: Double, val qty: Int) fun totalInCents(items: List<CartItem>): Long { val total = items.sumOf { it.price * it.qty } return (total * 100).toLong()} suspend fun checkout(items: List<CartItem>): PaymentIntent = paymentsApi.createPaymentIntent( amount = totalInCents(items), // the gateway takes minor units currency = "usd", )You want to raise the subscription price by 30%. What does Google Play handle for you, and when must an existing subscriber agree?
A user subscribes on their iPhone, then opens your Android app and your website. Where does "is this person a subscriber" actually live?
// One row, three writers. Every webhook converges here.data class Entitlement( val userId: String, val product: String, val state: SubState, val activeUntil: Instant, val source: Source, val sourceRef: String)…Find the bug: on a flaky mobile network, some customers are charged twice for one order.
suspend fun pay(order: Order): ChargeResult { repeat(3) { attempt -> try { return api.charge( ChargeRequest(order.id, order.amountCents), idempotencyKey = UUID.randomUUID().toString(), ) } catch (e: IOException) { // timeout or dropped connection delay(1_000L shl attempt) } } throw PaymentUnknownException(order.id)}How do you test renewals, a refund and a declined card on Google Play without spending money, and what will that never catch?
Product wants to A/B test three prices on the paywall and read the result weekly. What has to exist in the app and on the server for that?
// Server assigns; the client only renders what the store says those ids cost@Serializabledata class PaywallConfig(val variantId: String, val productIds: List<String>) suspend fun loadPaywall(): Paywall { val config = runCatching { api.paywallConfig() }.getOrDefault(BUNDLED_DEFAULT)…