Integration Guide ← Back to Sandbox

Native Apple Pay — iOS sample app

A ready-to-run SwiftUI + PassKit app that takes native in-app Apple Pay and sends the encrypted token to POST /api/v1/transactions/wallet-pay. TruePay decrypts it with your uploaded Payment Processing certificate and charges the card.

Read the Apple Pay integration guide first. Create the Xcode project, add these files, set your team + Apple Pay capability, and fill in Config.swift.

⬇ Download the whole sample (.zip)

README.md  Download

# TruePay — Native Apple Pay iOS demo

A minimal SwiftUI + PassKit app that takes a **native in-app Apple Pay** payment and
sends the **encrypted** payment token to TruePay's `POST /api/v1/transactions/wallet-pay`.
TruePay decrypts the token server-side with the merchant's **Payment Processing
certificate** (uploaded in the Merchant Portal → Apple Pay) and charges the card.

The app never sees the card number and never holds any certificate — it only produces
the encrypted Apple Pay token and forwards it to TruePay.

## How it maps to TruePay

1. Merchant creates an **Apple Merchant ID** + **Apple Pay Payment Processing certificate**
   in their Apple Developer account.
2. Merchant uploads that `.p12` (+ Apple merchant identifier) in **Merchant Portal → Apple Pay**.
3. This app runs Apple Pay with the **same Apple merchant identifier** and POSTs the token to
   `/api/v1/transactions/wallet-pay`:
   ```json
   {
     "amount": 7.250,
     "currency": "OMR",
     "reference_number": "demo-123",
     "wallet": {
       "type": "APPLE_PAY",
       "payment_token": { "paymentData": { … }, "paymentMethod": { … }, "transactionIdentifier": "…" }
     }
   }
   ```
   with headers `OMPAY-API-Key` / `OMPAY-API-Secret`.
4. TruePay decrypts and returns `{ "success": true, "data": { "status": "SUCCESSFUL", … } }`.

## Prerequisites

- **Xcode 15+**, a real **iPhone** (Apple Pay does not work in the Simulator for real payments),
  signed into an Apple ID with a card in Wallet.
- An **Apple Merchant ID** with an **Apple Pay Payment Processing certificate**, and the app's
  bundle signed by a team that owns it.
- For OMPAY testing you can use the identifier **`merchant.com.ompay.tp`** (team `9C48KB4AR3`) —
  its PSP certificate is already uploadable to a test merchant in TruePay.
- A TruePay **API key/secret** for a merchant that has `wallet_apple_pay` enabled and has uploaded
  the matching PSP certificate.

## Create the Xcode project

