Tap to Pay

Natively add contactless payments to your mobile app with Tap to Pay
View as MarkdownOpen in Claude
Applies to:Developers

Tap to Pay is Payabli’s native mobile component for accepting contactless, card-present payments in your app. It reads the payor’s card, phone, or watch over NFC directly, so your app never touches raw card or bank data. See Tap to Pay overview for how the payment flow works conceptually, or Pay In Payment Flow if you’re looking for Payabli’s card/ACH component instead.

Right now, only iOS is supported.

Prerequisites

Before you start, confirm you have:

  • A physical device. An iPhone XS or newer running iOS 16.7 or later.
  • Payabli sandbox credentials. A Payabli sandbox entryPoint with Tap to Pay enabled. Contact your Payabli representative if you don’t have one yet.
  • Your app registered on the paypoint allowlist. Your app’s appId (<TEAM_ID>.<BUNDLE_ID>) must be authorized on the entrypoint before device attestation succeeds. See Integration steps.
  • Apple’s Tap to Pay entitlement. com.apple.developer.proximity-reader.payment.acceptance. Request it through the Apple Developer Portal.
  • Xcode 15 or later and Swift 5.9 or later.

Apple’s Tap to Pay entitlement (com.apple.developer.proximity-reader.payment.acceptance) needs Apple’s approval before you can use it. Request it through the Apple Developer Portal. Approval can take 2–8 weeks. Apply for this entitlement as soon as you decide to integrate Tap to Pay. Don’t wait until the rest of your integration is ready.

Your app’s provisioning profile also needs com.apple.developer.devicecheck.appattest-environment, set to development for development builds or production for release builds. This value must match the environment you pass to PayabliTTP.

Installation

Install PayabliSDKTapToPay from the sdk-ios Swift package:

1

Add the package

In Xcode, choose File → Add Packages… and enter the sdk-ios repository URL:

https://github.com/payabli/sdk-ios.git

Or add it to your Package.swift dependencies:

1.package(url: "https://github.com/payabli/sdk-ios.git", branch: "main")
3

Import the package

Import it where you construct or call PayabliTTP:

1import PayabliSDKTapToPay

Backend token endpoint

The Tap to Pay component authenticates every request with a short-lived Payabli access token, obtained through a tokenProvider closure you implement. Your backend holds the Payabli clientId and clientSecret, exchanges them for the access token, and never exposes them to your mobile app.

1

Create the project

This example server uses JavaScript with Node.js and Express.

Requires: Node.js and npm

Create a new Node.js project and enable ES modules:

$mkdir payabli-token-proxy
$cd payabli-token-proxy
$npm init -y
$npm pkg set type=module
2

Install dependencies

Install Express, CORS, and dotenv:

$npm install express cors dotenv
3

Write the token endpoint

Create server.js with the following:

1// server.js
2import "dotenv/config";
3import express from "express";
4import cors from "cors";
5
6const app = express();
7app.use(cors(), express.json());
8
9const PAYABLI_URL = process.env.PAYABLI_URL || "https://api-sandbox.payabli.com/api";
10const { PAYABLI_CLIENT_ID, PAYABLI_CLIENT_SECRET } = process.env;
11
12app.post("/payabli/token", async (req, res) => {
13 const upstream = await fetch(`${PAYABLI_URL}/v2/Token/serverside`, {
14 method: "POST",
15 headers: { "Content-Type": "application/json" },
16 body: JSON.stringify({
17 clientId: PAYABLI_CLIENT_ID,
18 clientSecret: PAYABLI_CLIENT_SECRET,
19 }),
20 });
21
22 res.status(upstream.status).json(await upstream.json());
23});
24
25app.listen(process.env.PORT || 3000, () =>
26 console.log("Token server ready"));

Store clientId and clientSecret in environment variables on your server. Never commit them to source control.

4

Start the server

Start the server:

$node server.js
5

Call it from your app

The tokenProvider closure calls this endpoint and returns the token:

1let tokenProvider: () async throws -> String = {
2 try await backend.fetchPayabliAccessToken()
3}

The component calls this closure again automatically after a 401 Unauthorized response and retries the request. You don’t need to track expiration or schedule refreshes.

Implementation walkthrough

This walkthrough builds a minimal Tap to Pay charge, from constructing PayabliTTP through handling the result:

Loading walkthrough content

Loading walkthrough...

Loading walkthrough...

/// Add the import and token provider

Import PayabliSDKTapToPay and add a function that exchanges credentials with your backend for a Payabli access token, the same way the manual entry flow does.

/// Create the PayabliTTP instance

Construct PayabliTTP with your entryPoint, the app’s appId (<TEAM_ID>.<BUNDLE_ID>), and your token provider. The appId must already be authorized on the entrypoint’s allowlist.

/// Initialize and handle first-time activation

Call initialize() before the first charge. On a device’s first launch, it throws PayabliTTPError.devicePendingActivation until the partner backend delivers an activation code and your app calls activateDevice(activationCode:).

