PassKit and Apple Pay: Payments and Wallet Passes in iOS


Checkout abandonment rates on mobile hover around 85%, and the number one cause is friction at the payment step. Apple Pay eliminates address forms, card number entry, and CVC lookups with a single biometric confirmation. If your e-commerce app still asks users to type a 16-digit card number, you are leaving revenue on the table.

This post covers both sides of PassKit: accepting payments with Apple Pay and creating Wallet passes (boarding passes, loyalty cards, event tickets). We will also look at iOS 26’s push notification improvements for Wallet passes. StoreKit and in-app purchases are a separate domain — see StoreKit 2: In-App Purchases for that.

Contents

The Problem

Consider Pixar’s official merchandise store — the Toy Barn Online. Andy’s mom wants to buy a Buzz Lightyear action figure. The current checkout flow looks like this:

// The old way — manual card entry
struct CheckoutView: View {
    @State private var cardNumber = ""
    @State private var expirationDate = ""
    @State private var cvc = ""
    @State private var billingAddress = Address()
    @State private var shippingAddress = Address()

    var body: some View {
        Form {
            Section("Card Details") {
                TextField("Card Number", text: $cardNumber)
                    .keyboardType(.numberPad)
                TextField("MM/YY", text: $expirationDate)
                TextField("CVC", text: $cvc)
            }
            Section("Billing Address") {
                // 5 more text fields...
            }
            Section("Shipping Address") {
                // 5 more text fields...
            }
            Button("Pay $29.99") {
                // Tokenize card, send to backend, handle 3D Secure...
            }
        }
    }
}

This is 15+ form fields, PCI compliance burden on your backend, and a checkout flow that users abandon the moment they have to find their wallet. Apple Pay replaces all of this with two lines of meaningful configuration and a system-provided payment sheet that auto-fills shipping address, email, and payment card from the user’s Wallet.

Configuring Apple Pay

Before writing any code, you need three things:

  1. An Apple Developer account enrolled in the Apple Pay Merchant ID program.
  2. A Merchant ID created in the Certificates, Identifiers & Profiles portal — typically formatted as merchant.com.yourcompany.appname.
  3. A Payment Processing Certificate associated with that Merchant ID, which your payment processor (Stripe, Adyen, Braintree, etc.) uses to decrypt payment tokens.

In your Xcode project, add the Apple Pay capability under Signing & Capabilities and select your Merchant ID.

Note: You can test Apple Pay in the Simulator with sandbox cards. Add sandbox cards via Settings > Wallet & Apple Pay in the simulator. Real device testing requires an actual card registered with Apple Pay.

Checking Availability

Not every device supports Apple Pay, and not every user has cards configured. Always check before showing the payment button:

import PassKit

struct ToyBarnCheckoutView: View {
    private let paymentNetworks: [PKPaymentNetwork] = [
        .visa, .masterCard, .amex, .discover
    ]

    var canMakePayments: Bool {
        PKPaymentAuthorizationViewController.canMakePayments(
            usingNetworks: paymentNetworks
        )
    }

    var body: some View {
        VStack {
            if canMakePayments {
                PayWithApplePayButton(.buy, action: handleApplePay)
                    .frame(height: 50)
                    .padding()
            } else {
                // Fallback to traditional checkout or show "Set Up Apple Pay"
                SetUpApplePayButton(action: openWalletSetup)
                    .frame(height: 50)
                    .padding()
            }
        }
    }

    private func openWalletSetup() {
        let library = PKPassLibrary()
        library.openPaymentSetup()
    }

    private func handleApplePay() {
        // We'll build this next
    }
}

Apple Docs: PKPaymentAuthorizationViewController — PassKit

PayWithApplePayButton is a SwiftUI view introduced in iOS 16 that renders the standard Apple Pay button with proper styling. Do not create your own button design — Apple’s App Review guidelines require the standard button for Apple Pay.

Building a Payment Request

The PKPaymentRequest describes what you are selling, what information you need from the buyer, and which payment networks you accept.

final class ToyBarnPaymentHandler: NSObject {
    private let merchantID = "merchant.com.toybarn.store"

