Skip to navigation

Tap to Pay

Beta
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 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, in a region where Apple supports Tap to Pay on iPhone. The Simulator can’t take a Tap to Pay payment.
  • Payabli sandbox credentials. A Payabli sandbox entryPoint with Tap to Pay enabled. Contact your Payabli representative if you don’t have one yet.
  • An OAuth2 client ID and client secret. See Permissions for the required permissions.
  • Your app registered as an authorized app. Your app’s App ID (<APP_ID_PREFIX>.<BUNDLE_ID>, where the prefix is your Team ID for most apps) must be one of the paypoint’s authorized apps 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 PayabliConfig.

Installation

Install PayabliSDKTapToPay from the sdk-ios Swift package:

1

Add the package

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

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

Or add it to your Package.swift dependencies:

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

Import the modules

Import PayabliSDKCore and PayabliSDKTapToPay where you construct or call PayabliTTP:

import PayabliSDKCore
import 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 18 or later, 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 and dotenv:

npm install express dotenv
3

Write the token endpoint

Create server.js with the following. It returns the token as { "accessToken": "..." }:

// server.js
import "dotenv/config";
import express from "express";
const app = express();
const PAYABLI_URL = process.env.PAYABLI_URL ?? "https://api-sandbox.payabli.com/api";
// Replace with your app's own check of the caller's session. This example
// accepts a single development credential from APP_SESSION_TOKEN.
async function authenticateUser(req) {
const expected = process.env.APP_SESSION_TOKEN;
if (!expected || req.get("Authorization") !== `Bearer ${expected}`) {
return null;
}
return { id: "dev-user" };
}
// Replace with your app's own rule for who may take payments for this paypoint.
async function mayTakePayments(user) {
return true;
}
app.post("/payabli/token", async (req, res) => {
// Never return a token to a caller you haven't authenticated.
const user = await authenticateUser(req);
if (!user) {
return res.status(401).json({ error: "unauthenticated" });
}
if (!(await mayTakePayments(user))) {
return res.status(403).json({ error: "forbidden" });
}
let accessToken;
try {
const upstream = await fetch(`${PAYABLI_URL}/v2/token/serverside`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: process.env.PAYABLI_CLIENT_ID,
clientSecret: process.env.PAYABLI_CLIENT_SECRET,
}),
redirect: "error", // Never send the client secret to another origin.
signal: AbortSignal.timeout(10_000), // Well inside the SDK's 30-second limit.
});
const body = await upstream.json();
accessToken = upstream.ok ? (body.access_token ?? body.accessToken) : undefined;
} catch {
// A timeout, a redirect, a network failure, or a response that isn't JSON.
}
if (!accessToken) {
return res.status(502).json({ error: "token exchange failed" });
}
res.set("Cache-Control", "no-store").json({ accessToken });
});
app.listen(process.env.PORT ?? 3000, () => console.log("Token server ready"));

Create a .env file next to server.js with your credentials:

PAYABLI_CLIENT_ID=<your sandbox client ID>
PAYABLI_CLIENT_SECRET=<your sandbox client secret>
APP_SESSION_TOKEN=<a long random string your app sends as a bearer token>

Anyone who can call this route gets a token that can charge, store payment methods, and void for your paypoint. Before you go live, replace authenticateUser and mayTakePayments with the same session and permission checks the rest of your app uses.

Keep clientId and clientSecret on your server, in environment variables or a secret store. Never commit .env to source control.

4

Start the server

Start the server:

node server.js
5

Call it from your app

Pass a tokenProvider closure that calls this endpoint and returns the token when you start the session:

let session = try await PayabliSession.initialize(config: PayabliConfig(
entryPoint: "your-entry-point",
environment: .sandbox,
tokenProvider: { try await backend.fetchPayabliAccessToken() }
))

The SDK calls the token provider before its first request, and again when a token is rejected. Concurrent callers share one call, so you don’t need to track expiration or schedule refreshes. Each call has 30 seconds to return. A call that takes longer, throws, or returns a token the SDK can’t use fails with PayabliErrorType.tokenProviderFailed.

