บทความทั้งหมด
ทดสอบ Zoom Webhook บน localhost ผ่าน HTTPS
ZoomwebhookslocalhostHMAC verification

ทดสอบ Zoom Webhook บน localhost ผ่าน HTTPS

หากต้องการทดสอบ Zoom Webhook บน localhost ให้เปิด route แบบ POST ในเครื่องด้วย npx portpreview PORT นำ URL HTTPS สาธารณะที่ได้ไปตั้งเป็น event notification endpoint และรองรับ challenge endpoint.url_validation ก่อนกด Validate สำหรับ event ปกติ ต้องตรวจสอบ x-zm-signature จาก request body เดิมที่ไม่ผ่านการแก้ไขและ timestamp บันทึก event แบบ idempotent และตอบกลับ 2xx ภายในสามวินาที

Event subscription ของ Zoom ส่งมาถึง localhost ได้อย่างไร

Zoom Webhook คือการแจ้งเตือนแบบ HTTP POST ที่มี JSON สำหรับ event ที่แอป subscribe ไว้ในผลิตภัณฑ์อย่าง Meetings, Webinars, Phone, Team Chat, Rooms และบริการอื่น ๆ รายการ event และ field ที่ใช้ได้ขึ้นอยู่กับประเภทแอป ผลิตภัณฑ์ที่เปิดใช้ สิทธิ์ของบัญชี scope และแพลตฟอร์ม Zoom เวอร์ชันปัจจุบัน เลือกเฉพาะ event ที่ handler รองรับ และอ้างอิง schema ล่าสุดที่แสดงในขั้นตอนสร้างแอป

Endpoint ต้องเป็น HTTPS สาธารณะที่มีชื่อโดเมนแบบเต็ม certificate chain จาก CA ที่เชื่อถือได้ ใช้ TLS 1.2 ขึ้นไป และรองรับ JSON POST ดังนั้น loopback URL อย่าง http://localhost:3000 จึงใช้ไม่ได้ PortPreview ทำหน้าที่รับ HTTPS ที่ public edge แล้ว forward request ไปยัง process ในเครื่อง

ควรยึด เอกสาร Zoom Webhook อย่างเป็นทางการ เป็นข้อมูลหลักสำหรับข้อกำหนดของ endpoint การตอบ challenge การตรวจสอบลายเซ็น พฤติกรรมการส่ง และขั้นตอนตั้งค่าล่าสุด

สร้าง Express route ที่เก็บ raw body ไว้ครบถ้วน

ลายเซ็นของ Zoom ครอบคลุมข้อความใน body ทุก byte จึงต้องเก็บ byte ก่อนที่ JSON middleware จะ parse แล้ว serialize ใหม่ ตัวอย่างนี้รองรับทั้งการ validate URL และการตรวจสอบ event ปกติใน route เดียว:

import crypto from 'node:crypto';
import express from 'express';

const app = express();
app.post('/webhooks/zoom', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
  const rawBody = req.body.toString('utf8');
  let event;
  try {
    event = JSON.parse(rawBody);
  } catch {
    return res.status(400).json({ error: 'Invalid JSON' });
  }

  const secret = process.env.ZOOM_WEBHOOK_SECRET_TOKEN;
  if (!secret) return res.sendStatus(500);

  if (event.event === 'endpoint.url_validation') {
    const plainToken = event.payload?.plainToken;
    if (typeof plainToken !== 'string') return res.sendStatus(400);
    const encryptedToken = crypto
      .createHmac('sha256', secret)
      .update(plainToken)
      .digest('hex');
    return res.status(200).json({ plainToken, encryptedToken });
  }

  const timestamp = req.get('x-zm-request-timestamp') ?? '';
  const received = req.get('x-zm-signature') ?? '';
  const message = `v0:${timestamp}:${rawBody}`;
  const expected = `v0=${crypto
    .createHmac('sha256', secret)
    .update(message)
    .digest('hex')}`;

  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
  if (!valid) return res.sendStatus(401);

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return res.sendStatus(401);

  const requestId = req.get('x-zm-request-id');
  const deliveryKey = requestId || crypto.createHash('sha256').update(rawBody).digest('hex');
  await saveWebhookOnce({ provider: 'zoom', deliveryKey, event });
  return res.sendStatus(200);
});

