Implementing In-Game Shop for Mobile Games
In-game shop is the central monetization point. Player opens it intending to spend. Implementation task: don't get in the way with technical issues and make purchasing as smooth as possible.
Catalog Architecture
Shop catalog is stored on server — no hardcoded prices and items on client. This allows changing offers without app update, running A/B tests, and launching promotions in real-time.
Product structure:
{
"productId": "gems_pack_medium",
"type": "iap_consumable",
"storeProductId": {
"ios": "com.mygame.gems.500",
"android": "gems_500"
},
"displayName": "500 crystals",
"description": "Plus 50 bonus crystals",
"gemAmount": 500,
"bonusGemAmount": 50,
"badge": "best_value",
"position": 2,
"isVisible": true
}
storeProductId differs for iOS and Android, as product IDs are independent. Client selects appropriate by platform when displaying.
IAP Purchase: Technical Flow
// iOS - StoreKit 2
func purchaseProduct(_ product: Product) async throws -> PurchaseResult {
let result = try await product.purchase()
switch result {
case .success(let verification):
switch verification {
case .verified(let transaction):
// Verify with server
let serverVerified = await verifyWithServer(transaction)
if serverVerified {
await transaction.finish()
return .success
} else {
// Don't finish transaction — don't deliver item
return .verificationFailed
}
case .unverified:
return .verificationFailed
}
case .userCancelled: return .cancelled
case .pending: return .pending
}
}
Don't call transaction.finish() before delivering item. If finishing before server confirmation — on server error item not delivered but transaction finished, can't recover. Only after serverVerified = true.
Virtual Currency
Virtual currency wallet is stored on server. Client displays balance received from server on last sync and updates after each transaction.
Replenishment logic:
POST /shop/purchase
{ "productId": "gems_pack_medium", "receiptData": "...", "userId": "..." }
→ Server verifies receipt with Apple/Google
→ Checks transaction wasn't processed before (idempotency by transactionId)
→ Credits 550 gems to player balance
→ Returns { "newBalance": 1050, "transactionId": "..." }
Idempotency is mandatory: if client sends request twice (network fail → retry), item should deliver once, not twice.
Purchase History
Purchase history screen — common requirement and good practice to reduce chargebacks. Player sees all transactions with date, amount, and delivered item. This reduces "I don't remember buying this" as dispute reason.
Technically: purchase_history table on server, paginated API, client list with pull-to-refresh.
Rotating Offers and Promotions
Daily Deals, Flash Sales, personalized offers — separate shop item type with validUntil timestamp. Client displays countdown timer.
For personalization: Firebase Remote Config or custom recommendation engine selects offers based on player behavior (purchase history, level reached, time since last shop open).
Restore Purchases
On iOS restore purchases button is mandatory for non-consumable IAP and subscriptions. Implementation via Transaction.currentEntitlements in StoreKit 2. On Android — automatic on Play Store entry, but button in settings is good UX too.
Timeline: basic shop with several items, IAP integration and server verification — 5 days. Full implementation with rotating offers, history, subscriptions and analytics — 2 weeks.







