Apple Pay
Accept Apple Pay on the web. bahn.mountApplePay() renders the official Apple Pay button and handles merchant validation, payment authorization, and (if triggered) 3D Secure — you mount it, listen for a few events, and confirm the payment.
Requirements
Before integrating, complete these with Apple and with Cake:
| Requirement | Where | Notes |
|---|---|---|
| Domain verification | Apple Developer → Merchant IDs → Apple Pay on the Web | Your production domain must serve /.well-known/apple-developer-merchantid-domain-association at its root, and be registered against your Apple merchant identifier. This is required even though the button itself renders inside a Cake-hosted iframe — Apple validates against your top-level page's domain, not the iframe's. |
| Merchant account with Cake | Onboarding | Your domain must also be registered with Cake against your merchant, matching what you registered with Apple. |
| Safari 17 / iOS 17+ | — | Required for the button to render at all (see Browser support). |
| Cross-browser enrollment (optional) | Apple Developer | A separate Apple enrollment for "Apple Pay on the Web for other browsers," if you also want to support Chrome, Firefox, and Edge. |
Quick start
const bahn = new Bahn({ pk: "pk_live_...", environment: "sandbox" });
const ap = bahn.mountApplePay("#apple-pay-container", clientSecret, {
amount: 100000, // smallest currency unit — VND is not divided by 100
currency: "VND",
country: "VN",
});
ap.on("ready", () => console.log("Apple Pay button rendered"));
ap.on("success", (paymentIntent) => (window.location.href = "/success"));
ap.on("error", (err) => console.error((err as { message: string }).message));
ap.on("cancel", () => console.log("Shopper dismissed the sheet"));mountApplePay() mounts the button at the CSS selector you give it, sized to fit the container, and manages everything else internally — checking whether Apple Pay can be used, rendering the button, and running the full payment sheet. If Apple Pay isn't available on the shopper's browser, no button renders and an "error" event fires instead — use that to fall back to another payment method. merchantName, supportedNetworks, and merchantCapabilities all default to your merchant account's configured values, so pass them only to override.
mountApplePay(selector, clientSecret, options) parameters
| Param | Type | Required | Description |
|---|---|---|---|
selector | string | Yes | CSS selector for the mount container |
clientSecret | string | undefined | Yes (pass undefined for the token-based flow) | Client secret of the payment intent to confirm — see Confirming the payment |
options.amount | number | Yes | Transaction amount, in the smallest currency unit |
options.currency | string | Yes | ISO 4217 currency code (e.g. "VND", "USD") |
options.country | string | Yes | ISO 3166-1 alpha-2 country code (e.g. "VN", "US") |
options.priceLabel | string | No | Label shown as the transaction total in the sheet |
options.lineItems | Array<{ label, amount }> | No | Itemized breakdown shown in the sheet |
options.merchantName | string | No | Merchant name displayed in the sheet — defaults to your merchant's configured name |
options.type | ApplePayButtonType | No | Button label variant (default "plain") |
options.style | ApplePayButtonStyle | No | "black" | "white" | "white-outline" (default "black") |
options.locale | string | No | Button locale (default "en") |
options.borderRadius | number | No | Button border radius, in pixels (default 4) |
options.size | { width?, height? } | No | Button dimensions (default 100% × 48px) |
options.supportedNetworks | ApplePayCardNetwork[] | No | Allowed card networks — defaults to your merchant's configured networks |
options.merchantCapabilities | ApplePayMerchantCapability[] | No | Declared merchant capabilities — defaults to your merchant's configured capabilities |
options.requestBillingAddress | boolean | No | Request a billing address |
options.requestPayerDetails | ("name" | "email" | "phone")[] | No | Request payer contact details |
options.requestShipping | boolean | No | Collect a shipping address — see Collecting a shipping address |
options.shippingMethods | Array<{ identifier, label, detail?, amount }> | No | Initial shipping options shown before an address is picked |
Returns: IApplePayButtonComponent — .on(event, handler) (see Events), .unmount(). bahn.cleanup()/bahn.unmountApplePay() also unmount it.
Confirming the payment
mountApplePay() supports two ways to confirm the payment, chosen by whether you pass a clientSecret.
Direct confirmation
Pass the client_secret of a payment intent you created server-side. The sheet confirms the payment itself and reports the outcome:
const ap = bahn.mountApplePay("#apple-pay-container", clientSecret, {
amount: 100000,
currency: "VND",
country: "VN",
});
ap.on("success", (paymentIntent) => (window.location.href = "/success"));
ap.on("error", (err) => console.error((err as { message: string }).message));If the payment intent requires additional authentication, mountApplePay() calls bahn.mount3DS() automatically to render the challenge, then reports "success"/"error" once it resolves — this is rare in practice, since Face ID / Touch ID authorization already satisfies most issuers' requirements.
Token-based flow
Omit clientSecret to confirm the payment through your own backend instead — call mountApplePay(selector, undefined, options):
const ap = bahn.mountApplePay("#apple-pay-container", undefined, {
amount: 100000,
currency: "VND",
country: "VN",
merchantName: "Your Merchant Name",
});
ap.on("token", async ({ confirmation_token }) => {
// Send confirmation_token to your own backend, which confirms the
// PaymentIntent using confirmation_token and your secret key, and
// returns a client_secret.
const { client_secret } = await fetch("/api/confirm-payment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirmation_token, amount: 100000, currency: "vnd" }),
}).then((r) => r.json());
// Keep the Apple Pay button mounted until this resolves — handleNextAction()
// needs it to check the payment intent's status.
await bahn.handleNextAction(client_secret, {
onSuccess: () => (window.location.href = "/success"),
onFailure: () => (window.location.href = "/failed"),
onTimeout: () => (window.location.href = "/processing"),
});
});
ap.on("error", (err) => console.error((err as { message: string }).message));
ap.on("cancel", () => console.log("Shopper dismissed the sheet"));In this flow, the Apple Pay sheet completes as soon as the confirmation_token is created — the shopper sees the sheet close successfully right away, without waiting on your backend. Confirm the payment on your backend and surface the final result through your own redirect or UI, not through the Apple Pay button; a "success" event is not emitted for this flow.
Collecting a shipping address
Set requestShipping: true and provide shipping options as the shopper enters an address:
const ap = bahn.mountApplePay("#apple-pay-container", clientSecret, {
amount: 100000,
currency: "VND",
country: "VN",
requestShipping: true,
// Shown before the shopper has entered an address. Omit if your rates
// always depend on the address — supply them from "shippingcontactselected" instead.
shippingMethods: [{ identifier: "standard", label: "Standard", detail: "3-5 days", amount: 0 }],
});
ap.on("shippingcontactselected", (event) => {
const { contact, resolve, reject } = event;
if (contact.country !== "VN") {
reject(); // shows an inline "can't ship here" error in the sheet
return;
}
resolve({
shippingMethods: [
{ identifier: "standard", label: "Standard", detail: "3-5 days", amount: 0 },
{ identifier: "express", label: "Express", detail: "1-2 days", amount: 20000 },
],
});
});
ap.on("shippingmethodselected", (event) => {
const { shippingMethod, resolve } = event;
// Recompute the total to include the selected shipping cost.
resolve({ amount: 100000 + shippingMethod.amount });
});
ap.on("token", async ({ confirmation_token }) => { /* same as the token-based flow above */ });The sheet pauses after each selection until you call resolve() (with updated shipping options/total) or reject() (shows an inline error). The contact passed to "shippingcontactselected" is deliberately coarse — city, state, postal code, and country only, no street address — which is standard behavior for this kind of address collection; the full address becomes available once the payment is authorized. Every amount field here follows the same smallest-currency-unit convention as options.amount.
Checking availability before mounting
mountApplePay() already checks availability internally and simply won't render the button if Apple Pay can't be used. Call bahn.isApplePayAvailable() yourself only if you want to control your page's layout before that — for example, hiding an entire "Or pay with Apple Pay" section (button and any surrounding UI), rather than reacting to an "error" event after a container is already showing.
if (!(await bahn.isApplePayAvailable())) {
// hide the entry point — see Browser support below for what this covers
}This is a client-side check only — it does not confirm that Apple Pay is enabled for your merchant account. That's not a separate thing to check up front: if your merchant isn't fully set up, the sheet's own validation fails when the shopper taps the button, surfaced through the same "error" event as any other failure.
Events
.on(event, handler) on the object mountApplePay() returns:
| Event | Fires when | Handler receives |
|---|---|---|
"ready" | The button has rendered and can be tapped | — |
"success" | Payment confirmed (and any 3D Secure challenge passed) | The payment_intent — direct confirmation only |
"token" | A confirmation token was created; the sheet has already closed successfully | { confirmation_token } — token-based flow only |
"error" | Merchant validation or confirmation failed | { message } |
"cancel" | The shopper dismissed the sheet without paying | — |
"shippingcontactselected" | The shopper entered or changed a shipping address | { contact, resolve, reject } — requires requestShipping: true |
"shippingmethodselected" | The shopper chose a different shipping option | { shippingMethod, resolve, reject } — requires requestShipping: true |
Browser support
| Browser | Support |
|---|---|
| Safari (macOS / iOS) | Safari 17 / iOS 17+ |
| Chrome / Edge / Firefox | Supported only if your merchant has completed Apple's separate enrollment for "Apple Pay on the Web for other browsers" |
bahn.isApplePayAvailable() reflects whether the shopper's browser is capable of showing the sheet at all. On Chrome, Firefox, and Edge, that capability check only confirms the browser version — it can't confirm your own enrollment status with Apple, so a true result there means the browser could run the payment flow, not that it will succeed for your merchant without that enrollment. Confirm your enrollment status with Apple directly before relying on non-Safari support in production.
Testing
Use a Safari 17+ browser (macOS) or an iOS 17+ device signed into a sandbox Apple ID, with an Apple Pay Sandbox test card added to Wallet, pointed at your sandbox environment.