Webhook 詳解:簽名載荷與重試機制

Backend2026-10-29TryQuickToolBox

你剛剛整合了一個第三方支付提供商。文件上寫著:「支付成功時我們會發送一個 Webhook。」聽起來很簡單,對吧?但接著你意識到:你怎麼知道這個請求真的來自他們?如果他們發送時你的伺服器掛了怎麼辦?如果他們同一個事件發送了兩次呢?

Webhook 很強大,但需要小心處理。本指南涵蓋了基本要點:對載荷進行簽名以驗證真實性、實作重試以處理失敗,以及確保冪等性以避免重複處理。

什麼是 Webhook?

Webhook 是一種 HTTP 回呼:與其讓你的應用程式輪詢 API 來獲取變更,不如由提供商在事件發生時向你指定的 URL 發送 HTTP POST 請求。這使得整合高效且近乎即時。

常見的例子包括支付通知(Stripe、PayPal)、CI/CD 觸發器(GitHub、GitLab)以及訊息平台(Slack、Discord)。

為什麼要用簽名保護 Webhook?

你的 Webhook 端點是公開可存取的。任何人都可以向它發送 POST 請求。如果沒有驗證,攻擊者可以偽造事件——例如,將未支付的訂單標記為已支付。

使用共享密鑰對載荷進行簽名,可以讓你驗證請求確實來自預期的發送方,並且載荷未被篡改。

HMAC 簽名如何運作

提供商與你共享一個密鑰。對於每個 Webhook,提供商使用該密鑰計算載荷的 HMAC(基於雜湊的訊息驗證碼),並將簽名包含在標頭中(例如 X-Signature)。你在自己這邊重新計算 HMAC 並進行比較。

HMAC 確保完整性和真實性,因為只有擁有密鑰的人才能產生有效的簽名。

實作簽名載荷

讓我們逐步了解 Node.js(Express)和 Python(Flask)中的典型實作。這些原則適用於任何語言。

步驟 1:取得並儲存密鑰

當你註冊 Webhook 時,提供商會給你一個密鑰。請安全地儲存它——使用環境變數或密鑰管理器——切勿寫在程式碼中。

步驟 2:捕獲原始請求主體

簽名驗證需要確切的原始請求主體位元組。如果你的框架在驗證之前解析了 JSON,重新序列化可能會改變位元組並導致驗證失敗。

在 Express 中,對 Webhook 路由使用 express.raw({ type: 'application/json' })。在 Flask 中,在解析之前存取 request.get_data()。

步驟 3:計算並比較簽名

使用恆定時間比較函數以防止計時攻擊。

// Node.js (Express)
const crypto = require('crypto');

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-signature'];
  const secret = process.env.WEBHOOK_SECRET;
  const expected = crypto.createHmac('sha256', secret).update(req.body).digest('hex');
  
  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(401).send('Invalid signature');
  }
  
  // Process event
  const event = JSON.parse(req.body);
  // ... handle event
  res.status(200).send('OK');
});

在 Python 中,使用 hmac.compare_digest:

# Python (Flask)
import hmac, hashlib, os
from flask import request, abort

@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Signature')
    secret = os.environ['WEBHOOK_SECRET'].encode()
    expected = hmac.new(secret, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401)
    event = request.get_json()
    # ... handle event
    return 'OK', 200

步驟 4:處理時間戳(可選但建議)

一些提供商在簽名中包含時間戳以防止重放攻擊。例如,Stripe 的方案是對 timestamp.payload 進行簽名。你應該拒絕時間戳過於久遠的請求(例如超過 5 分鐘)。

實作重試

網路會故障。你的伺服器可能宕機,或者請求可能超時。可靠的 Webhook 系統會重試失敗的傳遞。

在提供商端

如果你正在建立 Webhook 發送器,請實作帶有指數退避的重試。常見模式:1 分鐘後重試,然後 5 分鐘,然後 30 分鐘,依此類推,直到最大嘗試次數(例如 5 次)。在載荷中包含唯一的事件 ID,以便接收方可以去重。

在接收方端

作為接收方,你應該:

冪等性:處理重複事件

重試可能導致重複事件。使用事件 ID(大多數提供商都會包含)來追蹤已處理的事件。在處理之前,檢查該 ID 是否已見過;如果是,則跳過。

將已處理的 ID 儲存在具有 TTL 的資料庫中,或在業務邏輯中使用冪等鍵。

在本機測試 Webhook

在開發過程中,你的本機伺服器不是公開可存取的。像 ngrok 或 localtunnel 這樣的工具會建立一個公共 URL,隧道連接到你的機器。或者,使用一個捕獲請求並允許你重放的 Webhook 測試服務。

常見陷阱與最佳實踐

比較:簽名與未簽名 Webhook

方面簽名未簽名
安全性高:驗證來源和完整性低:任何人都可以偽造
實作工作量中等:計算並比較 HMAC極少
推薦用於所有生產系統僅限內部、受信任的網路

常見問題

如何驗證 Webhook 簽名?

使用共享密鑰計算原始請求主體的 HMAC,然後使用恆定時間函數將其與簽名標頭進行比較。始終使用原始主體,而不是解析後的 JSON。

我的 Webhook 端點應該返回什麼?

快速返回 2xx 狀態碼以確認收到。如果處理失敗,返回 5xx 以觸發重試,但請確保你的端點具有冪等性以處理重複。

Webhook 會被重試多少次?

這取決於提供商。大多數會在數小時或數天內使用指數退避重試幾次。請查看提供商的文件以了解具體細節。

在除錯 Webhook 傳遞時,通常需要檢查伺服器日誌。如果你使用的是 Nginx,Nginx 日誌分析器 可以幫助你解析和分析存取日誌,以發現失敗的請求或不尋常的模式。