# Webhook

Webhook 用于及时通知状态变化，但它不是唯一事实来源。PSP 会向你配置的 HTTPS 回调地址发送 `POST` 请求；你的系统必须能处理**重复、乱序、延迟和暂时丢失**的事件，并用 Query API 主动对账。

> **自动重试窗口很短**
>
当前每个事件最多自动重试 5 次，退避间隔依次为 1、2、4、8、16 秒——也就是说，接收端持续不可用超过约半分钟，事件就会停止自动重试。你必须有定时对账任务，并在后台监控投递失败。

## 接入步骤

1. **准备接收端地址。** 公网可访问的 HTTPS 地址，要求见下文[回调地址要求](#回调地址要求)。
2. **配置回调地址。** 通过商户后台（Merchant Portal）为账户设置回调 URL，首次配置会自动生成签名密钥，见[配置与运维接口](#配置与运维接口)。
3. **保存 PSP 公钥。** 读取账户 Webhook 配置，保存返回的 `key_id` 与 `public_key`；接收端按 `X-Webhook-Key-ID` 选择公钥。
4. **实现接收端。** 读取原始 body → 验签 → 按事件 ID 去重 → 持久化并入队 → 返回 `2xx`。可直接参考[接收端示例](#接收端示例)。
5. **用测试向量自检验签。** 目前没有“发送测试事件”的接口，联调时请在测试环境创建订单，由真实事件触发。
6. **上线前。** 完成[上线检查清单](https://docs.potabank.com/guide/go-live.md)中的 Webhook 与对账项。

## 请求格式

每次投递都是 `POST`，`Content-Type: application/json`，`User-Agent: PSP-Webhook/1.0`，并携带以下 Header：

| Header | 说明 |
|---|---|
| `X-Timestamp` | 本次发送时的 Unix 秒级时间戳。**每次重试都会变** |
| `X-Signature` | Ed25519 签名，标准 Base64。**每次重试都会变** |
| `X-Signature-Algorithm` | 固定为 `ed25519` |
| `X-Webhook-Key-ID` | 签名所用密钥的 ID（形如 `whk_…`），用它选择对应的 PSP 公钥 |
| `X-Webhook-Version` | Header 契约版本，当前固定为 `2026-06-11` |
| `X-Webhook-ID` | 投递 ID（UUID）。首次投递、自动重试和商户手动重发都不变 |
| `X-Webhook-Event` | 事件类型，如 `payment.intent.completed` |
| `X-Webhook-Event-ID` | 事件 ID（UUID）。**推荐的去重键**：自动重试、手动重发和运营重投都保持不变 |
| `X-Merchant-Account-ID` | 事件所属的商户账户 ID |

你可能还会看到上表之外的 `X-Webhook-*` Header（例如单次尝试标识），它们不属于接口契约，请不要依赖。

请求体是统一的信封：

```json
{
  "event": "payment.intent.completed",
  "timestamp": "2026-01-01T00:00:00Z",
  "data": { }
}
```

`data` 的内容因事件而异，完整清单与载荷示例见 [Webhook 事件目录](https://docs.potabank.com/guide/webhook-events.md)；机器可读的信封契约见 [API 参考](https://docs.potabank.com/reference/merchant-webhook.md)。

## 推荐处理顺序

1. 读取原始请求 body。
2. 校验 PSP 签名和时间戳；不要对解析后重新序列化的 JSON 验签。
3. 使用 `X-Webhook-Event-ID` 做持久化去重。
4. 在本地事务内记录事件并提交待处理任务。
5. 尽快返回 `2xx`，把耗时业务处理放到异步消费者。
6. 状态跳跃或数据冲突时调用 Query API 校准。

## 验签算法

PSP 在每次投递中携带以下 Header：

| Header | 说明 |
|---|---|
| `X-Timestamp` | Unix 秒级时间戳 |
| `X-Signature` | Ed25519 签名，标准 Base64 |
| `X-Signature-Algorithm` | 当前为 `ed25519` |
| `X-Webhook-Key-ID` | 签名所用密钥的 ID，用它选择对应的 PSP 公钥 |

验签步骤：

1. 取原始请求 body 字节，**不要**解析后重新序列化。
2. 待验证消息 = `X-Timestamp` 的字符串 + 一个 `.` + 原始 body 字节，例如 `1767225600.{"event":...}`。
3. 对 `X-Signature` 做标准 Base64（带 `=` 填充）解码，结果应为 64 字节，用 `X-Webhook-Key-ID` 对应的 PSP 公钥做 Ed25519 验证。
4. 检查 `X-Timestamp` 与当前时间的偏差；建议与请求侧一致，使用 ±60 秒窗口，并用事件 ID 去重以抵御重放。

PSP 公钥与 `key_id` 通过账户 Webhook 配置获取（字段 `key_id`、`public_key`）；PSP 轮换签名密钥后 `X-Webhook-Key-ID` 会随之变化，请按 Key ID 选择公钥，而不是写死一把。生产环境的回调地址应使用 HTTPS。

### 测试向量

使用[认证与签名](https://docs.potabank.com/guide/authentication.md)中同一组测试密钥（公钥 `A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=`）：

- `X-Timestamp`：`1767225600`
- `X-Webhook-Key-ID`：`whk_test_vector`（示例值，仅用于接收端示例）
- 原始 body（一行，无尾部换行）：

```json
{"event":"payment.intent.expired","timestamp":"2026-01-01T00:00:00Z","data":{"account_id":"019ba648-1d00-7000-8000-000000000003","classification":{"classification_status":"resolved","payment_category":"bank_transfer","payment_method":"pix","rail_variant":"pix_qr"},"classification_schema_version":1,"expired_at":"2026-01-01T00:00:00Z","intent_id":"019ba648-1d00-7000-8000-000000000001","merchant_id":"019ba648-1d00-7000-8000-000000000002"}}
```

- 待验证消息为 `1767225600.` 后紧接上面的 body（中间无空格、无换行）。
- 期望的 `X-Signature`：

```text
loq3l3e4MFbVYT+TOsjlCFltloDnqL/kEpW0Kl3Eo9Eu3Vo9TPJWRYHBjX+jeE+WJVwM/vXL9GOi9UTih5S7DA==
```

## 接收端示例

两个示例都只用标准库，演示“验签 → 去重 → 快速返回”。示例中的去重集合是内存实现，生产环境请改为数据库唯一约束。构建本站时会用文档里的测试向量实际运行这些代码。

```js title="Node.js 接收端"
import http from 'node:http';
import crypto from 'node:crypto';

const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
// key_id -> 32 字节原始公钥的 Base64（来自账户 Webhook 配置；轮换期间请同时保留新旧两把）
const PUBLIC_KEYS = new Map([[process.env.PSP_WEBHOOK_KEY_ID, process.env.PSP_WEBHOOK_PUBLIC_KEY]]);
const seen = new Set();

export function verify({ keyId, timestamp, signature, rawBody }) {
  const publicKey = PUBLIC_KEYS.get(keyId);
  if (!publicKey || !timestamp || !signature) return false;
  const key = crypto.createPublicKey({
    key: Buffer.concat([SPKI_PREFIX, Buffer.from(publicKey, 'base64')]),
    format: 'der',
    type: 'spki',
  });
  const message = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
  return crypto.verify(null, message, key, Buffer.from(signature, 'base64'));
}

export async function webhookHandler(req, res) {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const rawBody = Buffer.concat(chunks); // 必须使用原始字节，不要先 JSON.parse 再序列化
  const h = req.headers;

  const fresh = Math.abs(Date.now() / 1000 - Number(h['x-timestamp'])) <= 60;
  if (!fresh || !verify({ keyId: h['x-webhook-key-id'], timestamp: h['x-timestamp'], signature: h['x-signature'], rawBody })) {
    res.writeHead(401).end(); // 验签失败：4xx 不会被 PSP 自动重试
    return;
  }

  const eventId = h['x-webhook-event-id'];
  if (!seen.has(eventId)) {
    seen.add(eventId);
    // 在这里把 rawBody 持久化并投递到异步队列，不要在请求线程里做耗时业务
  }
  res.writeHead(200).end(); // 重复事件同样返回成功
}

http.createServer(webhookHandler).listen(8080);
```

```go title="Go 验签函数"
package webhook

import (
	"crypto/ed25519"
	"encoding/base64"
)

// VerifyWebhook 校验 PSP 的 Webhook 签名。
// publicKeyB64 是账户 Webhook 配置返回的 public_key（32 字节原始公钥的 Base64），
// timestamp 与 signatureB64 分别取自 X-Timestamp 与 X-Signature，rawBody 是未经任何处理的请求体字节。
func VerifyWebhook(publicKeyB64, timestamp, signatureB64 string, rawBody []byte) bool {
	publicKey, err := base64.StdEncoding.DecodeString(publicKeyB64)
	if err != nil || len(publicKey) != ed25519.PublicKeySize {
		return false
	}
	signature, err := base64.StdEncoding.DecodeString(signatureB64)
	if err != nil || len(signature) != ed25519.SignatureSize {
		return false
	}
	message := append([]byte(timestamp+"."), rawBody...)
	return ed25519.Verify(ed25519.PublicKey(publicKey), message, signature)
}
```

## 投递与重试

**成功判定。** 只有 `2xx` 响应算投递成功。请在 **10 秒内返回响应头**，整体不要超过 30 秒。不要用重定向（`3xx`）：PSP 虽会跟随，但每一跳都要重新通过地址校验，且 `POST` 可能被改写为 `GET`。

**自动重试。** 失败后当前最多自动重试 5 次（含首次共 6 次尝试），第 n 次失败后等待 `min(1s × 2^(n-1), 60s)`，即 1、2、4、8、16 秒。响应带有 `Retry-After` 时（秒数或 HTTP 日期）改用该值，上限 60 秒。

| 情况 | 是否自动重试 |
|---|---|
| 网络错误、超时、连接失败 | 是 |
| `408`、`425`、`429`、所有 `5xx` | 是 |
| 其他 `4xx`、所有 `3xx` | **否** |
| 地址校验失败、回调配置已停用或删除 | 否 |

重试时请求体与 `X-Webhook-ID`、`X-Webhook-Event-ID`、`X-Webhook-Key-ID` 不变，`X-Timestamp` 与 `X-Signature` 每次重新生成。

**重试耗尽之后。** 投递状态变为 `failed`，不会再自动发送，也没有面向商户的告警。你可以：

- 在商户后台或通过[接口](#配置与运维接口)**手动重发**该次投递（`X-Webhook-ID` 与 `X-Webhook-Event-ID` 保持不变）；
- 或由你的对账任务通过 Query API 补齐状态。

**投递保证。** 至少一次，**可能重复，不保证顺序**。重复的来源包括：你的响应超时但实际已处理、手动重发、运营重投。请以 `X-Webhook-Event-ID` 做幂等，并以 payload 中的状态与时间、或回查订单为准处理乱序。

不要用 `X-Timestamp` 或 `X-Signature` 去重，它们每次尝试都不同。

## 回调地址要求

- 必须是 **HTTPS**，端口只能是 **443**。
- 主机必须是域名：不能是 `localhost` 或任何 IP 地址，URL 中不能带用户名密码。
- 域名解析出的**所有** IP 必须是公网地址；解析到内网、回环、链路本地、云元数据等地址会被拒绝，且每次发送前都会重新校验。
- 证书必须是受信任机构签发的有效证书（TLS 1.2 及以上）。
- URL 存储列长为 500 个字符，更长的地址无法保存；保存时会去掉末尾的 `/`。
- 可设置 `allowed_domains` 限制回调主机（精确域名、`*.example.com` 仅子域、`.example.com` 含根域和子域）；设置后回调地址及每一跳重定向都必须命中。
- 可配置自定义 Header（例如网关鉴权），但不能覆盖 `Content-Type`、`User-Agent`、签名相关和 `X-Webhook-*`、`X-Merchant-Account-ID`。
- 事件产生时，回调 URL 就已固定到该次投递上：之后修改 URL，**已产生的投递重试仍发往旧地址**，新事件才使用新地址。

## 配置与运维接口

以下接口属于商户后台（Merchant Portal）API，使用后台登录凭证和相应权限调用，**不是** Gateway 的 API Key + Ed25519 签名。路径前缀为 `/api/v1`。

| 操作 | 方法与路径 | 说明 |
|---|---|---|
| 设置回调 | `PUT /accounts/{account_id}/webhook` | 请求体：`url`（必填，HTTPS）、`allowed_domains`、`headers`、`enabled`、`event_type`（留空或 `*` 表示默认配置）。首次配置自动生成签名密钥 |
| 查询配置 | `GET /accounts/{account_id}/webhook` | 返回 `url`、`enabled`，以及 `key_id`、`public_key`、`public_key_fingerprint`、`signature_algorithm` |
| 列出配置 | `GET /accounts/{account_id}/webhooks` | 按事件列出配置 |
| 启用 / 停用 | `POST /accounts/{account_id}/webhook/enable`、`/disable` | 停用后不再产生新投递，在途的重试会终止 |
| 删除配置 | `DELETE /accounts/{account_id}/webhook` | 返回 `204` |
| 投递历史 | `GET /accounts/{account_id}/webhook/deliveries` | 分页：`limit`（1–100，默认 20）、`offset` |
| 手动重发 | `POST /accounts/{account_id}/webhook/deliveries/{delivery_id}/retry` | 重发同一投递；已成功的投递也可再次发送 |
| 准备新密钥 | `POST /accounts/{account_id}/webhook/signing-keys/prepare` | 生成下一把密钥，只返回公钥信息 |
| 启用新密钥 | `POST /accounts/{account_id}/webhook/signing-keys/activate` | 未先 prepare 时返回 `409 WEBHOOK_SIGNING_KEY_NOT_PREPARED` |

私钥由 PSP 生成并加密保存，任何接口都不会返回私钥。`public_key` 是 32 字节原始 Ed25519 公钥的标准 Base64。

## 密钥轮换

1. 调用 `signing-keys/prepare`，得到新的 `key_id` 与 `public_key`。
2. 把新公钥部署到接收端，**与旧公钥并存**。
3. 调用 `signing-keys/activate`。此后新事件使用新密钥签名。
4. 旧密钥在启用新密钥后仍保留 24 小时，仍在重试中的旧投递继续用旧 `X-Webhook-Key-ID` 签名。接收端应保留新旧两把公钥至少 24 小时，再移除旧公钥。

## 常见问题

| 现象 | 常见原因 |
|---|---|
| 验签总是失败 | 对解析后重新序列化的 JSON 验签；消息没有以 `X-Timestamp` 加 `.` 开头；没有按 `X-Webhook-Key-ID` 选公钥（例如轮换后只留了旧公钥）；Base64 解码错误 |
| 收到重复事件 | 属于正常的至少一次投递；用 `X-Webhook-Event-ID` 去重并返回 `2xx` |
| 事件顺序和业务顺序不一致 | 不保证顺序；以事件内状态为准，必要时回查订单 |
| 长时间没收到某个事件 | 重试窗口已耗尽：查看投递历史并手动重发，或由对账任务补齐 |
| 回调配置保存失败 | 地址不是 HTTPS/443、使用了 IP 或内网域名、URL 超过 500 个字符（存储列长度限制） |

## 状态机原则

- 不依赖事件到达顺序。
- 终态不可被较早的非终态事件回退。
- 同一事件重复投递应返回成功，而不是再次执行业务副作用。
- 金额、币种、商户订单号和账户必须与本地订单匹配后才能入账。

## 支付分类

代收、代付及信用卡生命周期事件的 `data.classification` 只包含创建时固化的
`classification_status`、`payment_category`、`payment_method` 和 `rail_variant`。
它不包含 runtime taxonomy version；目录 tuple 后续变为 `inactive` 只阻止新订单，
不会改写、取消或中断已接受订单。`classification_schema_version` 是 Webhook payload
结构版本，仍用于事件合同治理，与 taxonomy 无关。

## 响应时间

验签、持久化并入队后立即返回成功。不要在 Webhook 请求线程中调用下游发货、短信、邮件或长事务。

## 失败恢复

持续失败时应通过告警发现，并由定时对账任务使用 Query API 补齐状态。完整的重试规则见上文[投递与重试](#投递与重试)，信封契约见[商户 Webhook API 参考](https://docs.potabank.com/reference/merchant-webhook.md)，各事件的载荷见 [Webhook 事件目录](https://docs.potabank.com/guide/webhook-events.md)。
