Apple Pay (Native App)
Accept payments from a native iOS app using Apple Pay. The user authorises with Face ID or Touch ID, Apple hands your app an encrypted payment token, your app forwards that token to your own backend, and your backend confirms the PaymentIntent with Bahn.
Your app never sees a PAN. Apple returns a device-tokenized card encrypted to a payment processing certificate that Bahn holds the private key for — Bahn decrypts it server-side during confirm.
WARNING
This page documents the native app surface (PassKit). Apple Pay on the Web (Safari, Apple Pay JS) is a separate integration — see Apple Pay, or the Appendix below for how the two relate.
Preliminary
- Webhook
- API Integration
- One Time Payment — the card-based equivalent of this flow
Before writing any code, complete the Apple-side setup in your Apple Developer account:
| Requirement | Where | Notes |
|---|---|---|
| Merchant ID | Apple Developer → Identifiers → Merchant IDs | e.g. merchant.vn.cake.yourapp. Used as PKPaymentRequest.merchantIdentifier. |
| Payment Processing Certificate | Apple Developer → your Merchant ID | Generated from a CSR that Bahn provides. Bahn holds the matching private key and is the only party that can decrypt the token. |
| Apple Pay entitlement | Xcode → Signing & Capabilities | Add the Apple Pay capability and select your Merchant ID. |
INFO
Send Bahn your Merchant ID during onboarding. Bahn returns the CSR you upload to Apple. Do not generate the CSR yourself — if Bahn does not hold the private key, it cannot decrypt the payment token and every confirm will fail.
High-Level Flow
sequenceDiagram
autonumber
participant User as User
participant PassKit as PassKit<br/>(iOS / Secure Element)
participant App as Merchant App<br/>(iOS)
participant MerchantAPI as Merchant API<br/>(Backend)
participant API as Bahn API
Note over User, API: Phase 1: Create PaymentIntent
User ->> App: Tap "Buy with Apple Pay"
App ->> MerchantAPI: Request checkout
MerchantAPI ->> API: CreatePaymentIntent(amount, currency=704)
API -->> MerchantAPI: {payment_intent, client_secret}
MerchantAPI -->> App: {order_id}
Note over User, API: Phase 2: Authorise in PassKit
App ->> PassKit: Present PKPaymentAuthorizationController<br/>(PKPaymentRequest)
PassKit -->> User: Show sheet (card, amount, merchant)
User ->> PassKit: Approve with Face ID / Touch ID
PassKit -->> App: PKPayment.token.paymentData<br/>(encrypted JSON)
Note over User, API: Phase 3: Confirm
App ->> MerchantAPI: POST /checkout/apple-pay {order_id, token}
MerchantAPI ->> API: ConfirmPaymentIntent(id,<br/>payment_method_data.apple_pay.token)
API ->> API: Decrypt token, submit to network
API -->> MerchantAPI: payment_intent.status = "succeeded" | "processing"
MerchantAPI -->> App: {status}
App ->> PassKit: completion(.success)
PassKit -->> User: Dismiss sheet with checkmark
Note over MerchantAPI, API: Phase 4: Payment Result
par Notify
API ->> MerchantAPI: Webhook: payment_intent.status.succeeded
MerchantAPI ->> MerchantAPI: Consume webhook event
end
Note over User, API: Phase 5: Complete
App ->> MerchantAPI: GET Order status
MerchantAPI -->> App: Order
App -->> User: Show order status
How It Works
Phase 1 — Create PaymentIntent
- User taps the Apple Pay button — Your app receives the tap and requests a checkout from your backend.
- App requests a checkout — The app sends the cart or order reference to your Merchant API.
- Backend calls
CreatePaymentIntent— Your Merchant API callsCreatePaymentIntentwithamountandcurrency(numeric704for VND). Bahn returns thepayment_intentand itsclient_secret. - Backend returns only an order reference — Unlike the web SDK flow, the app does not need the
client_secret: it never talks to Bahn directly. Return your ownorder_idand keep bothpayment_intent.idand your API key on the backend.
INFO
In the native flow the confirm call is made server-to-server, so no Bahn credential of any kind ships in the app binary.
Phase 2 — Authorise in PassKit
App presents the payment sheet — Build a
PKPaymentRequestand present it withPKPaymentAuthorizationController.swiftlet request = PKPaymentRequest() request.merchantIdentifier = "merchant.vn.cake.yourapp" request.countryCode = "VN" request.currencyCode = "VND" request.supportedNetworks = [.visa, .masterCard] request.merchantCapabilities = .capability3DS request.paymentSummaryItems = [ PKPaymentSummaryItem(label: "Order #1234", amount: NSDecimalNumber(value: 8000)) ] let controller = PKPaymentAuthorizationController(paymentRequest: request) controller.delegate = self controller.present()merchantCapabilitiesmust include.capability3DS— it is required for all card networks and is unrelated to a 3DS challenge being shown to the user.PassKit shows the sheet — iOS renders the card picker, amount, and merchant name from the request.
User approves — The user authenticates with Face ID, Touch ID, or passcode.
PassKit returns the token — Your delegate receives a
PKPayment. The field you need ispayment.token.paymentData: aDatablob whose contents are the encrypted payment token as JSON ({"data":…,"signature":…,"header":{…},"version":"EC_v1"}).swiftfunc paymentAuthorizationController( _ controller: PKPaymentAuthorizationController, didAuthorizePayment payment: PKPayment, handler: @escaping (PKPaymentAuthorizationResult) -> Void ) { guard let token = String(data: payment.token.paymentData, encoding: .utf8) else { return } // send `token` to your backend, then call handler(...) }
WARNING
Pass paymentData through as the UTF-8 JSON string PassKit produced. Do not base64-wrap it, re-serialise it, or reorder its keys — Bahn verifies Apple's signature over the exact bytes before decrypting, and any mutation invalidates it.
Phase 3 — Confirm
- App posts the token to your backend — Send
{ order_id, token }to your own endpoint over TLS. The token is single-use and short-lived; forward it immediately. - Backend confirms the PaymentIntent — Your Merchant API calls the Bahn confirm endpoint with the token in
payment_method_data.apple_pay.token. See Merchant API Responsibilities below for the exact payload. - Bahn decrypts and returns the result — Bahn verifies the signature, decrypts the token with the payment processing certificate's private key, submits the network transaction, and returns the updated PaymentIntent. Typical statuses are
succeeded(captured) orprocessing(in flight). - App completes the sheet — Return the outcome to your app and call
handler(PKPaymentAuthorizationResult(status: .success, errors: nil)). PassKit shows the checkmark and dismisses.
WARNING
You have roughly 30 seconds from didAuthorizePayment to call the completion handler before iOS times the sheet out. If your confirm call may take longer, complete the sheet with .success as soon as Bahn returns processing and reconcile the final outcome from the webhook.
Phase 4 — Payment Result
Bahn notifies your backend independently of the app:
- Webhook notification — Bahn delivers
payment_intent.status.succeeded(orpayment_intent.payment_failed) to your registered webhook endpoint. The webhook is the authoritative source of truth for the final payment outcome. The user may kill the app between approving and your response arriving.
Phase 5 — Complete
- App fetches the order — Your app calls your Merchant API for the latest order status.
- Backend returns the order — Updated from the webhook event.
- User sees the outcome — The app renders the success or failure screen.
Merchant API Responsibilities
1. Create a PaymentIntent
Identical to One Time Payment — nothing is Apple-specific at this stage.
POST /api/payment_intentsRequest
{
"amount": 8000,
"currency": "704",
"description": "Order #1234",
"metadata": { "order_id": "1234" }
}Response
{
"payment_intent": {
"id": "pi_01HZ3X9YQ7M8KH8M5JYV9X4F2B",
"client_secret": "pi_secret_ilAA93E16NV3uic90rq--tz-7FOTeupgN5DH6GYwji0",
"amount": 8000,
"currency": "704",
"status": "requires_payment_method"
}
}2. Confirm with the Apple Pay Token
POST /api/payment_intents/{id}/confirmRequest
{
"payment_method_data": {
"apple_pay": {
"token": "{\"data\":\"WTP8BxZNUs…\",\"signature\":\"MIAGCSqGSIb3…\",\"header\":{\"publicKeyHash\":\"7c4ZFn…\",\"ephemeralPublicKey\":\"MFkwEwYH…\",\"transactionId\":\"31323334…\"},\"version\":\"EC_v1\"}"
}
}
}| Field | Required | Description |
|---|---|---|
payment_method_data.apple_pay.token | Yes | The encrypted Apple Pay PKPaymentToken as a JSON string — the UTF-8 contents of PKPaymentToken.paymentData, unmodified. Mutually exclusive with card, napas_qr, card_present, and google_pay. |
payment_method_options | No | Per-confirmation options. Omit unless Bahn has told you otherwise for your account. |
capture_method | No | Set to manual to authorise now and capture later. See Manual Capture. |
Bahn decrypts the token server-side. There is no merchant-decrypted variant — always send the raw encrypted token.
Authenticate this call like any other server-to-server request: Bn-Api-Key plus a JWS-signed body. See Make Your First Request.
curl -X POST 'https://bn.cakedigibank.tech/api/payment_intents/pi_01HZ3X9YQ7M8KH8M5JYV9X4F2B/confirm' \
-H 'Content-Type: application/jose' \
-H 'Bn-Api-Key: api_key-ab318df034' \
-H 'Bn-Jose-Method: jws' \
-H 'Bn-Request-Id: 7f3e9c1a-4b2d-4e8f-a1c0-123456789abc' \
-H 'Bn-Idempotency-Key: order-1234-applepay' \
-d '<compact JWS of the JSON body above>'Response
{
"payment_intent": {
"id": "pi_01HZ3X9YQ7M8KH8M5JYV9X4F2B",
"amount": 8000,
"currency": "704",
"status": "succeeded",
"payment_method_id": "pm_01HZ3XA2K4T7QJ0M9C5N8E1D3F",
"latest_charge_id": "ch_01HZ3XA2K4T7QJ0M9C5N8E1D3G"
}
}The resulting PaymentMethod carries the wallet provenance, which is useful for reconciliation and for reasoning about liability shift:
{
"type": "card",
"card": {
"brand": "visa",
"last4": "1234",
"wallet": {
"type": "apple_pay",
"assurance_details": {
"account_verified": true,
"card_holder_authenticated": true
}
},
"network_token": {
"dynamic_last4": "4242",
"token_requestor_id": "40010030273"
}
}
}Apple Pay always produces a device-tokenized (DPAN) card — there is no path that yields the underlying funding PAN. card.last4 is the funding card's last four digits (what the user sees in the Wallet app, so show that one); network_token.dynamic_last4 belongs to the device token.
INFO
Always set Bn-Idempotency-Key on this call, keyed to your order. If the app retries because a response was lost, idempotency replays the original response — the Apple token is single-use, so a genuine second attempt would otherwise fail confusingly.
3. Handle the Webhook
| Event | Description |
|---|---|
payment_intent.status.succeeded | Payment captured. Mark the order paid. |
payment_intent.status.processing | Payment in flight. Keep the order pending. |
payment_intent.status.requires_capture | Authorised only — you used capture_method: manual. Call capture. |
payment_intent.payment_failed | The attempt failed. Read last_payment_error for the reason. |
INFO
Do not treat the PassKit checkmark as proof of payment. It only means the user authorised and your handler reported success. Fulfil on the webhook.
Error Handling
| Symptom | Likely cause | Fix |
|---|---|---|
| Apple Pay button never appears | PKPaymentAuthorizationController.canMakePayments(usingNetworks:) returns false | Device has no eligible card, or supportedNetworks excludes what the user holds. Fall back to the card form. |
| Confirm fails with a decryption error | The payment processing certificate was generated from a CSR Bahn did not issue | Re-run the certificate step with Bahn's CSR. Bahn must hold the private key. |
| Confirm fails with a signature or invalid-token error | paymentData was base64-wrapped, re-serialised, or truncated in transit | Send the raw UTF-8 JSON string exactly as PassKit produced it. |
| Confirm fails on a retry | Apple Pay tokens are single-use | Send the same Bn-Idempotency-Key so the original response replays instead of a fresh authorisation being attempted. |
| Sheet times out | Confirm took longer than ~30s | Complete on processing and reconcile from the webhook. |
Testing
Apple Pay cannot be exercised in the iOS Simulator against real cards. Use a physical device signed into a sandbox Apple ID with an Apple Pay Sandbox test card added to Wallet, pointed at the Bahn sandbox host https://bn.cakedigibank.tech.
Appendix — Apple Pay on the Web
If you also accept Apple Pay in Safari (rather than, or alongside, a native app), that's a separate integration from the one on this page — see Apple Pay for the full web flow.
bahn.mountApplePay() renders the button and handles merchant validation for you — there's no separate merchant-session endpoint to call from your own page. The domain-registration requirement still applies exactly as it does for native (see Apple Pay → Requirements): your production domain must be registered with Apple and serve /.well-known/apple-developer-merchantid-domain-association, or the sheet closes with no usable error in the browser.