Anything digital that is used inside the app (premium features, a content subscription, coins, ad removal) must be sold through in-app purchase. Physical goods and real-world services (a taxi ride, a hoodie, a hotel night) must not use it: they go through Apple Pay or your own card form.
Digital, unlocked in the app (guideline 3.1.1): subscriptions, levels, in-game currency, a "Pro" unlock. Apple keeps 15 to 30 percent, and a button pointing to a cheaper web price gets the build rejected (outside the storefronts below).
Physical goods and services used outside the app (3.1.3(e)): delivery, tickets, rides, marketplace orders. In-app purchase is not allowed here, and Apple takes no cut.
One-to-one live services (3.1.3(d)): a tutor or a doctor on a call with one person may be paid outside in-app purchase; a class for many may not.
Content bought elsewhere (3.1.3(a), (b)): reader apps (video, music, books, news) may let users sign in to what they bought on the web; other multiplatform services too, if those items are also sold through in-app purchase.
Storefront exceptions: since May 2025 the US storefront allows links to a web checkout, and the EU and some other markets have alternative-payment terms.
iOSMiddle
StoreKit & App Store Release
StoreKit 2, subscriptions and offers, entitlements, server notifications, refunds, testing purchases, App Review, phased release, forced update
Questions closed
0 / 19
- 0Review
Questions
Consumables (coins, credits) are bought again and again, non-consumables (a lifetime unlock) once and forever, auto-renewable subscriptions renew until cancelled, and non-renewing subscriptions (a season pass) simply end. The type decides who keeps the record and what must be restorable.
Consumable: used up and bought again. Your app or server keeps the balance: it is not an entitlement StoreKit tracks for you. Currency bought this way may not expire (guideline 3.1.1), and a refund means taking it back.
Non-consumable: bought once, owned on every device with the same Apple Account. It must be restorable and can opt into Family Sharing.
Auto-renewable subscription: lives in a subscription group and renews until the user cancels in Settings. The paywall must show the price, the period and that it renews, with links to the terms and the privacy policy (guideline 3.1.2).
Non-renewing subscription: a fixed period with no renewal. StoreKit does not track its end: you compute the expiry from the purchase date and make it available on all of the user's devices, which means your own accounts.
Load the products with Product.products(for:), call purchase() on the one the user taps, check that the result is .success(.verified(transaction)), deliver the content, then call transaction.finish(). Purchases that complete later arrive through Transaction.updates.
Load: show displayName and displayPrice from the Product, never a hardcoded price; price and currency depend on the user's storefront. An unknown ID is silently left out, so an empty list usually means a typo, missing metadata in App Store Connect or an unsigned Paid Apps agreement.
Purchase: purchase() shows Apple's sheet. UIKit apps pass the scene with purchase(confirmIn:) (iOS 17+); SwiftUI has @Environment(\.purchase) and the ready-made StoreView and SubscriptionStoreView.
Verify: StoreKit 2 has already checked Apple's signature. .unverified means unlock nothing.
Deliver, then finish: an unfinished transaction is delivered again on the next launch, so a crash in between loses nothing.
**.pending** is not an error: Ask to Buy or a payment that needs extra approval. Show "waiting for approval" and let the Transaction.updates listener finish the job.
CFBundleShortVersionString is the version users see in the App Store (2.4.0); CFBundleVersion is the build number (1873). The build number must change for every upload; the version changes once per release and must be higher than the last approved one.
Where they live: the MARKETING_VERSION and CURRENT_PROJECT_VERSION build settings, which Info.plist reads as $(MARKETING_VERSION) and $(CURRENT_PROJECT_VERSION). Both are up to three dot-separated integers.
One version, many builds: 2.4.0 can have ten builds in TestFlight, and one of them is submitted. Uploading a build number that version already has fails as a redundant binary upload.
Let CI set the build number (the run number, or the latest TestFlight build plus one), so it only grows and nobody edits it by hand.
Extensions (widgets, notification extensions) must carry the same version and build as the app, or the upload warns. Passing both settings to xcodebuild on the command line applies them to every target.
Call the requestReview action from the SwiftUI environment (in UIKit, AppStore.requestReview(in:)) at a happy moment, and the system decides whether to show the dialog. It appears at most three times per app in 365 days and reports nothing back, so the app can neither know nor count on it.
When: right after a success (an order placed, a level finished), never at launch, in the middle of a task or after an error.
Limits you cannot see: users can switch these prompts off for every app in Settings. In a development build the dialog always appears; in TestFlight it never does.
No custom prompt: guideline 5.6.1 requires the system API for review prompts, so a home-made "Rate us 5 stars" dialog gets rejected.
A "Rate the app" button must open the store page with ?action=write-review. A button that calls requestReview often does nothing, which looks broken.
History: SKStoreReviewController is the old API, deprecated in iOS 18.
Many transactions never pass through your purchase() call: renewals, Ask to Buy approvals, purchases on another device, refunds, offer codes redeemed in the App Store, and anything a crash left unfinished. They arrive only through Transaction.updates, so a listener that starts late misses them.
How they arrive: a refund is the same transaction again with revocationDate set, and unfinished transactions are emitted once, right after launch.
Without a listener: the user is charged and still sees the paywall, and the transaction is never finished. StoreKit even logs a warning to the Xcode console when purchase() runs with no listener.
Start it once, early: in the App initializer or application(_:didFinishLaunchingWithOptions:), in an object that lives as long as the app. A listener in a paywall's .task runs only while that screen is open.
One code path: the button and the listener call the same function (verify, deliver, recompute entitlements, finish()), so the logic exists once.
Updates are not the state: updates says that something changed; Transaction.currentEntitlements says what the user owns now. Recompute from it at launch as well.
finish() is called before the coins are credited. A finished transaction is never delivered again, so when creditCoins fails nothing hands the purchase back: the user has paid, has no coins, and no retry will ever happen.
**What finish() means**: "the content is delivered, stop telling me about this". An unfinished transaction comes back through Transaction.updates after every launch and stays in Transaction.unfinished; a finished one leaves both for good.
The fix: credit first and call finish() only after the server confirms. On failure leave it unfinished, so the next launch or a retry from Transaction.unfinished delivers it again.
Idempotency becomes mandatory: the same transaction can now reach the server twice (a timeout whose response was lost, a retry after relaunch), so the backend credits each transaction.id once and answers "already credited" the second time.
The opposite bug: never calling finish(). The purchase comes back on every launch, and a server without that check credits the coins again each time.
The toast was half the bug: "please try again" invites a second purchase. Say the coins are on their way and retry quietly.
Recompute ownership from Transaction.currentEntitlements at launch and after every update: it holds the latest signed transaction for each active subscription and non-consumable. A UserDefaults flag is a copy that never learns about expiry, refunds, Family Sharing or another device.
What the sequence holds: every non-consumable, the latest transaction of each auto-renewable subscription that is active or in its grace period, and non-renewing subscriptions (you check their end date yourself). Refunded and revoked purchases are left out.
What a flag gets wrong: it stays true after the plan expires or Apple refunds it, stays false on a new iPhone or after a reinstall, and knows nothing about a plan shared by a family member (ownershipType == .familyShared).
Verified rows only: skip .unverified results (a forged transaction on a jailbroken device looks exactly like that) and rows with isUpgraded, which an upgrade replaced.
A cache is fine as a hint: keep the last known tier to draw the first frame, then overwrite it with the recomputed value. StoreKit keeps its own copy of the transactions on the device, so the recompute works offline too.
With a backend: the server's record, fed by Apple's notifications, decides server-side content; currentEntitlements decides what the app unlocks locally.
In StoreKit 2 restoring is mostly automatic: transactions sync to every device on the same Apple Account and show up in currentEntitlements. The button stays because App Review requires one, and the AppStore.sync() behind it is for the rare case where the device's copy is stale.
Why review asks for it: guideline 3.1.1 requires a restore mechanism for restorable purchases (non-consumables and subscriptions). A paywall with Buy and no Restore is a classic rejection.
**What sync() does: forces a fresh sync of the user's transactions with the App Store. It usually asks the user to authenticate, so call it only from an explicit tap, never at launch or on a timer.
After it returns: recompute entitlements and tell the user the outcome: unlocked, or "no purchases found for this Apple Account". The usual cause of the second is a different Apple Account, not a bug.
Consumables are not restored: their balance has to live on your server, under the user's account.
Your own accounts**: when purchases are tied to your login, restoring also means fetching the entitlement from your backend after sign-in.
A subscription group holds plans that replace each other; a user has at most one active plan per group. Plans are ranked by level: an upgrade starts at once with a prorated refund, a downgrade waits for the next renewal, and a crossgrade depends on the periods.
Levels: level 1 is the highest tier of service. Plans on the same level with different periods (Pro monthly and Pro yearly) are crossgrades of each other.
Upgrade (to a higher level): starts immediately, and Apple refunds the unused part of the old plan. The old transaction comes back with isUpgraded == true, and a new one begins.
Downgrade (to a lower level): the current plan runs to its end date, then the lower one renews. Until then RenewalInfo.autoRenewPreference names the new product, so the app can say "Basic from 12 March".
Crossgrade (same level): immediate if the periods are the same, otherwise from the next renewal date.
One group per product: put the monthly and yearly plans of one product in the same group. Separate groups let a user pay for both at once, and each group has its own introductory offer eligibility.
On the server: DID_CHANGE_RENEWAL_PREF with subtype UPGRADE or DOWNGRADE reports the change.
Show the trial only when isEligibleForIntroOffer says yes: one introductory offer per subscription group per Apple Account. Introductory offers target new subscribers, promotional offers current or lapsed ones (signed by your server), and offer codes are codes the user redeems.
Eligibility: only the App Store knows whether this Apple Account already had a trial or a plan in this group, so never decide it from your own flag. A "7 days free" paywall shown to an ineligible user charges them on day one: a refund request and a bad review.
Introductory offer: free trial, pay as you go (a lower price for a number of periods) or pay up front. Set up per plan in App Store Connect; StoreKit applies it to an eligible purchase on its own.
Promotional offer: for current or former subscribers, such as a discount when they are about to cancel. The app passes .promotionalOffer(...) to purchase(options:) with a signature your server makes with an In-App Purchase key, so a modified client cannot invent discounts.
Offer codes: one-time or custom codes for marketing and support, redeemed in the App Store or in the app with .offerCodeRedemption(isPresented:) (UIKit: AppStore.presentOfferCodeRedeemSheet(in:)). The result arrives in Transaction.updates.
Win-back offers (iOS 18): set up in App Store Connect for lapsed subscribers; the App Store can show them itself.
When a renewal fails, Apple retries the charge for up to 60 days (billing retry). With a billing grace period turned on, the user keeps access for its first days; after that, access stops until a retry succeeds. Both states need a "fix your payment" message.
Grace period (.inGracePeriod): switched on in App Store Connect, up to 28 days. The plan still counts as active and stays in currentEntitlements; show a non-blocking banner with the date from gracePeriodExpirationDate.
Billing retry (.inBillingRetryPeriod): no access, but the user has not cancelled. Show "your payment failed, update it", not the paywall as if this were a new sale.
Only Apple can charge the card: link to https://apps.apple.com/account/billing or open AppStore.showManageSubscriptions(in:). Since iOS 16.4 StoreKit can also show its own billing-issue sheet (Message with reason .billingIssue).
Recovery: when a retry succeeds, the renewal arrives in Transaction.updates; recompute and drop the banner. Recovered within grace, the user keeps the original renewal date.
On the server: DID_FAIL_TO_RENEW (subtype GRACE_PERIOD when grace applies), GRACE_PERIOD_EXPIRED, DID_RENEW with subtype BILLING_RECOVERY, or EXPIRED with subtype BILLING_RETRY when every retry failed.
Apple decides refunds, not you. When it grants one, the app gets the transaction again in Transaction.updates with revocationDate set, it drops out of currentEntitlements, and your server receives a REFUND notification. beginRefundRequest opens Apple's refund form without leaving the app.
In the app: recompute entitlements on that update and the paid tier goes away on its own. Consumables have nothing to recompute on the device: the server takes the coins back.
On the server: REFUND carries the signed transaction with revocationDate and revocationReason; revoke exactly that purchase by its transaction ID. REFUND_REVERSED gives it back, and REVOKE means a family member lost access through Family Sharing.
**beginRefundRequest**: Transaction.beginRefundRequest(for:in:) in UIKit, .refundRequestSheet(for:isPresented:) in SwiftUI. A .success result only means the request was sent; Apple's answer comes by email and through the same update.
Why offer it: a user who cannot find the refund path disputes the charge with the bank or leaves a one-star review instead.
Consumption data: a refund request also sends your server CONSUMPTION_REQUEST; you have 12 hours to reply with Send Consumption Information (whether the content was used), and only with the user's consent to share it.
Use three layers: a .storekit configuration file for fast local runs and unit tests, the sandbox for the real App Store Connect products and server notifications, and TestFlight for the release build in testers' hands. Each one fakes something, so none of them alone proves that purchasing works.
StoreKit configuration file: products defined in Xcode, no network, no account. Xcode's transaction manager and SKTestSession can renew in seconds, refund, send a renewal into billing retry or grace period and turn on Ask to Buy, so every state is testable on CI. Not caught: product IDs that differ from App Store Connect, and your server: transactions are signed with a local Xcode certificate that a server checking Apple's chain rejects, and no notifications are sent.
Sandbox: the real products, a sandbox Apple Account created in App Store Connect, accelerated renewals and notifications to your sandbox URL. Not caught: real payments and bank behaviour.
TestFlight: the real build, with free purchases in the sandbox environment. Not caught: production prices, taxes and real refunds.
App Review buys in the sandbox with your production build, so the production server must accept transactions whose environment is Sandbox and check them against Apple's sandbox endpoint. Otherwise the reviewer's purchase never unlocks and the build is rejected.
Read the cited guideline and the reviewer's notes, then fix and resubmit, or reply in App Store Connect to explain or ask what they need. Appeal to the App Review Board only when the rule was misapplied, and ask for an expedited review only for a critical fix or a dated event.
Reply first: the thread in App Store Connect goes to the reviewer. Many rejections are misunderstandings (a feature they could not reach, a missing demo account), and a short reply with steps, a screen recording or credentials clears them without a new build.
Metadata only: a wrong screenshot or description is fixed in App Store Connect and resubmitted with the same build.
Common reasons to fix: crashes or broken flows on the review device (2.1), no demo account for a login-only app, digital goods sold outside in-app purchase (3.1.1), no way to delete the account in the app (5.1.1(v)), permission prompts whose text does not say why.
Bug fixes are not held hostage: for an app already on the store, a bug-fix update is not delayed over an unrelated guideline issue (legal and safety ones aside); reply that you will address it in the next submission.
Appeal: through the App Review Board form on the developer site, naming the guideline and why it does not apply.
Expedited review: through the same contact page, for a crash fix or a time-bound event. It is a favour, not a paid lane; teams that ask every release stop getting it.
Ship the check before you need it: at launch the app fetches a minimum and a recommended version from your server, compares them with its own CFBundleShortVersionString, and shows a blocking screen or a banner that opens the App Store page. Only builds that already contain the check can be moved.
The gate must survive the change it enforces: keep the policy endpoint and its JSON stable, and do not make the check depend on what you are retiring (a new API version, a pinned key, the login). If the gate breaks, old builds never see the prompt.
Compare numerically: as strings, "1.10.0" < "1.9.0" is true. Use compare(_:options: .numeric) and the same number of components on both sides, because "1.9" sorts below "1.9.0".
Enforce it on the server too: send the app version in a header and answer versions below the minimum with a clear error (for example 426 Upgrade Required) that the app turns into the update screen.
Do not strand users: someone whose iOS is below the new deployment target cannot update at all, so set the minimum with that in mind, and only once the new build is live in every storefront.
Block only when you must: broken payments, a security hole, a removed API; otherwise a banner. The blocking screen has one button to the store and no network calls behind it.
Apple pushes App Store Server Notifications V2 to an HTTPS endpoint you register, and the App Store Server API answers questions on demand. Every payload is a JWS signed by Apple that the server verifies itself, while an app saying "I bought Pro" is a request anyone can forge.
Notifications
The POST body is {"signedPayload": "<JWS>"}. Decoded, it has notificationType and subtype (SUBSCRIBED, DID_RENEW, DID_CHANGE_RENEWAL_STATUS when auto-renew is turned off, DID_FAIL_TO_RENEW, EXPIRED, REFUND, REVOKE), a notificationUUID, and the signed transaction and renewal info (the example is a decoded grace-period event).
Answer 200 fast and process from a queue. Apple retries failed deliveries, so an event can arrive twice and late: deduplicate on notificationUUID, update by transaction ID, and compare signedDate rather than trusting arrival order.
After an event, fetching the current status (Get All Subscription Statuses) and storing it is safer than applying deltas. Get Notification History fills any gap.
Verifying a JWS
The x5c header carries the certificate chain: check that it leads to Apple Root CA - G3, verify the signature, then bundleId and environment. Apple's App Store Server Library (Swift, Java, Python, Node.js) does this in SignedDataVerifier.
Server API
Authenticated with a JWT signed by your In-App Purchase key. Use it to look up a transaction the app sent, read the subscription status when a user signs in, and pull history for support cases.
verifyReceipt and the app receipt are deprecated: new code sends jwsRepresentation or the transaction ID.
On your server, keyed by your user ID: each store knows only its own purchases. The iOS app passes your account's UUID as appAccountToken when buying, Apple signs it into every transaction and notification, and the backend grants that account the entitlement everywhere.
The flow
The server creates one UUID per account (not the email, no personal data); the app sends it with .appAccountToken(uuid).
After purchase() the app sends jwsRepresentation. The server verifies it, checks that the token matches the signed-in user, and stores the original transaction ID against the account.
Renewals, refunds and expiry arrive as server notifications for that original transaction ID, so the record stays current even if the user never opens the iPhone app again.
Android and the web ask your server what the account has, never Apple.
Decisions to make up front
One Apple subscription, two accounts: a second account on the same iPhone sees the same original transaction. Pick a rule (it stays with the first account, or moves only through support) and enforce it on the server.
Double billing: a web subscriber must never see the iOS paywall, so every paywall asks the server first.
Management stays with the store: an iOS plan is cancelled or changed only in Apple's settings, so the web shows "Managed by the App Store" instead of a Cancel button.
Purchases without a token (made before you had accounts) are linked on the first sign-in, from the transaction the app sends.
Guidelines: the iOS app may unlock what was bought on the web (3.1.3(b)) as long as the same plan is also sold through in-app purchase in the app.
Stop the spread, then roll forward: pause the phased release, switch the crashing path off from the server if you can, and ship a fix (or the previous version's code under a higher version number) with an expedited review request. Users who already updated wait for that build.
The first hour
Measure: crash-free users by version in Crashlytics or the Xcode Organizer, split by OS, device and account state. A third of users points to a condition: a data migration, a locale, one iOS version, a feature flag.
Pause the phased release in App Store Connect: automatic updates stop (for up to 30 days in total), manual updates do not. A release already at 100% cannot be paused.
Fix it from the server if the cause is there: a response field, a config value, a feature flag. A kill switch helps only if the app reads it before the crash.
Roll forward: a hotfix, or a revert: check out the last good tag, bump the version to 2.4.1, build and submit with an expedited review request and the crash numbers. A version cannot be removed from devices, and taking the app off sale stops all downloads without touching installed copies.
Tell users: support and social channels for those who cannot open the app, an in-app message for those who can.
What should have existed
Phased release on by default, internal and TestFlight users on the build first, and per-version crash alerts for the first hours.
Remote flags read early in launch, plus a crash-loop guard: after two launches that crash before the first screen, start in a safe mode without caches and optional features.
Migrations tested from every supported old version, and never able to block launch.
A pipeline that can ship a hotfix within an hour.