app.listen(3000);

ช่วงยอมรับ timestamp ห้านาทีในตัวอย่างเป็นนโยบายความปลอดภัยของแอป ไม่ใช่สิ่งที่ใช้แทนการตรวจสอบ HMAC ควรกำหนดช่วงเวลาให้เหมาะกับการ sync นาฬิกาและความล่าช้าที่คาดไว้ ใน log ให้บันทึกเฉพาะประเภทของข้อผิดพลาด ห้ามบันทึก secret token หรือ request body ทั้งหมด

เปิด local tunnel

  1. รันแอปและตรวจสอบว่า route รับ POST ในเครื่องที่ port 3000 ได้
  2. เปิด terminal อีกหน้าต่างแล้วรัน npx portpreview 3000 หากแอปใช้ port อื่นให้เปลี่ยน 3000 เป็น port นั้น
  3. ต่อท้าย route เข้ากับ origin ที่ระบบสร้างให้ เช่น https://YOUR-TUNNEL.portpreview.dev/webhooks/zoom
  4. เปิดทั้งแอปและ tunnel ค้างไว้ระหว่าง validate และทดสอบ event

เมื่อ tunnel ใหม่มี hostname ต่างจากเดิม Zoom จะถือว่าเป็น endpoint คนละตัว ต้องอัปเดตและ validate URL ใหม่ก่อนจึงจะรับ event ได้ URL ควรชี้ตรงไปยัง POST handler เพราะ redirect ไม่เหมาะกับการส่ง webhook ที่เชื่อถือได้ และ Zoom จะไม่ retry response แบบ 3xx

เพิ่ม event subscription ใน Zoom

ใน Zoom App Marketplace ให้เปิดแอปที่สร้างไว้ แล้วไปยังส่วน Features หรือ Access ตามหน้าสร้างแอปปัจจุบัน เปิด Event Subscriptions เพิ่ม subscription เลือกประเภท event และ receiver จากนั้นวาง URL ของ HTTPS endpoint แบบเต็ม ตัวเลือก receiver และ event จะแตกต่างตามประเภทแอปและการตั้งค่าบัญชี ส่วนแอปที่เผยแพร่แล้วอาจต้องเข้ารับการ review อีกครั้งเมื่อแก้ subscription

คัดลอก webhook secret token ของแอปไปเก็บใน environment variable ที่ไม่ถูก commit เช่น ZOOM_WEBHOOK_SECRET_TOKEN ค่านี้ไม่ใช่ OAuth client secret, access token หรือ verification token แบบเก่า หลังแก้ environment แล้วให้ restart local server

ทำ endpoint URL validation ให้ถูกต้อง

เมื่อกด Validate Zoom จะส่ง POST ที่มี event เป็น endpoint.url_validation และมี plainToken อยู่ใน payload ให้นำ webhook secret token มาเป็น key และ plain token เป็น message เพื่อคำนวณ HMAC SHA-256 จากนั้น encode digest เป็นเลขฐานสิบหกตัวพิมพ์เล็ก แล้วตอบ JSON ที่มีทั้ง plainToken ค่าเดิมและ encryptedToken ที่คำนวณได้

const encryptedToken = createHmac('sha256', webhookSecret)
  .update(event.payload.plainToken)
  .digest('hex');

return {
  plainToken: event.payload.plainToken,
  encryptedToken
};

ต้องตอบ HTTP 200 พร้อม JSON ภายในสามวินาที อย่า hash validation request ทั้งก้อน อย่าใช้ OAuth client secret อย่า encode digest เป็น Base64 และอย่าเติม v0= หน้า digest เพราะสิ่งเหล่านั้นเป็นคนละ flow ระบบจะยังบันทึก endpoint ไม่ได้จนกว่า validation ครั้งแรกจะสำเร็จ

