The thing worth saying upfront about StoreKit 2 is that it isn't a friendlier surface over the original StoreKit. It's a separate API that Apple introduced alongside the older one, built from the ground up around Swift concurrency and signed transactions. If you have an app on the old API and you want to move to the new one, that's a rewrite of the purchase layer, not a migration in the usual sense. We've done this on our own apps at Olnesta Ltd and the mental shift is bigger than the line count suggests.
The old flow was delegate-shaped. You asked SKProductsRequest for products, a delegate method fired later with the results, and transactions arrived through SKPaymentTransactionObserver. State lived in callback methods scattered across a controller, and if you wanted to reason about what the user currently owned, you were reading the app receipt and (usually) sending it to a server to be validated.
StoreKit 2 replaces essentially all of that. Product.products(for:) is an async call that returns the products directly. Transaction.currentEntitlements is an async sequence you iterate to find out what the user is entitled to right now, without asking a server anything. Each transaction arrives as a signed JWS payload, and StoreKit itself will verify the signature for you and hand you back a VerificationResult telling you whether it trusted the payload. For a lot of small apps, that's genuinely enough: you check the verification result, look at the product ID and expiration date on the transaction, and grant access.
What the listener is actually for
The piece we see people trip over most often is Transaction.updates. It's an async sequence that has to be consumed for the lifetime of the app, started as early as you reasonably can in the launch path, and never cancelled. Its job is to catch transactions that didn't originate from a purchase your UI just triggered: renewals of an auto-renewable subscription, refunds issued through Apple support, family sharing changes, and purchases the user made on another device that are now propagating to this one.
If that listener isn't running, those events don't get lost forever, but they can arrive late or only after the next cold start, which shows up as users who paid on their iPad and don't immediately see their entitlement on their iPhone. The pattern we've settled on is a small actor or singleton that spins up the Transaction.updates task during app initialization and holds onto it. It doesn't need much logic inside, mostly it re-checks entitlements and calls finish() on the transaction once the app has acted on it.
Calling finish() matters. An unfinished transaction will keep being redelivered. That's a feature, not a bug, because it means a crash between "user paid" and "we granted access" won't lose the purchase, but it also means you should only finish a transaction after your app has actually recorded the entitlement somewhere durable.
Local verification versus a server
One of the quieter wins of StoreKit 2 is that local verification is enough for a lot of apps. The JWS payload is signed by Apple, StoreKit checks the signature, and you can read the fields you care about directly. If your app is a straightforward one-time unlock or a single subscription and the entitlement only needs to be known on-device, you can genuinely skip running a receipt-validation server.
We still reach for a server when there's cross-device state that isn't a StoreKit entitlement (a synced library, a shared account), when we want to detect refunds server-side to revoke access to something hosted, or when the business logic around a subscription is more than "is it active right now." Apple's App Store Server Notifications and the App Store Server API are the pieces to look at for that side of things; they're a separate topic from the client API but they're what a server-side implementation actually leans on now, rather than the older receipt-validation endpoint.
What to expect if you're rewriting
In our experience the rewrite itself is smaller than the surrounding work. The actual purchase and restore code shrinks noticeably. What takes the time is deciding where entitlement state lives in your app, making sure the update listener is genuinely running from launch, and testing the awkward paths: interrupted purchases, ask-to-buy, refunds, and a user who bought on device A and opens device B for the first time.
StoreKit's testing tools in Xcode are worth the setup time here. A local StoreKit configuration file lets you simulate renewals, refunds, and revocations without waiting on the sandbox environment, which is the fastest way we've found to convince ourselves the listener is doing what we think it is before a build goes anywhere near App Review.