Irbisa · Cheatsheet

In-App Purchases & Subscriptions

StoreKit 2, Play Billing, entitlements, server validation, restores, offers, refunds, testing

Middle Developer · 23 questions

  1. 01

    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?

    Only the monthly plan.

  2. 02

    Walk through a one-time purchase with Play Billing Library: which calls happen, and in what order?

    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.

    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+…
  3. 03

    How is a Google Play subscription structured (subscription, base plan, offer), and what is an offerToken?

    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.

  4. 04

    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?

    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.

    // 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…
  5. 05

    What states can a Play Billing purchase be in, and why must you acknowledge it within three days?

    A Purchase is PURCHASED, PENDING or UNSPECIFIED_STATE; acknowledged is a separate flag, isAcknowledged.

    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…
  6. 06

    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    }  }}

    startConnection is asynchronous, so the query runs before the service is connected and fails with SERVICE_DISCONNECTED.

  7. 07

    This button unlocks premium when launchBillingFlow returns OK. 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) }  }}

    OK from launchBillingFlow only means the payment sheet opened, so anyone who opens it and cancels gets premium.

  8. 08

    The app decides what to unlock from is_premium in SharedPreferences; set piracy aside: what breaks for honest paying users?

    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.

    // 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,…
  9. 09

    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?

    The server exchanges the identifier for the store's current view of that purchase.

    // 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(…
  10. 10

    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?

    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.

    // 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…
  11. 11

    A user reinstalls, taps Restore, and the lifetime unlock does not come back, though Play says they own it. Where do you look?

    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.

    suspend fun restore(): RestoreOutcome {  val tokens = buildList {    for (type in listOf(ProductType.INAPP, ProductType.SUBS)) {      val result = billing.queryPurchasesAsync(        QueryPurchasesParams.newBuilder().setProductType(type).build()      )…
  12. 12

    A user gets a refund through Google Play. How does your backend find out, and what do you take back?

    Through a voidedPurchaseNotification in RTDN and, as a safety net, the Voided Purchases API (purchases.voidedpurchases.list).

  13. 13

    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?

    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.

    fun buy(activity: Activity, details: ProductDetails, offerToken: String, userId: String) {  val params = BillingFlowParams.newBuilder()    .setProductDetailsParamsList(listOf(      BillingFlowParams.ProductDetailsParams.newBuilder()        .setProductDetails(details)        .setOfferToken(offerToken)…
  14. 14

    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)}

    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.

  15. 15

    How do you move a Play subscriber from the monthly to the yearly plan, and what happens to the old purchase?

    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.

    fun changePlan(  activity: Activity,  yearly: ProductDetails,  offerToken: String,  current: Purchase,) {…
  16. 16

    A subscriber's card expires mid-cycle. What states do they pass through before losing access, and what should the app show in each?

    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.

    // 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…
  17. 17

    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?

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

  18. 18

    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",  )

    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.

  19. 19

    You want to raise the subscription price by 30%. What does Google Play handle for you, and when must an existing subscriber agree?

    You edit the base plan price; new buyers pay it at once, existing subscribers stay in a legacy cohort until migrated.

  20. 20

    A user subscribes on their iPhone, then opens your Android app and your website. Where does "is this person a subscriber" actually live?

    In your own database, keyed by your own user id.

    // 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)…
  21. 21

    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)}

    Every retry sends a fresh UUID as the idempotency key, so the gateway sees each attempt as a new payment.

  22. 22

    How do you test renewals, a refund and a declined card on Google Play without spending money, and what will that never catch?

    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.

  23. 23

    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?

    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.

    // 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)…