驗證簽章
每個請求都帶三個標頭,採用 Standard Webhooks 規範:
| 標頭 | 說明 |
|---|---|
webhook-id | 事件 ID;重試時不變 |
webhook-timestamp | 送出時間(Unix 秒);每次重試更新 |
webhook-signature | 一或多組 v1,<base64>,以空白分隔 |
演算法
signed_content = webhook-id + "." + webhook-timestamp + "." + 原始 body
key = base64_decode(API Key 去掉 "whsec_" 前綴)
signature = "v1," + base64(HMAC-SHA256(key, signed_content))
驗證時:
- 三個標頭都必須存在
webhook-timestamp與現在時間相差不得超過 5 分鐘(防重放)- 計算期望簽章,與
webhook-signature中的任一組做常數時間比較 - 一定要用原始 body。先
JSON.parse再序列化,空白或欄位順序改變就會驗證失敗
API Key 不會出現在請求中。不要要求我們以
Authorization標頭傳送金鑰;明文標頭容易被反向代理、WAF 或監控工具寫進日誌。
金鑰輪替
在 App 或入口網站輪替後,24 小時內 webhook-signature 會同時帶新舊兩組簽章。請在這段期間把系統更新為新金鑰。
使用官方函式庫
Standard Webhooks 提供多種語言的函式庫,可直接使用:
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.UDILENS_API_KEY); // whsec_...
const event = wh.verify(rawBody, headers); // 驗證失敗會拋出錯誤
各語言範例
Python(Flask)
import base64, hashlib, hmac, time
def verify(api_key: str, headers, raw_body: bytes) -> bool:
msg_id, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
if not (msg_id and ts and sigs) or abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(api_key.removeprefix("whsec_"))
expected = "v1," + base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(s, expected) for s in sigs.split(" "))
C#(.NET 8)
static bool Verify(string apiKey, string id, string ts, string sigs, string rawBody)
{
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(ts)) > 300) return false;
using var hmac = new HMACSHA256(Convert.FromBase64String(apiKey["whsec_".Length..]));
var expected = Encoding.UTF8.GetBytes("v1," + Convert.ToBase64String(
hmac.ComputeHash(Encoding.UTF8.GetBytes($"{id}.{ts}.{rawBody}"))));
return sigs.Split(' ').Any(s => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(s), expected));
}
Java 17
static boolean verify(String apiKey, String id, String ts, String sigs, String body) throws Exception {
if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(ts)) > 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(Base64.getDecoder().decode(apiKey.substring(6)), "HmacSHA256"));
byte[] expected = ("v1," + Base64.getEncoder().encodeToString(
mac.doFinal((id + "." + ts + "." + body).getBytes(StandardCharsets.UTF_8)))).getBytes(StandardCharsets.UTF_8);
for (String s : sigs.split(" ")) if (MessageDigest.isEqual(s.getBytes(StandardCharsets.UTF_8), expected)) return true;
return false;
}
測試向量
用這組資料確認你的實作:
| 項目 | 值 |
|---|---|
| API Key | whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw |
| webhook-id | msg_p5jXN8AQM9LWM0D4loKWxJek |
| webhook-timestamp | 1614265330 |
| body | {"test": 2432232314} |
| 期望簽章 | v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
(測試向量的時間戳很舊,驗證時請暫時略過時間檢查。)也可以在線上收件器的「簽章練習」貼上資料逐步檢查。