> This is Payabli documentation. For a complete page index, fetch https://docs.payabli.com/llms.txt — append .md to any page URL for lightweight markdown. For section-level indexes, query parameters, and other AI-optimized access methods, see https://docs.payabli.com/ai-agents.md

# Tap to Pay

> Natively add contactless payments to your mobile app with Tap to Pay

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](/guides/pay-in-tap-to-pay-overview) for how the payment flow works conceptually, or [Pay In Payment Flow](/guides/mobile-components-payin) 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](/guides/pay-in-developer-tap-to-pay#integration-steps).
* **Apple's Tap to Pay entitlement.** `com.apple.developer.proximity-reader.payment.acceptance`. Request it through the [Apple Developer Portal](https://developer.apple.com/documentation/proximityreader/setting-up-the-entitlement-for-tap-to-pay-on-iphone).
* **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](https://developer.apple.com/documentation/proximityreader/setting-up-the-entitlement-for-tap-to-pay-on-iphone). 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`](https://github.com/payabli/sdk-ios) Swift package:

#### Add the package

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

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

Or add it to your `Package.swift` dependencies:

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

#### Link the product

Link the `PayabliSDKTapToPay` product to your app target. It links `PayabliSDKCore` and the Tap to Pay reader engine automatically, so you don't need to add those separately:

```swift
.product(name: "PayabliSDKTapToPay", package: "sdk-ios")
```

#### Import the package

Import it where you construct or call `PayabliTTP`:

```swift
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.

#### Create the project

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

**Requires:** [Node.js](https://nodejs.org/en/download/) and [npm](https://www.npmjs.com/)

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

```bash
mkdir payabli-token-proxy
cd payabli-token-proxy
npm init -y
npm pkg set type=module
```

#### Install dependencies

Install Express, CORS, and dotenv:

```bash
npm install express cors dotenv
```

#### Write the token endpoint

Create `server.js` with the following:

```js
// server.js
import "dotenv/config";
import express from "express";
import cors from "cors";

const app = express();
app.use(cors(), express.json());

const PAYABLI_URL = process.env.PAYABLI_URL || "https://api-sandbox.payabli.com/api";
const { PAYABLI_CLIENT_ID, PAYABLI_CLIENT_SECRET } = process.env;

app.post("/payabli/token", async (req, res) => {
  const upstream = await fetch(`${PAYABLI_URL}/v2/Token/serverside`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      clientId: PAYABLI_CLIENT_ID,
      clientSecret: PAYABLI_CLIENT_SECRET,
    }),
  });

  res.status(upstream.status).json(await upstream.json());
});

app.listen(process.env.PORT || 3000, () =>
  console.log("Token server ready"));
```

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

#### Start the server

Start the server:

```bash
node server.js
```

#### Call it from your app

The `tokenProvider` closure calls this endpoint and returns the token:

```swift
let tokenProvider: () async throws -> String = {
    try await backend.fetchPayabliAccessToken()
}
```

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:

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

```swift
import SwiftUI
import PayabliSDKTapToPay

func fetchPayabliAccessToken() async throws -> String {
    var request = URLRequest(url: URL(string: "https://your-backend.example.com/payabli/token")!)
    request.httpMethod = "POST"

    struct Response: Decodable { let access_token: String }
    let (data, _) = try await URLSession.shared.data(for: request)
    return try JSONDecoder().decode(Response.self, from: data).access_token
}

@MainActor
final class TapToPayViewModel: ObservableObject {
    let ttp: PayabliTTP

    init(entryPoint: String, appId: String) async throws {
        ttp = PayabliTTP(
            accessToken: try await fetchPayabliAccessToken(),
            tokenProvider: {
                try await fetchPayabliAccessToken()
            },
            entryPoint: entryPoint,
            appId: appId,
            environment: .sandbox
        )
    }

    func start() async {
        do {
            try await ttp.initialize()
        } catch PayabliTTPError.devicePendingActivation {
            let code = await promptForActivationCode()
            do {
                try await ttp.activateDevice(activationCode: code)
                try await ttp.initialize()
            } catch {
                print("Activation failed:", error.localizedDescription)
            }
        } catch {
            print("Tap to Pay initialization failed:", error.localizedDescription)
        }
    }

