Calendly

Calendly es una herramienta de reservas online ampliamente utilizada, pero su función integrada de recordatorios por SMS solo admite algunos países y regiones, con una cobertura limitada en mercados como Asia-Pacífico, el Sudeste Asiático y Oriente Medio. Al combinar el Webhook de Calendly con EngageLab SMS, puedes enviar notificaciones por SMS a cualquier número del mundo cuando se crea o cancela una cita y antes de que comience la reunión, cubriendo así los vacíos geográficos de las capacidades nativas de Calendly.

Requisitos previos

Antes de empezar, confirma que se ha completado la siguiente configuración:

En EngageLab

  • Has activado el servicio EngageLab SMS
  • Has creado una plantilla de SMS en la página de gestión de plantillas, ha sido aprobada y has obtenido el ID de la plantilla
  • Has creado una API Key en la página de API Key y has obtenido el dev_key y el dev_secret

En Calendly

  • Tienes un plan Calendly Standard o superior (la función de Webhook requiere un plan de pago)
  • Has creado un Personal Access Token en la página Integrations & apps → API and webhooks

En el servidor

  • Dispones de un servidor accesible desde Internet, con un certificado HTTPS válido configurado
  • Durante el desarrollo y las pruebas locales, puedes usar ngrok para exponer temporalmente un puerto local

Paso 1: prepara las plantillas de SMS

Para enviar SMS mediante la API es obligatorio usar plantillas previamente aprobadas; no se admite pasar texto personalizado directamente.

Inicia sesión en la consola de EngageLab, ve a SMS → Gestión de plantillas y crea las siguientes tres plantillas según el escenario:

Uso de la plantilla Ejemplo de contenido de la plantilla
Confirmación de cita Hola {{name}}, tu reunión ha sido confirmada para el {{time}}. Esperamos verte.
Notificación de cancelación Hola {{name}}, tu cita para la reunión ha sido cancelada. Si deseas reprogramarla, ponte en contacto con nosotros.
Recordatorio de reunión Hola {{name}}, tienes una reunión que comenzará dentro de {{advance}}, el {{time}}. Por favor, prepárate con antelación.

Tras enviar las plantillas, espera a que sean aprobadas y anota los tres ID de plantilla.

Recomendación: crea una plantilla independiente para cada escenario; así la semántica es más clara y la tasa de aprobación es mayor. Si la plantilla contiene variables personalizadas (como {{name}}), al llamar a la API debes pasar el valor mediante el campo params; de lo contrario, la variable se enviará tal cual.

Paso 2: crea una suscripción de Webhook en Calendly

La gestión de Webhooks de Calendly no tiene una interfaz visual; debe crearse mediante la API.

Obtén la URI de la organización

Primero, llama a la siguiente interfaz para obtener la URI de la organización de la cuenta actual:

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"

            
Este bloque de código se muestra en una ventana flotante

En el resultado, busca el campo current_organization, con el siguiente formato:

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

            
Este bloque de código se muestra en una ventana flotante

Crea la suscripción de Webhook

Usa la URI de la organización para crear el Webhook y suscribirte a los dos eventos de creación y cancelación de citas:

curl -X POST https://api.calendly.com/webhook_subscriptions \ -H "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://tu-direccion-de-servidor/webhooks/calendly", "events": [ "invitee.created", "invitee.canceled" ], "organization": "https://api.calendly.com/organizations/tu-ID-de-organizacion", "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://tu-direccion-de-servidor/webhooks/calendly",
    "events": [
      "invitee.created",
      "invitee.canceled"
    ],
    "organization": "https://api.calendly.com/organizations/tu-ID-de-organizacion",
    "scope": "organization"
  }'

            
Este bloque de código se muestra en una ventana flotante

Tras crearla con éxito, Calendly enviará una solicitud de verificación a la url indicada, y el servidor debe responder con HTTP 200 para completar el handshake. A partir de entonces, cada vez que alguien reserve o cancele, se enviará un evento a esa dirección.

