บทความทั้งหมด
ทดสอบ HubSpot webhook บน localhost อย่างปลอดภัย
HubSpotwebhookslocalhostCRM integrations

ทดสอบ HubSpot webhook บน localhost อย่างปลอดภัย

บริการภายนอกเรียก localhost โดยตรงไม่ได้ tunnel จะสร้าง URL HTTPS สาธารณะและส่งต่อ headers กับ body ต้นฉบับมายังเซิร์ฟเวอร์ในเครื่อง เก็บ raw body ก่อน JSON parser ตรวจสอบลายเซ็นและข้อมูล บันทึกการส่งลง storage แบบถาวร แล้วจึงเริ่ม business logic

เส้นทางของ HubSpot webhook มายังแอปในเครื่อง

บริการภายนอกเรียก localhost โดยตรงไม่ได้ tunnel จะสร้าง URL HTTPS สาธารณะและส่งต่อ headers กับ body ต้นฉบับมายังเซิร์ฟเวอร์ในเครื่อง >เอกสารอย่างเป็นทางการ

1. สร้าง endpoint ที่เก็บ raw body

เก็บ raw body ก่อน JSON parser ตรวจสอบลายเซ็นและข้อมูล บันทึกการส่งลง storage แบบถาวร แล้วจึงเริ่ม business logic

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);

บันทึก URL HTTPS แบบเต็ม เลือกเฉพาะ event ที่ต้องใช้ และสร้าง event จริงใน workspace ทดสอบ เก็บ secret นอก repository

2. เปิดพอร์ตในเครื่องผ่าน HTTPS

เปิดเซิร์ฟเวอร์และรัน tunnel ใน terminal อีกหน้าตลอดการทดสอบ การได้ 401 จาก request ที่ไม่มีลายเซ็นแสดงว่า routing ทำงาน

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

เปิดเซิร์ฟเวอร์และรัน tunnel ใน terminal อีกหน้าตลอดการทดสอบ การได้ 401 จาก request ที่ไม่มีลายเซ็นแสดงว่า routing ทำงาน

3. ตั้งค่า webhook ใน HubSpot

บันทึก URL HTTPS แบบเต็ม เลือกเฉพาะ event ที่ต้องใช้ และสร้าง event จริงใน workspace ทดสอบ เก็บ secret นอก repository >เอกสารอย่างเป็นทางการ

รองรับเฉพาะ type ที่รู้จัก ยอมรับ field ใหม่ และใช้รหัสการส่งที่คงที่เพื่อ deduplication ใน storage

ตรวจสอบลายเซ็นจากไบต์ต้นฉบับ

คำนวณ HMAC ด้วย webhook secret และข้อมูลต้นฉบับตามสเปก เปรียบเทียบแบบ constant-time และห้ามเขียน secret ลง log >เอกสารอย่างเป็นทางการ

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 หลังลายเซ็นผ่าน และตั้งเวลาเครื่องให้ตรงเพื่อไม่ให้ request ที่ถูกต้องถูกปฏิเสธ

คำนวณ HMAC ด้วย webhook secret และข้อมูลต้นฉบับตามสเปก เปรียบเทียบแบบ constant-time และห้ามเขียน secret ลง log >คู่มือฉบับเต็ม

ตอบกลับเร็วและประมวลผลแบบ idempotent

รองรับเฉพาะ type ที่รู้จัก ยอมรับ field ใหม่ และใช้รหัสการส่งที่คงที่เพื่อ deduplication ใน storage

จองรหัสการส่งและสร้าง job ใน transaction เดียวก่อนตอบ 200 หากบันทึกถาวรไม่ได้ให้ตอบ error เพื่อรอ retry >คู่มือฉบับเต็ม

แก้ปัญหา HubSpot webhook

  • เปิดเซิร์ฟเวอร์และรัน tunnel ใน terminal อีกหน้าตลอดการทดสอบ การได้ 401 จาก request ที่ไม่มีลายเซ็นแสดงว่า routing ทำงาน
  • คำนวณ HMAC ด้วย webhook secret และข้อมูลต้นฉบับตามสเปก เปรียบเทียบแบบ constant-time และห้ามเขียน secret ลง log
  • ตรวจ routing, headers, ลายเซ็น เวลาตอบกลับ และ idempotency โดยใช้ request จำลองสำหรับทดสอบเส้นทางปฏิเสธ
  • ตรวจ POST path, port ของ tunnel, raw body, secret, เวลาเครื่อง และ latency ของฐานข้อมูล

>คู่มือฉบับเต็ม · >คู่มือฉบับเต็ม

รายการตรวจสอบความปลอดภัยสำหรับเครื่องพัฒนาและ production

  • บังคับ HTTPS จำกัดขนาดและ method ป้องกัน secret ปกปิดข้อมูลใน log และลบ URL ทดสอบที่หมดอายุ
  • เก็บ raw body ก่อน JSON parser ตรวจสอบลายเซ็นและข้อมูล บันทึกการส่งลง storage แบบถาวร แล้วจึงเริ่ม business logic
  • จองรหัสการส่งและสร้าง job ใน transaction เดียวก่อนตอบ 200 หากบันทึกถาวรไม่ได้ให้ตอบ error เพื่อรอ retry

บังคับ HTTPS จำกัดขนาดและ method ป้องกัน secret ปกปิดข้อมูลใน log และลบ URL ทดสอบที่หมดอายุ

คำถามที่พบบ่อย

จะทดสอบ HubSpot webhook บน localhost ได้อย่างไร
เปิดเซิร์ฟเวอร์และรัน tunnel ใน terminal อีกหน้าตลอดการทดสอบ การได้ 401 จาก request ที่ไม่มีลายเซ็นแสดงว่า routing ทำงาน
จะตรวจสอบลายเซ็น HubSpot webhook ได้อย่างไร
คำนวณ HMAC ด้วย webhook secret และข้อมูลต้นฉบับตามสเปก เปรียบเทียบแบบ constant-time และห้ามเขียน secret ลง log
ทำไม HubSpot จึงส่ง webhook ซ้ำ
จองรหัสการส่งและสร้าง job ใน transaction เดียวก่อนตอบ 200 หากบันทึกถาวรไม่ได้ให้ตอบ error เพื่อรอ retry
ควรใช้ค่าใดเป็น idempotency key
รองรับเฉพาะ type ที่รู้จัก ยอมรับ field ใหม่ และใช้รหัสการส่งที่คงที่เพื่อ deduplication ใน storage