Implement StoreKit 2 in-app purchases, subscriptions, consumables, and backend integration. Use when working with IAP, App Store purchases, transaction handling, JWS verification, subscription management, promotional offers, or migrating from StoreKit 1.
Expert guidance for implementing StoreKit 2 with custom backend integration for subscriptions, consumables, and rentals.
ProductView, StoreView, SubscriptionStoreView)| Type | Restores | Family Sharing | Use Case |
|---|---|---|---|
| Consumable | No | No | Coins, tips, one-time rentals |
| Non-Consumable | Yes | Opt-in | Permanent unlocks, ad removal |
| Auto-Renewable | Yes | Opt-in |
| Streaming subscriptions |
| Non-Renewing | Yes | No | Season passes, time-limited access |
| Field | Description | Use For |
|---|---|---|
transaction.id | Unique per transaction | Logging, deduplication |
transaction.originalID | Stable across renewals | Database linking |
Always use originalID for database operations.
import StoreKit
@MainActor
final class StoreManager: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
private var updateListenerTask: Task<Void, Error>?
init() {
// CRITICAL: Start listener at launch
updateListenerTask = listenForTransactions()
Task { await updatePurchasedProducts() }
}
deinit { updateListenerTask?.cancel() }
private func listenForTransactions() -> Task<Void, Error> {
Task {
for await result in Transaction.updates {
guard !Task.isCancelled else { break }
do {
let transaction = try checkVerified(result)
if transaction.revocationDate != nil {
purchasedProductIDs.remove(transaction.productID)
await transaction.finish()
continue
}
try await sendJWSToBackend(result.jwsRepresentation)
await updatePurchasedProducts()
await transaction.finish() // Only after backend confirms
} catch { /* Don't finish - will retry */ }
}
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error): throw error
case .verified(let safe): return safe
}
}
}
func purchase(_ product: Product, userUUID: UUID) async throws -> Transaction? {
let result = try await product.purchase(options: [.appAccountToken(userUUID)])
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
guard transaction.revocationDate == nil else { throw PurchaseError.revoked }
try await sendJWSToBackend(verification.jwsRepresentation)
await transaction.finish() // Only after backend confirms
return transaction
case .pending:
throw PurchaseError.pending // Will come through Transaction.updates
case .userCancelled:
return nil
@unknown default:
return nil
}
}
environment field matches expected (Production/Sandbox)originalTransactionID for database operationsrevocationDate before granting accessappAccountToken - Links purchases to your usersTransaction.updates at app launch - Catches renewals, Family Sharing, Ask to BuyrevocationDate - Handles refunds and Family Sharing removaloriginalID not id - Stable across subscription renewals| StoreKit 1 | StoreKit 2 |
|---|---|
SKPaymentTransactionObserver | Transaction.updates async sequence |
SKReceiptRefreshRequest | AppStore.sync() |
/verifyReceipt endpoint | JWS verification or App Store Server API |
SKProductsRequest | Product.products(for:) async |
SKPaymentQueue.add() | product.purchase() async |