Webhook 解析:簽章載荷與重試邏輯
你剛剛整合了一個支付閘道。文件上寫著:「當付款成功時,我們會發送一個 Webhook。」很好。但接著你想:我怎麼知道這個請求真的來自他們?如果他們發送時我的伺服器剛好掛掉怎麼辦?如果他們重複發送同一個事件呢?
Webhook 很強大,但也伴隨著一些陷阱。本指南將帶你了解其運作機制、安全性,以及你需要正確處理它們所需的可靠性模式。
什麼是 Webhook?
Webhook 是一種 HTTP POST 請求,當特定事件發生時,由一個系統發送給另一個系統。你的應用程式不需要每隔幾秒輪詢 API(「有新訂單嗎?有新訂單嗎?」),而是由服務提供者在事件發生時立即將資料推送給你。
典型流程:
- 你向服務提供者註冊一個 URL(例如
https://yourapp.com/webhooks/payments)。 - 事件發生(付款成功、使用者註冊、檔案上傳)。
- 服務提供者向你的 URL 發送一個 POST 請求,附帶描述該事件的 JSON 主體。
- 你的伺服器處理該事件並回應
2xx以確認收到。
如果你沒有快速回應(通常在 5 到 30 秒內),服務提供者會認為傳遞失敗,並可能重試。
為什麼必須驗證簽章載荷
你的 Webhook 端點是公開可存取的。任何發現該 URL 的人都可以發送偽造事件。如果沒有驗證,攻擊者可以偽造一個「付款成功」事件並免費取得商品。
解決方案:簽章載荷。服務提供者使用共享密鑰(只有你和他們知道)對請求主體計算密碼學簽章,並將其包含在標頭中。你在自己這邊重新計算簽章並進行比對。
HMAC 簽章如何運作
大多數服務提供者使用 HMAC-SHA256。步驟如下:
- 服務提供者將原始請求主體與時間戳串接(以防止重放攻擊)。
- 服務提供者計算
HMAC-SHA256(secret, timestamp + '.' + body)。 - 服務提供者在標頭中發送簽章,例如
X-Signature: t=1234567890,v1=abc123...。 - 你提取時間戳和簽章,使用你的密鑰副本重新計算 HMAC,並使用恆定時間比較函數進行比對。
以下是一個最小的 Node.js 範例:
const crypto = require('crypto');
function verifySignature(req, secret) {
const signatureHeader = req.headers['x-signature'];
const [tPart, vPart] = signatureHeader.split(',');
const timestamp = tPart.split('=')[1];
const receivedSig = vPart.split('=')[1];
const payload = timestamp + '.' + JSON.stringify(req.body);
const expectedSig = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(receivedSig, 'hex'),
Buffer.from(expectedSig, 'hex')
);
}
重要: 務必使用原始請求主體進行簽章驗證。如果你的框架解析 JSON 並重新序列化,位元組序列可能不同,導致驗證失敗。請在解析之前捕獲原始主體。
時間戳驗證
檢查時間戳是否為近期(例如 5 分鐘內)。這可以防止重放攻擊,攻擊者會捕獲有效請求並在稍後重新發送。
重試邏輯:優雅地處理失敗
你的伺服器可能當機、超載或遇到網路暫時故障。服務提供者透過重試失敗的傳遞來處理這種情況。但你必須設計你的端點以安全地處理重試。
指數退避與抖動
服務提供者通常會以遞增的延遲進行重試:1 分鐘、5 分鐘、30 分鐘、2 小時等。這稱為指數退避。有些會加入隨機抖動以避免驚群效應。
從你的角度來看,你無法控制服務提供者的重試排程,但你應該:
- 一旦安全地儲存事件後,立即回應
2xx(即使處理是非同步的)。 - 對於永久性失敗(例如格式錯誤的載荷)返回
4xx,讓服務提供者停止重試。 - 對於暫時性失敗(例如資料庫當機)返回
5xx以觸發重試。
冪等性:安全重試的關鍵
重試意味著你可能會多次收到同一個事件。你的處理必須是冪等的——處理同一個事件兩次應該與處理一次具有相同的效果。
常見做法:在載荷中包含一個唯一的事件 ID。在處理之前,檢查你是否已經處理過該 ID。如果是,則返回 200 並跳過。
async function handleWebhook(event) {
const existing = await db.events.findOne({ id: event.id });
if (existing) {
return { status: 200, body: 'Already processed' };
}
await db.events.insert({ id: event.id, processedAt: new Date() });
await processEvent(event);
return { status: 200, body: 'OK' };
}
這種模式確保即使服務提供者發送事件三次,你也只會執行一次操作。
比較:輪詢 vs Webhook
| 面向 | 輪詢 | Webhook |
|---|---|---|
| 延遲 | 取決於間隔(例如 30 秒) | 近乎即時 |
| 伺服器負載 | 恆定,即使沒有事件 | 僅在事件發生時 |
| 複雜度 | 易於實現 | 需要端點、驗證、重試 |
| 可靠性 | 你控制重試 | 取決於服務提供者的重試策略 |
Webhook 在即時更新方面勝出,但如果你擔心遺漏傳遞,輪詢可以作為關鍵事件的備用方案。
最佳實踐檢查清單
- 始終驗證簽章。 絕不信任未經驗證的 Webhook。
- 使用 HTTPS。 Webhook 載荷通常包含敏感資料。
- 快速回應。 迅速確認收到,然後根據需要非同步處理。
- 實現冪等性。 儲存事件 ID 以避免重複處理。
- 記錄一切。 保留收到事件的記錄以用於除錯和審計。
- 監控失敗。 為重複的 5xx 回應或簽章驗證失敗設定警報。
- 使用真實載荷測試。 使用服務提供者的測試模式或向你的端點發送範例事件。
除錯 Webhook 問題
當 Webhook 失敗時,第一步是檢查原始請求。常見問題:
- 簽章不符: 你使用了已解析的主體而不是原始主體。透過捕獲原始主體來修復。
- 逾時: 你的端點回應時間過長。將繁重的處理移至背景作業。
- 遺漏事件: 檢查服務提供者的儀表板以查看傳遞日誌和重試歷史。
如果你正在除錯 HTTP 流量,像 Nginx Log Analyzer 這樣的工具可以幫助你發現請求失敗和回應碼中的模式。
常見問題
什麼是 Webhook 密鑰,我該如何儲存它?
密鑰是用於簽署載荷的共享金鑰。將其儲存在環境變數或密鑰管理器中——絕不要放在程式碼或版本控制中。如果服務提供者支援,請定期輪換它。
服務提供者會重試失敗的 Webhook 多少次?
這因服務提供者而異。有些會以指數退避重試 24 小時,其他則重試 3 天。請查看你的服務提供者文件以了解確切的排程和最大嘗試次數。
我可以將 Webhook 與無伺服器函數一起使用嗎?
可以,但要注意執行時間限制。快速確認 Webhook(返回 200)並將繁重處理卸載到佇列或背景函數以避免逾時。
準備好建立穩健的 Webhook 整合了嗎?從驗證簽章和實現冪等性開始——這兩個步驟將為你節省數小時的除錯時間,並防止代價高昂的安全事件。