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_ vs sk_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, or null if 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 from PaymentCardReader().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 to org.hlhunt.payments) and rejects a missing or unlisted value with 403 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 same TR... value you sent). Use this to correlate with the SDK's transfer result.
  • state: The transfer state (SUCCEEDED, PENDING, FAILED, etc.).
  • created: true if a new Transaction row was created; false if 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 SDK

The 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)

1

Log in to the HL Hunt dashboard.

2

Switch to Test mode (sandbox).

3

Go to Developers → API Keys.

4

Click Create Secret Key — you will get a key like sk_test_xxxxxxxxxxxx.

5

Copy the key immediately (it's shown only once).

6

In the iOS app, store it in the iOS Keychain (never in UserDefaults, plist, or source code).

7

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

1

API key: Create a sandbox API key (sk_test_) from the Developers dashboard in test mode.

2

Sandbox merchant: The merchant must have a sandbox merchant account. Use the dashboard's test mode to provision one.

3

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

1

API key: Create a production API key (sk_live_) from the Developers dashboard in live mode.

2

Live merchant: The merchant must have an approved live merchant account with processing enabled.

3

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:

1

Paid Apple Developer organization account (not a personal account).

2

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).

3

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:

1

The iPhone fetches its reader identifier: try await PaymentCardReader().readerIdentifier

2

The app sends it to POST /api/terminal/tap-to-pay/device with the Authorization: Bearer sk_test_xxx header.

3

HL Hunt's backend creates and activates the terminal device.

4

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 StatusCodeMeaning
401invalid_api_keyThe API key is missing, invalid, expired, or revoked.
401wrong_key_typeA publishable key (pk_) was used. These endpoints require a secret key (sk_).
400missing_reader_identifierThe readerIdentifier field is missing from the request body.
400missing_transfer_idThe transferId field is missing from the request body.
403invalid_bundle_idThe appBundleId is missing or not in the server's allowlist. Use org.hlhunt.payments.
403processing_not_enabledLive mode requested but merchant processing is not yet approved. Complete onboarding first.
403transfer_ownership_errorThe transaction belongs to a different merchant.
404transfer_not_foundThe transaction ID was not found.
409transfer_mismatchThe amount or currency doesn't match the recorded transaction.
409processor_errorDevice provisioning conflict (e.g. duplicate device).
502processor_errorThe payment infrastructure returned an error.
503credentials_not_configuredCredentials for the requested environment are not set.
500config_error / device_error / record_errorInternal 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.