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
- Configuring Apple Pay
- Building a Payment Request
- Presenting the Payment Sheet
- Creating Wallet Passes
- iOS 26: Push Notification Updates for Passes
- Performance Considerations
- When to Use (and When Not To)
- Summary
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:
- An Apple Developer account enrolled in the Apple Pay Merchant ID program.
- A Merchant ID created in the
Certificates, Identifiers & Profiles portal — typically formatted as
merchant.com.yourcompany.appname. - 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
paymentSummaryItemsis treated as the total and itslabelappears 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
ToyBarnPaymentHandlershould publish payment status updates through a callback, delegate, or@Observableproperty 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 iconlogo.png/logo@2x.png— Optional logomanifest.json— SHA-256 hashes of all filessignature— 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
.pkpassbundles 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)
| Scenario | Recommendation |
|---|---|
| E-commerce checkout for physical goods | Apple 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 programs | Wallet passes with storeCard type — users see their card in Wallet and get push updates. |
| Event tickets | Wallet passes with eventTicket type — supports barcodes, seat assignments, and real-time updates. |
| Boarding passes | Wallet passes with boardingPass type — integrates with airport NFC readers. |
| Peer-to-peer payments | Apple Pay does not support P2P. Consider Apple Cash integration or a third-party service. |
| Regions where Apple Pay is not available | Always 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.
PKPaymentRequestdefines 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
.pkpassbundles added viaPKAddPassesViewController— 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.