All Articles
CategoryiOS
Reading Time
22 min read
Published
2026-04-18
Word Count
1,629words

Grab a coffee — this one is a deep dive!

StoreKit 2 Production Guide: Modern IAP with Async/Await

Summary

A complete guide to StoreKit 2: the modern async/await purchase flow, JWS receipt validation, subscription offers, family sharing and server-side validation.

  • StoreKit 2 uses async/await, JWS-based verification and type-safe Product/Transaction APIs.
  • After a purchase, the JWS should be decoded on the server with the App Store Server API and its ECDSA signature chain verified.
  • With iOS 18+, winback offers can re-target subscribers who cancelled, directly from inside the app.
  • Entitlements should be granted only from a server-verified transaction; client-side verification exists for UX only.
StoreKit 2 Production Guide: Modern IAP with Async/Await

# 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

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

swift
1import StoreKit
2 
3@Observable
4final 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

swift
1struct Product {
2 let id: String
3 let displayName: String
4 let description: String
5 let price: Decimal
6 let displayPrice: String // "$9.99" localized
7 let type: ProductType // .consumable, .nonConsumable, .nonRenewable, .autoRenewable
8 let subscription: SubscriptionInfo?
9}

Purchase Flow: Modern async/await

swift
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 bekleniyor
13 break
14 
15 case .userCancelled:
16 break
17 
18 @unknown default:
19 break
20 }
21 }
22 
23 func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
24 switch result {
25 case .unverified(_, let error):
26 throw error
27 case .verified(let safe):
28 return safe
29 }
30 }
31}

Transaction Updates Listener

swift
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

typescript
1// Node.js server
2import 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 chain
10 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 signature
16 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:

typescript
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)

swift
1let offer = product.subscription?.introductoryOffer
2 
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)

swift
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.id
5)
6 
7// 2. Purchase with offer
8let 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.timestamp
15 )
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:

swift
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:

typescript
1// Express webhook handler
2app.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

swift
1let purchases = Transaction.currentEntitlements
2for 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+)

swift
1// iOS 15+ Apple resmi refund UI
2@Environment(\.requestReview) var requestReview
3@Environment(\.openURL) var openURL
4 
5// Refund request
6if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
7 let result = try await Transaction.beginRefundRequest(
8 for: transactionID,
9 in: windowScene
10 )
11}

Refund Notification on the Server

typescript
1case 'REFUND':
2 // notification.data.signedRenewalInfo → revoked
3 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

  1. Create a Sandbox Tester: App Store Connect > Users and Access > Sandbox Testers
  2. Settings > App Store > Sandbox Account: Sign in with the test user
  3. Purchase in the app: Sandbox mode is detected automatically

StoreKit Test in Xcode

The .storekit file — a test catalog inside Xcode:

json
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

swift
1struct PaywallView: View {
2 @Environment(StoreVM.self) private var store
3 @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 in
11 ProductCard(
12 product: product,
13 isSelected: selectedProduct?.id == product.id
14 )
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#Swift#StoreKit 2#IAP#Subscription#App Store#JWS
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

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.

Share