賬戶上發生的每一件事都會記錄成一個事件,再由 KukoPay 主動推送到你配置的地址。事件是既成事實,推送只是告訴你的一次嘗試——這個區分很重要,它意味着即使推送全部失敗,事件也不會丟,可以從 事件接口 補回來。
事件類型
| 事件 | 含義 |
|---|---|
payment.succeeded | 支付成功、資金已入錢包,可以履約 |
payment.failed | 支付被通道判定為終態失敗,不會再成功 |
payment.expired | 收銀台鏈接過期(expires_at)仍無人支付,或商戶調用關閉訂單,或訂單所屬的收款鏈接收款筆數已滿,訂單已關閉 |
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,
"fee_details": { "fee_group": "card", "fee_percent": 0.035, "fixed_fee": 30 },
"currency": "USD",
"status": "paid",
"payment_method": "card",
"refundable_until": null,
"metadata": { "customer_id": "cus_42" },
"paid_at": "2026-09-21T08:31:10.882Z"
}
}
}請求頭
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)
)
})
}import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_kukopay(raw_body: bytes, signature_header: str, secret: str) -> bool:
timestamp = None
received = []
for item in signature_header.split(","):
key, _, value = item.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
received.append(value)
if not timestamp or not received:
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
# 輪換窗口內新舊密鑰各簽一次,任意一個匹配即合法
return any(hmac.compare_digest(expected, candidate) for candidate in received)<?php
function verify_kukopay(string $rawBody, string $signatureHeader, string $secret): bool
{
$timestamp = null;
$received = [];
foreach (explode(',', $signatureHeader) as $item) {
[$key, $value] = array_pad(explode('=', trim($item), 2), 2, null);
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$received[] = $value;
}
}
if ($timestamp === null || $received === []) {
return false;
}
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
// 輪換窗口內新舊密鑰各簽一次,任意一個匹配即合法
foreach ($received as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
return false;
}配置接收端點
端點屬於應用:每個應用每個環境最多 5 個端點,每個端點有獨立的簽名密鑰,並可以只訂閲自己關心的事件類型,只接收本應用訂單產生的事件。用 API 創建的端點總是屬於該密鑰所在的應用。
如果有一個系統需要看到所有應用的事件(比如財務對賬),可以在商戶後台「開發者 → Webhook」創建賬戶級端點,它接收賬戶下所有應用的事件,app_id 為 null;賬戶級端點只能在後台管理,應用的密鑰看不到也改不了它。每個事件都帶有 app_id,賬戶級端點可以據此區分來源。完整接口見 Webhook 端點。
curl -X POST "https://api.kukopay.com/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,該訂單的事件只發往這個地址,不再發給已配置的端點。沒有傳時,事件發給訂單所屬應用的、以及賬戶級的、訂閲了該類型的已啓用端點。
notify_url 的回調用訂單所屬應用的回調簽名密鑰簽名,它在商戶後台左上角切換到該應用後,於「開發者 → API 密鑰」頁面裏簽發。沒有簽發時回調無法簽名,不會發出。
密鑰輪換
調用輪換接口後,舊密鑰會在 24 小時內繼續與新密鑰一起簽名,兩個簽名出現在同一個 X-KukoPay-Signature 裏。你可以在窗口期內任意時刻更新配置,不必和平台同步切換。
在商戶後台輪換端點密鑰、或在「API 密鑰」頁面重置回調簽名密鑰,也是同樣的規則:舊密鑰繼續簽名 24 小時,頁面會顯示它的失效時間。
curl -X POST "https://api.kukopay.com/v1/webhook_endpoints/whe_xxx/rotate_secret" \
-H "X-Api-Key: kuko_live_你的密鑰"重試策略
接收端在 10 秒內返回 2xx 表示已接收。其它狀態或超時視為失敗,按以下間隔重投,連同第一次最多共發送 6 次:
首次發送 → 30 秒 → 2 分鐘 → 10 分鐘 → 30 分鐘 → 2 小時
第 6 次仍失敗後不再自動重投,可以在後台手動重發(見下文)。
同一事件重投時 id 不變。多個端點各自獨立重試,一個端點故障不會拖住其它端點。
平台不跟隨重定向,3xx 按失敗處理,所以回調地址要填最終處理請求的接口,不要填網站首頁。每次重投都會發到端點當前的地址:發現地址填錯,改正後,還在排隊的重試和後台的手動重發都會發到新地址。端點刪除後,發往它的投遞會停止。按訂單傳入的 notify_url 跟隨訂單,不會變。
推薦處理順序
- 讀取原始請求體。
- 驗證簽名,並拒絕超過 5 分鐘的時間戳。
- 在數據庫事務中插入唯一的事件
id。 - 執行業務狀態變更。
- 儘快返回 2xx;耗時任務交給自己的隊列。
不要只在內存裏去重,也不要先發貨再記錄事件。進程重啓或併發通知會繞過這兩種做法。
在沙箱裏測試回調
接收端上線前,可以在商戶後台「開發者 → 沙箱聯調」直接驗證,不需要真的付款:
- 發送測試回調:選一個接收地址和事件類型,平台立即發出一條和正式回調格式、簽名完全相同的請求,並在頁面上同時顯示我們發出的請求和你的接口返回的內容。測試回調的
id以evt_test_開頭、帶"test": true,不會出現在事件接口裏,也不會改動任何訂單或餘額——接收端看到test: true時不要發貨。 - 模擬支付結果:對一筆還沒打開過收銀台的沙箱訂單選擇「模擬支付成功 / 失敗 / 過期」,模擬成功時再選擇買家使用的支付方式。這會走真實流程:訂單狀態變化、沙箱餘額按該支付方式所在渠道的真實費率入賬、事件寫入事件接口、回調按正式規則投遞和重試。適合驗證"收到回調 → 查單 → 只發一次貨"的完整鏈路;模擬成功的訂單之後也可以正常發起退款,再測退款回調。
- 驗簽自檢:在「開發者 → Webhook」頁面粘貼收到的原始請求體和
X-KukoPay-Signature,在瀏覽器本地重算簽名,指出問題出在密鑰、請求體被重新格式化還是時間戳。
投遞記錄裏的每一行都可以展開,查看當次請求的內容和你的接口返回的狀態碼、響應體與耗時。
漏了怎麼辦
接收端宕機一整天、重試全部耗盡,甚至壓根沒配置端點——事件都還在。用 事件接口 按時間範圍拉回來即可,不需要拿自己的訂單表跟我們逐條比對。
商戶後台的 Webhook 投遞記錄會展示每次嘗試的 HTTP 狀態;重試耗盡後,也可以在修復接收端後手動重發。投遞記錄保留 30 天,超過後無法在後台重發,但事件本身不會被清理,仍可以通過事件接口或後台「開發者 → 事件」頁面找回。