/// Charge a payment

Call charge(type:paymentDetails:) when ttp.isReady is true. Hold the device near the customer’s card, phone, or watch when prompted.

1import SwiftUI
2import PayabliSDKTapToPay
3
4func fetchPayabliAccessToken() async throws -> String {
5 var request = URLRequest(url: URL(string: "https://your-backend.example.com/payabli/token")!)
6 request.httpMethod = "POST"
7
8 struct Response: Decodable { let access_token: String }
9 let (data, _) = try await URLSession.shared.data(for: request)
10 return try JSONDecoder().decode(Response.self, from: data).access_token
11}
12
13@MainActor
14final class TapToPayViewModel: ObservableObject {
15 let ttp: PayabliTTP
16
17 init(entryPoint: String, appId: String) async throws {
18 ttp = PayabliTTP(
19 accessToken: try await fetchPayabliAccessToken(),
20 tokenProvider: {
21 try await fetchPayabliAccessToken()
22 },
23 entryPoint: entryPoint,
24 appId: appId,
25 environment: .sandbox
26 )
27 }
28
29 func start() async {
30 do {
31 try await ttp.initialize()
32 } catch PayabliTTPError.devicePendingActivation {
33 let code = await promptForActivationCode()
34 do {
35 try await ttp.activateDevice(activationCode: code)
36 try await ttp.initialize()
37 } catch {
38 print("Activation failed:", error.localizedDescription)
39 }
40 } catch {
41 print("Tap to Pay initialization failed:", error.localizedDescription)
42 }
43 }
44
45 func charge(amount: Decimal) async {
46 guard ttp.isReady else {
47 print("Tap to Pay isn't ready yet.")
48 return
49 }
50 do {
51 let result = try await ttp.charge(
52 type: .sale,
53 paymentDetails: PayabliTTPPaymentDetails(amount: amount)
54 )
55 print("Transaction captured. ID:", result.paymentTransId)
56 } catch {
57 print("Charge failed:", error.localizedDescription)
58 }
59 }
60}

This walkthrough covers the component-level constructor, initialize(), and charge(). For the full Tap to Pay setup (paypoint registration, Apple entitlements, and device activation), see Accept Tap to Pay payments.

Implementation reference

PayabliTTP and charge(...) are the two types you construct and call directly. There’s no hosted form for Tap to Pay.

PayabliTTP constructor

accessToken
StringRequired

A valid short-lived bearer token issued by your backend. Used for the first request.

tokenProvider
() async throws -> StringRequired

Invoked to get a new token after a 401 Unauthorized response. Concurrent refresh attempts are deduplicated.

entryPoint
StringRequired

The entrypoint slug provisioned by Payabli.

appId
StringRequired

<TEAM_ID>.<BUNDLE_ID>. Must already be authorized on the entrypoint’s allowlist.

environment
PayabliEnvironmentRequired

.sandbox or .production. Must match the app’s appattest-environment entitlement.

charge(...) parameters

The charge(...) method returns a TransactionResult, which has a single field: paymentTransId. Use it to track and reconcile the payment.

type
PayabliTTPPaymentTypeRequired

Must be .sale.

paymentDetails
PayabliTTPPaymentDetailsRequired
amount
numberRequired

Must be greater than 0.

serviceFee
numberDefaults to 0
currency
string

Omit to authorize in the merchant’s configured processor currency.

paymentDescription
string
customer
PayabliTTPCustomerData

Cardholder snapshot, persisted at /initiate. Defaults to anonymous. Every field is optional; blank values are ignored.

firstName
string
lastName
string
customerNumber
string
customerId
number

Payabli’s internal customer ID, when the customer is already registered.

company
string
email
string
phone
string
billingEmail
string
billingPhone
string
billingAddress1
string
billingAddress2
string
billingCity
string
billingState
string
billingZip
string
billingCountry
string
shippingAddress1
string
shippingAddress2
string
shippingCity
string
shippingState
string
shippingZip
string
shippingCountry
string
invoice
PayabliTTPInvoiceData

Invoice metadata, persisted at /initiate.

invoiceNumber
string

Invoice reference forwarded to the backend and the processor.

orderDescription
String

Optional free-form description forwarded to the backend.

Errors

PayabliTTPError covers the Tap to Pay session and charge lifecycle. Catch specific cases before the general case so you can respond appropriately:

1do {
2 try await ttp.initialize()
3 let result = try await ttp.charge(
4 type: .sale,
5 paymentDetails: PayabliTTPPaymentDetails(amount: 9.99)
6 )
7} catch PayabliTTPError.devicePendingActivation {
8 // First-time device. Prompt for an activation code.
9} catch let PayabliTTPError.invalidState(current, attempted) {
10 // The session isn't in the required state for this call.
11} catch let PayabliTTPError.attestationFailed(reason) {
12 // App Attest or Payabli refused to attest the device.
13} catch let PayabliTTPError.nfcFailed(reason) {
14 // The card was removed too soon, the reader timed out, or similar. Usually retryable.
15} catch let PayabliTTPError.initiateFailed(reason) {
16 // POST /MoneyIn/initiate failed. No transaction was created. Safe to retry.
17} catch let PayabliTTPError.updateFailed(reason) {
18 // PATCH /MoneyIn/update/{id} failed after retries. See the note below.
19} catch PayabliTTPError.tokenExpired {
20 // Your token provider returned no token. Re-authenticate and try again.
21}

