Skip to navigation

Purchasing

The WhopCheckout SDK handles the entire purchase flow: displaying a checkout sheet, processing payment, and updating membership status automatically.

Displaying Available Plans

Access your plans through the checkout.plans array. Each plan includes pricing and display information:

struct PlansView: View {
@Environment(Checkout.self) var checkout
var body: some View {
Group {
if checkout.plans.isEmpty {
ProgressView("Loading plans...")
} else {
List(checkout.plans) { plan in
HStack {
VStack(alignment: .leading) {
Text(plan.title ?? "Subscription")
.font(.headline)
Text(plan.description ?? "")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Text(plan.initialPrice, format: .currency(code: plan.baseCurrency))
.bold()
}
}
}
}
}
}

Plans are fetched during configure(). The plans array will be empty until configuration completes successfully. You can refresh plans later using refreshPlans().

| Property | Type | Description | |----------|------|-------------| | id | String | Plan ID (starts with plan_) | | title | String? | Display name | | description | String? | Description text | | initialPrice | Double | Price amount | | baseCurrency | String | ISO currency code (e.g., “USD”) | | planType | PlanType | .oneTime or .renewal | | renewalPeriod | RenewalPeriod? | .monthly, .yearly, etc. |

Initiating a Purchase

Call purchase() with a plan ID. The SDK presents a checkout sheet, handles payment, and returns when complete:

struct PurchaseButton: View {
@Environment(Checkout.self) var checkout
let planId: String
@State private var isPurchasing = false
@State private var error: Error?
var body: some View {
Button {
Task {
await makePurchase()
}
} label: {
if isPurchasing {
ProgressView()
} else {
Text("Subscribe")
}
}
.disabled(isPurchasing)
.alert("Purchase Failed", isPresented: .constant(error != nil)) {
Button("OK") { error = nil }
} message: {
Text(error?.localizedDescription ?? "")
}
}
func makePurchase() async {
isPurchasing = true
defer { isPurchasing = false }
do {
let result = try await checkout.purchase(planId)
print("Purchase complete! Receipt: \(result.receiptId)")
} catch WhopCheckoutError.cancelled {
// User dismissed the checkout - not an error
} catch {
self.error = error
}
}
}

What Happens During Purchase

1

Checkout sheet appears

The SDK presents a native sheet with the Whop payment form (US) or StoreKit (elsewhere).

2

User completes payment

User enters payment details and confirms. Supports cards, Apple Pay, and more.

3

Sheet dismisses automatically

Once payment succeeds (or user cancels), the sheet closes.

4

Membership updates

The SDK refreshes checkout.memberships and isSubscribed returns true.

func purchase(_ planId: String, method: PaymentMethod? = nil) async throws -> CheckoutPurchaseResult

Parameters:

  • planId: The plan ID to purchase (starts with plan_)
  • method: Optional override for payment method (.whop or .storeKit). For Apple Pay, use Checkout.ApplePayButton instead.

Returns: CheckoutPurchaseResult with receiptId and membership details.

Throws:

  • WhopCheckoutError.cancelled - User dismissed checkout
  • WhopCheckoutError.notConfigured - SDK not configured
  • WhopCheckoutError.paymentFailed(String) - Payment was declined

Handling Purchase Results

The CheckoutPurchaseResult gives you confirmation details:

let result = try await checkout.purchase("plan_xxxxxxxxxxxxxx")
// Access the new membership (for Whop purchases)
if let membership = result.membership {
print("Membership ID: \(membership.id)")
print("Product: \(membership.productId)")
print("Status: \(membership.status)")
}
// Store receipt for your records
saveReceipt(result.receiptId)
PropertyTypeDescription
receiptIdStringUnique receipt identifier
membershipCheckoutMembership?The created/updated membership (nil for StoreKit-only purchases)

Membership properties:

PropertyTypeDescription
idStringMembership ID
productIdStringAssociated product ID
statusStatus.active, .cancelled, .expired, etc.
isActiveBoolWhether the membership grants access
expiresAtDate?When the membership expires
createdAtDateWhen the membership was created

Error Handling

Always handle the possible error cases:

do {
let result = try await checkout.purchase("plan_xxxxxxxxxxxxxx")
showSuccessMessage()
} catch WhopCheckoutError.cancelled {
// User tapped outside the sheet or hit cancel
// This is normal - don't show an error
} catch WhopCheckoutError.notConfigured {
// SDK not configured - shouldn't happen in production
showError("Please restart the app.")
} catch WhopCheckoutError.paymentFailed(let message) {
// Card declined, insufficient funds, etc.
showError("Payment failed: \(message)")
} catch {
// Unexpected error
showError("Something went wrong. Please try again.")
}

Don’t treat WhopCheckoutError.cancelled as an error. Users commonly dismiss checkout sheets to think about their purchase or check something else first.

Refreshing Plans

You can refresh plans after initialization to get updated pricing or availability:

// Refresh and use the return value
let plans = try await Checkout.shared.refreshPlans()
// Or just refresh (plans property updates automatically)
try await Checkout.shared.refreshPlans()

Complete Example

A full paywall view combining plans display and purchasing:

struct PaywallView: View {
@Environment(Checkout.self) var checkout
@Environment(\.dismiss) var dismiss
@State private var selectedPlan: CheckoutPlan?
@State private var isPurchasing = false
@State private var errorMessage: String?
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Text("Go Premium")
.font(.largeTitle)
.bold()
Text("Unlock all features")
.foregroundStyle(.secondary)
}
.padding(.top, 32)
// Plans
VStack(spacing: 12) {
ForEach(checkout.plans) { plan in
PlanCard(
plan: plan,
isSelected: selectedPlan?.id == plan.id
) {
selectedPlan = plan
}
}
}
.padding(.horizontal)
Spacer()
// Purchase button
Button {
Task { await purchase() }
} label: {
Group {
if isPurchasing {
ProgressView()
} else {
Text("Continue")
}
}
.frame(maxWidth: .infinity)
.padding()
.background(selectedPlan == nil ? Color.gray : Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(selectedPlan == nil || isPurchasing)
.padding(.horizontal)
.padding(.bottom)
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.alert("Error", isPresented: .constant(errorMessage != nil)) {
Button("OK") { errorMessage = nil }
} message: {
Text(errorMessage ?? "")
}
}
}
func purchase() async {
guard let plan = selectedPlan else { return }
isPurchasing = true
defer { isPurchasing = false }
do {
_ = try await checkout.purchase(plan.id)
dismiss()
} catch WhopCheckoutError.cancelled {
// User cancelled - do nothing
} catch WhopCheckoutError.paymentFailed(let message) {
errorMessage = message
} catch {
errorMessage = "Something went wrong. Please try again."
}
}
}
struct PlanCard: View {
let plan: CheckoutPlan
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading) {
Text(plan.title ?? "Plan")
.font(.headline)
if let period = plan.renewalPeriod {
Text(period.description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Spacer()
Text(plan.initialPrice, format: .currency(code: plan.baseCurrency))
.bold()
}
.padding()
.background(isSelected ? Color.accentColor.opacity(0.1) : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
}
}

Quick Reference

Method/PropertyPurpose
checkout.plansArray of available plans
checkout.purchase(_:)Initiate checkout for a plan
checkout.refreshPlans()Refresh plans from server
WhopCheckoutError.cancelledUser dismissed checkout
WhopCheckoutError.paymentFailedPayment was declined

Next Steps