How does StoreKit 2 work: products, purchases, transaction verification, entitlements, refunds, and what must a server do?
Predict first
A purchase returns a verified result for a non-consumable, but the app only sets a local flag and never listens to transaction updates. What goes wrong?
// A: nothing, the flag is enough
// B: refunds, revocations and purchases made elsewhere are never seen, so access can stay wrong
// C: the purchase is charged twice
- A
- B
- C
Mental model. A StoreKit transaction is a notarised sales slip. Your app checks the notary’s stamp (verification), hands over the goods, and files the slip (finish). A server keeps its own ledger from the notary’s mailed copies (server notifications), so refunds and renewals still reach it when the app is closed.
StoreKit 2 replaces the old receipt-based flow with a Swift-concurrency API and signed JWS transactions. You define products in App Store Connect (or a local .storekit configuration file for testing): consumables (coins), non-consumables (a lifetime unlock), auto-renewable subscriptions (with groups, introductory offers and renewal) and non-renewing subscriptions. In code: Product.products(for: ids) fetches localised prices (always display displayPrice, never format your own), product.purchase() returns a result (.success(verification), .userCancelled, .pending for Ask to Buy or SCA), and the success value is a VerificationResult: unwrap it to check the signature is valid (.verified(transaction) versus .unverified), deliver the content, then call await transaction.finish() (an unfinished transaction is redelivered). Listen to Transaction.updates from app launch, because purchases and renewals can complete outside your purchase call (a family member’s approval, a renewal, a refund, a purchase made on another device). Determine what the user owns from Transaction.currentEntitlements, which lists the currently valid non-consumables and subscriptions (already filtered for expiry and revocation), and restore on a new device with AppStore.sync(), which is usually unnecessary because entitlements sync automatically. Handle refunds and revocation: a refunded or revoked transaction has a revocationDate and disappears from entitlements; observe Transaction.updates to remove access, and offer beginRefundRequest in-app.
On-device versus server: client-side verification is the baseline and works offline, but anything of real value (digital goods with a server cost, cross-platform accounts, fraud-sensitive entitlements) needs the server. Send the signed transaction (or its jwsRepresentation) to your backend, verify the JWS chain to Apple’s root, and use the App Store Server API (look up transaction history, subscription status) and App Store Server Notifications V2 (signed webhooks for renewals, refunds, billing retry, grace period, expiry) so the server tracks state even when the app is closed. Tie purchases to your user with an appAccountToken (a UUID you supply at purchase time, returned in the transaction), so the server knows whose purchase it is. Design rules: grant access from a verified entitlement, never from the purchase call alone; make delivery idempotent (a transaction id is unique, so record it); handle pending and cancelled states without errors; keep consumable balances on your server; test with the local StoreKit configuration, the sandbox, and TestFlight, including refunds and renewals; and follow the guideline requirements (a restore mechanism, clear price and renewal terms, and no steering that App Review forbids). The legacy appStoreReceiptURL receipt path still exists for older systems but should not be the basis of new code.
Entitlements change outside the purchase call. Listening to Transaction.updates and reading currentEntitlements keeps access correct.
Common traps
- Granting content without verifying the transaction signature. Only act on
.verifiedresults. - Not listening to
Transaction.updatesfrom launch. Renewals, refunds and Ask to Buy approvals are missed until the next purchase call.
They might follow up with
- How do you know what a user currently owns?
Transaction.currentEntitlements, which excludes expired and revoked items. - Why do you need a server? To verify signed transactions independently and track renewals and refunds through server notifications.
Related questions
- 17.4.1 How does CloudKit work: containers, databases, zones, records, subscriptions, CKShare and sync?
- 17.4.3 How do Apple Pay and Wallet passes work with PassKit: payment requests, merchant capabilities, tokens and…
← How does CloudKit work: containers, databases, zones, records… How do Apple Pay and Wallet passes work with PassKit: payment… →