On this page 10 sections
- What StoreKit 2 changed, in practice
- Product types and how to model entitlements
- Fetching products and making a purchase
- Listening for transactions and finishing them
- Subscriptions, grace periods and family sharing
- Testing with StoreKit configuration files and TestFlight
- Receipts, App Store Server API, and what a small app can skip
- Review, privacy and the paid-apps agreement
- Key takeaways
- Frequently asked questions
This is an implementation guide, not a launch-process walkthrough or a pricing essay. For the wider release steps see from build to App Store; for whether a purchase is even the right business model, see what an iOS app really costs. Product IDs, agreements and review still have to be in place before any of the code below matters.
What StoreKit 2 changed, in practice
StoreKit 1 centred on SKPaymentQueue, receipts you had to parse yourself, and observers you could miss if they were not registered at launch. StoreKit 2, available from iOS 15, changes the daily work in a few concrete ways:
- Products are async values.
Product.products(for:)returns typed metadata, including the display price for the user’s storefront, so you do not hard-code a pound sign in a button. - Transactions are signed. Each one is a JWS you verify. The payload is something you can inspect with a JWT decoder when you are debugging a fixture; in the app you use
VerificationResultrather than parsing by hand. - Entitlements are a query,
Transaction.currentEntitlements, not a receipt field you cache once. Restore, family sharing, and a purchase made on another device are the same query after StoreKit has refreshed. - You finish transactions yourself with
transaction.finish(), once your app has granted the entitlement or recorded what it needs. Unfinished transactions will reappear. That is a feature, not a nuisance. - Updates are a sequence.
Transaction.updatesdelivers renewals, revokes and purchases that happened outside the current session. You start listening at launch, not only on the paywall screen.
You can still use some StoreKit 1 types, but a new indie app should not. If you are migrating, move one product at a time and do not finish the same transaction in both APIs.
Product types and how to model entitlements
Create products in App Store Connect, and use the same identifiers in a StoreKit configuration file for local testing. The types you will actually choose between are:
- Consumable. Coins, a pack of exports, a one-off credit. Each purchase is a new transaction. You must grant the credit and finish the transaction;
currentEntitlementsdoes not keep a running balance for you. - Non-consumable. A lifetime unlock, a skin pack. Once purchased, it should appear in
currentEntitlementsuntil a refund or revoke. - Auto-renewable subscription. Access for a period, with renewals arriving as new transactions. This is where grace periods, billing retry and family sharing show up.
- Non-renewing subscription. A fixed period without automatic renewal. Less common, and easy to model wrongly if you pretend it is auto-renewing.
Model your own entitlement as something derived, not as a UserDefaults flag set in the purchase button:
import Foundation
import StoreKit
enum Entitlement: Hashable {
case pro
case extraExportPacks(Int)
}
struct EntitlementState: Equatable {
var hasPro = false
var exportCredits = 0
}
func entitlements(from transactions: [Transaction], storedCredits: Int) -> EntitlementState {
var state = EntitlementState(exportCredits: storedCredits)
for transaction in transactions {
switch transaction.productID {
case "uk.leej.example.pro":
state.hasPro = true
default:
break
}
}
return state
}Consumable credits still need storage you own, because the finished transaction will not sit in currentEntitlements as a count. Keep that storage on the device (or on your server if you have one), and update it only after a verified, unfinished transaction for that product. Non-consumables and subscriptions should be re-derived every time you iterate current entitlements, so a refund can take Pro away.
Keep product IDs in one list the app and the configuration file share. A typo will not crash; it will just return an empty product list, which looks like a storefront failure.
Fetching products and making a purchase
Load products when you show the paywall, and handle an empty list as a first-class state — not as a blank screen with a frozen button.
import StoreKit
final class Store {
let productIDs: [String] = [
"uk.leej.example.pro",
"uk.leej.example.exports.10"
]
func loadProducts() async throws -> [Product] {
let products = try await Product.products(for: productIDs)
return products.sorted { $0.displayName < $1.displayName }
}
func purchase(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await apply(transaction)
await transaction.finish()
return transaction
case .userCancelled:
return nil
case .pending:
// Ask to Buy, or a deferred payment. Do not grant yet.
return nil
@unknown default:
return nil
}
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw error
case .verified(let value):
return value
}
}
}Product.purchase() presents the system sheet. You do not style that sheet, and you should not try to replace it. Use product.displayPrice on your own button so the price the user sees before the sheet matches the storefront.
A few purchase options exist (app account tokens, quantity for consumables, promotional offers). For a first version, skip promotions and quantity until the basic success, cancel and pending paths are tested. If you do pass an appAccountToken, use a UUID you can recognise later when a server notification arrives — do not invent a format you cannot parse.
Always finish after you have applied the entitlement. If apply can fail, do not finish, so StoreKit will offer the transaction again. If apply is a local flag flip and cannot reasonably fail, finish immediately afterwards.
Listening for transactions and finishing them
If you only handle the purchase() return value, you will miss renewals, Family Sharing, purchases from another device, Ask to Buy approvals, and anything that finished in a previous session but was not finish()ed. Start a listener at launch, and run it for the life of the process.
func listenForTransactions() -> Task<Void, Never> {
Task.detached {
for await update in Transaction.updates {
do {
let transaction = try self.checkVerified(update)
await self.apply(transaction)
await transaction.finish()
} catch {
// Leave unfinished so it can be retried. Log the verification failure.
}
}
}
}On launch, also iterate Transaction.currentEntitlements and Transaction.unfinished, apply, and finish the unfinished ones you have successfully applied. That covers a crash between grant and finish().
Verification can fail. Treat .unverified as “do not grant”. Do not try to “make do” with the unverified payload just because it looks right in a decoder. If you are curious about a test JWS, paste it into the JWT decoder or check a signature with the JWT / JWKS verifier; that is a debugging aid, not a substitute for VerificationResult in the app.
apply should be idempotent. A transaction id you have already seen should not add a second pile of credits. Store the processed transaction ids for consumables, and let non-consumables simply refresh from current entitlements.
Subscriptions, grace periods and family sharing
For auto-renewable subscriptions, current entitlements tell you whether there is a transaction that still entitles the user. For the richer status — billing retry, grace period, expired, revoked — use Product.subscription and Product.SubscriptionInfo.status.
A practical policy for a small app:
- In grace period or billing retry, keep access on, and optionally say that billing needs attention. Cutting a user off on the first failed renewal is harsher than Apple’s own guidance, and it creates support mail.
- Expired or revoked, remove access the next time you recompute entitlements. Do not wait for a special “expiry timer” you built yourself if current entitlements already dropped the transaction.
- Family Sharing, if you enabled it for the product, will surface transactions with an ownership type for a family member. If the feature should be shared, honour those entitlements the same way as the purchaser’s. If it should not be shared, do not enable Family Sharing on the product.
Renewals arrive through Transaction.updates. You do not schedule them. If you need to explain to yourself when a test subscription will renew in a local StoreKit file, you can shorten the renewal period in the configuration; do not confuse that with real-world billing. A cron explainer will not drive StoreKit, and you should not invent your own renewal cron on device. The App Store is the scheduler.
Offer codes, promotional offers and win-back offers are layerable later. Each has its own StoreKit 2 API and its own App Store Connect setup. Get the ordinary subscribe, renew, expire and restore paths right first.
Testing with StoreKit configuration files and TestFlight
Test in this order: a local StoreKit configuration file in Xcode, then a sandbox Apple ID on a device, then TestFlight.
A StoreKit configuration file sits in the project and lists products, prices, and renewal timings for subscriptions. You can:
- purchase without App Store Connect being complete,
- speed up renewals,
- trigger a refund, a failed renewal, or an ask-to-buy outcome from the Xcode transaction manager,
- inspect the resulting transactions in the debug UI.
Point the scheme at the configuration file for local runs, and take it away for TestFlight and production, where the real storefront is the source of products. If the file is still active, you will not see your live products, which is a common “empty paywall on a device that worked in the simulator” report.
Sandbox and TestFlight use App Store Connect products, a sandbox tester or a TestFlight account, and real product identifiers. Purchases are still not charged as production. You will need the Paid Apps Agreement, banking and tax forms to be in place for sandbox to behave; an incomplete agreement is a frequent reason products fail to load on a device.
Test at least these cases, not only the happy path:
- purchase of each product type you sell,
- cancel at the payment sheet,
- a pending or Ask to Buy result if you might have younger users or Family Sharing,
- relaunch after a purchase, with the listener running, and confirm the entitlement is still there,
- a renewal (local file, short period),
- a refund or revoke (local file), and confirm the entitlement disappears,
- an offline purchase attempt, and a retry when the network returns,
- a fresh install, then a restore / entitlements refresh for a returning customer.
You do not need a screenshot of every case for Review, but you do need the returning-customer path to work.
Receipts, App Store Server API, and what a small app can skip
StoreKit 1 taught everyone to upload the receipt to a server and to verify it with Apple. StoreKit 2 does not require that for a correct client. Signed transactions and currentEntitlements are already the replacement for most of that work on the device.
Add a server when you have something a device-only model cannot do honestly:
- Consumables that must not be replayed across devices or reinstalls you do not trust.
- Your own account system, where a purchase should unlock a web app or a second platform.
- App Store Server Notifications, so you hear about refunds and revokes even if the user never opens the app again.
The App Store Server API and server notifications v2 send signed payloads. If you go down that road, verify them properly (the JWKS verifier on this site is a way to understand the shape of that job) and treat the payloads as the source of truth for account-level entitlements. Do not half-verify a JWS and store it.
A small utility that unlocks a feature on the same device can skip all of this. Skipping it is not a shortcut if you do not have an account system; it is the appropriate design. You can add a server later without changing product IDs.
AppTransaction.shared exists for the signed app transaction — useful if you need to know the original purchase date of a paid app, or to reason about a bundle — and is separate from in-app entitlements. Most freemium apps will not need it on day one.
Review, privacy and the paid-apps agreement
None of the client code ships until the business side is in place. Apple’s App Review Guidelines, especially the payments section, and the Paid Apps agreement in App Store Connect are the source of truth; both can change, so read them again before you submit.
A short list of things that regularly hold up a first in-app purchase, in my experience:
- The Paid Apps Agreement, banking and tax are incomplete, so products do not load, or Review cannot purchase.
- The paywall does not use the system purchase flow, or it looks like it might charge outside the app for digital features. If StoreKit is the way the feature is sold, make that obvious.
- Subscription terms are unclear. Duration, price, auto-renewal and how to cancel must be clear. Follow the current subscription guideline rather than inventing your own wording from memory.
- There is no way for a returning user to get their purchase back. With StoreKit 2, a “Restore purchases” control that refreshes entitlements is still worth putting on the paywall, even though
currentEntitlementsand the listener do most of the work. Review and users both look for it. - The privacy nutrition labels and your privacy manifest disagree with what you actually collect at purchase, or you added tracking around the paywall without the right declarations. Purchasing through StoreKit does not, by itself, require a photo or location permission; do not request one.
- The product in Connect does not match the binary — wrong IDs, a subscription group you did not mean, a product still in a missing metadata state.
Write a Review note that says which sandbox account to use, which button starts the purchase, and whether a configuration file is only for local builds (it should not be in the Review path). Keep the note factual. The rest of a clean submission is the same launch discipline as any other release.
Key takeaways
- Fetch
Productvalues, purchase through the system sheet, and displaydisplayPricerather than a price you typed into the view. - Derive non-consumable and subscription access from verified
Transaction.currentEntitlements. Start listening toTransaction.updatesat launch, and finish every transaction you have applied. - Store consumable balances yourself, keyed so you cannot apply the same transaction twice.
- Test with a StoreKit configuration file first, then sandbox or TestFlight, including cancel, pending, renewal, refund and a fresh install.
- A small device-only app can skip the App Store Server API. Add a server when you have accounts, cross-device consumables, or a need for refund notifications while the app is closed.
- The Paid Apps Agreement, clear subscription wording, a restore control, and consistent privacy declarations matter as much as the Swift.
If you want help wiring a first StoreKit 2 paywall in a small app, tell me about it.
Frequently asked questions
What is the difference between StoreKit 1 and StoreKit 2?
StoreKit 2, from iOS 15, uses async Product and Transaction APIs, signed transactions, Transaction.currentEntitlements and Transaction.updates, instead of SKPaymentQueue and manual receipt parsing. New work should use StoreKit 2. You do not need to ship both.
How do you test in-app purchases in Xcode?
Add a StoreKit configuration file to the project, list the same product IDs you will use in App Store Connect, and point your scheme at it. You can purchase, renew, refund and fail billing locally with the Xcode transaction UI, without a sandbox Apple ID. Remove the configuration for TestFlight and production so the real storefront is used.
How do you unlock features after a StoreKit 2 purchase?
Verify the transaction, grant the entitlement, then call finish(). For a lifetime or subscription product, also recompute access from Transaction.currentEntitlements on launch and whenever updates arrive, so a refund or a purchase on another device is reflected. Do not rely only on a flag set in the button handler.
How do you handle subscription renewals and expiration?
Renewals arrive on Transaction.updates. Recompute entitlements from currentEntitlements, and use Product.SubscriptionInfo.status if you need grace period or billing-retry detail. Keep access on during a grace period if you can; remove it when the entitlement is no longer current. Do not schedule renewals yourself.
What does App Review check for in-app purchases?
They try the purchase, and they look for a complete Paid Apps Agreement, honest prices and subscription terms, a way to restore, and privacy disclosures that match the app. They also catch digital goods that should use StoreKit and do not. Read the current payments section of the App Review Guidelines before you submit, and include a sandbox account in your Review notes.
Written by Lee Johnson. Published 10 November 2026.