Nota: la url debe ser una dirección HTTPS accesible desde Internet. Durante el desarrollo local, puedes usar ngrok para generar una dirección temporal: ngrok http 3000, y rellenar la dirección https://xxxx.ngrok.io resultante.

Paso 3: crea el servicio receptor del Webhook

A continuación se muestra un ejemplo completo del lado del servidor en Node.js que, tras recibir los eventos del Webhook de Calendly, llama a la API de EngageLab SMS para enviar SMS.

Instala las dependencias

npm install express
              
              npm install express

            
Este bloque de código se muestra en una ventana flotante

Código completo

// server.js import express from 'express'; const app = express(); app.use(express.json()); // Autenticación de EngageLab: base64(dev_key:dev_secret) const ENGAGELAB_AUTH = Buffer.from( `${process.env.ENGAGELAB_DEV_KEY}:${process.env.ENGAGELAB_DEV_SECRET}` ).toString('base64'); // Llama a la API de EngageLab SMS para enviar el 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 no significa que el envío fue exitoso; hay que comprobar el campo code if (data.code && data.code !== 0) { console.error(`Error al enviar el SMS: code=${data.code}, message=${data.message}`); } else { console.log(`SMS enviado correctamente: plan_id=${data.plan_id}, message_id=${data.message_id}`); } } // Convierte una cadena de tiempo ISO a la hora local (Asia/Shanghai) function formatTime(isoString) { return new Date(isoString).toLocaleString('es-ES', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } // Registra las tareas programadas de recordatorio previo a la reunión (una a 24 h y otra a 1 h) function scheduleReminders({ phone, name, startTime }) { const meetingTime = new Date(startTime).getTime(); const now = Date.now(); const reminders = [ { advance: '24 horas', triggerAt: meetingTime - 24 * 60 * 60 * 1000 }, { advance: '1 hora', triggerAt: meetingTime - 60 * 60 * 1000 }, ]; for (const { advance, triggerAt } of reminders) { const delay = triggerAt - now; if (delay <= 0) continue; // El momento de activación ya pasó; se omite setTimeout(async () => { await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_REMINDER, params: { name, time: formatTime(startTime), advance }, }); }, delay); } } // Endpoint receptor del Webhook app.post('/webhooks/calendly', async (req, res) => { const { event, payload } = req.body; const phone = payload.text_reminder_number; // Número de móvil que el usuario indicó al reservar const name = payload.invitee?.name ?? 'Usuario'; const startTime = payload.scheduled_event?.start_time; // Si no se indicó número de móvil, se omite el envío del SMS if (!phone) { console.log('Esta cita no incluye número de móvil; se omite la notificación por SMS'); return res.sendStatus(200); } if (event === 'invitee.created') { // Envía el SMS de confirmación de la cita await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_CONFIRM, params: { name, time: formatTime(startTime) }, }); // Registra las tareas de recordatorio previo a la reunión scheduleReminders({ phone, name, startTime }); } else if (event === 'invitee.canceled') { // Envía el SMS de notificación de cancelación await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_CANCEL, params: { name }, }); } // Debe devolverse 200 en menos de 2 segundos; de lo contrario, Calendly considerará que el envío falló y reintentará 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());

// Autenticación de EngageLab: base64(dev_key:dev_secret)
const ENGAGELAB_AUTH = Buffer.from(
  `${process.env.ENGAGELAB_DEV_KEY}:${process.env.ENGAGELAB_DEV_SECRET}`
).toString('base64');

// Llama a la API de EngageLab SMS para enviar el 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 no significa que el envío fue exitoso; hay que comprobar el campo code
  if (data.code && data.code !== 0) {
    console.error(`Error al enviar el SMS: code=${data.code}, message=${data.message}`);
  } else {
    console.log(`SMS enviado correctamente: plan_id=${data.plan_id}, message_id=${data.message_id}`);
  }
}

