Skip to content

Apple Pay (Merchant Decrypt) ​

A variant of the Apple Pay flow for merchants who hold their own Apple Payment Processing private key. Instead of handing Bahn the encrypted PKPaymentToken, you decrypt it yourself and confirm the PaymentIntent with the plaintext device-token fields.

Everything up to the point where PassKit returns the token is identical to the base flow — the same Merchant ID, the same PKPaymentRequest, the same payment sheet. Only what your backend does with the token changes.

Which variant am I on?

Apple PayThis page
Who holds the Payment Processing private keyBahnYou
Who generates the CSRBahnYou
What your backend sendsThe encrypted tokenThe decrypted fields
Field on the requestapple_pay.tokenapple_pay.decrypted_token
PCI / key-management burdenBahn'sYours

Pick this variant only if you already run an HSM or KMS and want the token plaintext to stay inside your own perimeter. It is more work and more risk; the base flow exists because most integrations do not need it.

Preliminary ​

The Apple-side setup differs in exactly one row from the base flow:

RequirementWhereNotes
Merchant IDApple Developer → Identifiers → Merchant IDsUnchanged. Used as PKPaymentRequest.merchantIdentifier.
Payment Processing CertificateApple Developer → your Merchant IDGenerated from a CSR you create. You keep the matching private key, and you are the only party that can decrypt the token. Do not ask Bahn for a CSR on this variant.
Apple Pay entitlementXcode → Signing & CapabilitiesUnchanged.

WARNING

The Payment Processing private key is the only thing standing between an intercepted token and a usable device PAN plus cryptogram. Generate it inside an HSM or KMS (P-256 for EC_v1 tokens), never export it, and give only your confirm service permission to use it. Losing it means re-issuing the certificate; leaking it means an attacker can read every token Apple ever encrypted to it.

INFO

Tell Bahn your Merchant ID during onboarding so wallet transactions reconcile against it. Bahn does not need your Payment Processing certificate on this variant — it never sees the encrypted token.

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)

    rect rgb(255, 244, 250)
    Note over App, MerchantAPI: Phase 3: Decrypt — the delta
    App ->> MerchantAPI: POST /checkout/apple-pay {order_id, token}
    MerchantAPI ->> MerchantAPI: Verify Apple's signature
    MerchantAPI ->> MerchantAPI: Decrypt with Payment<br/>Processing private key (HSM)
    end

    Note over User, API: Phase 4: Confirm
    MerchantAPI ->> API: ConfirmPaymentIntent(id,<br/>payment_method_data.apple_pay.decrypted_token)
    API ->> API: Tokenize DPAN, 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 5: Payment Result
    par Notify
        API ->> MerchantAPI: Webhook: payment_intent.status.succeeded
        MerchantAPI ->> MerchantAPI: Consume webhook event
    end

Phases 1, 2 and 5 are unchanged from the base flow — see Apple Pay for the PKPaymentRequest setup, the delegate callback, and the webhook contract. The rest of this page covers phases 3 and 4.

WARNING

You still have roughly 30 seconds from didAuthorizePayment to call the completion handler, and decryption now sits inside that budget. Keep the HSM connection warm. If confirm may still take longer, complete the sheet with .success as soon as Bahn returns processing and reconcile from the webhook.


Decrypting the Token ​

Apple's canonical description is the Payment Token Format Reference; this section covers what you need to reach a Bahn decrypted_token.

1. What PassKit gave you ​

PKPaymentToken.paymentData is a JSON envelope:

json
{
  "data": "WTP8BxZNUs…",
  "signature": "MIAGCSqGSIb3…",
  "header": {
    "publicKeyHash": "7c4ZFn…",
    "ephemeralPublicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE…",
    "transactionId": "31323334353637383930"
  },
  "version": "EC_v1"
}
  • version is EC_v1 for an ECC certificate (the normal case) or RSA_v1 for an RSA one. Which you get is determined by the certificate you uploaded to Apple.
  • header.publicKeyHash is the SHA-256 of your Payment Processing public key, base64-encoded. Use it to select the right key when you rotate certificates or run more than one.
  • header.transactionId is the device-generated transaction identifier. Keep it — Bahn requires it and it does not appear in the decrypted payload.

WARNING

Forward paymentData from the app as the UTF-8 JSON string PassKit produced. Do not base64-wrap it, re-serialise it, or reorder its keys — Apple's signature covers the exact bytes, and any mutation makes verification fail.

2. Verify the signature ​

Before decrypting, verify the detached PKCS#7 signature in signature:

  • The chain terminates at Apple Root CA – G3.
  • The leaf carries OID 1.2.840.113635.100.6.29; the intermediate carries 1.2.840.113635.100.6.2.14.
  • The signature covers the concatenation of ephemeralPublicKey (or wrappedKey for RSA_v1), data, transactionId, and applicationData when present — all as raw bytes.
  • Reject a token whose signing time is unreasonably far from now.

3. Decrypt data ​

