Calendly
Calendly 是廣泛使用的線上排程工具,但其內建簡訊提醒僅支援部分國家與地區,在亞太、東南亞、中東等市場覆蓋有限。透過將 Calendly Webhook 與 EngageLab SMS 整合,您可在預約建立、取消或會議開始前,向全球任一電話號碼發送簡訊通知,補足 Calendly 原生能力在地域上的缺口。
前置條件
開始前,請確認以下設定已完成:
EngageLab 端
- 已開通 EngageLab SMS 服務
- 已在範本管理頁面建立簡訊範本並通過審核,已取得範本 ID
- 已在 API Keys 頁面建立 API 金鑰,已取得
dev_key與dev_secret
Calendly 端
- 擁有 Calendly Standard 或以上方案(Webhook 功能需付費方案)
- 已在 Integrations & apps → API and webhooks 頁面建立 Personal Access Token
伺服器端
- 擁有可從公網存取的伺服器,並已設定有效的 HTTPS 憑證
- 本機開發與除錯時,可使用 ngrok 暫時對外暴露本機埠號
步驟一:準備簡訊範本
透過 API 發送簡訊須使用預先審核通過的範本,無法直接傳入自訂文字內容。
登入 EngageLab 控制台,前往 SMS → Template Management,依情境分別建立下列三個範本:
| 範本用途 | 內容範例 |
|---|---|
| 預約確認 | 您好 {{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"
在回應中找到 current_organization 欄位,格式如下:
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://your-server-address/webhooks/calendly",
"events": [
"invitee.created",
"invitee.canceled"
],
"organization": "https://api.calendly.com/organizations/your-org-id",
"scope": "organization"
}'
建立完成後,Calendly 會向您提供的 url 發送驗證請求,您的伺服器須回傳 HTTP 200 以完成交握。之後預約與取消事件將推送到此位址。
注意:
url須為可從公網存取的 HTTPS 位址。本機開發時可使用 ngrok 產生暫時位址:ngrok http 3000,再使用輸出的https://xxxx.ngrok.ioURL。
步驟三:建置 Webhook 接收服務
以下為完整的 Node.js 伺服器範例:接收 Calendly Webhook 事件後,呼叫 EngageLab SMS API 發送訊息。
安裝相依套件
npm install express
完整程式碼
// server.js
import express from 'express';
const app = express();
app.use(express.json());
// EngageLab authentication: base64(dev_key:dev_secret)
const ENGAGELAB_AUTH = Buffer.from(
`${process.env.ENGAGELAB_DEV_KEY}:${process.env.ENGAGELAB_DEV_SECRET}`
).toString('base64');
// Call EngageLab SMS API to send a message
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 does not guarantee delivery; check the code field
if (data.code && data.code !== 0) {
console.error(`SMS sending failed: code=${data.code}, message=${data.message}`);
} else {
console.log(`SMS sent successfully: plan_id=${data.plan_id}, message_id=${data.message_id}`);
}
}
// Convert an ISO time string to local time (Asia/Shanghai)
function formatTime(isoString) {
return new Date(isoString).toLocaleString('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
// Schedule pre-meeting reminders (24h and 1h before)
function scheduleReminders({ phone, name, startTime }) {
const meetingTime = new Date(startTime).getTime();
const now = Date.now();
const reminders = [
{ advance: '24 hours', triggerAt: meetingTime - 24 * 60 * 60 * 1000 },
{ advance: '1 hour', triggerAt: meetingTime - 60 * 60 * 1000 },
];
for (const { advance, triggerAt } of reminders) {
const delay = triggerAt - now;
if (delay <= 0) continue; // Trigger time has passed, skip
setTimeout(async () => {
await sendSMS({
to: phone,
templateId: process.env.TEMPLATE_ID_REMINDER,
params: { name, time: formatTime(startTime), advance },
});
}, delay);
}
}
// Webhook receiver endpoint
app.post('/webhooks/calendly', async (req, res) => {
const { event, payload } = req.body;
const phone = payload.text_reminder_number; // Phone number provided by the invitee
const name = payload.invitee?.name ?? 'User';
const startTime = payload.scheduled_event?.start_time;
// Skip SMS if no phone number is provided
if (!phone) {
console.log('No phone number provided for this appointment, skipping SMS notification');
return res.sendStatus(200);
}
if (event === 'invitee.created') {
// Send appointment confirmation SMS
await sendSMS({
to: phone,
templateId: process.env.TEMPLATE_ID_CONFIRM,
params: { name, time: formatTime(startTime) },
});
// Schedule pre-meeting reminders
scheduleReminders({ phone, name, startTime });
} else if (event === 'invitee.canceled') {
// Send cancellation notice SMS
await sendSMS({
to: phone,
templateId: process.env.TEMPLATE_ID_CANCEL,
params: { name },
});
}
// Must return 200 within 2 seconds, otherwise Calendly will consider the push failed and retry
res.sendStatus(200);
});
app.listen(3000, () => console.log('Server running on port 3000'));
環境變數設定
在專案根目錄建立 .env 檔案,填入以下變數:
ENGAGELAB_DEV_KEY=your_dev_key
ENGAGELAB_DEV_SECRET=your_dev_secret
TEMPLATE_ID_CONFIRM=appointment_confirmation_template_id
TEMPLATE_ID_CANCEL=cancellation_notice_template_id
TEMPLATE_ID_REMINDER=meeting_reminder_template_id
關鍵欄位說明
Calendly Webhook 酬載中,下列欄位與簡訊發送直接相關:
| 欄位 | 說明 |
|---|---|
event |
事件類型:invitee.created(預約建立)或 invitee.canceled(預約取消) |
payload.text_reminder_number |
受邀者提供的電話號碼,含國碼;可能為空 |
payload.invitee.name |
受邀者姓名 |
payload.scheduled_event.start_time |
會議開始時間,ISO 8601 格式 |
延伸情境
會議結束後發送後續簡訊
在 scheduleReminders 中加入會議結束後的觸發,以發送滿意度調查或下次預約邀請:
// Append to the scheduleReminders function
const followUpAt = meetingTime + 30 * 60 * 1000; // 30 minutes after the meeting ends
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') {
// Notify the invitee
await sendSMS({
to: phone,
templateId: process.env.TEMPLATE_ID_CONFIRM,
params: { name, time: formatTime(startTime) },
});
// Notify the host
await sendSMS({
to: process.env.HOST_PHONE,
templateId: process.env.TEMPLATE_ID_HOST_NOTIFY,
params: { name, time: formatTime(startTime) },
});
}
注意事項
- Webhook 須在 2 秒內回應
HTTP 200,否則 Calendly 會視為推送失敗並重試。建議先回傳 200,再以非同步方式執行簡訊發送邏輯,避免因 EngageLab API 回應延遲導致 Calendly 誤判。 text_reminder_number欄位可能為空 — 請在程式中加入空值檢查,避免將空號碼傳入簡訊 API 而導致錯誤。- 電話號碼須包含國碼,例如中國大陸號碼為
+8618701235678。Calendly 在收集電話號碼時會引導使用者填寫國際格式,但仍建議在伺服器端再做格式驗證。 setTimeout不適用於正式環境 — 伺服器重新啟動後,所有排程工作都會遺失。正式環境請使用持久化工作佇列(例如 BullMQ + Redis)或雲端排程,並將提醒紀錄存入資料庫以便重啟後復原。- 範本須先通過審核方可使用 — 若呼叫時範本仍在審核中或已被拒絕,API 將回傳
4001錯誤。 - API 回傳 HTTP 200 不代表簡訊一定送達成功 — 請檢查回應本文中的
code欄位。非零值請參考錯誤碼說明文件排查。
