บริการภายนอกเรียก 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 ทดสอบที่หมดอายุ