Anyone who can call your token endpoint gets a token that can charge for your paypoint. Authenticate the caller and check that they’re allowed to take payments before you return a token.

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 imports and token provider

Import PayabliSDKCore and PayabliSDKTapToPay, and add a function that exchanges credentials with your backend for a Payabli access token, the same way the Pay In component does. In production, send your app’s own session credential with this request so your token endpoint can authenticate the caller.

/// Start the session and create PayabliTTP

Start one PayabliSession with your entrypoint, environment, and token provider, then call PayabliTTP.create(), which runs on that session. create() throws a TapToPayError whose type is .sessionNotInitialized if no session has been started. You don’t pass your App ID: the SDK reads it from the signed app, and it must already be registered as one of the paypoint’s authorized apps.

/// Initialize and handle first-time setup

Call initialize() before the first charge. On a device’s first launch, it throws a TapToPayError whose type is .devicePendingActivation until your app calls activateDevice(activationCode:) with a code issued for the device. If the merchant hasn’t accepted Apple’s Tap to Pay terms, it throws one whose type is .termsNotAccepted. Present the terms with presentTerms(), confirm with areTermsAccepted(), then initialize again.

/// Charge a payment

Call charge(type:paymentDetails:customer:) when ttp.isReady is true. Hold the device near the customer’s card, phone, or watch when prompted. If the card is declined, ask for another card. For any other TapToPayError, its capture value says whether the card was charged. Keep error.paymentTransId when the outcome is unknown or charged, so you can look up or reconcile the payment.

import SwiftUI
import PayabliSDKCore
import PayabliSDKTapToPay
func fetchPayabliAccessToken() async throws -> String {
var request = URLRequest(url: URL(string: "https://your-backend.example.com/payabli/token")!)
request.httpMethod = "POST"
// Your app's own session credential, which your token endpoint verifies.
request.setValue("Bearer \(yourSessionToken)", forHTTPHeaderField: "Authorization")
struct Response: Decodable { let accessToken: String }
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(Response.self, from: data).accessToken
}
@MainActor
final class TapToPayViewModel: ObservableObject {
let ttp: PayabliTTP
init(ttp: PayabliTTP) {
self.ttp = ttp
}
static func make(entryPoint: String) async throws -> TapToPayViewModel {
_ = try await PayabliSession.initialize(config: PayabliConfig(
entryPoint: entryPoint,
environment: .sandbox,
tokenProvider: { try await fetchPayabliAccessToken() }
))
return TapToPayViewModel(ttp: try await PayabliTTP.create())
}
func start() async {
do {
try await ttp.initialize()
} catch let error as TapToPayError where error.type == .devicePendingActivation {
let code = await promptForActivationCode()
do {
try await ttp.activateDevice(activationCode: code)
} catch {
print("Activation failed:", error.localizedDescription)
return
}
await start()
} catch let error as TapToPayError where error.type == .termsNotAccepted {
do {
try await ttp.presentTerms()
guard try await ttp.areTermsAccepted() else { return }
} catch {
print("Couldn't confirm Apple's terms:", error.localizedDescription)
return
}
await start()
} catch {
print("Tap to Pay initialization failed:", error.localizedDescription)
}
}
func charge(amount: Decimal) async {
do {
let result = try await ttp.charge(
type: .sale,
paymentDetails: PayabliTTPPaymentDetails(amount: amount),
customer: PayabliTTPCustomerData(firstName: "Jane", lastName: "Doe")
)
// Store result.paymentTransId with your order record. Don't log it.
saveTransactionId(result.paymentTransId)
} catch let error as TapToPayError where error.type == .cardDeclined {
print("Declined. Ask for another card.")
} catch let error as TapToPayError {
switch error.capture {
case .notCharged:
print("Not charged. Safe to retry.")
case .unknown:
// Keep the ID to look up the transaction before charging again.
// When there's no ID, find the transaction in the Payabli Portal.
saveTransactionId(error.paymentTransId)
print("Outcome unknown. Look up the transaction before charging again.")
case .charged:
// Keep the ID to reconcile the payment. Don't charge again.
saveTransactionId(error.paymentTransId)
print("Charged, but a later step failed. Reconcile the payment.")
}
} catch {
// A CancellationError, which charge throws only before the card is read.
print("Charge canceled before the card was read.")
}
}
}

