跳到正文
PayHub开发者文档

3DS 与托管收银台接入

直接 API 如何处理 3DS 跳转与浏览器信息;托管收银台如何创建会话、复用与过期、回跳地址白名单,以及履约以服务端状态为准。

最后更新于 2026-10-16 · 契约 v1.0.0

3DS 的产品边界

  1. 认证成功不等于扣款成功:认证只证明是持卡人本人,发卡行仍可能拒绝扣款;以支付的 status 为准。
  2. 平台不能保证每次都不弹认证:可能无需交互,也可能需要在银行页面挑战,由发卡行与上游决定。
  3. 不能靠商户配置绕过上游或发卡行的认证要求。

什么时候需要认证

场景平台行为你看到的结果你该做什么
无需交互即可认证继续支付处理根据实际扣款结果返回状态(可能没有 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。客户端须容忍未来新增的类型。

  • redirect_to_url
  • 将来可能新增取值;不认识的状态不要当作成功。

示例:redirect_to_url

authentication_session_id
string必填

认证会话号,关联具体的支付尝试。

示例:auth_demo_001

url
string(uri)必填

平台认证承载页地址。

示例:https://pay.payhub.example/auth/<SHORT_LIVED_TOKEN>

expires_at
string(date-time)必填

认证访问的失效时间(最长 10 分钟)。

示例:2026-09-23T09:10:00Z

在 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 或状态。平台会先查证原尝试,能安全继续时重建认证会话。

直接 API 的 3DS
直接 API 的 3DS

认证结束之后

付款人完成认证后,浏览器回到你在创建或确认支付时提供的 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。

约束:最长 2048

示例:Mozilla/5.0

accept_header
string否

付款人浏览器的 Accept 请求头。

约束:最长 2048

示例:text/html,application/xhtml+xml

language
string否

浏览器语言(BCP 47)。

约束:最长 35

示例:zh-CN

screen_width
integer(int32)否

约束:≥ 0

示例:1366

screen_height
integer(int32)否

约束:≥ 0

示例:768

color_depth
integer(int32)否

约束:1–48

示例:24

timezone_offset_minutes
integer(int32)否

时区偏移分钟数,采用 JavaScript getTimezoneOffset() 的符号语义,例如 UTC+8 为 -480。

约束:-840–720

示例:-480

javascript_enabled
boolean否

示例:true

ip_address
string否

付款人 IPv4 或 IPv6 地址,须按受信代理链获取,不能用商户服务器 IP 替代。

约束:最长 45

示例:203.0.113.10

在 API 参考中查看:browser_info。

字段来源注意
user_agent付款人浏览器 navigator.userAgent,或付款人请求的 User-Agent 头不能用你服务器的 HTTP 客户端 UA
accept_header付款人打开付款页时页面导航请求的 Accept 头(服务端记录)不是 XHR 请求的 Accept(那通常是 application/json)
languagenavigator.language如 zh-CN
screen_width / screen_height / color_depthwindow.screen—
timezone_offset_minutesnew 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±00
UTC−5(美国东部标准时间)300
UTC+14 / UTC−12(取值范围两端)-840 / 720

夏令时期间同一地区的值会变化,所以必须在付款时实时采集,不能写死。示例中的 203.0.113.x 是文档保留地址,不可用于生产。

托管收银台流程概览

  • 收银台使用平台的独立域名。卡信息在收银台内由隔离的采集组件接收(上游托管组件,或平台独立子域上的隔离 iframe,由平台按上游能力选择,对你的接入没有差别),你的页面和服务端都不接触卡数据;创建请求中也没有卡数据。
  • 收银台显示商户展示名、订单摘要(description)、金额与币种;不会显示你的服务端密钥。
托管收银台
托管收银台

创建收银会话的两种方式

方式请求字段适用规则
A:传 payment_idpayment_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:

curl
# 方式 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)必须匹配预先登记的规则,否则返回 422 url_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 页面的推荐写法
// 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 / 未知:显示"正在确认"
  }
});
return_url / cancel_url 页面的推荐写法
return_url / cancel_url 页面的推荐写法

常见问题

这页有帮助吗?