Mobile Design Rounds
Build guideAndroidiOS16 min read

Build it: in-app purchases and subscriptions on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Kotlin and SQL run; Swift and the real store libraries not compiled. Not reviewed by an iOS engineer. Part of: Design in-app purchases and subscriptions (Android and iOS) · Live data and payments · The five-step method.

This is the implementation material behind the in-app purchases interview case. It covers:

  • the three server tables and the SQL that saves a purchase
  • the four API calls and how the app reads their answers
  • a purchase manager that reconciles with the store on every start
  • the Android and iOS adapters
  • tests for the lost-answer and double-send paths
  • platform limits

None of it belongs in a 45-minute round (the length I assume). I draw the tables and the states, and write code only if asked. Checked on 16 October 2026:

  • The SQL ran in SQLite and six checks passed: the same token twice gives one row, the longest purchase wins, a refund of one purchase keeps the other, and a pending purchase grants nothing. It was not run on Postgres.
  • The Kotlin manager and seven tests compiled with Kotlin 2.0.21 and all seven passed. They run against fake store, server and cache classes.
  • The Android adapter was not compiled against the real Play Billing library. The Swift was not compiled, because I had no Swift toolchain. Compile both before you copy them.
  • No iOS engineer has reviewed this page.

1. The server tables

Three tables. The key of purchases is the store's own token, so a repeat is harmless. entitlements is worked out from the purchases and never edited by hand.

CREATE TABLE products (
  product_id TEXT PRIMARY KEY,
  feature    TEXT NOT NULL            -- what this product unlocks, e.g. 'pro'
);

CREATE TABLE purchases (
  token       TEXT PRIMARY KEY,       -- Google purchaseToken, or Apple transaction id
  user_id     TEXT NOT NULL,
  product_id  TEXT NOT NULL REFERENCES products(product_id),
  store       TEXT NOT NULL,          -- 'google' or 'apple'
  state       TEXT NOT NULL,          -- 'pending', 'active', 'expired', 'revoked'
  expires_at  TEXT NOT NULL,          -- ISO time; a far date for a one-time unlock
  updated_at  TEXT NOT NULL
);

CREATE TABLE entitlements (
  user_id     TEXT NOT NULL,
  feature     TEXT NOT NULL,
  valid_until TEXT NOT NULL,
  PRIMARY KEY (user_id, feature)
);

Saving a purchase is two steps in one transaction. Step 1 is an upsert on the token. Step 2 rebuilds what this user owns from all their active purchases.

-- 1. Save the purchase. The same token twice is one row.
INSERT INTO purchases (token, user_id, product_id, store, state, expires_at, updated_at)
VALUES (:token, :user, :product, :store, :state, :expires, :now)
ON CONFLICT (token) DO UPDATE SET
  state = excluded.state, expires_at = excluded.expires_at, updated_at = excluded.updated_at;

-- 2. Work out what the user owns, from all their purchases.
DELETE FROM entitlements WHERE user_id = :user;
INSERT INTO entitlements (user_id, feature, valid_until)
SELECT p.user_id, pr.feature, MAX(p.expires_at)
FROM purchases p JOIN products pr ON pr.product_id = p.product_id
WHERE p.user_id = :user AND p.state = 'active'
GROUP BY p.user_id, pr.feature;

What the six SQLite checks show:

  • Sending token T1 twice leaves one row.
  • A monthly purchase and a lifetime purchase for the same feature: the later end date wins.
  • The lifetime purchase is refunded: access follows the monthly one.
  • Both are refunded: the user owns nothing.
  • A pending purchase grants nothing.

The server must also check the token with the store before step 1, and read the product and the end date from the store's answer, never from the app. That call is not shown here because it differs for each store.

2. The API

Four calls. The app uses the first two. The stores call the third. The fourth is for buyers who sign in later.

