# StoreKit 2 Production Guide: Modern IAP with Async/Await
StoreKit 2 (iOS 15+) is the release in which Apple rewrote its IAP API from the ground up. The old StoreKit 1 delegate pattern, callback hell, manual receipt parsing — all gone. In their place: Swift Concurrency, JWS-based verification, and strongly typed Product/Transaction/Purchase APIs. This guide is a comprehensive walkthrough of a production-ready StoreKit 2 implementation, covering server-side validation, subscription offers, family sharing, error handling and test strategy.
💡 Pro Tip: Migrating from StoreKit 1 to StoreKit 2 is not a one-day refactor — it is a 2-3 week project. Bringing legacy receipt validation to value parity with StoreKit 2's JWS validation is the hardest part.
Table of Contents
- StoreKit 2 vs StoreKit 1
- Product and Transaction API
- Purchase Flow: Modern async/await
- JWS Receipt Validation (Server-Side)
- Subscription Offers: Intro, Promo, Winback
- Server Notifications V2
- Family Sharing
- Refund Handling
- Testing: Sandbox + TestFlight
- SwiftUI Paywall Example
StoreKit 2 vs StoreKit 1
Feature | StoreKit 1 | StoreKit 2 |
|---|---|---|
Pattern | Delegate + Notification | async/await |
Type safety | Weak (AnyObject) | Strong (Codable struct) |
Receipt | Binary PKCS#7 | JWS (signed JSON) |
Server validation | Apple endpoint /verifyReceipt | App Store Server API |
Minimum iOS | iOS 3 | iOS 15+ |
Subscription offers | Manual JWT | Type-safe API |
Parallel verification | Manual | Automatic |
Product and Transaction API
Product Load
1import StoreKit2 3@Observable4final class StoreVM {5 var products: [Product] = []6 var purchasedProductIDs: Set<String> = []7 8 func loadProducts() async throws {9 let ids = ["com.myapp.pro.monthly", "com.myapp.pro.yearly", "com.myapp.coin.100"]10 products = try await Product.products(for: ids)11 }12}Product Structure
1struct Product {2 let id: String3 let displayName: String4 let description: String5 let price: Decimal6 let displayPrice: String // "$9.99" localized7 let type: ProductType // .consumable, .nonConsumable, .nonRenewable, .autoRenewable8 let subscription: SubscriptionInfo?9}Purchase Flow: Modern async/await
1extension StoreVM {2 func purchase(_ product: Product) async throws {3 let result = try await product.purchase()4 5 switch result {6 case .success(let verification):7 let transaction = try checkVerified(verification)8 await handleTransactionCompletion(transaction)9 await transaction.finish()10 11 case .pending:12 // Aile onayı veya Apple ID doğrulama bekleniyor13 break14 15 case .userCancelled:16 break17 18 @unknown default:19 break20 }21 }22 23 func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {24 switch result {25 case .unverified(_, let error):26 throw error27 case .verified(let safe):28 return safe29 }30 }31}Transaction Updates Listener
1Task.detached {2 for await update in Transaction.updates {3 if case .verified(let transaction) = update {4 await handleTransactionCompletion(transaction)5 await transaction.finish()6 }7 }8}The transactions listener runs for the entire app lifecycle — offline purchases, renewals and refunds are handled automatically.
JWS Receipt Validation (Server-Side)
StoreKit 2 uses JWS (JSON Web Signature) — signed with ECDSA. Validate it on the server:
1. JWS Decode
1// Node.js server2import jwt from 'jsonwebtoken';3import { X509Certificate } from 'node:crypto';4 5async function verifyJWS(signedPayload: string): Promise<TransactionInfo> {6 const [headerB64] = signedPayload.split('.');7 const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());8 9 // 1. Get Apple public keys from x5c chain10 const certChain = header.x5c.map(c => new X509Certificate(Buffer.from(c, 'base64')));11 12 // 2. Verify chain against Apple Root CA (G3)13 await verifyCertChain(certChain, APPLE_ROOT_CA_G3);14 15 // 3. Verify signature16 const leafCert = certChain[0];17 const publicKey = leafCert.publicKey;18 const decoded = jwt.verify(signedPayload, publicKey, { algorithms: ['ES256'] });19 20 return decoded as TransactionInfo;21}2. App Store Server API
Query the transaction state through Apple's REST API:
1const response = await fetch(2 `https://api.storekit.itunes.apple.com/inApps/v1/transactions/${transactionId}`,3 {4 headers: {5 'Authorization': `Bearer ${generateJWT(APP_STORE_KEY_ID, ISSUER_ID)}`,6 },7 }8);9const { signedTransactionInfo } = await response.json();10const transaction = await verifyJWS(signedTransactionInfo);This approach also works with a local cache, without hitting Apple's endpoint every single time.
Subscription Offers: Intro, Promo, Winback
Intro Offer (First-Time Subscribers)
1let offer = product.subscription?.introductoryOffer2 3if let offer {4 print("İlk (offer.period.formatted): (offer.displayPrice)")5 // UI'da göster: "İlk 7 gün ücretsiz, sonra ayda $9.99"6}Promotional Offer (Existing Subscribers)
1// 1. Server'da signature oluştur (App Store Connect'te tanımlı offer)2let signedOffer = await myServer.signPromoOffer(3 offerID: "winter_discount",4 userUUID: currentUser.id5)6 7// 2. Purchase with offer8let result = try await product.purchase(options: [9 .promotionalOffer(10 offerID: signedOffer.id,11 keyID: signedOffer.keyID,12 nonce: signedOffer.nonce,13 signature: signedOffer.signature,14 timestamp: signedOffer.timestamp15 )16])Winback Offer (Lapsed Subscribers)
A new capability in iOS 18+. You can also trigger the offer that the App Store surfaces from inside your app:
1let winbackOffers = product.subscription?.winBackOffers ?? []2if let firstOffer = winbackOffers.first {3 try await product.purchase(options: [.winBackOfferID(firstOffer.id)])4}Server Notifications V2
Apple sends subscription state changes to you as webhooks:
1// Express webhook handler2app.post('/apple/webhook', async (req, res) => {3 const { signedPayload } = req.body;4 const notification = await verifyJWS(signedPayload);5 6 switch (notification.notificationType) {7 case 'SUBSCRIBED':8 await grantEntitlement(notification);9 break;10 case 'DID_RENEW':11 await extendEntitlement(notification);12 break;13 case 'DID_FAIL_TO_RENEW':14 await warnUser(notification);15 break;16 case 'EXPIRED':17 await revokeEntitlement(notification);18 break;19 case 'REFUND':20 await processRefund(notification);21 break;22 case 'GRACE_PERIOD_EXPIRED':23 await downgradeUser(notification);24 break;25 }26 27 res.status(200).send('OK');28});Important: The webhook must be idempotent — Apple retries, so never process the same notification twice.
Family Sharing
1let purchases = Transaction.currentEntitlements2for await entitlement in purchases {3 if case .verified(let transaction) = entitlement {4 if transaction.ownershipType == .familyShared {5 print("Bu satın alma aile paylaşımıyla geldi")6 } else {7 print("Kullanıcı kendisi satın aldı")8 }9 }10}Family-sharable product configuration is enabled with the "Family Sharing" toggle in App Store Connect.
Refund Handling
Refund Request from the App (iOS 15+)
1// iOS 15+ Apple resmi refund UI2@Environment(\.requestReview) var requestReview3@Environment(\.openURL) var openURL4 5// Refund request6if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {7 let result = try await Transaction.beginRefundRequest(8 for: transactionID,9 in: windowScene10 )11}Refund Notification on the Server
1case 'REFUND':2 // notification.data.signedRenewalInfo → revoked3 await db.transaction(async (tx) => {4 await tx.update('entitlements', { status: 'refunded' });5 await tx.insert('refund_log', { ... });6 });7 break;Revoke the user's entitlement immediately. There is no grace period.
Testing: Sandbox + TestFlight
Sandbox Setup
- Create a Sandbox Tester: App Store Connect > Users and Access > Sandbox Testers
- Settings > App Store > Sandbox Account: Sign in with the test user
- Purchase in the app: Sandbox mode is detected automatically
StoreKit Test in Xcode
The .storekit file — a test catalog inside Xcode:
1{2 "products": [{3 "id": "com.myapp.pro.monthly",4 "type": "auto_renewable_subscription",5 "displayPrice": "9.99",6 "subscriptionGroupID": "pro_group"7 }]8}TestFlight
- Beta Apple IDs are not routed to the sandbox — real purchases are made, but no payment reaches Apple and TestFlight expires.
- Subscription offerings in TestFlight are pulled from the real configuration.
SwiftUI Paywall Example
1struct PaywallView: View {2 @Environment(StoreVM.self) private var store3 @State private var selectedProduct: Product?4 5 var body: some View {6 VStack(spacing: 16) {7 Text("Pro'ya Yükselt")8 .font(.largeTitle)9 10 ForEach(store.products) { product in11 ProductCard(12 product: product,13 isSelected: selectedProduct?.id == product.id14 )15 .onTapGesture { selectedProduct = product }16 }17 18 Button {19 Task {20 guard let product = selectedProduct else { return }21 try await store.purchase(product)22 }23 } label: {24 Text("Satın Al")25 .frame(maxWidth: .infinity)26 .padding()27 }28 .buttonStyle(.borderedProminent)29 30 Text("Her zaman iptal edebilirsiniz")31 .font(.caption)32 .foregroundStyle(.secondary)33 }34 .padding()35 .task { try? await store.loadProducts() }36 }37}ALTIN İPUCU
Bu yazının en değerli bilgisi
Bu ipucu, yazının en önemli çıkarımını içeriyor.
Easter Egg
Gizli bir bilgi buldun!
Bu bölümde gizli bir bilgi var. Keşfetmek ister misin?
Okuyucu Ödülü
When taking IAP to production: 1. ✅ Integrate server-side JWS validation 2. ✅ Webhook signature verification 3. ✅ Idempotent transaction processing (transaction_id as a unique key) 4. ✅ Grace period handling (billing retry) 5. ✅ Refund webhook → immediate entitlement revoke 6. ✅ Family Sharing UI (show the ownership type) 7. ✅ Localized prices (displayPrice, currency) 8. ✅ Restore purchases button (required by the App Store) 9. ✅ Privacy policy link (data handling) 10. ✅ Sandbox test flow + TestFlight final test 11. ✅ Production keys kept in a secret manager on the server (AWS Secrets, 1Password) 12. ✅ Monitoring: purchase success rate, refund rate, renewal rate External Resources: - StoreKit 2 documentation - App Store Server API - Server Notifications V2 - JWS signed transaction verification - RevenueCat StoreKit 2 guide
Conclusion
StoreKit 2 is the mandatory standard for modern iOS IAP: clean code with async/await, secure verification with JWS, reliable webhooks with server notifications V2, and churn reduction with winback offers. Migrating from StoreKit 1 is 2-3 weeks of work, but the ROI is high. Whether to build IAP directly is a RevenueCat vs. self-built tradeoff (a topic for another post). The checklist above is critical for production-ready code — above all, the server-side validation step must never be skipped.
_Related posts: [StoreKit Subscription](./storekit-subscription-ios), [Async/Await Best Practices](./async-await-best-practices), [Swift 6 Concurrency](./swift-6-neler-yeni)._
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

