外部サービスから 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 を削除します。
