Webhook
Webhook 用于及时通知状态变化,但它不是唯一事实来源。PSP 会向你配置的 HTTPS 回调地址发送 POST 请求;你的系统必须能处理重复、乱序、延迟和暂时丢失的事件,并用 Query API 主动对账。
- 准备接收端地址。 公网可访问的 HTTPS 地址,要求见下文回调地址要求。
- 配置回调地址。 通过商户后台(Merchant Portal)为账户设置回调 URL,首次配置会自动生成签名密钥,见配置与运维接口。
- 保存 PSP 公钥。 读取账户 Webhook 配置,保存返回的
key_id与public_key;接收端按X-Webhook-Key-ID选择公钥。 - 实现接收端。 读取原始 body → 验签 → 按事件 ID 去重 → 持久化并入队 → 返回
2xx。可直接参考接收端示例。 - 用测试向量自检验签。 目前没有“发送测试事件”的接口,联调时请在测试环境创建订单,由真实事件触发。
- 上线前。 完成上线检查清单中的 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(例如单次尝试标识),它们不属于接口契约,请不要依赖。
请求体是统一的信封:
{ "event": "payment.intent.completed", "timestamp": "2026-01-01T00:00:00Z", "data": { }}data 的内容因事件而异,完整清单与载荷示例见 Webhook 事件目录;机器可读的信封契约见 API 参考。
推荐处理顺序
Section titled “推荐处理顺序”- 读取原始请求 body。
- 校验 PSP 签名和时间戳;不要对解析后重新序列化的 JSON 验签。
- 使用
X-Webhook-Event-ID做持久化去重。 - 在本地事务内记录事件并提交待处理任务。
- 尽快返回
2xx,把耗时业务处理放到异步消费者。 - 状态跳跃或数据冲突时调用 Query API 校准。
PSP 在每次投递中携带以下 Header:
| Header | 说明 |
|---|---|
X-Timestamp |
Unix 秒级时间戳 |
X-Signature |
Ed25519 签名,标准 Base64 |
X-Signature-Algorithm |
当前为 ed25519 |
X-Webhook-Key-ID |
签名所用密钥的 ID,用它选择对应的 PSP 公钥 |
验签步骤:
- 取原始请求 body 字节,不要解析后重新序列化。
- 待验证消息 =
X-Timestamp的字符串 + 一个.+ 原始 body 字节,例如1767225600.{"event":...}。 - 对
X-Signature做标准 Base64(带=填充)解码,结果应为 64 字节,用X-Webhook-Key-ID对应的 PSP 公钥做 Ed25519 验证。 - 检查
X-Timestamp与当前时间的偏差;建议与请求侧一致,使用 ±60 秒窗口,并用事件 ID 去重以抵御重放。
PSP 公钥与 key_id 通过账户 Webhook 配置获取(字段 key_id、public_key);PSP 轮换签名密钥后 X-Webhook-Key-ID 会随之变化,请按 Key ID 选择公钥,而不是写死一把。生产环境的回调地址应使用 HTTPS。
使用认证与签名中同一组测试密钥(公钥 A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=):
X-Timestamp:1767225600X-Webhook-Key-ID:whk_test_vector(示例值,仅用于接收端示例)- 原始 body(一行,无尾部换行):
{"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:
loq3l3e4MFbVYT+TOsjlCFltloDnqL/kEpW0Kl3Eo9Eu3Vo9TPJWRYHBjX+jeE+WJVwM/vXL9GOi9UTih5S7DA==两个示例都只用标准库,演示“验签 → 去重 → 快速返回”。示例中的去重集合是内存实现,生产环境请改为数据库唯一约束。构建本站时会用文档里的测试向量实际运行这些代码。
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);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 去重,它们每次尝试都不同。
回调地址要求
Section titled “回调地址要求”- 必须是 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,已产生的投递重试仍发往旧地址,新事件才使用新地址。
配置与运维接口
Section titled “配置与运维接口”以下接口属于商户后台(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。
- 调用
signing-keys/prepare,得到新的key_id与public_key。 - 把新公钥部署到接收端,与旧公钥并存。
- 调用
signing-keys/activate。此后新事件使用新密钥签名。 - 旧密钥在启用新密钥后仍保留 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 参考,各事件的载荷见 Webhook 事件目录。