    func charge(amount: Decimal) async {
        guard ttp.isReady else {
            print("Tap to Pay isn't ready yet.")
            return
        }
        do {
            let result = try await ttp.charge(
                type: .sale,
                paymentDetails: PayabliTTPPaymentDetails(amount: amount)
            )
            print("Transaction captured. ID:", result.paymentTransId)
        } catch {
            print("Charge failed:", error.localizedDescription)
        }
    }
}
```

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](/guides/pay-in-developer-tap-to-pay).

## 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

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

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

The entrypoint slug provisioned by Payabli.

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

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

Must be `.sale`.

#### properties

Must be greater than 0.

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

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

#### properties

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

Invoice metadata, persisted at `/initiate`.

#### properties

Invoice reference forwarded to the backend and the processor.

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:

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

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:

```swift
for await event in ttp.events() {
    switch event {
    case .chargeInitiated(let paymentTransId):
        pendingPaymentTransId = paymentTransId
    case .updateFailed(let paymentTransId, let error):
        print("Update failed for", paymentTransId, "-", error, "- reconcile manually.")
    default:
        break
    }
}
```

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

#### Clone the repository

Clone `sdk-ios` and open the example project:

```bash
git clone https://github.com/payabli/sdk-ios.git
cd sdk-ios/Example/PayabliDemo
```

#### 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:

/// 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:

```swift
enum Secrets {
    static let entryPoint = "your-sandbox-entry-point"
    static let appId = "ABCDE12345.com.yourcompany.app"
    static let placeholderAccessToken = "placeholder-token"
    static let partnerTokenEndpoint = URL(string: "https://your-backend.example.com/payabli/token")!
    static let partnerPaymentMethodAccessTokenEndpoint = URL(string: "http://127.0.0.1:8787/payabli/access-token")!
    static let paymentMethodDiagnosticsEnabled = false
    static let paymentCaptureDiagnosticsEnabled = paymentMethodDiagnosticsEnabled

    static func fetchAccessToken() async throws -> String {
        var request = URLRequest(url: partnerTokenEndpoint)
        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)
        if let token = response.accessToken ?? response.access_token { return token }
        throw URLError(.userAuthenticationRequired)
    }

    static func fetchPaymentMethodAccessToken() async throws -> String {
        var request = URLRequest(url: partnerPaymentMethodAccessTokenEndpoint)
        request.httpMethod = "POST"
        let (data, _) = try await URLSession.shared.data(for: request)
        struct PaymentMethodTokenResponse: Decodable { let accessToken: String?; let access_token: String? }
        let response = try JSONDecoder().decode(PaymentMethodTokenResponse.self, from: data)
        if let token = response.accessToken ?? response.access_token { return token }
        throw URLError(.userAuthenticationRequired)
    }

    static func fetchPaymentCaptureAccessToken() async throws -> String {
        try await fetchPaymentMethodAccessToken()
    }
}
```

#### Start the local token server

In a separate terminal, start the bundled `LocalTokenServer`:

```bash
cd LocalTokenServer
cp .env.example .env
```

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

```bash
node server.mjs
```

See the [`LocalTokenServer` README](https://github.com/payabli/sdk-ios/blob/main/Example/PayabliDemo/LocalTokenServer/README.md) for the credential-exchange mode and physical-device networking notes.

#### 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](/guides/pay-in-tap-to-pay-overview#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.

## Related resources

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

#### Prerequisites

* **[Mobile components overview](/guides/mobile-components-overview)** - Learn how Payabli's native mobile SDK components let you accept payments in your iOS app

#### Related topics

* **[Pay In Payment Flow](/guides/mobile-components-payin)** - Add native card and ACH payments to your mobile app with Pay In Payment Flow
* **[Tap to Pay overview](/guides/pay-in-tap-to-pay-overview)** - Learn how Tap to Pay lets your app accept contactless, card-present payments with no external hardware
* **[Accept Tap to Pay payments](/guides/pay-in-developer-tap-to-pay)** - Integrate Tap to Pay in your native iOS app, from paypoint registration through your first charge