POST /v1/purchases
{ "store": "google", "productId": "pro_monthly", "token": "<purchaseToken>" }
200 { "entitlements": { "pro": "2026-11-16T00:00:00Z" }, "serverTime": "2026-10-16T08:00:00Z" }
422 { "error": "not_valid" }        the store says this token is not real
503                                 the store or my database is down

GET /v1/entitlements
200 { "entitlements": { "pro": "2026-11-16T00:00:00Z" }, "serverTime": "2026-10-16T08:00:00Z" }

POST /v1/store-notifications/google
POST /v1/store-notifications/apple
    called by the stores on a renewal, a refund or a change; the body is signed, so check it

POST /v1/accounts/link
{ "token": "<purchaseToken>" }       attach an earlier purchase to the signed-in user

How the app reads the answers:

  • 200 is Granted. The app saves the answer and then acknowledges.
  • 422 is Rejected. The app never acknowledges it.
  • A timeout or a 5xx is TryLater. The store still shows the purchase as unfinished, so the next reconcile sends it again.
  • The notification endpoints call the same save code as step 1 and 2. Each store has its own notification service. I did not check the details of either one.

3. The purchase manager

One class that lives as long as the app. It has three jobs: reconcile with the store, save the server's answer, and answer "does this user have access?" from the saved answer. The store, the server and the cache are interfaces, so tests can replace them.

3.1 Kotlin

package purchases

enum class StoreState { Pending, Purchased }

data class StorePurchase(
    val token: String,
    val productId: String,
    val state: StoreState,
    val acknowledged: Boolean,
)

data class Entitlements(
    val validUntil: Map<String, Long>, // feature -> epoch millis
    val fetchedAt: Long,
)

sealed interface ServerAnswer {
    data class Granted(val entitlements: Entitlements) : ServerAnswer
    data object Rejected : ServerAnswer // the store says this token is not real
    data object TryLater : ServerAnswer // no network, or a server error
}

interface Store {
    suspend fun owned(): List<StorePurchase>
    suspend fun acknowledge(token: String) // Android: acknowledge. iOS: finish.
}

interface Server {
    suspend fun submit(purchase: StorePurchase): ServerAnswer
}

interface EntitlementCache {
    suspend fun read(): Entitlements?
    suspend fun write(entitlements: Entitlements)
}

class PurchaseManager(
    private val store: Store,
    private val server: Server,
    private val cache: EntitlementCache,
    private val now: () -> Long,
) {
    /** Call on app start, on every resume, and after the store reports a purchase. */
    suspend fun reconcile() {
        for (p in store.owned()) {
            if (p.state == StoreState.Pending) continue // grant nothing, acknowledge nothing
            when (val answer = server.submit(p)) {
                is ServerAnswer.Granted -> {
                    cache.write(answer.entitlements)           // 1. keep the server's answer
                    if (!p.acknowledged) store.acknowledge(p.token) // 2. only now tell the store
                }
                ServerAnswer.Rejected -> Unit        // never acknowledge what the server refused
                ServerAnswer.TryLater -> return      // the store still shows it; next reconcile retries
            }
        }
    }

    /** What the screens ask. It reads the saved answer, never the store. */
    suspend fun hasAccess(feature: String, offlineWindowMs: Long = 72L * 60 * 60 * 1000): Boolean {
        val e = cache.read() ?: return false
        val t = now()
        if (t > e.fetchedAt + offlineWindowMs) return false
        return (e.validUntil[feature] ?: return false) > t
    }
}

Three rules sit in reconcile():

  • A pending purchase is skipped. Nothing is granted and nothing is acknowledged.
  • The order is fixed: the server answers, the app saves the answer, and only then does it tell the store.
  • If the server cannot answer, the function stops. The store still shows the purchase, so the next start tries again.

3.2 Swift

The same shape. Transaction.updates has to be listened to from launch, because a purchase can finish outside the app. finish() plays the role of acknowledge. Not compiled.

import StoreKit

enum ServerAnswer {
    case granted(Entitlements)
    case rejected
    case tryLater
}

