Skip to main content
Version: 4.0

Halo.Swift (iOS SDK)

Halo.Swift enables merchants to accept contactless card payments on iPhone using Apple’s Tap to Pay on iPhone technology. The SDK handles device capability checks, the Proximity Reader lifecycle, communication with Apple services, transaction execution, and security enforcement.

Halo.Swift is distributed as the HaloSDK Swift module. All code examples reference the HaloSDK namespace.

Requirements

You'll need the following before getting started:

  • iOS 15.5 or later on a physical device (the simulator will not work for Tap to Pay)

  • iPhone XS or newer. Older models do not have the required NFC hardware

  • The ProximityReader entitlement from Apple, requested through your Apple Developer account

  • A merchant account enrolled in Apple’s Tap to Pay on iPhone program

Before You Start

Before integrating the SDK into your app, you'll need to set up JWT authentication on your backend. This involves generating RSA keys and configuring token signing — the SDK uses these tokens for all API requests.

Follow the JWT Integration Guide to get that sorted first. Once your backend can generate valid JWT tokens, come back here and continue with installation.

Installation

Login to AWS CodeArtifact using the following commands:

export AWS_ACCESS_KEY_ID=<provided to you by Halo Dot>
export AWS_SECRET_ACCESS_KEY=<provided to you by Halo Dot>
aws codeartifact login --tool swift --repository halo_sdk_ios --domain halo --domain-owner 459295082152 --region eu-west-1

Add it directly to your Package.swift:

dependencies: [

.package(id: "synthesis.halosdk", from: "1.0.58")

]

Then import the module where needed:


import HaloSDK
import Combine // Required for the token provider

Quick Start

1. Initialize the SDK

Your app provides a token provider closure that the SDK calls whenever it needs an auth token. The SDK calls it once during initialization (to validate it works), and again automatically whenever the cached token expires or is rejected.


do {

let capabilities = try await HaloSDK.initialize(

tokenProvider: {
Future { promise in
// Call your backend to get a fresh JWT
YourBackend.fetchAuthToken { token, error in
if let token = token {
promise(.success(token))
} else {
promise(.failure(error ?? HaloError.authTokenUnauthorized))
}
}
}
},

environment: .sandbox // use .production for live payments

)

if capabilities.canAcceptPayments {

print("Ready to accept payments")

}

} catch HaloError.deviceNotSupported(let reason) {

// This device can't accept Tap to Pay

print("Device not supported: \(reason)")

} catch HaloError.authTokenUnauthorized {

// Token provider failed — check your backend

print("Token provider returned invalid token")

} catch {

print("Initialization failed: \(error)")

}

The SDK checks device capabilities and throws if the device doesn't support Tap to Pay. You get back a HaloDeviceCapabilities object that tells you what the device can do.

Why a token provider instead of a static token? Tokens expire. With a provider, the SDK can fetch a fresh token on-demand — before requests if the JWT is expired, or automatically on 401 retry. You don't need to worry about token expiry timing or re-initializing the SDK.

2. Start a Payment

Starting a contactless payment must be initiated by a clear user action, such as tapping a button, in accordance with Apple’s Tap to Pay on iPhone user experience requirements. For processing Refunds


let result = await HaloSDK.startContactlessPayment(

amountMinor: 1500, // 15.00 in cents

currency: "ZAR",

merchantReference: "order_12345"
// type: .purchase (default) or .refund


)

This brings up Apple's card reader UI. The customer taps their card on the iPhone, and a result is returned. merchantReference is an application-defined identifier used to correlate the payment with an internal order or transaction. It is returned unchanged in the payment receipt and can be used for reconciliation or support purposes.

3. Handle the Result

startContactlessPayment returns a HaloPaymentResult that represents the final outcome of a single checkout attempt. A payment is either approved or declined.

A declined result may represent a customer cancellation, an issuer decline, a network failure, or another error condition.


