Why start a Transaction.updates listener at launch, and what do finish() and idempotent delivery guarantee?
Predict first
How many coins does each handler credit when transaction 1 is delivered twice and transaction 2 once?
actor Ledger {
var granted: Set<UInt64> = []
var coins = 0
var unfinished: Set<UInt64> = []
func deliverNaively(_ id: UInt64, coins n: Int) { unfinished.insert(id); coins += n }
func deliverOnce(_ id: UInt64, coins n: Int) {
unfinished.insert(id)
if granted.insert(id).inserted { coins += n }
}
func finish(_ id: UInt64) { unfinished.remove(id) }
}
// transaction 1 is delivered, the app dies before finish(), and it is delivered again next launch
let deliveries: [(UInt64, Int)] = [(1, 100), (1, 100), (2, 500)]
let naive = Ledger(), safe = Ledger()
for (id, n) in deliveries { await naive.deliverNaively(id, coins: n) }
for (id, n) in deliveries {
await safe.deliverOnce(id, coins: n)
await safe.finish(id)
}
print("naive coins:", await naive.coins, "unfinished left:", await naive.unfinished.count)
print("idempotent coins:", await safe.coins, "unfinished left:", await safe.unfinished.count)
- naive 700 coins with 2 unfinished, idempotent 600 coins with 0 unfinished
- both credit 600 coins
- naive credits 600 and idempotent credits 700
Mental model. Finishing a transaction is signing the delivery note: until the store sees your signature it may knock again, so the second delivery must be harmless.
A purchase can complete when your purchase call is not running: a renewal at 3 a.m., an Ask to Buy approval, a refund, a purchase made on the iPad, a promo-code redemption, or a purchase that finished while the app was killed. All of those arrive on Transaction.updates, an AsyncSequence you must iterate for the whole lifetime of the app: start a detached Task in App.init or the app delegate before showing UI, keep a reference, and cancel it only on teardown. Apple's documentation also tells you to do this at launch because unfinished transactions are delivered through the same path. Each element is a VerificationResult<Transaction>; verify it, update your entitlement state, deliver, and then await transaction.finish(). Finishing tells the store you have delivered the content; until then it can hand the transaction back to you again.
That redelivery is the reason delivery must be idempotent: key every grant by transaction.id (or originalID for subscriptions) and make "grant" a no-op the second time. The check models a crash between the grant and finish(): a naive handler credits 100 coins twice and leaves nothing marked finished, while the idempotent handler credits once per transaction id and finishes each. Persist the processed-id set (a database, not memory) in the same transaction as the grant, otherwise the set and the balance can disagree after a crash. Subscriptions and non-consumables do not need balances, but they still flow through updates so you can refresh a cached entitlement; consumables are the case where double delivery costs real money. Transaction.unfinished lets you drain pending ones explicitly, which is useful in a settings "Restore" action or a test.
The naive handler adds on every delivery; the idempotent one records the id and finishes.
Common traps
- Starting the listener lazily, when the paywall opens. Everything that completed outside the app is missed until then.
- Calling
finish()before the grant is saved. A crash then loses the delivery with nothing left to redeliver; grant (durably) first, finish second.
They might follow up with
- Does a subscription renewal need
finish()? Yes, every transaction you process should be finished once delivered. - Where should the listener live? In an app-lifetime object (a store/manager), not in a view that can disappear.
Related questions
- 28.1.2 What can product.purchase() return or throw, and how should each outcome behave in the UI?
- 28.1.3 What does a .verified StoreKit result prove, what does it not prove, and when would you read…
- 28.3.1 How do you compute what a user is entitled to, and which transactions are excluded from…
- 17.4.2 How does StoreKit 2 work: products, purchases, transaction verification, entitlements, refunds, and what must…
← What does a .verified StoreKit result prove, what does it not prove…