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 userHow 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
submitthenack, 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
| Topic | Android | iOS | Basis |
|---|---|---|---|
| Listening | Without 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 purchases | Call queryPurchasesAsync() on start and in onResume(). | Read Transaction.currentEntitlements and the unfinished transactions. | Integrate Google Play Billing; Apple StoreKit: Transaction |
| Telling the store | Acknowledge 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 |
| Pending | Grant 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 key | purchaseToken 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 app | Always 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
- Add a consumable product, such as 100 credits. Which table changes, and what does the server do when the same token arrives twice?
- 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?
- The server is down for an hour. What does the app show, and what keeps the user's access?
- Change the 72 hour offline window to 7 days. What do you gain and what do you risk?
- A notification from a store arrives before the app has sent the token. What does the server save?
Further reading (official docs)
- The interview part of this case: scope, decisions, failures, follow-ups and levels
- Integrate Google Play Billing
- Google Play Billing: security
- Apple StoreKit: Transaction
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.