In-App Purchases & Subscriptions
StoreKit 2, Play Billing, entitlements, server validation, restores, offers, refunds, testing
- 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?
EasyOnly the monthly plan.
// One place decides how a catalogue item is paid for, so review has one answer to audit sealed interface Item { val sku: String // Consumed inside the app: store billing is mandatory data class Digital(override val sku: String, val storeProductId: String) : Item // Exists in the world: store billing is forbidden … - 02
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?
MediumThe coins must be consumed after your server credits them, the unlock must never be persisted locally because the store remembers it forever, and the subscription has a lifecycle the client never sees, so it can only ever read the current state from your backend.
// Play has two product types (INAPP, SUBS); "consumable" is your decision, not the store's private val consumableIds = setOf("coins_500", "coins_2000") suspend fun settle(purchase: Purchase) { // PENDING (Ask to Buy, cash payment) is not money yet: never grant, never settle if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) return … - 03
Your paywall unlocks the feature the moment purchase() returns success — which real purchases does that flow silently drop?
MediumEvery purchase that does not come back through that call site: pending approvals, purchases completed while the app was dead, renewals, buys made on another device, and App Store promoted purchases that never touched your paywall.
@main struct ShopApp: App { // Started before any UI. Renewals, Ask to Buy approvals, promoted purchases and // buys made on another device all arrive here — never through the paywall callback. @State private var updates = Task.detached { await listen() } … - 04
Sales look healthy, then three days later a batch of them is refunded and those users are furious — what did the code skip?
MediumAcknowledgement.
// The wrong way: settle in the client callback, hope the grant catches up override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) { purchases?.forEach { p -> billing.acknowledgePurchase( // acknowledged... AcknowledgePurchaseParams.newBuilder().setPurchaseToken(p.purchaseToken).build() ) { } … - 05
The app decides what to unlock from is_premium in SharedPreferences; set piracy aside — what breaks for honest paying users?
MediumA boolean cannot express the four things entitlement actually is — who owns it, until when, from which platform and in what state — so it goes wrong for paying customers long before anybody tries to cheat it.
// What the client is allowed to know data class Entitlement( val productId: String?, val status: Status, // ACTIVE, GRACE, ON_HOLD, EXPIRED, NONE val expiresAt: Instant?, val willRenew: Boolean, … - 06
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?
MediumThe server exchanges the identifier for the store's current view of that purchase, because a signature only proves the payload came from the store when it was issued — not that it is still valid, and not that it belongs to this user.
// Backend. The client is a courier: it carries an identifier, it does not assert anything. suspend fun claim(userId: UserId, req: ClaimRequest): Entitlement = when (req.platform) { Platform.APP_STORE -> { // Signed JWS in, signed JWS out — verified against Apple's root by the library val status = appStore.getAllSubscriptionStatuses(req.originalTransactionId) … - 07
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?
MediumBoth stores push lifecycle events to an endpoint you register — App Store Server Notifications V2 and Play's Real-time Developer Notifications over Pub/Sub — and the only handler that stays correct treats a notification as a hint to re-fetch state, never as the state itself.
// Play RTDN. The message is a doorbell: it tells you which token changed, nothing more. suspend fun onRtdn(message: PubsubMessage) { // At-least-once delivery: the same messageId will arrive again if (!processed.insertIfAbsent(message.messageId)) return ack(message) val n = Json.decodeFromString<DeveloperNotification>(message.data.decodeToString()) … - 08
A user reinstalls, taps Restore, and the lifetime unlock does not come back — but the store insists they already own it. Where do you look?
MediumAt which store account is signed in on the device, and at what kind of product they bought — restore reads what the store account owns, not what your app account paid for.
// Restore, in the order the platform actually intends func restore() async -> RestoreOutcome { // 1. The user tapped the button, so the expensive step is allowed: re-authenticate // with the App Store. Never call this on launch — it prompts for a password. do { try await AppStore.sync() } catch { return .failed(error: error) } … - 09
A user with one Apple ID signs into a second account in your app and expects the subscription to follow. What decides who gets it?
MediumThe store account owns the payment and your account owns the entitlement, so the link between the two is something you create at purchase time and enforce on your server — neither store enforces it for you.
// Purchase: attach your user id so the store echoes it back everywhere fun buy(activity: Activity, details: ProductDetails, offerToken: String, userId: String) { val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf( BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(details) … - 10
A user who cancelled a year ago opens the paywall and it offers the free trial again. Who was supposed to prevent that?
MediumThe store decides introductory-offer eligibility and enforces it at purchase, so a paywall promising a trial the store will not grant is a UI bug: you have to ask before you render.
// Ask first, then render — never assume the trial is available func paywall(for product: Product, userId: UUID) async -> Paywall { guard let sub = product.subscription else { return .oneTime(product.displayPrice) } if await sub.isEligibleForIntroOffer, let intro = sub.introductoryOffer { return .trial(intro.period, then: product.displayPrice) // store will honour it … - 11
A subscriber's card expires mid-cycle. What states do they pass through before losing access, and what should the app show in each?
MediumBetween "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 the grace part.
// One vocabulary for both stores; the UI never sees a store-specific string enum 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 … - 12
You want to raise the subscription price by 30%. What do the stores handle for you, and when does the existing subscriber have to say yes?
MediumYou change the price in App Store Connect or Play Console and the store handles notice, currency, tax and billing; the only piece you own is the consent case, where the subscription simply stops renewing if the user never agrees.
// Price-increase consent arrives as a StoreKit message, not as a push @MainActor final class StoreMessages: ObservableObject { @Published var deferred: [Message] = [] var isBusy = false // true during checkout or full-screen video … - 13
A user subscribes on their iPhone, then opens your Android app and your website. Where does "is this person a subscriber" actually live?
HardIn your own database, keyed by your own user id: a store transaction is evidence, and the entitlement row it produces is the single thing every client reads.
// 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 ) … - 14
How do you test a year of renewals, a refund and a failed payment without spending a cent, and what will none of that catch?
HardThree layers, each catching a different class of bug: an Xcode StoreKit configuration file for everything local, the store sandboxes for the real client-to-store round trip, and test notifications for your webhook.
import StoreKitTest @MainActor final class EntitlementTests: XCTestCase { var session: SKTestSession! … - 15
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?
HardStart from the identifier on the receipt — both stores let your server turn an order id into the real transaction, so the fix is a server-side lookup and a grant, never "please buy it again".
// Support path: an order id off the user's email receipt is enough suspend fun recoverFromReceipt(orderId: String, userId: String): Recovery { val lookup = appStore.lookUpOrderId(orderId) // GET /inApps/v1/lookup/{orderId} if (lookup.status != OrderLookupStatus.VALID) return Recovery.NotThisStoreAccount val tx = lookup.signedTransactions … - 16
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?
HardYou 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 @Serializable data class PaywallConfig( val variantId: String, // travels with the purchase claim val productIds: List<String>, val headline: String …