Why I Integrated Cal.com with My Chatbot
I run a small personal site that doubles as a lead‑generation hub for Dream Futures Tech. I wanted visitors to schedule a quick discovery call without leaving the chat window. Cal.com’s v2 API gives me programmatic access to availability, booking, and cancellation – exactly what a chatbot needs.
Prerequisites
- A Cal.com account (free tier works fine) with a team or user that owns the event type you want to expose.
- Node.js ≥18 on my home automation server (a Raspberry Pi 4 running Ubuntu Server).
- A simple chatbot built with Botpress (or any webhook‑compatible bot). I kept the bot logic in a single
bot.jsfile for clarity. -
dotenvfor environment variables andnode-fetchfor HTTP calls.
Getting an API Key
- Log into Cal.com, go to Settings → API Keys.
- Click Create new key, give it a name like
chatbot-integration, and copy the secret. - Store it in a
.envfile:
CALCOM_API_KEY=sk_live_XXXXXXXXXXXXXXXXXXXX
CALCOM_BASE_URL=https://api.cal.com/v2
Never commit this file; I add it to .gitignore.
Fetching Open Slots
Cal.com exposes a /v2/event-types/:id/available-times endpoint. I wrapped it in a tiny helper:
// utils/cal.js
import fetch from 'node-fetch';
import dotenv from 'dotenv';
dotenv.config();
const BASE = process.env.CALCOM_BASE_URL;
const HEADERS = { Authorization: `Bearer ${process.env.CALCOM_API_KEY}` };
export async function getOpenSlots(eventTypeId, { start, end }) {
const url = new URL(`${BASE}/event-types/${eventTypeId}/available-times`);
url.searchParams.append('start', start);
url.searchParams.append('end', end);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`Cal.com error ${res.status}`);
const { data } = await res.json();
return data; // array of { start: ISO, end: ISO }
}
I call this from the chatbot whenever the user asks "When can we talk?".
Presenting Slots in the Chat
Botpress lets me send a quick‑reply carousel. I map the slot objects to a friendly string and store the raw ISO timestamps in a hidden payload:
import { getOpenSlots } from './utils/cal.js';
async function handleAskAvailability(state, event) {
const slots = await getOpenSlots('event_ABC123', {
start: new Date().toISOString(),
end: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
});
const quickReplies = slots.slice(0, 5).map(s => ({
title: new Date(s.start).toLocaleString('en-US', { hour: 'numeric', minute: '2-digit' }),
payload: `BOOK_${s.start}`,
}));
await bp.events.replyToEvent(event, { type: 'quick_reply', quick_replies: quickReplies });
return state;
}
The user taps a time, and the payload BOOK_2024-10-20T14:00:00.000Z triggers the next step.
Creating a Booking
A booking is a POST to /v2/bookings. I keep the request thin – only the event type, the selected slot, and the user’s email (collected earlier in the conversation).
export async function createBooking(eventTypeId, slotIso, email) {
const url = `${process.env.CALCOM_BASE_URL}/bookings`;
const body = {
eventTypeId,
start: slotIso,
end: new Date(new Date(slotIso).getTime() + 30 * 60 * 1000).toISOString(), // 30‑min default
attendees: [{ email }],
};
const res = await fetch(url, {
method: 'POST',
headers: {
...HEADERS,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Booking failed ${res.status}`);
return await res.json(); // contains bookingId, confirmation URL, etc.
}
After a successful call I reply with the confirmation link and store the bookingId in the user’s session for later cancellation.
Handling Cancellations
Cal.com lets you delete a booking via /v2/bookings/:id. I expose a tiny Express route that the chatbot can hit when the user says "Cancel my call".
// server.js
import express from 'express';
import dotenv from 'dotenv';
import fetch from 'node-fetch';
dotenv.config();
const app = express();
app.use(express.json());
app.delete('/cancel/:bookingId', async (req, res) => {
const { bookingId } = req.params;
const url = `${process.env.CALCOM_BASE_URL}/bookings/${bookingId}`;
const delRes = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${process.env.CALCOM_API_KEY}` } });
if (!delRes.ok) return res.status(500).json({ error: 'Cancel failed' });
res.json({ success: true });
});
app.listen(3001, () => console.log('Cancel endpoint listening on 3001'));
In the bot flow I call this endpoint with the stored bookingId. If the API returns 200, I tell the user the slot is free again.
Wiring the Bot to the Express Helper
Botpress can make HTTP calls directly, but I prefer keeping the logic in Node so I can reuse the same functions for a future Slack integration. The chatbot sends a POST to my local server:
// inside bot action
await fetch('http://localhost:3001/cancel/' + state.bookingId, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
if (data.success) bp.dialog.reply(event, 'Your call has been cancelled.');
else bp.dialog.reply(event, 'Sorry, I could not cancel the call.');
});
All communication stays inside my home network; the only public endpoint is the Cal.com API.
Security & Rate‑Limiting
- Environment isolation – the API key lives only on my home server, never in the front‑end bundle.
- IP whitelist – Cal.com allows restricting keys to specific IPs; I added my home router’s static IP.
-
Simple throttling – I added a
express-rate-limitmiddleware to the cancel endpoint to avoid accidental hammering.
import rateLimit from 'express-rate-limit';
app.use('/cancel', rateLimit({ windowMs: 60_000, max: 5 }));
Deploying on My Home Automation Server
I run the bot and the Express helper as systemd services. A pm2 process manager keeps them alive after reboots. The steps are straightforward:
# Install dependencies
npm ci
# Start services with pm2
pm2 start bot.js --name chatbot
pm2 start server.js --name cal‑api
pm2 save
# Enable at boot
pm2 startup
Because everything is lightweight, the Pi handles a few concurrent chats without issue.
Lessons Learned
-
Time‑zone handling – Cal.com returns UTC. I always convert to the visitor’s local time using the browser’s
Intl.DateTimeFormatbefore showing options. - Idempotency – If a user double‑clicks a slot, the second booking attempt fails with a slot already taken error. I catch that and simply resend the confirmation of the first successful booking.
- Graceful fallback – If the Cal.com API is down, the bot now replies with "Our scheduling service is temporarily unavailable. Please email me at hello@dreamfuturestech.com."
Wrap‑Up
By pulling open slots, creating bookings, and exposing a tiny cancellation endpoint, I turned a static contact page into an interactive, self‑service scheduler. The code lives entirely on my own hardware, the Cal.com API does the heavy lifting, and the chatbot keeps the user experience fluid.
I build sites and automations for small businesses at dreamfuturestech.com.
Top comments (0)