Skip to main content

Making Purchases

AppActor handles the full purchase lifecycle โ€” from initiating a purchase to validating the receipt and updating entitlements.

How Purchases Workโ€‹

Making a Purchaseโ€‹

let offerings = try await AppActor.shared.offerings()
guard let offering = offerings.current else { return }
guard let monthly = offering.monthly else { return }

do {
let result = try await AppActor.shared.purchase(package: monthly)

switch result {
case .success(let customerInfo):
if customerInfo?.hasActiveEntitlement("premium") == true {
showPremiumContent()
}
case .pendingServerValidation:
// Receipt is queued and will retry automatically
showSyncingMessage()
case .failedServerValidation(let errorCode, let message, let requestId):
showValidationError(code: errorCode, message: message, requestId: requestId)

case .cancelled:
// User cancelled the purchase โ€” no action needed
break

case .pending:
// Transaction requires approval (Ask to Buy) or
// Strong Customer Authentication (SCA/3DS)
// The transaction will arrive later via Transaction.updates
showPendingMessage()
}
} catch {
// Handle purchase error
print("Purchase failed: \(error.localizedDescription)")
}

Purchase Statesโ€‹

ResultDescriptionAction
.success(AppActorServerCustomerInfo?)Purchase completed and server validatedCheck entitlements, unlock features
.pendingServerValidationReceipt queued for retryShow syncing/pending state
.failedServerValidationReceipt permanently rejectedShow error + support code (requestId)
.cancelledUser dismissed the payment sheetNo action needed
.pendingAwaiting external approvalShow a "pending" message; the SDK will update when the transaction is approved

Double-Purchase Preventionโ€‹

The SDK prevents multiple simultaneous purchases with a built-in concurrency guard. If a purchase is already in progress, calling purchase() again will throw an error.

struct BuyButton: View {
let package: AppActorPackage
@State private var isPurchasing = false

var body: some View {
Button("Subscribe") {
Task {
guard !isPurchasing else { return }
isPurchasing = true
defer { isPurchasing = false }
do {
_ = try await AppActor.shared.purchase(package: package)
} catch let error as AppActorBillingError where error.kind == .purchaseAlreadyInProgress {
// Another purchase is already active
} catch {
// Handle other purchase errors
}
}
}
.disabled(isPurchasing)
}
}

Receipt Pipeline (Payment Mode)โ€‹

In payment mode, the receipt pipeline handles validation automatically:

The SDK queues receipts and retries failed validations automatically. You don't need to handle retry logic.

Receipt Pipeline Observabilityโ€‹

Use these APIs to inspect retry state and support/debug failed validations:

// Trigger a manual drain of pending receipt events
await AppActor.shared.syncPurchases()

// Queue counters
let pending = await AppActor.shared.pendingReceiptCount
let deadLettered = await AppActor.shared.deadLetteredReceiptCount
print("pending=\(pending), deadLettered=\(deadLettered)")

// Detailed queue snapshot
let snapshot = await AppActor.shared.receiptQueueSnapshot()
for item in snapshot {
print("\(item.id) status=\(item.status) attempts=\(item.attemptCount)")
}

// Optional pipeline callback for diagnostics
AppActor.shared.onReceiptPipelineEvent = { event in
print("pipeline event: \(event)")
}

Consumable Productsโ€‹

Consumable products (like coins or gems) are tracked differently. After purchase, the balance is automatically incremented.

// Check balance
let coins = await AppActor.shared.consumableBalance(for: "com.myapp.coins")

// Spend consumables
let success = await AppActor.shared.consumeProduct("com.myapp.coins", amount: 5)
if success {
// 5 coins deducted
} else {
// Insufficient balance
}
Local Mode Only

In local mode, consumable balances are stored in UserDefaults on the device. If the app is deleted, balances are lost. In payment mode, balances are managed server-side.

Error Handlingโ€‹

do {
let result = try await AppActor.shared.purchase(package: package)
// Handle result...
} catch let error as AppActorBillingError where error.kind == .purchaseAlreadyInProgress {
// Another purchase is already in progress
} catch let error as AppActorBillingError where error.kind == .validation {
// Invalid package/product input
print(error.localizedDescription)
} catch let error as AppActorBillingError {
// Network/server/storekit related billing error
print("Billing error: \(error.localizedDescription)")
} catch {
// Other error
print("Unexpected error: \(error)")
}

Next Stepsโ€‹