For EC_v1:

  1. ECDH between your Payment Processing private key and header.ephemeralPublicKey to derive a shared secret.
  2. Run the NIST SP 800-56A concatenation KDF with SHA-256 over that secret: counter 0x00000001, AlgorithmID = 0x0D id-aes256-GCM, PartyUInfo = Apple, PartyVInfo = the SHA-256 of your merchant identifier (read it from the certificate's 1.2.840.113635.100.6.32 extension).
  3. AES-256-GCM decrypt data with a 16-byte all-zero IV; the last 16 bytes of the ciphertext are the auth tag.

For RSA_v1: RSA-OAEP unwrap header.wrappedKey with your private key to get the AES key, then decrypt data with AES-256-GCM as above.

DANGER

The plaintext is a live device PAN and a chargeable cryptogram. Never log it, never persist it, and never return it to the app. Decrypt it, send it to Bahn, and drop it.

4. Map the plaintext onto decrypted_token ​

The result is Apple's payment data JSON:

json
{
  "applicationPrimaryAccountNumber": "4111111111111111",
  "applicationExpirationDate": "281231",
  "currencyCode": "704",
  "transactionAmount": 8000,
  "cardholderName": "Nguyen Van A",
  "deviceManufacturerIdentifier": "040010030273",
  "paymentDataType": "3DSecure",
  "paymentData": {
    "onlinePaymentCryptogram": "AoWXNg6NkAAAAAAAAAAAAAA=",
    "eciIndicator": "05"
  }
}

Bahn's field names mirror Apple's 1:1 in snake_case, so there is no translation table to learn:

Apple keyBahn fieldRequiredNotes
applicationPrimaryAccountNumberapplication_primary_account_numberYesThe device PAN (DPAN).
applicationExpirationDateapplication_expiration_dateYesYYMMDD, exactly as Apple emits it. Do not reformat to MM/YY.
currencyCodecurrency_codeYesISO 4217 numeric, as a string so leading zeros survive.
transactionAmounttransaction_amountYesApple emits a JSON number; Bahn takes a decimal string. Stringify it, do not rescale it.
cardholderNamecardholder_nameNoAccepted for parity with Apple's token; not persisted.
deviceManufacturerIdentifierdevice_manufacturer_identifierYesHex string.
paymentData.onlinePaymentCryptogrampayment_data.online_payment_cryptogramYesBase64 TAVV. This is what makes the DPAN chargeable.
paymentData.eciIndicatorpayment_data.eci_indicatorNoSome tokens omit it. When present, pass it through unchanged.
header.transactionId (from the envelope, not the plaintext)transaction_idYesBahn's replay guard — see below.

WARNING

Only paymentDataType: "3DSecure" is accepted. An EMV token has a different paymentData shape, cannot be expressed in decrypted_token, and is rejected. If you see EMV, your PKPaymentRequest.merchantCapabilities is missing .capability3DS.

INFO

transaction_id is scoped per merchant and consumed once: a given Apple Pay transaction may create a card exactly once. A retried confirm with the same transaction_id is rejected, so pair every confirm with Bn-Idempotency-Key rather than relying on a second attempt succeeding.


Confirming with the Decrypted Token ​

Create the PaymentIntent exactly as in the base flow — nothing is Apple-specific at that stage. The confirm call is where the two variants diverge.

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

Request

json
{
  "payment_method_data": {
    "apple_pay": {
      "decrypted_token": {
        "application_primary_account_number": "4111111111111111",
        "application_expiration_date": "281231",
        "currency_code": "704",
        "transaction_amount": "8000",
        "cardholder_name": "Nguyen Van A",
        "device_manufacturer_identifier": "040010030273",
        "payment_data": {
          "online_payment_cryptogram": "AoWXNg6NkAAAAAAAAAAAAAA=",
          "eci_indicator": "05"
        },
        "transaction_id": "31323334353637383930"
      }
    }
  }
}
FieldRequiredDescription
payment_method_data.apple_pay.decrypted_tokenYesThe decrypted Apple Pay token, mapped as in Map the plaintext. 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.

The same shape works on POST /api/payment_intents with confirm: true, if you prefer to create and confirm in one call.

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 is a device-tokenized card tagged with the wallet, identical to what the base flow produces:

json
{
  "type": "card",
  "card": {
    "brand": "visa",
    "last4": "1234",
    "wallet": { "type": "apple_pay" },
    "network_token": {
      "dynamic_last4": "4242",
      "token_requestor_id": "40010030273"
    }
  }
}

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 — transaction_id is single-use, so a genuine second attempt would otherwise fail confusingly.

Handle the webhook exactly as in the base flow: payment_intent.status.succeeded is the source of truth, not the PassKit checkmark. See Apple Pay → Handle the Webhook.

Error Handling ​

These are the failures specific to decrypting yourself. For the shared ones (button never appears, sheet times out) see the base page.

SymptomLikely causeFix
Your decryption fails or yields garbageThe token was encrypted to a different Payment Processing keySelect the key by header.publicKeyHash, not by configuration order. During a certificate rotation both keys must stay loadable.
Signature verification failspaymentData was base64-wrapped, re-serialised, or truncated between the app and your backendForward the raw UTF-8 JSON string exactly as PassKit produced it.
Confirm rejected, plaintext looks rightapplication_expiration_date reformatted to MM/YY, or transaction_amount sent as a JSON numberKeep Apple's YYMMDD, and stringify the amount without rescaling it.
Confirm rejected mentioning the payment data typeThe token is EMV, not 3DSecureAdd .capability3DS to PKPaymentRequest.merchantCapabilities.
Confirm rejected with a missing-field errortransaction_id was read from the decrypted payload instead of the envelope headerIt lives on paymentData.header.transactionId, outside the ciphertext.
Confirm fails on a retrytransaction_id is single-use per merchantSend the same Bn-Idempotency-Key so the original response replays instead of a fresh authorisation being attempted.

Testing ​

Use a physical device signed into a sandbox Apple ID with an Apple Pay Sandbox test card in Wallet, pointed at the Bahn sandbox host https://bn.cakedigibank.tech. Apple Pay cannot be exercised in the iOS Simulator against real cards.

Sandbox tokens are encrypted to the same certificate as production ones, so your decrypt path is exercised end to end. A useful first checkpoint is to assert that the decrypted paymentDataType is 3DSecure before you ever call Bahn — it separates "my crypto is wrong" from "my mapping is wrong".

Developers