// Convierte una cadena de tiempo ISO a la hora local (Asia/Shanghai)
function formatTime(isoString) {
  return new Date(isoString).toLocaleString('es-ES', {
    timeZone: 'Asia/Shanghai',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  });
}

// Registra las tareas programadas de recordatorio previo a la reunión (una a 24 h y otra a 1 h)
function scheduleReminders({ phone, name, startTime }) {
  const meetingTime = new Date(startTime).getTime();
  const now = Date.now();

  const reminders = [
    { advance: '24 horas', triggerAt: meetingTime - 24 * 60 * 60 * 1000 },
    { advance: '1 hora',  triggerAt: meetingTime -      60 * 60 * 1000 },
  ];

  for (const { advance, triggerAt } of reminders) {
    const delay = triggerAt - now;
    if (delay <= 0) continue; // El momento de activación ya pasó; se omite

    setTimeout(async () => {
      await sendSMS({
        to: phone,
        templateId: process.env.TEMPLATE_ID_REMINDER,
        params: { name, time: formatTime(startTime), advance },
      });
    }, delay);
  }
}

// Endpoint receptor del Webhook
app.post('/webhooks/calendly', async (req, res) => {
  const { event, payload } = req.body;

  const phone     = payload.text_reminder_number;     // Número de móvil que el usuario indicó al reservar
  const name      = payload.invitee?.name ?? 'Usuario';
  const startTime = payload.scheduled_event?.start_time;

  // Si no se indicó número de móvil, se omite el envío del SMS
  if (!phone) {
    console.log('Esta cita no incluye número de móvil; se omite la notificación por SMS');
    return res.sendStatus(200);
  }

  if (event === 'invitee.created') {
    // Envía el SMS de confirmación de la cita
    await sendSMS({
      to: phone,
      templateId: process.env.TEMPLATE_ID_CONFIRM,
      params: { name, time: formatTime(startTime) },
    });

    // Registra las tareas de recordatorio previo a la reunión
    scheduleReminders({ phone, name, startTime });

  } else if (event === 'invitee.canceled') {
    // Envía el SMS de notificación de cancelación
    await sendSMS({
      to: phone,
      templateId: process.env.TEMPLATE_ID_CANCEL,
      params: { name },
    });
  }

  // Debe devolverse 200 en menos de 2 segundos; de lo contrario, Calendly considerará que el envío falló y reintentará
  res.sendStatus(200);
});

app.listen(3000, () => console.log('Server running on port 3000'));

            
Este bloque de código se muestra en una ventana flotante

Configuración de las variables de entorno

Crea un archivo .env en la raíz del proyecto y rellena las siguientes variables:

ENGAGELAB_DEV_KEY=tu_dev_key ENGAGELAB_DEV_SECRET=tu_dev_secret TEMPLATE_ID_CONFIRM=ID_de_la_plantilla_de_confirmacion TEMPLATE_ID_CANCEL=ID_de_la_plantilla_de_cancelacion TEMPLATE_ID_REMINDER=ID_de_la_plantilla_de_recordatorio
              
              ENGAGELAB_DEV_KEY=tu_dev_key
ENGAGELAB_DEV_SECRET=tu_dev_secret

TEMPLATE_ID_CONFIRM=ID_de_la_plantilla_de_confirmacion
TEMPLATE_ID_CANCEL=ID_de_la_plantilla_de_cancelacion
TEMPLATE_ID_REMINDER=ID_de_la_plantilla_de_recordatorio

            
Este bloque de código se muestra en una ventana flotante

Descripción de los campos clave

En el payload que envía el Webhook de Calendly, los siguientes campos están directamente relacionados con el envío de SMS:

Campo Descripción
event Tipo de evento: invitee.created (cita creada) o invitee.canceled (cita cancelada)
payload.text_reminder_number Número de móvil indicado por la persona que reserva, con código de país; puede estar vacío
payload.invitee.name Nombre de la persona que reserva
payload.scheduled_event.start_time Hora de inicio de la reunión, en formato ISO 8601

