跳转到内容

Webhook

查看 .md

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

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

  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。

使用认证与签名中同一组测试密钥(公钥 A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=):

  • X-Timestamp:1767225600
  • X-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==

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

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 验签函数
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 参考,各事件的载荷见 Webhook 事件目录。