protocol PurchaseServer {
    func submit(signedTransaction: String) async -> ServerAnswer
}

protocol EntitlementCache {
    func read() async -> Entitlements?
    func write(_ entitlements: Entitlements) async
}

struct Entitlements: Codable {
    var validUntil: [String: Date]
    var fetchedAt: Date
}

actor PurchaseManager {
    private let server: PurchaseServer
    private let cache: EntitlementCache
    private var listener: Task<Void, Never>?

    init(server: PurchaseServer, cache: EntitlementCache) {
        self.server = server
        self.cache = cache
    }

    /// Call once, when the app starts.
    func start() {
        listener = Task { [weak self] in
            // Purchases can finish outside the app, so something must always be listening.
            for await result in Transaction.updates { await self?.handle(result) }
        }
        Task { await reconcile() }
    }

    /// Call on start and whenever the scene becomes active.
    func reconcile() async {
        for await result in Transaction.unfinished { await handle(result) }
        for await result in Transaction.currentEntitlements { await handle(result) }
    }

    private func handle(_ result: VerificationResult<Transaction>) async {
        // The check on the device is only a hint. The server still verifies.
        guard case .verified(let transaction) = result else { return }
        switch await server.submit(signedTransaction: result.jwsRepresentation) {
        case .granted(let entitlements):
            await cache.write(entitlements)   // 1. keep the server's answer
            await transaction.finish()        // 2. only now tell the store
        case .rejected, .tryLater:
            break                             // not finished: it comes back next time
        }
    }

    func hasAccess(_ feature: String, offlineWindow: TimeInterval = 72 * 3600) async -> Bool {
        guard let e = await cache.read() else { return false }
        let now = Date()
        guard now < e.fetchedAt.addingTimeInterval(offlineWindow),
              let until = e.validUntil[feature] else { return false }
        return until > now
    }
}

4. The store adapters

On Android, one small class turns Play Billing purchases into StorePurchase and calls acknowledge. The purchase listener only calls reconcile(), so one path handles a new purchase, an old one and a purchase that finished while the app was closed. Not compiled against the real library. It assumes the Play Billing Kotlin extensions, and you should check the calls against the version you use.

class AndroidStore(private val billing: BillingClient) : Store {

    override suspend fun owned(): List<StorePurchase> =
        listOf(BillingClient.ProductType.SUBS, BillingClient.ProductType.INAPP).flatMap { type ->
            val params = QueryPurchasesParams.newBuilder().setProductType(type).build()
            billing.queryPurchasesAsync(params).purchasesList.map { p ->
                StorePurchase(
                    token = p.purchaseToken,
                    productId = p.products.first(),
                    state = if (p.purchaseState == Purchase.PurchaseState.PURCHASED)
                        StoreState.Purchased else StoreState.Pending,
                    acknowledged = p.isAcknowledged,
                )
            }
        }

    override suspend fun acknowledge(token: String) {
        val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(token).build()
        billing.acknowledgePurchase(params)
    }
}

// In the Application or the activity:
// - the purchase listener only calls reconcile(), so one path handles every purchase
val billing = BillingClient.newBuilder(context)
    .setListener { _, _ -> appScope.launch { manager.reconcile() } }
    // enable pending purchases here; the call depends on your Play Billing version
    .build()

// And on every resume:
override fun onResume() {
    super.onResume()
    lifecycleScope.launch { manager.reconcile() }
}

On iOS the adapter is already inside the Swift manager above. Transaction.unfinished and Transaction.currentEntitlements play the role of owned().

5. Tests

Seven tests, written as plain checks that run in a main function. They use a fake store, a fake server that keeps rows by token, and a cache in memory. All seven passed on 16 October 2026.

  • A pending purchase leaves the call log empty.
  • The call log reads submit then ack, in that order.
  • The app is killed between submit and acknowledge. The next reconcile finishes it, and the server has one row.
  • The server says try later: nothing is acknowledged.
  • The server rejects: nothing is acknowledged and the user has no access.
  • A purchase that is already acknowledged is sent again and is acknowledged only once.
  • The saved answer is trusted at 71 hours and not at 73.
