Calendly

Calendlyは広く利用されているオンライン予約ツールですが、組み込みのSMSリマインダー機能は一部の国と地域にしか対応しておらず、アジア太平洋、東南アジア、中東などの市場ではカバレッジが限られています。Calendly WebhookとEngageLab SMSを組み合わせることで、予約の作成・キャンセル時やミーティング開始前に、世界中の任意の番号へSMS通知を送信でき、Calendly本来の機能の地理的なギャップを埋めることができます。

前提条件

開始する前に、次の設定が完了していることを確認してください。

EngageLab側

  • EngageLab SMSサービスが有効化されている
  • テンプレート管理ページでSMSテンプレートを作成・承認済みで、テンプレートIDを取得している
  • API KeyページでAPIキーを作成済みで、dev_keydev_secret を取得している

Calendly側

  • Calendly Standardプラン以上を利用している(Webhook機能は有料プランが必要)
  • Integrations & apps → API and webhooks ページでPersonal Access Tokenを作成済み

サーバー側

  • 有効なHTTPS証明書が設定された、一般公開可能なサーバーを保有している
  • ローカル開発・デバッグの場合は、ngrok を使用してローカルポートを一時的に公開できます

ステップ1:SMSテンプレートの準備

APIを呼び出してSMSを送信するには、事前に承認されたテンプレートが必要であり、カスタムテキストを直接渡すことはできません。

EngageLabコンソールにログインし、SMS → テンプレート管理 に移動して、シーンに応じて次の3つのテンプレートを作成します。

テンプレートの用途 テンプレート内容の例
予約確認 Hello {{name}}, your meeting is confirmed for {{time}}. Looking forward to seeing you.
キャンセル通知 Hello {{name}}, your meeting appointment has been canceled. Please contact us if you need to reschedule.
ミーティングリマインダー Hello {{name}}, you have a meeting starting in {{advance}} at {{time}}. Please prepare in advance.

テンプレートを送信した後、承認を待ち、3つのテンプレートIDを記録します。

推奨:シーンごとに独立したテンプレートを作成すると、意味がより明確になり、承認率も高まります。テンプレートにカスタム変数(例:{{name}})が含まれる場合は、API呼び出し時に params フィールドで値を渡す必要があります。そうしないと、変数はそのまま配信されてしまいます。

ステップ2:CalendlyでのWebhookサブスクリプションの作成

CalendlyのWebhook管理にはビジュアルインターフェースがなく、API経由で作成する必要があります。

Organization URIの取得

まず、次のインターフェースを呼び出して、現在のアカウントのorganization 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サブスクリプションの作成

organization 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-organization-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://your-server-address/webhooks/calendly",
    "events": [
      "invitee.created",
      "invitee.canceled"
    ],
    "organization": "https://api.calendly.com/organizations/your-organization-ID",
    "scope": "organization"
  }'

            
このコードブロックはフローティングウィンドウ内に表示されます

作成に成功すると、Calendlyは指定した url へ検証リクエストをプッシュし、サーバーはハンドシェイクを完了するために HTTP 200 を返す必要があります。その後、誰かが予約またはキャンセルするたびに、このアドレスへイベントがプッシュされます。

注意url は一般公開可能なHTTPSアドレスである必要があります。ローカル開発時は、ngrokを使用して一時的なアドレスを生成します:ngrok http 3000 を実行し、出力された https://xxxx.ngrok.io を入力します。

ステップ3:Webhook受信サービスのセットアップ

以下は、Calendly Webhookイベントを受信し、EngageLab SMS APIを呼び出してSMSを送信する、完全なNode.jsのサーバー側サンプルです。

依存関係のインストール

npm install express
              
              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 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 does not mean successful sending; check the code field if (data.code && data.code !== 0) { console.error(`SMS send failed: code=${data.code}, message=${data.message}`); } else { console.log(`SMS sent successfully: plan_id=${data.plan_id}, message_id=${data.message_id}`); } } // Convert 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', }); } // Register pre-meeting reminder tasks (one at 24h and one at 1h) 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 Receiving Endpoint app.post('/webhooks/calendly', async (req, res) => { const { event, payload } = req.body; const phone = payload.text_reminder_number; // Phone number provided by user during booking const name = payload.invitee?.name ?? 'User'; const startTime = payload.scheduled_event?.start_time; // Skip sending SMS if phone number is not 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) }, }); // Register pre-meeting reminder tasks scheduleReminders({ phone, name, startTime }); } else if (event === 'invitee.canceled') { // Send cancellation notification SMS await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_CANCEL, params: { name }, }); } // Must return 200 within 2 seconds, otherwise Calendly considers the push failed and retries 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 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 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 does not mean successful sending; check the code field
  if (data.code && data.code !== 0) {
    console.error(`SMS send failed: code=${data.code}, message=${data.message}`);
  } else {
    console.log(`SMS sent successfully: plan_id=${data.plan_id}, message_id=${data.message_id}`);
  }
}