เอกสารปัจจุบันของ Zoom ยังระบุว่าจะ validate ซ้ำอัตโนมัติทุก 72 ชั่วโมง เมื่อไม่ผ่านหลายครั้งจะส่งการแจ้งเตือนไปยังเจ้าของแอป และเมื่อไม่ผ่านติดต่อกันหกครั้ง Zoom จะปิด event subscription และหยุดส่ง event ดังนั้น development tunnel ที่ปิดไปแล้วจะไม่ผ่านการตรวจสอบรอบถัดไป ควรลบ subscription ชั่วคราวหลังทดสอบ และเปิด challenge handler ของ production ให้พร้อมใช้งานตลอดเวลา

ตรวจสอบ request ของ Zoom Webhook ปกติ

การ validate URL ยืนยันว่า endpoint รู้ secret ตอนตอบ challenge ส่วนการตรวจสอบ event ปกติยืนยันว่า body ที่ได้รับตรงกับ HMAC จาก Zoom ให้อ่าน x-zm-request-timestamp แล้วสร้าง message ต่อไปนี้ให้ตรงทุกตัวอักษร:

v0:{x-zm-request-timestamp}:{raw request body}

คำนวณ HMAC SHA-256 ของ message ด้วย webhook secret token เป็น key, encode digest เป็นเลขฐานสิบหก เติม v0= ด้านหน้า แล้วเปรียบเทียบกับ x-zm-signature แบบ constant-time ต้องใช้ request body ต้นฉบับเท่านั้น เพราะการ parse JSON แล้วเรียก JSON.stringify อาจเปลี่ยน whitespace หรือรูปแบบ property จนลายเซ็นที่ถูกต้องตรวจสอบไม่ผ่าน

ปฏิเสธลายเซ็นที่หายไป รูปแบบผิด ไม่ถูกต้อง หรือเก่าเกินช่วงที่กำหนดก่อนทำ business logic และตั้งเวลาเครื่องให้ sync อยู่เสมอ webhook verification token แบบเก่าถูกเลิกใช้และมีกำหนดยุติในเดือนมิถุนายน 2025 โค้ดใหม่จึงควรใช้ secret-token HMAC ตามเอกสาร Zoom ไม่ใช่เปรียบเทียบค่า Authorization ตามบทความเก่า ดูรายละเอียดเรื่อง raw body และ timing-safe comparison ได้ใน คู่มือตรวจสอบลายเซ็น Webhook

แยกประมวลผล event แต่ละประเภทของผู้ให้บริการ

แม้ payload จะผ่านการตรวจสอบลายเซ็นแล้ว ก็ยังต้อง validate schema และตรวจสิทธิ์ สำหรับ event ของ meeting นั้น meeting ID และ UUID มีหน้าที่ต่างกัน โดยการอ้างอิงด้วย UUID สำคัญเป็นพิเศษกับ meeting ที่เกิดซ้ำ ควรอ่าน field ตามประเภท event และใช้ reference ล่าสุดของ subscription นั้น

async function processZoomEvent(event) {
  switch (event.event) {
    case 'meeting.started':
      await markMeetingStarted({
        uuid: event.payload.object.uuid,
        startedAt: event.payload.object.start_time
      });
      break;
    case 'meeting.ended':
      await markMeetingEnded({
        uuid: event.payload.object.uuid,
        endedAt: event.payload.object.end_time
      });
      break;
    default:
      await recordUnhandledZoomEvent(event.event);
  }
}

อย่าถือว่าลำดับที่ event มาถึงเป็น transaction log เพราะ network delay, retry และการประมวลผลพร้อมกันอาจทำให้ลำดับสลับกัน ควรเก็บ timestamp จากผู้ให้บริการและใช้กฎการเปลี่ยน state แบบไปข้างหน้าเมื่อเหมาะสม สำหรับ event ที่ยังไม่รู้จัก ให้บันทึกอย่างปลอดภัย ทำให้ตรวจสอบย้อนหลังได้ และตอบรับแทนการปล่อยให้ endpoint ล้มเหลวซ้ำ ๆ

ตอบให้ทันกำหนดสามวินาที

Zoom คาดหวัง HTTP 200 หรือ 204 ภายในสามวินาทีจึงจะถือว่าส่งสำเร็จ ให้ตรวจสอบ request, validate envelope ขั้นต่ำ เขียนลง durable inbox หรือ queue แล้วตอบกลับ ส่วนการประมวลผลวิดีโอ อัปเดต CRM เรียก calendar ส่งอีเมล และ analytics ควรทำใน worker

