Use when implementing in-app purchases, StoreKit 2, subscriptions, or transaction handling - testing-first workflow with .storekit configuration, StoreManager architecture, transaction verification, subscription management, and restore purchases for consumables, non-consumables, and auto-renewable subscriptions
/plugin marketplace add CharlesWiltgen/Axiom/plugin install axiom@axiom-marketplaceThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Purpose: Guide robust, testable in-app purchase implementation StoreKit Version: StoreKit 2 iOS Version: iOS 15+ (iOS 18.4+ for latest features) Xcode: Xcode 13+ (Xcode 16+ recommended) Context: WWDC 2025-241, 2025-249, 2023-10013, 2021-10114
✅ Use this skill when:
❌ Do NOT use this skill for:
If you wrote purchase code before creating .storekit configuration, you have three options:
Delete all IAP code and follow the testing-first workflow below. This reinforces correct habits and ensures you experience the full benefit of .storekit-first development.
Why this is best:
Create the .storekit file now with your existing product IDs. Test everything works locally. Document in your PR that you tested in sandbox first.
Trade-offs:
If choosing this path: Create .storekit immediately, verify locally, and commit a note explaining the approach.
Commit without .storekit configuration, test only in sandbox.
Why this is problematic:
Bottom line: Choose Option A if possible, Option B if pragmatic, never Option C.
Best Practice: Create and test StoreKit configuration BEFORE writing production purchase code.
The recommended workflow is to create .storekit configuration before writing any purchase code. This isn't arbitrary - it provides concrete benefits:
Immediate product ID validation:
Faster iteration:
Team benefits:
Common objections addressed:
❓ "I already tested in sandbox" - Sandbox testing is valuable but comes later. Local testing with .storekit is faster and enables true TDD.
❓ "My code works" - Working code is great! Adding .storekit makes it easier for teammates to verify and maintain.
❓ "I've done this before" - Experience is valuable. The .storekit-first workflow makes experienced developers even more productive.
❓ "Time pressure" - Creating .storekit takes 10-15 minutes. The time saved in iteration pays back immediately.
StoreKit Config → Local Testing → Production Code → Unit Tests → Sandbox Testing
↓ ↓ ↓ ↓ ↓
.storekit Test purchases StoreManager Mock store Integration test
Why this order helps:
Benefits of following this workflow:
Before marking IAP implementation complete, ALL items must be verified:
.storekit configuration file with all productsTransaction.updatesVerificationResult.finish() after entitlement grantedpurchase(confirmIn:options:) with UI context (iOS 18.2+)PurchaseResult cases (success, userCancelled, pending)Product.SubscriptionInfo.StatusTransaction.currentEntitlements(for:)Transaction.currentEntitlements or Transaction.allDO THIS BEFORE WRITING ANY PURCHASE CODE.
Products.storekit (or your app name)Click "+" and add each product type:
Product ID: com.yourapp.coins_100
Reference Name: 100 Coins
Price: $0.99
Product ID: com.yourapp.premium
Reference Name: Premium Upgrade
Price: $4.99
Product ID: com.yourapp.pro_monthly
Reference Name: Pro Monthly
Price: $9.99/month
Subscription Group ID: pro_tier
Products.storekitAll purchase logic must go through a single StoreManager. No scattered Product.purchase() calls throughout app.
import StoreKit
@MainActor
final class StoreManager: ObservableObject {
// Published state for UI
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
// Product IDs from StoreKit configuration
private let productIDs = [
"com.yourapp.coins_100",
"com.yourapp.premium",
"com.yourapp.pro_monthly"
]
private var transactionListener: Task<Void, Never>?
init() {
// Start transaction listener immediately
transactionListener = listenForTransactions()
Task {
await loadProducts()
await updatePurchasedProducts()
}
}
deinit {
transactionListener?.cancel()
}
}
Why @MainActor: Published properties must update on main thread for UI binding.
extension StoreManager {
func loadProducts() async {
do {
// Load products from App Store
let loadedProducts = try await Product.products(for: productIDs)
// Update published property on main thread
self.products = loadedProducts
} catch {
print("Failed to load products: \(error)")
// Show error to user
}
}
}
Call from: App.init() or first view's .task modifier
extension StoreManager {
func listenForTransactions() -> Task<Void, Never> {
Task.detached { [weak self] in
// Listen for ALL transaction updates
for await verificationResult in Transaction.updates {
await self?.handleTransaction(verificationResult)
}
}
}
@MainActor
private func handleTransaction(_ result: VerificationResult<Transaction>) async {
// Verify transaction signature
guard let transaction = try? result.payloadValue else {
print("Transaction verification failed")
return
}
// Grant entitlement to user
await grantEntitlement(for: transaction)
// CRITICAL: Always finish transaction
await transaction.finish()
// Update purchased products
await updatePurchasedProducts()
}
}
Why detached: Transaction listener runs independently of view lifecycle
extension StoreManager {
func purchase(_ product: Product, confirmIn scene: UIWindowScene) async throws -> Bool {
// Perform purchase with UI context for payment sheet
let result = try await product.purchase(confirmIn: scene)
switch result {
case .success(let verificationResult):
// Verify the transaction
guard let transaction = try? verificationResult.payloadValue else {
print("Transaction verification failed")
return false
}
// Grant entitlement
await grantEntitlement(for: transaction)
// CRITICAL: Finish transaction
await transaction.finish()
// Update state
await updatePurchasedProducts()
return true
case .userCancelled:
// User tapped "Cancel" in payment sheet
return false
case .pending:
// Purchase requires action (Ask to Buy, payment issue)
// Will be delivered via Transaction.updates when approved
return false
@unknown default:
return false
}
}
}
struct ProductRow: View {
let product: Product
@Environment(\.purchase) private var purchase
var body: some View {
Button("Buy \(product.displayPrice)") {
Task {
do {
let result = try await purchase(product)
// Handle result
} catch {
print("Purchase failed: \(error)")
}
}
}
}
}
func purchase(
_ product: Product,
confirmIn scene: UIWindowScene,
accountToken: UUID
) async throws -> Bool {
// Purchase with appAccountToken for server-side association
let result = try await product.purchase(
confirmIn: scene,
options: [
.appAccountToken(accountToken)
]
)
// ... handle result
}
When to use: When your backend needs to associate purchases with user accounts
func handleTransaction(_ result: VerificationResult<Transaction>) async {
switch result {
case .verified(let transaction):
// ✅ Transaction signed by App Store
await grantEntitlement(for: transaction)
await transaction.finish()
case .unverified(let transaction, let error):
// ❌ Transaction signature invalid
print("Unverified transaction: \(error)")
// DO NOT grant entitlement
// DO finish transaction to clear from queue
await transaction.finish()
}
}
Why verify: Prevents granting entitlements for:
func grantEntitlement(for transaction: Transaction) async {
// Check transaction hasn't been revoked
guard transaction.revocationDate == nil else {
print("Transaction was refunded")
await revokeEntitlement(for: transaction.productID)
return
}
// Grant based on product type
switch transaction.productType {
case .consumable:
await addConsumable(productID: transaction.productID)
case .nonConsumable:
await unlockFeature(productID: transaction.productID)
case .autoRenewable:
await activateSubscription(productID: transaction.productID)
default:
break
}
}
extension StoreManager {
func updatePurchasedProducts() async {
var purchased: Set<String> = []
// Iterate through all current entitlements
for await result in Transaction.currentEntitlements {
guard let transaction = try? result.payloadValue else {
continue
}
// Only include active entitlements (not revoked)
if transaction.revocationDate == nil {
purchased.insert(transaction.productID)
}
}
self.purchasedProductIDs = purchased
}
}
func isEntitled(to productID: String) async -> Bool {
// Check current entitlements for specific product
for await result in Transaction.currentEntitlements(for: productID) {
if let transaction = try? result.payloadValue,
transaction.revocationDate == nil {
return true
}
}
return false
}
extension StoreManager {
func checkSubscriptionStatus(for groupID: String) async -> Product.SubscriptionInfo.Status? {
// Get subscription statuses for group
guard let result = try? await Product.SubscriptionInfo.status(for: groupID),
let status = result.first else {
return nil
}
return status.state
}
}
func updateSubscriptionUI(for status: Product.SubscriptionInfo.Status) {
switch status.state {
case .subscribed:
// User has active subscription
showSubscribedContent()
case .expired:
// Subscription expired - show win-back offer
showResubscribeOffer()
case .inGracePeriod:
// Billing issue - show payment update prompt
showUpdatePaymentPrompt()
case .inBillingRetryPeriod:
// Apple retrying payment - maintain access
showBillingRetryMessage()
case .revoked:
// Family Sharing access removed
removeAccess()
@unknown default:
break
}
}
struct SubscriptionView: View {
var body: some View {
SubscriptionStoreView(groupID: "pro_tier") {
// Marketing content
VStack {
Image("premium-icon")
Text("Unlock all features")
}
}
.subscriptionStoreControlStyle(.prominentPicker)
}
}
extension StoreManager {
func restorePurchases() async {
// Sync all transactions from App Store
try? await AppStore.sync()
// Update current entitlements
await updatePurchasedProducts()
}
}
struct SettingsView: View {
@StateObject private var store = StoreManager()
var body: some View {
Button("Restore Purchases") {
Task {
await store.restorePurchases()
}
}
}
}
App Store Requirement: Apps with IAP must provide restore functionality for non-consumables and subscriptions.
extension StoreManager {
func listenForTransactions() -> Task<Void, Never> {
Task.detached { [weak self] in
for await verificationResult in Transaction.updates {
await self?.handleTransaction(verificationResult)
}
}
}
@MainActor
private func handleTransaction(_ result: VerificationResult<Transaction>) async {
guard let transaction = try? result.payloadValue else {
return
}
// Check if transaction was refunded
if let revocationDate = transaction.revocationDate {
print("Transaction refunded on \(revocationDate)")
await revokeEntitlement(for: transaction.productID)
} else {
await grantEntitlement(for: transaction)
}
await transaction.finish()
}
}
protocol StoreProtocol {
func products(for ids: [String]) async throws -> [Product]
func purchase(_ product: Product) async throws -> PurchaseResult
}
// Production
final class StoreManager: StoreProtocol {
func products(for ids: [String]) async throws -> [Product] {
try await Product.products(for: ids)
}
}
// Testing
final class MockStore: StoreProtocol {
var mockProducts: [Product] = []
var mockPurchaseResult: PurchaseResult?
func products(for ids: [String]) async throws -> [Product] {
mockProducts
}
func purchase(_ product: Product) async throws -> PurchaseResult {
mockPurchaseResult ?? .userCancelled
}
}
@Test func testSuccessfulPurchase() async {
let mockStore = MockStore()
let manager = StoreManager(store: mockStore)
// Given: Mock successful purchase
mockStore.mockPurchaseResult = .success(.verified(mockTransaction))
// When: Purchase product
let result = await manager.purchase(mockProduct)
// Then: Entitlement granted
#expect(result == true)
#expect(manager.purchasedProductIDs.contains("com.app.premium"))
}
@Test func testCancelledPurchase() async {
let mockStore = MockStore()
let manager = StoreManager(store: mockStore)
// Given: User cancels
mockStore.mockPurchaseResult = .userCancelled
// When: Purchase product
let result = await manager.purchase(mockProduct)
// Then: No entitlement granted
#expect(result == false)
#expect(manager.purchasedProductIDs.isEmpty)
}
// ❌ WRONG: Writing purchase code without .storekit file
let products = try await Product.products(for: productIDs)
// Can't test this without App Store Connect setup!
✅ Correct: Create .storekit file FIRST, test in Xcode, THEN implement.
// ❌ Less ideal: Write code, test in sandbox, add .storekit later
let products = try await Product.products(for: productIDs)
let result = try await product.purchase(confirmIn: scene)
// "I tested this in sandbox, it works! I'll add .storekit config later."
✅ Recommended: Create .storekit config first, then write code.
If you're in this situation: See "Already Wrote Code Before Creating .storekit Config?" section above for your options (A, B, or C).
Why .storekit-first is better:
Sandbox testing is valuable - it validates against real App Store infrastructure. But starting with .storekit makes sandbox testing easier because you've already validated product IDs locally.
// ❌ WRONG: Purchase calls scattered throughout app
Button("Buy") {
try await product.purchase() // In view 1
}
Button("Subscribe") {
try await subscriptionProduct.purchase() // In view 2
}
✅ Correct: All purchases through centralized StoreManager.
// ❌ WRONG: Never calling finish()
func handleTransaction(_ transaction: Transaction) {
grantEntitlement(for: transaction)
// Missing: await transaction.finish()
}
✅ Correct: ALWAYS call transaction.finish() after granting entitlement.
// ❌ WRONG: Using unverified transaction
for await transaction in Transaction.all {
grantEntitlement(for: transaction) // Unsafe!
}
✅ Correct: Always check VerificationResult before granting.
// ❌ WRONG: Only handling purchases in purchase() method
func purchase() {
let result = try await product.purchase()
// What about pending purchases, family sharing, restore?
}
✅ Correct: Listen to Transaction.updates for ALL transaction sources.
// ❌ WRONG: No restore button
// App Store will REJECT your app!
✅ Correct: Provide visible "Restore Purchases" button in settings.
Before marking IAP implementation complete, verify:
Run these searches to verify compliance:
# Check StoreKit configuration exists
find . -name "*.storekit"
# Check transaction.finish() is called
rg "transaction\.finish\(\)" --type swift
# Check VerificationResult usage
rg "VerificationResult" --type swift
# Check Transaction.updates listener
rg "Transaction\.updates" --type swift
# Check restore implementation
rg "AppStore\.sync|Transaction\.all" --type swift
WWDC: 2025-241, 2025-249, 2023-10013, 2021-10114
Docs: /storekit, /appstoreserverapi
Skills: axiom-storekit-ref
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.