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 Pay | This page | |
|---|---|---|
| Who holds the Payment Processing private key | Bahn | You |
| Who generates the CSR | Bahn | You |
| What your backend sends | The encrypted token | The decrypted fields |
| Field on the request | apple_pay.token | apple_pay.decrypted_token |
| PCI / key-management burden | Bahn's | Yours |
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
- Apple Pay — read this first; this page only covers the delta
- Webhook
- API Integration
The Apple-side setup differs in exactly one row from the base flow:
| Requirement | Where | Notes |
|---|---|---|
| Merchant ID | Apple Developer → Identifiers → Merchant IDs | Unchanged. Used as PKPaymentRequest.merchantIdentifier. |
| Payment Processing Certificate | Apple Developer → your Merchant ID | Generated 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 entitlement | Xcode → Signing & Capabilities | Unchanged. |
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:
{
"data": "WTP8BxZNUs…",
"signature": "MIAGCSqGSIb3…",
"header": {
"publicKeyHash": "7c4ZFn…",
"ephemeralPublicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE…",
"transactionId": "31323334353637383930"
},
"version": "EC_v1"
}versionisEC_v1for an ECC certificate (the normal case) orRSA_v1for an RSA one. Which you get is determined by the certificate you uploaded to Apple.header.publicKeyHashis 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.transactionIdis 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 carries1.2.840.113635.100.6.2.14. - The signature covers the concatenation of
ephemeralPublicKey(orwrappedKeyforRSA_v1),data,transactionId, andapplicationDatawhen present — all as raw bytes. - Reject a token whose signing time is unreasonably far from now.
3. Decrypt data
For EC_v1:
- ECDH between your Payment Processing private key and
header.ephemeralPublicKeyto derive a shared secret. - Run the NIST SP 800-56A concatenation KDF with SHA-256 over that secret: counter
0x00000001,AlgorithmID=0x0Did-aes256-GCM,PartyUInfo=Apple,PartyVInfo= the SHA-256 of your merchant identifier (read it from the certificate's1.2.840.113635.100.6.32extension). - AES-256-GCM decrypt
datawith 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:
{
"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 key | Bahn field | Required | Notes |
|---|---|---|---|
applicationPrimaryAccountNumber | application_primary_account_number | Yes | The device PAN (DPAN). |
applicationExpirationDate | application_expiration_date | Yes | YYMMDD, exactly as Apple emits it. Do not reformat to MM/YY. |
currencyCode | currency_code | Yes | ISO 4217 numeric, as a string so leading zeros survive. |
transactionAmount | transaction_amount | Yes | Apple emits a JSON number; Bahn takes a decimal string. Stringify it, do not rescale it. |
cardholderName | cardholder_name | No | Accepted for parity with Apple's token; not persisted. |
deviceManufacturerIdentifier | device_manufacturer_identifier | Yes | Hex string. |
paymentData.onlinePaymentCryptogram | payment_data.online_payment_cryptogram | Yes | Base64 TAVV. This is what makes the DPAN chargeable. |
paymentData.eciIndicator | payment_data.eci_indicator | No | Some tokens omit it. When present, pass it through unchanged. |
header.transactionId (from the envelope, not the plaintext) | transaction_id | Yes | Bahn'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.
POST /api/payment_intents/{id}/confirmRequest
{
"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"
}
}
}
}| Field | Required | Description |
|---|---|---|
payment_method_data.apple_pay.decrypted_token | Yes | The decrypted Apple Pay token, mapped as in Map the plaintext. 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. |
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.
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 is a device-tokenized card tagged with the wallet, identical to what the base flow produces:
{
"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.
| Symptom | Likely cause | Fix |
|---|---|---|
| Your decryption fails or yields garbage | The token was encrypted to a different Payment Processing key | Select the key by header.publicKeyHash, not by configuration order. During a certificate rotation both keys must stay loadable. |
| Signature verification fails | paymentData was base64-wrapped, re-serialised, or truncated between the app and your backend | Forward the raw UTF-8 JSON string exactly as PassKit produced it. |
| Confirm rejected, plaintext looks right | application_expiration_date reformatted to MM/YY, or transaction_amount sent as a JSON number | Keep Apple's YYMMDD, and stringify the amount without rescaling it. |
| Confirm rejected mentioning the payment data type | The token is EMV, not 3DSecure | Add .capability3DS to PKPaymentRequest.merchantCapabilities. |
| Confirm rejected with a missing-field error | transaction_id was read from the decrypted payload instead of the envelope header | It lives on paymentData.header.transactionId, outside the ciphertext. |
| Confirm fails on a retry | transaction_id is single-use per merchant | Send 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".