# webhook 验签

> docId：`XD-OP-05` ｜ version：`1.0.0` ｜ status：`PUBLISHED`

平台把支付终态推送到你在应用里配置的 `notifyUrl`。本篇给出验签、去重、响应与补偿的完整做法。

> `notifyUrl` 是**应用级配置**（在门户「应用配置变更」里提交，**不是下单请求体字段**——把它写进下单请求体会因严格模式被 `400` 拒绝）。提交时短信二次验证，平台自动校验后**即时生效**（无人工审核环节）。真实约束有四条：
>
> 1. 绝对 `https`，公网可达；不得带 query / fragment；path 不得为裸 `/`；
> 2. **host 必须属于你已申报的域名**（回调设计在哪个子域，就要先申报它）；域名归属验证是**可选**的自助加固、**不是生效判据**（见[接入指南 §2](./02-integration-guide.md#2-域名归属验证可选建议完成)）；
> 3. 提交需**短信二次验证**（只发往会话绑定手机号）；
> 4. **提交成功即新地址生效**（平台自动校验，无人工审核环节）；在途事件按**事件创建时冻结的地址**投递，改配置不会改投已经在途的事件。
>
> **未配置（或提交未通过校验）的应用不产生事件——注意是「不产生」而不是「产生了没投」**：事件不会落库、**事后无法补发**。所以不要用「先上线跑单、回头再补配置」的方式接入；这段时间的终态只能靠查单补齐。同理，平台侧的内部异常（极少数）也可能导致事件未产生——这就是查单补偿路径（[上线检查清单 D6](./11-go-live-checklist.md)）**必须接**的原因。

## 1. 投递形态

```http
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`。
- 验签用的**平台公钥**取自 [接入指南 · 平台公钥与指纹](./02-integration-guide.md#31-平台公钥与指纹公示)（与门户同源），按投递头里的 `X-XD-Webhook-Key-Id` 选择对应公钥。

## 2. 签名规范串（`XD-Webhook-v1`）

签名算法 **RSA-SHA256**（签名值 Base64）。待签名串按下面的顺序，用 `\n` 连接成 7 行：

```text
XD-Webhook-v1
<EVENT_ID>
<EVENT_TYPE>
<TIMESTAMP>
<NONCE>
<KEY_ID>
<SHA256_HEX(rawBody)>
```

- `SHA256_HEX(rawBody)`：对**收到的原始请求体字节**做 SHA-256，取小写十六进制。
- 六个取值全部来自投递头（`X-XD-Webhook-*`），不要从 body 里取。
- **不要复用请求签名的拼串逻辑**：三者（请求 / 响应 / 事件）是**三套不同的规范串**。

## 3. 验签步骤

```text
1. 取原始 body 字节（框架里通常是「未经 JSON 解析的 raw body」）
2. 按上节拼出 7 行规范串
3. 用平台公钥（RSA-SHA256 + Base64 签名）验签
4. 验签失败 → 丢弃并记异常（不要落账）
5. 验签通过 → 用 eventId 去重 → 业务处理 → 返回成功
```

参考实现：[`examples/node/verify_webhook.js`](../examples/node/verify_webhook.js)（**在公开仓与「下载接入包」内；不在「下载文档包」内**）。

> ⚠️ 最常见的踩坑：用了框架里**已被解析过的对象**重新序列化成字符串去算哈希。中文字段、字段顺序、空格差异都会让哈希不同。**必须从原始字节算**。

## 4. 你必须返回什么

- 成功：HTTP **2xx**，且响应体字节**正好**是 `success`（不带 JSON 包装、不带多余空白）。
- 其他任何情况都视为**投递失败**，平台会**退避重试**：同一事件**共投递 8 次**（**首次不等待退避间隔、由平台调度即时发出**〔秒级窗口，当前线上通常 **10 秒内**〕，其后按 1 分钟 / 5 分钟 / 15 分钟 / 1 小时 / 6 小时 / 24 小时 / 48 小时 退避）；第 8 次仍失败转入终止态。
- **终止态之后平台不会自动感知**：没有告警会提醒平台「某个商家漏了事件」，也没有商家侧的自助补推入口。请用[查单与日对账](./11-go-live-checklist.md)发现缺口，带 `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 参考 · 合同镜像](./03-api-reference.md#4-合同镜像机器校验勿手改)。

## 7. 幂等落账骨架

```text
收到投递
 ├─ 验签失败 → 记异常，丢弃（不回 success，让平台重试也别回：这是配置问题，需人工介入）
 ├─ eventId 已处理 → 回 success（幂等；真正的判重靠下一步事务内的唯一索引）
 ├─ 目标状态不允许迁移（按订单/退款**状态机**判，别看 stateVersion） → 回 success（丢弃旧事件）
 ├─ 业务提交（订单/退款状态迁移 + 你的交付动作）**与 eventId 去重行（唯一索引）同一事务**
 │    ├─ 去重行冲突（重复投递）→ 回滚 → 回 success
 │    ├─ 成功 → 提交 → 回 success
 │    └─ 失败 → 回滚（去重行一并回滚） → 返回非 2xx（让平台重试）
 └─ 已知会长期失败的 → 依赖人工补推或改为主动查单补偿
```

## 8. 与「回跳」的关系

收银页在买家点「返回商家」时把浏览器跳回 `returnUrl`——那是**给你做 UX 的**（展示「支付处理中/已完成」），**不是**终态通知，**也不是自动跳转**（买家不点、刷新过页面时不会回跳；按钮由收银页本次会话**首次观察到支付成功**时提供，**扫码支付（PC 出码 → 手机付款）不受此限**）。你的 `returnUrl` 落地页应当：只展示、只轮询自己的服务端，**不要**用 query 参数里的任何东西当支付结果，也**不要**在页面里做交付。

**微信内（JSAPI）支付**买家不经过收银页的「返回商家」按钮（完成页由微信接管），但仍可从**微信支付记录中的「商家小票」**点「返回商家」回到同一个 `returnUrl`（平台侧配置，无需商家开通）——同属导航，不是终态。详见[收银页对接 · 回跳](./06-cashier-integration.md)。

## 9. 本地怎么测

`demo/h5-cashier/` 的 mock 平台会真的按上面的规范串签名并投递事件，附带一个验签消费者示例，可直接对着改（`demo/` **只在公开仓提供；不在门户的两个下载包内**）：

```bash
node demo/h5-cashier/server.js
```

> ⚠️ **demo 只是 mock**：它的请求体不是生产请求体形状（生产形状以 [快速开始 §2](./01-quickstart.md) 的六个必填字段为准），它也不模拟通道闸门与来源 IP 等前置门；**mock 全绿 ≠ 生产可用**。
