支付回調重複通知怎麼辦?用 Webhook 驗簽與冪等防止重複發貨
支付平台的 Webhook 會重試,同一筆支付可能通知你兩次甚至更多。本文用 HMAC-SHA256 驗簽、事件 ID 唯一約束和數據庫事務,講清怎樣讓一筆支付只履約一次,以及回調全部丟失時如何補回。
本文目錄
支付回調重複是正常現象,不是平台的 bug。網絡超時、接收端重啓、響應在回程中丟失,都會讓同一個事件被推送不止一次。正確的處理方式只有三條:
- 先用原始請求體驗簽,再做任何事;
- 以事件
id做唯一約束,並且和履約放在同一個數據庫事務裏; - 儘快返回 2xx,把耗時的工作交給自己的隊列。
下文以 KukoPay 的 Webhook 為例展開,這三條原則對任何支付平台的回調都適用。
為什麼支付回調會重複?
從平台的角度看,一次推送只有兩種結果:在規定時間內收到 2xx,或者沒有。KukoPay 要求接收端在 10 秒內返回 2xx,否則就按失敗處理,並按以下間隔重投,連同第一次最多共發送 6 次:
首次發送 → 30 秒 → 2 分鐘 → 10 分鐘 → 30 分鐘 → 2 小時
注意「失敗」的判斷發生在平台這一側。下面幾種情況,你的系統其實已經發了貨,平台卻認為沒有送達:
- 你在返回響應之前先調用了一個很慢的物流接口,處理超過了 10 秒;
- 處理完成後進程崩潰,響應沒有發出去;
- 響應在網絡回程中丟失。
於是同一個事件會再來一次。重投時事件 id 保持不變,這是你去重的依據。
第一步:用原始請求體驗簽
每個回調都帶一個簽名頭:
X-KukoPay-Signature: t=1758268800,v1=9c1f...簽名內容是 HMAC-SHA256(secret, "{t}.{原始請求體}") 的十六進制結果。驗簽時有三個細節最容易出錯:
- 必須使用原始請求字節。 先把 JSON 解析成對象、再重新序列化,字段順序和空格一變,簽名就對不上了。很多框架默認會幫你解析請求體,需要專門拿到 raw body。
- 拒絕過舊的時間戳。
t和當前時間相差超過 5 分鐘就拒絕,防止一個被截獲的舊請求被重放。 - 簽名頭裏可能有多個
v1=。 輪換簽名密鑰後的 24 小時內,平台會用新舊兩個密鑰各簽一次,任意一個匹配即合法。用Object.fromEntries之類的方法把簽名頭解析成對象,只會留下最後一個v1,輪換期間驗簽會全部失敗。
import crypto from "crypto"
export function verify(rawBody, header, secret) {
const parts = header.split(",").map((item) => {
const i = item.indexOf("=")
return [item.slice(0, i).trim(), item.slice(i + 1).trim()]
})
const t = parts.find(([k]) => k === "t")?.[1]
const candidates = parts.filter(([k]) => k === "v1").map(([, v]) => v)
if (!t || candidates.length === 0) return false
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = Buffer.from(crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"))
return candidates.some((c) => {
const b = Buffer.from(c)
return b.length === expected.length && crypto.timingSafeEqual(b, expected)
})
}Python 和 PHP 的版本見 Webhook 文檔。
第二步:以事件 ID 去重,並和履約放在同一個事務裏
去重最可靠的地方是數據庫。為已處理的事件建一張表,事件 id 做主鍵:
CREATE TABLE processed_events (
event_id text PRIMARY KEY,
received_at timestamptz NOT NULL DEFAULT now()
);處理回調時,在同一個事務裏先插入事件 id,插入成功才履約:
BEGIN;
INSERT INTO processed_events (event_id) VALUES ('evt_3f2a9c7e1b4d0568a2cf')
ON CONFLICT (event_id) DO NOTHING;
-- 受影響行數為 0:這個事件處理過了,直接 COMMIT 並返回 2xx
-- 受影響行數為 1:在同一個事務裏更新訂單、開通服務
UPDATE orders SET status = 'paid' WHERE out_trade_no = 'ORD_20260921_0001' AND status = 'pending';
COMMIT;為什麼一定要在同一個事務裏?看看兩種常見的錯誤做法:
| 做法 | 會出什麼問題 |
|---|---|
| 在內存裏記一個「處理過的 ID」集合 | 進程重啓後集合清空;多實例部署時每個實例各記各的,併發的兩次通知會同時通過 |
| 先發貨,再記錄事件 ID | 發貨後、記錄前進程崩潰,重投的通知會再發一次貨 |
| 先記錄事件 ID,再在事務外發貨 | 記錄成功、發貨失敗,重投時被當作「已處理」跳過,這筆訂單永遠不會發貨 |
把「記錄事件」和「改變業務狀態」綁在一個事務裏,兩者要麼一起成功,要麼一起回滾,重複通知自然無害。
訂單狀態更新裏的 AND status = 'pending' 是第二道保險:即使兩個事件 ID 不同(例如你同時用了訂單級 notify_url 和事件接口補償),同一筆訂單也只會從「待支付」變成「已支付」一次。
第三步:快速返回 2xx,把慢活交給隊列
驗簽、寫事件表、改訂單狀態,這幾步都應該在毫秒級完成。發郵件、通知倉庫、調用第三方開通接口這類慢操作,放進你自己的任務隊列異步執行,然後立刻返回 2xx。
這樣做還有一個好處:平台不跟隨重定向,3xx 會按失敗處理。回調地址要填最終處理請求的接口,不要填網站首頁。
只訂閲「支付成功」夠嗎?
不夠。只訂閲 payment.succeeded,你只能靠輪詢才能發現一筆訂單已經失敗或過期。上線前至少訂閲這幾個事件:
payment.succeeded、payment.failed、payment.expiredrefund.succeeded、refund.faileddispute.created
退款要等真實扣賬後才會推送 refund.succeeded;拒付一開案就會推送 dispute.created,見 拒付與爭議。
回調全部丟失了怎麼辦?
接收端宕機一整天、重試全部耗盡,甚至根本沒配置端點——事件都還在。KukoPay 把賬戶上發生的每件事先記成事件,推送只是一次送達嘗試,所以可以用 事件接口 按時間範圍把事件拉回來,用同一套處理代碼補處理一遍:
curl -G "https://api.kukopay.com/v1/events" \
-H "X-Api-Key: $KUKOPAY_API_KEY" \
-d type=payment.succeeded \
-d "created[gte]=2026-09-20T00:00:00Z"事件接口返回的 data.object 和 Webhook 裏的完全一致,前面寫的去重表同樣適用。控制台的 Webhook 投遞記錄保留 30 天,修好接收端後也可以在那裏手動重發。
下單請求也需要冪等
回調的冪等解決「同一個結果通知多次」,下單的冪等解決「同一個請求發送多次」。網絡超時後你不知道訂單有沒有創建成功,這時應該原樣重發:
out_trade_no(商戶訂單號)本身就是冪等鍵:內容相同的重複下單返回首次創建的訂單;- 再加一個
Idempotency-Key請求頭,平台會在 24 小時內原樣重放首次的響應。
兩層機制的區別和一個完整的重試實現見 冪等與重試。
上線前的自檢清單
- 驗簽使用原始請求體,並拒絕超過 5 分鐘的時間戳
- 簽名頭中的多個
v1=都會被逐一比對 - 事件
id有唯一約束,並與履約在同一事務中 - 回調處理在 10 秒內返回 2xx,慢操作進入隊列
- 訂閲了失敗、過期、退款和拒付事件
- 有一個按時間範圍從事件接口補數據的腳本
在沙箱的「開發者 → 沙箱聯調」裏可以發送測試回調,和正式回調格式、簽名完全相同;模擬接收端返回 500,就能看到同一個事件 id 被重投。
在沙箱裏把它跑通
註冊商戶後即可申請測試密鑰,不需要等待資質審核。