Logo Site EngageLab Mark Colored Transparent文件
搜尋

Calendly

Calendly 是廣泛使用的線上預約工具,但其內建簡訊提醒功能僅支援部分國家和地區,亞太、東南亞、中東等市場覆蓋有限。透過將 Calendly Webhook 與 EngageLab SMS 結合,可在預約建立、取消以及會議開始前,向全球任意號碼發送簡訊通知,補齊 Calendly 原生能力的地域空白。

前置條件

開始前,請確認以下設定已完成:

EngageLab 側

  • 已開通 EngageLab SMS 服務
  • 已在範本管理頁面建立簡訊範本並通過審核,取得範本 ID
  • 已在 API 金鑰頁面建立 API 金鑰,取得 dev_keydev_secret

Calendly 側

  • 已擁有 Calendly Standard 及以上方案(Webhook 功能需付費方案支援)
  • 已在 Integrations & apps → API and webhooks 頁面建立 Personal Access Token

伺服器側

  • 擁有一台可公網存取的伺服器,並設定了有效的 HTTPS 憑證
  • 本機開發除錯時,可使用 ngrok 臨時暴露本機連接埠

第一步:準備簡訊範本

呼叫 API 發送簡訊必須使用預先審核通過的範本,不支援直接傳入自訂文字。

登入 EngageLab 控制台,進入 SMS → 範本管理,根據場景分別建立以下三個範本:

範本用途 範本內容範例
預約確認 您好 {{name}},您的會議已確認,時間:{{time}},期待與您相見。
取消通知 您好 {{name}},您的會議預約已取消,如需重新預約請聯絡我們。
會議提醒 您好 {{name}},您有一個會議將在 {{advance}} 後開始,時間:{{time}},請提前準備。

範本提交後等待審核通過,分別記錄三個範本 ID。

建議:為每個場景建立獨立範本,語意更清晰,審核通過率也更高。若範本中包含自訂變數(如 {{name}}),呼叫 API 時需透過 params 欄位傳值,否則變數將原樣下發。

第二步:在 Calendly 建立 Webhook 訂閱

Calendly 的 Webhook 管理沒有視覺化介面,需透過 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 完成交握。後續每次有人預約或取消,均會向該地址推送事件。

注意url 必須是公網可存取的 HTTPS 地址。本機開發時,可使用 ngrok 產生臨時地址:ngrok http 3000,將輸出的 https://xxxx.ngrok.io 填入即可。

第三步:搭建 Webhook 接收服務

以下為完整的 Node.js 伺服器端範例,接收 Calendly Webhook 事件後呼叫 EngageLab SMS API 發送簡訊。

安裝依賴

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 發送簡訊 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/Taipei) function formatTime(isoString) { return new Date(isoString).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } // 註冊會議前提醒排程任務(24h 和 1h 各一條) 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; // 手機號碼未填時跳過簡訊發送 if (!phone) { console.log('該預約未填寫手機號碼,跳過簡訊通知'); return res.sendStatus(200); } if (event === 'invitee.created') { // 發送預約確認簡訊 await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_CONFIRM, params: { name, time: formatTime(startTime) }, }); // 註冊會議前提醒任務 scheduleReminders({ phone, name, startTime }); } else if (event === 'invitee.canceled') { // 發送取消通知簡訊 await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_CANCEL, params: { name }, }); } // 必須在 2 秒內回傳 200,否則 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 發送簡訊
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/Taipei)
function formatTime(isoString) {
  return new Date(isoString).toLocaleString('zh-TW', {
    timeZone: 'Asia/Taipei',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  });
}

// 註冊會議前提醒排程任務(24h 和 1h 各一條)
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;

  // 手機號碼未填時跳過簡訊發送
  if (!phone) {
    console.log('該預約未填寫手機號碼,跳過簡訊通知');
    return res.sendStatus(200);
  }

  if (event === 'invitee.created') {
    // 發送預約確認簡訊
    await sendSMS({
      to: phone,
      templateId: process.env.TEMPLATE_ID_CONFIRM,
      params: { name, time: formatTime(startTime) },
    });

    // 註冊會議前提醒任務
    scheduleReminders({ phone, name, startTime });

  } else if (event === 'invitee.canceled') {
    // 發送取消通知簡訊
    await sendSMS({
      to: phone,
      templateId: process.env.TEMPLATE_ID_CANCEL,
      params: { name },
    });
  }

  // 必須在 2 秒內回傳 200,否則 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

            
此代碼塊在浮窗中顯示

關鍵欄位說明

Calendly Webhook 推送的 payload 中,以下欄位與簡訊發送直接相關:

欄位 說明
event 事件類型,invitee.created(預約建立)或 invitee.canceled(預約取消)
payload.text_reminder_number 預約者填寫的手機號碼,含國家代碼,可能為空
payload.invitee.name 預約者姓名
payload.scheduled_event.start_time 會議開始時間,ISO 8601 格式

擴充場景

會議結束後發送跟進簡訊

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 需在 2 秒內回應 HTTP 200,否則 Calendly 會認為推送失敗並進行重試。建議先回傳 200,再非同步執行簡訊發送邏輯,避免因 EngageLab API 回應延遲導致 Calendly 誤判。
  2. text_reminder_number 欄位可能為空,需在程式碼中做判空處理,避免將空號碼傳入 SMS API 導致報錯。
  3. 手機號碼格式需含國家代碼,如新加坡號碼為 +6591234567。Calendly 在收集手機號碼時會引導使用者填寫國際格式,但建議在伺服器端做一次格式校驗。
  4. setTimeout 不適用於生產環境,伺服器重啟後所有排程任務將遺失。生產環境建議使用持久化任務佇列(如 BullMQ + Redis)或雲端服務的排程任務,將提醒紀錄存入資料庫,重啟後可恢復。
  5. 範本必須審核通過後方可使用,呼叫時若範本處於待審核或審核拒絕狀態,API 將回傳 4001 錯誤。
  6. API 回傳 HTTP 200 並不代表簡訊發送成功,請檢查回應本體中的 code 欄位,非零時參考錯誤碼說明排查原因。
Icon Solid Transparent White Qiyu
聯繫銷售