    func createPaymentRequest() -> PKPaymentRequest {
        let request = PKPaymentRequest()
        request.merchantIdentifier = merchantID
        request.countryCode = "US"
        request.currencyCode = "USD"
        request.supportedNetworks = [.visa, .masterCard, .amex, .discover]
        request.merchantCapabilities = .threeDSecure

        // What the customer is buying from Toy Barn
        request.paymentSummaryItems = [
            PKPaymentSummaryItem(
                label: "Buzz Lightyear Action Figure",
                amount: NSDecimalNumber(string: "24.99")
            ),
            PKPaymentSummaryItem(
                label: "Slinky Dog Plush Toy",
                amount: NSDecimalNumber(string: "14.99")
            ),
            PKPaymentSummaryItem(
                label: "Shipping",
                amount: NSDecimalNumber(string: "5.99")
            ),
            // The last item MUST be the total with your company name
            PKPaymentSummaryItem(
                label: "Toy Barn Online",
                amount: NSDecimalNumber(string: "45.97"),
                type: .final
            ),
        ]

        // Request shipping address and email
        request.requiredShippingContactFields = [
            .postalAddress, .emailAddress, .name
        ]
        request.requiredBillingContactFields = [.postalAddress]

        // Shipping methods
        request.shippingMethods = [
            PKShippingMethod(
                label: "Standard Shipping (5-7 days)",
                amount: NSDecimalNumber(string: "5.99")
            ),
            PKShippingMethod(
                label: "Express Shipping (2-3 days)",
                amount: NSDecimalNumber(string: "12.99")
            ),
        ]

        return request
    }
}

Warning: The last item in paymentSummaryItems is treated as the total and its label appears as the merchant name on the payment sheet. If you misconfigure this, the sheet will show confusing text to the user. Always put a single total item with your company name last.

A few rules about NSDecimalNumber: use string initializers (NSDecimalNumber(string: "24.99")) rather than float initializers (NSDecimalNumber(value: 24.99)) to avoid floating-point precision issues. A price of $24.99 initialized from a Double can become $24.9899999… and show up as an incorrect amount.

Presenting the Payment Sheet

With the request configured, present the payment authorization view controller. Use the delegate pattern to handle authorization and completion.

extension ToyBarnPaymentHandler: PKPaymentAuthorizationViewControllerDelegate {

    func presentApplePay(from viewController: UIViewController) {
        let request = createPaymentRequest()

        guard let paymentVC = PKPaymentAuthorizationViewController(paymentRequest: request) else {
            // This fails if the request is invalid (e.g., empty summary items)
            return
        }
        paymentVC.delegate = self
        viewController.present(paymentVC, animated: true)
    }

    func paymentAuthorizationViewController(
        _ controller: PKPaymentAuthorizationViewController,
        didAuthorizePayment payment: PKPayment,
        handler completion: @escaping (PKPaymentAuthorizationResult) -> Void
    ) {
        // payment.token contains the encrypted payment data
        // Send this to your backend for processing
        Task {
            do {
                let result = try await processPaymentOnServer(token: payment.token)

                if result.success {
                    completion(PKPaymentAuthorizationResult(status: .success, errors: nil))
                } else {
                    let error = PKPaymentError(.shippingContactInvalidError,
                        userInfo: [NSLocalizedDescriptionKey: "Payment declined."])
                    completion(PKPaymentAuthorizationResult(
                        status: .failure,
                        errors: [error]
                    ))
                }
            } catch {
                completion(PKPaymentAuthorizationResult(
                    status: .failure,
                    errors: [error]
                ))
            }
        }
    }

    func paymentAuthorizationViewControllerDidFinish(
        _ controller: PKPaymentAuthorizationViewController
    ) {
        controller.dismiss(animated: true)
    }

    private func processPaymentOnServer(token: PKPaymentToken) async throws -> PaymentResult {
        // Send token.paymentData to your payment processor's API
        // The encrypted blob is decrypted server-side using your Payment Processing Certificate
        let paymentData = token.paymentData
        // POST to your backend endpoint...
        // Simplified for clarity
        return PaymentResult(success: true)
    }
}

struct PaymentResult {
    let success: Bool
}

SwiftUI Integration

For SwiftUI apps, use PKPaymentAuthorizationController (the non-view-controller variant) or wrap the UIKit presentation in a coordinator:

struct ApplePayCheckoutView: View {
    @State private var paymentStatus: PaymentStatus = .idle
    private let paymentHandler = ToyBarnPaymentHandler()

    var body: some View {
        VStack(spacing: 16) {
            OrderSummaryView()

            PayWithApplePayButton(.buy) {
                presentPayment()
            }
            .frame(height: 50)
            .payWithApplePayButtonStyle(.black)

            if case .success = paymentStatus {
                Label("Payment Complete", systemImage: "checkmark.circle.fill")
                    .foregroundStyle(.green)
            }
        }
        .padding()
    }

