StoreKit 2: one-time purchase with a custom trial
August 2026
reell Focus is a focus timer for iPhone. It has no account, no server, no sync. You buy it once and it is yours. That decision was made before a single line of purchase code existed, and it shaped everything that came after.
The reasoning is short. A subscription is a promise of ongoing work: servers to keep running, data to keep safe, a service that would stop working if we stopped paying for it. reell Focus has none of that. Everything stays on the device. Charging monthly for something that costs us nothing per month would be a business model in search of a justification.
What that means in code
A one-time purchase is a non-consumable product. Compared with an auto-renewable subscription, a lot of machinery simply disappears: no renewal logic, no grace periods, no billing retry state, no status transitions, no server notifications to receive and verify. There is exactly one question to answer, and it never changes: does this Apple ID own the product?
func refreshPurchased() async {
var owned = false
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
transaction.productID == Self.productID,
transaction.revocationDate == nil {
owned = true
}
}
isPurchased = owned
didCheckEntitlements = true
}
That is the whole entitlement check. currentEntitlements reads the local
receipt, so it works offline, which matters for an app that promises to work
without a network.
One thing does get harder, though. With a subscription, the account carries
the purchase and users are used to signing in. With a one-time purchase and no
account of any kind, restore becomes mandatory, not a nicety. New phone, new
install, family member’s device: the only path back is AppStore.sync()
followed by a fresh entitlement check.
func restore() async {
try? await AppStore.sync()
await refreshPurchased()
}
The free trial, without a subscription
This is the part nobody tells you: Apple’s introductory offers only exist for subscriptions. There is no built-in “7 days free, then pay once”. If you want a trial on a non-consumable, you build it yourself.
The naive version stores the first launch date in UserDefaults and compares.
It works, and it resets the moment someone deletes and reinstalls the app. So
the date lives in two places, and the earlier one wins:
private func resolveInstallDate() {
let keychainDate = Keychain.shared.date(forKey: installKey)
let defaultsDate = UserDefaults.standard.object(forKey: installKey) as? Date
// The earliest known date wins; otherwise now.
let date = [keychainDate, defaultsDate].compactMap { $0 }.min() ?? Date()
if keychainDate == nil { Keychain.shared.set(date, forKey: installKey) }
if defaultsDate != date { UserDefaults.standard.set(date, forKey: installKey) }
}
The keychain item survives deleting the app, so reinstalling does not hand out
another seven days. It is stored with kSecAttrAccessibleAfterFirstUnlock,
which means it is readable in the background after the first unlock following a
reboot.
Now the honest part, the compromises we accepted knowingly:
A new device gives a new trial. The keychain item is device-local. Someone who really wants a second trial can get one by switching phones. We decided that is fine: the effort is larger than the price of the app.
Setting the clock back extends the trial. We compare against the system clock, so moving it backwards works. The alternative is asking a server for the time, and a server is exactly what this app does not have. Someone willing to change their system time to save the price of a coffee is not a customer we are losing.
Nothing is verified server-side, because there is no server. The trial is a courtesy, not a lock. The actual purchase is verified by StoreKit.
What we did not accept was punishing honest users. isUnlocked is the union
of both states, never an intersection:
var isUnlocked: Bool { isPurchased || isInTrial }
SwiftUI and StoreKit 2, no third-party library
There is no purchase SDK in this app. StoreKit 2 with async/await is enough:
Product.products(for:) to load, product.purchase() to buy,
Transaction.currentEntitlements to check, Transaction.updates to catch
purchases that arrive from elsewhere, such as an interrupted purchase completing
later or a family member’s device.
private func observeTransactionUpdates() {
Task(priority: .background) { [weak self] in
for await result in Transaction.updates {
if case .verified(let transaction) = result {
await transaction.finish()
await self?.refreshPurchased()
}
}
}
}
Two details that cost us more time than the purchase flow itself:
The paywall flash. At launch, isPurchased is false until the first
entitlement check finishes. A paying customer would see the paywall blink for a
fraction of a second. The fix is a second flag, didCheckEntitlements, and a UI
that waits for it. If it never arrives, no paywall is shown at all: failing open
is the right direction here.
The price when StoreKit does not answer. Offline, or when the product fails
to load, product.displayPrice is nil. Showing “Unlock” with no price is worse
than showing an approximate one, so there is a currency-aware fallback that
formats the base amount in the device’s currency, with the real localised price
taking over as soon as StoreKit responds.
Testing without drowning in the sandbox
We tested on two tracks: in the simulator against a local StoreKit configuration, and on a real iPhone against the actual sandbox. That split is why there is no sandbox horror story to tell here.
The configuration is a .storekit file in the project that describes the
product once:
{
"products": [
{ "productID": "imflow.pro", "type": "NonConsumable",
"displayPrice": "7.99", "referenceName": "reell Focus Pro" }
],
"settings": { "_locale": "de_CH", "_failTransactionsEnabled": false }
}
With that, the whole purchase runs locally: no sandbox account, no network delay, no transaction arriving ten minutes later. Xcode can also force failures from there, which is what makes the interesting states testable at all: purchase fails, purchase cancelled, product does not load. Those are the cases you rarely see in the sandbox, because there everything usually works.
What the simulator cannot do is the real flow: the password prompt, the system
sheet, Apple’s receipt, AppStore.sync() against a real Apple ID. That needs a
device and a sandbox account, created in App Store Connect and entered on the
iPhone under the developer settings, not in the normal App Store settings. Mixing
those two up is the most common reason a sandbox purchase suddenly wants real
money.
The second part of testing has nothing to do with StoreKit: the trial. Waiting seven days is not a test method, and moving the system clock forward confuses the device. So there are two switches that only exist in debug builds:
#if DEBUG
var debugTrialDaysOverride: Int? // 0 = expired -> paywall
func resetTrial() {
Keychain.shared.removeItem(forKey: installKey)
UserDefaults.standard.removeObject(forKey: installKey)
debugTrialDaysOverride = nil
resolveInstallDate()
}
#endif
The reset has to clear both stores, the keychain item and UserDefaults. That
is exactly where you notice during testing whether the double storage works: if
you clear only one, the trial stays expired, and that is the correct answer.
Before submitting
With a one-time purchase and a self-built trial, review tends to snag on three things. All three are cheap to satisfy if you know them beforehand.
Restore has to be visible. Not buried in settings, but where the purchase prompt is. In this app it is a button right under the price, labelled “Restore purchases”. Without it, rejection is certain: with no account, there is no other way back to the purchased state.
Price and scope belong on the paywall, not in the fine print. What it costs, what you get, and that it is a single purchase. The price comes from StoreKit, not from a string constant: otherwise some country sees a number that is wrong, and that is a rejection reason of its own.
The trial must not sound like a subscription. Seven days free, then one purchase, no subscription, no auto-renewal, nothing to cancel. Describing a self-built trial in the words of a subscription creates an expectation the app cannot meet.

What it looks like: the price on the button, “one-time purchase, no subscription” as the first line, and restore right below it instead of buried in settings. The dollar amount comes from the local test configuration; in the store it is the price of the respective country.
On top of that comes the App Store Connect homework, which has nothing to do with code: create the product and set the price, fill in the privacy details, and, because this app collects nothing, actually select “data not collected”. A privacy URL pointing at an empty page costs you a round.
What I would do differently
Build the trial as a real date range, not as a day counter. Everything is
derived from daysSinceInstall, which is a Calendar calculation against
“now”. A stored end date would have been simpler to reason about, simpler to
display, and simpler to test.
Decide about the second device earlier. We shipped the keychain approach and only afterwards thought through what it means when someone changes phones. That is the wrong order: the question is a product decision, not an implementation detail, and it deserved five minutes at the whiteboard rather than a fix in code.
Put the debug switches in from day one. debugTrialDaysOverride and
resetTrial() exist now, and every hour before they existed was spent deleting
the app, waiting, or moving the clock. They are three lines each.
reell Focus is in the App Store, one purchase, seven days free before it.