跳轉到主要內容

支付回調重複通知怎麼辦?用 Webhook 驗簽與冪等防止重複發貨

支付平台的 Webhook 會重試,同一筆支付可能通知你兩次甚至更多。本文用 HMAC-SHA256 驗簽、事件 ID 唯一約束和數據庫事務,講清怎樣讓一筆支付只履約一次,以及回調全部丟失時如何補回。

發佈於 約 4 分鐘閲讀

手繪插畫:三封寫着同一個事件 ID 的信封進入一個帶唯一約束的數據庫,只被記錄一次
本文目錄
  1. 為什麼支付回調會重複?
  2. 第一步:用原始請求體驗簽
  3. 第二步:以事件 ID 去重,並和履約放在同一個事務裏
  4. 第三步:快速返回 2xx,把慢活交給隊列
  5. 只訂閲「支付成功」夠嗎?
  6. 回調全部丟失了怎麼辦?
  7. 下單請求也需要冪等
  8. 上線前的自檢清單

支付回調重複是正常現象,不是平台的 bug。網絡超時、接收端重啓、響應在回程中丟失,都會讓同一個事件被推送不止一次。正確的處理方式只有三條:

  1. 先用原始請求體驗簽,再做任何事;
  2. 以事件 id 做唯一約束,並且和履約放在同一個數據庫事務裏;
  3. 儘快返回 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.expired
  • refund.succeeded、refund.failed
  • dispute.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 被重投。

在沙箱裏把它跑通

註冊商戶後即可申請測試密鑰,不需要等待資質審核。