Escenarios ampliados

Enviar un SMS de seguimiento tras la reunión

Añade en scheduleReminders una tarea que se active después de que finalice la reunión, para enviar una encuesta de satisfacción o una invitación a una próxima cita:

// Añadir dentro de la función scheduleReminders const followUpAt = meetingTime + 30 * 60 * 1000; // 30 minutos después de que finalice la reunión const followUpDelay = followUpAt - now; if (followUpDelay > 0) { setTimeout(async () => { await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_FOLLOWUP, params: { name }, }); }, followUpDelay); }
              
              // Añadir dentro de la función scheduleReminders
const followUpAt = meetingTime + 30 * 60 * 1000; // 30 minutos después de que finalice la reunión
const followUpDelay = followUpAt - now;

if (followUpDelay > 0) {
  setTimeout(async () => {
    await sendSMS({
      to: phone,
      templateId: process.env.TEMPLATE_ID_FOLLOWUP,
      params: { name },
    });
  }, followUpDelay);
}

            
Este bloque de código se muestra en una ventana flotante

Notificar también al anfitrión

Al crear una cita, además de notificar a la persona que reserva, también puedes enviar un recordatorio al anfitrión de la reunión:

if (event === 'invitee.created') { // Notifica a la persona que reserva await sendSMS({ to: phone, templateId: process.env.TEMPLATE_ID_CONFIRM, params: { name, time: formatTime(startTime) }, }); // Notifica al anfitrión await sendSMS({ to: process.env.HOST_PHONE, templateId: process.env.TEMPLATE_ID_HOST_NOTIFY, params: { name, time: formatTime(startTime) }, }); }
              
              if (event === 'invitee.created') {
  // Notifica a la persona que reserva
  await sendSMS({
    to: phone,
    templateId: process.env.TEMPLATE_ID_CONFIRM,
    params: { name, time: formatTime(startTime) },
  });

  // Notifica al anfitrión
  await sendSMS({
    to: process.env.HOST_PHONE,
    templateId: process.env.TEMPLATE_ID_HOST_NOTIFY,
    params: { name, time: formatTime(startTime) },
  });
}

            
Este bloque de código se muestra en una ventana flotante

Consideraciones

  1. El Webhook debe responder con HTTP 200 en menos de 2 segundos; de lo contrario, Calendly considerará que el envío falló y reintentará. Se recomienda devolver primero 200 y, después, ejecutar de forma asíncrona la lógica de envío del SMS, para evitar que Calendly haga un juicio erróneo por la latencia de respuesta de la API de EngageLab.
  2. El campo text_reminder_number puede estar vacío; debes comprobar si está vacío en el código para evitar pasar un número vacío a la API de SMS y provocar un error.
  3. El formato del número de móvil debe incluir el código de país, por ejemplo, un número de Singapur sería +6591234567. Calendly guía al usuario para que escriba el formato internacional al recopilar el número, pero se recomienda hacer una validación de formato en el servidor.
  4. setTimeout no es adecuado para entornos de producción: tras reiniciar el servidor se perderán todas las tareas programadas. En producción se recomienda usar una cola de tareas persistente (como BullMQ + Redis) o las tareas programadas de un servicio en la nube, almacenando los recordatorios en una base de datos para poder recuperarlos tras un reinicio.
  5. La plantilla solo puede usarse después de ser aprobada; si al llamar la plantilla está pendiente de revisión o ha sido rechazada, la API devolverá el error 4001.
  6. Que la API devuelva HTTP 200 no significa que el SMS se haya enviado con éxito; comprueba el campo code en el cuerpo de la respuesta y, si no es cero, consulta la descripción de los códigos de error para investigar la causa.
Icon Solid Transparent White Qiyu
Contacto