支付回调

支付成功后,网关向商户配置的回调地址发送 POST 请求。回调是入账的唯一依据。 用户可能付完款就关掉页面,所以页面跳转、前端轮询结果都不能用来入账。

回调地址在商户后台「接入配置 → 支付回调」里自己配。点「保存配置」时,网关会先向新地址发一条 webhook.ping 测试事件(信封和真实事件一样、签名规则一样,data 里只有商户号和一句说明),收到 2xx 才生效;平时想自检,点地址旁边的「测试地址」再发一条。所以回调程序遇到不认识的 event_type 直接返回 2xx 就行,不要报错。

对回调地址的几点要求:

  • 公网能访问到就行,带端口也可以。建议用 HTTPS;填 http 也能保存,但回调内容会明文经过公网,风险自担
  • 别按来源 IP 设白名单。网关跑在阿里云函数计算上,出口 IP 不固定,来源可不可信靠验签判断
  • 别用 https 加 IP 配自签证书。网关会校验证书,过不了就直接投递失败;测试阶段用 http,或者用有正规证书的域名

按单指定回调地址

默认情况下回调地址只有一个,就是「接入配置」里填的那个,三种事件都往它发,下单时不用传、也不能传回调地址。 几个网站共用一个商户号、又想各收各的回调,就把回调方式切成「按单指定」,下单时带上 notify_url。

在「接入配置 → 支付回调」里选「按单指定」,把各站的地址逐条填进去,最多 20 条,然后点「保存配置」。保存时网关会给新加的地址各发一条测试回调,都返回 2xx 才生效;确认框里要勾一项:下单会带上这里登记的地址,漏传或对不上就拒单。之后下单传 notify_url,值要和登记的地址一字不差,大小写、末尾的斜杠都算。这张单的 order.paid、refund.succeeded、refund.failed 都发到这个地址。

几条规则记一下:

  • 统一地址模式下传了 notify_url,报 42202
  • 按单指定模式下 notify_url 必传:漏传报 40004,地址不在白名单报 42202。漏传在下单这一步就拦住了,不会发到别的地方去
  • 切换只管之后的单,已经下的单和路上的回调还按原来的地址发
  • 从白名单里删掉一个地址,发往它的回调就停了,记成「未投递」,不会转发到统一地址
  • 顽兔运营这边只能看,不会替你切换,也不会替你改白名单
Node.js
Python
Go
Java
const order = await pay.create_order({
  out_trade_no: 'B_20260917000042',
  amount: '100.00',
  subject: '积分充值100元',
  pay_mode: 'qrcode',
  notify_url: 'https://b.example.com/wantu/webhook',   // 要和白名单里登记的地址一字不差
})

接收回调

Node.js(Express)
Python(FastAPI)
Go(net/http)
Java(Spring Boot)
// 签名针对请求体原文,webhook 路由必须挂 express.raw()(不要全局挂 express.json())
app.post('/wantu/webhook', express.raw({ type: '*/*' }), async (req, res) => {
  let event
  try {
    event = pay.verify_webhook(req.headers, req.body)   // req.body 是原文 Buffer,直接传
  } catch (err) {
    return res.sendStatus(400)   // 验签失败:不处理业务,网关会重试
  }

  if (event.event_type === 'order.paid') {
    const data = event.data
    // 入账三件套:查到本地订单 → 金额一致 → 事务内幂等入账
    // UPDATE orders SET status='paid' WHERE out_trade_no=? AND status='created'
    // 更新行数为 1 才执行加积分;为 0 说明是重复回调,直接返回 200
  }
  res.sendStatus(200)
})

各框架取原始请求体的方式:Express 用 express.raw()(并且不要全局挂 express.json(), 会提前吃掉原文),Koa 用 raw-body,FastAPI 用 await request.body(), Next.js App Router 用 await request.text(),Nest 开启 rawBody: true, Go 用 io.ReadAll(r.Body)(Gin 别用 ShouldBindJSON), Spring 用 @RequestBody byte[](声明成 Map 或 DTO 就拿不到原文了)。

处理步骤

  1. 验签。verify_webhook 抛错时返回 400,不处理业务
  2. 核对金额。事件里的 amount 必须与本地订单金额一致
  3. 幂等入账。以 out_trade_no 建唯一约束,重复回调直接返回 200
  4. 返回 200。耗时逻辑先落库再异步处理,不要让回调等待

事件类型

事件类型触发时机data 主要字段
order.paid支付成功order_id、out_trade_no、amount、paid_at、channel_trade_no、attach
refund.succeeded退款成功refund_id、out_trade_no、out_refund_no、amount、refunded_at、attach
refund.failed退款终态失败(商户后台审批拒绝 / 14 天超时自动拒绝 / 渠道失败),资金未动refund_id、out_trade_no、out_refund_no、amount、fail_reason(rejected / expired / channel)、attach

attach 是下单时传的附加数据,三种事件都原样带回:不去空格、不转码,中文和符号都行,传了空串就回空串; 下单没传就没有这个键。它不经过支付渠道,所以没有 subject 那些字符限制。 订单关闭不推送回调,需要时用查单接口获取。收到 refund.failed 表示这笔退款不会到账, 处理方式见查单与退款。

