HubSpot 如何傳遞 Webhook

一個 5 秒的時限要涵蓋最多 100 則通知的整個批次,而且文件寫明 eventId 不保證唯一。附 v3 簽章與 Node 驗證程式。

傳遞行為

取自服務商自己的文件,最後檢查於 2026-08-06。服務商沒寫數字的地方,這張表也不填。

回應時限5 秒

以整個批次計算,一個批次最多可含 100 則通知

檢查你的處理程式能否及時回應

重試最多嘗試 10 次,分散在 24 小時內
放棄服務商未公開
出處https://developers.hubspot.com/docs/api-reference/latest/webhooks/guide

簽章

用一個經測試套件證明可運作的驗證程式核對過,不是照文件抄來的。最後驗證於 2026-08-20。

演算法HMAC-SHA256
簽章內容POST{decodedUrl}{body}{timestamp}
編碼base64
標頭x-hubspot-signature-v3
容許時間差5 分鐘

驗證簽章

要用原始請求主體(raw body),逐位元組原封不動,並在任何 JSON 解析之前取得。框架只要重新序列化 payload,這頁上的每一種機制就會立刻失效。

const crypto = require("node:crypto");

// v3: base64(HMAC-SHA256(secret, "POST" + url + body + timestamp))
const ts = req.headers["x-hubspot-request-timestamp"];
if (Math.abs(Date.now() - Number(ts)) > 300_000) throw new Error("stale");

const expected = crypto
  .createHmac("sha256", clientSecret) // the app's client secret
  .update("POST" + fullUrl + rawBody + ts)
  .digest("base64");
const received = Buffer.from(req.headers["x-hubspot-signature-v3"] ?? "");
const wanted = Buffer.from(expected);
// timingSafeEqual throws on a length mismatch, so check that first.
const valid =
  received.length === wanted.length && crypto.timingSafeEqual(received, wanted);

fullUrl 是 HubSpot 實際呼叫的公開網址,包含查詢字串,並且經過 URL 解碼。如果前面有會改寫 Host 標頭的代理伺服器,要從轉送標頭重建它,否則簽章永遠對不上。

容易踩的坑

5 秒的時限以請求計算,而一個請求最多可帶 100 則通知。如果在請求中逐一處理,實際每則只有 50ms。

HubSpot 的文件寫明 eventId 不保證唯一,也不承諾順序,所以在 Stripe 用事件 ID 去重的習慣,搬到這裡不管用。

延伸閱讀

AnyHook 架在接收 HubSpot Webhook 的端點前面:大約 1 秒就回應,伺服器當機時依自己的排程重試,並讓每個事件都能重播。 換一個網址,程式碼不用動;要接受重試與重播,改為檢查 AnyHook-Signature,因為 HubSpot 自己的簽章在 5 分鐘後就會過期。

運作方式 →