Calendly

Calendly เป็นเครื่องมือนัดหมายออนไลน์ที่ใช้กันอย่างแพร่หลาย แต่ฟังก์ชันเตือนผ่าน SMS ในตัวรองรับเพียงบางประเทศและภูมิภาคเท่านั้น และครอบคลุมตลาดเอเชียแปซิฟิก เอเชียตะวันออกเฉียงใต้ และตะวันออกกลางได้จำกัด การนำ Calendly Webhook มาผสานรวมกับ EngageLab SMS จะช่วยให้คุณส่งการแจ้งเตือน SMS ไปยังหมายเลขใด ๆ ทั่วโลกเมื่อมีการสร้างการนัดหมาย ยกเลิก และก่อนการประชุมจะเริ่ม เพื่อเติมเต็มช่องว่างด้านภูมิภาคของความสามารถดั้งเดิมของ Calendly

ข้อกำหนดเบื้องต้น

ก่อนเริ่ม โปรดตรวจสอบว่าได้กำหนดค่าต่อไปนี้เสร็จแล้ว:

ฝั่ง EngageLab

  • เปิดใช้บริการ EngageLab SMS แล้ว
  • สร้างเทมเพลต SMS ในหน้าการจัดการเทมเพลตและผ่านการตรวจสอบแล้ว พร้อมได้รับ ID เทมเพลต
  • สร้าง API Key ในหน้า API Key แล้ว พร้อมได้รับ dev_key และ dev_secret

ฝั่ง Calendly

  • มีแพ็กเกจ Calendly Standard ขึ้นไป (ฟังก์ชัน Webhook ต้องใช้แพ็กเกจแบบชำระเงิน)
  • สร้าง Personal Access Token ในหน้า Integrations & apps → API and webhooks แล้ว

ฝั่งเซิร์ฟเวอร์

  • มีเซิร์ฟเวอร์ที่เข้าถึงได้จากอินเทอร์เน็ตสาธารณะ และกำหนดค่าใบรับรอง HTTPS ที่ใช้งานได้แล้ว
  • ขณะพัฒนาและดีบักในเครื่อง สามารถใช้ ngrok เพื่อเปิดเผยพอร์ตในเครื่องชั่วคราวได้

ขั้นตอนที่ 1: เตรียมเทมเพลต SMS

การเรียกใช้ API เพื่อส่ง SMS ต้องใช้เทมเพลตที่ผ่านการตรวจสอบล่วงหน้า ไม่รองรับการส่งข้อความที่กำหนดเองโดยตรง

เข้าสู่ระบบคอนโซล EngageLab ไปที่ SMS → การจัดการเทมเพลต และสร้างเทมเพลตสามรายการต่อไปนี้ตามสถานการณ์:

วัตถุประสงค์ของเทมเพลต ตัวอย่างเนื้อหาเทมเพลต
ยืนยันการนัดหมาย สวัสดี {{name}} การประชุมของคุณได้รับการยืนยันแล้ว เวลา: {{time}} เราหวังว่าจะได้พบคุณ
แจ้งเตือนการยกเลิก สวัสดี {{name}} การนัดหมายประชุมของคุณถูกยกเลิกแล้ว หากต้องการนัดหมายใหม่ โปรดติดต่อเรา
เตือนการประชุม สวัสดี {{name}} คุณมีการประชุมที่จะเริ่มในอีก {{advance}} เวลา: {{time}} โปรดเตรียมตัวล่วงหน้า

หลังจากส่งเทมเพลตแล้ว ให้รอการตรวจสอบให้ผ่าน และบันทึก ID เทมเพลตทั้งสามไว้

คำแนะนำ: ควรสร้างเทมเพลตแยกสำหรับแต่ละสถานการณ์ เพื่อให้ความหมายชัดเจนขึ้นและมีอัตราการผ่านการตรวจสอบสูงขึ้น หากเทมเพลตมีตัวแปรที่กำหนดเอง (เช่น {{name}}) ตอนเรียกใช้ API ต้องส่งค่าผ่านฟิลด์ params มิฉะนั้นตัวแปรจะถูกส่งออกตามเดิม

ขั้นตอนที่ 2: สร้างการสมัครรับ Webhook ใน Calendly

การจัดการ Webhook ของ Calendly ไม่มีอินเทอร์เฟซแบบกราฟิก ต้องสร้างผ่าน API

รับ URI องค์กร

ก่อนอื่น เรียกใช้อินเทอร์เฟซต่อไปนี้เพื่อรับ URI องค์กรของบัญชีปัจจุบัน:

