references/api-reference.md
# ProximityReader API Reference
Complete reference for all types in the ProximityReader framework.
## Table of Contents
1. [Payment Card Reader](#payment-card-reader)
2. [Payment Requests & Results](#payment-requests--results)
3. [Store and Forward](#store-and-forward)
4. [Loyalty / VAS](#loyalty--vas)
5. [Mobile Document Reader](#mobile-document-reader)
6. [Mobile Document Requests](#mobile-document-requests)
7. [Merchant Discovery](#merchant-discovery)
8. [Errors](#errors)
---
## Payment Card Reader
### `class PaymentCardReader`
Configures Tap to Pay on iPhone on the current device.
```swift
class PaymentCardReader {
// Nested Types
struct Token {
let rawValue: String
init(rawValue: String)
}
// Initialization
init()
// Account Management
func isAccountLinked(using token: Token) async throws -> Bool
func linkAccount(using token: Token) async throws
// Device Capability
static var isSupported: Bool { get }
// Reader Identification
var readerIdentifier: String { get async throws }
}
```
**Usage notes:**
- `isSupported` — Check before attempting any reader operations. Returns `false` on unsupported devices (pre-iPhone XS) or missing entitlements.
- `linkAccount(using:)` — Presents a system sheet for the merchant to accept Tap to Pay T&Cs. Only needed once per device/merchant.
- `isAccountLinked(using:)` — Quick check to see if the account setup has been completed.
### `class PaymentCardReaderSession`
Manages a payment card reading session.
```swift
class PaymentCardReaderSession {
// Initialization
init(reader: PaymentCardReader, token: PaymentCardReader.Token) async throws
// Session Lifecycle
func prepare() async throws
func cancelRead() async throws
// Reading Cards
func readPaymentCard(_ request: PaymentCardTransactionRequest) async throws -> PaymentCardReadResult
func readPaymentCard(_ request: PaymentCardTransactionRequest, vasRequest: VASRequest) async throws -> PaymentCardReadResult
func readPaymentCard(_ request: PaymentCardVerificationRequest) async throws -> PaymentCardReadResult
func readPaymentCard(_ request: PaymentCardVerificationRequest, vasRequest: VASRequest) async throws -> PaymentCardReadResult
// Events
var events: AsyncStream<PaymentCardReaderSession.Event> { get }
// Nested Types
enum Event {
case updateProgress(Int) // 0-100
case notReady
case ready
case readNotCompleted
case readCompleted
case readRetry
case removeCard
case readCancelled
}
enum ReadError: Error {
case cancelled
case invalidAmount
case notReady
case readNotAllowed
case deviceBanned
case networkError
case pinEntryTimeout
case pinEntryFailed
case readFromBackgroundError
}
}
```
**Usage notes:**
- `prepare()` — Must be called before `readPaymentCard()`. Also call after backgrounding and after each transaction.
- `events` — AsyncStream for UI updates (progress, read state). Use in SwiftUI with `.task { for await event in session.events { ... } }`.
- `cancelRead()` — Cancels an in-progress card read.
---
## Payment Requests & Results
### `struct PaymentCardTransactionRequest`
```swift
struct PaymentCardTransactionRequest {
let amount: Decimal
let currencyCode: String
let type: TransactionType
enum TransactionType {
case purchase
case refund
}
init(amount: Decimal, currencyCode: String, type: TransactionType)
}
```
**Notes:**
- `amount` must be positive (> 0)
- `currencyCode` uses ISO 4217 (e.g. "USD", "EUR", "GBP")
- For refunds, use `.refund` type with the refund amount
### `struct PaymentCardVerificationRequest`
```swift
struct PaymentCardVerificationRequest {
let currencyCode: String
init(currencyCode: String)
}
```
Used to verify card details without charging (e.g. card-on-file enrollment).
### `struct PaymentCardReadResult`
```swift
struct PaymentCardReadResult {
let paymentCardData: String? // Encrypted card data for PSP
let generalCardData: String? // General card metadata
let vasReadResult: VASReadResult? // Loyalty data (if VAS request included)
}
```
**Notes:**
- `paymentCardData` is encrypted and must be sent to your PSP for decryption/processing
- The app never has access to raw card numbers or sensitive data
---
## Store and Forward
For offline/intermittent connectivity scenarios. Transactions are stored securely on device and batched for later submission.
### `class StoreAndForwardPaymentCardReaderSession`
```swift
class StoreAndForwardPaymentCardReaderSession {
init(reader: PaymentCardReader, token: PaymentCardReader.Token) async throws
func prepare() async throws
func cancelRead() async throws
func readPaymentCard(_ request: PaymentCardTransactionRequest) async throws -> PaymentCardReadResult
func readPaymentCard(_ request: PaymentCardTransactionRequest, vasRequest: VASRequest) async throws -> PaymentCardReadResult
var events: AsyncStream<PaymentCardReaderSession.Event> { get }
}
```
### `struct PaymentCardReaderStore`
```swift
struct PaymentCardReaderStore {
init()
func allBatches() async throws -> [StoreAndForwardBatch]
func delete(using token: StoreAndForwardBatchDeletionToken) async throws
var status: StoreAndForwardStatus { get async throws }
}
```
### `struct StoreAndForwardBatch`
```swift
struct StoreAndForwardBatch {
let data: Data // Encrypted batch to send to PSP
let deletionToken: StoreAndForwardBatchDeletionToken
let creationDate: Date
let transactionCount: Int
}
```
### `struct StoreAndForwardStatus`
```swift
struct StoreAndForwardStatus {
let isEnabled: Bool
let batchCount: Int
let totalTransactionCount: Int
}
```
---
## Loyalty / VAS
Value Added Services — read NFC loyalty/discount passes from Apple Wallet.
### `class VASRequest`
```swift
class VASRequest {
let merchantIdentifier: String
let localizedDescription: String
init(merchantIdentifier: String, localizedDescription: String)
}
```
**Notes:**
- `merchantIdentifier` matches the pass type identifier in your loyalty pass (e.g. `pass.com.example.loyalty`)
- Can be used standalone with a verification request or combined with a transaction
### `struct VASReadResult`
```swift
struct VASReadResult {
let entries: [VASReadResult.Entry]
struct Entry {
let identifier: String
let data: Data?
let status: Status
enum Status {
case success
case dataNotFound
case dataNotTransferred
case incorrectCredentials
}
}
}
```
---
## Mobile Document Reader
### `class MobileDocumentReader`
```swift
class MobileDocumentReader {
init()
static var isSupported: Bool { get }
var readerIdentifier: String { get async throws }
}
```
### `class MobileDocumentReaderSession`
```swift
class MobileDocumentReaderSession {
init(reader: MobileDocumentReader, readerToken: Data) async throws
func prepare() async throws
func readDocument<T: MobileDocumentRequest>(_ request: T) async throws -> T.Result
var events: AsyncStream<MobileDocumentReaderSession.Event> { get }
}
```
---
## Mobile Document Requests
All document requests conform to `MobileDocumentRequest`. There are three patterns:
| Pattern | Description | Use Case |
|---------|-------------|----------|
| **Display** | Shows results on-screen for visual inspection | Age verification at a venue |
| **Data** | Returns validated, structured elements | Backend processing |
| **RawData** | Returns raw CBOR response data | Custom processing pipelines |
### Driver's License Requests
```swift
struct MobileDriversLicenseDisplayRequest: MobileDocumentRequest {
let retainedElements: [Element]
init(retainedElements: [Element])
enum Element {
case givenName, familyName, dateOfBirth, portrait
case ageOver18, ageOver21
case documentNumber, issuingAuthority, expiryDate
case drivingPrivileges, address, issuingCountry
// ... additional elements
}
}
struct MobileDriversLicenseDataRequest: MobileDocumentDataRequest { ... }
struct MobileDriversLicenseRawDataRequest: MobileDocumentRawDataRequest { ... }
```
### National ID Card Requests
```swift
struct MobileNationalIDCardDisplayRequest: MobileDocumentRequest { ... }
struct MobileNationalIDCardDataRequest: MobileDocumentDataRequest { ... }
struct MobileNationalIDCardRawDataRequest: MobileDocumentRawDataRequest { ... }
```
### Photo ID Requests
```swift
struct MobilePhotoIDDataRequest: MobileDocumentDataRequest { ... }
struct MobilePhotoIDRawDataRequest: MobileDocumentRawDataRequest { ... }
```
### Generic Document Requests
```swift
struct MobileDocumentDisplayRequest: MobileDocumentRequest { ... }
struct MobileDocumentAnyOfDataRequest: MobileDocumentDataRequest { ... }
struct MobileDocumentAnyOfRawDataRequest: MobileDocumentRawDataRequest { ... }
```
### Protocols
```swift
protocol MobileDocumentRequest {
associatedtype Result
// All document requests conform to this
}
protocol MobileDocumentDataRequest: MobileDocumentRequest {
// Returns validated document elements
}
protocol MobileDocumentRawDataRequest: MobileDocumentRequest {
// Returns raw response data (CBOR)
}
```
---
## Merchant Discovery
### `class ProximityReaderDiscovery`
```swift
class ProximityReaderDiscovery {
init()
func present() async throws
}
```
Apple-provided UI that educates merchants about Tap to Pay on iPhone. Content is automatically localized and kept up-to-date by Apple. Available iOS 18+.
---
## Errors
### `enum PaymentCardReaderError`
```swift
enum PaymentCardReaderError: Error {
case notAllowed // Missing entitlement
case unsupported // Device doesn't support Tap to Pay
case networkError // Connectivity issue
case networkAuthenticationError
case invalidReaderToken(_:) // Invalid/expired PSP token
case readerBusy // Another session is active
case readerMemoryFull // Too many S&F transactions stored
case accountNotLinked // linkAccount() not called
case accountAlreadyLinked
case accountLinkingFailed
case accountLinkingCancelled
case accountLinkingRequiresUpdate
case merchantBlocked
case invalidMerchant
case passcodeDisabled // Device has no passcode set
case backgrounded // App was backgrounded
case osVersionNotSupported
case modelNotSupported
case unknown
}
```
### `enum MobileDocumentReaderError`
```swift
enum MobileDocumentReaderError: Error {
case notAllowed
case unsupported
case networkError
case invalidReaderToken
case readerBusy
case sessionNotReady
case documentRequestFailed
case documentRequestCancelled
case unknown
}
```
references/integration-patterns.md
# Integration Patterns
Practical patterns for integrating ProximityReader into production apps.
## Table of Contents
1. [SwiftUI + MVVM Architecture](#swiftui--mvvm-architecture)
2. [PSP Integration Patterns](#psp-integration-patterns)
3. [Combined Payment + Loyalty Flow](#combined-payment--loyalty-flow)
4. [Store and Forward Offline Pattern](#store-and-forward-offline-pattern)
5. [Session Lifecycle Management](#session-lifecycle-management)
---
## SwiftUI + MVVM Architecture
### Payment View Model
```swift
import ProximityReader
import Observation
@Observable
@MainActor
class PaymentViewModel {
// MARK: - State
var amount: Decimal = 0
var isReady = false
var isProcessing = false
var readProgress: Int = 0
var errorMessage: String?
var transactionComplete = false
// MARK: - Private
private let reader = PaymentCardReader()
private var session: PaymentCardReaderSession?
private var eventTask: Task<Void, Never>?
// MARK: - Setup
func setup(pspToken: String) async {
guard PaymentCardReader.isSupported else {
errorMessage = "Tap to Pay is not supported on this device."
return
}
do {
let token = PaymentCardReader.Token(rawValue: pspToken)
// Link account if needed (first-time setup)
if try await !reader.isAccountLinked(using: token) {
try await reader.linkAccount(using: token)
}
// Create and prepare session
session = try await PaymentCardReaderSession(reader: reader, token: token)
try await session?.prepare()
// Observe events
observeEvents()
isReady = true
} catch {
errorMessage = "Setup failed: \(error.localizedDescription)"
}
}
// MARK: - Read Payment
func readPayment() async {
guard let session, amount > 0 else { return }
isProcessing = true
errorMessage = nil
do {
let request = PaymentCardTransactionRequest(
amount: amount,
currencyCode: "USD",
type: .purchase
)
let result = try await session.readPaymentCard(request)
// Send to PSP for processing
try await processWithPSP(result.paymentCardData)
transactionComplete = true
// Re-prepare for next transaction
try await session.prepare()
} catch let error as PaymentCardReaderSession.ReadError {
handleReadError(error)
} catch let error as PaymentCardReaderError {
handleReaderError(error)
} catch {
errorMessage = "Payment failed: \(error.localizedDescription)"
}
isProcessing = false
}
// MARK: - Events
private func observeEvents() {
eventTask?.cancel()
eventTask = Task {
guard let session else { return }
for await event in session.events {
switch event {
case .updateProgress(let progress):
readProgress = progress
case .ready:
isReady = true
case .notReady:
isReady = false
case .readCompleted:
readProgress = 100
case .readCancelled:
isProcessing = false
default:
break
}
}
}
}
// MARK: - Error Handling
private func handleReadError(_ error: PaymentCardReaderSession.ReadError) {
switch error {
case .cancelled:
errorMessage = nil // User cancelled, no error to show
case .invalidAmount:
errorMessage = "Invalid payment amount."
case .notReady:
errorMessage = "Reader not ready. Please wait."
default:
errorMessage = "Read error: \(error)"
}
}
private func handleReaderError(_ error: PaymentCardReaderError) {
switch error {
case .backgrounded:
errorMessage = "App was backgrounded. Please try again."
Task { try? await session?.prepare() }
case .networkError:
errorMessage = "Network error. Check your connection."
case .invalidReaderToken:
errorMessage = "Session expired. Please restart."
default:
errorMessage = "Reader error: \(error)"
}
}
private func processWithPSP(_ cardData: String?) async throws {
// Send cardData to your PSP's backend for processing
// Implementation depends on your PSP (Stripe, Adyen, Square, etc.)
}
deinit {
eventTask?.cancel()
}
}
```
### SwiftUI Payment View
```swift
import SwiftUI
struct PaymentView: View {
@State private var viewModel = PaymentViewModel()
@State private var amountText = ""
let pspToken: String
var body: some View {
VStack(spacing: 24) {
// Amount Input
TextField("Amount", text: $amountText)
.keyboardType(.decimalPad)
.font(.largeTitle)
.multilineTextAlignment(.center)
.onChange(of: amountText) { _, newValue in
viewModel.amount = Decimal(string: newValue) ?? 0
}
// Status
if viewModel.isProcessing {
ProgressView("Reading card...")
.progressViewStyle(.circular)
if viewModel.readProgress > 0 {
ProgressView(value: Double(viewModel.readProgress), total: 100)
}
}
// Error
if let error = viewModel.errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.callout)
}
// Charge Button
Button("Charge") {
Task { await viewModel.readPayment() }
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(!viewModel.isReady || viewModel.isProcessing || viewModel.amount <= 0)
// Success
if viewModel.transactionComplete {
Label("Payment Complete", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.title2)
}
}
.padding()
.task {
await viewModel.setup(pspToken: pspToken)
}
}
}
```
---
## PSP Integration Patterns
### Stripe
Stripe wraps ProximityReader in their Terminal SDK. You can either use Stripe's SDK (simpler) or use ProximityReader directly with Stripe as the token provider.
```swift
// Using Stripe Terminal SDK (recommended for Stripe users)
import StripeTerminal
// Stripe handles ProximityReader internally
let config = try LocalMobileDiscoveryConfiguration(simulated: false)
let cancelable = Terminal.shared.discoverReaders(config, delegate: self) { readers, error in
guard let reader = readers?.first else { return }
Terminal.shared.connectLocalMobileReader(reader, delegate: self) { reader, error in
// Reader connected, ready for payments
}
}
```
```swift
// Using ProximityReader directly with Stripe token
// 1. Get token from your backend (which calls Stripe's API)
let stripeToken = try await fetchStripeReaderToken()
let token = PaymentCardReader.Token(rawValue: stripeToken)
// 2. Standard ProximityReader flow
let session = try await PaymentCardReaderSession(reader: reader, token: token)
// ... continue with normal flow
```
### Adyen
```swift
// Adyen provides their own iOS Mobile SDK
// But the token can also be used directly with ProximityReader
let adyenToken = try await fetchAdyenReaderToken()
let token = PaymentCardReader.Token(rawValue: adyenToken)
```
### Generic PSP Pattern
```swift
protocol PaymentServiceProvider {
func fetchReaderToken() async throws -> String
func processPayment(cardData: String, amount: Decimal, currency: String) async throws -> PaymentResult
func processRefund(cardData: String, amount: Decimal, currency: String) async throws -> RefundResult
}
class TapToPayService {
private let psp: PaymentServiceProvider
private let reader = PaymentCardReader()
private var session: PaymentCardReaderSession?
init(psp: PaymentServiceProvider) {
self.psp = psp
}
func initialize() async throws {
let jwt = try await psp.fetchReaderToken()
let token = PaymentCardReader.Token(rawValue: jwt)
if try await !reader.isAccountLinked(using: token) {
try await reader.linkAccount(using: token)
}
session = try await PaymentCardReaderSession(reader: reader, token: token)
try await session?.prepare()
}
func charge(amount: Decimal, currency: String) async throws -> PaymentResult {
guard let session else { throw TapToPayError.notInitialized }
let request = PaymentCardTransactionRequest(
amount: amount,
currencyCode: currency,
type: .purchase
)
let readResult = try await session.readPaymentCard(request)
guard let cardData = readResult.paymentCardData else {
throw TapToPayError.noCardData
}
let paymentResult = try await psp.processPayment(
cardData: cardData,
amount: amount,
currency: currency
)
// Re-prepare for next transaction
try await session.prepare()
return paymentResult
}
}
```
---
## Combined Payment + Loyalty Flow
```swift
func chargeWithLoyalty(
amount: Decimal,
currency: String,
loyaltyPassIdentifier: String
) async throws -> (PaymentCardReadResult, VASReadResult?) {
guard let session else { throw TapToPayError.notInitialized }
let paymentRequest = PaymentCardTransactionRequest(
amount: amount,
currencyCode: currency,
type: .purchase
)
let vasRequest = VASRequest(
merchantIdentifier: loyaltyPassIdentifier,
localizedDescription: "Scan your loyalty card"
)
// Single tap reads both payment AND loyalty card
let result = try await session.readPaymentCard(
paymentRequest,
vasRequest: vasRequest
)
// Process payment
if let cardData = result.paymentCardData {
try await psp.processPayment(cardData: cardData, amount: amount, currency: currency)
}
// Process loyalty (may be nil if customer has no matching pass)
if let vasResult = result.vasReadResult {
for entry in vasResult.entries where entry.status == .success {
try await loyaltyService.recordVisit(identifier: entry.identifier)
}
}
try await session?.prepare()
return (result, result.vasReadResult)
}
```
---
## Store and Forward Offline Pattern
```swift
@Observable
@MainActor
class OfflinePaymentManager {
var pendingBatchCount = 0
var pendingTransactionCount = 0
private let reader = PaymentCardReader()
private var sfSession: StoreAndForwardPaymentCardReaderSession?
private let store = PaymentCardReaderStore()
func initialize(token: PaymentCardReader.Token) async throws {
sfSession = try await StoreAndForwardPaymentCardReaderSession(
reader: reader,
token: token
)
try await sfSession?.prepare()
await refreshStatus()
}
/// Accept payment offline — stored securely on device
func acceptPayment(amount: Decimal, currency: String) async throws {
guard let sfSession else { throw TapToPayError.notInitialized }
let request = PaymentCardTransactionRequest(
amount: amount,
currencyCode: currency,
type: .purchase
)
_ = try await sfSession.readPaymentCard(request)
try await sfSession.prepare()
await refreshStatus()
}
/// Upload all stored batches when connectivity is restored
func syncPendingTransactions() async throws {
let batches = try await store.allBatches()
for batch in batches {
do {
// Send to PSP
try await psp.submitBatch(batch.data)
// Delete on success
try await store.delete(using: batch.deletionToken)
} catch {
// Skip this batch, try next
continue
}
}
await refreshStatus()
}
private func refreshStatus() async {
if let status = try? await store.status {
pendingBatchCount = status.batchCount
pendingTransactionCount = status.totalTransactionCount
}
}
}
```
---
## Session Lifecycle Management
Critical pattern: handle app backgrounding and foregrounding properly.
```swift
@Observable
@MainActor
class ReaderSessionManager {
private var session: PaymentCardReaderSession?
private var scenePhaseTask: Task<Void, Never>?
/// Call from SwiftUI's .onChange(of: scenePhase)
func handleScenePhase(_ phase: ScenePhase) {
switch phase {
case .active:
// CRITICAL: Re-prepare when returning to foreground
Task {
do {
try await session?.prepare()
} catch {
// Handle re-preparation failure
}
}
case .background:
// Session is automatically invalidated by the system
// No action needed, but prepare() is required on return
break
default:
break
}
}
}
// In your SwiftUI App or root view:
struct ContentView: View {
@Environment(\.scenePhase) var scenePhase
@State private var sessionManager = ReaderSessionManager()
var body: some View {
PaymentView()
.onChange(of: scenePhase) { _, newPhase in
sessionManager.handleScenePhase(newPhase)
}
}
}
```
references/troubleshooting.md
# Troubleshooting ProximityReader
Common issues, debugging tips, and solutions for ProximityReader integration.
## Table of Contents
1. [Setup & Entitlement Issues](#setup--entitlement-issues)
2. [Reader Token Problems](#reader-token-problems)
3. [Runtime Errors](#runtime-errors)
4. [Device & Compatibility](#device--compatibility)
5. [Testing & Debugging](#testing--debugging)
6. [Common Pitfalls](#common-pitfalls)
---
## Setup & Entitlement Issues
### `PaymentCardReaderError.notAllowed`
**Cause**: Missing or misconfigured entitlement.
**Fix**:
1. Ensure you've requested the Tap to Pay on iPhone entitlement from Apple
2. Add the entitlement to your app target's `.entitlements` file:
```xml
<key>com.apple.developer.proximity-reader.payment.acceptance</key>
<true/>
```
3. For development: request the **development** entitlement
4. For App Store: request the **distribution** entitlement
5. Verify your provisioning profile includes the entitlement
### Entitlement Not Appearing in Developer Portal
- Tap to Pay requires a relationship with a Level 3 certified PSP
- Contact Apple Developer support with your PSP partnership details
- Ensure your Apple Developer account is enrolled as an Organization (not Individual)
---
## Reader Token Problems
### `PaymentCardReaderError.invalidReaderToken`
**Cause**: The JWT token from your PSP is invalid, expired, or malformed.
**Debugging steps**:
1. Decode the JWT at jwt.io and check:
- `exp` claim is in the future
- `iss` matches your PSP's expected issuer
- Signature is valid
2. Ensure you're passing the raw JWT string (not base64-encoded again)
3. Check your PSP's documentation for token format requirements
4. Some PSPs require a fresh token per session — don't cache indefinitely
```swift
// Common mistake: double-encoding
// WRONG
let token = PaymentCardReader.Token(rawValue: Data(jwt.utf8).base64EncodedString())
// CORRECT
let token = PaymentCardReader.Token(rawValue: jwt)
```
### Token Refresh Strategy
```swift
class TokenManager {
private var currentToken: String?
private var tokenExpiry: Date?
func getValidToken() async throws -> PaymentCardReader.Token {
if let token = currentToken, let expiry = tokenExpiry, expiry > Date().addingTimeInterval(300) {
return PaymentCardReader.Token(rawValue: token)
}
// Refresh token from PSP/server
let newToken = try await psp.fetchReaderToken()
currentToken = newToken.jwt
tokenExpiry = newToken.expiresAt
return PaymentCardReader.Token(rawValue: newToken.jwt)
}
}
```
---
## Runtime Errors
### `PaymentCardReaderError.backgrounded`
**Cause**: The app moved to the background during a read session.
**Fix**: Always re-prepare after returning to the foreground:
```swift
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
Task { try? await session?.prepare() }
}
}
```
### `PaymentCardReaderError.readerBusy`
**Cause**: Another `PaymentCardReaderSession` or `StoreAndForwardPaymentCardReaderSession` is active.
**Fix**:
- Only create one session at a time
- Cancel existing sessions before creating new ones
- This can also occur if another app on the device is using Tap to Pay
### `PaymentCardReaderSession.ReadError.notReady`
**Cause**: `prepare()` was not called, or hasn't completed yet.
**Fix**:
```swift
// Always await prepare() before reading
try await session.prepare()
// Now safe to read
let result = try await session.readPaymentCard(request)
```
### `PaymentCardReaderSession.ReadError.invalidAmount`
**Cause**: Amount is zero, negative, or has too many decimal places.
**Fix**:
```swift
// Validate before creating request
guard amount > 0 else { return }
// Round to 2 decimal places for most currencies
let roundedAmount = NSDecimalNumber(decimal: amount)
.rounding(accordingToBehavior: NSDecimalNumberHandler(
roundingMode: .plain,
scale: 2,
raiseOnExactness: false,
raiseOnOverflow: false,
raiseOnUnderflow: false,
raiseOnDivideByZero: false
)).decimalValue
```
### `PaymentCardReaderError.passcodeDisabled`
**Cause**: The device has no passcode set. Tap to Pay requires a device passcode for security.
**Fix**: Prompt the user to set a device passcode in Settings.
### `PaymentCardReaderError.readerMemoryFull`
**Cause**: Too many Store and Forward transactions stored on device.
**Fix**: Process pending batches before accepting more transactions:
```swift
let store = PaymentCardReaderStore()
let batches = try await store.allBatches()
for batch in batches {
try await psp.submitBatch(batch.data)
try await store.delete(using: batch.deletionToken)
}
```
---
## Device & Compatibility
### Supported Devices
Tap to Pay on iPhone requires **iPhone XS or later**:
- iPhone XS, XS Max, XR (2018)
- iPhone 11, 11 Pro, 11 Pro Max (2019)
- iPhone SE (2nd gen, 2020)
- iPhone 12 series (2020)
- iPhone 13 series (2021)
- iPhone 14 series (2022)
- iPhone 15 series (2023)
- iPhone 16 series (2024)
- All later models
**Not supported**: iPhone X, iPhone 8, iPhone SE (1st gen), any iPad.
### OS Requirements
| Feature | Minimum iOS |
|---------|-------------|
| Tap to Pay (basic) | iOS 15.4 |
| PIN entry | iOS 16.4 |
| Store and Forward | iOS 17.0 |
| Verifier API | iOS 17.0 |
| ProximityReaderDiscovery | iOS 18.0 |
### Checking Capability at Runtime
```swift
if PaymentCardReader.isSupported {
// Device supports Tap to Pay
} else {
// Show alternative payment method or explain requirement
}
if MobileDocumentReader.isSupported {
// Device supports Verifier API
}
```
### Regional Availability
Tap to Pay on iPhone is available in: US, UK, Canada, Australia, France, Germany, Netherlands, Taiwan, Brazil, Japan, and more (expanding). Check Apple's current list at developer.apple.com.
PIN entry regional notes:
- **UK**: Some cards require Strong Customer Authentication (chip insertion) — not supported via Tap to Pay
- **Canada/Finland**: Many cards are offline-PIN-only (requires chip insertion) — suggest alternative payment method
- **US**: PIN entry works normally for cards that require it
---
## Testing & Debugging
### Simulator Testing
Use `ProximityReaderStub` for basic testing in the simulator:
```swift
#if targetEnvironment(simulator)
import ProximityReaderStub
#else
import ProximityReader
#endif
```
**Limitations**: Stub provides basic API surface but cannot simulate actual NFC reads.
### TestFlight Testing
- Tap to Pay works in TestFlight builds with the development entitlement
- Use a real contactless card or Apple Pay on a second device
- Test with both credit and debit cards
- Test with Apple Pay, Google Pay, and physical cards
### Debugging Tips
1. **Enable console logging**: Filter Xcode console for "ProximityReader" to see framework-level logs
2. **Monitor events stream**: Log all events from `session.events` to track state transitions
3. **Network diagnostics**: Many operations require network connectivity — test with poor/no connectivity
4. **Background handling**: Test by switching apps during a read to ensure proper recovery
```swift
// Detailed event logging for debugging
Task {
for await event in session.events {
print("[ProximityReader] Event: \(event)")
}
}
```
### Common Test Scenarios
1. ✅ Happy path: payment succeeds
2. ✅ Customer cancels the tap
3. ✅ App backgrounded during read → returns to foreground → retries
4. ✅ Invalid amount (0, negative)
5. ✅ Network loss during transaction
6. ✅ Token expiry during session
7. ✅ Multiple rapid transactions
8. ✅ Refund flow
9. ✅ Card verification (no charge)
10. ✅ Combined payment + loyalty read
---
## Common Pitfalls
### 1. Forgetting to Re-prepare
The #1 source of bugs. You must call `prepare()`:
- After app returns to foreground
- After each successful/failed transaction
- After session creation
### 2. Holding Strong Reference to Session in Background
```swift
// BAD: session stays alive in background, wastes resources
class BadViewModel {
var session: PaymentCardReaderSession? // never released
}
// GOOD: nil out session on background, recreate on foreground
class GoodViewModel {
var session: PaymentCardReaderSession?
func handleBackground() {
session = nil
}
func handleForeground() async throws {
session = try await PaymentCardReaderSession(reader: reader, token: token)
try await session?.prepare()
}
}
```
### 3. Not Handling Cancellation
```swift
// Always handle the cancelled case gracefully
do {
let result = try await session.readPaymentCard(request)
} catch let error as PaymentCardReaderSession.ReadError where error == .cancelled {
// User cancelled — don't show error, just return to amount input
return
} catch {
// Actual error — show to user
}
```
### 4. Caching Tokens Too Long
PSP tokens have expiry times. Don't cache indefinitely:
```swift
// BAD
let token = savedToken // Might be expired
// GOOD
let token = try await tokenManager.getValidToken()
```
### 5. Testing on Beta iOS
Tap to Pay does **not** work on iOS beta releases. Always test on stable releases.
### 6. Multiple Simultaneous Sessions
Only one reader session can be active at a time across the entire device. Even other apps using Tap to Pay will block your session.
references/verifier-api.md
# Verifier API — Mobile Document & ID Reading
Complete guide for implementing Apple's Verifier API to read mobile driver's licenses, national ID cards, and other mobile documents using ProximityReader.
## Overview
The Verifier API allows your iPhone app to read and verify mobile documents (mDL — mobile driver's licenses, national ID cards) from another person's iPhone or Apple Watch, without any additional hardware. The holder presents their document via Apple Wallet, and your app reads the requested elements.
**Availability**: iOS 17.0+
## Prerequisites
1. **Entitlement**: Request the Verifier API entitlement from Apple (separate from Tap to Pay)
2. **Server component**: You must run a server that generates **reader tokens** (signed JWTs)
3. **Apple certificate**: Obtain a reader authentication certificate from Apple's developer portal
4. **Device**: iPhone XS or later running iOS 17+
## Architecture
```
┌──────────────┐ Reader Token ┌──────────────┐
│ Your Server │ ◄──────────────────► │ Your App │
│ (JWT Signer) │ │ (Verifier) │
└──────┬───────┘ └──────┬───────┘
│ │ NFC/Proximity
│ Apple Reader │
│ Certificate ┌─────▼────────┐
│ │ Holder's │
└────────────────────────────── │ iPhone/Watch │
│ (Wallet) │
└──────────────┘
```
## Server-Side: Generating Reader Tokens
Your server must generate a signed JWT that the app uses to initialize the reader session. The token tells Apple what data elements you're authorized to request.
### JWT Structure
```json
{
"iss": "<your-team-id>",
"iat": 1700000000,
"exp": 1700003600,
"sub": "<your-reader-identifier>",
"aud": "https://verify.apple.com",
"nonce": "<unique-request-nonce>",
"requested_elements": {
"org.iso.18013.5.1": {
"given_name": true,
"family_name": true,
"date_of_birth": true,
"portrait": true,
"age_over_21": true
}
}
}
```
### Signing the Token
Sign the JWT with your Apple-issued reader authentication certificate using ES256 algorithm.
```python
# Server-side (Python example)
import jwt
import time
def generate_reader_token(private_key, team_id, reader_id):
payload = {
"iss": team_id,
"iat": int(time.time()),
"exp": int(time.time()) + 3600,
"sub": reader_id,
"aud": "https://verify.apple.com",
"nonce": generate_unique_nonce(),
"requested_elements": {
"org.iso.18013.5.1": {
"given_name": True,
"family_name": True,
"date_of_birth": True,
"portrait": True,
"age_over_21": True
}
}
}
return jwt.encode(payload, private_key, algorithm="ES256")
```
## Client-Side: Reading Documents
### Basic Flow
```swift
import ProximityReader
class IDVerificationManager {
private let docReader = MobileDocumentReader()
private var session: MobileDocumentReaderSession?
// Step 1: Check device support
func checkSupport() -> Bool {
MobileDocumentReader.isSupported
}
// Step 2: Fetch reader token from your server
func fetchReaderToken() async throws -> Data {
let url = URL(string: "https://your-server.com/api/reader-token")!
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
// Step 3: Create session and prepare
func prepareSession() async throws {
let token = try await fetchReaderToken()
session = try await MobileDocumentReaderSession(
reader: docReader,
readerToken: token
)
try await session?.prepare()
}
// Step 4: Read a document
func verifyDriversLicense() async throws {
guard let session else { throw VerificationError.sessionNotReady }
// Display request: shows results on screen for visual verification
let displayRequest = MobileDriversLicenseDisplayRequest(
retainedElements: [
.givenName,
.familyName,
.portrait,
.dateOfBirth,
.ageOver21
]
)
try await session.readDocument(displayRequest)
// System UI shows the verified document elements on screen
}
// Alternative: Get validated data back for processing
func getDriversLicenseData() async throws -> some MobileDocumentDataRequest.Result {
guard let session else { throw VerificationError.sessionNotReady }
let dataRequest = MobileDriversLicenseDataRequest(
retainedElements: [
.givenName,
.familyName,
.dateOfBirth
]
)
return try await session.readDocument(dataRequest)
}
}
```
### Request Types by Document
**Driver's License**:
- `MobileDriversLicenseDisplayRequest` — Visual inspection
- `MobileDriversLicenseDataRequest` — Validated data
- `MobileDriversLicenseRawDataRequest` — Raw CBOR data
**National ID Card**:
- `MobileNationalIDCardDisplayRequest`
- `MobileNationalIDCardDataRequest`
- `MobileNationalIDCardRawDataRequest`
**Photo ID**:
- `MobilePhotoIDDataRequest`
- `MobilePhotoIDRawDataRequest`
**Any Document** (accepts whichever the holder presents):
- `MobileDocumentAnyOfDataRequest`
- `MobileDocumentAnyOfRawDataRequest`
- `MobileDocumentDisplayRequest`
### Display vs Data vs RawData
| Type | Returns | Best For |
|------|---------|----------|
| **Display** | Shows UI on screen, no data returned to app | Bouncer checking age at a bar |
| **Data** | Validated, structured Swift types | Backend processing, compliance |
| **RawData** | Raw CBOR bytes | Custom parsing, third-party verification |
### Available Elements (ISO 18013-5)
```swift
// Common driver's license elements
.givenName // First name
.familyName // Last name
.dateOfBirth // Full birth date
.portrait // Photo
.ageOver18 // Boolean: is holder 18+?
.ageOver21 // Boolean: is holder 21+?
.documentNumber // License number
.issuingAuthority // Issuing state/authority
.expiryDate // Document expiration
.drivingPrivileges // License classes/endorsements
.address // Residential address
.issuingCountry // Country of issuance
.sex // Sex as on document
.height // Height
.weight // Weight
.eyeColour // Eye color
.hairColour // Hair color
```
### Monitoring Session Events
```swift
func observeEvents() async {
guard let session else { return }
for await event in session.events {
switch event {
case .sessionReady:
// Ready to scan
updateUI(status: .ready)
case .documentPresented:
// Holder tapped their device
updateUI(status: .reading)
case .documentRead:
// Successfully read
updateUI(status: .complete)
default:
break
}
}
}
```
## Error Handling
```swift
do {
try await session.readDocument(request)
} catch let error as MobileDocumentReaderError {
switch error {
case .notAllowed:
// Missing Verifier API entitlement
showError("This app is not authorized for ID verification.")
case .unsupported:
// Device doesn't support document reading
showError("This device doesn't support mobile ID reading.")
case .invalidReaderToken:
// Server-generated token is invalid or expired
// → Refresh the token from your server
try await prepareSession()
case .documentRequestCancelled:
// Holder declined to share their document
showError("The document holder cancelled the request.")
case .networkError:
showError("Network connection required for verification.")
default:
showError("An unexpected error occurred: \(error)")
}
}
```
## Privacy & Compliance
- Only request elements you genuinely need (principle of minimal disclosure)
- Display requests show data on-screen but don't give your app access to it — ideal for age checks
- Data requests return actual values — ensure you have proper data handling policies
- The holder always sees what's being requested and must explicitly consent
- Apple does not have access to the verification data
- All communication is encrypted end-to-end via the Secure Element
- Retain data only as long as legally required
## Testing
- Use the `ProximityReaderStub` framework for Xcode simulator testing
- Real document reads require two physical devices (verifier + holder)
- Apple provides test mDL profiles for development — request via your developer account
SKILL.md
---
name: proximity-reader
description: >
Comprehensive iOS development skill for Apple's ProximityReader framework.
Covers Tap to Pay on iPhone (payment card reading), loyalty card (VAS) integration,
Store and Forward mode, the Verifier API (mobile document / ID reading), and
merchant discovery UI. Use this skill whenever the user mentions ProximityReader,
Tap to Pay on iPhone, contactless payments on iPhone, NFC payment reading,
loyalty card reading from Wallet, VAS requests, mobile driver's license verification,
ID verification on iPhone, MobileDocumentReader, PaymentCardReader,
PaymentCardReaderSession, or any related topic. Also trigger when the user wants
to build a point-of-sale (POS) app, accept contactless payments without hardware,
or read digital wallet passes on iPhone.
---
# ProximityReader iOS Development Skill
Build contactless payment, loyalty, and ID verification features using Apple's ProximityReader framework on iPhone — no additional hardware required.
## When to Use This Skill
- Integrating **Tap to Pay on iPhone** (contactless payment acceptance)
- Reading **loyalty cards / VAS passes** from Apple Wallet
- Implementing **Store and Forward** for offline payment scenarios
- Building **ID verification** with the Verifier API (mobile driver's licenses, national IDs)
- Showing **merchant education UI** via `ProximityReaderDiscovery`
- Debugging `PaymentCardReaderError` or `MobileDocumentReaderError`
## Framework Overview
**ProximityReader** (iOS 15.4+, iPadOS 15.4+, Mac Catalyst 15.4+) enables an iPhone to act as a contactless reader for:
| Domain | Key Classes | Min iOS |
|--------|-------------|---------|
| Payments | `PaymentCardReader`, `PaymentCardReaderSession` | 15.4 |
| Loyalty (VAS) | `VASRequest`, `VASReadResult` | 15.4 |
| Store & Forward | `StoreAndForwardPaymentCardReaderSession`, `PaymentCardReaderStore` | 17.0+ |
| ID Verification | `MobileDocumentReader`, `MobileDocumentReaderSession` | 17.0+ |
| Merchant Discovery | `ProximityReaderDiscovery` | 18.0+ |
## Prerequisites & Entitlements
Before writing any code, ensure:
1. **Entitlement**: Request the Tap to Pay on iPhone entitlement from Apple via your developer account. Without it, the framework won't function.
2. **Payment Service Provider (PSP)**: You must coordinate with a Level 3 certified PSP (e.g. Stripe, Adyen, Square, Windcave). The PSP provides the **reader token** (JWT) required to initialize the reader.
3. **Device**: iPhone XS or later. No additional NFC hardware needed.
4. **Xcode**: Add the `com.apple.developer.proximity-reader.payment.acceptance` entitlement to your app's entitlements file.
For the Verifier API (ID reading), a separate entitlement and server-side reader token generation is required. Read `references/verifier-api.md` for details.
## Architecture at a Glance
```
┌─────────────────────────────────────────────────┐
│ Your App │
├──────────┬──────────┬───────────┬───────────────┤
│ Payment │ Loyalty │ Store & │ ID │
│ Flow │ (VAS) │ Forward │ Verification │
├──────────┴──────────┴───────────┴───────────────┤
│ ProximityReader Framework │
├─────────────────────────────────────────────────┤
│ Secure Element / NFC Hardware │
└─────────────────────────────────────────────────┘
```
## Quick Reference: Common Patterns
### 1. Payment Card Reading (Tap to Pay)
```swift
import ProximityReader
// 1. Create and configure the reader
let reader = PaymentCardReader()
// 2. Obtain token from your PSP
let token = PaymentCardReader.Token(rawValue: pspProvidedJWT)
// 3. Link the merchant account (first use only)
if try await !reader.isAccountLinked(using: token) {
try await reader.linkAccount(using: token)
}
// 4. Create a session and prepare
let session = try await PaymentCardReaderSession(reader: reader, token: token)
try await session.prepare()
// 5. Read a payment card
let request = PaymentCardTransactionRequest(
amount: Decimal(29.99),
currencyCode: "USD",
type: .purchase
)
let result: PaymentCardReadResult = try await session.readPaymentCard(request)
// Send result.paymentCardData to your PSP for processing
```
### 2. Loyalty Card (VAS) Reading
```swift
// Can be combined with payment or standalone
let vasRequest = VASRequest(
merchantIdentifier: "pass.com.example.loyalty",
localizedDescription: "Example Loyalty Program"
)
// Read loyalty card alongside payment
let result = try await session.readPaymentCard(
request,
vasRequest: vasRequest
)
// Access loyalty data
if let vasResult = result.vasReadResult {
// Process loyalty identifiers
}
```
### 3. Store and Forward (Offline)
```swift
let sfSession = try await StoreAndForwardPaymentCardReaderSession(
reader: reader,
token: token
)
try await sfSession.prepare()
// Transactions are stored locally
let result = try await sfSession.readPaymentCard(request)
// Later, when online: retrieve and send batches
let store = PaymentCardReaderStore()
let batches: [StoreAndForwardBatch] = try await store.allBatches()
for batch in batches {
// Send batch.data to PSP
// On success, delete the batch
try await store.delete(using: batch.deletionToken)
}
```
### 4. Mobile Document / ID Reading (Verifier API)
Read `references/verifier-api.md` for the full setup including server-side reader token generation.
```swift
import ProximityReader
let docReader = MobileDocumentReader()
let readerToken = try await fetchReaderToken() // From your server
let session = try await MobileDocumentReaderSession(
reader: docReader,
readerToken: readerToken
)
try await session.prepare()
// Request a driver's license display
let displayRequest = MobileDriversLicenseDisplayRequest(
retainedElements: [.givenName, .familyName, .portrait, .dateOfBirth, .ageOver21]
)
try await session.readDocument(displayRequest)
// Or request validated data
let dataRequest = MobileDriversLicenseDataRequest(
retainedElements: [.givenName, .familyName, .dateOfBirth]
)
let documentResult = try await session.readDocument(dataRequest)
```
### 5. Merchant Discovery UI
```swift
// iOS 18+: Show Apple-provided merchant education
let discovery = ProximityReaderDiscovery()
try await discovery.present()
```
## Error Handling
Always wrap ProximityReader calls in do-catch. The two main error types are:
```swift
do {
try await session.readPaymentCard(request)
} catch let error as PaymentCardReaderError {
switch error {
case .notAllowed:
// Missing entitlement or not authorized
case .unsupported:
// Device doesn't support Tap to Pay
case .networkError:
// Connectivity issue
case .invalidReaderToken:
// Token from PSP is invalid or expired
case .readerBusy:
// Another read session is active
case .backgrounded:
// App moved to background during read — must call prepare() again
default:
break
}
} catch let error as PaymentCardReaderSession.ReadError {
switch error {
case .cancelled:
// Customer cancelled the tap
case .invalidAmount:
// Amount was negative or zero
case .notReady:
// prepare() was not called
default:
break
}
}
```
For the Verifier API:
```swift
catch let error as MobileDocumentReaderError {
// Handle session preparation and document request errors
}
```
## Critical Implementation Notes
- **Always call `prepare()` after the app returns to the foreground.** The reader session is invalidated when backgrounded. Safe to call multiple times.
- **Call `prepare()` after each transaction** to reset internal state for the next transaction.
- **The reader token (JWT) comes from your PSP**, not from Apple directly. Each PSP has their own token generation flow.
- **PIN entry** is supported on iOS 16.4+ for contactless cards that require it.
- **Testing**: Use `ProximityReaderStub` for simulator testing. Real NFC reads require a physical device.
- **Thread safety**: ProximityReader uses Swift concurrency (async/await). All calls should be made from the main actor or appropriate actor context.
- **Store and Forward**: Batches persist on device. Always delete after successful processing to avoid data accumulation.
## Deeper Reference Docs
For more detailed implementation guides, read these reference files:
| Reference | When to Read |
|-----------|-------------|
| `references/api-reference.md` | Full class/struct/enum listing with all properties and methods |
| `references/verifier-api.md` | Complete Verifier API setup including server-side token generation |
| `references/integration-patterns.md` | PSP integration patterns (Stripe, Adyen, Square), SwiftUI patterns, MVVM architecture |
| `references/troubleshooting.md` | Common errors, debugging tips, device compatibility |
## Code Generation Guidelines
When generating ProximityReader code:
1. Always `import ProximityReader`
2. Use Swift concurrency (async/await) — the entire API is async
3. Wrap all reader operations in do-catch with specific error handling
4. Include `prepare()` calls after foregrounding and after each transaction
5. Never hardcode PSP tokens — always fetch from server/PSP SDK
6. For SwiftUI: use `.task {}` modifier or `@MainActor` view models
7. Always check device capability before attempting to use the reader
8. Follow Apple HIG: the tap payment sheet is system-provided — don't recreate it