StoreKit Entitlements: currentEntitlements vs updates
StoreKit 2 has two transaction sequences that are easy to blur together:
Transaction.currentEntitlementsTransaction.updates
They are both async sequences. They both hand you VerificationResult<Transaction> values. They are both involved in unlocking paid content.
But they solve different problems.
currentEntitlements answers “what does this customer have access to right now?” updates answers “what changed while my app is alive?” A production app usually wants both: read the current state at launch, and keep a listener running so purchases, renewals, revocations, and pending approvals do not drift away from your UI.
Apple’s docs for currentEntitlements, updates, VerificationResult, and finish() are worth keeping open while you build this. Here is the small shape I like to start with.
These StoreKit 2 APIs require iOS 15, iPadOS 15, macOS 12, tvOS 15, watchOS 8, or newer.
The entitlement store
This example is for non-consumables and auto-renewable subscriptions. Consumables are usually a different model because the customer is not entitled to the consumable forever; you normally track a balance, credits, server state, or some other app-specific inventory. Non-renewing subscriptions also need their own expiration policy because your app is responsible for calculating the active service period.
import Combine
import Foundation
import StoreKit
enum PremiumProducts {
static let monthly = "com.example.app.pro.monthly"
static let yearly = "com.example.app.pro.yearly"
static let lifetime = "com.example.app.pro.lifetime"
static let all: Set<String> = [
monthly,
yearly,
lifetime
]
}
@MainActor
final class EntitlementStore: ObservableObject {
@Published private(set) var activeProductIDs: Set<String> = []
@Published private(set) var isLoading = true
@Published private(set) var verificationError: Error?
private let productIDs: Set<String>
private var updatesTask: Task<Void, Never>?
private var hasLoaded = false
var hasPro: Bool {
!activeProductIDs.isDisjoint(with: productIDs)
}
init(productIDs: Set<String>) {
self.productIDs = productIDs
updatesTask = listenForTransactionUpdates()
}
deinit {
updatesTask?.cancel()
}
func load() async {
guard !hasLoaded else { return }
hasLoaded = true
await refreshCurrentEntitlements()
}
func refreshCurrentEntitlements() async {
verificationError = nil
var activeIDs = Set<String>()
for await result in Transaction.currentEntitlements {
do {
let transaction = try checkVerified(result)
guard productIDs.contains(transaction.productID) else {
continue
}
if transaction.isActiveEntitlement {
activeIDs.insert(transaction.productID)
}
} catch {
verificationError = error
}
}
activeProductIDs = activeIDs
isLoading = false
}
func purchase(_ product: Product) async throws {
verificationError = nil
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
let transaction = try checkVerified(verificationResult)
apply(transaction)
await transaction.finish()
case .pending:
break
case .userCancelled:
break
@unknown default:
break
}
}
func restorePurchases() async throws {
try await AppStore.sync()
await refreshCurrentEntitlements()
}
private func listenForTransactionUpdates() -> Task<Void, Never> {
Task(priority: .background) { [weak self] in
for await result in Transaction.updates {
await self?.handle(transactionUpdate: result)
}
}
}
private func handle(
transactionUpdate result: VerificationResult<Transaction>
) async {
do {
let transaction = try checkVerified(result)
guard productIDs.contains(transaction.productID) else {
return
}
await refreshCurrentEntitlements()
await transaction.finish()
} catch {
verificationError = error
}
}
private func apply(_ transaction: Transaction) {
guard productIDs.contains(transaction.productID) else {
return
}
if transaction.isActiveEntitlement {
activeProductIDs.insert(transaction.productID)
} else {
activeProductIDs.remove(transaction.productID)
}
isLoading = false
}
private func checkVerified<T>(
_ result: VerificationResult<T>
) throws -> T {
switch result {
case .verified(let safe):
return safe
case .unverified(_, let error):
throw error
}
}
}
private extension Transaction {
var isActiveEntitlement: Bool {
guard revocationDate == nil else { return false }
guard !isUpgraded else { return false }
return true
}
}
There is a lot of app policy hidden in a small amount of code here, so let’s walk through the important pieces.
Read current state at launch
Transaction.currentEntitlements is the “tell me what to unlock” pass. StoreKit returns the latest transactions that currently entitle the customer to products, including non-consumables and auto-renewable subscriptions that are subscribed or in billing grace period. Refunded or revoked products do not appear there, and consumables do not appear there.
That makes it a good fit for launch loading. When your app starts, your UI should not guess whether the user is premium based on a stale local flag. Ask StoreKit, verify each transaction, then replace your local in-memory entitlement set.
The important detail is that refresh rebuilds the set from StoreKit’s current answer instead of merging it into the old value. That avoids the classic bug where an app only ever adds product IDs locally and never removes them after an expiration, refund, upgrade, or other entitlement change. It also gives you one predictable place to clear loading state, clear old verification errors, and make the UI switch from “checking purchases” to the real app or paywall.
I like keeping the launch load idempotent with a tiny hasLoaded flag. SwiftUI’s .task can run again if the root view is recreated, and repeated entitlement refreshes are not usually dangerous, but launch code is easier to reason about when “first load” and “manual refresh” are separate actions. In the sample, load() runs once, while refreshCurrentEntitlements() can still be called after a Restore Purchases button.
Listen as soon as possible
Transaction.updates is not a replacement for loading current entitlements. It is the long-lived listener. Apple describes it as the sequence for transaction changes that happen outside the app, on other devices, or while your app is running.
It also matters at launch. If the app has unfinished transactions, the updates listener can receive them shortly after launch. That is why the store starts updatesTask in init, before the SwiftUI view kicks off the load() call.
Keep that task alive for as long as your entitlement store is alive. In a SwiftUI app, that usually means one store owned near the app root, not one listener per paywall screen. Multiple short-lived listeners make it much easier to accidentally cancel the task that was supposed to receive an important update.
The listener should be boring infrastructure. It should verify the transaction, update your entitlement state, finish the transaction after delivery, and then go back to waiting. Avoid putting paywall presentation, navigation, or analytics-heavy branching directly in the listener. Those things can observe the entitlement store instead.
Verify before trusting
StoreKit wraps transactions in VerificationResult. Your app should only unlock content from verified transactions. The unverified case still includes a transaction value, but it failed automatic verification, so do not use it to grant access.
For many apps, StoreKit’s on-device verification is the right first layer. If your business model needs server-side validation, account syncing, or cross-platform access, send the transaction’s signed jwsRepresentation to your server and make the server the source of truth. The client-side pattern still stays useful because the UI can react immediately while your backend catches up.
One practical habit: log the verification error, but do not silently unlock from unsafePayloadValue just to make a support case disappear. If verification fails, the safe behavior is to leave access unchanged or locked, then give the user a retry path.
Finish only after delivery
Finishing a transaction tells the App Store that your app delivered the product or enabled the service. In this store, “delivery” is updating the entitlement set or refreshing it from StoreKit’s current answer.
That is why purchase(_:) calls finish() after apply(_:), and the update listener calls finish() after it refreshes currentEntitlements.
Do not finish pending purchases, cancelled purchases, or unverified transactions. Also, do not rely on Transaction.updates to handle a successful purchase made on the same device. After product.purchase() succeeds, StoreKit gives you the transaction in the purchase result, so process and finish it right there.
For purchases that become approved later, such as a pending purchase that completes outside your immediate button tap, the update listener is the path that catches up. That is another reason the listener belongs at app scope instead of inside the paywall view that happened to start the purchase.
Handle expiration and revocation
The isActiveEntitlement helper is intentionally conservative, but notice what it does not do: it does not deny access just because expirationDate is in the past.
At a high level:
revocationDatemeans the transaction should no longer unlock the product.isUpgradedhelps avoid granting access for a lower subscription tier after the customer moved to a higher one.expirationDateis useful context, but it is not enough for auto-renewable subscription policy by itself.
That last point matters. A subscription in billing grace period may still be entitled to service, and Transaction.currentEntitlements is the safer source for “what should be unlocked right now” than a local expiration-date check. That is why the update listener above refreshes currentEntitlements after a verified transaction update instead of trying to infer the entire subscription state from one transaction.
Real subscription apps may also look at Product.SubscriptionInfo.Status, renewal state, grace periods, billing retry state, product tiers, server notifications, and account-level rules. Keep this helper small, but make the policy explicit so it is easy to test.
If you sell non-renewing subscriptions, add a separate branch for those product IDs and calculate the active window from the original purchase date plus the duration you sold. Do not use the auto-renewable shortcut above for them.
Put it in the SwiftUI environment
Create one entitlement store near the app root and put it in the environment:
import SwiftUI
@main
struct ExampleApp: App {
@StateObject private var entitlements = EntitlementStore(
productIDs: PremiumProducts.all
)
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(entitlements)
.task {
await entitlements.load()
}
}
}
}
Then read it anywhere that needs to gate UI:
struct RootView: View {
@EnvironmentObject private var entitlements: EntitlementStore
var body: some View {
if entitlements.isLoading {
ProgressView()
} else if entitlements.hasPro {
ProHomeView()
} else {
PaywallView()
}
}
}
Your paywall can use the same store for purchases:
struct ProductRow: View {
@EnvironmentObject private var entitlements: EntitlementStore
let product: Product
var body: some View {
Button(product.displayName) {
Task {
try await entitlements.purchase(product)
}
}
}
}
In a real paywall, add progress, error handling, and duplicate-tap protection.
Practical wrap-up
Use currentEntitlements to rebuild what the customer owns now. Start a long-lived Transaction.updates task early and keep it alive. Verify every transaction before trusting it. Finish transactions only after your app has delivered access. Treat revocations, expirations, and upgrades as removal paths, not just edge cases.
That gives you a small, testable entitlement layer instead of scattered paywall state, and it leaves room for server validation or more advanced subscription policy later.