すべての記事
HubSpot webhook を localhost で安全にテストする方法
HubSpotwebhookslocalhostCRM integrations

HubSpot webhook を localhost で安全にテストする方法

外部サービスから localhost へ直接接続はできません。トンネルは公開 HTTPS URL を用意し、実際の headers と変更されていない body をローカルサーバーへ転送します。 JSON parser より先に raw body を保存します。署名と入力を確認し、配信を永続化してから業務処理を開始します。

HubSpot webhook がローカル環境へ届く仕組み

外部サービスから localhost へ直接接続はできません。トンネルは公開 HTTPS URL を用意し、実際の headers と変更されていない body をローカルサーバーへ転送します。 >公式ドキュメント

1. raw body を保持するエンドポイントを作る

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 は routing と認証拒否が正常な証拠です。

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

サーバーを起動し、別のターミナルでトンネルを開いたままにします。未署名リクエストへの 401 は routing と認証拒否が正常な証拠です。

3. HubSpot で webhook を設定する

完全な HTTPS URL と必要なイベントだけを登録し、テスト環境で実際にイベントを発生させます。secret はリポジトリ外に保存します。 >公式ドキュメント

既知の type だけを処理し、追加フィールドは許容します。安定した配信 ID を永続ストレージで重複排除に使います。

元のバイト列で署名を検証する

webhook secret と仕様で定められた正確なデータから HMAC を計算します。定数時間で比較し、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 を計算します。定数時間で比較し、secret をログへ出力しません。 >実践ガイド

すばやく応答し冪等に処理する

既知の type だけを処理し、追加フィールドは許容します。安定した配信 ID を永続ストレージで重複排除に使います。

配信 ID の予約と job 作成を同じ transaction で行ってから 200 を返します。永続化できない場合はエラーを返します。 >実践ガイド

HubSpot webhook のトラブルシューティング

  • サーバーを起動し、別のターミナルでトンネルを開いたままにします。未署名リクエストへの 401 は routing と認証拒否が正常な証拠です。
  • webhook secret と仕様で定められた正確なデータから HMAC を計算します。定数時間で比較し、secret をログへ出力しません。
  • routing、headers、署名、応答時間、冪等性を順に確認します。合成リクエストは拒否経路のテストに向いています。
  • POST path、トンネルの port、raw body、secret、時計、DB latency を確認してください。

>実践ガイド · >実践ガイド

ローカル環境と本番環境のセキュリティ確認

  • HTTPS を必須にし、サイズと method を制限し、secret とログを保護して、古いテスト URL を削除します。
  • JSON parser より先に raw body を保存します。署名と入力を確認し、配信を永続化してから業務処理を開始します。
  • 配信 ID の予約と job 作成を同じ transaction で行ってから 200 を返します。永続化できない場合はエラーを返します。

HTTPS を必須にし、サイズと method を制限し、secret とログを保護して、古いテスト URL を削除します。

よくある質問

localhost で HubSpot webhook をテストするには?
サーバーを起動し、別のターミナルでトンネルを開いたままにします。未署名リクエストへの 401 は routing と認証拒否が正常な証拠です。
HubSpot webhook の署名を検証するには?
webhook secret と仕様で定められた正確なデータから HMAC を計算します。定数時間で比較し、secret をログへ出力しません。
HubSpot が webhook を再送するのはなぜですか?
配信 ID の予約と job 作成を同じ transaction で行ってから 200 を返します。永続化できない場合はエラーを返します。
冪等性にはどのキーを使いますか?
既知の type だけを処理し、追加フィールドは許容します。安定した配信 ID を永続ストレージで重複排除に使います。