This walkthrough covers the component-level constructor, initialize(), and charge(). For the full Tap to Pay setup (authorized apps, Apple entitlements, device activation, and Apple’s terms), 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.

Session and PayabliTTP.create()

PayabliTTP runs on the app’s one PayabliSession. Start the session with PayabliSession.initialize(config:) before you call PayabliTTP.create():

let session = try await PayabliSession.initialize(config: PayabliConfig(
entryPoint: "your-entry-point",
environment: .sandbox,
tokenProvider: { try await fetchPayabliAccessToken() }
))
let ttp = try await PayabliTTP.create()

create() throws a TapToPayError whose type is .sessionNotInitialized when no session has been started. Pay In runs on the same session: pass session to PayabliPayIn. You don’t pass your App ID to the SDK. It reads the App ID from the signed app and sends it when the device attests.

PayabliConfig takes these values:

entryPoint
StringRequired

The entrypoint slug provisioned by Payabli. PayabliConfig throws if it’s blank.

environment
PayabliEnvironmentRequired

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

tokenProvider
() async throws -> StringRequired

Returns a new access token from your token endpoint. The SDK calls it before its first request, and again when a token is rejected. Concurrent callers share one call. Each call has 30 seconds to return.

Start the session once. Calling initialize again with the same entrypoint, environment, and telemetry setting returns the same session. Different values throw a PayabliError whose type is .invalidConfiguration, and the running session stays in place.

Methods and properties

initialize()
async throws

Attests the device, fetches its configuration, and prepares the reader. The first run on a device takes longer than later runs.

activateDevice(activationCode:)
async throws

Activates a device that’s pending activation. Call initialize() again afterward.

presentTerms()
async throws

Shows Apple’s Tap to Pay on iPhone Terms and Conditions sheet. Returning doesn’t mean the merchant accepted. Call areTermsAccepted() to check.

areTermsAccepted()
async throws -> Bool

Returns false when the merchant hasn’t accepted Apple’s terms. Throws a TapToPayError when the reader can’t answer. Call it each time instead of caching the answer, because acceptance can change outside your app.

charge(type:paymentDetails:customer:invoice:orderDescription:)
async throws -> TransactionResult

Takes a payment. See the charge(...) parameters below.

sessionState
PayabliTTPSessionState

The session’s current state. PayabliTTP is an ObservableObject, so you can bind sessionState and isReady in SwiftUI.

isReady
Bool

true when the session is ready to charge.

Session states

StateMeaning
.idleNot started, or activated and waiting for initialize().
.attestingDevice, .fetchingConfig, .initializingReader(percent:)initialize() is running.
.readyReady to charge.
.charging(activity:)charge(...) is running, and isReady is false until it ends. activity is .opening, .waitingForCard, or .closing, or a prompt the reader raised during the tap: .cardDetected, .cardRemovalRequested, .cardReadRetryRequested, .pinEntryRequested, .pinEntryCompleted, or .readerPromptDismissed.
.pendingActivation(activationId:)The device needs an activation code. activationId is the value the activation code route’s deviceId field takes.
.pendingTermsThe merchant hasn’t accepted Apple’s terms.
.sessionExpiredThe session needs refreshing. The next charge(...) refreshes it.
.reinitializingThe session is being refreshed.
.failed(reason:)The session stopped. See the failure reasons below.

A charge ends at .ready, at .sessionExpired when the reader session was used up, or at .failed(reason: .deviceSetupRequired) when the device’s registration is gone. The charge’s outcome is what charge(...) returns or throws.

To show progress, switch on sessionState in SwiftUI:

