User charged, but .userCancelled returned

Hello,

Is anyone else seeing Purchase.PurchaseResult.UserCancelled, despite a successful transaction?

I had a user notify me today that he:

  1. Attempted a purchase
  2. Entered payment credentials
  3. Was asked to opt in to email subscription notifications
  4. Opted In
  5. Was shown my app's "User Canceled Purchase" UI
  6. Attempted to repurchase
  7. Was alerted that he was "Already Subscribed"

I have adjusted my code to check Transaction.currentEntitlements on receiving a .userCancelled result, to avoid this in the future. Is this logically sound?

Here is my code - please let me know if you see any issues:

func purchase(product: Product, userId: String) async throws -> StoreKit.Transaction {
    let purchaseUUID = UUID()        
    let options: Set<Product.PurchaseOption> = [.appAccountToken(purchaseUUID)]
    let result = try await product.purchase(options: options)
    
    switch result {
    case .success(let verification):
        guard case .verified(let tx) = verification else {
            throw PurchaseError.verificationFailed // Show Error UI
        }
        return try await processVerified(tx)
                    
    case .userCancelled:
        for await result in Transaction.currentEntitlements {
            if case .verified(let tx) = result, tx.productID == product.id, tx.revocationDate == nil {
                return try await processVerified(tx)
            }
        }

        throw PurchaseError.cancelled // Show User Cancelled UI
    case .pending:
        throw PurchaseError.pending // Show Pending UI
    @unknown default:
        throw PurchaseError.unknown // Show Error UI
    }
}

@MainActor
func processVerified(_ transaction: StoreKit.Transaction) async throws -> StoreKit.Transaction {
    let id = String(transaction.id)

    if await transactionCache.contains(id) {
        await transaction.finish()
        return transaction // Show Success UI
    }

    let (ok, error) = await notifyServer(transaction)
    guard ok else {
        throw error ?? PurchaseError.serverFailure(nil) // Show Error UI
    }
    await transaction.finish()
    await transactionCache.insert(id)
    return transaction  // Show Success UI
}

The only place the "User Cancelled Purchase" UI is displayed on my app is after the one instance of "throw PurchaseError.cancelled" above.

This happened in Production, but I have also seen userCancelled happen unexpectedly in Sandbox.

Thank you for your time and help.

I’ve confirmed this is a StoreKit bug: when you dismiss the Turn Off Renewal Receipt Emails dialog by tapping Keep Renewal Emails, StoreKit treats it like the payment sheet and returns .userCancelled—even if the purchase went through.

Below is a temporary, functional (but imperfect) workaround that polls Transaction.currentEntitlements after .userCancelled:

func purchase(product: Product, userId: String) async throws -> StoreKit.Transaction {
    let purchaseUUID = UUID()        
    let options: Set<Product.PurchaseOption> = [.appAccountToken(purchaseUUID)]
    let result = try await product.purchase(options: options)
    
    switch result {
    case .success(let verificationResult):
        switch verificationResult {
            case .verified(let tx):
                logPurchaseResult(
                    context: "New Purchase – verified success",
                    status: "processing",
                    transaction: tx
                )
                return try await processVerified(tx)
            case .unverified(let tx, let verificationError):
                return try await handleUnverifiedTransaction(
                    source: ".success", 
                    tx: tx, 
                    verificationError: verificationError
                )
        }
                    
    case .userCancelled:
        // Attempting to work around the StoreKit bug by checking for valid transactions
        // that may exist despite receiving .userCancelled
        let maxRetries = 10
        let retryIntervalSeconds: UInt64 = 1_000_000_000 // 1 second
        let startTime = Date()
                    
        for retryCount in 0..<maxRetries {
            do {
                for await verificationResult in Transaction.currentEntitlements {
                    switch verificationResult {
                    case .verified(let tx):
                        if tx.productID == product.id, tx.revocationDate == nil {
                            logPurchaseResult(
                                context: "Found verified entitlement after cancel: retry \(retryCount), time \(elapsedTime(since: startTime))",
                                status: "recovered",
                                transaction: tx
                            )
                            return try await processVerified(tx)
                        }
                        
                    case .unverified(let tx, let verificationError):
                        return try await handleUnverifiedTransaction(
                            source: ".userCancelled - retry \(retryCount), time \(elapsedTime(since: startTime))", 
                            tx: tx, 
                            verificationError: verificationError
                        )
                    }
                }
                
                try await Task.sleep(nanoseconds: retryIntervalSeconds)
            } catch {
                // Log the error but continue retrying
                logPurchaseResult(
                    context: "Error during cancellation recovery: \(error.localizedDescription)",
                    status: "retrying"
                )
            }
        }
        
        logPurchaseResult(
            context: "Purchase cancelled (no entitlement found after \(maxRetries) retries)",
            status: "userCancelled"
        )
        throw PurchaseError.cancelled
        
    case .pending:
        logPurchaseResult(
            context: "Purchase – Pending",
            status: "pending"
        )
        throw PurchaseError.pending
        
    @unknown default:
        logPurchaseResult(
            context: "Purchase – Unknown result",
            status: "unknown"
        )
        throw PurchaseError.unknown
    }
}

This workaround has drawback, on top of needless complexity:

  • Poor UX: Genuine cancellations stall for 10s before the UI updates.
  • Unreliable: In our tests, entitlement recovery takes 6-7s—and may not arrive in 10s.

This clearly needs an Apple-side fix. In the meantime:

  1. Has anyone found a cleaner workaround?
  2. Can an Apple engineer confirm if this is being addressed?
  3. Is this polling approach acceptable until StoreKit is updated?

Thanks!

User charged, but .userCancelled returned
 
 
Q