If updateFailed occurs, the transaction may still be authorized by the processor, but PayabliTTPError.updateFailed(reason:) only carries a failure reason, not the transaction ID. initiateFailed, in contrast, means no transaction was created at all, so it’s always safe to retry.

Capturing paymentTransId

Because updateFailed doesn’t carry paymentTransId, capture it earlier by subscribing to ttp.events() and storing the ID from .chargeInitiated(paymentTransId:), which fires as soon as /initiate succeeds, before any card is read:

1for await event in ttp.events() {
2 switch event {
3 case .chargeInitiated(let paymentTransId):
4 pendingPaymentTransId = paymentTransId
5 case .updateFailed(let paymentTransId, let error):
6 print("Update failed for", paymentTransId, "-", error, "- reconcile manually.")
7 default:
8 break
9 }
10}

Example app

The sdk-ios repository includes PayabliDemo, a SwiftUI example app that exercises PayabliPayInPaymentFlow and PayabliTTP (card/ACH capture and Tap to Pay), alongside a bundled local Node.js token server for testing.

1

Clone the repository

Clone sdk-ios and open the example project:

$git clone https://github.com/payabli/sdk-ios.git
$cd sdk-ios/Example/PayabliDemo
2

Configure your secrets

Copy the secrets template, then fill in your sandbox identifiers and configure Pay In Payment Flow to use the bundled local token server:

Loading walkthrough content

Loading walkthrough...

Loading walkthrough...

/// Set your sandbox identifiers

Copy Secrets.swift.sample to Secrets.swift, then set your sandbox entryPoint and appId (<TEAM_ID>.<BUNDLE_ID>):

/// Configure Pay In Payment Flow to use the local token server

fetchPaymentMethodAccessToken() calls partnerPaymentMethodAccessTokenEndpoint. Set it to the bundled LocalTokenServer so the Pay In Payment Flow tabs work without a real backend:

1enum Secrets {
2 static let entryPoint = "your-sandbox-entry-point"
3 static let appId = "ABCDE12345.com.yourcompany.app"
4 static let placeholderAccessToken = "placeholder-token"
5 static let partnerTokenEndpoint = URL(string: "https://your-backend.example.com/payabli/token")!
6 static let partnerPaymentMethodAccessTokenEndpoint = URL(string: "http://127.0.0.1:8787/payabli/access-token")!
7 static let paymentMethodDiagnosticsEnabled = false
8 static let paymentCaptureDiagnosticsEnabled = paymentMethodDiagnosticsEnabled
9
10 static func fetchAccessToken() async throws -> String {
11 var request = URLRequest(url: partnerTokenEndpoint)
12 request.httpMethod = "POST"
13 let (data, _) = try await URLSession.shared.data(for: request)
14 struct TokenResponse: Decodable { let accessToken: String?; let access_token: String? }
15 let response = try JSONDecoder().decode(TokenResponse.self, from: data)
16 if let token = response.accessToken ?? response.access_token { return token }
17 throw URLError(.userAuthenticationRequired)
18 }
19
20 static func fetchPaymentMethodAccessToken() async throws -> String {
21 var request = URLRequest(url: partnerPaymentMethodAccessTokenEndpoint)
22 request.httpMethod = "POST"
23 let (data, _) = try await URLSession.shared.data(for: request)
24 struct PaymentMethodTokenResponse: Decodable { let accessToken: String?; let access_token: String? }
25 let response = try JSONDecoder().decode(PaymentMethodTokenResponse.self, from: data)
26 if let token = response.accessToken ?? response.access_token { return token }
27 throw URLError(.userAuthenticationRequired)
28 }
29
30 static func fetchPaymentCaptureAccessToken() async throws -> String {
31 try await fetchPaymentMethodAccessToken()
32 }
33}
3

Start the local token server

In a separate terminal, start the bundled LocalTokenServer:

$cd LocalTokenServer
$cp .env.example .env

Edit .env and set PAYABLI_ACCESS_TOKEN to a short-lived sandbox access token, then start the server:

$node server.mjs

See the LocalTokenServer README for the credential-exchange mode and physical-device networking notes.

4

Run the app

Open PayabliDemo.xcodeproj in Xcode, select the PayabliDemo scheme, and run it. The Pay In Payment Flow tabs work in the Simulator. Tap to Pay requires a physical device. See Requirements.

The example app assumes a sandbox entryPoint with card, ACH, and Tap to Pay enabled. Contact your Payabli representative if your sandbox paypoint doesn’t have these enabled yet.

See these related resources to help you get the most out of Payabli.