struct TerminalView: View {
@ObservedObject var ttp: PayabliTTP
var body: some View {
switch ttp.sessionState {
case .ready: Button("Charge") { Task { await charge() } }
case .charging(.waitingForCard): Text("Hold a card near the top of the iPhone")
case .charging(.cardRemovalRequested): Text("Remove the card")
case .initializingReader(let percent): ProgressView(value: Double(percent ?? 0), total: 100)
default: ProgressView()
}
}
}

When the session is .failed, reason is a PayabliTTPFailureReason:

ReasonWhat to do
.configurationRejectedThe paypoint, the app, the device, or its setup is missing something. For example, the app isn’t one of the paypoint’s authorized apps, the credentials lack tools_init or pos_create, or the app is missing its Keychain entitlement. Fix the setup, then call initialize() again. If you can’t find the cause, contact Payabli.
.deviceSetupRequiredThe device must be set up again: its setup was refused or revoked, its registration was replaced, or its key is gone. Check the entitlements, then call initialize() again.
.serviceUnavailableThe service, Apple’s attestation service, or the reader wasn’t available. Try again later.
.deviceIneligibleThis iPhone or iOS version can’t take Tap to Pay payments, the card reader refused it, or the app has no bundle identifier. If an iPhone that meets the requirements lands here, contact Payabli before replacing it.
.deviceKeyUnavailableThe device’s secure storage is unavailable, for example before the first unlock after a restart. Call initialize() again. If it keeps failing, the problem is with the device.
.sdkInternalErrorReport it to Payabli.

charge(...) parameters

The charge(...) method returns a TransactionResult only when the payment is approved. TransactionResult has a single field, paymentTransId. Store it with your order to track and reconcile the payment.

type
PayabliTTPPaymentTypeRequired

Must be .sale.

paymentDetails
PayabliTTPPaymentDetailsRequired
amount
DecimalRequired

The total amount charged.

serviceFee
DecimalDefaults to 0

Part of amount, not added to it. The card is charged amount.

currency
string

Omit to charge in the paypoint’s currency.

paymentDescription
string
customer
PayabliTTPCustomerData

The payor. Name the payor with at least one of firstName, lastName, customerNumber, or customerId, because a charge that names nobody can be refused. The other fields are optional, and 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

Every Tap to Pay failure is a TapToPayError, except a cancellation, which can end the call as a CancellationError. A TapToPayError carries the error catalog entry for its cause:

  • category: What to do, such as .credential (call initialize() again) or .outcomeUnknown (find the transaction before you repeat the call).
  • type: The cause, for the cases your app handles individually, such as .devicePendingActivation.
  • code: The catalog number. Give it to Payabli support with the failure.
  • message: Fixed text. detail holds the service’s or the reader’s own words, when there are any.
  • retryAfter: How long the service asked you to wait before trying again, when it asked.

You don’t need to handle every type individually. Every TapToPayError also carries capture, which tells you whether the card was charged, and paymentTransId, when there is one:

captureMeaningWhat to do
.notChargedThe card wasn’t charged.You can retry.
.unknownThe outcome isn’t known.Look up paymentTransId with Get transaction details before you charge again. When there’s no ID, find the transaction in the Payabli Portal.
.chargedThe card was charged, but a later step failed.Don’t charge again. Reconcile the payment.

Handle these types individually, then fall back to capture for everything else:

do {
let result = try await ttp.charge(
type: .sale,
paymentDetails: PayabliTTPPaymentDetails(amount: 9.99),
customer: PayabliTTPCustomerData(firstName: "Jane", lastName: "Doe")
)
// Store result.paymentTransId with your order. Don't log it.
} catch let error as TapToPayError where error.type == .devicePendingActivation {
// The device needs an activation code.
} catch let error as TapToPayError where error.type == .termsNotAccepted {
// The merchant hasn't accepted Apple's terms. Present them.
} catch let error as TapToPayError where error.type == .cardDeclined {
// The card was declined. Ask for another card.
} catch let error as TapToPayError where error.type == .paymentOutcomeUnknown {
// Look up error.paymentTransId before charging again. Don't retry.
} catch let error as TapToPayError {
switch error.capture {
case .notCharged:
break // Safe to retry.
case .unknown:
break // Look up error.paymentTransId before charging again.
case .charged:
break // Don't charge again. Reconcile the payment.
}
} catch {
// A CancellationError, which charge(...) throws only before the card is read,
// so nothing was charged.
}