switch result {

case .approved(let receipt):

// Payment completed successfully

print("Payment approved: \(receipt.transactionId)")

print("Amount: \(receipt.amountMinor) \(receipt.currency)")

print("Card: \(receipt.cardBrand ?? "Unknown") ending in \(receipt.last4 ?? "****")")

case .declined(let errorCode, let errorMessage):

// Payment didn't go through — could be a decline, cancellation, or error

print("Payment declined")

if let code = errorCode {

print("Error code: \(code)")

}

if let message = errorMessage {

print("Reason: \(message)")

}

}

Common Error Codes

When a payment is declined, errorCode identifies the outcome for application logic and analytics. User-facing messaging should remain clear and non-technical.

Error CodeMeaning
USER_CANCELLEDCustomer cancelled the payment (includes Apple's card reader cancel button and cancelPayment())
CARD_DECLINEDCard was declined by the issuer
NETWORK_ERRORNetwork connectivity issue
PAYMENT_IN_PROGRESSAnother payment is already in progress
DEVICE_NOT_SUPPORTEDDevice does not support Tap to Pay on iPhone
SCREEN_CAPTURE_DETECTEDScreen recording/mirroring detected
NOT_INITIALIZEDSDK wasn't initialized
TIMEOUTRequest timed out
AUTH_TOKEN_UNAUTHORIZEDAuth token rejected by server (401)
104Server configuration error (contact support)

You can also check these programmatically:

case .declined(let errorCode, let errorMessage):
switch errorCode {
case HaloErrorCode.userCancelled:
// User tapped cancel — maybe show "Try again?" prompt
break
case HaloErrorCode.cardDeclined:
// Card didn't work — ask for different payment method
break
case HaloErrorCode.networkError:
// Network issue — worth retrying
break
default:
// Show the error message to the user
showAlert(title: "Payment Failed", message: errorMessage ?? "Please try again")
}
}

Applications should always present a clear outcome after a contactless payment attempt. This includes confirming successful payments, indicating when a payment is declined, and clearly distinguishing user-initiated cancellations from errors. This behavior is required to meet Apple’s Tap to Pay on iPhone checkout experience guidelines.

Configuration

Configuration is applied during SDK initialization and affects how the SDK prepares Tap to Pay on iPhone, connects to backend services, and validates transactions. Configuration changes require reinitializing the SDK to take effect.

Environments

The SDK supports two environments:


// For development and testing
try await HaloSDK.initialize(tokenProvider: myProvider, environment: .sandbox)

// For real payments
try await HaloSDK.initialize(tokenProvider: myProvider, environment: .production)

Sandbox mode connects to test services and does not process real payments. It should be used for development and testing only. Production mode is required for live payments and App Store distribution.

Token Provider

The token provider is a closure that returns a Future<String, Error>. The SDK calls it:

  1. During initialization — to validate your backend is working
  2. Before requests — if the cached JWT is expired (or within 30 seconds of expiry)
  3. On 401 retry — if the backend rejects a token the SDK thought was valid

Here's a typical implementation:


func makeTokenProvider() -> @Sendable () -> Future<String, Error> {
return {
Future { promise in
// Your async backend call
APIClient.shared.getAuthToken { result in
switch result {
case .success(let token):
promise(.success(token))
case .failure(let error):
promise(.failure(error))
}
}
}
}
}

// Use it
try await HaloSDK.initialize(
tokenProvider: makeTokenProvider(),
environment: .sandbox
)

Gotchas:

  • The provider must be @Sendable — it can be called from any thread
  • Return quickly — the SDK waits for your provider before proceeding
  • If your provider throws, the SDK treats it as authTokenUnauthorized
  • The JWT must have an exp claim — the SDK reads it to detect expiry client-side

Error Handling

These error codes apply to declined payment results returned from startContactlessPayment. They represent transaction-level outcomes rather than SDK initialization or configuration errors.

Payment attempts return a HaloPaymentResult. Transaction-level outcomes (including declines and cancellations) are returned as .declined with an error code. HaloError is used for SDK lifecycle and operational errors (for example initialization, preparation, token issues, or reader failures).

The SDK uses a single HaloError enum for all errors. Here's how to handle the common ones:


