3DS 与托管收银台接入
直接 API 如何处理 3DS 跳转与浏览器信息;托管收银台如何创建会话、复用与过期、回跳地址白名单,以及履约以服务端状态为准。
最后更新于 2026-10-16 · 契约 v1.0.0
3DS 的产品边界
- 认证成功不等于扣款成功:认证只证明是持卡人本人,发卡行仍可能拒绝扣款;以支付的
status为准。 - 平台不能保证每次都不弹认证:可能无需交互,也可能需要在银行页面挑战,由发卡行与上游决定。
- 不能靠商户配置绕过上游或发卡行的认证要求。
什么时候需要认证
| 场景 | 平台行为 | 你看到的结果 | 你该做什么 |
|---|---|---|---|
| 无需交互即可认证 | 继续支付处理 | 根据实际扣款结果返回状态(可能没有 payment.requires_action 事件) | 按状态处理 |
| 需要挑战认证 | 创建认证会话 | requires_action + next_action | 浏览器整页跳转认证地址 |
| 认证完成且扣款成功 | 查证上游支付事实,记账并通知 | succeeded | 履约 |
| 认证成功但扣款被拒 | 记录认证成功和支付失败两个独立结果 | failed + 脱敏失败原因 | 提示换卡;不要把认证成功当作支付成功 |
| 付款人关闭页面 | 不推断成功或失败,按上游状态查询 | 保持 requires_action 或 processing | 显示“正在确认”;有效期内可再次引导认证 |
| 认证链接失效 | 禁止继续使用旧会话,先查证原尝试;能安全继续时重建认证会话 | 当前真实支付状态 | 查询支付,按最新 next_action 或状态处理 |
| 浏览器伪造认证返回 | 只触发服务端查证,不记为成功 | 资金结果不变 | 你的页面同样只信服务端查询 |
在测试环境用 3DS 测试卡模拟无需交互、挑战成功、挑战失败与认证成功但扣款被拒等结果。
处理 next_action.redirect_to_url
需要付款人浏览器完成的动作。url 指向平台认证承载页,携带只能查看并继续这一笔认证的短时凭据, 有效期不超过上游会话。商户须把付款人浏览器带到该地址,不能由服务端代替持卡人完成认证;不要把该地址写入分析日志或经 Referer 泄漏。 认证地址过期后,查询支付获取最新的 next_action;不要缓存旧地址。
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
type | string | 必填 | 首期公共 API 只开放 redirect_to_url。客户端须容忍未来新增的类型。
示例: |
authentication_session_id | string | 必填 | 认证会话号,关联具体的支付尝试。 示例: |
url | string(uri) | 必填 | 平台认证承载页地址。 示例: |
expires_at | string(date-time) | 必填 | 认证访问的失效时间(最长 10 分钟)。 示例: |
在 API 参考中查看:next_action。
- 整页跳转:把
next_action.url从你的服务端返回给付款人浏览器并整页跳转;不要放进 iframe(认证承载页禁止被第三方页面嵌入)。App 内用系统浏览器或应用内浏览器标签页打开。 - 发卡行挑战始终在平台认证承载页内完成(承载页按上游要求以内嵌框架或整页跳转展示发卡行页面):你的页面不需要、也不能集成上游 3DS SDK。
- 有效期:
next_action.expires_at最长 10 分钟(取平台上限与上游会话有效期的较短者)。 - 安全:认证地址带只绑定这一笔认证的短时凭据,不能用于其他订单。不要写入日志、分析工具或错误上报;不要放进你自己页面的 URL(查询参数或 hash),以免经 Referer 或分析脚本泄漏。
- 不要在你的页面上收集发卡行密码或短信验证码;挑战信息只在发卡行或上游提供的认证流程中填写。
- 需要交互的认证不能由服务端代替持卡人完成:发起支付时付款人必须在场。
type首期只有redirect_to_url;遇到不认识的类型按“未完成”处理并查询,不要当成功。
注意:认证地址过期
不要重新 confirm:查询支付(GET/v1/payments/{payment_id})获取最新的 next_action 或状态。平台会先查证原尝试,能安全继续时重建认证会话。
认证结束之后
付款人完成认证后,浏览器回到你在创建或确认支付时提供的 return_url。
| 实际结果 | 服务端查询到的状态 | 你该做什么 |
|---|---|---|
| 认证成功且扣款成功 | succeeded,随后收到 payment.succeeded | 履约 |
| 认证成功但发卡行拒绝扣款 | failed,failure.code 如 card_declined;authentication_status 表示认证已通过 | 提示付款人换卡;不要因为“认证成功”而履约 |
| 认证失败 | failed(如 authentication_failed) | 提示换卡或重试 |
| 付款人未完成或关闭页面 | requires_action 或 processing | 显示“正在确认支付结果”;有效期内可引导付款人继续认证 |
| 仍在确认 | processing | 等待 Webhook 或按查单频率查询 |
上面每种结果都可以用 3DS 测试卡在测试环境复现。
浏览器信息采集
browser_info 用于 3DS 风险评估,必须来自付款人的真实浏览器。字段是否必填由当前上游能力决定,缺失时在提交上游前返回 400 并在 error.field 中指出字段;建议始终提供。
付款人真实浏览器上下文,用于 3DS。必须来自付款人浏览器,不能用商户服务器信息替代;是否必填由当前上游适配器能力决定,缺失必要信息时在提交上游前返回明确错误。
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
user_agent | string | 否 | 付款人浏览器的 User-Agent。 约束: 示例: |
accept_header | string | 否 | 付款人浏览器的 Accept 请求头。 约束: 示例: |
language | string | 否 | 浏览器语言(BCP 47)。 约束: 示例: |
screen_width | integer(int32) | 否 | 约束: 示例: |
screen_height | integer(int32) | 否 | 约束: 示例: |
color_depth | integer(int32) | 否 | 约束: 示例: |
timezone_offset_minutes | integer(int32) | 否 | 时区偏移分钟数,采用 JavaScript getTimezoneOffset() 的符号语义,例如 UTC+8 为 -480。 约束: 示例: |
javascript_enabled | boolean | 否 | 示例: |
ip_address | string | 否 | 付款人 IPv4 或 IPv6 地址,须按受信代理链获取,不能用商户服务器 IP 替代。 约束: 示例: |
在 API 参考中查看:browser_info。
| 字段 | 来源 | 注意 |
|---|---|---|
user_agent | 付款人浏览器 navigator.userAgent,或付款人请求的 User-Agent 头 | 不能用你服务器的 HTTP 客户端 UA |
accept_header | 付款人打开付款页时页面导航请求的 Accept 头(服务端记录) | 不是 XHR 请求的 Accept(那通常是 application/json) |
language | navigator.language | 如 zh-CN |
screen_width / screen_height / color_depth | window.screen | — |
timezone_offset_minutes | new Date().getTimezoneOffset() | 符号语义见下表 |
javascript_enabled | 采集脚本运行即为 true | — |
ip_address | 你的服务端按受信代理链解析的付款人 IP | 不能用服务器 IP;不能直接信任客户端传来的 X-Forwarded-For |
// 在付款人浏览器中采集,随卡信息一起提交到你的服务端
export function collectBrowserInfo() {
return {
user_agent: navigator.userAgent,
language: navigator.language,
screen_width: window.screen.width,
screen_height: window.screen.height,
color_depth: window.screen.colorDepth,
timezone_offset_minutes: new Date().getTimezoneOffset(), // UTC+8 为 -480
javascript_enabled: true
};
}timezone_offset_minutes 使用 JavaScript getTimezoneOffset() 的符号语义,与“UTC+8”的写法相反:
| 付款人所在时区 | timezone_offset_minutes |
|---|---|
| UTC+8(北京、新加坡) | -480 |
| UTC+9(东京) | -540 |
| UTC+5:30(印度) | -330 |
| UTC±0 | 0 |
| UTC−5(美国东部标准时间) | 300 |
| UTC+14 / UTC−12(取值范围两端) | -840 / 720 |
夏令时期间同一地区的值会变化,所以必须在付款时实时采集,不能写死。示例中的 203.0.113.x 是文档保留地址,不可用于生产。
托管收银台流程概览
- 收银台使用平台的独立域名。卡信息在收银台内由隔离的采集组件接收(上游托管组件,或平台独立子域上的隔离 iframe,由平台按上游能力选择,对你的接入没有差别),你的页面和服务端都不接触卡数据;创建请求中也没有卡数据。
- 收银台显示商户展示名、订单摘要(
description)、金额与币种;不会显示你的服务端密钥。
创建收银会话的两种方式
| 方式 | 请求字段 | 适用 | 规则 |
|---|---|---|---|
A:传 payment_id | payment_id + return_url(必填)+ 可选 cancel_url、locale、description | 已经通过 API 创建了 Payment(例如 confirm=false)再交给收银台 | 不得同时传 merchant_order_no、amount、currency |
| B:传订单三元组(最常见) | merchant_order_no + amount + currency + return_url(必填)+ 可选字段 | 直接为订单创建收银会话 | 平台创建或复用该订单的 Payment;复用时金额、币种必须一致 |
两种方式都必须带 Idempotency-Key。方式 B 的示例见快速开始,方式 A:
# 方式 A:为已创建的 Payment(例如 confirm=false)创建收银会话
curl https://api.payhub.example/v1/checkout/sessions \
-H "Authorization: Bearer <TEST_SERVER_API_KEY>" \
-H "Idempotency-Key: order-demo-008-session-v1" \
-H "Content-Type: application/json" \
-d '{
"payment_id": "<PAYMENT_ID>",
"return_url": "https://merchant.example/payment/return",
"cancel_url": "https://merchant.example/payment/cancel",
"locale": "zh-CN"
}'需要替换的占位符
<TEST_SERVER_API_KEY>:测试环境服务端 API Key:在商户后台「API Key」页面创建<PAYMENT_ID>:创建支付返回的 id(pay_ 开头)
错误:两组参数都传或都缺 → 400 invalid_request;币种未在当前环境开通 → 403 capability_disabled(先于金额检查返回);金额或币种与已有订单不一致 → 422 order_amount_mismatch;回跳地址未登记 → 422 url_not_allowed;订单不可支付 → 409。完整字段与响应示例见 POST/v1/checkout/sessions。
警告
checkout_url 含收银令牌(令牌保留在地址中,以支持付款人刷新、回退与重复打开),只用于让付款人浏览器整页跳转:不要写入日志、分析工具或错误上报,也不要分享或转发给他人。修改 checkout_url 的参数不能改变金额、币种或收款商户。
复用与有效期
| 情况 | 平台行为 |
|---|---|
| 同一订单再次创建会话,订单仍可支付 | 复用同一个 Payment,生成新的短时会话;金额不变 |
| 关联支付已成功 | 返回 200 与 status=completed 的只读会话,不生成新的可扣款入口 |
| 订单不可支付(已过期、已取消、尝试次数用尽等) | 返回 409 |
| 金额或币种与已有订单不一致 | 返回 422 |
| 付款人在两个标签页同时提交 | 只产生一笔在途尝试 |
| 会话过期 | 只表示不再接受新提交,不代表支付失败;在途支付继续查证;过期后的只读期内(默认且最长 24 小时)付款人仍可打开原链接查看会话与支付结果(只读),之后链接失效 |
| 会话有效期 | 最长 30 分钟,且不超过订单剩余可支付期 |
| 支付成功后刷新、回退或重复打开链接 | 不会再次扣款 |
| 付款人支付失败 | 在次数与有效期内可在收银台重新填写卡信息重试 |
收银台语言
locale 使用 BCP 47:以 zh 开头显示中文、以 en 开头显示英文,其他取值或未传时使用商户默认语言。付款人也可以在收银台页脚切换语言。
回跳地址白名单
return_url、cancel_url(以及直接 API 的return_url)必须匹配预先登记的规则,否则返回 422url_not_allowed:登记回跳地址(在新标签页打开商户后台)(测试环境;需要所有者、管理员或开发者)。- 规则按环境分别登记,由协议 + 主机(精确主机,或
*.example.com一级通配)+ 可选端口 + 可选路径前缀组成。 - 路径前缀按路径段匹配:
/pay匹配/pay/return,不匹配/payment。查询串与片段不参与匹配。 - 生产只允许
https;测试环境可额外登记http://localhost等本地地址。 - 平台回跳时原样使用你提供的地址,不追加任何参数。
返回商户不等于撤销扣款
付款人点击“返回商户”(cancel_url)不等于撤销已提交的扣款。你的 cancel_url 页面要先查询支付:仍在途时显示“正在确认”;确认不存在在途扣款、需要关闭订单时,调用 POST/v1/payments/{payment_id}/cancel 并以 status=canceled 为准。
履约以服务端状态为准
- 浏览器回到
return_url只用于体验。 - 履约依据只能是:服务端查询到的
status=succeeded,或经过验签的payment.succeeded事件(验签方法见 Webhook 验签步骤)。 - URL 参数(包括你自己放的订单号)不能当作结果,只能当作“去查哪一笔”的线索。
- 认证与收银凭据只绑定一个对象:A 订单的凭据不能操作 B 订单;你的系统也不要在订单之间复用这些地址。
// return_url / cancel_url 页面:只展示,结果以服务端查询为准;URL 参数只作为查找订单的线索
app.get('/payment/return', async (req, res) => {
const order = await findOrderForSession(req.session.userId); // 从你的会话或订单表找到订单
const payment = await getPayment(order.paymentId); // 服务端查询最新状态
switch (payment.status) {
case 'succeeded':
return res.render('paid', { order }); // 履约由 Webhook 或服务端查询触发,页面只展示
case 'failed':
return res.render('failed', { order, message: payment.failure?.message }); // 可重新进入收银台换卡
case 'canceled':
case 'expired':
return res.render('closed', { order });
default:
return res.render('confirming', { order }); // processing / requires_action / 未知:显示"正在确认"
}
});