A .tokenProviderFailed type means your token provider failed. Credentials without inboundpayments_create reach .ready, and charge(...) then throws a TapToPayError whose type is .permissionDenied, before the card is read.

If you cancel the task running charge(...) after the transaction has opened, charge(...) throws a TapToPayError with paymentTransId and capture. Handle it like any other error.

Example app

The sdk-ios repository includes PayabliDemo, a SwiftUI example app that exercises PayabliPayIn 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 point the app at the bundled local token server:

cp App/Configuration/Secrets.swift.sample App/Configuration/Secrets.swift
Loading walkthrough content

Loading walkthrough...

Loading walkthrough...

/// Set your sandbox identifiers

In App/Configuration/Secrets.swift, set the sandbox entry in entryPoints to your sandbox entryPoint. There’s no App ID to set: the SDK reads it from the signed app, and the TapToPay tab shows the value to register as an authorized app. localNetworkHost is the fallback host a physical device uses when you don’t pass -PayabliTokenHost. Set it to the output of scutil --get LocalHostName on your Mac, followed by .local:

/// Point the app at the local token server

Both token endpoints ship pointing at a placeholder backend. Switch them to DemoConfiguration.TokenServer.accessTokenURL, which resolves to 127.0.0.1 in the Simulator, so the Save and Capture tabs work there without a real backend. The TapToPay tab needs a physical device, which also needs the server setup in the next step:

import Foundation
enum Secrets {
static let entryPoints: [String: String] = [
"sandbox": "your-sandbox-entry-point"
]
static let localNetworkHost = "your-mac-hostname.local"
static var partnerTokenEndpoint: URL { DemoConfiguration.TokenServer.accessTokenURL }
static var partnerPaymentMethodAccessTokenEndpoint: URL { DemoConfiguration.TokenServer.accessTokenURL }
static let paymentMethodDiagnosticsEnabled = false
static let paymentCaptureDiagnosticsEnabled = paymentMethodDiagnosticsEnabled
static func fetchAccessToken() async throws -> String {
try await token(from: partnerTokenEndpoint)
}
static func fetchPaymentMethodAccessToken() async throws -> String {
try await token(from: partnerPaymentMethodAccessTokenEndpoint)
}
static func fetchPaymentCaptureAccessToken() async throws -> String {
try await fetchPaymentMethodAccessToken()
}
private static func token(from url: URL) async throws -> String {
var request = URLRequest(url: url)
request.httpMethod = "POST"
let (data, _) = try await URLSession.shared.data(for: request)
struct TokenResponse: Decodable {
let accessToken: String?
let access_token: String?
}
let response = try JSONDecoder().decode(TokenResponse.self, from: data)
guard let token = response.accessToken ?? response.access_token else {
throw URLError(.userAuthenticationRequired)
}
return token
}
}
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

The server binds only to 127.0.0.1 by default and authenticates no caller, so run it only on your own machine.

To test on a physical device, which Tap to Pay requires, the phone has to reach the server over your network:

  1. Start the server on all interfaces instead, only on a trusted network, and stop it when you’re done:

    PAYABLI_LOCAL_TOKEN_SERVER_HOST=0.0.0.0 node server.mjs
  2. In Xcode, choose Product → Scheme → Edit Scheme → Run → Arguments and pass your Mac’s LAN address, on the same network as the phone:

    -PayabliTokenHost 192.168.1.42
  3. Allow Local Network access when the app asks for it on the phone. See the LocalTokenServer README for the credential-exchange mode and physical-device networking notes.

4

Run the app

Open PayabliDemo.xcodeproj in Xcode and set DEVELOPMENT_TEAM on the PayabliDemo target. Select the PayabliDemo sandbox scheme, which picks the sandbox entry from Secrets.entryPoints, and run it. The Save and Capture tabs work in the Simulator. The TapToPay tab 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.