Webhook 詳解:簽名載荷與可靠重試
你上線了一個支付整合,供應商觸發了 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。你用自己那份密鑰重新計算相同的摘要並進行比較。
有兩個細節比人們預期的更重要:
- 對原始位元組簽名。如果你的框架解析 JSON 並重新序列化,鍵的順序和空白會改變,摘要將不匹配。先讀取原始主體。
- 以恆定時間比較。天真的
==會洩露時間資訊。使用語言加密庫中的恆定時間比較函數。
一個最小的驗證範例(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>。在你這邊:
- 從標頭中解析時間戳。
- 如果請求早於容忍窗口(通常是 5 分鐘),則拒絕該請求。
- 對
timestamp + "." + rawBody重新計算 HMAC。
這將洩露請求的損害限制在幾分鐘內,而不是永久。
設計真正能送達的重試
網路會故障,你的服務會重啟,部署會輪換 Pod。嚴肅的供應商會按計劃重試失敗的傳遞。嚴肅的接收方必須假設每個事件都可能到達多次。
發送方重試規則
- 將任何非 2xx 回應、連線錯誤或超時視為失敗。
- 使用指數退避和抖動進行重試——例如 1 秒、5 秒、30 秒、5 分鐘、30 分鐘、2 小時——這樣供應商中斷不會在你恢復時產生驚群效應。
- 限制嘗試次數(通常是 5–10 次),並記錄最終失敗,以便使用者或操作員可以看到。
- 設定較短的客戶端超時(5–10 秒)。Webhook 處理程序應快速確認並非同步執行工作。
接收方規則
- 先驗證,後解析。在觸碰資料庫之前,以 401 拒絕錯誤的簽名。
- 持久化事件,然後返回 200。將原始載荷和事件 ID 寫入佇列或資料表,立即回應,並在工作執行緒中處理。如果處理失敗,則在內部重試。
- 使處理程序冪等。使用供應商的事件 ID 作為唯一鍵。如果它已存在,則返回 200 而不重新執行副作用。
- 誠實地返回錯誤。如果你的資料庫宕機,返回 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。
- 在不同環境中使用相同的密鑰,或從不輪換密鑰。
- 在請求處理程序中執行繁重的工作(PDF 生成、第三方呼叫)。
- 在處理錯誤時返回 200,導致供應商永遠不會重試。
- 在關鍵時刻不重新從供應商 API 獲取資源,就信任載荷中的金額或狀態。
常見問題
如果主體是 JSON,我該如何驗證 webhook 簽名?
在任何 JSON 解析之前,將原始請求主體作為位元組讀取,對這些位元組計算 HMAC,並使用恆定時間函數與標頭進行比較。重新序列化的 JSON 會產生不同的摘要。
Webhook 應該重試多少次?
沒有通用數字,但通常是在幾個小時內使用指數退避和抖動進行 5–10 次嘗試。重要的是接收方是冪等的,因此即使重試頻繁也是安全的。
我的 webhook 端點應該返回什麼狀態碼?
僅在持久化儲存事件後返回 2xx。對於無效簽名返回 401,對於格式錯誤的載荷返回 400,當你希望發送方重試時返回 5xx 或 429。
如果你正在測試 webhook 載荷或記錄收到的 JSON,JSON 格式化工具 是一種快速美化列印和驗證捕獲事件的方法,然後再圍繞它構建解析邏輯。