curl https://api.calendly.com/users/me \ -H "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN"
              
              curl https://api.calendly.com/users/me \
  -H "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN"

            
โค้ดนี้โชว์เป็นหน้าต่างลอย

ในผลลัพธ์ที่ส่งกลับ ให้หาฟิลด์ current_organization ซึ่งมีรูปแบบดังนี้:

https://api.calendly.com/organizations/xxxxxxxxxxxxxxxx
              
              https://api.calendly.com/organizations/xxxxxxxxxxxxxxxx

            
โค้ดนี้โชว์เป็นหน้าต่างลอย

สร้างการสมัครรับ Webhook

ใช้ URI องค์กรเพื่อสร้าง Webhook โดยสมัครรับสองเหตุการณ์ ได้แก่ การสร้างการนัดหมายและการยกเลิก:

curl -X POST https://api.calendly.com/webhook_subscriptions \ -H "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://ที่อยู่เซิร์ฟเวอร์ของคุณ/webhooks/calendly", "events": [ "invitee.created", "invitee.canceled" ], "organization": "https://api.calendly.com/organizations/ID องค์กรของคุณ", "scope": "organization" }'
              
              curl -X POST https://api.calendly.com/webhook_subscriptions \
  -H "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://ที่อยู่เซิร์ฟเวอร์ของคุณ/webhooks/calendly",
    "events": [
      "invitee.created",
      "invitee.canceled"
    ],
    "organization": "https://api.calendly.com/organizations/ID องค์กรของคุณ",
    "scope": "organization"
  }'

            
โค้ดนี้โชว์เป็นหน้าต่างลอย

หลังจากสร้างสำเร็จ Calendly จะส่งคำขอตรวจสอบหนึ่งรายการไปยัง url ที่กรอกไว้ เซิร์ฟเวอร์ต้องส่งกลับ HTTP 200 เพื่อทำการ handshake ให้เสร็จสมบูรณ์ หลังจากนั้นทุกครั้งที่มีคนนัดหมายหรือยกเลิก ระบบจะส่งเหตุการณ์ไปยังที่อยู่ดังกล่าว

หมายเหตุ: url ต้องเป็น URL แบบ HTTPS ที่เข้าถึงได้จากอินเทอร์เน็ตสาธารณะ ขณะพัฒนาในเครื่อง สามารถใช้ ngrok เพื่อสร้างที่อยู่ชั่วคราว: ngrok http 3000 แล้วนำ https://xxxx.ngrok.io ที่ได้มากรอก

ขั้นตอนที่ 3: สร้างบริการรับ Webhook

ต่อไปนี้คือตัวอย่างฝั่งเซิร์ฟเวอร์แบบ Node.js ที่สมบูรณ์ ซึ่งจะเรียกใช้ EngageLab SMS API เพื่อส่ง SMS หลังจากรับเหตุการณ์ Calendly Webhook

ติดตั้ง dependency

npm install express
              
              npm install express

            
โค้ดนี้โชว์เป็นหน้าต่างลอย

โค้ดฉบับเต็ม