    private func presentPayment() {
        let request = paymentHandler.createPaymentRequest()
        let controller = PKPaymentAuthorizationController(paymentRequest: request)
        controller.delegate = paymentHandler
        controller.present()
    }
}

Tip: In production, your ToyBarnPaymentHandler should publish payment status updates through a callback, delegate, or @Observable property so the SwiftUI view can react to success or failure states.

Creating Wallet Passes

The other half of PassKit is Wallet passes — boarding passes, loyalty cards, event tickets, coupons, and generic passes. Imagine the Toy Barn loyalty card: every 10 purchases earns a free toy.

Pass Structure

A Wallet pass is a signed .pkpass bundle containing:

  • pass.json — The pass definition (type, fields, barcode, colors)
  • icon.png / icon@2x.png — Required pass icon
  • logo.png / logo@2x.png — Optional logo
  • manifest.json — SHA-256 hashes of all files
  • signature — Cryptographic signature from your Pass Type ID certificate

The pass is created server-side and delivered to the app. Here is how the JSON for a Toy Barn loyalty card looks:

{
  "formatVersion": 1,
  "passTypeIdentifier": "pass.com.toybarn.loyalty",
  "serialNumber": "TOYBARN-LOYALTY-001",
  "teamIdentifier": "ABCDE12345",
  "organizationName": "Toy Barn",
  "description": "Toy Barn Loyalty Card",
  "foregroundColor": "rgb(255, 255, 255)",
  "backgroundColor": "rgb(44, 62, 145)",
  "labelColor": "rgb(200, 210, 255)",
  "storeCard": {
    "headerFields": [
      {
        "key": "points",
        "label": "POINTS",
        "value": 750,
        "changeMessage": "You now have %@ points!"
      }
    ],
    "primaryFields": [
      {
        "key": "member-name",
        "label": "MEMBER",
        "value": "Andy Davis"
      }
    ],
    "secondaryFields": [
      {
        "key": "tier",
        "label": "TIER",
        "value": "Buzz Lightyear Level"
      }
    ]
  },
  "barcode": {
    "format": "PKBarcodeFormatQR",
    "message": "TOYBARN-LOYALTY-001",
    "messageEncoding": "iso-8859-1"
  }
}

Adding Passes to Wallet Programmatically

Once your server generates the .pkpass bundle, download it in the app and present the add-to-Wallet dialog:

import PassKit

final class WalletPassService {
    private let passLibrary = PKPassLibrary()

    /// Downloads and presents a Toy Barn loyalty pass for adding to Wallet.
    func addLoyaltyPass(
        from url: URL,
        presenter: UIViewController
    ) async throws {
        let (data, response) = try await URLSession.shared.data(from: url)

        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw WalletError.downloadFailed
        }

        let pass = try PKPass(data: data)

        // Check if the pass is already in Wallet
        if passLibrary.containsPass(pass) {
            throw WalletError.passAlreadyExists
        }

        await MainActor.run {
            guard let addController = PKAddPassesViewController(pass: pass) else {
                return
            }
            presenter.present(addController, animated: true)
        }
    }

    /// Checks if the user's Wallet already contains a specific pass.
    func isPassInstalled(passTypeIdentifier: String, serialNumber: String) -> Bool {
        passLibrary.passes().contains { pass in
            pass.passTypeIdentifier == passTypeIdentifier
            && pass.serialNumber == serialNumber
        }
    }
}

enum WalletError: LocalizedError {
    case downloadFailed
    case passAlreadyExists

    var errorDescription: String? {
        switch self {
        case .downloadFailed:
            return "Failed to download the pass from the server."
        case .passAlreadyExists:
            return "This pass is already in your Wallet."
        }
    }
}

Apple Docs: PKPassLibrary — PassKit

SwiftUI Add-to-Wallet Button

For SwiftUI, use AddPassToWalletButton:

struct LoyaltyPassView: View {
    let passData: Data

    var body: some View {
        VStack {
            Text("Your Toy Barn Loyalty Card")
                .font(.headline)

            if let pass = try? PKPass(data: passData) {
                AddPassToWalletButton {
                    pass
                } fallback: {
                    // Shows when the pass can't be added
                    Text("Unable to add pass to Wallet")
                }
                .frame(height: 50)
            }
        }
    }
}

iOS 26: Push Notification Updates for Passes

iOS 26 introduced significant improvements to how Wallet passes receive push updates. Previously, updating a pass required sending a push notification to your web service endpoint, which then responded with the updated pass data — a cumbersome server-to-server flow.

