Webhook 事件与验签
平台如何推送事件、每种事件何时发送、如何验签与去重、乱序与重试、密钥轮换、测试发送与补齐遗漏事件。
最后更新于 2026-10-15 · 契约 v1.0.0
概览
- 每个商户在每个环境(测试、生产)配置一个主端点,并选择要订阅的事件:配置 Webhook(在新标签页打开商户后台)(测试环境;需要所有者、管理员或开发者)。
- 平台以 HTTPS POST 把事件 JSON 发送到端点,并用签名密钥签名。签名密钥与 API Key 相互独立。
- 事件用于通知事实,不承诺推送所有内部状态:例如无需交互直接成功的支付可能没有
payment.requires_action。
投递语义
- 同一事件可能重复到达。
- 多个事件可能乱序到达。
- 按事件 ID 持久化去重。
- 按
data.object.version处理:旧版本不能覆盖新状态,冲突时调用 API 查询最新状态。
注意
不能按状态名排序:failed 之后付款人可能换卡重试并成功,所以 payment.failed 可能晚于 payment.succeeded 到达。
事件类型
| 事件 | 触发条件 | 你通常要做什么 | GET /v1/events 所需权限 |
|---|---|---|---|
| payment.requires_action | 支付进入待认证状态;无需交互而直接成功时可能不产生 | 一般无需处理(同步响应已给出 next_action);可用于监控 | payments:read |
| payment.succeeded | 首次确认支付成功 | 按 Payment ID 幂等履约 | payments:read |
| payment.failed | 某次尝试明确失败,载荷含失败的尝试号与当前 Payment 版本 | 不要立即关闭订单:付款人可能换卡重试;按 version 处理 | payments:read |
| payment.canceled | 支付已安全取消 | 关闭订单 | payments:read |
| payment.expired | 无在途尝试时到期关闭 | 关闭订单;再次收款需新的商户订单号 | payments:read |
| payment.corrected | 经可信证据和受控流程纠正异常历史状态(例如本地已过期的支付被核实为真实扣款成功) | 查询支付最新状态并按新状态处理(如补发货,或按业务发起退款);建议人工复核这类订单 | payments:read |
| refund.pending_review | 退款已受理,等待审核 | 记录 | refunds:read |
| refund.processing | 退款进入上游处理 | 记录 | refunds:read |
| refund.succeeded | 上游确认退款成功 | 更新订单退款状态 | refunds:read |
| refund.failed | 退款明确未成功 | 通知客服;如需再次退款,用新的商户退款号申请 | refunds:read |
| refund.rejected | 审核驳回,未提交上游 | 查看 failure.message,必要时重新申请 | refunds:read |
| refund.canceled | 提交上游前被平台取消 | 记录 | refunds:read |
| refund.returned | 曾成功的退款发生真实资金退回 | 查看 returned_amount;不要自动再退一次,先核实付款人是否收到退款,再按新的退款申请处理 | refunds:read |
| dispute.created | 争议案件建立 | 在商户后台争议页面准备并上传材料,留意证据截止时间 | payments:read |
| dispute.updated | 案件信息或状态变化 | 查看变化 | payments:read |
| dispute.closed | 结案 | 资金影响以结算、账务流水中的真实资金事件为准 | payments:read |
| settlement.created | 结算单生成并占用资金 | 对账 | finance:read |
| settlement.paid | 结算打款成功 | 对账,核对银行入账 | finance:read |
| settlement.failed | 结算打款明确失败 | 平台会重新生成并审批新的结算单,关注后续 settlement.created | finance:read |
| settlement.returned | 确认银行退汇 | 核对收款账户信息;平台不会自动用旧账户重新打款 | finance:read |
平台将来可能新增事件类型;收到不认识的类型时返回 2xx 并忽略。
- 平台异常退款(平台向持卡人原路退还重复扣款或金额不符多扣的款项)不发送
refund.*事件,也不出现在退款接口中;平台另以站内信和邮件通知你,这类退还不影响你的余额与可退金额。 - 开放 API 不提供争议资源:争议只能通过
dispute.*事件、GET /v1/events/{event_id}(需要payments:read)与商户后台查看。dispute.*事件的data.object是争议快照:
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | 必填 | 争议号(dsp_ 前缀)。 示例: |
payment_id | string | 必填 | 关联的支付号。 示例: |
merchant_order_no | string | 必填 | 关联的商户订单号。 示例: |
reason | string | 必填 | 争议原因(平台原因分类):fraudulent、product_not_received、product_unacceptable、duplicate、credit_not_processed、general; 接收方须容忍新增取值。 示例: |
amount | integer(int64) | 必填 | 争议金额,最小货币单位。 约束: 示例: |
currency | string | 必填 | 大写三字母币种代码。 约束: 示例: |
status | string | 必填 | 案件状态(04 §5.1)。
示例: |
funds_impact | string | 必填 | 资金影响(04 §5.2)。
示例: |
evidence_due_by | string(date-time)可为空 | 必填 | 证据截止时间,可为空。 示例: |
created_at | string(date-time) | 必填 | 建案时间。 示例: |
closed_at | string(date-time)可为空 | 必填 | 结案时间;未结案为 null。 示例: |
version | integer(int64) | 必填 | 每次有效变更递增,用于丢弃旧事件。 约束: 示例: |
事件结构
{
"id": "evt_demo_001",
"type": "payment.succeeded",
"api_version": "v1",
"created_at": "2026-09-23T09:05:00Z",
"environment": "test",
"merchant_id": "mer_demo_001",
"data": {
"object": {
"id": "pay_demo_001",
"merchant_order_no": "ORDER-DEMO-001",
"amount": 10000,
"currency": "USD",
"status": "succeeded",
"latest_attempt_id": "pat_demo_001",
"version": 4
}
}
}| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | 必填 | 事件 ID,与 X-Pay-Event-Id 相同。 示例: |
type | string | 必填 | 事件类型。事件用于通知事实,不承诺逐一推送所有内部状态;接收方须容忍未来新增的类型。
示例: |
api_version | string | 必填 | 载荷的 API 版本。 示例: |
created_at | string(date-time) | 必填 | 事件创建时间(业务时间),不能代替签名时间。 示例: |
environment | string | 必填 | 环境:test 为测试,live 为生产。接收方须校验环境。
示例: |
merchant_id | string | 必填 | 商户号。 示例: |
data | EventData | 必填 | 事件载荷。 |
object | object | 必填 | 事件发生时的业务对象快照(Payment、Refund、Dispute 或 Settlement),字段与商户 API 中对应资源一致,至少包含 id 与 version。 示例: |
- 事件 ID 与请求头
X-Pay-Event-Id相同,是去重键。 created_at是业务时间,不能代替签名时间判断时效。- 接收方必须校验
environment与merchant_id属于自己。 - 同一事件重试时,事件 ID 与请求体字节不变。事件只含最小必要数据,不含完整卡号、CVC、密钥或认证秘密。
data.object中的金额字段(如amount、returned_amount)都是最小货币单位整数,并与currency一起出现。
警告:金额是最小货币单位整数
USD 10000 表示 100.00 美元,JPY 10000 表示 10,000 日元,不能统一除以 100;不同币种的金额不能相加。详见数据格式。
请求头
| 请求头 | 示例 | 说明 |
|---|---|---|
| X-Pay-Event-Id | evt_demo_001 | 事件 ID,与载荷中的 id 相同;接收方据此持久化去重。 |
| X-Pay-Delivery-Id | del_demo_001 | 本次投递 ID;同一事件的每次投递都不同。 |
| X-Pay-Test-Event | true | 只在商户后台"发送测试事件"产生的投递中出现,值固定为 true。测试事件的对象 ID 为前缀加 26 个 0, 不对应任何真实对象;接收方不得据此履约,可据此跳过业务处理但仍应验签并返回 2xx。 |
| X-Pay-Signature | t=1790154300,v1=0867b244eee91cdcd27eb83756e1f133889a7faec94648c80d1a0298474e3b77 | 签名时间与 HMAC-SHA256 签名;密钥轮换重叠期内可含多个 v1。 |
X-Pay-Delivery-Id 每次投递都不同,只用于排查,不要用它去重。收到 X-Pay-Test-Event: true 时只记录、不履约。
签名协议
signed_payload = ASCII(t) + "." + 原始 HTTP 请求体字节
v1 = hex(HMAC-SHA256(webhook_secret, signed_payload)) // 小写十六进制,64 个字符t是本次投递的签名时间(UNIX 秒),每次重试都重新生成;所以旧事件的合法重发不会因业务时间较早被拒。- 校验
|当前时间 − t| ≤ 300 秒,双方服务器需保持时间同步(NTP)。 - 用常量时间比较签名(Node.js
crypto.timingSafeEqual、Pythonhmac.compare_digest、Gohmac.Equal、JavaMessageDigest.isEqual)。 - 头中有多个
v1时任一匹配即通过(密钥轮换重叠期)。 - 必须用原始请求体字节计算:先验签、再解析 JSON;不能先解析再重新序列化。
- HMAC 的密钥是签名密钥的完整字符串(含
whsec_前缀)的 UTF-8 字节,不做 Base64 解码,也不要去掉前缀。
验签步骤与示例代码
- 读取原始请求体字节(对 Webhook 路由关闭框架的自动 JSON 解析,或保留 raw body)。
- 解析
X-Pay-Signature,取出t与全部v1;格式不合法直接拒绝。 - 校验
t与本地时间相差不超过 300 秒,防重放。 - 用当前有效的签名密钥(轮换期为新旧两个)计算期望签名。
- 与每个
v1常量时间比较,任一匹配即通过;全部不匹配返回 400,不执行任何业务。 - 解析 JSON。
- 校验
environment与merchant_id属于你(测试端点只接受test)。 - 以事件 ID 持久化接收记录(如
INSERT … ON CONFLICT (event_id) DO NOTHING),已存在说明是重复投递。 - 持久化成功后尽快返回 2xx(5 秒内);持久化失败返回 5xx,让平台稍后重试。
- 在异步任务中处理业务:比较
data.object.version,需要时调用 API 查询最新状态。
验签函数:
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
// rawBody: Buffer(原始请求体字节);secrets: 当前有效的签名密钥数组(轮换期放新旧两个)
// 每个 secret 是完整的签名密钥字符串(含 whsec_ 前缀),按 UTF-8 字节作为 HMAC 密钥,不做 Base64 解码
export function verifySignature(
rawBody,
header,
secrets,
nowSeconds = Math.floor(Date.now() / 1000)
) {
if (typeof header !== 'string' || !Buffer.isBuffer(rawBody)) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i <= 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === 't') timestamp = value;
else if (key === 'v1') signatures.push(value);
}
if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
if (Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, 'ascii'), rawBody]);
for (const secret of secrets) {
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();
for (const sig of signatures) {
if (!/^[0-9a-f]{64}$/.test(sig)) continue; // 长度不等时 timingSafeEqual 会抛错,先校验格式
if (crypto.timingSafeEqual(expected, Buffer.from(sig, 'hex'))) return true;
}
}
return false;
}接收端:
import express from 'express';
import { verifySignature } from './verify.mjs';
const app = express();
// 只对 Webhook 路由使用 raw 解析,拿到原始字节;不要在它之前全局挂 express.json()
app.post(
'/webhooks/payhub',
express.raw({ type: 'application/json', limit: '1mb' }),
async (req, res) => {
const secrets = [
process.env.PAYHUB_WEBHOOK_SECRET,
process.env.PAYHUB_WEBHOOK_SECRET_PREVIOUS
].filter(Boolean);
if (!verifySignature(req.body, req.get('X-Pay-Signature'), secrets)) {
return res.status(400).send('invalid signature'); // 不执行任何业务
}
const event = JSON.parse(req.body.toString('utf8'));
if (
event.environment !== process.env.PAYHUB_ENVIRONMENT ||
event.merchant_id !== process.env.PAYHUB_MERCHANT_ID
) {
return res.status(400).send('unexpected environment or merchant');
}
if (req.get('X-Pay-Test-Event') === 'true') {
await recordTestEvent(event.id); // 测试事件只记录、不履约
return res.status(200).end();
}
let isNew;
try {
isNew = await saveEventIfAbsent(event.id, req.body); // INSERT ... ON CONFLICT (event_id) DO NOTHING
} catch {
return res.status(500).end(); // 没能持久化:返回非 2xx,让平台稍后重试
}
res.status(200).end(); // 持久化后尽快应答
if (isNew) enqueue(event.id).catch(() => {}); // 入队失败由定时任务扫描"已接收未处理"的事件补偿
}
);
app.listen(Number(process.env.PORT ?? 8080));其他语言的实现须满足同样的要求:在解码 JSON 前读取完整原始字节;用常量时间比较;支持多个 v1 与 300 秒容差;并通过下面的测试向量。
测试向量
用下面的输入验证你的验签实现(数值由文档构建时计算):
| 演示密钥(仅用于验证实现,不是任何环境的真实密钥;带 whsec_ 前缀) | whsec_demo_webhook_secret_for_docs_only |
| t | 1790154300(2026-09-23T09:05:00Z) |
| 原始请求体(330 字节,单行、无多余空格、无结尾换行) | {"id":"evt_demo_001","type":"payment.succeeded","api_version":"v1","created_at":"2026-09-23T09:05:00Z","environment":"test","merchant_id":"mer_demo_001","data":{"object":{"id":"pay_demo_001","merchant_order_no":"ORDER-DEMO-001","amount":10000,"currency":"USD","status":"succeeded","latest_attempt_id":"pat_demo_001","version":4}}} |
| 期望 v1 | 0867b244eee91cdcd27eb83756e1f133889a7faec94648c80d1a0298474e3b77 |
| 轮换演示:旧密钥 whsec_demo_webhook_secret_previous_docs_only 的签名 | 44a4429c71945a5c66e80b0a64196b84cade4adeafb454a11839c4afa34fb9f2 |
| 去掉 whsec_ 前缀计算得到(与期望不同,验签必须失败) | 825c83c31826be26c5e4b84f0e9f21c08b83acec8ac616a852531dd8b70a6bab |
你的实现应得到以下结果(“当前时间”固定在 1790154300 附近):
- 正确签名:通过
- 轮换期两个 v1,只配置新密钥:通过
- 改动请求体(amount 10000 → 1000):失败
- 请求体重新格式化为多行 JSON:失败
- 去掉 whsec_ 前缀计算:失败
- 超过 300 秒容差:失败
本地命令自查:
# body.json 保存测试向量的原始请求体(不要带结尾换行)
{ printf '%s.' "1790154300"; cat body.json; } | openssl dgst -sha256 -hmac "whsec_demo_webhook_secret_for_docs_only"
# 输出应以 0867b244eee91cdcd27eb83756e1f133889a7faec94648c80d1a0298474e3b77 结尾注意
验证测试向量时要把“当前时间”固定在 t 附近(示例函数的 nowSeconds / now 参数),否则会因超过 300 秒容差而失败。
接收方最佳实践
- 顺序固定:验签 → 校验环境与商户号 → 按事件 ID 持久化去重 → 尽快 2xx → 异步处理业务。
- 业务处理必须幂等:即使去重了,你的 2xx 也可能没送达平台,平台会重投同一事件。
- 不依赖事件顺序:比较
version,冲突或缺失时调用 API 查询最新状态。 - 不认识的事件类型返回 2xx 并记录,不要返回错误(否则平台会持续重试 48 小时)。
- 5 秒内应答;耗时的工作放进队列。
- 不要返回 3xx:
http→https跳转、补尾斜杠跳转、登录跳转都会被视为失败。 - Webhook 路由跳过登录与 CSRF 校验,凭签名鉴权;WAF 放行该路由。
- 测试环境与生产环境使用不同的端点与签名密钥,代码中按环境分别配置。
- 日志可以记录事件 ID、投递 ID、事件类型;不要记录签名密钥。
- 监控:在商户后台查看投递记录(事件投递记录(在新标签页打开商户后台)(需要所有者、管理员或开发者));投递最终失败时技术联系人与开发者会收到每小时汇总邮件。
乱序与版本比较
| 业务发生顺序 | 事件 | data.object.version | 实际到达顺序 | 正确处理 |
|---|---|---|---|---|
| 1 | payment.failed(尝试 1 被发卡行拒绝) | 3 | 第 2 个到达 | 本地已是 v5,3 < 5,忽略 |
| 2 | 付款人换卡重试,尝试 2 成功:payment.succeeded | 5 | 第 1 个到达 | 本地无记录或版本更低,应用并履约 |
// 按 version 处理乱序事件(伪代码,按你的数据库改写)
export async function applyPaymentEvent(db, event) {
const incoming = event.data.object;
await db.transaction(async (tx) => {
const local = await tx.findPaymentForUpdate(incoming.id); // 锁住本地记录
if (local && local.version >= incoming.version) return; // 重复或旧事件:忽略
await tx.savePayment({ id: incoming.id, status: incoming.status, version: incoming.version });
if (incoming.status === 'succeeded') await tx.enqueueFulfillment(incoming.id); // 履约按 Payment ID 幂等
});
}异步任务取到事件后的处理顺序:
重试计划
| 第几次 | 时点(距事件首次排队) |
|---|---|
| 1 | 立即 |
| 2 | 1 分钟 |
| 3 | 5 分钟 |
| 4 | 15 分钟 |
| 5 | 1 小时 |
| 6 | 6 小时 |
| 7 | 24 小时 |
| 8 | 48 小时 |
- 成功条件:单次 5 秒内收到 HTTP 2xx;超时视为本次投递失败,不代表业务失败。
- 任何非 2xx 都视为本次投递失败并按计划重试,包括 4xx:例如你的 WAF 返回 401 / 403,平台也会持续重试。
- 不跟随 3xx:返回重定向视为失败。
- 共 8 次(含首次)全部失败:进入失败投递列表并告警;之后可由平台人工重发——复用事件 ID,生成新的投递 ID 与签名,不会重做支付或记账。
- 每次投递的
X-Pay-Delivery-Id、t、签名都不同;事件 ID 与请求体不变。
投递记录中的汇总状态(事件投递记录(在新标签页打开商户后台)(需要所有者、管理员或开发者)):
- 事件在业务事务内写入:状态已经提交时,即使平台服务随后崩溃,通知也会继续发送。所以“支付成功了却没收到通知”时,先查投递记录,再按补齐遗漏事件处理。
- 同一业务事实只有一个事件 ID;多次投递不会产生多次业务效果。
端点地址要求
| 要求 | 说明 |
|---|---|
| HTTPS | 必须;证书须由公共 CA 签发且在有效期内(自签名或过期证书按投递失败处理) |
| 公网可达 | 不能指向内网、环回、链路本地、云元数据或其他受限地址;平台同时检查 IPv4、IPv6、DNS 解析结果与实际连接地址,防止 DNS 重绑定,配置或实际发送时被拦截 |
| 端口 | 只能是默认的 443,或 1024–65535;其他端口在配置时被拒绝 |
| 不重定向 | 发送器不跟随 3xx |
| 不支持自定义代理与秘密请求头 | 来源真实性只靠签名验证 |
| 每环境一个主端点 | 测试、生产各一个;在 Webhook 配置(在新标签页打开商户后台)(需要所有者、管理员或开发者) 中设置并选择订阅的事件 |
| 修改端点 | 需要二次认证并留审计;变更会通知所有者、管理员与技术联系人 |
签名密钥与轮换
- 签名密钥只在 Webhook 配置(在新标签页打开商户后台)(需要所有者、管理员或开发者) 中创建端点与轮换时一次性展示,之后不能再查看;请立即存入你的密钥管理服务,遗失时只能轮换。密钥与 API Key 无关,轮换 API Key 不影响 Webhook。
- 发起轮换(需二次认证)后,新密钥只显示一次,同时进入重叠期:平台同时用新旧密钥签名,
X-Pay-Signature带两个v1。 - 把新密钥加入你的配置(示例中的
secrets数组同时放新旧两个)并部署。 - 重叠期结束后旧密钥停用,只剩新签名;从配置中删除旧密钥。
- 重叠期最长 24 小时(默认 24 小时),发起轮换时可选择更早停用旧密钥,重叠期内也可以立即停用;怀疑泄漏时立即轮换并尽快停用旧密钥。
测试发送与本地调试
- 在商户后台选择事件类型“发送测试事件”到当前环境的端点(Webhook 配置(在新标签页打开商户后台)(测试环境;需要所有者、管理员或开发者));测试发送不会产生真实支付、退款或财务分录。
- 测试事件带请求头
X-Pay-Test-Event: true,用当前签名密钥正常签名(重叠期内同样双签);只投递 1 次,不进入自动重试,在投递记录中带“测试”标签。 - 测试事件的对象 ID 不对应平台中的真实对象,查询会得到 404;收到带
X-Pay-Test-Event: true的事件时只记录、不履约。 - 本地开发:平台不会访问内网或本机地址;通过公开测试端点或受控隧道把本地服务暴露为公网 HTTPS 地址。
- 发送后在 事件投递记录(在新标签页打开商户后台)(测试环境;需要所有者、管理员或开发者) 查看结果(HTTP 状态、耗时、错误摘要)。完整测试步骤见测试指南。
补齐遗漏事件
- 在 事件投递记录(在新标签页打开商户后台)(需要所有者、管理员或开发者) 中找到失败事件的 ID,调用 GET/v1/events/{event_id} 获取载荷(与投递时一致,事件 ID 不变)。所需权限按事件所属业务;事件不会自动清除,历史事件都可查询;不属于当前商户或环境的事件返回 404。
- 或直接查询对象最新状态(如
GET /v1/payments/{payment_id},或按status、created_gte、created_lt列表查询)完成对账。 - 需要重新投递时联系平台技术支持(人工重发复用事件 ID)。
故障排查
| 现象 | 可能原因 | 解决 |
|---|---|---|
| 签名总是不匹配 | 框架先解析 JSON 再序列化;用错密钥(测试 / 生产混用、轮换后仍用旧密钥);网关或中间件改写了请求体(解压、字符集转换、去空格);把 t 当毫秒 | 读取原始字节;核对环境与密钥;接入层不改写请求体;t 是秒 |
| 提示时间戳超出容差 | 服务器时钟不同步 | 启用 NTP |
| 投递记录显示返回 3xx | http → https 跳转、尾斜杠跳转、登录跳转 | 配置最终地址;Webhook 路由不做跳转 |
| 投递记录显示超时 | 在请求中同步处理业务 | 先持久化再返回 2xx,业务异步 |
| 投递记录显示地址被拦截 | 指向内网或受限地址;域名解析到私有地址 | 使用公网地址 |
| 同一事件被处理两次 | 没有按事件 ID 去重;你的 2xx 未送达平台导致重投 | 按事件 ID 持久化去重,业务幂等 |
| 订单状态被旧事件覆盖 | 按到达顺序处理 | 比较 version |
| 你的 WAF 返回 401 / 403 | WAF 拦截了平台请求 | 放行 Webhook 路由,凭签名鉴权 |