**Fastest (with [XcodeGen](https://github.com/yonaskolb/XcodeGen)):** a `project.yml` is
included, so `brew install xcodegen && xcodegen generate` produces
`TruePayApplePayDemo.xcodeproj` ready to open/build. Then edit `Config.swift` and run.

**Or manually (2 minutes):**

1. **File → New → Project → iOS → App.** Product name `TruePayApplePayDemo`, Interface **SwiftUI**,
   Language **Swift**. Set the **Team** to the one that owns the Apple Merchant ID.
2. Delete the generated `ContentView.swift` / `…App.swift` and **drag in** the four Swift files
   from `Sources/` here, plus add `TruePayApplePayDemo.entitlements`.
3. **Signing & Capabilities → + Capability → Apple Pay**, then add the merchant identifier
   `merchant.com.ompay.tp` (this writes `com.apple.developer.in-app-payments` — the same as the
   provided `.entitlements`).
4. Edit **`Config.swift`**: set `baseURL` to your TruePay Core URL
   (`https://api.uat.truepay.ompay.om`), `apiKey`/`apiSecret` to your merchant credentials, and
   `merchantIdentifier` to your Apple merchant id.
5. Run on a **real device** with a card already in Wallet, and tap **Pay with Apple Pay**.

> **"Could not present Apple Pay"** means Apple Pay refused the request on the device — nothing
> was sent to TruePay, so no certificate, merchant record or setting there can cause it or fix it.
> The app now names the reason. Three things must carry the SAME identifier, and the entitlement
> is the one people miss:
> 1. `Config.merchantIdentifier`,
> 2. `com.apple.developer.in-app-payments` in `TruePayApplePayDemo.entitlements`,
> 3. the App ID's **Apple Pay** capability — and the provisioning profile must be re-downloaded
> after the merchant id is added there, or the build is still signed without it.
>
> After that: the device must have a card in Wallet for one of `supportedNetworks` (the Simulator
> usually has none), and the amount must be greater than zero.

> Apple Pay in-app does **not** require a verified web domain (that's only for Apple Pay on the
> web). The app's Apple Merchant ID + entitlement is what authorizes it.

## Files

- `Sources/Config.swift` — TruePay base URL, API key/secret, Apple merchant id, amount.
- `Sources/TruePayApplePayDemoApp.swift` — SwiftUI `@main` entry point.
- `Sources/ContentView.swift` — amount + Apple Pay button + result.
- `Sources/PaymentHandler.swift` — builds the `PKPaymentRequest`, presents Apple Pay, and POSTs
  the token to TruePay `/wallet-pay`.
- `TruePayApplePayDemo.entitlements` — the Apple Pay in-app-payments entitlement.

project.yml  Download

name: TruePayApplePayDemo
options:
  bundleIdPrefix: com.ompay.truepay
  deploymentTarget:
    iOS: "16.0"
targets:
  TruePayApplePayDemo:
    type: application
    platform: iOS
    sources:
      - path: Sources
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.ompay.truepay.applepaydemo
        GENERATE_INFOPLIST_FILE: YES
        MARKETING_VERSION: "1.0"
        CURRENT_PROJECT_VERSION: "1"
        TARGETED_DEVICE_FAMILY: "1"
        SWIFT_VERSION: "5.0"
        CODE_SIGN_ENTITLEMENTS: TruePayApplePayDemo.entitlements
        INFOPLIST_KEY_UILaunchScreen_Generation: YES
        DEVELOPMENT_TEAM: "9C48KB4AR3"
        CODE_SIGN_STYLE: Automatic
        CODE_SIGN_IDENTITY: "Apple Development"

Sources/TruePayApplePayDemoApp.swift  Download

import SwiftUI

@main
struct TruePayApplePayDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Sources/ContentView.swift  Download

import SwiftUI
import PassKit

struct ContentView: View {
    @StateObject private var handler = PaymentHandler()
    @State private var amount: String = "7.250"

    private var canPay: Bool {
        PKPaymentAuthorizationController.canMakePayments() && (Double(amount) ?? 0) > 0
    }

    var body: some View {
        VStack(spacing: 24) {
            VStack(spacing: 4) {
                Text("TruePay").font(.largeTitle).bold()
                Text("Apple Pay demo").foregroundColor(.secondary)
            }

            HStack {
                Text("Amount")
                Spacer()
                TextField("0.000", text: $amount)
                    .keyboardType(.decimalPad)
                    .multilineTextAlignment(.trailing)
                    .frame(width: 120)
                Text(Config.currencyCode).foregroundColor(.secondary)
            }
            .padding()
            .background(Color(.secondarySystemBackground))
            .cornerRadius(12)

            if canPay {
                ApplePayButton {
                    handler.startPayment(amount: Double(amount) ?? 0)
                }
                .frame(height: 48)
                .cornerRadius(10)
            } else {
                Text("Apple Pay is not available on this device.")
                    .font(.footnote).foregroundColor(.secondary)
            }

            if let status = handler.status {
                Text(status)
                    .font(.callout)
                    .foregroundColor(handler.succeeded ? .green : .red)
                    .multilineTextAlignment(.center)
            }

            Spacer()
        }
        .padding()
    }
}

/// Native PKPaymentButton wrapped for SwiftUI.
struct ApplePayButton: UIViewRepresentable {
    let action: () -> Void

    func makeUIView(context: Context) -> PKPaymentButton {
        let button = PKPaymentButton(paymentButtonType: .buy, paymentButtonStyle: .black)
        button.addTarget(context.coordinator, action: #selector(Coordinator.tapped), for: .touchUpInside)
        return button
    }

    func updateUIView(_ uiView: PKPaymentButton, context: Context) {}

    func makeCoordinator() -> Coordinator { Coordinator(action: action) }

    final class Coordinator: NSObject {
        let action: () -> Void
        init(action: @escaping () -> Void) { self.action = action }
        @objc func tapped() { action() }
    }
}

Sources/PaymentHandler.swift  Download

import Foundation
import PassKit

/// Presents Apple Pay and sends the resulting (encrypted) token to TruePay's
/// /api/v1/transactions/wallet-pay. TruePay decrypts it server-side with the
/// merchant's Payment Processing certificate.
final class PaymentHandler: NSObject, ObservableObject, PKPaymentAuthorizationControllerDelegate {

    @Published var status: String?
    @Published var succeeded = false

    private var controller: PKPaymentAuthorizationController?
    private var amount: Double = 0
    private var reference = ""

    func startPayment(amount: Double) {
        self.amount = amount
        self.reference = "ios-demo-\(Int(Date().timeIntervalSince1970))"
        self.status = nil
        self.succeeded = false

        let request = PKPaymentRequest()
        request.merchantIdentifier = Config.merchantIdentifier
        request.merchantCapabilities = .threeDSecure
        request.supportedNetworks = Self.networks(Config.supportedNetworks)
        request.countryCode = Config.countryCode
        request.currencyCode = Config.currencyCode
        request.paymentSummaryItems = [
            PKPaymentSummaryItem(label: Config.merchantDisplayName,
                                 amount: NSDecimalNumber(value: amount),
                                 type: .final)
        ]

        let controller = PKPaymentAuthorizationController(paymentRequest: request)
        controller.delegate = self
        self.controller = controller
        controller.present { presented in
            if !presented {
                // `presented: false` is Apple Pay refusing the REQUEST — it never reaches the
                // network, so nothing configured in TruePay can cause or fix it. Say which of the
                // few possible reasons it is, because the bare message sends people to the server.
                let reason = Self.cannotPresentReason(request)
                DispatchQueue.main.async { self.status = "Could not present Apple Pay — \(reason)" }
            }
        }
    }

    /// Why Apple Pay declined to open.
    ///
    /// `presented: false` is Apple Pay refusing the REQUEST. Nothing has gone to the network, so
    /// no certificate, merchant record or setting in TruePay can cause it or fix it — which is
    /// worth saying, because the bare message sends people to the server every time.
    ///
    /// iOS gives no API to read the app's own entitlements (SecTask… is macOS only), so the two
    /// device-side causes are tested directly and the entitlement is named as what is left.
    private static func cannotPresentReason(_ request: PKPaymentRequest) -> String {
        if !PKPaymentAuthorizationController.canMakePayments() {
            return "this device cannot make Apple Pay payments at all."
        }
        if !PKPaymentAuthorizationController.canMakePayments(usingNetworks: request.supportedNetworks) {
            let nets = request.supportedNetworks.map { $0.rawValue }.joined(separator: ", ")
            return "there is no card in Wallet for \(nets) on this device. Add one and try again."
        }
        if request.paymentSummaryItems.isEmpty
            || request.paymentSummaryItems[request.paymentSummaryItems.count - 1].amount.doubleValue <= 0 {
            return "the total must be greater than zero."
        }
        // The device is capable and has a usable card, so the request itself was rejected. In
        // practice that is the merchant identifier not being entitled to this build.
        return "the device is ready, so the request was rejected. Almost always this means "
            + "\(request.merchantIdentifier) is missing from this build's Apple Pay entitlement or "
            + "from its provisioning profile — add it under Signing & Capabilities -> Apple Pay, then "
            + "re-download the profile. Otherwise check countryCode (\(request.countryCode)) and "
            + "currencyCode (\(request.currencyCode)). Nothing in TruePay affects this."
    }

    // MARK: PKPaymentAuthorizationControllerDelegate

    func paymentAuthorizationController(
        _ controller: PKPaymentAuthorizationController,
        didAuthorizePayment payment: PKPayment,
        handler completion: @escaping (PKPaymentAuthorizationResult) -> Void
    ) {
        chargeWithTruePay(payment) { ok, message in
            DispatchQueue.main.async {
                self.succeeded = ok
                self.status = ok ? "Payment successful" : "Payment failed: \(message ?? "declined")"
            }
            completion(PKPaymentAuthorizationResult(status: ok ? .success : .failure, errors: nil))
        }
    }

    func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) {
        controller.dismiss()
    }

    // MARK: TruePay call

    /// POST the encrypted token to /api/v1/transactions/wallet-pay.
    private func chargeWithTruePay(_ payment: PKPayment, completion: @escaping (Bool, String?) -> Void) {
        guard let paymentDataObject = try? JSONSerialization.jsonObject(with: payment.token.paymentData) else {
            completion(false, "Malformed Apple Pay token"); return
        }

        // The PKPaymentToken as TruePay expects it under wallet.payment_token.
        let token: [String: Any] = [
            "paymentData": paymentDataObject,
            "paymentMethod": [
                "displayName": payment.token.paymentMethod.displayName ?? "",
                "network": payment.token.paymentMethod.network?.rawValue ?? "",
                "type": "\(payment.token.paymentMethod.type.rawValue)"
            ],
            "transactionIdentifier": payment.token.transactionIdentifier
        ]

        let body: [String: Any] = [
            "amount": amount,
            "currency": Config.currencyCode,
            "reference_number": reference,
            "wallet": ["type": "APPLE_PAY", "payment_token": token]
        ]

        guard let url = URL(string: "\(Config.baseURL)/api/v1/transactions/wallet-pay"),
              let payload = try? JSONSerialization.data(withJSONObject: body) else {
            completion(false, "Bad request"); return
        }

        var req = URLRequest(url: url)
        req.httpMethod = "POST"
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.setValue(Config.apiKey, forHTTPHeaderField: "OMPAY-API-Key")
        req.setValue(Config.apiSecret, forHTTPHeaderField: "OMPAY-API-Secret")
        req.httpBody = payload

        URLSession.shared.dataTask(with: req) { data, _, error in
            if let error = error { completion(false, error.localizedDescription); return }
            guard let data = data,
                  let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
                completion(false, "No response from TruePay"); return
            }
            let ok = (json["success"] as? Bool) == true
            let dataObj = json["data"] as? [String: Any]
            let message = (dataObj?["error_message"] as? String) ?? (json["message"] as? String)
            completion(ok, message)
        }.resume()
    }

    private static func networks(_ names: [String]) -> [PKPaymentNetwork] {
        names.compactMap { name in
            switch name.lowercased() {
            case "visa": return .visa
            case "mastercard": return .masterCard
            case "amex": return .amex
            case "discover": return .discover
            case "mada": if #available(iOS 12.1.1, *) { return .mada } else { return nil }
            default: return nil
            }
        }
    }
}

Sources/Config.swift  Download

import Foundation

/// Demo configuration.
///
/// Do NOT hardcode live credentials here — this file is tracked in git. Put your
/// real merchant API key/secret in an untracked `Config.local.swift` (git-ignored)
/// that defines `ConfigSecrets`, e.g.:
///
///     enum ConfigSecrets {
///         static let apiKey = "…"
///         static let apiSecret = "…"
///     }
///
/// If `Config.local.swift` is absent the placeholders below are used and calls
/// will fail auth — that's intentional, so a build never ships real secrets.
enum Config {
    /// TruePay Core base URL (no trailing slash). UAT: https://api.sandbox.truepay.ompay.om
    static let baseURL = "https://api.truepay.ompay.om"

    /// Merchant API credentials (the same headers the sandbox uses).
    /// Sourced from the untracked Config.local.swift; placeholders otherwise.
    static let apiKey = ConfigSecrets.apiKey
    static let apiSecret = ConfigSecrets.apiSecret

    /// Your Apple Merchant ID. Three places must agree or Apple Pay will not even open:
    ///   1. this value,
    ///   2. `com.apple.developer.in-app-payments` in TruePayApplePayDemo.entitlements,
    ///   3. the App ID's Apple Pay capability (so the provisioning profile carries it).
    /// It must ALSO match the certificate uploaded to TruePay and the merchant's
    /// appleMerchantIdentifier there — that one is the KDF input for decrypting the token,
    /// and it only matters AFTER Apple Pay hands back a token.
    /// For OMPAY testing: merchant.com.ompay.tp (team 9C48KB4AR3)
    static let merchantIdentifier = "merchant.com.ompay.tp"

    /// Storefront / payment display.
    static let merchantDisplayName = "OMPAY TruePay Demo"
    static let countryCode = "OM"
    static let currencyCode = "OMR"

    /// Networks the merchant accepts.
    static let supportedNetworks: [String] = ["visa", "masterCard"]
}

TruePayApplePayDemo.entitlements  Download

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<!-- MUST list the exact identifier Config.merchantIdentifier asks for, and that identifier
	     MUST also be on the App ID's Apple Pay capability so the provisioning profile carries
	     it. PKPaymentAuthorizationController.present() answers `presented: false` when the
	     requested merchant id is not entitled — which surfaces as "Could not present Apple Pay"
	     and never reaches TruePay, so no server-side certificate can affect it. -->
	<key>com.apple.developer.in-app-payments</key>
	<array>
		<string>merchant.com.ompay.tp</string>
	</array>
</dict>
</plist>