Calendly
Calendly 是广泛使用的在线预约工具,但其内置短信提醒功能仅支持部分国家和地区,亚太、东南亚、中东等市场覆盖有限。通过将 Calendly Webhook 与 EngageLab SMS 结合,可在预约创建、取消以及会议开始前,向全球任意号码发送短信通知,补齐 Calendly 原生能力的地域空白。
前置条件
开始前,请确认以下配置已完成:
EngageLab 侧
- 已开通 EngageLab SMS 服务
- 已在模板管理页面创建短信模板并通过审核,获取模板 ID
- 已在 API 密钥页面创建 API 密钥,获取
dev_key和dev_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"
在返回结果中找到 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://你的服务器地址/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
完整代码
// 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/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',
});
}
// 注册会议前提醒定时任务(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
关键字段说明
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);
}
同时通知主持人
预约创建时,除通知预约者外,还可向会议主持人发送一条提醒:
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) },
});
}
注意事项
- Webhook 需在 2 秒内响应
HTTP 200,否则 Calendly 会认为推送失败并进行重试。建议先返回 200,再异步执行短信发送逻辑,避免因 EngageLab API 响应延迟导致 Calendly 误判。 text_reminder_number字段可能为空,需在代码中做判空处理,避免将空号码传入 SMS API 导致报错。- 手机号格式需含国家代码,如中国大陆号码为
+8618701235678。Calendly 在收集手机号时会引导用户填写国际格式,但建议在服务端做一次格式校验。 setTimeout不适用于生产环境,服务器重启后所有定时任务将丢失。生产环境建议使用持久化任务队列(如 BullMQ + Redis)或云服务的定时任务,将提醒记录存入数据库,重启后可恢复。- 模板必须审核通过后方可使用,调用时若模板处于待审核或审核拒绝状态,API 将返回
4001错误。 - API 返回 HTTP 200 并不代表短信发送成功,请检查响应体中的
code字段,非零时参考错误码说明排查原因。
