Apple Pay Web 跨浏览器二维码支付接入与踩坑实战:从 Safari 到 Windows Chrome
本文记录一次 Apple Pay on the Web 从 Safari 原生支付扩展到 Chrome、Edge 二维码支付的完整实践。示例中的域名、接口、商户信息、订单号、金额和 Token 均为虚构数据。
一、最终要实现什么
Apple Pay Web 现在可以覆盖两类体验:
| 访问环境 | 用户体验 |
|---|---|
| iPhone、iPad Safari | 直接打开原生 Apple Pay 支付面板 |
| Mac Safari | 使用 Touch ID,或通过配对的 iPhone、Apple Watch 确认 |
| Windows Chrome、Edge 等受支持的桌面浏览器 | 弹出 Apple 托管窗口和二维码,使用 iOS 18 及以上 iPhone 扫码 |
| 不受支持的设备、浏览器或地区 | 能力检测失败,不展示 Apple Pay |
业务系统不需要生成二维码,也不应该根据 User-Agent 自己决定展示原生面板还是二维码。前端统一创建 ApplePaySession,最终呈现方式由 Apple 官方 SDK 决定。
一条规范的支付链路如下:
text
进入结算页
-> 加载 Apple Pay JS SDK
-> 检查 supportsVersion + canMakePayments
-> 后端支付方式列表包含 Apple Pay 时展示选项
-> 用户点击官方 Apple Pay 按钮
-> 同步创建 ApplePaySession 并 begin
-> onvalidatemerchant
-> 后端向 Apple 请求 Merchant Session
-> completeMerchantValidation
-> 用户完成授权
-> onpaymentauthorized
-> 后端创建/确认订单并提交 Apple Pay Token 给 PSP
-> 后端支付成功
-> completePayment(STATUS_SUCCESS)
-> 跳转支付成功页
二、最容易混淆的两个版本号
Apple Pay Web 接入中同时存在两个完全不同的版本概念。
1. Apple Pay JS SDK 版本
html
<script
src="https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js"
crossorigin="anonymous"
></script>
这里的 1.latest 是 JavaScript SDK 发布版本。Apple 在 WWDC24 中说明,跨浏览器支付需要加载 Apple Pay JS SDK 1.2.0 或以上,并使用 SDK 提供的官方按钮。
2. ApplePaySession API 版本
ts
const session = new ApplePaySession(3, paymentRequest)
这里的 3 是 Apple Pay on the Web API Version。它决定 Payment Request 可以使用哪些字段,与 SDK 的 1.latest 不是同一套版本号。
对于一次性支付、基础卡组织、Merchant Validation 和授权回调,Version 3 已经够用。Version 14 增加的是循环支付、自动充值、多 Token 等较新能力,并不是二维码支付的必要条件。
正确策略是使用满足业务功能的最低版本,并在运行时检查:
ts
const APPLE_PAY_VERSION = 3
if (!ApplePaySession.supportsVersion(APPLE_PAY_VERSION)) {
// 当前环境不支持项目使用的 API 版本
}
三、接入前置条件
正式写代码前,应先完成以下配置:
- 注册 Apple Pay Merchant ID。
- 配置 Merchant Identity Certificate。
- 配置 Payment Processing Certificate,或由 PSP 管理处理证书。
- 对所有实际结算域名完成 Merchant Domain Verification。
- 所有包含 Apple Pay 的页面使用有效 HTTPS。
- 后端允许访问 Apple 返回的 Merchant Validation URL,但必须做严格的 Host Allowlist。
- 测试证书、Merchant ID、PSP 环境和测试域名属于同一套环境。
以下信息不能混用:
text
生产 Merchant ID + 测试 PSP
测试证书 + 生产 Merchant ID
已验证的 checkout.example.com + 实际访问 m.example.com
域名验证是精确到 Host 的。checkout.example.com 验证成功,不代表 m.example.com 自动生效。
四、在 Vue 中动态加载 Apple 官方 SDK
如果只有结算页需要 Apple Pay,可以动态加载 SDK,避免全站引入。
1. 扩展通用脚本加载方法
ts
export interface InjectScriptOptions {
crossOrigin?: string
fetchPriority?: 'high' | 'low' | 'auto'
}
export function injectScript(
url: string,
async = true,
id?: string,
options: InjectScriptOptions = {}
) {
return new Promise<boolean>((resolve, reject) => {
const script = document.createElement('script')
script.src = url
script.async = async
script.fetchPriority = options.fetchPriority ?? 'auto'
if (id) script.id = id
if (options.crossOrigin) script.crossOrigin = options.crossOrigin
script.onload = () => resolve(true)
script.onerror = () => reject(new Error(`Script failed: ${url}`))
document.head.appendChild(script)
})
}
2. 封装单例加载器
多个组件可能同时检查 Apple Pay。如果每次都插入 <script>,可能造成 SDK 重复初始化、事件混乱或偶发错误,因此应共享同一个 Promise。
ts
import { injectScript } from './injectScript'
export const APPLE_PAY_VERSION = 3
const SDK_URL =
'https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js'
const SDK_ID = 'apple-pay-js-sdk'
const SDK_TIMEOUT = 8000
let loadingPromise: Promise<boolean> | null = null
function sdkReady() {
return Boolean(
window.ApplePaySession &&
window.customElements?.get('apple-pay-button')
)
}
function withTimeout<T>(promise: Promise<T>, timeout: number) {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(
() => reject(new Error('Apple Pay SDK loading timed out')),
timeout
)
promise.then(
value => {
clearTimeout(timer)
resolve(value)
},
error => {
clearTimeout(timer)
reject(error)
}
)
})
}
export function loadApplePayScript() {
if (typeof document === 'undefined') return Promise.resolve(false)
if (sdkReady()) return Promise.resolve(true)
if (loadingPromise) return loadingPromise
const existing = document.getElementById(SDK_ID) as HTMLScriptElement | null
const loadTask = existing
? new Promise<boolean>((resolve, reject) => {
existing.addEventListener('load', () => resolve(sdkReady()), {
once: true
})
existing.addEventListener(
'error',
() => reject(new Error('Apple Pay SDK failed to load')),
{ once: true }
)
})
: injectScript(SDK_URL, true, SDK_ID, {
crossOrigin: 'anonymous',
fetchPriority: 'high'
}).then(() => sdkReady())
loadingPromise = withTimeout(loadTask, SDK_TIMEOUT)
.then(ready => {
if (!ready) throw new Error('Apple Pay SDK is not ready')
return true
})
.catch(error => {
document.getElementById(SDK_ID)?.remove()
console.error('Failed to load Apple Pay SDK:', error)
return false
})
.finally(() => {
// 加载失败后允许用户刷新支付方式或重新进入页面时重试。
if (!sdkReady()) loadingPromise = null
})
return loadingPromise
}
export function isApplePayAvailable() {
if (typeof window === 'undefined') return false
try {
return Boolean(
window.ApplePaySession?.supportsVersion?.(APPLE_PAY_VERSION) &&
window.ApplePaySession?.canMakePayments?.()
)
} catch (error) {
console.error('Apple Pay capability check failed:', error)
return false
}
}
export async function prepareApplePay() {
const loaded = await loadApplePayScript()
return loaded && isApplePayAvailable()
}
SSR 环境不能访问 window 或 document。加载器必须在最前面返回不可用,不能让 SDK 初始化代码进入服务端执行路径。
五、必须使用官方 Apple Pay 按钮
过去 Safari 可以使用 CSS 模拟按钮:
css
-webkit-appearance: -apple-pay-button;
但这种按钮不能开启新的跨浏览器体验。二维码支付必须使用 SDK 注册的 Web Component:
vue
<template>
<apple-pay-button
buttonstyle="black"
type="buy"
locale="en-US"
@click="startApplePay"
/>
</template>
Vue 需要把它声明为自定义元素:
ts
// vite.config.ts
import vue from '@vitejs/plugin-vue'
export default {
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: tag => tag === 'apple-pay-button'
}
}
})
]
}
按钮尺寸使用 Apple Web Component 的 CSS 变量:
css
apple-pay-button {
--apple-pay-button-width: 100%;
--apple-pay-button-height: 52px;
--apple-pay-button-border-radius: 6px;
--apple-pay-button-padding: 0;
--apple-pay-button-box-sizing: border-box;
display: block;
width: 100%;
min-height: 52px;
}
不要自行绘制 Apple Logo 或二维码。
六、支付方式列表为什么有 Apple Pay,页面却不显示
后端返回支付方式,只代表业务上配置了 Apple Pay,不代表当前浏览器可以使用。
json
{
"payKey": "ApplePay",
"payName": "Apple Pay",
"paymethodCode": 27
}
前端通常还会做一次能力过滤:
ts
const canApplePay = await prepareApplePay()
const response = await getPaymentMethods()
paymentMethods.value = response.data.filter(method => {
return method.payKey !== 'ApplePay' || canApplePay
})
因此出现"接口返回了,但页面没显示"时,应先看 canMakePayments(),而不是继续查支付方式接口。
建议在结算页执行:
js
({
url: location.href,
secureContext: window.isSecureContext,
sdkScript: [...document.scripts]
.find(script => script.src.includes('apple-pay-sdk'))?.src,
applePaySession: typeof window.ApplePaySession,
officialButton: Boolean(customElements.get('apple-pay-button')),
supportsVersion3: window.ApplePaySession?.supportsVersion?.(3),
canMakePayments: window.ApplePaySession?.canMakePayments?.(),
userAgent: navigator.userAgent,
platform: navigator.userAgentData?.platform,
mobile: navigator.userAgentData?.mobile
})
理想结果如下:
json
{
"secureContext": true,
"applePaySession": "function",
"officialButton": true,
"supportsVersion3": true,
"canMakePayments": true,
"platform": "Windows",
"mobile": false
}
Chrome 手机模拟是一个典型误区
在 Windows Chrome 中打开设备模拟工具栏后,浏览器可能向 Apple SDK 暴露移动设备 User-Agent。此时即使页面仍运行在 Windows 上,也可能得到:
js
ApplePaySession.canMakePayments() === false
正确的桌面二维码测试方法是关闭设备模拟,直接在普通 Windows Chrome/Edge 中访问移动站域名。可以缩小窗口测试响应式布局,但不要覆盖 User-Agent。
不要为了让模拟器显示按钮而忽略 canMakePayments()。按钮虽然可能出现,Apple 托管会话仍无法正常启动。
七、Payment Request 中最容易传错的 countryCode
Apple 对 countryCode 的定义是商户主要经营地或交易处理/结算国家,通常需要由 PSP 确认。
下面这些来源都是错误的:
ts
countryCode: shippingAddress.countryCode // 客户收货国家
countryCode: geolocation.countryCode // 客户 IP 国家
countryCode: serverRegion // 网站服务器所在国家
countryCode: developerAccountRegion // 开发者账号注册地
例如,一家跨境电商企业的客户在美国、墨西哥和欧洲,但 Apple Pay 收单机构在中国,则 Payment Request 应使用:
ts
const paymentRequest = {
countryCode: 'CN',
currencyCode: 'USD',
// ...
}
countryCode 和 currencyCode 是两个独立概念:
countryCode:本次交易对应的商户/收单处理国家。currencyCode:本次实际扣款币种。
客户在美国不等于 countryCode 必须是 US,使用美元也不等于 countryCode 必须是 US。
如果商户存在多个国家的收单路由,后端应在用户点击 Apple Pay 之前提供明确配置:
json
{
"payKey": "ApplePay",
"applePayCountryCode": "US",
"applePayCurrencyCode": "USD"
}
不能等 ApplePaySession 启动后再猜测国家。尤其不要把含义不明的 countryIso2 直接当成 Apple Pay 商户国家。
八、完整的前端最小实现
下面示例覆盖一次性支付的完整生命周期。为了保持用户手势调用链,点击处理函数不能在创建 ApplePaySession 之前 await 网络请求。
ts
const APPLE_PAY_VERSION = 3
const AUTH_TIMEOUT = 25_000
let sessionActive = false
function parseMerchantSession(response: any) {
const value = response?.data ?? response
const session = typeof value === 'string' ? JSON.parse(value) : value
if (!session || typeof session !== 'object' || Array.isArray(session)) {
throw new Error('Invalid merchant session')
}
return session
}
function withAuthorizationTimeout<T>(promise: Promise<T>) {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(
() => reject(new Error('Apple Pay authorization timed out')),
AUTH_TIMEOUT
)
promise.then(
value => {
clearTimeout(timer)
resolve(value)
},
error => {
clearTimeout(timer)
reject(error)
}
)
})
}
function startApplePay() {
if (sessionActive || !isApplePayAvailable()) return
const amount = Number('11.50')
if (!Number.isFinite(amount) || amount <= 0) return
const request = {
countryCode: 'CN', // 示例:PSP 已确认收单处理国家为中国
currencyCode: 'USD',
supportedNetworks: [
'visa',
'masterCard',
'amex',
'discover',
'chinaUnionPay'
],
merchantCapabilities: [
'supports3DS',
'supportsDebit',
'supportsCredit'
],
total: {
label: 'Example Store',
type: 'final',
amount: amount.toFixed(2)
}
}
// 必须在用户点击事件的同步调用栈中构造 Session。
let session: any
try {
session = new ApplePaySession(APPLE_PAY_VERSION, request)
} catch (error) {
console.error('Failed to create Apple Pay session:', error)
return
}
sessionActive = true
let finished = false
// 可以并行创建订单,但不要在 new ApplePaySession 之前 await。
const orderPromise = fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymethodCode: 27 })
})
.then(response => {
if (!response.ok) throw new Error('Create order failed')
return response.json()
})
.then(
response => ({ response }),
error => ({ error })
)
const release = () => {
sessionActive = false
}
const abort = (error: unknown) => {
if (finished) return
finished = true
release()
console.error('Apple Pay session aborted:', error)
try {
session.abort()
} catch (abortError) {
console.error('Failed to abort Apple Pay session:', abortError)
}
}
const complete = (success: boolean) => {
if (finished) return false
finished = true
const status = success
? ApplePaySession.STATUS_SUCCESS
: ApplePaySession.STATUS_FAILURE
try {
session.completePayment({ status })
return true
} catch (error) {
console.error('Failed to complete Apple Pay session:', error)
return false
} finally {
release()
}
}
session.onvalidatemerchant = async (event: any) => {
if (!event?.validationURL) {
abort(new Error('Missing validationURL'))
return
}
try {
const response = await fetch('/api/apple-pay/merchant-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ validationURL: event.validationURL })
})
if (!response.ok) throw new Error('Merchant validation failed')
const result = await response.json()
if (finished) return
session.completeMerchantValidation(parseMerchantSession(result))
} catch (error) {
abort(error)
}
}
session.onpaymentmethodselected = () => {
if (finished) return
try {
session.completePaymentMethodSelection({
newTotal: request.total,
newLineItems: []
})
} catch (error) {
abort(error)
}
}
session.onpaymentauthorized = async (event: any) => {
try {
const result = await withAuthorizationTimeout(
(async () => {
const { response: orderResult, error: orderError } =
await orderPromise
if (orderError) throw orderError
const order = orderResult?.data
if (!order?.orderNumber) {
throw new Error('Missing order number')
}
const payment = event?.payment
const network =
payment?.token?.paymentMethod?.network ||
payment?.token?.paymentMethod?.displayName?.split(' ')[0] ||
''
const payResponse = await fetch('/api/payments/apple-pay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
orderNumber: order.orderNumber,
paymentNumber: order.paymentNumber,
appleParam: {
token: payment?.token,
cardType: network
}
})
})
if (!payResponse.ok) throw new Error('Payment request failed')
return {
order,
paymentResult: await payResponse.json()
}
})()
)
const paid = result.paymentResult?.success === true
const completed = complete(paid)
// 必须先通知 Apple 成功,再跳转业务成功页。
if (paid && completed) {
location.href = `/payment/success?order=${encodeURIComponent(
result.order.orderNumber
)}`
}
} catch (error) {
complete(false)
console.error('Apple Pay authorization failed:', error)
}
}
session.oncancel = () => {
finished = true
release()
// 用户取消后停留在结算页;预创建订单交给后端过期机制处理。
}
try {
session.begin()
} catch (error) {
abort(error)
}
}
九、后端 Merchant Validation 最小示例
Merchant Validation 必须由后端执行,因为 Merchant Identity Certificate 和私钥不能暴露给浏览器。
下面是简化的 Node.js 伪代码,生产环境还需要完善证书管理、日志脱敏和错误处理:
ts
import fs from 'node:fs'
import https from 'node:https'
import express from 'express'
const app = express()
app.use(express.json())
const ALLOWED_VALIDATION_HOSTS = new Set([
'apple-pay-gateway.apple.com',
'apple-pay-gateway-nc-pod1.apple.com'
])
app.post('/api/apple-pay/merchant-session', async (req, res) => {
const validationURL = new URL(req.body.validationURL)
if (
validationURL.protocol !== 'https:' ||
!ALLOWED_VALIDATION_HOSTS.has(validationURL.hostname)
) {
return res.status(400).json({ message: 'Invalid validation URL' })
}
const body = JSON.stringify({
merchantIdentifier: 'merchant.com.example.store',
displayName: 'Example Store',
initiative: 'web',
initiativeContext: 'checkout.example.com'
})
const merchantSession = await postToApple(validationURL, body, {
cert: fs.readFileSync('/run/secrets/merchant-identity.crt'),
key: fs.readFileSync('/run/secrets/merchant-identity.key')
})
// 推荐返回对象,避免前端二次 JSON.parse。
res.json({ success: true, data: merchantSession })
})
必须注意:
initiativeContext必须与用户当前访问的已验证域名一致。- 不要信任前端传入的任意 Validation URL,否则可能产生 SSRF 风险。
- 不要在日志中记录证书、私钥、完整 Merchant Session 签名或支付 Token。
- Merchant Session 有有效期,不能长期缓存复用。
Merchant Session 字符串和对象兼容
常见后端响应有两种:
json
{
"success": true,
"data": {
"epochTimestamp": 1700000000000,
"expiresAt": 1700003600000,
"merchantSessionIdentifier": "SESSION_...",
"domainName": "checkout.example.com",
"displayName": "Example Store",
"signature": "..."
}
}
或者:
json
{
"success": true,
"data": "{\"epochTimestamp\":1700000000000,\"domainName\":\"checkout.example.com\",\"signature\":\"...\"}"
}
传给 Apple 的必须是 Merchant Session 对象,而不是业务包装对象或 JSON 字符串:
ts
session.completeMerchantValidation(parseMerchantSession(response))
不要未经确认就固定写成:
ts
session.completeMerchantValidation(response)
也不要假设所有环境永远都是:
ts
session.completeMerchantValidation(response.data)
十、CSP 配置
结算页如果启用了 Content Security Policy,需要放行 Apple 官方 CDN:
text
script-src 'self' https://applepay.cdn-apple.com;
img-src 'self' data: https://applepay.cdn-apple.com;
frame-src 'self' https://applepay.cdn-apple.com;
connect-src 'self' https://applepay.cdn-apple.com;
实际项目通常还包含 PayPal、Google Pay、信用卡风控等域名,应只增量加入 Apple 域名,不要覆盖现有白名单。
如果 SDK 加载失败,先查看浏览器 Console 是否出现:
text
Refused to load the script because it violates Content Security Policy
十一、典型故障与定位方法
1. InvalidAccessError: Trying to start an Apple Pay session from an insecure document
原因:页面不是浏览器认可的安全上下文。
js
({
protocol: location.protocol,
secureContext: window.isSecureContext
})
修复要求:
- 页面使用 HTTPS。
- TLS 证书有效并受浏览器信任。
- 页面没有嵌套在不安全的 HTTP iframe 中。
- 不要指望前端代码绕过 Apple 的安全限制。
2. 接口返回 Apple Pay,但支付选项不显示
按顺序检查:
text
SDK 是否加载成功
-> ApplePaySession 是否存在
-> apple-pay-button 是否注册
-> supportsVersion(3) 是否为 true
-> canMakePayments() 是否为 true
-> 前端过滤后的支付方式列表是否仍包含 ApplePay
后端支付方式接口返回 Apple Pay 只解决业务开关,浏览器能力检测仍可能将它过滤。
3. Windows Chrome 正常,手机模拟模式却不显示
这是模拟 User-Agent 导致的能力判断差异。关闭 DevTools 设备模拟、清理 Service Worker 和站点缓存后重新加载。
4. 弹出"当前无法在所在国家或地区使用 Apple Pay"
需要区分两个国家概念:
- Payment Request 的商户
countryCode。 - 用户当前设备、Apple ID、Wallet、网络所在地对应的可用地区。
先确认 countryCode 没有误用客户收货地址,再在 Apple Pay 支持地区使用真实设备验证。canMakePayments() === true 表示浏览器可以启动流程,不等于最终地区、账户和卡片一定可用。
5. onvalidatemerchant 执行,但随后 Session aborted
重点检查:
validationURL是否原样传给后端。- 后端是否成功请求 Apple,而不是只返回 HTTP 200 的业务失败对象。
- Merchant Session 是对象还是 JSON 字符串。
domainName是否等于当前页面 Host。- Merchant ID、证书、PSP 沙盒/生产环境是否一致。
- 是否错误地把整个
{ code, data, success }包装对象传给 Apple。
6. onpaymentauthorized 没有执行
它不是点击按钮后立即执行。只有以下步骤全部成功才会触发:
text
Session 创建成功
-> begin 成功
-> Merchant Validation 成功
-> Apple 支付面板/二维码可用
-> 用户选择卡片并完成授权
如果 Merchant Validation 失败、用户取消、地区不可用或 Session 被主动 abort(),就不会进入授权回调。
7. 支付成功但 Apple 面板显示失败
常见原因是业务成功后直接跳转,没有先完成 Apple Session:
ts
// 错误顺序
location.href = '/payment/success'
session.completePayment({ status: ApplePaySession.STATUS_SUCCESS })
正确顺序:
ts
session.completePayment({
status: ApplePaySession.STATUS_SUCCESS
})
location.href = '/payment/success'
8. 同一个 Session 被完成两次
网络超时、后端迟到响应和异常分支可能同时调用 completePayment()。应设置 finished 标记,使完成操作幂等。否则会出现 Session 状态异常或重复跳转。
十二、Promise 作用域与订单竞态
下面写法是合法的:
ts
function startApplePay() {
const orderPromise = createOrder()
session.onpaymentauthorized = async () => {
const order = await orderPromise
}
}
虽然回调在以后执行,但 JavaScript 闭包会保留 startApplePay 词法作用域中的 orderPromise 引用。
它解决的是订单竞态:
- 用户点击后立即发起订单创建。
- Apple 面板同时进行 Merchant Validation 和用户授权。
- 授权回调真正执行时,再等待同一个订单 Promise。
- 不依赖某个响应式变量"碰巧已经赋值"。
但不要在创建 Session 前这样写:
ts
async function startApplePay() {
const order = await createOrder()
const session = new ApplePaySession(3, request)
}
await 可能破坏 Apple 对"必须由用户手势同步触发"的要求。
十三、如何避免影响其他支付方式
Apple Pay 改造应遵循最小隔离原则:
- SDK 只在结算页动态加载。
- 能力检测只过滤
payKey === 'ApplePay'的项目。 - 不修改公共 HTTP 拦截器。
- 不改变 Google Pay、PayPal、信用卡等支付函数的默认跳转行为。
- Apple Pay 单独使用延迟跳转,即先完成 Apple Session,再跳成功页。
- SDK 加载失败只隐藏 Apple Pay,不能阻塞整个支付方式列表。
- CSP 只增量添加 Apple 域名,不删除其他支付域名。
示例:
ts
const canApplePay = await prepareApplePay()
paymentMethods.value = serverMethods.filter(method => {
if (method.payKey === 'ApplePay') return canApplePay
return true
})
十四、测试矩阵
| 场景 | 预期结果 |
|---|---|
| HTTP 测试地址 | Apple Pay 不启动,控制台提示不安全文档 |
| 有效 HTTPS + SDK 加载失败 | 隐藏 Apple Pay,其他支付不受影响 |
| Windows Chrome 普通桌面模式 | 能力检测通过,点击后出现二维码 |
| Windows Chrome 手机模拟 | 可能返回 canMakePayments=false,不作为真实验收结果 |
| iPhone Safari | 打开原生 Apple Pay 面板,不显示二维码 |
| Version 3 不支持 | 隐藏 Apple Pay |
| Merchant Session 为对象 | Merchant Validation 成功 |
| Merchant Session 为 JSON 字符串 | 解析后 Merchant Validation 成功 |
| Merchant Session 域名不一致 | 验证失败,不进入授权回调 |
| 用户取消 | 释放 Session 锁,停留在结算页 |
| 后端扣款失败 | 只完成一次 STATUS_FAILURE,不跳成功页 |
| 授权超过 25 秒 | 完成失败状态,迟到结果不得触发成功跳转 |
| 用户连续点击 | 只创建一个 Session 和一份订单 |
| 后端扣款成功 | 先 STATUS_SUCCESS,再跳成功页 |
十五、上线检查清单
前端
- 页面为有效 HTTPS,
window.isSecureContext === true。 - 官方 SDK 只加载一次。
- 使用官方
<apple-pay-button>。 -
supportsVersion()和canMakePayments()均有异常保护。 - 金额为大于 0 的两位小数字符串。
-
countryCode来自 PSP 确认的商户/收单国家。 - Merchant Session 字符串和对象响应均可处理。
- completion 幂等,授权超时早于 Apple 最终超时。
- 成功时先完成 Session,再跳转。
- 取消时不误跳成功页。
- 不打印完整支付 Token。
后端
- Merchant Validation URL 使用 HTTPS Host Allowlist。
- Merchant ID、证书和环境一致。
-
initiativeContext等于实际访问域名。 - 所有 PC/H5 域名分别完成 Merchant Domain Verification。
- Merchant Session 未被长期缓存。
- 支付接口可幂等处理重复请求。
- PSP 明确支持当前币种、卡组织和收单国家。
- 日志已脱敏,不包含证书、签名和完整 Token。
真实环境
- Windows Chrome/Edge 使用正常桌面模式测试二维码。
- 使用 iOS 18 及以上 iPhone 扫码。
- 使用 Apple 沙盒账号和沙盒卡测试。
- Safari 回归原生支付。
- Google Pay、PayPal、信用卡等支付方式完成回归。
十六、结论
Apple Pay Web 跨浏览器接入本身不需要重写支付链路。真正关键的是:
- 加载 Apple 官方 SDK 1.2.0 以上版本。
- 使用官方 Apple Pay Web Component 按钮。
- 保持
ApplePaySession在用户点击同步调用栈中创建。 - 正确区分 SDK 版本和 Apple Pay API Version。
- 把
countryCode当作商户/收单处理国家,而不是客户国家。 - Merchant Session 必须与当前域名、证书和环境严格一致。
- 后端支付成功后,先完成 Apple Session,再跳转业务页面。
- 用真实 HTTPS、真实浏览器模式和真实设备完成最终验收。
大量"接口成功但 Apple Pay 失败"的问题,实际上发生在后端扣款之前。沿着"安全上下文 -> SDK -> 能力检测 -> Merchant Validation -> 用户授权 -> PSP 扣款 -> completePayment"的顺序排查,通常可以快速定位问题所在。
参考资料
- Apple Pay on the Web
- Loading the latest version of the Apple Pay JS SDK
- Checking for Apple Pay availability
- Creating an Apple Pay Session
- ApplePayPaymentRequest.countryCode
- Providing Merchant Validation
- Apple Pay on the Web version history
- WWDC24: What's new in Wallet and Apple Pay
- TN3103: Apple Pay on the Web troubleshooting guide