Webhook 詳解:簽名載荷與可靠重試

Backend2026-10-23TryQuickToolBox

你上線了一個支付整合,供應商觸發了 webhook,你的端點返回 200——但三小時後客服報告訂單丟失。或者更糟:同一個事件到達四次,客戶被重複扣款。Webhook 看起來很簡單(向你的 URL 發送一個 HTTP POST),但困難的部分在於信任和傳遞保證。

本指南涵蓋兩者:如何使用簽名載荷驗證載荷確實來自發送方,以及如何構建一個能夠承受停機、超時和重複傳遞的重試模型。

Webhook 究竟是什麼

Webhook 是一種反向 API 呼叫。與其讓你的伺服器每分鐘輪詢 GET /events,不如讓供應商在發生事件時向你註冊的 URL 推送一個 HTTP 請求。載荷通常是 JSON,方法幾乎總是 POST,而你返回的回應是一個信號:2xx 表示「已收到」,其他任何狀態碼表示「稍後再試」。

最後這句話就是整個契約。其他一切——簽名、重試、順序——都是為了讓這個契約在不可靠的網路上安全運作。

為什麼未簽名的 Webhook 很危險

你的 webhook 端點是一個公開 URL。任何發現它的人都可以 POST 一個偽造的載荷,聲稱訂單已支付。如果你不經驗證就信任請求主體,你就等於為計費邏輯開了一扇敞開的門。

IP 允許清單有一點幫助,但在 CDN、無伺服器出口和供應商 IP 變更的情況下會失效。穩健的答案是共享密鑰加上對原始請求主體的 HMAC 簽名。

簽名載荷如何運作

供應商和你共享一個密鑰。每次傳遞時,供應商對原始主體計算 HMAC(通常是 SHA-256)——有時是對時間戳加上主體——並在標頭中發送十六進制摘要,例如 X-Signature。你用自己那份密鑰重新計算相同的摘要並進行比較。

有兩個細節比人們預期的更重要:

一個最小的驗證範例(Node.js)

const crypto = require('crypto');

function verifySignature(rawBody, headerSig, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)          // 原始 Buffer 或字串,不是解析後的 JSON
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(headerSig || '', 'utf8');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

同樣的模式在 Python 中使用 hmac.compare_digest,在 Go 中使用 hmac.Equal,在 Java 中使用 MessageDigest.isEqual。原理在各處都相同:對原始位元組進行 HMAC,恆定時間比較。

使用時間戳進行重放保護

有效的簽名證明載荷來自持有密鑰的人——但捕獲的請求可以被永久重放。關心這一點的供應商會在簽名字串中包含時間戳,例如 t=1712345678,v1=<hex>。在你這邊:

  1. 從標頭中解析時間戳。
  2. 如果請求早於容忍窗口(通常是 5 分鐘),則拒絕該請求。
  3. 對 timestamp + "." + rawBody 重新計算 HMAC。

這將洩露請求的損害限制在幾分鐘內,而不是永久。

設計真正能送達的重試

網路會故障,你的服務會重啟,部署會輪換 Pod。嚴肅的供應商會按計劃重試失敗的傳遞。嚴肅的接收方必須假設每個事件都可能到達多次。

發送方重試規則

接收方規則

  1. 先驗證,後解析。在觸碰資料庫之前,以 401 拒絕錯誤的簽名。
  2. 持久化事件,然後返回 200。將原始載荷和事件 ID 寫入佇列或資料表,立即回應,並在工作執行緒中處理。如果處理失敗,則在內部重試。
  3. 使處理程序冪等。使用供應商的事件 ID 作為唯一鍵。如果它已存在,則返回 200 而不重新執行副作用。
  4. 誠實地返回錯誤。如果你的資料庫宕機,返回 500 以便供應商重試——不要返回 200 並默默丟棄事件。
回應對發送方的意義接下來會發生什麼
200 / 204已傳遞不重試
400格式錯誤或被拒絕通常不重試;記錄以供除錯
401簽名無效不重試;調查密鑰
429速率限制使用退避重試
5xx / 超時暫時性失敗使用退避重試

實務中的冪等性

冪等性在這裡不是流行語——它是重試與重複扣款之間唯一的屏障。模式如下:

INSERT INTO webhook_events (event_id, payload, received_at)
VALUES ($1, $2, now())
ON CONFLICT (event_id) DO NOTHING;

如果插入影響零行,表示你已經見過此事件;確認並繼續。這在並行傳遞下也能運作,因為唯一約束會在資料庫層序列化它們。

對於必須恰好執行一次的副作用(發送電子郵件、扣款),向下游傳遞你自己的冪等鍵——大多數支付和電子郵件 API 都接受一個。

順序、除錯與可觀測性

不要假設事件按順序到達。重試可能會重新排序傳遞,並行工作執行緒也會如此。如果順序很重要,請在載荷中包含序列號或時間戳,並協調狀態,而不是盲目應用增量。

記錄每次傳遞嘗試,包括事件 ID、簽名結果、回應碼和處理時間。當客戶說「我的訂單從未到達」時,你希望能在幾秒內回答:webhook 是否收到、簽名是否有效、處理是否成功?每個事件一行結構化日誌就足夠了。

對於接收大量流量的端點,部署後要留意重試風暴。快速發現它們的方法是查看存取日誌中的請求模式——來自一個 IP 的相同路徑伴隨 5xx 回應的尖峰是典型的重試迴圈。像 Nginx 日誌分析器 這樣的工具可以讓這種模式顯而易見,而無需手寫查詢。

要避免的常見錯誤

常見問題

如果主體是 JSON,我該如何驗證 webhook 簽名?

在任何 JSON 解析之前,將原始請求主體作為位元組讀取,對這些位元組計算 HMAC,並使用恆定時間函數與標頭進行比較。重新序列化的 JSON 會產生不同的摘要。

Webhook 應該重試多少次?

沒有通用數字,但通常是在幾個小時內使用指數退避和抖動進行 5–10 次嘗試。重要的是接收方是冪等的,因此即使重試頻繁也是安全的。

我的 webhook 端點應該返回什麼狀態碼?

僅在持久化儲存事件後返回 2xx。對於無效簽名返回 401,對於格式錯誤的載荷返回 400,當你希望發送方重試時返回 5xx 或 429。

如果你正在測試 webhook 載荷或記錄收到的 JSON,JSON 格式化工具 是一種快速美化列印和驗證捕獲事件的方法,然後再圍繞它構建解析邏輯。