Webhooks 詳解:簽名載荷與重試機制
你已經建立了一個 API,現在需要在事件發生時通知其他服務。你設置了一個 webhook 端點,但很快發現任何人都可以發送偽造載荷,而且當你的伺服器宕機時,事件會永遠丟失。這是一個常見的陷阱:webhooks 概念簡單,但需要仔細設計以確保安全性和可靠性。
在本指南中,我們將介紹 webhooks 的基本要素:如何簽名載荷以防止篡改、如何在接收端驗證簽名,以及如何實作指數退避重試邏輯來處理失敗。我們還會涵蓋冪等性以避免重複處理。最後,你將擁有一個值得信賴的穩健 webhook 系統。
什麼是 Webhooks?
Webhooks 是使用者定義的 HTTP 回呼。當來源系統中發生事件(例如付款處理完成)時,它會向您指定的 URL 發送 HTTP POST 請求,其中包含事件詳細資訊的載荷。這允許服務之間進行即時通訊,而無需輪詢。
然而,webhooks 引入了兩個挑戰:
- 安全性:接收方如何知道請求確實來自發送方?
- 可靠性:如果接收方暫時不可用怎麼辦?我們如何確保事件最終被送達?
使用簽名載荷保護 Webhooks
為了防止偽造請求,發送方應使用共享密鑰對載荷進行簽名。接收方隨後可以驗證簽名以驗證發送方的身份。最常見的方法是使用 SHA-256 的 HMAC(基於雜湊的消息驗證碼)。
HMAC 簽名如何運作
- 發送方和接收方共享一個密鑰(理想情況下是長隨機字串)。
- 對於每個 webhook,發送方使用密鑰計算請求主體的 HMAC,並將其包含在標頭中(例如
X-Signature)。 - 接收方對收到的主體計算相同的 HMAC,並與標頭值進行比較。如果匹配,則請求是真實的。
關鍵是要對原始請求主體進行簽名,而不是解析後的版本,以避免因 JSON 序列化差異而產生不一致。
範例:在 Node.js 中簽名載荷
const crypto = require('crypto');
function signPayload(payload, secret) {
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}
// 發送方:
const payload = JSON.stringify({ event: 'payment.success', amount: 100 });
const signature = signPayload(payload, 'your-secret-key');
// 在標頭中包含簽名:X-Signature: signature
在接收方驗證簽名
在接收端,必須在處理載荷之前驗證簽名。使用常數時間比較以防止時序攻擊。
const crypto = require('crypto');
function verifySignature(payload, signature, secret) {
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
// 在你的 webhook 端點中:
app.post('/webhook', (req, res) => {
const signature = req.headers['x-signature'];
const payload = req.rawBody; // 原始主體作為字串
if (!verifySignature(payload, signature, 'your-secret-key')) {
return res.status(401).send('Invalid signature');
}
// 處理事件
res.status(200).send('OK');
});
注意: 始終使用 HTTPS 以防止中間人攻擊。此外,考慮在簽名載荷中包含時間戳以防止重放攻擊。
實作指數退避重試
即使接收方可靠,網路問題或暫時性中斷也可能導致 webhook 傳遞失敗。穩健的發送方應重試失敗的傳遞。然而,簡單的重試可能會淹沒接收方或導致重複處理。解決方案:帶有抖動和最大重試次數的指數退避。
重試策略
- 失敗時重試:當接收方返回非 2xx 狀態碼或超時時重試。
- 指數退避:以指數方式增加重試之間的延遲(例如 1秒、2秒、4秒、8秒...)。
- 抖動:在延遲中加入隨機變化,以避免驚群效應。
- 最大嘗試次數:設置限制(例如 5 次重試),然後將 webhook 標記為失敗(或許通知管理員)。
範例:Node.js 中的重試邏輯
async function deliverWebhook(url, payload, secret, maxRetries = 5) {
const signature = signPayload(payload, secret);
let attempt = 0;
while (attempt < maxRetries) {
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Signature': signature },
body: payload,
});
if (response.ok) return;
throw new Error(`HTTP ${response.status}`);
} catch (error) {
attempt++;
if (attempt >= maxRetries) throw error;
const delay = Math.min(1000 * 2 ** attempt + Math.random() * 1000, 30000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
此函數最多重試 5 次,使用指數退避和抖動,延遲上限為 30 秒。根據你的需求調整參數。
確保冪等性
重試可能導致重複傳遞,因此你的 webhook 處理程序必須是冪等的。在載荷中包含唯一的事件 ID,並讓接收方追蹤已處理的 ID 以避免重複處理。
// 在接收方:
const processedEvents = new Set();
app.post('/webhook', (req, res) => {
const { id } = req.body;
if (processedEvents.has(id)) {
return res.status(200).send('Already processed');
}
// 處理事件
processedEvents.add(id);
res.status(200).send('OK');
});
在生產環境中,使用持久化存儲(例如 Redis 或資料庫)來跨重啟追蹤已處理的 ID。
比較:簽名與未簽名 Webhooks
| 方面 | 未簽名 | 簽名 (HMAC) |
|---|---|---|
| 安全性 | 任何人都可以偽造請求 | 只有密鑰持有者才能創建有效簽名 |
| 完整性 | 載荷可能被篡改 | 篡改會使簽名無效 |
| 複雜度 | 低 | 中等(密鑰管理、驗證) |
| 使用案例 | 內部、可信網路 | 任何公開 webhook |
常見問題
什麼是 webhook 簽名?
Webhook 簽名是載荷的加密雜湊(通常是 HMAC),使用共享密鑰創建。它允許接收方驗證請求來自預期的發送方,並且載荷未被更改。
我應該重試失敗的 webhook 多少次?
沒有固定次數,但常見做法是使用指數退避重試 3 到 5 次。之後,將傳遞標記為失敗並通知你的團隊。考慮事件的重要性:付款通知可能比非關鍵更新需要更多重試。
我可以在沒有 HTTPS 的情況下使用 webhooks 嗎?
在生產環境中,絕不應通過純 HTTP 使用 webhooks。HTTPS 在傳輸過程中加密載荷,防止竊聽和中間人攻擊。即使有簽名,HTTP 也會暴露敏感資料。
準備測試你的 webhook 載荷了嗎?使用我們的 JSON Formatter 在發送或除錯之前美化和驗證 JSON 載荷。