ตามเอกสารการส่ง notification ปัจจุบันของ Zoom ความผิดพลาดของ server และ connection ที่เข้าเงื่อนไขจะถูก retry สามครั้ง คือประมาณห้านาทีหลังครั้งแรก จากนั้น 20 นาทีหลัง retry ครั้งนั้น และ 60 นาทีหลัง retry ครั้งที่สอง Zoom ถือว่า 2xx สำเร็จ และไม่ retry redirect แบบ 3xx หรือ client error แบบ 4xx เนื่องจากนโยบายอาจเปลี่ยน ควรตรวจหน้าเอกสารทางการอีกครั้งก่อนผูก alert เข้ากับช่วงเวลาเหล่านี้

ทำให้ทุก event เป็น idempotent

Zoom อาจ retry หลัง timeout ที่ไม่ชัดเจน แม้ request แรกจะ commit สำเร็จแล้ว จึงต้องตัดข้อมูลซ้ำก่อนเกิด side effect header x-zm-request-id อยู่ในโครงสร้าง request ที่ Zoom ระบุ แต่โค้ดควรรองรับกรณีที่ไม่มีในบางผลิตภัณฑ์หรือเวอร์ชัน หากมีให้ใช้ค่านี้ หากไม่มีให้สร้าง key ที่คงที่จากข้อมูล event ที่ตรวจสอบแล้วและไม่เปลี่ยนแปลง หรือ digest แบบเข้ารหัสของ raw body ที่ผ่านการตรวจสอบ ควรบังคับ uniqueness ที่ storage ไม่ใช่อาศัย in-memory cache เท่านั้น

await db.transaction(async (tx) => {
  const claimed = await tx.webhookInbox.insertOnce({
    provider: 'zoom',
    deliveryKey,
    eventType: event.event,
    payload: event
  });
  if (!claimed) return;
  await tx.jobs.enqueue({ type: 'process-zoom-event', deliveryKey });
});

การจอง record ใน inbox และการสร้าง job ต้องเป็น atomic transaction เดียวกัน หาก worker ล้มเหลวให้ retry job โดยไม่ต้องขอให้ Zoom ส่งใหม่ อ่านเรื่อง inbox table, unique key และขอบเขตของ side effect เพิ่มเติมได้ใน คู่มือ retry และ idempotency

แก้ปัญหา validation และการส่ง event

Validate แจ้งว่าไม่สำเร็จ

ตรวจว่า URL เป็น HTTPS สาธารณะ มี route ตรงทุกตัวอักษร ไม่มี redirect และเข้าถึง port ในเครื่องที่กำลังทำงานได้ ยืนยันว่า response เป็น HTTP 200 JSON ที่มี plain token เดิมกับ HMAC ของ token นั้นเพียงค่าเดียวในรูปเลขฐานสิบหกตัวพิมพ์เล็ก และวัดเวลาตอบกลับทั้งหมดให้ไม่เกินสามวินาที

Event ปกติทุกอันตรวจสอบลายเซ็นไม่ผ่าน

ตรวจว่าใช้ webhook secret token ไม่ใช่ OAuth client secret หรือ verification token แบบเก่า เก็บ body เป็น raw bytes ก่อน JSON middleware ใช้ timestamp จาก header ให้ตรง ใส่ colon ทั้งสองตำแหน่งใน message v0:timestamp:body และเติม v0= เฉพาะหน้าลายเซ็น event ขั้นสุดท้าย

Validation ผ่านแต่ไม่มี event เข้ามา

ตรวจว่า subscription เปิดใช้และบันทึกแล้ว เลือกประเภท event และ receiver ที่ต้องการ และบัญชีหรือผู้ใช้ได้ทำให้ event นั้นเกิดขึ้นจริง ตรวจสถานะ revalidation และข้อกำหนดการเผยแพร่แอป รวมถึงยืนยันว่า tunnel URL ไม่ได้เปลี่ยนหลัง validation

Handler ทำงานสำเร็จแต่ Zoom ยัง retry

