# Verifying StoreKit 2 transactions > VerificationResult, current entitlements, the Transaction.updates listener, finish(), appAccountToken, revocation and environment, and checking the signed JWS on your server. - Canonical page: https://obelisk.club/blog/storekit-2-transaction-verification - Author: Peter Zhang - Published: 2026-11-01 - Tags: storekit, in-app-purchase, app-store-server-api, subscriptions, swift, ios, indie StoreKit 2 hands you every transaction wrapped in a `VerificationResult`. Unlock content only from `.verified`, never from `.unverified`; start a `Transaction.updates` loop when the app launches so purchases made elsewhere are not missed; read `Transaction.currentEntitlements` to learn what the customer owns now; and call `finish()` only after you delivered what they paid for. Apple says StoreKit checks the values it gives you, and that for the most control you can verify the signed data yourself, on the device or on your server, using the JWS string in `jwsRepresentation`. On a server, Apple points to its App Store Server Library, which checks the signature, the certificate chain, the bundle ID and the environment for you. Before you act on a transaction, also look at its `revocationDate`, its `appAccountToken` and its `environment`. This page is the verification side. For refunds that arrive as server notifications, read [App Store refunds and consumption requests](/blog/app-store-refunds-consumption-requests). To look at a signed string while debugging, paste it into the [App Store JWS decoder](/tools/app-store-jws-decoder), and to test purchases locally, open your `.storekit` file in the [StoreKit configuration viewer](/tools/storekit-config-viewer). ## On the device: VerificationResult `VerificationResult` is a frozen enum with two cases: - `.verified(SignedType)`: the value passed StoreKit's checks. The associated value is the App Store-signed value. - `.unverified(SignedType, VerificationError)`: the value failed those checks. You still get the signed value, plus the reason. Apple's `VerificationError` cases are `invalidCertificateChain` (a certificate in the chain is expired or untrusted), `invalidDeviceVerification` (the signed value was not generated for this device), `invalidEncoding`, `invalidSignature` (the signature does not match the header and payload), `missingRequiredProperties` and `revokedCertificate` (Apple notes this can sometimes resolve if you try again later). What to do with `.unverified` is your call; Apple's samples say to "handle unverified transactions based on your business model". For a paid unlock, the safe choice is to grant nothing and log the error. The `payloadValue` property throws for unverified values, while `unsafePayloadValue` returns the data whatever the result; Apple says not to trust that one and to use it for debugging. ## The three places transactions come from 1. **The purchase call.** `product.purchase(options:)` returns a `PurchaseResult`: `.success` with a `VerificationResult`, `.pending` (the customer must act, for example Ask to Buy; if it completes it arrives through `Transaction.updates`) or `.userCancelled`. 2. **`Transaction.currentEntitlements`**, a sequence of the latest transaction for each product the customer is entitled to. Apple lists: each non-consumable in-app purchase; the latest transaction for each subscription that renews whose renewal state is `subscribed` or `inGracePeriod`; and the latest for each non-renewing subscription, including finished ones. Refunded or revoked products do not appear, and consumables never do (use `unfinished` or `all` for those). 3. **`Transaction.updates`**, for anything that happens outside the purchase call: Ask to Buy approvals, offer code redemptions, purchases made in the App Store, and purchases made on another device. Apple says to create a `Task` that iterates it as soon as the app launches, because unfinished transactions are delivered once, right after launch, and "without the Task to listen for these transactions, your app may miss them." ## finish() `finish()` tells the App Store you delivered the content or enabled the service. Apple's wording: call it after you deliver the purchased content or enable the purchased service; for on-demand resources, wait until the download is complete. A transaction stays in `Transaction.unfinished` until you call it. Call `finish()` after your own state is updated, not before, so a crash in between does not lose a purchase the customer paid for. ## Putting it together This compiles against the iOS 18 SDK with Swift 6.4 (also in Swift 6 language mode). The `@MainActor` class starts the listener in `init`, buys with an `appAccountToken`, rebuilds the unlocked set from entitlements, and finishes only verified transactions: ```swift import Foundation import StoreKit @MainActor final class Store { private(set) var unlocked: Set = [] private var updatesTask: Task? // Start this as soon as the app launches (for example in init of your app model). init() { updatesTask = Task { [weak self] in for await result in Transaction.updates { await self?.handle(result) } } } deinit { updatesTask?.cancel() } func buy(_ product: Product, accountToken: UUID) async throws { let result = try await product.purchase(options: [.appAccountToken(accountToken)]) switch result { case .success(let verification): await handle(verification) case .pending, .userCancelled: break @unknown default: break } } func refreshEntitlements() async { var ids: Set = [] for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result, transaction.revocationDate == nil { ids.insert(transaction.productID) } } unlocked = ids } private func handle(_ result: VerificationResult) async { switch result { case .unverified(_, let error): // Do not unlock. Log the reason. print("unverified:", error) case .verified(let transaction): if transaction.revocationDate != nil { unlocked.remove(transaction.productID) } else { unlocked.insert(transaction.productID) } // Only after the content is delivered: await transaction.finish() } } } ``` We type-checked this and did not run it, because purchases need a StoreKit configuration or a sandbox account. Apple's reference for `purchase(options:)` recommends `PurchaseAction` in SwiftUI and `purchase(confirmIn:options:)` in UIKit and AppKit apps; use those in your UI and keep the handling above. ## What to check on each transaction | Field | What Apple says | What to do | |---|---|---| | `revocationDate` | The date the App Store refunded the transaction or revoked it from Family Sharing. | If it is set, take access away. `currentEntitlements` already leaves these out. | | `appAccountToken` | A UUID you set in the purchase options; the App Store returns the same value in the transaction. It is omitted if you did not provide one. | Compare it to the signed-in user on your server, so a transaction cannot be attached to someone else's account. | | `environment` | The server environment that generated and signed the transaction: `.production`, `.sandbox` or `.xcode` on the device; `Production` or `Sandbox` in server data. | Make your server reject anything that does not match the environment it expects. | | `expirationDate` and `isUpgraded` | The date a subscription expires or renews; `isUpgraded` is true when the customer moved to another subscription. | Do not grant access from an expired or upgraded transaction; Apple's own example skips both. | | `productID` | Your product identifier from App Store Connect. | Match it to what you unlock. | If you need to set or change an app account token for a purchase made outside your app, Apple's server API has a Set App Account Token endpoint. ## On your server: check the JWS yourself The signed string is a JWS in Compact Serialization: three Base64URL parts, header, payload and signature, joined by dots. It is the same format as `JWSTransaction` in the App Store Server API and in App Store Server Notifications V2, where it arrives as `signedTransactionInfo` (and `signedRenewalInfo` for renewal data) inside the notification's `data`. The on-device payload also has `deviceVerification` and `deviceVerificationNonce` fields for checking that the value belongs to the device. Apple's description of the header: `alg` names the signing algorithm and `x5c` carries the certificate chain, in this order: the certificate holding the key that signed the JWS, an Apple intermediate certificate (with the Worldwide Developer Relations extension ID 1.2.840.113635.100.6.2.1), and an Apple root certificate. The root you trust must be one you downloaded from Apple, not the one inside the message: Apple's PKI site lists Apple Root CA - G3, and our decoder checks the chain against it. Our decoder also requires `alg` to be `ES256`. You can write that check yourself, but Apple recommends its open source App Store Server Library, which has Swift, Java, Python and Node versions and the functions `verifyAndDecodeTransaction`, `verifyAndDecodeAppTransaction` and `verifyAndDecodeRenewalInfo`. In the Swift version, `SignedDataVerifier` takes the Apple root certificates, your bundle ID, your numeric app ID (required in production) and the environment, and it rejects a transaction whose bundle ID or environment differs. Its `enableOnlineChecks` option turns on revocation checking and checks expiry against the current date. This builds and runs against App Store Server Library 6.0.0 (it rejects a garbage string, which is all we can test without a real signed transaction): ```swift import Foundation import AppStoreServerLibrary func verifiedTransaction( signedTransaction: String, appleRootCertificates: [Data], bundleId: String, appAppleId: Int64?, environment: AppStoreEnvironment ) async throws -> JWSTransactionDecodedPayload? { let verifier = try SignedDataVerifier( rootCertificates: appleRootCertificates, bundleId: bundleId, appAppleId: appAppleId, environment: environment, enableOnlineChecks: true ) switch await verifier.verifyAndDecodeTransaction(signedTransaction: signedTransaction) { case .valid(let payload): return payload case .invalid: return nil } } func shouldGrantAccess(_ t: JWSTransactionDecodedPayload, expectedUser: UUID) -> Bool { guard t.appAccountToken == expectedUser else { return false } return t.revocationDate == nil } ``` On the device, send `verification.jwsRepresentation` to your server together with the user's session. On the server, verify, compare `appAccountToken` with the user, and then act. For long-running state such as renewals and refunds, rely on notifications rather than only on what the app reports. ## Sources All read on 1 November 2026. - [VerificationResult](https://developer.apple.com/documentation/storekit/verificationresult), its [verified](https://developer.apple.com/documentation/storekit/verificationresult/verified(_:)) and [unverified](https://developer.apple.com/documentation/storekit/verificationresult/unverified(_:_:)) cases, [payloadValue](https://developer.apple.com/documentation/storekit/verificationresult/payloadvalue), [unsafePayloadValue](https://developer.apple.com/documentation/storekit/verificationresult/unsafepayloadvalue) and [VerificationError](https://developer.apple.com/documentation/storekit/verificationresult/verificationerror), StoreKit - [currentEntitlements](https://developer.apple.com/documentation/storekit/transaction/currententitlements), [updates](https://developer.apple.com/documentation/storekit/transaction/updates), [unfinished](https://developer.apple.com/documentation/storekit/transaction/unfinished) and [finish()](https://developer.apple.com/documentation/storekit/transaction/finish()), StoreKit - [appAccountToken](https://developer.apple.com/documentation/storekit/transaction/appaccounttoken), [environment](https://developer.apple.com/documentation/storekit/transaction/environment), [revocationDate](https://developer.apple.com/documentation/storekit/transaction/revocationdate), [AppStore.Environment](https://developer.apple.com/documentation/storekit/appstore/environment) and [isUpgraded](https://developer.apple.com/documentation/storekit/transaction/isupgraded), StoreKit - [purchase(options:)](https://developer.apple.com/documentation/storekit/product/purchase(options:)) and [Product.PurchaseResult](https://developer.apple.com/documentation/storekit/product/purchaseresult), StoreKit - [Simplifying your implementation by using the App Store Server Library](https://developer.apple.com/documentation/appstoreserverapi/simplifying-your-implementation-by-using-the-app-store-server-library), App Store Server API - [JWSTransaction](https://developer.apple.com/documentation/appstoreserverapi/jwstransaction), [JWSTransactionDecodedPayload](https://developer.apple.com/documentation/appstoreserverapi/jwstransactiondecodedpayload) and [JWSDecodedHeader](https://developer.apple.com/documentation/appstoreserverapi/jwsdecodedheader), App Store Server API - [data](https://developer.apple.com/documentation/appstoreservernotifications/data) (signedTransactionInfo), App Store Server Notifications - [Apple PKI](https://www.apple.com/certificateauthority/), root certificates - [app-store-server-library-swift](https://github.com/apple/app-store-server-library-swift), README and `SignedDataVerifier.swift`, Apple on GitHub *Apple's pages and APIs change; check the pages above for current details.*