所有文章
在 localhost 安全测试 HubSpot webhook
HubSpotwebhookslocalhostCRM integrations

在 localhost 安全测试 HubSpot webhook

远程服务无法直接访问 localhost。隧道提供公网 HTTPS URL,并把真实 headers 和未经修改的 body 转发到本地服务。 必须在 JSON parser 之前保留 raw body。先验证签名和输入,再持久化投递记录,最后执行后续业务逻辑。

HubSpot webhook 如何到达本地应用

远程服务无法直接访问 localhost。隧道提供公网 HTTPS URL,并把真实 headers 和未经修改的 body 转发到本地服务。 >官方文档

1. 创建保留 raw body 的 endpoint

必须在 JSON parser 之前保留 raw body。先验证签名和输入,再持久化投递记录,最后执行后续业务逻辑。

import express from "express";
import crypto from "node:crypto";

const app = express();
const secret = process.env.HUBSPOT_CLIENT_SECRET;
const publicBase = process.env.WEBHOOK_PUBLIC_BASE_URL;

app.post(
  "/webhooks/hubspot",
  express.raw({ type: "application/json", limit: "1mb" }),
  async (req, res) => {
    const signature = req.get("x-hubspot-signature-v3");
    const timestamp = req.get("x-hubspot-request-timestamp");
    const rawBody = req.body.toString("utf8");

    if (!verifyHubSpotV3({
      signature,
      timestamp,
      method: req.method,
      publicUri: `${publicBase}${req.originalUrl}`,
      rawBody,
      secret,
    })) {
      return res.sendStatus(401);
    }

    let events;
    try {
      events = JSON.parse(rawBody);
      if (!Array.isArray(events)) throw new Error("Expected a batch");
      await enqueueBatchIdempotently(events);
    } catch (error) {
      console.error("HubSpot webhook rejected", error);
      return res.sendStatus(500);
    }

    return res.sendStatus(200);
  }
);

app.use(express.json());
app.listen(3000);

填写完整 HTTPS URL,只订阅需要的事件,并在测试环境触发真实操作。secret 应保存在代码仓库之外。

2. 通过 HTTPS 暴露本地端口

启动本地服务后,在另一个终端保持隧道运行。未签名请求返回 401,说明路由正常且鉴权按预期拒绝请求。

npx portpreview 3000
https://example.portpreview.dev/webhooks/hubspot

启动本地服务后,在另一个终端保持隧道运行。未签名请求返回 401,说明路由正常且鉴权按预期拒绝请求。

3. 在 HubSpot 中配置 webhook

填写完整 HTTPS URL,只订阅需要的事件,并在测试环境触发真实操作。secret 应保存在代码仓库之外。 >官方文档

只处理已知 type,兼容新增字段,并用稳定的投递 ID 在持久化存储中去重。

使用原始字节验证签名

按供应商规范使用 webhook secret 和精确原始数据计算 HMAC,采用 constant-time 比较,禁止记录 secret。 >官方文档

function decodeHubSpotQuery(uri) {
  const [base, query] = uri.split("?", 2);
  if (query === undefined) return base;
  const map = {
    "%3A": ":", "%2F": "/", "%3F": "?", "%40": "@",
    "%21": "!", "%24": "$", "%27": "'", "%28": "(",
    "%29": ")", "%2A": "*", "%2C": ",", "%3B": ";",
  };
  const decoded = query.replace(
    /%3A|%2F|%3F|%40|%21|%24|%27|%28|%29|%2A|%2C|%3B/g,
    value => map[value]
  );
  return `${base}?${decoded}`;
}

function verifyHubSpotV3(input) {
  if (!input.secret || !input.signature || !input.timestamp) return false;

  const sentAt = Number(input.timestamp);
  if (!Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > 300_000) {
    return false;
  }

  const uri = decodeHubSpotQuery(input.publicUri.split("#")[0]);
  const source = `${input.method}${uri}${input.rawBody}${input.timestamp}`;
  const expected = crypto
    .createHmac("sha256", input.secret)
    .update(source, "utf8")
    .digest("base64");

  const actualBuffer = Buffer.from(input.signature, "utf8");
  const expectedBuffer = Buffer.from(expected, "utf8");
  return actualBuffer.length === expectedBuffer.length &&
    crypto.timingSafeEqual(actualBuffer, expectedBuffer);
}

签名通过后检查 timestamp 是否新鲜,并保持主机时间同步,避免误拒绝合法投递。

按供应商规范使用 webhook secret 和精确原始数据计算 HMAC,采用 constant-time 比较,禁止记录 secret。 >实用指南

快速响应并保证幂等处理

只处理已知 type,兼容新增字段,并用稳定的投递 ID 在持久化存储中去重。

在同一 transaction 中预留投递 ID 并创建 job,然后返回 200。持久化不可用时应返回错误等待重试。 >实用指南

排查 HubSpot webhook 投递问题

  • 启动本地服务后,在另一个终端保持隧道运行。未签名请求返回 401,说明路由正常且鉴权按预期拒绝请求。
  • 按供应商规范使用 webhook secret 和精确原始数据计算 HMAC,采用 constant-time 比较,禁止记录 secret。
  • 依次验证路由、headers、签名、响应时限和幂等性。模拟请求更适合测试拒绝分支。
  • 重点检查 POST path、隧道 port、raw body、secret、系统时间和数据库延迟。

>实用指南 · >实用指南

本地与生产环境安全清单

  • 强制使用 HTTPS,限制请求大小与 method,保护 secret,脱敏日志,并删除过期测试 URL。
  • 必须在 JSON parser 之前保留 raw body。先验证签名和输入,再持久化投递记录,最后执行后续业务逻辑。
  • 在同一 transaction 中预留投递 ID 并创建 job,然后返回 200。持久化不可用时应返回错误等待重试。

强制使用 HTTPS,限制请求大小与 method,保护 secret,脱敏日志,并删除过期测试 URL。

常见问题

如何在 localhost 测试 HubSpot webhook?
启动本地服务后,在另一个终端保持隧道运行。未签名请求返回 401,说明路由正常且鉴权按预期拒绝请求。
如何验证 HubSpot webhook 签名?
按供应商规范使用 webhook secret 和精确原始数据计算 HMAC,采用 constant-time 比较,禁止记录 secret。
HubSpot 为什么会重试 webhook?
在同一 transaction 中预留投递 ID 并创建 job,然后返回 200。持久化不可用时应返回错误等待重试。
幂等处理应该使用哪个键?
只处理已知 type,兼容新增字段,并用稳定的投递 ID 在持久化存储中去重。