// server.js import express from 'express'; const app = express(); app.use(express.json()); // การยืนยันตัวตน EngageLab: base64(dev_key:dev_secret) const ENGAGELAB_AUTH = Buffer.from( `${process.env.ENGAGELAB_DEV_KEY}:${process.env.ENGAGELAB_DEV_SECRET}` ).toString('base64'); // เรียกใช้ EngageLab SMS API เพื่อส่ง SMS async function sendSMS({ to, templateId, params }) { const res = await fetch('https://smsapi.engagelab.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${ENGAGELAB_AUTH}`, }, body: JSON.stringify({ to: [to], template: { id: templateId, params, }, }), }); const data = await res.json(); // HTTP 200 ไม่ได้หมายความว่าส่งสำเร็จ ต้องตรวจสอบฟิลด์ code if (data.code && data.code !== 0) { console.error(`ส่ง SMS ล้มเหลว: code=${data.code}, message=${data.message}`); } else { console.log(`ส่ง SMS สำเร็จ: plan_id=${data.plan_id}, message_id=${data.message_id}`); } } // แปลงสตริงเวลา ISO เป็นเวลาท้องถิ่น (Asia/Shanghai) function formatTime(isoString) { return new Date(isoString).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } // ลงทะเบียนงานตามกำหนดเวลาสำหรับเตือนก่อนการประชุม (24 ชม. และ 1 ชม. อย่างละหนึ่งรายการ) function scheduleReminders({ phone, name, startTime }) { const meetingTime = new Date(startTime).getTime(); const now = Date.now(); const reminders = [ { advance: '24 ชั่วโมง', triggerAt: meetingTime - 24 * 60 * 60 * 1000 }, { advance: '1 ชั่วโมง', triggerAt: meetingTime - 60 * 60 * 1000 }, ]; for (const { advance, triggerAt } of reminders) { const delay = triggerAt - now; if (delay <= 0) continue; // เลยเวลาที่จะทริกเกอร์แล้ว ข้าม setTimeout(async () => { await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_REMINDER, params: { name, time: formatTime(startTime), advance }, }); }, delay); } } // จุดสิ้นสุดสำหรับรับ Webhook app.post('/webhooks/calendly', async (req, res) => { const { event, payload } = req.body; const phone = payload.text_reminder_number; // หมายเลขโทรศัพท์ที่ผู้ใช้กรอกตอนนัดหมาย const name = payload.invitee?.name ?? 'ผู้ใช้'; const startTime = payload.scheduled_event?.start_time; // ข้ามการส่ง SMS เมื่อไม่ได้กรอกหมายเลขโทรศัพท์ if (!phone) { console.log('การนัดหมายนี้ไม่ได้กรอกหมายเลขโทรศัพท์ ข้ามการแจ้งเตือน SMS'); return res.sendStatus(200); } if (event === 'invitee.created') { // ส่ง SMS ยืนยันการนัดหมาย await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_CONFIRM, params: { name, time: formatTime(startTime) }, }); // ลงทะเบียนงานเตือนก่อนการประชุม scheduleReminders({ phone, name, startTime }); } else if (event === 'invitee.canceled') { // ส่ง SMS แจ้งเตือนการยกเลิก await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_CANCEL, params: { name }, }); } // ต้องส่งกลับ 200 ภายใน 2 วินาที มิฉะนั้น Calendly จะถือว่าการส่งล้มเหลวและลองใหม่ res.sendStatus(200); }); app.listen(3000, () => console.log('Server running on port 3000'));
              
              // server.js
import express from 'express';

const app = express();
app.use(express.json());

// การยืนยันตัวตน EngageLab: base64(dev_key:dev_secret)
const ENGAGELAB_AUTH = Buffer.from(
  `${process.env.ENGAGELAB_DEV_KEY}:${process.env.ENGAGELAB_DEV_SECRET}`
).toString('base64');

// เรียกใช้ EngageLab SMS API เพื่อส่ง SMS
async function sendSMS({ to, templateId, params }) {
  const res = await fetch('https://smsapi.engagelab.com/v1/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Basic ${ENGAGELAB_AUTH}`,
    },
    body: JSON.stringify({
      to: [to],
      template: {
        id: templateId,
        params,
      },
    }),
  });

  const data = await res.json();

  // HTTP 200 ไม่ได้หมายความว่าส่งสำเร็จ ต้องตรวจสอบฟิลด์ code
  if (data.code && data.code !== 0) {
    console.error(`ส่ง SMS ล้มเหลว: code=${data.code}, message=${data.message}`);
  } else {
    console.log(`ส่ง SMS สำเร็จ: plan_id=${data.plan_id}, message_id=${data.message_id}`);
  }
}

// แปลงสตริงเวลา ISO เป็นเวลาท้องถิ่น (Asia/Shanghai)
function formatTime(isoString) {
  return new Date(isoString).toLocaleString('zh-CN', {
    timeZone: 'Asia/Shanghai',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  });
}

// ลงทะเบียนงานตามกำหนดเวลาสำหรับเตือนก่อนการประชุม (24 ชม. และ 1 ชม. อย่างละหนึ่งรายการ)
function scheduleReminders({ phone, name, startTime }) {
  const meetingTime = new Date(startTime).getTime();
  const now = Date.now();

  const reminders = [
    { advance: '24 ชั่วโมง', triggerAt: meetingTime - 24 * 60 * 60 * 1000 },
    { advance: '1 ชั่วโมง',  triggerAt: meetingTime -      60 * 60 * 1000 },
  ];

  for (const { advance, triggerAt } of reminders) {
    const delay = triggerAt - now;
    if (delay <= 0) continue; // เลยเวลาที่จะทริกเกอร์แล้ว ข้าม

    setTimeout(async () => {
      await sendSMS({
        to: phone,
        templateId: process.env.TEMPLATE_ID_REMINDER,
        params: { name, time: formatTime(startTime), advance },
      });
    }, delay);
  }
}

