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
- Product Load
- Product Structure
- Purchase Flow: Modern async/await
- Transaction Updates Listener
- JWS Receipt Validation (Server-Side)
- 1. JWS Decode
- 2. App Store Server API
- Subscription Offers: Intro, Promo, Winback
- Intro Offer (First-Time Subscribers)
- Promotional Offer (Existing Subscribers)
- Winback Offer (Lapsed Subscribers)
- Server Notifications V2
- Family Sharing
- Refund Handling
- Refund Request from the App (iOS 15+)
- Refund Notification on the Server
- Testing: Sandbox + TestFlight
- Sandbox Setup
- StoreKit Test in Xcode
- TestFlight
- SwiftUI Paywall Example
- Conclusion
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 // Waiting for family approval or Apple ID verification13 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("First (offer.period.formatted): (offer.displayPrice)")5 // Show in UI: "First 7 days free, then $9.99/month"6}Promotional Offer (Existing Subscribers)
1// 1. Generate signature on server (offer defined in App Store Connect)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("This purchase came via family sharing")6 } else {7 print("User purchased it themselves")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+ official Apple 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("Upgrade to Pro")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("Purchase")25 .frame(maxWidth: .infinity)26 .padding()27 }28 .buttonStyle(.borderedProminent)29 30 Text("Cancel anytime")31 .font(.caption)32 .foregroundStyle(.secondary)33 }34 .padding()35 .task { try? await store.loadProducts() }36 }37}GOLDEN TIP
The most valuable insight in this article
This tip holds the article's most important takeaway.
Easter Egg
You found a hidden gem!
There's a hidden detail in this section. Want to uncover it?
Reader Reward
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:
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, Async/Await Best Practices (in Turkish), Swift 6 Concurrency (in Turkish)._
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