package purchases

import kotlinx.coroutines.runBlocking

class FakeStore(var items: MutableList<StorePurchase>, var failAck: Boolean = false) : Store {
    val log = mutableListOf<String>()
    override suspend fun owned() = items.toList()
    override suspend fun acknowledge(token: String) {
        if (failAck) throw RuntimeException("app killed")
        log += "ack:$token"
        items = items.map { if (it.token == token) it.copy(acknowledged = true) else it }.toMutableList()
    }
}

class FakeServer(val store: FakeStore, var answer: (StorePurchase) -> ServerAnswer) : Server {
    val rows = mutableMapOf<String, StorePurchase>() // the token is the unique key
    override suspend fun submit(purchase: StorePurchase): ServerAnswer {
        store.log += "submit:${purchase.token}"
        val a = answer(purchase)
        if (a is ServerAnswer.Granted) rows[purchase.token] = purchase
        return a
    }
}

class MemoryCache : EntitlementCache {
    var value: Entitlements? = null
    override suspend fun read() = value
    override suspend fun write(entitlements: Entitlements) { value = entitlements }
}

const val HOUR = 3_600_000L
fun granted(at: Long) = ServerAnswer.Granted(Entitlements(mapOf("pro" to at + 30 * 24 * HOUR), at))
fun p(token: String, state: StoreState = StoreState.Purchased, ack: Boolean = false) =
    StorePurchase(token, "pro_monthly", state, ack)

fun test(name: String, body: suspend () -> Unit) {
    runBlocking { body() }
    println("PASS $name")
}

fun main() {
    var clock = 0L
    val now = { clock }

    test("pending grants and acknowledges nothing") {
        val s = FakeStore(mutableListOf(p("T1", StoreState.Pending)))
        val srv = FakeServer(s) { granted(0) }
        PurchaseManager(s, srv, MemoryCache(), now).reconcile()
        check(s.log.isEmpty()) { s.log.toString() }
    }
    test("submit comes before acknowledge") {
        val s = FakeStore(mutableListOf(p("T1")))
        val srv = FakeServer(s) { granted(0) }
        val m = PurchaseManager(s, srv, MemoryCache(), now)
        m.reconcile()
        check(s.log == listOf("submit:T1", "ack:T1")) { s.log.toString() }
        check(m.hasAccess("pro"))
    }
    test("killed between submit and acknowledge: the next reconcile finishes, one row") {
        val s = FakeStore(mutableListOf(p("T1")), failAck = true)
        val srv = FakeServer(s) { granted(0) }
        val m = PurchaseManager(s, srv, MemoryCache(), now)
        runCatching { m.reconcile() } // the app dies here
        s.failAck = false
        m.reconcile()                  // next start
        check(srv.rows.size == 1)
        check(s.items.single().acknowledged)
        check(s.log.last() == "ack:T1")
    }
    test("server says try later: nothing is acknowledged") {
        val s = FakeStore(mutableListOf(p("T1")))
        val srv = FakeServer(s) { ServerAnswer.TryLater }
        PurchaseManager(s, srv, MemoryCache(), now).reconcile()
        check(s.log == listOf("submit:T1"))
    }
    test("server rejects: nothing is acknowledged and no access") {
        val s = FakeStore(mutableListOf(p("T1")))
        val srv = FakeServer(s) { ServerAnswer.Rejected }
        val m = PurchaseManager(s, srv, MemoryCache(), now)
        m.reconcile()
        check(s.log == listOf("submit:T1"))
        check(!m.hasAccess("pro"))
    }
    test("already acknowledged: submitted again, acknowledged once") {
        val s = FakeStore(mutableListOf(p("T1", ack = true)))
        val srv = FakeServer(s) { granted(0) }
        val m = PurchaseManager(s, srv, MemoryCache(), now)
        m.reconcile(); m.reconcile()
        check(srv.rows.size == 1)
        check(s.log.none { it.startsWith("ack") })
    }
    test("saved answer is trusted for 72 hours, then access stops") {
        val s = FakeStore(mutableListOf(p("T1")))
        val srv = FakeServer(s) { granted(0) }
        val m = PurchaseManager(s, srv, MemoryCache(), now)
        m.reconcile()
        clock = 71 * HOUR; check(m.hasAccess("pro"))
        clock = 73 * HOUR; check(!m.hasAccess("pro"))
    }
}