// จุดสิ้นสุดสำหรับรับ Webhook
app.post('/webhooks/calendly', async (req, res) => {
  const { event, payload } = req.body;

  const phone     = payload.text_reminder_number;     // หมายเลขโทรศัพท์ที่ผู้ใช้กรอกตอนนัดหมาย
  const name      = payload.invitee?.name ?? 'ผู้ใช้';
  const startTime = payload.scheduled_event?.start_time;

  // ข้ามการส่ง SMS เมื่อไม่ได้กรอกหมายเลขโทรศัพท์
  if (!phone) {
    console.log('การนัดหมายนี้ไม่ได้กรอกหมายเลขโทรศัพท์ ข้ามการแจ้งเตือน SMS');
    return res.sendStatus(200);
  }

  if (event === 'invitee.created') {
    // ส่ง SMS ยืนยันการนัดหมาย
    await sendSMS({
      to: phone,
      templateId: process.env.TEMPLATE_ID_CONFIRM,
      params: { name, time: formatTime(startTime) },
    });

    // ลงทะเบียนงานเตือนก่อนการประชุม
    scheduleReminders({ phone, name, startTime });

  } else if (event === 'invitee.canceled') {
    // ส่ง SMS แจ้งเตือนการยกเลิก
    await sendSMS({
      to: phone,
      templateId: process.env.TEMPLATE_ID_CANCEL,
      params: { name },
    });
  }

  // ต้องส่งกลับ 200 ภายใน 2 วินาที มิฉะนั้น Calendly จะถือว่าการส่งล้มเหลวและลองใหม่
  res.sendStatus(200);
});

app.listen(3000, () => console.log('Server running on port 3000'));

            
โค้ดนี้โชว์เป็นหน้าต่างลอย

การกำหนดค่าตัวแปรสภาพแวดล้อม

สร้างไฟล์ .env ในไดเรกทอรีรากของโปรเจกต์ แล้วกรอกตัวแปรต่อไปนี้:

ENGAGELAB_DEV_KEY=dev_key ของคุณ ENGAGELAB_DEV_SECRET=dev_secret ของคุณ TEMPLATE_ID_CONFIRM=ID เทมเพลตยืนยันการนัดหมาย TEMPLATE_ID_CANCEL=ID เทมเพลตแจ้งเตือนการยกเลิก TEMPLATE_ID_REMINDER=ID เทมเพลตเตือนการประชุม
              
              ENGAGELAB_DEV_KEY=dev_key ของคุณ
ENGAGELAB_DEV_SECRET=dev_secret ของคุณ

TEMPLATE_ID_CONFIRM=ID เทมเพลตยืนยันการนัดหมาย
TEMPLATE_ID_CANCEL=ID เทมเพลตแจ้งเตือนการยกเลิก
TEMPLATE_ID_REMINDER=ID เทมเพลตเตือนการประชุม

            
โค้ดนี้โชว์เป็นหน้าต่างลอย

คำอธิบายฟิลด์สำคัญ

ใน payload ที่ Calendly Webhook ส่งมา ฟิลด์ต่อไปนี้เกี่ยวข้องโดยตรงกับการส่ง SMS:

ฟิลด์ คำอธิบาย
event ประเภทเหตุการณ์ invitee.created (สร้างการนัดหมาย) หรือ invitee.canceled (ยกเลิกการนัดหมาย)
payload.text_reminder_number หมายเลขโทรศัพท์ที่ผู้นัดหมายกรอก รวมรหัสประเทศ อาจเป็นค่าว่าง
payload.invitee.name ชื่อของผู้นัดหมาย
payload.scheduled_event.start_time เวลาเริ่มการประชุม รูปแบบ ISO 8601

สถานการณ์เพิ่มเติม

ส่ง SMS ติดตามผลหลังการประชุมจบ

เพิ่มงานที่ทริกเกอร์หลังจากการประชุมจบใน scheduleReminders เพื่อส่งแบบสำรวจความพึงพอใจหรือคำเชิญนัดหมายครั้งถัดไป:

// เพิ่มต่อในฟังก์ชัน scheduleReminders const followUpAt = meetingTime + 30 * 60 * 1000; // 30 นาทีหลังการประชุมจบ const followUpDelay = followUpAt - now; if (followUpDelay > 0) { setTimeout(async () => { await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_FOLLOWUP, params: { name }, }); }, followUpDelay); }
              
              // เพิ่มต่อในฟังก์ชัน scheduleReminders
