Logo Site EngageLab Mark Colored TransparentDocument
Search

Calendly

Calendly is a widely used online scheduling tool, but its built-in SMS reminder feature only supports certain countries and regions, leaving markets like Asia-Pacific, Southeast Asia, and the Middle East with limited coverage. By combining Calendly Webhooks with EngageLab SMS, you can send SMS notifications to any number globally when appointments are created or canceled, and before meetings start, filling the geographical gaps of Calendly's native capabilities.

Prerequisites

Before starting, please ensure the following configurations are complete:

On the EngageLab Side

  • The EngageLab SMS service is enabled
  • SMS templates have been created and approved on the Template Management page, and template IDs obtained
  • API keys have been created on the API Key page, and dev_key and dev_secret obtained

On the Calendly Side

  • You have a Calendly Standard plan or above (Webhook feature requires a paid plan)
  • A Personal Access Token has been created on the Integrations & apps → API and webhooks page

On the Server Side

  • You have a publicly accessible server configured with a valid HTTPS certificate
  • For local development and debugging, you can use ngrok to temporarily expose local ports

Step 1: Prepare SMS Templates

Calling the API to send SMS requires pre-approved templates; direct custom text passing is not supported.

Log in to the EngageLab Console, go to SMS → Template Management, and create the following three templates based on the scenario:

Template Purpose Template Content Example
Appointment Confirmation Hello {{name}}, your meeting is confirmed for {{time}}. Looking forward to seeing you.
Cancellation Notification Hello {{name}}, your meeting appointment has been canceled. Please contact us if you need to reschedule.
Meeting Reminder Hello {{name}}, you have a meeting starting in {{advance}} at {{time}}. Please prepare in advance.

After submitting the templates, wait for approval and record the three template IDs.

Recommendation: Creating independent templates for each scenario ensures clearer semantics and higher approval rates. If the template contains custom variables (e.g., {{name}}), you must pass values via the params field when calling the API, otherwise, the variables will be delivered as-is.

Step 2: Create Webhook Subscription in Calendly

Calendly's Webhook management does not have a visual interface and must be created via API.

Get Organization URI

First, call the following interface to get the organization URI of the current account:

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"

            
This code block in the floating window

Find the current_organization field in the response, formatted as follows:

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

            
This code block in the floating window

Create Webhook Subscription

Use the organization URI to create a Webhook subscribing to appointment creation and cancellation events:

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"
  }'

            
This code block in the floating window

Once successfully created, Calendly will push a verification request to the provided url, and the server must return HTTP 200 to complete the handshake. Subsequently, every time someone schedules or cancels, an event will be pushed to this address.

Note: The url must be a publicly accessible HTTPS address. During local development, use ngrok to generate a temporary address: ngrok http 3000, and fill in the output https://xxxx.ngrok.io.

Step 3: Set Up Webhook Receiving Service

Below is a complete Node.js server-side example that receives Calendly Webhook events and calls the EngageLab SMS API to send an SMS.

Install Dependencies

npm install express
              
              npm install express

            
This code block in the floating window

Complete Code

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

            
This code block in the floating window

Environment Variable Configuration

Create a .env file in the project root directory and fill in the following variables:

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

            
This code block in the floating window

Key Field Descriptions

In the payload pushed by the Calendly Webhook, the following fields are directly related to SMS sending:

Field Description
event Event type, invitee.created (appointment creation) or invitee.canceled (appointment cancellation)
payload.text_reminder_number Phone number filled out by the invitee, including country code; may be empty
payload.invitee.name Invitee's name
payload.scheduled_event.start_time Meeting start time, ISO 8601 format

Extended Scenarios

Send Follow-up SMS After Meeting

Add a task triggered after the meeting to scheduleReminders to send a satisfaction survey or next appointment invitation:

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

            
This code block in the floating window

Notify Host Simultaneously

When an appointment is created, besides notifying the invitee, a reminder can also be sent to the meeting host:

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

            
This code block in the floating window

Notes

  1. Webhook must respond with HTTP 200 within 2 seconds, otherwise, Calendly will consider the push failed and retry. It is recommended to return 200 first, and then execute the SMS sending logic asynchronously to prevent Calendly from misjudging due to EngageLab API response delays.
  2. The text_reminder_number field may be empty, so you need to handle null checks in your code to avoid passing an empty number to the SMS API and causing errors.
  3. The phone number format must include the country code, such as +6591234567 for a Singapore number. Calendly guides users to fill in international formats when collecting phone numbers, but it's recommended to do a format check on the server side.
  4. setTimeout is not suitable for production environments, as all scheduled tasks will be lost if the server restarts. In a production environment, it is recommended to use persistent task queues (like BullMQ + Redis) or cloud service cron jobs to store reminder records in a database so they can be restored upon restart.
  5. Templates must be approved before they can be used. If the template is pending review or rejected when called, the API will return a 4001 error.
  6. An HTTP 200 response from the API does not mean the SMS was sent successfully. Please check the code field in the response body. If it is non-zero, refer to the Error Code Instructions for troubleshooting.
Icon Solid Transparent White Qiyu
Contact Sales