XD-OP-05 · DOC
webhook 验签
事件通知是支付终态的主路径。本篇给出 `XD-Webhook-v1` 的规范串、原始字节验签、`eventId` 去重与幂等落账的完整做法。
小豆支付开放平台是面向已有自有系统的商家的支付接入服务:通过服务器对服务器的接口,把支付宝与微信收款接进商家自己的系统,并以签名事件通知回传支付状态;资金由持牌支付公司收付结算。个人也能申请:主体类型选「个人」,身份证 + 本人银行卡即可完成商户进件。央行持牌机构清算,安全可靠的个人和企业支付接口。
平台把支付终态推送到你在应用里配置的 notifyUrl。本篇给出验签、去重、响应与补偿的完整做法。
notifyUrl是应用级配置(在门户「应用配置变更」里提交,不是下单请求体字段——把它写进下单请求体会因严格模式被400拒绝)。提交时短信二次验证,平台自动校验后即时生效(无人工审核环节)。真实约束有四条:
- 绝对
https,公网可达;不得带 query / fragment;path 不得为裸/;- host 必须属于你已申报的域名(回调设计在哪个子域,就要先申报它);域名归属验证是可选的自助加固、不是生效判据(见接入指南 §2);
- 提交需短信二次验证(只发往会话绑定手机号);
- 提交成功即新地址生效(平台自动校验,无人工审核环节);在途事件按事件创建时冻结的地址投递,改配置不会改投已经在途的事件。
未配置(或提交未通过校验)的应用不产生事件——注意是「不产生」而不是「产生了没投」:事件不会落库、事后无法补发。所以不要用「先上线跑单、回头再补配置」的方式接入;这段时间的终态只能靠查单补齐。同理,平台侧的内部异常(极少数)也可能导致事件未产生——这就是查单补偿路径(上线检查清单 D6)必须接的原因。
1. 投递形态
POST <你的 notifyUrl>
Content-Type: application/json
X-XD-Webhook-Signature: <Base64 签名值>
X-XD-Webhook-Key-Id: <平台密钥标识>
X-XD-Webhook-Timestamp: <秒级时间戳>
X-XD-Webhook-Nonce: <一次性随机串>
X-XD-Webhook-Event-Id: <事件唯一标识>
X-XD-Webhook-Event-Type: <事件类型>
{ …事件体… }- 事件体是平台侧的原始字节:重推时重签同字节——所以验签必须用收到的原始 body 字节,不能先解析再序列化。
- 投递头里没有
Authorization、没有你的凭证、没有买家的个人敏感信息。 X-XD-Webhook-Nonce是事件级的:同一事件重投会复用同一个 nonce(只刷新时间戳)。不要拿它做防重放判据——判定「重复投递」一律用eventId。- 验签用的平台公钥取自 接入指南 · 平台公钥与指纹(与门户同源),按投递头里的
X-XD-Webhook-Key-Id选择对应公钥。
2. 签名规范串(XD-Webhook-v1)
签名算法 RSA-SHA256(签名值 Base64)。待签名串按下面的顺序,用 \n 连接成 7 行:
XD-Webhook-v1
<EVENT_ID>
<EVENT_TYPE>
<TIMESTAMP>
<NONCE>
<KEY_ID>
<SHA256_HEX(rawBody)>SHA256_HEX(rawBody):对收到的原始请求体字节做 SHA-256,取小写十六进制。- 六个取值全部来自投递头(
X-XD-Webhook-*),不要从 body 里取。 - 不要复用请求签名的拼串逻辑:三者(请求 / 响应 / 事件)是三套不同的规范串。
3. 验签步骤
1. 取原始 body 字节(框架里通常是「未经 JSON 解析的 raw body」)
2. 按上节拼出 7 行规范串
3. 用平台公钥(RSA-SHA256 + Base64 签名)验签
4. 验签失败 → 丢弃并记异常(不要落账)
5. 验签通过 → 用 eventId 去重 → 业务处理 → 返回成功参考实现:undefined(在公开仓与「下载接入包」内;不在「下载文档包」内)。
⚠️ 最常见的踩坑:用了框架里已被解析过的对象重新序列化成字符串去算哈希。中文字段、字段顺序、空格差异都会让哈希不同。必须从原始字节算。
4. 你必须返回什么
- 成功:HTTP 2xx,且响应体字节正好是
success(不带 JSON 包装、不带多余空白)。 - 其他任何情况都视为投递失败,平台会退避重试:同一事件共投递 8 次(首次不等待退避间隔、由平台调度即时发出〔秒级窗口,当前线上通常 10 秒内〕,其后按 1 分钟 / 5 分钟 / 15 分钟 / 1 小时 / 6 小时 / 24 小时 / 48 小时 退避);第 8 次仍失败转入终止态。
- 终止态之后平台不会自动感知:没有告警会提醒平台「某个商家漏了事件」,也没有商家侧的自助补推入口。请用查单与日对账发现缺口,带
outTradeNo/outRefundNo走官网反馈渠道申请人工补推(事件行按平台保留期清理,过期就无法再补)。 - 投递超时 10 秒;你的响应体超过 64KB 也按失败计;平台不跟随 3xx 重定向(请让
notifyUrl直接返回结果,不要 302 到别处)。 - 回执必须同步给出:在你的处理函数里落库并立即返回,不要在返回之后异步处理。
- 因此:先落库去重、再返回成功;不要在返回成功之后再异步处理(进程一旦退出事件就丢了)。
- 处理耗时较长时:先持久化事件、立即回
success,再由你自己的任务异步消费。
5. 幂等与乱序
eventId是幂等键:同一笔终态的多条路径会收敛到同一个eventId。请对它建唯一索引,重复投递直接丢弃并回success。- 可能乱序:不要假设事件按时间到达。请用订单/退款状态机判「当前状态是否允许迁移到目标状态」(例如已
SUCCEEDED的单不要再被旧的关闭事件改回去)。事件体里的stateVersion字段当前恒为0(保留字段,平台尚未逐次递增),不要用它判新旧——用它做「比大小」会把后续事件全部当成旧事件丢掉。 PAYMENT_CLOSED/PAYMENT_FAILED不是终点:支付机构随后确认到账时,平台会把订单提升为SUCCEEDED并推送PAYMENT_SUCCEEDED。状态机里不要把这两个状态建成吸收态。- 可能重复:重试与人工补推都会造成同一事件多次到达,去重必须覆盖这两条来源。
6. 事件类型与事件体
eventType | 何时产生 |
|---|---|
PAYMENT_SUCCEEDED | 订单支付成功(终态) |
PAYMENT_CLOSED | 订单关闭(过期/商家关闭/支付机构关闭) |
PAYMENT_FAILED | 支付机构明确回读确认未支付并进入失败态时 |
REFUND_SUCCEEDED | 退款成功 |
REFUND_FAILED | 退款失败 |
事件体字段:eventId、eventType、appId、outTradeNo、outRefundNo(可空)、eventAmountFen、grossAmountFen、status、reason(仅订单封闭词表;支付机构内部原因永不出现在事件体)、occurredAt(UTC epoch 秒)、stateVersion、attach(你下单时的附加数据原样回带,≤256 字符、首尾空白会被去掉)、feeProjection。
status是「事件主体」自己的状态,不是订单状态:订单事件里它是订单状态(SUCCEEDED/CLOSED/FAILED),退款事件里它是退款状态(SUCCEEDED/FAILED)。不要拿订单表去解释退款事件的status——那会把一笔已支付的订单误标成失败。
两个金额字段别用混:
grossAmountFen恒为关联订单总额;eventAmountFen是本事件对应的金额——订单事件里只有PAYMENT_SUCCEEDED才有实付金额,关闭/失败事件恒为0;退款事件里是退款金额。要按事件落账,请以grossAmountFen(订单总额)为准、eventAmountFen只在该事件确实发生资金变动时使用。
reason是订单关闭原因的子集:事件里只会出现EXPIRED/MERCHANT_CLOSED/PROVIDER_CLOSED三个值。CLOSE_REQUESTED是关单请求的中间态(「已受理」),不会出现在事件体里——关单真正生效时产生的事件是PAYMENT_CLOSED。其余事件类型的reason为空,请对空值做兼容。
字段名与枚举的机器镜像见 API 参考 · 合同镜像。
7. 幂等落账骨架
收到投递
├─ 验签失败 → 记异常,丢弃(不回 success,让平台重试也别回:这是配置问题,需人工介入)
├─ eventId 已处理 → 回 success(幂等;真正的判重靠下一步事务内的唯一索引)
├─ 目标状态不允许迁移(按订单/退款**状态机**判,别看 stateVersion) → 回 success(丢弃旧事件)
├─ 业务提交(订单/退款状态迁移 + 你的交付动作)**与 eventId 去重行(唯一索引)同一事务**
│ ├─ 去重行冲突(重复投递)→ 回滚 → 回 success
│ ├─ 成功 → 提交 → 回 success
│ └─ 失败 → 回滚(去重行一并回滚) → 返回非 2xx(让平台重试)
└─ 已知会长期失败的 → 依赖人工补推或改为主动查单补偿8. 与「回跳」的关系
收银页在买家点「返回商家」时把浏览器跳回 returnUrl——那是给你做 UX 的(展示「支付处理中/已完成」),不是终态通知,也不是自动跳转(买家不点、刷新过页面时不会回跳;按钮由收银页本次会话首次观察到支付成功时提供,扫码支付(PC 出码 → 手机付款)不受此限)。你的 returnUrl 落地页应当:只展示、只轮询自己的服务端,不要用 query 参数里的任何东西当支付结果,也不要在页面里做交付。
微信内(JSAPI)支付买家不经过收银页的「返回商家」按钮(完成页由微信接管),但仍可从微信支付记录中的「商家小票」点「返回商家」回到同一个 returnUrl(平台侧配置,无需商家开通)——同属导航,不是终态。详见收银页对接 · 回跳。
9. 本地怎么测
demo/h5-cashier/ 的 mock 平台会真的按上面的规范串签名并投递事件,附带一个验签消费者示例,可直接对着改(demo/ 只在公开仓提供;不在门户的两个下载包内):
node demo/h5-cashier/server.js⚠️ demo 只是 mock:它的请求体不是生产请求体形状(生产形状以 快速开始 §2 的六个必填字段为准),它也不模拟通道闸门与来源 IP 等前置门;mock 全绿 ≠ 生产可用。
open-platform/docs/05-webhook-verification.md · 最后更新 2026-10-12
下载本文 .md
在 GitHub 查看