Webhooks 詳解:簽章載荷與可靠重試
你的整合在測試環境中運作正常:支付供應商觸發事件,你的端點接收,訂單狀態翻轉為 paid。然後在生產環境中,客戶重複付款、部署期間某次傳遞無聲消失,還有人重放擷取的請求將訂單標記為已退款。這三種故障有相同的根本原因:webhook 被當成簡單的 HTTP POST,而非安全關鍵、至少一次傳遞的通道。
本指南涵蓋正確 webhook 實作的兩個部分——驗證載荷的真實性,以及讓傳遞能在重試中存活——並提供可適應任何技術堆疊的程式碼。
Webhook 究竟是什麼
Webhook 是當事件發生時,一個系統向另一個系統發送的伺服器對伺服器 HTTP 請求。它反轉了通常的輪詢模型:不是你的應用每分鐘詢問「有新的嗎?」,而是生產者將事件推送到你註冊的 URL。
這種設計帶來兩個特性,兩者都很重要:
- 你的端點是公開的。 網際網路上任何人都可以 POST 到它,因此每個請求都必須經過認證。
- 傳遞是至少一次。 網路會故障、伺服器會重啟,而逾時是模糊的——發送方無法得知你的處理程序是否執行。因此它會重試,你可能會多次看到同一個事件。
以下所有內容都是這兩個事實的結果。
使用 HMAC 簽署載荷
標準做法是共享密鑰加上對原始請求主體的 HMAC。發送方計算簽章並放入標頭;你重新計算並比較。如果匹配,載荷來自知道密鑰的人,且傳輸中未被修改。
三個細節區分了可運作與損壞的實作:
- 對原始位元組簽章。 僅在驗證後解析 JSON。重新序列化主體會改變空白和鍵順序,簽章將永遠不匹配。
- 使用常數時間比較。 天真的
==會洩漏時間資訊。使用語言內建的安全比較。 - 在簽章字串中包含時間戳。 簽署
timestamp + "." + body讓你能拒絕舊請求,從而阻擋重放攻擊。
在 Node.js 中驗證簽章
const crypto = require('crypto');
function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const signature = headers['x-signature']; // e.g. "t=1712345678,v1=abc123..."
const timestamp = headers['x-timestamp'];
if (!signature || !timestamp) return false;
// 1. Reject stale requests (replay protection)
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
// 2. Recompute over the exact bytes received
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// 3. Constant-time compare
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
在 Express 中,這需要在 webhook 路由上使用 express.raw({ type: 'application/json' }),以便主體以 Buffer 形式到達。如果全域 express.json() 中介軟體先執行,原始主體就會消失,驗證將失敗——這是非常常見的錯誤。
在 Python 中驗證
import hmac, hashlib, time
def verify(raw_body: bytes, timestamp: str, signature: str, secret: bytes, tolerance: int = 300) -> bool:
if abs(time.time() - int(timestamp)) > tolerance:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
檢查失敗時始終返回 401 並記錄請求 ID——但絕不記錄密鑰或完整簽章。
設計真正有效的重試
只有當你的端點行為可預測時,重試才有用。契約很簡單:一旦你持久地接受了事件,就快速返回 2xx,讓其他一切都被重試。
| 你的回應 | 發送方行為 | 應該怎麼做 |
|---|---|---|
200 / 204 | 標記為已傳遞 | 正確——排入佇列後確認 |
400 / 401 | 通常不重試 | 修復簽章或載荷處理 |
429 / 503 | 以退避重試 | 超載時返回,而非通用 500 |
500 / 逾時 | 重試 | 將慢速工作移至佇列 |
兩條規則保持健康:
- 快速確認。 驗證、將事件持久化到佇列或資料表、回應
200。以非同步方式執行實際工作。在回應前呼叫三個 API 的處理程序會逾時並觸發重複傳遞。 - 使用指數退避與抖動。 如果發送方立即重試,短暫中斷會變成雷鳴群。像 10 秒、30 秒、2 分鐘、10 分鐘、1 小時加上隨機抖動的排程可以分散負載。
讓處理程序冪等
由於傳遞是至少一次,相同的事件 ID 可能到達兩次——一次是在你的處理程序實際成功後的逾時。儲存已處理的事件 ID 並跳過重複:
INSERT INTO processed_events (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- If 0 rows inserted, this is a duplicate: return 200 and stop.
盡可能讓業務操作本身冪等——將訂單狀態設為 paid 而非遞增計數器,並在呼叫下游服務時使用事件 ID 作為冪等鍵。
上線前的檢查清單
- 使用常數時間比較驗證原始位元組的 HMAC。
- 拒絕超過幾分鐘的時間戳以阻止重放。
- 持久化事件,然後在一兩秒內返回
2xx。 - 在執行任何工作前按事件 ID 去重。
- 記錄事件 ID、類型和結果——絕不記錄密鑰。
- 提供從儀表板或管理工具手動重放事件的方式。
- 使用故意損壞的簽章、重複傳遞和慢速處理程序進行測試。
當你除錯失敗的整合時,原始請求主體是你最常盯著看的東西。將其貼到 JSON 格式化工具 中,可以讓巢狀事件物件變得可讀,而不會改變你已擷取的位元組。
常見問題
為什麼載荷看起來完全相同,我的簽章檢查卻失敗?
幾乎總是因為主體在雜湊前被解析並重新序列化。JSON 鍵順序、空白和 unicode 轉義在解析/字串化過程中會改變,因此位元組不再匹配發送方簽署的內容。擷取原始主體並對其進行雜湊。
我應該在處理事件之前還是之後返回 200?
在持久化儲存之後,執行繁重工作之前。如果你先處理,慢速的下游呼叫會導致逾時,發送方重試,你就會得到重複。將事件排入佇列,立即回應,並以非同步方式處理。
HTTPS 足以保護 webhook 端點嗎?
不。TLS 保護傳輸中的資料,但不能證明誰發送了請求——任何人都可以 POST 到公開 URL。使用共享密鑰的 HMAC 簽章認證發送方,而簽署的時間戳可防止擷取請求的重放。
總結
將 webhooks 視為任何不受信任的輸入:驗證原始位元組的簽章、檢查時間戳、按事件 ID 去重,並僅在事件安全儲存後確認。這樣做,重試就不再是重複收費的來源,而是回歸其本意——安全網。