- StoreKit 2 (iOS 15+) is a Swift-native, async/await rewrite of in-app purchases. Products load with
Product.products(for:), you buy withproduct.purchase(), and every purchase is a signedTransaction— no receipt file to parse. - Every transaction arrives as a
VerificationResult. Always check.verifiedvs.unverifiedbefore granting access — StoreKit validates the App Store's JWS signature for you on device. Transaction.currentEntitlementstells you what the user owns right now;Transaction.updates— listened to at launch — delivers renewals, refunds, and cross-device purchases you would otherwise miss.- The client can be tampered with, so on-device checks are not enough. Your server verifies entitlements independently through the App Store Server API and gets pushed lifecycle events through App Store Server Notifications V2, both authenticated with a P8 key. That server is the source of truth.
- Sandbox renews on an accelerated clock and sends notifications to a separate URL. Always read a transaction's
environmentbefore granting access. - Crossdeck does the server side for you — verified subscriptions and entitlements across every rail (Apple, Google Play, Stripe) on one timeline, so you make the purchase with StoreKit 2 and never hand-roll the verification.
Definitions used in this guide
A Product value StoreKit 2 loads for a product identifier you configured in App Store Connect. Carries a localised name, displayPrice, a type, and subscription info.
The record of a single purchase, delivered as a cryptographically signed JWS. Replaces the StoreKit 1 receipt line item. You verify it, grant access, then finish() it.
An enum wrapping a value StoreKit already signature-checked — .verified (trust it) or .unverified (the JWS failed; do not grant access).
The access a purchase grants — pro, team. StoreKit reports the raw transactions; you map product IDs to the capability your app unlocks.
JSON Web Signature — the signed format Apple uses for transactions and notification payloads, so a payload can be verified against Apple's public key rather than trusted blindly.
Apple's REST API your backend calls (authenticated with a P8-signed JWT) to pull transaction history and subscription status — the server-side source of truth.
What is StoreKit 2?
StoreKit 2 is Apple's framework for selling digital products inside an app — one-off purchases, consumables, and auto-renewing subscriptions — rebuilt around Swift concurrency. Apple introduced it at WWDC 2021 alongside iOS 15, macOS 12, tvOS 15, and watchOS 8, and it now runs on those platforms and later, including visionOS. It sits on top of the same App Store billing that StoreKit 1 used; what changed is the developer-facing API and, crucially, how a purchase is represented and trusted.
Three shifts define it. First, it is async/await, not delegates and callbacks: you await the products and await the purchase result instead of implementing an SKPaymentTransactionObserver and waiting for the queue to call you back. Second, each purchase is an individual, signed Transaction rather than a line inside one opaque app receipt — so there is no base64 receipt blob to Base64-decode, no ASN.1 to parse, and no round-trip to a legacy verifyReceipt endpoint. Third, StoreKit verifies the App Store's cryptographic signature for you on device and hands you the result as a VerificationResult, so checking authenticity is a switch statement, not a security project.
The practical effect is that a correct in-app-purchase flow that took hundreds of lines of receipt-parsing and observer plumbing in StoreKit 1 becomes a few dozen lines of readable Swift. But — and this is the part this guide keeps returning to — the client running that code still lives on a device you do not control. StoreKit 2 makes the on-device story clean; it does not remove the need for a server you do control to be the final word on who has access. We will build up to exactly why, and what the server side looks like.
StoreKit 1 vs StoreKit 2: what actually changed
If you have shipped in-app purchases before iOS 15, your mental model is StoreKit 1: SKProductsRequest to fetch products, SKPaymentQueue to buy, an observer to hear back, and a single receipt file you either parse on device or send to Apple's verifyReceipt endpoint. StoreKit 2 keeps the same store and the same products, but replaces almost every touchpoint. The two frameworks can run side by side in one app, which matters if you still support iOS 14 or earlier.
| Concern | StoreKit 1 | StoreKit 2 |
|---|---|---|
| Language model | Delegates, callbacks, notifications | async/await, async sequences |
| Load products | SKProductsRequest + delegate | await Product.products(for:) |
| Make a purchase | SKPaymentQueue.add(_:) + observer | await product.purchase() |
| Proof of purchase | One opaque base64 app receipt | Individual signed Transaction (JWS) |
| Verify authenticity | Parse receipt or call verifyReceipt | VerificationResult, checked on device |
| What the user owns now | Parse the receipt for active items | Transaction.currentEntitlements |
| Renewals / external events | Observer transactions | Transaction.updates async sequence |
| Minimum OS | iOS 3+ | iOS 15+ (and platform equivalents) |
The single most important row is proof of purchase. In StoreKit 1, "what did this user buy?" meant decoding one receipt that bundled everything together, which is why so many teams outsourced it. In StoreKit 2, each purchase is its own signed object, and the framework exposes convenient async sequences — currentEntitlements for "what is valid now" and all for "everything ever" — so you rarely handle raw signatures yourself on the client. The receipt is not gone (an app-level AppTransaction still exists), but you no longer live inside it.
Note the deprecation direction as well: the old verifyReceipt endpoint is legacy, and Apple's guidance is to use the App Store Server API and Server Notifications V2 for server-side validation. If you are choosing an approach today, build on StoreKit 2 on the client and the modern server APIs on the backend. We wrote a focused companion on the trade-off of doing that verification yourself versus not in how to validate App Store purchases without building subscription infrastructure.
Setting up StoreKit 2
Before any code, three things have to be true. One, your products exist in App Store Connect with product identifiers, a price, and — for subscriptions — a subscription group. Two, you have signed the Paid Applications agreement, or products will silently fail to load. Three, for local testing you have a StoreKit configuration file (File → New → File → StoreKit Configuration File) so you can iterate in the simulator without the network. There is no capability to toggle in Xcode for StoreKit 2 itself — you just import StoreKit.
A clean architecture for a real app has one long-lived store manager that: loads products once, starts the Transaction.updates listener at launch, exposes the set of entitlements the UI reads, and offers a purchase(_:) method. We will build each of those pieces below and assemble them at the end. Keep the manager an @MainActor observable object if it drives SwiftUI; the network and verification work still happens off the main actor inside await calls.
Loading products in StoreKit 2
You load products by identifier with the static async method Product.products(for:). It performs a network request to the App Store and throws if that fails, so it belongs inside a do/catch. The returned array has no guaranteed order, and it silently omits any identifier the App Store does not recognise — a misconfigured or not-yet-approved product simply will not appear, which is the first thing to check when a product is "missing".
import StoreKit
// Identifiers you configured in App Store Connect (illustrative).
let productIDs = [
"com.example.pro.monthly",
"com.example.pro.yearly",
"com.example.coins.100"
]
func loadProducts() async throws -> [Product] {
// Async and throwing: hits the App Store, can fail on the network.
let products = try await Product.products(for: productIDs)
// No guaranteed order — sort before you show a paywall.
return products.sorted { $0.price < $1.price }
}
Each Product carries everything a paywall needs, already localised for the user's storefront: displayName, description, and a fully formatted displayPrice string (for example "$4.99" or "€4,99"). Never format the price yourself from the raw price decimal and priceFormatStyle unless you have a specific reason — StoreKit's displayPrice already applies the correct currency and locale, and hand-rolling it is a classic source of wrong-currency bugs in international apps.
for product in products {
print(product.id) // "com.example.pro.monthly"
print(product.displayName) // "Pro Monthly"
print(product.description) // localised marketing description
print(product.displayPrice) // "$4.99" — already currency-formatted
// Subscription-specific detail lives on product.subscription.
if let sub = product.subscription {
let period = sub.subscriptionPeriod // e.g. .month, value 1
print(period.unit, period.value)
}
}
Product types: consumables, non-consumables, and subscriptions
StoreKit 2 exposes a product's kind through product.type, a Product.ProductType. There are four, and they change how you deliver the purchase and whether it appears in currentEntitlements.
.consumable— used up and can be bought again (100 coins, a boost). Not returned bycurrentEntitlementsonce finished; you must record the grant yourself, ideally server-side..nonConsumable— bought once, owned forever (a pro unlock, a level pack). Appears incurrentEntitlementsfor the life of the account..autoRenewable— a subscription that renews until cancelled. Appears incurrentEntitlementswhile active, and carries a richsubscriptioninfo object with status and renewal detail..nonRenewing— a fixed-length subscription that does not auto-renew (a season pass). Apple does not track its expiry for you; you manage the period yourself.
switch product.type {
case .consumable:
// Grant the quantity; record it yourself — it won't be re-reported.
break
case .nonConsumable:
// Permanent unlock; currentEntitlements will keep reporting it.
break
case .autoRenewable:
// Subscription; read product.subscription for status and renewal.
break
case .nonRenewing:
// You own the expiry logic for these.
break
default:
break // stay forward-compatible with future types
}
The default case is not optional ceremony — Product.ProductType is a struct-backed type Apple can extend, so handle the known cases and fall through gracefully on anything new rather than assuming the four you know today are the complete set.
Making a purchase with Product.purchase()
You buy a product by calling purchase() on it. The call is async and throwing, and it returns a Product.PurchaseResult with three meaningful cases you must handle: .success (which carries a VerificationResult<Transaction> you still have to verify), .userCancelled, and .pending. Because it is a value type Apple can extend, treat it like any StoreKit enum and include an @unknown default.
enum StoreError: Error { case failedVerification }
func purchase(_ product: Product) async throws -> Transaction? {
// Optionally stamp an app account token so the purchase can be
// attributed to a user server-side (see "Why you still need a server").
let result = try await product.purchase()
switch result {
case .success(let verification):
// Purchase went through — but you MUST verify the signature.
let transaction = try checkVerified(verification)
await grantAccess(to: transaction)
// Finish once you've delivered, or StoreKit keeps re-delivering.
await transaction.finish()
return transaction
case .userCancelled:
return nil
case .pending:
// Ask to Buy, SCA, or other deferral. It may complete later and
// arrive through Transaction.updates — do NOT grant access now.
return nil
@unknown default:
return nil
}
}
Three details are easy to get wrong. The .pending case is real and common — a child under Ask to Buy, or a bank requiring Strong Customer Authentication — and the purchase you started will complete later, arriving through Transaction.updates rather than as the return value here. Do not treat pending as failure and do not grant access. Second, .success does not mean verified; it means the payment sheet completed, and you still verify the enclosed VerificationResult. Third, always finish() — an unfinished transaction is re-presented on every launch.
You can pass options to purchase(options:). The most important for anyone with a backend is .appAccountToken(_:), a UUID you attach so the transaction — and every later renewal and server notification — carries a stable handle back to your user. Getting that token right is subtle enough that our SDK provides a single helper for it, Crossdeck.appAccountTokenForCurrentIdentity(), which returns a non-optional UUID that stays stable across sign-in changes so purchases never orphan.
let token: UUID = Crossdeck.appAccountTokenForCurrentIdentity()
let result = try await product.purchase(options: [
.appAccountToken(token)
])
Verifying transactions: VerificationResult and JWS
Every transaction StoreKit 2 gives you — from a purchase, from currentEntitlements, from updates — arrives wrapped in a VerificationResult. Under the hood the App Store signs each transaction as a JWS (JSON Web Signature), and StoreKit checks that signature against Apple's public key on device before handing it to you. The result tells you the outcome: .verified(value) means the signature was valid and you can trust the payload; .unverified(value, error) means it failed and you must not grant access on the strength of it.
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
// Signature did not validate — treat as untrusted.
throw error
case .verified(let safe):
// StoreKit checked the JWS against the App Store's public key.
return safe
}
}
This tiny generic is worth having in one place because you use it everywhere. It converts "a value I have to reason about" into "a value or a thrown error", which composes cleanly with the async code around it. The key mental correction for anyone coming from StoreKit 1: you are no longer parsing a receipt or calling an Apple endpoint to check authenticity on the client. StoreKit did the cryptography; your job is to respect its verdict.
There is an important caveat, and it is the hinge of this whole guide. On-device verification proves the transaction is a genuine, untampered App Store transaction. It does not prove the device running the check is honest — a jailbroken device or a modified build can lie about the result of your switch after the fact. That is why on-device verification is necessary but not sufficient, and why the server sections below exist. For a business, the trustworthy verification happens where you control the machine.
Transaction.currentEntitlements: what the user owns now
Transaction.currentEntitlements is an async sequence of the customer's currently valid purchases — every non-consumable they own and every auto-renewable subscription that is still active, one VerificationResult<Transaction> per entitlement. It is the on-device answer to "what should this account have access to right now?", and it is what you iterate at launch and after any change to rebuild your paywall gate. Consumables are absent because they are consumed; you track those yourself.
@MainActor
func refreshEntitlements() async {
var owned = Set<String>()
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue // ignore anything whose signature didn't validate
}
// revocationDate is set when a purchase is refunded or revoked.
if transaction.revocationDate == nil {
owned.insert(transaction.productID)
}
}
self.ownedProductIDs = owned // your @Published gate for the UI
}
Two habits keep this correct. First, filter on revocationDate: a refunded purchase can still surface here transiently, and a non-nil revocationDate means access should be withdrawn. Second, re-run this after every relevant event — at launch, when Transaction.updates fires, and when the app returns to the foreground — because entitlements change from outside the app (a renewal succeeded, a subscription lapsed, a refund landed) and a stale set is how users end up locked out or getting free access. To map raw product IDs to the capabilities your app gates on, keep a small lookup from product ID to entitlement name rather than sprinkling string comparisons through the UI.
Listening to Transaction.updates
Transaction.updates is an async sequence that delivers transactions which happen outside a direct purchase() call. That includes subscription auto-renewals, refunds and revocations, deferred purchases completing (Ask to Buy, SCA), purchases the user made on another device, and Family Sharing changes. If you do not listen to it, your app can be wrong for hours: a subscription renews overnight, the user reopens the app, and your local state never learned about it.
The rule is: start a long-lived, detached task that iterates Transaction.updates at app launch, before any purchase UI is shown, and keep it running for the app's lifetime. Starting it early is what guarantees you catch a transaction that arrives during launch, before your paywall is even on screen.
@MainActor
final class Store: ObservableObject {
@Published var ownedProductIDs: Set<String> = []
private var updatesTask: Task<Void, Never>?
init() {
// Start listening BEFORE anything else, so no update is missed.
updatesTask = listenForTransactions()
Task { await refreshEntitlements() }
}
private func listenForTransactions() -> Task<Void, Never> {
Task.detached(priority: .background) {
for await update in Transaction.updates {
guard case .verified(let transaction) = update else { continue }
await self.grantAccess(to: transaction)
await transaction.finish()
}
}
}
deinit { updatesTask?.cancel() }
}
A subtle point: the transaction you get back directly from product.purchase() will also generally surface through updates, so make grantAccess(to:) idempotent — granting the same entitlement twice must be harmless. Idempotency is the quiet property that makes the whole event-driven model safe, on the client and, as we will see, on the server.
Finishing transactions
Once you have verified a transaction and delivered whatever it entitles the user to, you call await transaction.finish(). This tells StoreKit the transaction is fully handled and removes it from the queue of unfinished transactions. Forgetting to finish is one of the most common StoreKit 2 bugs: an unfinished transaction is re-delivered through Transaction.updates on every launch, so the app appears to "re-buy" or re-grant repeatedly, and for consumables Apple may keep prompting.
The discipline is: verify, deliver, then finish — in that order, and only finish after the content is durably granted. For consumables, "durably granted" means you have recorded the coins on your server or in persistent local storage; finishing before you persist the grant risks losing the purchase if the app is killed in between. For non-consumables and subscriptions the entitlement is re-derivable from currentEntitlements, so the ordering is less fragile, but the habit is the same.
let transaction = try checkVerified(verification)
try await persistGrant(for: transaction) // record it durably first
await transaction.finish() // only then finish
StoreKit 2 subscriptions: status and renewal info
Subscriptions are where StoreKit 2 earns its keep, because "is this subscription active?" has more than two answers. A subscriber can be active-and-renewing, active-but-cancelled (they turned off auto-renew and still have paid time left), expired, in a grace period after a failed payment, in billing retry, or revoked. StoreKit 2 models these as Product.SubscriptionInfo.RenewalState, and you read them through the subscription's status.
func describeSubscription(_ product: Product) async throws {
guard let subscription = product.subscription else { return }
// One status per subscription in the group; usually take the first.
let statuses = try await subscription.status
guard let status = statuses.first else {
print("Never subscribed")
return
}
// renewalInfo is itself a VerificationResult — verify it too.
let renewalInfo = try checkVerified(status.renewalInfo)
switch status.state {
case .subscribed:
print("Active. Auto-renews: \(renewalInfo.willAutoRenew)")
case .inGracePeriod:
print("Payment failed but access retained (grace period)")
case .inBillingRetryPeriod:
print("Payment failed; Apple is retrying; access at risk")
case .expired:
print("Expired")
case .revoked:
print("Refunded or revoked — remove access")
default:
print("Unknown state")
}
}
The status array has one entry per subscription in the group, because a user can hold at most one active subscription per group but may have history across several. The renewalInfo tells you the future: willAutoRenew (false means they cancelled but may still have access), autoRenewPreference (which product they will renew into, revealing an upgrade or downgrade), and expirationReason when it lapsed. The status.transaction gives you the latest transaction for the subscription. Together they answer both "do they have access now?" and "what happens at the next renewal?".
A frequent mistake is treating a cancelled subscription as immediately inactive. When a user turns off auto-renew, willAutoRenew becomes false but status.state stays .subscribed until the paid period actually ends. Cutting access the moment they cancel is a support-ticket generator; respect the paid-through date. If you want the deeper version of how failed payments play out over time, we cover it in billing retry and grace period explained for iOS subscriptions, and the analytics you should build on all of this in StoreKit 2 subscription analytics: what developers should track.
Introductory offers and free trials
Most subscription apps lead with a free trial or an introductory price, and StoreKit 2 models both as offers on the subscription. An introductoryOffer is the first-time deal — a free trial (paymentMode == .freeTrial), a pay-as-you-go, or a pay-up-front intro price — and promotionalOffers are the win-back and retention deals you target at lapsed or existing subscribers. You read the intro offer straight off the product's subscription info, and its localised price and duration are already formatted for display.
if let sub = product.subscription,
let intro = sub.introductoryOffer {
switch intro.paymentMode {
case .freeTrial:
print("Free for \(intro.period.value) \(intro.period.unit)")
case .payAsYouGo:
print("\(intro.displayPrice) per period, \(intro.periodCount) times")
case .payUpFront:
print("\(intro.displayPrice) up front for the intro period")
default:
break
}
// Eligibility is not local — confirm it before you advertise the trial.
let eligible = await sub.isEligibleForIntroOffer
print("Eligible for intro offer: \(eligible)")
}
The trap here is eligibility. A customer is only eligible for a subscription group's introductory offer once — a returning subscriber who already used their trial is not eligible, and showing them "7 days free" is both misleading and, once they hit the App Store sheet, simply wrong. Check Product.SubscriptionInfo.isEligibleForIntroOffer (or the per-group variant) before you render trial copy, and treat the answer as the truth for the UI. On the server side, whether a trial was granted and whether it converted is a first-class analytics event — trial-start and trial-to-paid conversion are the two numbers most subscription businesses live and die by, and they belong on the same customer timeline as the eventual renewal, which is exactly the kind of thing verified subscriptions are meant to record.
Why you still need a server
Here is the sentence that should reshape your architecture: the client runs on a device you do not control, so nothing it tells you about entitlements can be fully trusted. StoreKit 2's on-device verification proves a transaction is a genuine, untampered App Store transaction — a real cryptographic guarantee. But it runs inside your app, on hardware that can be jailbroken, in a build that can be modified, behind a network that can be intercepted. A determined user can bypass the branch that reads currentEntitlements and simply set isPro = true. On-device checks stop honest mistakes and casual tampering; they do not stop a motivated attacker, and they cannot be your billing source of truth.
The fix is not to distrust StoreKit — it is to add a second, independent check on a machine you do control. Your server verifies the customer's entitlements directly with Apple, using two complementary channels: the App Store Server API (you ask Apple, on demand, for a customer's transaction history and subscription status) and App Store Server Notifications V2 (Apple pushes you lifecycle events as they happen). The client makes the purchase and gets a snappy, offline paywall gate; the server independently confirms access before anything valuable is unlocked, and stays correct even when the user never reopens the app.
| Property | Client-only (StoreKit 2 on device) | Server-verified (Server API + Notifications V2) |
|---|---|---|
| Trust boundary | Device you don't control | Machine you control |
| Tamper resistance | Bypassable on a modified build | Authoritative |
| Works offline | Yes — fast paywall gate | No — needs the network |
| Learns about a renewal at 3am | Only when the app reopens | Immediately, via a notification |
| Good for | Instant UI gating | Granting real value, billing truth |
The two are partners, not rivals. Use on-device StoreKit 2 for the responsive paywall — a subscriber shouldn't wait on a network round-trip to see their paid features — and use the server as the authority before you unlock anything that costs you money to give away. If building and operating that server side sounds like a project in itself, that is exactly the observation the last section is about.
App Store Server Notifications V2
App Store Server Notifications V2 are server-to-server webhooks Apple sends to a URL you configure in App Store Connect. They are how your backend learns about everything that happens to a subscription without the app being open — renewals, cancellations, failed payments, grace periods, refunds, and revocations. You set a production URL and a separate sandbox URL, and Apple POSTs a JSON body containing a single signedPayload: a JWS you verify against Apple's certificate chain, then decode.
{
"notificationType": "DID_RENEW",
"subtype": "",
"notificationUUID": "d1c8a1f0-...-4b2e",
"data": {
"appAppleId": 1234567890,
"bundleId": "com.example.app",
"environment": "Production",
"signedTransactionInfo": "<JWS>",
"signedRenewalInfo": "<JWS>"
},
"version": "2.0",
"signedDate": 1700000000000
}
The notificationType (with an optional subtype) tells you what happened; the signedTransactionInfo and signedRenewalInfo inside data are themselves JWS objects carrying the same fields you would get from the Server API. The types you will handle most are below.
| notificationType | What it means | Typical action |
|---|---|---|
SUBSCRIBED | New subscription or resubscribe | Grant access |
DID_RENEW | Auto-renewal succeeded | Extend access |
DID_FAIL_TO_RENEW | Renewal payment failed | Check grace vs retry; maybe hold access |
GRACE_PERIOD_EXPIRED | Grace ended without recovery | Revoke access |
EXPIRED | Subscription lapsed | Revoke access |
DID_CHANGE_RENEWAL_STATUS | Auto-renew turned on/off | Update, keep access until expiry |
REFUND | Apple refunded a purchase | Revoke; reconcile revenue |
REVOKE | Access revoked (e.g. Family Sharing) | Revoke access |
Two operational rules matter. Verify the JWS — validate the signature against Apple's certificate chain before you trust a byte of it; an unverified webhook is an open door. And respond fast and be idempotent — return 200 quickly, do the real work asynchronously, and make replayed notifications harmless, because Apple retries on failure and you will occasionally see the same event twice. We wrote a founder-level explainer of the whole mechanism in App Store Server Notifications V2 explained for founders.
The App Store Server API
If Server Notifications are Apple pushing events to you, the App Store Server API is you pulling the current truth from Apple. It is a REST API your backend calls, keyed by a transaction ID, to answer questions like "what is this customer's full purchase history?" and "what is the status of every subscription in this group?". It is what you use to verify entitlements independently at any moment — for example when a client says it is entitled and you want Apple's word before granting real value — and to recover if you ever miss a notification.
| Endpoint | Returns |
|---|---|
GET /inApps/v1/subscriptions/{transactionId} | All subscription statuses for the group |
GET /inApps/v1/history/{transactionId} | Full transaction history |
GET /inApps/v1/transactions/{transactionId} | Info for a single transaction |
GET /inApps/v2/refund/lookup/{transactionId} | Refund history |
POST /inApps/v1/notifications/test | Ask Apple to send a test notification |
Every response is signed data — the transactions and renewal info come back as JWS objects, the same signed format as the notifications, so a single verification-and-decode path on your server handles both. There are two host environments, api.storekit.itunes.apple.com for production and api.storekit-sandbox.itunes.apple.com for sandbox, and you must call the right one for the transaction's environment. Apple also ships an official App Store Server Library (Swift, Java, Node, Python) that handles JWT signing and JWS verification, and using it is far safer than hand-rolling the crypto. A pragmatic pattern is: notifications keep your database live, and the Server API is your reconciliation and on-demand-verification tool.
P8 keys and JWT authentication
Both the App Store Server API and the verification of Apple's signed payloads rest on one credential: a P8 key. This is an Elliptic Curve private key you generate once in App Store Connect under Users and Access → Integrations → In-App Purchase, and download as a .p8 file. Apple lets you download it exactly once, so if you lose it you revoke and reissue. It is a secret: it belongs on your server or in a secrets manager, never in client code, never in a repo.
You use the P8 key to sign a short-lived JSON Web Token (JWT) with the ES256 algorithm, and send that token as a bearer credential on every Server API request. The header names your key with kid; the claims carry your issuer ID, an expiry no more than 60 minutes out, the audience appstoreconnect-v1, and your bundle ID.
// Header
{
"alg": "ES256",
"kid": "ABC123DEFG", // your P8 key ID
"typ": "JWT"
}
// Claims
{
"iss": "57246542-96fe-...-072a", // issuer ID from App Store Connect
"iat": 1700000000,
"exp": 1700003600, // <= 60 minutes after iat
"aud": "appstoreconnect-v1",
"bid": "com.example.app" // your app's bundle ID
}
The same P8 mechanism is what any third-party subscription platform needs from you to verify Apple purchases on your behalf — which is why "upload your P8 key" is a step in onboarding to services like Crossdeck. If you want the plain-English version of what these keys are and why they matter, we wrote what Apple P8 keys are and why subscription platforms need them. Treat the key like a production database password: scoped, stored in a secrets manager, rotated if exposed.
Sandbox vs production: testing StoreKit 2 without surprises
StoreKit 2 has three test surfaces, and confusing them is the source of most "it worked on my machine" purchase bugs. Know which one you are in, and always read a transaction's environment before you act on it.
- Xcode StoreKit testing — a local
.storekitconfiguration file. No network, no Apple ID; you define products in Xcode and can simulate renewals, refunds, and failures with a time-compression slider. Fastest loop, but it is a simulation, not Apple's servers. - App Store sandbox — real Apple servers with a sandbox Apple ID (created in App Store Connect). This is where you validate the real end-to-end flow, including your server receiving real Server Notifications.
- TestFlight and production — real billing (TestFlight subscriptions are free but behave like production), the final check before and after release.
The single biggest trap is accelerated sandbox renewal: in sandbox, subscription periods are compressed so a "monthly" plan renews roughly every five minutes and auto-renews only a handful of times before stopping. This is great for testing renewal handling quickly, but it means sandbox durations tell you nothing about production timing, and a test subscription "expiring" fast is expected, not a bug. Equally important: sandbox and production send notifications to different URLs and use different Server API hosts, and every transaction carries an environment field of Sandbox or Production. Gate on it — never grant real, paid access on the strength of a Sandbox transaction that slipped into your production path. We go deeper on the failure modes in sandbox vs production for app subscriptions: how to test safely.
Billing retry and grace periods
Renewals fail — expired cards, insufficient funds, a bank declining. When a renewal payment fails, Apple does not immediately cut the subscriber off. It enters billing retry, attempting to recover the payment for up to 60 days. Separately, if you enable Billing Grace Period in App Store Connect, the customer keeps access during a short grace window (a few days to a couple of weeks depending on plan length) while Apple retries. Grace period is a retention feature: it stops a transient card problem from instantly locking out a paying customer.
In StoreKit 2 these appear as two RenewalState values — inGracePeriod (keep granting access; the customer still has it) and inBillingRetryPeriod (payment is failing and access is at risk; grace, if any, has ended). On the server, DID_FAIL_TO_RENEW tells you a renewal failed (its subtype indicates whether a grace period is active), and GRACE_PERIOD_EXPIRED tells you the grace window closed without recovery — the point to revoke.
func shouldGrantAccess(_ state: Product.SubscriptionInfo.RenewalState) -> Bool {
switch state {
case .subscribed, .inGracePeriod:
return true // active, or failing but still in grace
case .inBillingRetryPeriod, .expired, .revoked:
return false // no valid access
default:
return false
}
}
The design rule is simple and worth stating: grant access in grace, withhold it in bare billing retry. Getting this wrong in either direction hurts — cut off a customer whose renewal will recover and you generate an angry ticket; keep granting access through a full 60-day retry with no grace and you give away two months. The full treatment, with the timelines, is in billing retry and grace period explained for iOS subscriptions.
Refunds in StoreKit 2
When Apple grants a customer a refund, StoreKit 2 makes it visible in two places. On the client, the refunded transaction gets a non-nil revocationDate and drops out of Transaction.currentEntitlements — so an entitlement check that filters on revocationDate (as ours above does) stops granting access automatically, without you polling anything. On the server, the REFUND notification tells you a refund occurred, and the GET /inApps/v2/refund/lookup/{transactionId} endpoint returns refund history so you can reconcile revenue you had recognised.
You can also participate in refunds rather than just react to them. Transaction.beginRefundRequest(in:) lets a user start an Apple-mediated refund request from inside your app, which is a better experience than sending them to a web form. And for consumables, Apple may send a consumption request notification asking how much of the product the user consumed, so it can make a fair refund decision — responding accurately (through the Server API's send-consumption endpoint) improves your refund outcomes over time. Refunds are also where reconciliation matters most: a refund reverses revenue you may already have counted, and if your analytics do not hear about it, your numbers drift.
Common StoreKit 2 mistakes
The same handful of errors account for most StoreKit 2 support threads. Each has a one-line fix, and every one traces back to a concept above.
- Not starting
Transaction.updatesat launch. Start the listener in your app's init, before any purchase UI. Start it late and you miss renewals and cross-device purchases. - Never calling
finish(). Verify, grant, then finish. An unfinished transaction is re-delivered forever and looks like a duplicate purchase. - Treating
.successas verified..successonly means the payment sheet completed; you still switch on the enclosedVerificationResult. - Trusting the client as the source of truth. On-device checks gate the UI; the server grants real value. Skip the server and you ship a paywall a modified build walks straight through.
- Cutting access the instant a user cancels.
willAutoRenew == falseis not expiry — honour the paid-through date, or generate tickets. - Ignoring
.pending. Ask to Buy and SCA defer the purchase; it completes later throughupdates. Don't treat pending as failure, and don't grant access on it. - Formatting price by hand. Use
displayPrice; rolling your own currency formatting breaks in other storefronts. - Not gating on
environment. A sandbox transaction reaching a production code path must never grant real access. - Non-idempotent grants. The same transaction can arrive from
purchase()andupdates, and notifications can replay. Granting twice must be harmless.
Where Crossdeck fits
Everything above the "why you still need a server" line is genuinely a few dozen lines of Swift, and StoreKit 2 makes it pleasant. Everything below it — verifying Server Notifications V2, calling the Server API, holding and rotating the P8 key, separating sandbox from production, reconciling refunds, keeping an entitlement database correct as renewals and cancellations stream in overnight — is the part that quietly becomes a permanent piece of infrastructure to build and operate. And it is Apple-only: the day you add Google Play or a Stripe web plan, you build the whole thing again in a different shape.
That server side is what Crossdeck is. You make the purchase on device with StoreKit 2 exactly as this guide describes; Crossdeck takes the P8 key, verifies Apple's signed transactions and Server Notifications V2, and turns the raw stream into verified subscriptions and verified entitlements you can read from any client — iOS, Android, or web — on one customer timeline. Because entitlements are unified across every rail, a customer who subscribed on the App Store and later signed in on the web carries the same access, without you writing a second verifier. The Swift SDK also closes the attribution gap for you: one line, Crossdeck.appAccountTokenForCurrentIdentity(), stamps each purchase with a stable token so a renewal months later still joins back to the right user server-side.
The honest framing: StoreKit 2 is the right way to take the payment, and you should learn it — this guide is here so you can. Crossdeck is the bank-grade layer behind it so that server-side verification, cross-rail entitlements, and reconciliation are something you read rather than something you build and babysit. If your web subscriptions live on Stripe and you want that same access to unlock the iOS app, that specific bridge is walked through in how to connect Stripe web subscriptions to iOS app access.
Putting it together: a complete store manager
Each piece above is small; the value is in how they compose. A production-ready client integration is one long-lived object that loads products once, starts the updates listener the instant it is created, keeps a published set of entitlements the UI can gate on, and offers a single idempotent grant path that both a direct purchase and a background update flow through. The example below assembles everything in this guide into that shape — a shell you can drop into a SwiftUI app and grow.
import StoreKit
enum StoreError: Error { case failedVerification }
@MainActor
final class Store: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var ownedProductIDs: Set<String> = []
private let productIDs = [
"com.example.pro.monthly",
"com.example.pro.yearly",
"com.example.coins.100"
]
private var updatesTask: Task<Void, Never>?
init() {
// 1. Listen BEFORE anything else so no transaction is missed.
updatesTask = listenForTransactions()
Task {
await loadProducts()
// 2. Reconcile against what the user actually owns.
await refreshEntitlements()
}
}
deinit { updatesTask?.cancel() }
// MARK: Load
func loadProducts() async {
do {
let loaded = try await Product.products(for: productIDs)
self.products = loaded.sorted { $0.price < $1.price }
} catch {
// Surface to the UI; a failed load usually means a config issue.
print("Failed to load products: \(error)")
}
}
// MARK: Purchase
func purchase(_ product: Product) async throws {
let token = Crossdeck.appAccountTokenForCurrentIdentity()
let result = try await product.purchase(options: [.appAccountToken(token)])
switch result {
case .success(let verification):
let transaction = try Self.checkVerified(verification)
await grant(transaction)
await transaction.finish()
case .userCancelled, .pending:
break
@unknown default:
break
}
}
// MARK: Entitlements
func refreshEntitlements() async {
var owned = Set<String>()
for await result in Transaction.currentEntitlements {
guard case .verified(let t) = result, t.revocationDate == nil else { continue }
owned.insert(t.productID)
}
self.ownedProductIDs = owned
}
func isEntitled(to productID: String) -> Bool {
ownedProductIDs.contains(productID)
}
// MARK: Plumbing
private func listenForTransactions() -> Task<Void, Never> {
Task.detached(priority: .background) {
for await update in Transaction.updates {
guard case .verified(let t) = update else { continue }
await self.grant(t)
await t.finish()
}
}
}
// Idempotent: the same transaction can arrive from purchase() AND updates.
private func grant(_ transaction: Transaction) async {
await refreshEntitlements()
// For consumables, additionally record the quantity durably here.
}
nonisolated static func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error): throw error
case .verified(let safe): return safe
}
}
}
A few design choices in that shell are deliberate. The manager is @MainActor so its published state drives SwiftUI safely, while the network and verification work still happens off the main actor inside the await calls. The transaction listener is started in init, not lazily on first purchase, because the whole point of Transaction.updates is to catch events that arrive before the user touches your paywall. And grant(_:) deliberately routes everything back through refreshEntitlements() so there is exactly one place that decides what the user owns — a purchase and a 3am renewal converge on the same code, which is what makes the double-delivery of a transaction harmless instead of a bug.
What this shell deliberately does not do is treat itself as the source of truth. isEntitled(to:) is a UI convenience for a snappy gate; before you unlock anything that costs real money, the answer should be confirmed by your server against the App Store Server API, for every reason in why you still need a server. The client manager makes the experience fast; the server makes it correct.
The StoreKit 2 checklist
Everything above, compressed to a checklist you can run against your own integration:
- Products configured in App Store Connect; Paid Applications agreement signed.
- A
.storekitconfiguration file for fast local testing. - Load with
Product.products(for:); sort the results; usedisplayPrice. - Start the
Transaction.updateslistener at launch, before any purchase. - On every transaction: verify the
VerificationResult, grant idempotently, thenfinish(). - Handle all purchase cases:
.success,.userCancelled,.pending,@unknown default. - Rebuild entitlements from
currentEntitlementsat launch, on foreground, and on updates; filterrevocationDate. - Read subscription
statusandrenewalInfo; honour paid-through dates on cancellation. - Stamp purchases with an
appAccountTokenso the server can attribute them. - Stand up a server that verifies entitlements via the App Store Server API and receives Server Notifications V2.
- Store the P8 key as a secret; sign ES256 JWTs; never ship it in client code.
- Verify every JWS (notifications and API responses); respond to notifications fast and idempotently.
- Gate on
environment; use the sandbox host for sandbox, production host for production. - Grant access in grace period, withhold in billing retry; revoke on refund and expiry.
Frequently asked questions
What is StoreKit 2?
StoreKit 2 is Apple's modern Swift framework for in-app purchases and subscriptions, introduced in 2021 for iOS 15 and later. It replaces StoreKit 1's delegate-and-callback model with async/await, and replaces the opaque on-device receipt file with individual, cryptographically signed Transaction objects (JWS) you can verify on device or on your server. It covers loading products, purchasing, checking entitlements, and reading subscription status.
What is the difference between StoreKit 1 and StoreKit 2?
StoreKit 1 uses SKPaymentQueue with delegate callbacks and a single base64 receipt you validate against Apple's verifyReceipt endpoint. StoreKit 2 uses async/await, exposes each purchase as a signed Transaction, verifies the App Store's JWS signature for you on device, and exposes Transaction.currentEntitlements and Transaction.updates as async sequences. StoreKit 2 requires iOS 15+; the two can coexist in one app.
How do I load products in StoreKit 2?
Call the static async method Product.products(for:) with your identifiers: let products = try await Product.products(for: ids). It returns products in no guaranteed order, so sort them. Each Product carries a localised displayName, description, and displayPrice, plus a type and, for subscriptions, a subscription info object.
How do I verify a transaction in StoreKit 2?
StoreKit hands you a VerificationResult<Transaction>. Switch on it: .verified(let transaction) means StoreKit checked the App Store's JWS signature and you can trust the payload on device; .unverified(_, let error) means the signature failed and you must not grant access. A small generic helper that throws on .unverified keeps this clean across purchase, currentEntitlements, and updates.
What is Transaction.currentEntitlements?
An async sequence of the customer's currently valid purchases — every non-consumable they own and every auto-renewable subscription still active. You iterate it at launch and after any change to rebuild the set of products the user is entitled to. Consumables are absent because they are used up. It is the on-device answer to "what does this user have access to right now?".
What is Transaction.updates and where do I listen to it?
An async sequence that delivers transactions from outside a direct purchase call — renewals, refunds, Ask to Buy approvals, Family Sharing changes, and purchases made on another device. Start a long-lived detached Task that iterates it at app launch, before any purchase, so a transaction that arrives while the purchase UI is closed is never missed. Finish each one after granting access.
Do I need a server for StoreKit 2 in-app purchases?
For anything that matters commercially, yes. The client runs on a device you do not control, so on-device entitlement checks can be tampered with. Your server should verify entitlements independently with the App Store Server API and receive renewal, refund, and billing events through App Store Server Notifications V2. On-device StoreKit 2 gives a fast, offline paywall gate; the server is the source of truth.
What are App Store Server Notifications V2?
Server-to-server webhooks Apple sends to a URL you configure, reporting lifecycle events: SUBSCRIBED, DID_RENEW, EXPIRED, DID_FAIL_TO_RENEW, GRACE_PERIOD_EXPIRED, REFUND, REVOKE, and more. The payload is a signed JWS containing signed transaction and renewal info. They are how your server learns about renewals and cancellations that never touch the app.
What is the App Store Server API?
Apple's REST API your backend calls to get transaction history, all subscription statuses, transaction info, and refund history, keyed by transaction ID. You authenticate with a JWT signed by an in-app-purchase private key (a .p8 key). It is the pull-based counterpart to the push-based Server Notifications; together they are the server-side source of truth.
What is a P8 key and why do I need one?
An Elliptic Curve private key you download once from App Store Connect (Users and Access → Integrations → In-App Purchase). Your server uses it to sign the ES256 JWT that authenticates every App Store Server API request and to verify Apple's signed payloads. It is a secret that must never ship in client code, and it can be downloaded only once, so store it securely.
How do I test StoreKit 2 purchases in sandbox?
Three environments: Xcode's local StoreKit testing (a .storekit file, no network), the App Store sandbox (real Apple servers, a sandbox Apple ID), and TestFlight/production. Sandbox subscriptions renew on an accelerated clock — a monthly plan renews about every five minutes — and sandbox and production use separate notification URLs and API hosts. Always read a transaction's environment and never grant real access from a sandbox transaction in production.
What are billing retry and grace period in StoreKit 2?
When a renewal payment fails, Apple enters billing retry and attempts recovery for up to 60 days. If you enable Billing Grace Period in App Store Connect, the customer keeps access during a short grace window while Apple retries. In StoreKit 2 these are the RenewalState values inBillingRetryPeriod and inGracePeriod; on the server, DID_FAIL_TO_RENEW and GRACE_PERIOD_EXPIRED tell you when to keep or cut access.
How do refunds work in StoreKit 2?
When Apple grants a refund, the transaction gets a revocationDate and drops out of Transaction.currentEntitlements, so an on-device check that filters on it stops granting access automatically. Your server is told through the REFUND notification and can inspect refund history via the Server API. You can also let users start a refund in-app with Transaction.beginRefundRequest, and respond to consumption requests for consumables.
Does Crossdeck replace StoreKit 2?
No. You still make the purchase with StoreKit 2. Crossdeck sits behind it: it verifies App Store Server Notifications V2 and the App Store Server API for you, holds the P8 key, and turns the raw transactions into verified subscriptions and entitlements you can read from any client — iOS, Android, or web — on one customer timeline. It removes the server-side verification, reconciliation, and cross-rail work you would otherwise hand-roll.
Make the purchase with StoreKit 2. Skip building the server.
Crossdeck takes your P8 key, verifies Apple's signed transactions and Server Notifications V2, and gives you verified subscriptions and entitlements across every rail — Apple, Google Play, Stripe — on one customer timeline. You keep StoreKit 2 on device; the server-side verification and reconciliation are ours.