Webhooks 詳解:簽章載荷與可靠重試

Backend2026-11-05TryQuickToolBox

你的整合在測試環境中運作正常:支付供應商觸發事件,你的端點接收,訂單狀態翻轉為 paid。然後在生產環境中,客戶重複付款、部署期間某次傳遞無聲消失,還有人重放擷取的請求將訂單標記為已退款。這三種故障有相同的根本原因:webhook 被當成簡單的 HTTP POST,而非安全關鍵、至少一次傳遞的通道。

本指南涵蓋正確 webhook 實作的兩個部分——驗證載荷的真實性,以及讓傳遞能在重試中存活——並提供可適應任何技術堆疊的程式碼。

Webhook 究竟是什麼

Webhook 是當事件發生時,一個系統向另一個系統發送的伺服器對伺服器 HTTP 請求。它反轉了通常的輪詢模型:不是你的應用每分鐘詢問「有新的嗎?」,而是生產者將事件推送到你註冊的 URL。

這種設計帶來兩個特性,兩者都很重要:

以下所有內容都是這兩個事實的結果。

使用 HMAC 簽署載荷

標準做法是共享密鑰加上對原始請求主體的 HMAC。發送方計算簽章並放入標頭;你重新計算並比較。如果匹配,載荷來自知道密鑰的人,且傳輸中未被修改。

三個細節區分了可運作與損壞的實作:

  1. 對原始位元組簽章。 僅在驗證後解析 JSON。重新序列化主體會改變空白和鍵順序,簽章將永遠不匹配。
  2. 使用常數時間比較。 天真的 == 會洩漏時間資訊。使用語言內建的安全比較。
  3. 在簽章字串中包含時間戳。 簽署 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 / 逾時重試將慢速工作移至佇列

兩條規則保持健康:

讓處理程序冪等

由於傳遞是至少一次,相同的事件 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 作為冪等鍵。

上線前的檢查清單

  1. 使用常數時間比較驗證原始位元組的 HMAC。
  2. 拒絕超過幾分鐘的時間戳以阻止重放。
  3. 持久化事件,然後在一兩秒內返回 2xx。
  4. 在執行任何工作前按事件 ID 去重。
  5. 記錄事件 ID、類型和結果——絕不記錄密鑰。
  6. 提供從儀表板或管理工具手動重放事件的方式。
  7. 使用故意損壞的簽章、重複傳遞和慢速處理程序進行測試。

當你除錯失敗的整合時,原始請求主體是你最常盯著看的東西。將其貼到 JSON 格式化工具 中,可以讓巢狀事件物件變得可讀,而不會改變你已擷取的位元組。

常見問題

為什麼載荷看起來完全相同,我的簽章檢查卻失敗?

幾乎總是因為主體在雜湊前被解析並重新序列化。JSON 鍵順序、空白和 unicode 轉義在解析/字串化過程中會改變,因此位元組不再匹配發送方簽署的內容。擷取原始主體並對其進行雜湊。

我應該在處理事件之前還是之後返回 200?

在持久化儲存之後,執行繁重工作之前。如果你先處理,慢速的下游呼叫會導致逾時,發送方重試,你就會得到重複。將事件排入佇列,立即回應,並以非同步方式處理。

HTTPS 足以保護 webhook 端點嗎?

不。TLS 保護傳輸中的資料,但不能證明誰發送了請求——任何人都可以 POST 到公開 URL。使用共享密鑰的 HMAC 簽章認證發送方,而簽署的時間戳可防止擷取請求的重放。

總結

將 webhooks 視為任何不受信任的輸入:驗證原始位元組的簽章、檢查時間戳、按事件 ID 去重,並僅在事件安全儲存後確認。這樣做,重試就不再是重複收費的來源,而是回歸其本意——安全網。