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>