事件类型、HMAC-SHA256 验签、密钥轮换、幂等与失败重投。
账户上发生的每一件事都会记录成一个事件,再由 KukoPay 主动推送到你配置的地址。事件是既成事实,推送只是告诉你的一次尝试——这个区分很重要,它意味着即使推送全部失败,事件也不会丢,可以从 事件接口 补回来。
| 事件 | 含义 |
|---|---|
payment.succeeded | 支付成功、资金已入钱包,可以履约 |
payment.failed | 支付被通道判定为终态失败,不会再成功 |
payment.expired | 超过 24 小时无人支付,订单已关闭 |
payment.duplicate_refunded | 订单关闭后又收到买家付款,已全额退回买家,不影响商户余额(见 支付流程) |
refund.succeeded | 退款已真实扣账完成 |
refund.failed | 退款被通道拒绝,冻结资金已退回可用余额 |
dispute.created | 买家发起拒付,争议金额已被冻结 |
dispute.updated | 拒付进入申诉审理阶段 |
dispute.won | 申诉成功,冻结资金已退回 |
dispute.lost | 申诉失败,争议金额已扣账 |
dispute.closed | 发卡行撤销拒付,冻结资金已退回 |
只订阅成功事件是常见的坑:失败和过期事件不推送时,你只能靠轮询才能发现一笔订单已经死了。
data.object 与对应查询接口返回的对象完全一致,所以一套解析代码同时服务于 Webhook 和主动查询。
{
"object": "event",
"id": "evt_3f2a9c7e1b4d0568a2cf",
"type": "payment.succeeded",
"mode": "live",
"created_at": "2026-09-21T08:31:12.114Z",
"data": {
"object": {
"object": "order",
"trade_no": "TRD_9F3A2C7E...",
"out_trade_no": "ORD_20260921_0001",
"amount": 2990,
"fee": 135,
"net_amount": 2855,
"currency": "USD",
"status": "paid",
"payment_method": "card",
"refundable_until": null,
"metadata": { "customer_id": "cus_42" },
"paid_at": "2026-09-21T08:31:10.882Z"
}
}
}顶层的 event_id、event_type 以及 data 里与 data.object 平级的那些扁平字段属于旧版结构,仅为兼容保留,新代码请一律读 id、type 和 data.object。
X-KukoPay-Event-Id: evt_3f2a9c7e1b4d0568a2cf
X-KukoPay-Event-Type: payment.succeeded
X-KukoPay-Endpoint-Id: whe_8c2f41ab9d0e7635
X-KukoPay-Signature: t=1758268800,v1=9c1f...X-KukoPay-Endpoint-Id 只在事件发往你配置的端点时出现;使用订单级 notify_url 时没有这个头。
签名内容是 HMAC-SHA256(secret, "{t}.{原始请求体}") 的十六进制结果。必须使用原始请求字节验签,不要先解析 JSON 再重新序列化。
X-KukoPay-Signature 里可能出现多个 v1=(密钥轮换窗口期内新旧密钥各签一次)。
只要有任意一个匹配就是合法请求。用 Object.fromEntries 之类的写法解析会只保留最后一个,导致轮换期间验签全部失败。
import crypto from "crypto"
const TOLERANCE_SECONDS = 300
export function verifyKukoPay(rawBody, signatureHeader, secret) {
// 头里可能有多个 v1=,逐个收集
const parts = signatureHeader.split(",").map((item) => {
const index = item.indexOf("=")
return [item.slice(0, index).trim(), item.slice(index + 1).trim()]
})
const timestamp = parts.find(([key]) => key === "t")?.[1]
const received = parts.filter(([key]) => key === "v1").map(([, value]) => value)
if (!timestamp || received.length === 0) return false
// 拒绝过旧的时间戳,避免签名被重放
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return false
}
const expected = Buffer.from(
crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex")
)
// 任意一个匹配即可:轮换窗口内新旧密钥会各签一次
return received.some((candidate) => {
const buffer = Buffer.from(candidate)
return (
buffer.length === expected.length &&
crypto.timingSafeEqual(buffer, expected)
)
})
}一个环境下可以配置多个端点,每个端点有独立的签名密钥,并可以只订阅自己关心的事件类型。完整接口见 Webhook 端点。
curl -X POST "https://web.kukopay.com/api/v1/webhook_endpoints" \
-H "X-Api-Key: kuko_live_你的密钥" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourshop.com/hooks/kukopay",
"description": "订单履约服务",
"enabled_events": ["payment.succeeded", "refund.succeeded"]
}'签名密钥只在创建和轮换时返回一次,之后无法再读回明文。
下单时传了 notify_url,该订单的事件只发往这个地址,不再发给已配置的端点。没有传时,事件发给所有订阅了该类型的端点。两者都没有时,回落到开发者中心配置的单一地址。
调用轮换接口后,旧密钥会在 24 小时内继续与新密钥一起签名,两个签名出现在同一个 X-KukoPay-Signature 里。你可以在窗口期内任意时刻更新配置,不必和平台同步切换。
curl -X POST "https://web.kukopay.com/api/v1/webhook_endpoints/whe_xxx/rotate_secret" \
-H "X-Api-Key: kuko_live_你的密钥"接收端在 10 秒内返回 2xx 表示已接收。其它状态或超时会按以下节奏重投,共 6 次:
30 秒 → 2 分钟 → 10 分钟 → 30 分钟 → 2 小时 → 6 小时
同一事件重投时 id 不变。多个端点各自独立重试,一个端点故障不会拖住其它端点。
id。不要只在内存里去重,也不要先发货再记录事件。进程重启或并发通知会绕过这两种做法。
接收端宕机一整天、重试全部耗尽,甚至压根没配置端点——事件都还在。用 事件接口 按时间范围拉回来即可,不需要拿自己的订单表跟我们逐条比对。
商户后台的 Webhook 投递记录会展示每次尝试的 HTTP 状态;重试耗尽后,也可以在修复接收端后手动重发。