Tap to Pay on iPhone
Tap to Pay on iPhone
Accept in-person contactless payments on iPhone with no card reader or extra hardware, using the HL Hunt Tap to Pay on iPhone SDK.
Architecture
The HL Hunt Tap to Pay SDK performs the actual payment directly with HL Hunt's payment infrastructure — it tokenizes the card read, submits the transfer, and returns the result. Your app's backend integration with HL Hunt handles merchant configuration, device provisioning, and dashboard reconciliation — it is not a second charge API in this flow.
text
iPhone App (Swift SDK) HL Hunt Backend HL Hunt Payments
| | |
| 1. Store API key (sk_test_ or sk_live_) |
| in iOS Keychain | |
| | |
| 2. GET /terminal/tap-to-pay/config |
| Authorization: Bearer sk_test_xxx |
|----------------------------->| |
| credentials + merchant + deviceId |
|<-----------------------------| |
| | |
| 3. POST /terminal/tap-to-pay/device |
| Authorization: Bearer sk_test_xxx |
| { readerIdentifier, appBundleId } |
|----------------------------->| Create + activate |
| deviceId (DV...) | terminal device |
|<-----------------------------|----------------------->|
| | |
| 4. SDK initializes with config (in-memory only) |
| 5. SDK links Apple account (Apple UI) |
| 6. SDK starts transaction |
| Buyer taps card |
| SDK → HL Hunt Payments (direct, TLS) |
| Transfer result |
|<-------------------------------------------------------|
| | |
| 7. POST /terminal/tap-to-pay/record |
| Authorization: Bearer sk_test_xxx |
| { transferId, amount, currency } |
|----------------------------->| Fetch + verify |
| { id, processorTransactionId, state } |
|<-----------------------------| Upsert Transaction row |
| |----------------------->|Key principle: The iOS app never embeds payment processor credentials in the binary. It stores only the HL Hunt API key (sk_test_ / sk_live_) in the iOS Keychain. The backend delivers the payment processor credentials at runtime via GET /config, scoped to the authenticated merchant. This allows per-merchant scoping, rotation, and revocation.
Authentication
All three endpoints accept API key authentication via the Authorization header. The iOS app authenticates with a secret key created in the Developers section of the HL Hunt dashboard.
text
Authorization: Bearer sk_test_your_secret_key
Sandbox vs. Production is determined by the key prefix:
sk_test_→ sandbox mode (no real money moves)sk_live_→ production mode (real transactions)
There is no need to set x-hl-mode headers or cookies when using API keys — the key prefix determines the mode automatically.
The backend resolves the merchant context from the API key's associated merchant — never from a client-supplied merchantId. Each API key is permanently bound to one merchant account, so multi-merchant users need separate keys per business.
> Session fallback: These endpoints also accept session cookies (same as the web dashboard) for testing from a browser. The API key path is the primary method for iOS apps.
> Only secret keys (`sk_`) are accepted. Publishable keys (pk_) are rejected because these endpoints return payment processor credentials.
GET /api/terminal/tap-to-pay/config
Returns the terminal configuration the SDK needs, scoped to the authenticated merchant.
Headers:
text
Authorization: Bearer sk_test_xxx
Response:
json
{
"success": true,
"data": {
"environment": "sandbox",
"credentials": {
"username": "USxxxxxxxxxxxxx",
"password": "..."
},
"merchant": {
"merchantId": "MUxxxxxxxxxxxxx",
"merchantMid": "123456789012345",
"merchantName": "My Business"
},
"deviceId": "DVxxxxxxxxxxxxx"
}
}environment:"sandbox"or"production"— determined by the API key prefix (sk_test_vssk_live_).credentials: Payment processor API username + password for the resolved environment. Never store these — use them in-memory only.merchant.merchantId: The merchant account ID the payments settle to.merchant.merchantMid: The merchant's processing MID (15-digit). May be empty in sandbox.merchant.merchantName: Display name shown to the buyer on Apple's tap screen.deviceId: Cached terminal device ID for this merchant, ornullif not yet provisioned.
POST /api/terminal/tap-to-pay/device
Provisions (or returns existing) a terminal device for the authenticated merchant + iPhone.
Headers:
text
Authorization: Bearer sk_test_xxx Content-Type: application/json
Request:
json
{
"readerIdentifier": "apple-reader-id-from-iphone",
"appBundleId": "org.hlhunt.payments"
}readerIdentifier(required): The iPhone's reader identifier fromPaymentCardReader().readerIdentifier.appBundleId(required): The app's bundle identifier,org.hlhunt.payments. The server checks it against its allowlist (HL_TAP_TO_PAY_BUNDLE_IDS, which defaults toorg.hlhunt.payments) and rejects a missing or unlisted value with403 invalid_bundle_id.
Response:
json
{
"success": true,
"data": {
"deviceId": "DVxxxxxxxxxxxxx",
"enabled": true,
"created": true
}
}Idempotency: Repeated calls for the same merchant + readerIdentifier return the existing device without creating a duplicate. If the device exists but is disabled, it is activated automatically.
POST /api/terminal/tap-to-pay/record
Reconciles a transaction (created by the SDK) into the HL Hunt dashboard.
Headers:
text
Authorization: Bearer sk_test_xxx Content-Type: application/json
Request:
json
{
"transferId": "TRxxxxxxxxxxxxx",
"amount": 1500,
"currency": "USD"
}Response:
json
{
"success": true,
"data": {
"id": "clxxxxxxxxxxxxxxxxxx",
"processorTransactionId": "TRxxxxxxxxxxxxx",
"state": "SUCCEEDED",
"created": true
}
}id: The HL Hunt internal transaction ID (used in the dashboard, webhooks, and the REST API).processorTransactionId: The payment processor's transfer ID (the sameTR...value you sent). Use this to correlate with the SDK's transfer result.state: The transfer state (SUCCEEDED,PENDING,FAILED, etc.).created:trueif a new Transaction row was created;falseif an existing one was updated (idempotent).
Security: The backend fetches the transaction from HL Hunt's payment infrastructure and verifies it belongs to the authenticated merchant before creating any DB row. A foreign transaction ID is rejected with 403.
Swift Integration Flow
swift
import HLHuntTapToPay
import ProximityReader
// ─── 0. API Key Setup ───────────────────────────────────────
// Store your HL Hunt API key in the iOS Keychain (NOT in code).
// Use sk_test_ for sandbox, sk_live_ for production.
// Create keys in the Developers section of the HL Hunt dashboard.
let apiKey = KeychainHelper.read("hlhunt_api_key")! // e.g. "sk_test_xxx"
// Helper: attach the API key to every HL Hunt request
func hlRequest(_ url: String, method: String = "GET", body: Data? = nil) -> URLRequest {
var req = URLRequest(url: URL(string: url)!)
req.httpMethod = method
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let body { req.httpBody = body }
return req
}
// ─── 1. GET config from HL Hunt backend ─────────────────────
let (configData, _) = try await URLSession.shared.data(
for: hlRequest("https://payments.hlhunt.org/api/terminal/tap-to-pay/config")
)
let config = try JSONDecoder().decode(
TapToPayConfigResponse.self, from: configData
).data
// ─── 2. Get the iPhone's reader identifier ──────────────────
let readerId = try await PaymentCardReader().readerIdentifier
// ─── 3. POST device to HL Hunt (provisions + activates) ─────
let deviceBody = try JSONEncoder().encode([
"readerIdentifier": readerId,
"appBundleId": Bundle.main.bundleIdentifier!,
])
let (deviceData, _) = try await URLSession.shared.data(
for: hlRequest(
"https://payments.hlhunt.org/api/terminal/tap-to-pay/device",
method: "POST", body: deviceBody
)
)
let deviceId = try JSONDecoder().decode(
TapToPayDeviceResponse.self, from: deviceData
).data.deviceId
// ─── 4. Initialize the HL Hunt Tap to Pay SDK ───────────────
let configuration = TapToPayConfiguration(
credentials: APICredentials(
username: config.credentials.username,
password: config.credentials.password
),
merchant: MerchantInfo(
merchantId: config.merchant.merchantId,
merchantMid: config.merchant.merchantMid,
merchantName: config.merchant.merchantName
),
environment: config.environment == "sandbox" ? .sandbox : .production,
deviceId: deviceId
)
let tapToPay = HLHuntTapToPay(configuration: configuration)
// ─── 5. Link the merchant's Apple Account (one-time, Apple UI)
try await tapToPay.linkAccount()
// ─── 6. Warm up the reader ──────────────────────────────────
try await tapToPay.prepareReader()
// ─── 7. Start a transaction (SDK handles the tap + charge) ──
let result = try await tapToPay.startTransaction(
amount: 1500, // cents
currency: "USD",
type: .purchase,
identityId: config.merchant.merchantId
)
// ─── 8. POST the transferId to HL Hunt for reconciliation ───
let recordBody = try JSONEncoder().encode([
"transferId": result.transferId,
"amount": "\(1500)",
"currency": "USD",
])
let (recordData, _) = try await URLSession.shared.data(
for: hlRequest(
"https://payments.hlhunt.org/api/terminal/tap-to-pay/record",
method: "POST", body: recordBody
)
)
let record = try JSONDecoder().decode(
TapToPayRecordResponse.self, from: recordData
).data
print("Payment complete: \(record.processorTransactionId) — \(record.state)")
// record.id is the HL Hunt dashboard transaction ID
// record.processorTransactionId is the TR... from the SDKThe SDK processes the payment directly — your app does not call a charge endpoint. Step 8 is purely for dashboard visibility.
API Key Setup (for Ahsan)
Log in to the HL Hunt dashboard.
Switch to Test mode (sandbox).
Go to Developers → API Keys.
Click Create Secret Key — you will get a key like sk_test_xxxxxxxxxxxx.
Copy the key immediately (it's shown only once).
In the iOS app, store it in the iOS Keychain (never in UserDefaults, plist, or source code).
For production, repeat with the Live mode selected — you'll get sk_live_xxxxxxxxxxxx.
The key prefix (sk_test_ vs sk_live_) automatically routes requests to sandbox or production. No mode headers needed.
Sandbox Setup
API key: Create a sandbox API key (sk_test_) from the Developers dashboard in test mode.
Sandbox merchant: The merchant must have a sandbox merchant account. Use the dashboard's test mode to provision one.
Apple Sandbox Account: Create a Sandbox Apple Account in App Store Connect. Sign in on the test iPhone with this account as the primary Apple Account.
Production Setup
API key: Create a production API key (sk_live_) from the Developers dashboard in live mode.
Live merchant: The merchant must have an approved live merchant account with processing enabled.
Apple Account: The merchant's iPhone is signed in with their regular Apple Account.
Apple Developer / Tap to Pay Entitlement
Tap to Pay on iPhone requires:
Paid Apple Developer organization account (not a personal account).
The `com.apple.developer.proximity-reader.payment.acceptance` entitlement, requested from Apple via the [Tap to Pay entitlement request form](https://developer.apple.com/contact/request/tap-to-pay-on-iphone).
App bundle ID: org.hlhunt.payments — the entitlement is scoped to this bundle identifier.
The Account Holder requests the entitlement from Apple's Developer portal. Once approved, add the entitlement to your Xcode project's entitlements file.
Device Provisioning
Each iPhone is registered as a terminal device (ID: DV...) in HL Hunt's system. Provisioning happens once per merchant + iPhone pair:
The iPhone fetches its reader identifier: try await PaymentCardReader().readerIdentifier
The app sends it to POST /api/terminal/tap-to-pay/device with the Authorization: Bearer sk_test_xxx header.
HL Hunt's backend creates and activates the terminal device.
The device ID is cached in HL Hunt's database and returned to the app.
Re-provision on new iPhone: A new iPhone (new reader identifier) or a different merchant needs its own device. The app should cache the device ID keyed by both merchant ID and reader identifier.
Error Handling
| HTTP Status | Code | Meaning |
|---|---|---|
| 401 | invalid_api_key | The API key is missing, invalid, expired, or revoked. |
| 401 | wrong_key_type | A publishable key (pk_) was used. These endpoints require a secret key (sk_). |
| 400 | missing_reader_identifier | The readerIdentifier field is missing from the request body. |
| 400 | missing_transfer_id | The transferId field is missing from the request body. |
| 403 | invalid_bundle_id | The appBundleId is missing or not in the server's allowlist. Use org.hlhunt.payments. |
| 403 | processing_not_enabled | Live mode requested but merchant processing is not yet approved. Complete onboarding first. |
| 403 | transfer_ownership_error | The transaction belongs to a different merchant. |
| 404 | transfer_not_found | The transaction ID was not found. |
| 409 | transfer_mismatch | The amount or currency doesn't match the recorded transaction. |
| 409 | processor_error | Device provisioning conflict (e.g. duplicate device). |
| 502 | processor_error | The payment infrastructure returned an error. |
| 503 | credentials_not_configured | Credentials for the requested environment are not set. |
| 500 | config_error / device_error / record_error | Internal server error. Check logs. |
Security Requirements
- Store the API key in the iOS Keychain. Never hardcode it in source, plist, or UserDefaults.
- Never hardcode payment processor credentials in Swift. The backend delivers them at runtime via
GET /config. Use them in-memory only. - Never accept merchantId from the client. The merchant context is resolved from the API key — each key is bound to one merchant.
- Never expose another merchant's credentials or device. All queries are scoped to the authenticated merchant and environment.
- Device provisioning is idempotent. Repeated calls for the same merchant + reader do not create duplicates.
- Transaction recording verifies ownership. The backend fetches the transaction and verifies it belongs to the authenticated merchant before creating any DB row.
- All three endpoints require a secret API key. Publishable keys are rejected. The key prefix determines sandbox vs. production mode.
Processor-Agnostic Design
The Tap to Pay API is processor-agnostic. The same three endpoints serve every merchant, regardless of how their account is configured. The processor field in the config response is an opaque identifier that tells your app which payment SDK to initialize.
text
GET /api/terminal/tap-to-pay/config
→ returns { "processor": "...", "environment": "...", "credentials": { ... }, "merchant": { ... }, "deviceId": ... }Your app does NOT implement processor selection or routing logic. The HL Hunt API key determines the merchant, and the backend resolves everything else, including which payment SDK the merchant uses. Map each processor value to its SDK and initialization steps using the integration package provided to you separately — those SDK-specific details are not part of the public API surface.