Hello!
We are implementing consumable IAP for iOS using StoreKit 2 together with our own server backend.
Our happy path looks like this:
1. Call /prepare on our server
• We only allow one purchase at a time, so this fails if a pending transaction already exists.
2. Call /purchase via StoreKit
3. If successful, call /complete on our server
4. Call .finish() on the Transaction
⸻
The Problem
Some users report being charged by Apple but not receiving coins.
I suspect this happens in rare cases where the purchase flow throws an exception:
• Every time we throw, we immediately cancel the transaction on our server.
• However, some errors may actually be temporary. StoreKit seems to recover by later sending an update through Transactions.updates.
• When that happens, since the transaction was already canceled on our server, we cannot match it. As a result, we just call .finish() without granting consumables.
⸻
Additional Observations
From user logs, the issue tends to appear when the following errors are reported:
• リクエストを完了できません。
• Triggered by: StoreKit.notEntitled
• Triggered by: StoreKit.unknown
• ヘルパーアプリケーションと通信できませんでした。
I was not able to reproduce the last one, even when testing all possible StoreKit configuration errors. Some sources suggest this may be a rare case where the app cannot communicate with the App Store itself.
Additionally, affected users often seem to be using non-standard payment methods (PayPay, gift cards, carrier billing, etc.) rather than credit cards.
⸻
My Question
What is the best practice for handling StoreKitError and PurchaseError?
Specifically:
• Should some of these errors be treated as temporary instead of final?
• How should we ensure users always receive their consumables in such cases?
⸻
Code
do {
let result = try await wrapper.product.purchase()
switch result {
case .success(let result):
let transaction = try StoreKitVerifier.checkVerified(result)
let iOSPurchase = IOSPurchase(transaction: transaction, jws: result.jwsRepresentation)
return .Success(purchase: iOSPurchase)
case .userCancelled:
return .Canceled()
case .pending:
return .Pending()
@unknown default:
return .Error(
message: "Purchase result doesn't match with anything. This must not be executed")
}
} catch {
return .Error(message: error.localizedDescription)
}