多个网站共用一个商户号

一个商户号给两个以上的网站(产品线)收款很常见。两个站用的是同一套后端,就不用配多个回调地址: 区分靠打标——下单时把站点写进单据,回调原样带回来。打标有两处:

  1. out_trade_no 加站点前缀(A_、B_)。单号在商户号内必须唯一,两个站各自生成单号早晚会撞上 40901, 前缀是必须的;顺带三种回调事件都能靠它认出是哪个站的单
  2. attach 写站点标识(如 site=B)。三种回调都原样带回,后台、财务看流水一眼能认;要同时放用户 ID 之类的 内容也行,总长不超过 128 字符即可
Node.js
Python
Go
Java
const localOrderNo = '20260911000042'   // 站内自己的订单号
const order = await pay.create_order({
  out_trade_no: 'B_' + localOrderNo,    // 站点前缀:同一商户号下单号不能撞,回调也靠它认站
  amount: '100.00',
  subject: '积分充值100元',
  pay_mode: 'qrcode',
  attach: 'site=B',                     // 三种回调都原样带回,后台、财务看流水一眼能认
})

两个站是同一套后端时,到这里就够了:回调按 out_trade_no 查到本地订单,站点自然就知道了。

两个站是两套独立部署、各有各的库,首选按单指定回调地址:每个站下单时传自己的 notify_url,回调各收各的。不想动回调方式的话,也可以让收回调的那个站(下面叫 A 站)当个管子: 不是自己的单就原样转给 B 站——请求体和三个签名头一个字节不动,B 站用同一个密钥自己验签,A 站不需要理解报文。 A 站把 B 站的应答状态透传回去:B 站没收好就回非 2xx,网关按正常节奏重试,B 站等于借用了网关的重试机制。

Node.js(Express)
Python(FastAPI)
Go(net/http)
Java(Spring Boot)
const SITE_B_WEBHOOK = 'https://b.example.com/wantu/webhook'
const SIGN_HEADERS = ['x-wantu-timestamp', 'x-wantu-nonce', 'x-wantu-signature', 'content-type']

app.post('/wantu/webhook', express.raw({ type: '*/*' }), async (req, res) => {
  let event
  try {
    event = pay.verify_webhook(req.headers, req.body)
  } catch (err) {
    return res.sendStatus(400)
  }

  // B 站的单:请求体和签名头原样转过去,B 站自己验签;B 站没收好就回 502,网关会重试
  if (String(event.data.out_trade_no ?? '').startsWith('B_')) {
    const headers: Record<string, string> = {}
    for (const name of SIGN_HEADERS) {
      const value = req.headers[name]
      if (typeof value === 'string') headers[name] = value
    }
    try {
      const upstream = await fetch(SITE_B_WEBHOOK, {
        method: 'POST', headers, body: req.body, signal: AbortSignal.timeout(5000),
      })
      return res.sendStatus(upstream.ok ? 200 : 502)
    } catch {
      return res.sendStatus(502)
    }
  }

  // 下面是 A 站自己的入账逻辑,和「接收回调」一节一样
  res.sendStatus(200)
})

几点说明:

  • 转发要设超时(示例 5 秒):网关等单次投递最多 10 秒,别让 B 站慢把 A 站也拖超时
  • webhook.ping 测试事件的 data 里没有 out_trade_no,按上面的写法会走到 A 站自己的分支,返回 2xx 即可
  • 站点再多,把「前缀 → 地址」做成一张表就行
  • 两个站属于不同主体、钱要进不同的支付宝或银商账户,那是两个商户号,不是本节的场景

重试机制

回调返回非 2xx 或超时(支付成功那一刻的首投 3 秒,之后的重试 10 秒),网关按 1 分钟、5 分钟、10 分钟、30 分钟、1 小时、 3 小时、6 小时、12 小时的间隔重试,共 8 次。商户后台的测试回调也按 3 秒算,处理逻辑请先落库再异步做。

这意味着同一事件一定会重复送达,入账幂等不是可选项。全部重试失败的事件顽兔侧 会告警处理,商户也可以随时用查单接口核对状态。

验签规则

用 SDK 时不需要关心本节。自行验签的参考:

  • 请求头:X-Wantu-Timestamp(秒级时间戳)、X-Wantu-Nonce、X-Wantu-Signature
  • 签名串三行拼接:timestamp + "\n" + nonce + "\n" + 请求体原文
  • 算法:HMAC-SHA256,输出 hex 小写
  • 密钥:在商户后台「接入配置」启用了独立的 webhook_secret 就用它,否则用 api_secret; 两者都没有并存期,轮换 api_secret(未启用独立密钥时)或重新生成 webhook_secret 后,回调立即改用新密钥签名
  • 时间戳与当前时间相差超过 300 秒应拒绝
  • 网关投递不跟随跳转:地址返回 3xx 按失败重试,请直接配置最终地址