ตรวจ latency และ status ของ response ฝั่ง public ไม่ใช่ดูเฉพาะ local log งานที่ช้าอาจใช้เกินสามวินาทีแม้จะเสร็จในภายหลัง ให้ persist อย่างรวดเร็ว ตอบ 2xx และประมวลผลแบบ asynchronous โดย inbox ที่ป้องกันข้อมูลซ้ำจะช่วยไม่ให้ retry ทำ side effect ซ้ำ

Event ที่บันทึกไว้ตรวจสอบไม่ผ่านเมื่อนำมา replay

freshness check ควรปฏิเสธ timestamp เก่า และการแก้ JSON จะทำให้ HMAC ใช้ไม่ได้ สำหรับ end-to-end test ให้สร้าง event ใหม่จากผู้ให้บริการ ส่วนการทดสอบ business logic ให้เก็บ fixture ที่ parse และลบข้อมูลสำคัญแล้ว จากนั้นเรียก dispatcher โดย bypass การตรวจ ingress เฉพาะใน test harness อ่านแนวทางแยกสองส่วนนี้ได้ใน คู่มือ replay Webhook เพื่อแก้บั๊ก

เช็กลิสต์ความปลอดภัยสำหรับทดสอบ Zoom Webhook

  • เก็บ webhook secret token ในไฟล์ environment ที่ไม่ถูก commit และ rotate credential ที่รั่วไหล
  • ตรวจสอบ HMAC จาก raw body ก่อนเชื่อถือ field ใด ๆ ใน payload
  • กำหนดช่วงยอมรับ timestamp และ sync นาฬิกา server เพื่อลดความเสี่ยงจาก replay
  • ตรวจ event type, account context, object identifier, content type และขนาด body
  • ปกปิดชื่อผู้เข้าร่วม อีเมล หัวข้อ meeting เนื้อหา chat และข้อมูล recording ใน log
  • แยก endpoint หรือ secret ระหว่าง development กับ production หากการตั้งค่าแอปรองรับ
  • ลบ URL สาธารณะและ subscription ชั่วคราวเมื่อจบ session ในเครื่อง

แนวทางสำหรับ production คือรูปแบบเดียวกับที่ทดสอบในเครื่อง: HTTPS ingress ที่เสถียร, challenge handler ที่พร้อมตลอดเวลา, การตรวจ HMAC จาก raw body, idempotency ที่คงทน, การตอบรับภายในสามวินาที และ worker ที่แยกจาก ingress สำหรับการ trace request และตรวจ route ทั่วไป ดู คู่มือ debug Webhook ในเครื่อง

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

จะ validate URL ของ Zoom Webhook บน localhost ได้อย่างไร
เปิด route ในเครื่องผ่าน HTTPS แล้วตอบ endpoint.url_validation ด้วย plainToken ค่าเดิมและ encryptedToken แบบ HMAC SHA-256 เลขฐานสิบหก ซึ่งคำนวณโดยใช้ Zoom webhook secret token เป็น key
จะตรวจสอบลายเซ็นของ Zoom Webhook ปกติอย่างไร
สร้างข้อความ v0:{x-zm-request-timestamp}:{raw body} คำนวณ HMAC SHA-256 ด้วย webhook secret token เติม v0= หน้าค่า digest แบบเลขฐานสิบหก แล้วเปรียบเทียบกับ x-zm-signature
ทำไม URL validation ผ่าน แต่ตรวจสอบลายเซ็น event ไม่ผ่าน
สอง flow ใช้ message คนละแบบ โดย validation hash เฉพาะ plainToken ส่วน event ปกติ sign เวอร์ชัน request timestamp และ raw body ที่ตรงทุก byte การ parse แล้ว serialize JSON ใหม่จึงทำให้ลายเซ็นไม่ตรงได้
Zoom Webhook ต้องตอบกลับเร็วแค่ไหน
เอกสารปัจจุบันของ Zoom กำหนดให้ตอบ HTTP 200 หรือ 204 ภายในสามวินาที ควร persist หรือ enqueue event ที่ตรวจสอบแล้ว ตอบกลับทันที และทำ business logic ที่ใช้เวลานานแบบ asynchronous