HubSpot 如何投递 Webhook

HubSpot 的 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 分钟

验证签名

要用原始请求体,逐字节一致,并且在任何 JSON 解析之前。框架一旦重新序列化请求体,本页的每一种方案都会失效。

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,包括查询字符串,并经过 URL 解码。如果前面有改写 Host 请求头的代理,要根据转发相关的请求头还原它,否则签名永远对不上。

容易踩的坑

5 秒的时限按请求计算,而一个请求最多可以带 100 条通知。如果在请求里直接处理这些通知,每条实际只有 50 毫秒。

HubSpot 的文档写明 eventId 不保证唯一,也不承诺顺序,所以在 Stripe 那里按事件 ID 去重的习惯,在这里行不通。

延伸阅读

AnyHook 挡在接收 HubSpot Webhook 的端点前面:约 1 秒应答,你的服务器宕机时按自己的计划重试,并且每个事件都可以重放。 只改一个 URL,代码不用动;要接受重试和重放,就验证 AnyHook-Signature 请求头,因为 HubSpot 自己的签名 5 分钟后就会过期。

工作原理 →