const followUpAt = meetingTime + 30 * 60 * 1000; // 30 นาทีหลังการประชุมจบ
const followUpDelay = followUpAt - now;

if (followUpDelay > 0) {
  setTimeout(async () => {
    await sendSMS({
      to: phone,
      templateId: process.env.TEMPLATE_ID_FOLLOWUP,
      params: { name },
    });
  }, followUpDelay);
}

            
โค้ดนี้โชว์เป็นหน้าต่างลอย

แจ้งเตือนผู้ดำเนินการประชุมพร้อมกัน

ตอนสร้างการนัดหมาย นอกจากแจ้งเตือนผู้นัดหมายแล้ว ยังสามารถส่งการเตือนหนึ่งรายการไปยังผู้ดำเนินการประชุมได้ด้วย:

if (event === 'invitee.created') { // แจ้งเตือนผู้นัดหมาย await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_CONFIRM, params: { name, time: formatTime(startTime) }, }); // แจ้งเตือนผู้ดำเนินการประชุม await sendSMS({ to: process.env.HOST_PHONE, templateId: process.env.TEMPLATE_ID_HOST_NOTIFY, params: { name, time: formatTime(startTime) }, }); }
              
              if (event === 'invitee.created') {
  // แจ้งเตือนผู้นัดหมาย
  await sendSMS({
    to: phone,
    templateId: process.env.TEMPLATE_ID_CONFIRM,
    params: { name, time: formatTime(startTime) },
  });

  // แจ้งเตือนผู้ดำเนินการประชุม
  await sendSMS({
    to: process.env.HOST_PHONE,
    templateId: process.env.TEMPLATE_ID_HOST_NOTIFY,
    params: { name, time: formatTime(startTime) },
  });
}

            
โค้ดนี้โชว์เป็นหน้าต่างลอย

ข้อควรทราบ

  1. Webhook ต้องตอบกลับ HTTP 200 ภายใน 2 วินาที มิฉะนั้น Calendly จะถือว่าการส่งล้มเหลวและลองใหม่ แนะนำให้ส่งกลับ 200 ก่อน แล้วจึงดำเนินตรรกะการส่ง SMS แบบอะซิงโครนัส เพื่อหลีกเลี่ยงไม่ให้ความล่าช้าในการตอบกลับของ EngageLab API ทำให้ Calendly ตัดสินผิดพลาด
  2. ฟิลด์ text_reminder_number อาจเป็นค่าว่าง ต้องตรวจสอบค่าว่างในโค้ด เพื่อหลีกเลี่ยงการส่งหมายเลขว่างเข้าไปยัง SMS API จนเกิดข้อผิดพลาด
  3. รูปแบบหมายเลขโทรศัพท์ต้องมีรหัสประเทศ เช่น หมายเลขสิงคโปร์คือ +6591234567 Calendly จะแนะนำให้ผู้ใช้กรอกในรูปแบบสากลขณะเก็บหมายเลขโทรศัพท์ แต่แนะนำให้ตรวจสอบรูปแบบอีกครั้งที่ฝั่งเซิร์ฟเวอร์
  4. setTimeout ไม่เหมาะกับสภาพแวดล้อมการใช้งานจริง เมื่อเซิร์ฟเวอร์รีสตาร์ท งานตามกำหนดเวลาทั้งหมดจะสูญหาย สำหรับสภาพแวดล้อมการใช้งานจริง แนะนำให้ใช้คิวงานแบบถาวร (เช่น BullMQ + Redis) หรืองานตามกำหนดเวลาของบริการคลาวด์ โดยจัดเก็บบันทึกการเตือนไว้ในฐานข้อมูล ซึ่งสามารถกู้คืนได้หลังรีสตาร์ท
  5. เทมเพลตต้องผ่านการตรวจสอบก่อนจึงจะใช้งานได้ หากเทมเพลตอยู่ในสถานะรอการตรวจสอบหรือถูกปฏิเสธการตรวจสอบขณะเรียกใช้ API จะส่งกลับข้อผิดพลาด 4001
  6. API ส่งกลับ HTTP 200 ไม่ได้หมายความว่าส่ง SMS สำเร็จ โปรดตรวจสอบฟิลด์ code ในเนื้อหาการตอบกลับ หากไม่ใช่ศูนย์ ให้ดูคำอธิบายรหัสข้อผิดพลาดเพื่อตรวจหาสาเหตุ
Icon Solid Transparent White Qiyu
ติดต่อฝ่ายขาย