switch error {

case .notInitialized:

// You forgot to call HaloSDK.initialize()

case .deviceNotSupported(let reason):

// Device can't do Tap to Pay

// reason tells you why: .modelNotSupported, .osVersionNotSupported, etc.

case .deviceNotCompliant:

// Device failed security checks (possibly jailbroken)

case .paymentInProgress:

// There's already a payment happening — wait for it to finish

case .networkError(let reason):

// Network issues — check connectivity and retry

case .authTokenUnauthorized:

// Auth token was rejected (401) — get a fresh token from your backend

case .accountLinkingError(let reason):

// Merchant account needs to be linked in Settings → Wallet & Apple Pay

case .tokenError(let reason, let detail):

// Connection token issue — might need a fresh token from your backend

case .readerError(let reason, let detail):

// Card reader had a problem — usually retryable

case .userCancelled:

// Customer cancelled the payment

case .cardDeclined:

// The card was declined by the issuer

default:

print("Error: \(error.localizedDescription)")

}

Error Properties

Every HaloError has properties that help you decide what to do:


if error.isRetryable {

// Retry the operation (for example refresh token, re-attempt preparation, or retry payment start)

}

if error.requiresUserAction {

// Prompt the user to complete the required step (for example account linking or enabling permissions)

}

if error.isFatal {

// Disable Tap to Pay features

Handling Auth Token Rejection

The SDK automatically refreshes tokens — you typically don't need to handle this yourself. Here's what happens under the hood:

  1. Before each request, the SDK checks the cached JWT's exp claim. If expired (or within 30 seconds of expiry), it calls your token provider for a fresh one.

  2. If the backend returns 401 anyway (clock skew, revocation, etc.), the SDK clears the cache, calls your provider again, and retries the request once.

  3. If the retry also fails, the SDK returns authTokenUnauthorized.

So when you see authTokenUnauthorized, it means your token provider itself is broken — the SDK already tried twice. Check your provider implementation:


case .declined(let errorCode, let errorMessage):

if errorCode == HaloErrorCode.authTokenUnauthorized {

// SDK already tried twice with your provider — something's wrong with it
// Check: Is your backend returning valid JWTs?
// Check: Is the JWT's `exp` claim correct?
// Check: Are you using the right environment (sandbox vs production)?

showAlert(title: "Authentication Error",
message: "Please restart the app or contact support")

}

When this happens:

  • Your token provider is returning invalid or expired JWTs
  • Your backend is down or returning errors
  • JWT signature is invalid or malformed
  • Wrong environment (sandbox token in production or vice versa)

Event Delegate

The SDK notifies your app about errors, reader preparation progress, and analytics/timeline events through a delegate. Applications should display clear preparation status while Tap to Pay on iPhone is being configured. The delegate provides preparation progress and readiness callbacks to support this.

The example below shows one way to map SDK events to UI state. Replace the UI updates with your application’s own patterns.


class PaymentHandler: HaloEventDelegate {

// Called during reader preparation (0.0 to 1.0)

func haloSDK(didUpdatePreparationProgress progress: Double) {

DispatchQueue.main.async {

self.progressView.progress = Float(progress)

self.statusLabel.text = "Preparing Tap to Pay..."

}

}

// Called when reader is ready to accept payments

func haloSDKDidBecomeReady() {

DispatchQueue.main.async {

self.progressView.isHidden = true

self.payButton.isEnabled = true

self.statusLabel.text = "Ready to accept payments"

}

}

// Called if reader preparation fails

func haloSDK(didFailPreparationWithError error: HaloError) {

DispatchQueue.main.async {

self.progressView.isHidden = true

self.statusLabel.text = "Setup failed: \(error.localizedDescription)"

}

}

// Called when any error occurs

func haloSDK(didReceiveError event: HaloErrorEvent) {

Analytics.track("payment_error", properties: [

"error_code": event.errorCode,

"error_name": event.errorName,

"description": event.errorDescription,

"source": event.source.rawValue

])

}

// Called whenever the SDK emits an analytics event
// Use this to build a per-payment timeline or send to your analytics backend.
func haloSDK(didEmitAnalyticsEvent event: HaloAnalyticsEvent) {

var properties: [String: Any] = [
"type": event.type.rawValue,
"timestamp": event.timestamp.timeIntervalSince1970
]

// Merge metadata into analytics properties
for (key, value) in event.metadata {
properties[key] = value
}

Analytics.track("halo_payment_timeline", properties: properties)
}

}

// Set it up early, before or right after initialization

HaloSDK.setEventDelegate(PaymentHandler())

Preparation progress callbacks are triggered during SDK initialization and whenever the system needs to prepare or re-prepare Tap to Pay on iPhone (for example after app foregrounding or a configuration change). Applications should be prepared to receive these callbacks more than once during the app lifecycle.

Payment Timeline Analytics

The SDK emits timestamped analytics events during the payment lifecycle using HaloAnalyticsEvent. These are delivered through haloSDK(didEmitAnalyticsEvent:) on HaloEventDelegate and can be used to:

  • Build an in-app timeline for a single payment (for example: initialized → ready for tap → card detected → submitted → response received → approved/declined).
  • Forward the same events to your backend analytics or observability pipeline.

Event types are grouped roughly as:

  • Lifecycle: sdkInitialized, sdkReaderPrepared, discoveryShown
  • Payment flow: paymentStarted, paymentValidated, readyForTap, cardDetected, transactionSubmissionStarted, transactionResponseReceived
  • Outcomes: paymentApproved, paymentDeclined, paymentCancelled, paymentError

A common pattern is to collect all HaloAnalyticsEvent instances for the current payment in an array, render them as a UI timeline, and then send them as a JSON payload to your backend once the payment completes.

To see a single, human-readable timeline in your console, you can map analytics events to labels and print them as relative offsets from the first event:

final class PaymentTimelinePrinter: HaloEventDelegate {
private var startTime: Date?

// Called whenever the SDK emits an analytics event
func haloSDK(didEmitAnalyticsEvent event: HaloAnalyticsEvent) {
if startTime == nil {
startTime = event.timestamp
}
guard let start = startTime else { return }

let delta = event.timestamp.timeIntervalSince(start)
let timestamp = Self.format(delta)
let label = Self.label(for: event)

print("\(timestamp) \(label)")
}

private static func format(_ interval: TimeInterval) -> String {
let totalMillis = Int((interval * 1000.0).rounded())
let minutes = totalMillis / 60000
let seconds = (totalMillis % 60000) / 1000
let millis = totalMillis % 1000
return String(format: "%02d:%02d.%03d", minutes, seconds, millis)
}

private static func label(for event: HaloAnalyticsEvent) -> String {
switch event.type {
case .sdkInitialized:
return "SDK Initialized"
case .sdkReaderPrepared:
return "SDK Prepared (Reader Ready)"
case .discoveryShown:
return "Discovery Shown"
case .paymentStarted:
return "Payment Started"
case .paymentValidated:
return "Payment Validated"
case .readyForTap:
return "Ready For Tap"
case .cardDetected:
return "Card Tapped"
case .transactionSubmissionStarted:
if event.metadata["isSingleTapAndPin"] == "true" {
return "PIN Resubmission Submitted"
} else {
return "Transaction Submitted"
}
case .transactionResponseReceived:
if event.metadata["isApproved"] == "true" {
return "Response Received (Approved)"
} else if event.metadata["isSingleTapAndPin"] == "true" {
return "Response Received (Declined - PIN Required)"
} else {
return "Response Received (Declined)"
}
case .pinCaptureStarted:
return "PIN Capture Started"
case .pinCaptureSucceeded:
return "PIN Entered"
case .paymentApproved:
return "Payment Approved"
case .paymentDeclined:
return "Payment Declined"
case .paymentCancelled:
return "Payment Cancelled"
case .paymentError:
return "Payment Error"
}
}
}

With this in place, a Single Tap + PIN flow might produce console output like:

00:00.000  SDK Initialized
00:00.120 Terms & Conditions Shown
00:02.340 Terms & Conditions Accepted
00:02.500 Discovery Shown
00:05.100 SDK Prepared (Reader Ready)
00:12.340 Card Tapped
00:12.890 Transaction Submitted
00:14.200 Response Received (Declined - PIN Required)
00:14.210 PIN Capture Started
00:18.500 PIN Entered
00:18.900 PIN Resubmission Submitted
00:20.100 Response Received (Approved)

During Single Tap + PIN flows, the SDK automatically uses a dedicated PIN resubmission endpoint (/transactions/{originalTransactionID}/submitPinApple) when available. This allows your backend to properly correlate the initial declined transaction with the subsequent approved transaction that includes PIN verification. The originalTransactionId field is included in the analytics metadata for transactionResponseReceived events when isSingleTapAndPin is true.

Your app is responsible for emitting the non-SDK events (for example Terms & Conditions Shown/Accepted) into the same timeline if you want them included. The SDK-driven events (initialization, discovery, reader ready, card tap, submissions, responses, approvals/declines) come from HaloAnalyticsEvent.

Security

The SDK enforces security controls during Tap to Pay on iPhone preparation and payment. If a prohibited condition is detected, the payment attempt is stopped and an appropriate error is returned.

  • Screen recording or mirroring — the payment attempt is aborted if detected

  • Compromised device checks — devices that fail security integrity checks are blocked

  • App state changes — the payment cannot proceed if the app moves out of the foreground

You don't need to implement any of this yourself. If a security check fails during a payment attempt, the payment returns a declined result with an appropriate error code. If a device fails mandatory security checks outside of a payment attempt, the SDK surfaces a HaloError and disables Tap to Pay functionality for that device.

Sensitive data like tokens are stored in the iOS Keychain, not in UserDefaults or plain files.

Device Support

Tap to Pay requires specific hardware. The SDK checks this during initialization and will throw deviceNotSupported if the device can't accept payments.

Supported devices:

  • iPhone XS and later (includes XR, 11, 12, 13, 14, 15, 16 series)

  • Must be running iOS 15.5 or later

  • Must be a physical device — the simulator doesn't have NFC hardware

Not supported:

  • iPhone X and earlier

  • Any iPad (Tap to Pay on iPhone is not supported on iPadOS)

  • Simulator builds

Device compatibility is evaluated during SDK initialization based on device model, iOS version, entitlements, and required system capabilities. If any requirement is not met, initialization fails with deviceNotSupported.

Cancelling a Payment

If you need to cancel a payment that's in progress (maybe a timeout in your UI):


HaloSDK.cancelPayment()

This stops the card reader and ends the current payment attempt. The startContactlessPayment call will return .declined(errorCode: "USER_CANCELLED", errorMessage: "Payment cancelled by user").

Applications should call cancelPayment() only in response to a clear user action or application-level timeout. The SDK automatically cancels the payment if required by system, security, or lifecycle conditions, and applications do not need to handle those cases explicitly.

Processing Refunds

To process a refund, pass type: .refund to startContactlessPayment. The customer taps their card again, and Apple's reader shows "Refund" instead of "Pay".


let result = await HaloSDK.startContactlessPayment(

amountMinor: 500, // refund amount in cents

currency: "ZAR",

merchantReference: "refund_order_12345",

type: .refund

)

switch result {

case .approved(let receipt):

print("Refund processed: \(receipt.transactionId)")

case .declined(let errorCode, let errorMessage):

print("Refund failed: \(errorMessage ?? "Unknown error")")

}

Gotchas:

  • The card must be physically present — this isn't a "card-not-present" refund

  • Use your own reference linking (e.g., refund_order_12345) to tie refunds to original transactions

Cleanup

When you're done with the SDK (user logs out, switching accounts, etc.):


HaloSDK.deinitialize()

This clears all cached tokens and resets the SDK state. You'll need to call initialize() again before accepting more payments.

Location Permissions

The SDK can include device location in transaction requests for fraud prevention. This is optional but recommended. To enable it, add this key to your app's Info.plist:

<key>NSLocationWhenInUseUsageDescription</key>
<string>Location is used to help verify transactions and prevent fraud.</string>

If location permission is denied or unavailable, transactions still proceed normally, but location data is not included in the request headers. If a user has previously denied location permission, iOS will not prompt again and the user must enable it manually in system settings.

What happens with location:

  • If granted: Location coordinates are sent in the X-Location header for fraud scoring

  • If denied: Transaction proceeds normally without location data

  • If not determined: SDK requests permission automatically (with a 5-second timeout)

Transaction Flow

When you call startContactlessPayment(), here's what happens under the hood:

  1. Card read — Apple's ProximityReader captures encrypted card data

  2. Backend submission — SDK sends the encrypted data to your payment processor via POST /transactions/apple

  3. Result mapping — Backend response is mapped to HaloPaymentResult

The SDK automatically includes these headers with each transaction:

  • X-Device-Installation-Id — A unique UUID generated once per device install (persisted in Keychain)

  • X-Correlation-Id — A unique UUID for each transaction (useful for debugging)

  • X-Location — Device coordinates if location permission is granted

  • Authorization — Bearer token from your auth configuration

Receipt Data

When a payment is approved, the HaloReceipt contains data from both the card reader and the backend:


case .approved(let receipt):

receipt.transactionId // Backend's haloReference

receipt.authCode // Authorization code from processor

receipt.cardBrand // Card type (Visa, Mastercard, etc.)

receipt.last4 // Last 4 digits of card

receipt.amountMinor // Amount in minor units

receipt.currency // Currency code

receipt.reference // Your merchant reference

receipt.approvedAt // Timestamp

Thread Safety

All HaloSDK methods are @MainActor — you should call them from the main thread. The async methods (startContactlessPayment) can be awaited from any async context and will handle threading internally.


// This is fine

Task { @MainActor in

let result = await HaloSDK.startContactlessPayment(...)

}

// This is also fine if you're already in a @MainActor context

@MainActor

func processPayment() async {

let result = await HaloSDK.startContactlessPayment(...)

}

Troubleshooting

"Device not supported" even though I have an iPhone XS

Make sure you're running on a physical device, not the simulator. Also check that you're on iOS 15.5 or later.

"Entitlement missing" error

You need the ProximityReader entitlement from Apple. This requires approval through the Apple Developer program for Tap to Pay.

Location not being sent with transactions

Check that you've added NSLocationWhenInUseUsageDescription to your Info.plist. If the user previously denied location permission, they'll need to enable it manually in Settings → Your App → Location. The SDK won't prompt again after a denial.

"PPS-4003" or "Request Expired" error

The encrypted card data must be submitted to the backend within 60 seconds of the card tap. If you're seeing this error, there may be network delays or the payment flow is taking too long. The SDK handles this automatically, but slow network conditions can cause timeouts.

AUTH_TOKEN_UNAUTHORIZED or "Authentication token rejected" error

The SDK automatically refreshes expired tokens, so if you're seeing this error, your token provider is the problem — not just an expired token. The SDK tried twice (once proactively, once on 401 retry) and both attempts failed.

Check your token provider:

  • Is your backend returning valid JWTs with an exp claim?
  • Is the exp timestamp correct (not already in the past)?
  • Are you using the correct environment (sandbox token for sandbox, production for production)?
  • Is your backend reachable and returning 200 responses?

The SDK reads the JWT's exp claim client-side and refreshes proactively — so even clock skew of a few minutes shouldn't cause issues. If you're still getting this error, add logging to your token provider to see what's being returned.

Your auth token was rejected by the backend (HTTP 401). This usually means:

  • The token has expired — fetch a fresh one from your backend and call HaloSDK.initialize() again
  • You're using a sandbox token in production (or vice versa) — make sure the token matches your environment
  • The token signature is invalid — verify your backend is signing tokens correctly
  • The token was revoked server-side — check with your payment provider

The SDK sets error.requiresTokenRefresh = true for this error, so you can catch it alongside other token issues.