6. Platform limits

TopicAndroidiOSBasis
ListeningWithout an active billing connection, onPurchasesUpdated is not called. Keep one active BillingClient.Purchases can finish outside the app, on another device. They arrive through Transaction.updates, which only works if something listens from launch.Integrate Google Play Billing; Apple StoreKit: Transaction
Finding missed purchasesCall queryPurchasesAsync() on start and in onResume().Read Transaction.currentEntitlements and the unfinished transactions.Integrate Google Play Billing; Apple StoreKit: Transaction
Telling the storeAcknowledge within three days, or the purchase is refunded and the entitlement is revoked. Acknowledge right after granting access.Finish the transaction after the app delivers the content.Integrate Google Play Billing; Apple StoreKit: Transaction
PendingGrant access only in the PURCHASED state. The three-day window starts when a pending purchase becomes PURCHASED.Not checked here.Integrate Google Play Billing
The unique keypurchaseToken is globally unique. Do not use the order id, because not every purchase has one.The transaction id. Apple also offers server tools to verify signed transactions.Google Play Billing: security; Apple StoreKit: Transaction
Trusting the appAlways verify a purchase before you grant a benefit.The check on the device is a hint. Verify on the server.Google Play Billing: security; Apple StoreKit: Transaction

The rows come from the same three pages I read for the case page on 16 October 2026. Two things I did not check: whether Apple has a deadline like Google's three days, and how the notification services work in detail.

7. More practice prompts

  1. Add a consumable product, such as 100 credits. Which table changes, and what does the server do when the same token arrives twice?
  2. The user buys on Android and signs in on an iPhone. Which call links the purchase, and what must a refund on one store remove?
  3. The server is down for an hour. What does the app show, and what keeps the user's access?
  4. Change the 72 hour offline window to 7 days. What do you gain and what do you risk?
  5. A notification from a store arrives before the app has sent the token. What does the server save?

Further reading (official docs)

Verification notes (last reviewed 16 Oct 2026)

Facts from the three official pages (the same pages as the case page, read on 2026-10-16):

  • Play Billing integrate: no active connection means no onPurchasesUpdated; one BillingClient; query purchases on start and resume; acknowledge within three days.
  • Play Billing security: verify before granting; the purchase token is globally unique; the order id is not on every purchase.
  • StoreKit Transaction: updates, current entitlements, unfinished transactions and finish.

Code checks.

  • The SQL ran in SQLite. It was not run on Postgres.
  • The Kotlin manager and seven tests compiled with Kotlin 2.0.21 and kotlinx-coroutines 1.6.4, and all seven passed.
  • The Android adapter was not compiled. The Swift was not compiled.

Not verified.

  • The exact Play Billing calls for your library version, including how pending purchases are enabled.
  • How the stores' notification services work in detail.
  • Whether Apple has a deadline like Google's three days.

My own opinion or design.

  • The tables, the SQL, the reconcile order, the 72 hour window, the API shape and the tests are my design.

Get the free one-page cheat sheet

One page: the five steps, a 45-minute time plan and two checklists. Print it or keep it on your phone.

We email you the cheat sheet after you confirm your address. You can also open it now from the Start here page. You get a short note when a new case is published. Unsubscribe any time. Privacy.