Automatic Pass Updates

The new PKPushRegistry pass update flow in iOS 26 simplifies live updates. Passes can now register for push-type updates directly, and the system handles the refresh cycle more efficiently:

import PassKit
import UserNotifications

final class PassUpdateService {
    private let passLibrary = PKPassLibrary()

    /// Registers for pass update notifications.
    /// iOS 26 streamlines push delivery to installed passes.
    @available(iOS 26, *)
    func registerForPassUpdates() {
        // Enable automatic updates for all installed passes
        // The system now batches and coalesces update checks
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handlePassLibraryChange),
            name: PKPassLibrary.didChangeNotification,
            object: nil
        )
    }

    @objc private func handlePassLibraryChange(_ notification: Notification) {
        // Respond to passes being added, removed, or updated
        let updatedPasses = passLibrary.passes()
        for pass in updatedPasses {
            if pass.passTypeIdentifier == "pass.com.toybarn.loyalty" {
                // Update your UI with the latest pass data
                processUpdatedPass(pass)
            }
        }
    }

    private func processUpdatedPass(_ pass: PKPass) {
        // Extract updated field values
        // Refresh loyalty point display, event time changes, etc.
    }
}

Note: The server-side component for pass updates still requires implementing the Apple Wallet web service protocol. iOS 26 improves the client-side delivery mechanism, but your server must still serve updated .pkpass bundles when the system requests them.

Live Activity Integration

For time-sensitive passes like boarding passes or event tickets, consider pairing Wallet passes with Live Activities. The pass lives in Wallet for long-term storage, while a Live Activity surfaces real-time updates (gate changes, delays) on the Lock Screen. These are complementary technologies, not competitors.

Performance Considerations

Payment sheet presentation is fast. PKPaymentAuthorizationViewController is a system-provided view controller that loads near-instantly. Do not add loading indicators before presenting it — configure the PKPaymentRequest ahead of time and present immediately on tap.

Pass downloads should be small. A .pkpass bundle typically weighs 10-50 KB. If your passes include high-resolution images, keep retina assets under 150 KB total. The system will reject bundles larger than a few MB.

Avoid polling for pass updates. Use PKPassLibrary.didChangeNotification to react to pass changes rather than polling passes() on a timer. The notification fires when any pass is added, removed, or updated.

Payment token processing is time-sensitive. The didAuthorizePayment delegate callback expects you to call the completion handler promptly. If your backend takes more than ~30 seconds to process, the payment sheet may dismiss with an error. Design your server endpoint for fast acknowledgment — process fulfillment asynchronously.

Apple Docs: PKPaymentRequest — PassKit

When to Use (and When Not To)

ScenarioRecommendation
E-commerce checkout for physical goodsApple Pay is ideal — reduces friction, handles shipping address, supports all major networks.
In-app digital purchases (subscriptions, coins)Use StoreKit 2 instead — Apple Pay is for merchant transactions, not App Store purchases.
Loyalty cards and membership programsWallet passes with storeCard type — users see their card in Wallet and get push updates.
Event ticketsWallet passes with eventTicket type — supports barcodes, seat assignments, and real-time updates.
Boarding passesWallet passes with boardingPass type — integrates with airport NFC readers.
Peer-to-peer paymentsApple Pay does not support P2P. Consider Apple Cash integration or a third-party service.
Regions where Apple Pay is not availableAlways implement a fallback checkout flow. Check canMakePayments(usingNetworks:) before showing the Apple Pay button.

One important architectural decision: Apple Pay tokens are single-use. You cannot store a token for recurring charges. For subscriptions billed through your own payment processor (not StoreKit), use Apple Pay to collect the initial payment method, then store the resulting payment method ID from your processor (Stripe’s PaymentMethod, Braintree’s nonce) for future charges.

Summary

  • Apple Pay replaces 15+ form fields with a single biometric confirmation — use it for any merchant transaction to reduce checkout abandonment.
  • PKPaymentRequest defines what you sell, the networks you accept, and what buyer information you need — the last summary item must be your total with your company name.
  • Use NSDecimalNumber(string:) for prices to avoid floating-point precision bugs.
  • Wallet passes are server-generated .pkpass bundles added via PKAddPassesViewController — keep bundles small and implement the web service protocol for push updates.
  • iOS 26 improves push notification delivery for Wallet passes with more efficient system-managed update cycles.

For digital goods and subscriptions within your app, see StoreKit 2: In-App Purchases. To explore Apple’s financial data APIs for budgeting and transaction history, check out FinanceKit.