// Convert 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',
  });
}

// Register pre-meeting reminder tasks (one at 24h and one at 1h)
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 Receiving Endpoint
app.post('/webhooks/calendly', async (req, res) => {
  const { event, payload } = req.body;

  const phone     = payload.text_reminder_number;     // Phone number provided by user during booking
  const name      = payload.invitee?.name ?? 'User';
  const startTime = payload.scheduled_event?.start_time;

  // Skip sending SMS if phone number is not 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) },
    });

    // Register pre-meeting reminder tasks
    scheduleReminders({ phone, name, startTime });

  } else if (event === 'invitee.canceled') {
    // Send cancellation notification SMS
    await sendSMS({
      to: phone,
      templateId: process.env.TEMPLATE_ID_CANCEL,
      params: { name },
    });
  }

  // Must return 200 within 2 seconds, otherwise Calendly considers the push failed and retries
  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_notification_template_id TEMPLATE_ID_REMINDER=meeting_reminder_template_id
              
              ENGAGELAB_DEV_KEY=your_dev_key
ENGAGELAB_DEV_SECRET=your_dev_secret

TEMPLATE_ID_CONFIRM=appointment_confirmation_template_id
TEMPLATE_ID_CANCEL=cancellation_notification_template_id
TEMPLATE_ID_REMINDER=meeting_reminder_template_id

            
このコードブロックはフローティングウィンドウ内に表示されます

主要フィールドの説明

Calendly Webhookがプッシュするpayloadのうち、SMS送信に直接関係するフィールドは次のとおりです。

フィールド 説明
event イベントの種類。invitee.created(予約作成)または invitee.canceled(予約キャンセル)
payload.text_reminder_number 招待者が入力した国番号を含む電話番号。空の場合があります
payload.invitee.name 招待者の名前
payload.scheduled_event.start_time ミーティング開始時刻。ISO 8601形式

拡張シーン

ミーティング後のフォローアップSMSの送信

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); }
              
              // 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 invitee await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_CONFIRM, params: { name, time: formatTime(startTime) }, }); // Notify host await sendSMS({ to: process.env.HOST_PHONE, templateId: process.env.TEMPLATE_ID_HOST_NOTIFY, params: { name, time: formatTime(startTime) }, }); }
              
              if (event === 'invitee.created') {
  // Notify invitee
  await sendSMS({
    to: phone,
    templateId: process.env.TEMPLATE_ID_CONFIRM,
    params: { name, time: formatTime(startTime) },
  });

  // Notify host
  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はプッシュが失敗したとみなして再試行します。EngageLab APIのレスポンス遅延によりCalendlyが誤判定するのを防ぐため、まず200を返してから、SMS送信ロジックを非同期で実行することをおすすめします。
  2. text_reminder_number フィールドは空の場合があります。そのため、空の番号をSMS APIに渡してエラーを引き起こさないよう、コード内でnullチェックを行う必要があります。
  3. 電話番号の形式には国番号を含める必要があります。シンガポールの番号であれば +6591234567 のようになります。Calendlyは電話番号の収集時にユーザーへ国際形式での入力を促しますが、サーバー側でも形式チェックを行うことをおすすめします。
  4. setTimeout は本番環境には適していません。サーバーが再起動すると、すべてのスケジュールされたタスクが失われるためです。本番環境では、永続的なタスクキュー(BullMQ + Redisなど)やクラウドサービスのcronジョブを使用し、リマインダー記録をデータベースに保存して、再起動時に復元できるようにすることをおすすめします。
  5. テンプレートは承認後にのみ使用できます。呼び出し時にテンプレートが審査待ちまたは却下されている場合、APIは 4001 エラーを返します。
  6. APIからのHTTP 200レスポンスは、SMSが正常に送信されたことを意味しません。レスポンスボディの code フィールドを確認してください。0以外の場合は、エラーコードの説明を参照してトラブルシューティングを行ってください。
Icon Solid Transparent White Qiyu
お問い合わせ