Skip to content

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 ​

Before writing any code, complete the Apple-side setup in your Apple Developer account:

RequirementWhereNotes
Merchant IDApple Developer → Identifiers → Merchant IDse.g. merchant.vn.cake.yourapp. Used as PKPaymentRequest.merchantIdentifier.
Payment Processing CertificateApple Developer → your Merchant IDGenerated from a CSR that Bahn provides. Bahn holds the matching private key and is the only party that can decrypt the token.
Apple Pay entitlementXcode → Signing & CapabilitiesAdd 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 ​

  1. User taps the Apple Pay button — Your app receives the tap and requests a checkout from your backend.
  2. App requests a checkout — The app sends the cart or order reference to your Merchant API.
  3. Backend calls CreatePaymentIntent — Your Merchant API calls CreatePaymentIntent with amount and currency (numeric 704 for VND). Bahn returns the payment_intent and its client_secret.
  4. 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 own order_id and keep both payment_intent.id and 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 ​

  1. App presents the payment sheet — Build a PKPaymentRequest and present it with PKPaymentAuthorizationController.

    swift
    let 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()

    merchantCapabilities must include .capability3DS — it is required for all card networks and is unrelated to a 3DS challenge being shown to the user.

  2. PassKit shows the sheet — iOS renders the card picker, amount, and merchant name from the request.

  3. User approves — The user authenticates with Face ID, Touch ID, or passcode.

  4. PassKit returns the token — Your delegate receives a PKPayment. The field you need is payment.token.paymentData: a Data blob whose contents are the encrypted payment token as JSON ({"data":…,"signature":…,"header":{…},"version":"EC_v1"}).

    swift
    func 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 ​

  1. 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.
  2. 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.
  3. 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) or processing (in flight).
  4. 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 (or payment_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 ​

  1. App fetches the order — Your app calls your Merchant API for the latest order status.
  2. Backend returns the order — Updated from the webhook event.
  3. 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.

http
POST /api/payment_intents

Request

json
{
  "amount": 8000,
  "currency": "704",
  "description": "Order #1234",
  "metadata": { "order_id": "1234" }
}

Response

json
{
  "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 ​

http
POST /api/payment_intents/{id}/confirm

Request

json
{
  "payment_method_data": {
    "apple_pay": {
      "token": "{\"data\":\"WTP8BxZNUs…\",\"signature\":\"MIAGCSqGSIb3…\",\"header\":{\"publicKeyHash\":\"7c4ZFn…\",\"ephemeralPublicKey\":\"MFkwEwYH…\",\"transactionId\":\"31323334…\"},\"version\":\"EC_v1\"}"
    }
  }
}
FieldRequiredDescription
payment_method_data.apple_pay.tokenYesThe 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_optionsNoPer-confirmation options. Omit unless Bahn has told you otherwise for your account.
capture_methodNoSet 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.

bash
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

json
{
  "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:

json
{
  "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 ​

EventDescription
payment_intent.status.succeededPayment captured. Mark the order paid.
payment_intent.status.processingPayment in flight. Keep the order pending.
payment_intent.status.requires_captureAuthorised only — you used capture_method: manual. Call capture.
payment_intent.payment_failedThe 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 ​

SymptomLikely causeFix
Apple Pay button never appearsPKPaymentAuthorizationController.canMakePayments(usingNetworks:) returns falseDevice has no eligible card, or supportedNetworks excludes what the user holds. Fall back to the card form.
Confirm fails with a decryption errorThe payment processing certificate was generated from a CSR Bahn did not issueRe-run the certificate step with Bahn's CSR. Bahn must hold the private key.
Confirm fails with a signature or invalid-token errorpaymentData was base64-wrapped, re-serialised, or truncated in transitSend the raw UTF-8 JSON string exactly as PassKit produced it.
Confirm fails on a retryApple Pay tokens are single-useSend the same Bn-Idempotency-Key so the original response replays instead of a fresh authorisation being attempted.
Sheet times outConfirm took longer than ~30sComplete 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.

Developers