Skip to the page

Guides

Confirm and remind Calendly and Cal.com bookings

Text each person who books through Calendly or Cal.com a confirmation from your own number, then a reminder the day before that cancels itself when they cancel or reschedule.

Jess runs Pine Street Physio and takes bookings through Calendly. A missed appointment costs her an hour she can’t sell, and email reminders go unread. She wants each client to get a text from her own number when they book, and another the day before. When a client cancels or moves the booking, the old reminder must not go: “See you tomorrow at 9” for a booking that no longer exists is worse than nothing.

What you’ll have

  • A confirmation text from your number as soon as somebody books.
  • A reminder the day before, scheduled in BlueBot when they book.
  • The reminder canceled when they cancel, and a new one scheduled when they reschedule.
  • Their replies in your inbox and on your iPhone, so “Can we make it 10?” reaches you.

What you need

  • A BlueBot account with your Mac connected.
  • A Calendly or Cal.com account where you can change an event type.
  • A required phone number question on the booking form, with a line saying you’ll text about the appointment.

Calendly connects through Zapier (or Make), and Cal.com through its own webhooks to a small handler of yours. Use whichever matches your booking app; the Cal.com handler is also the pattern for any booking app with webhooks.

How a reminder cancels itself

A reminder is an ordinary text with send_at set to a day before the appointment. Until that time it’s scheduled, and you can change it or cancel it. Each reminder carries the booking’s id in its metadata, so when a booking is canceled you can find the reminder that belongs to it and cancel exactly that one. Its expires_at is the appointment’s start: if your Mac is off all day and the reminder is still waiting when the appointment begins, it gives up instead of going late.

Calendly with Zapier

You’ll make two Zaps: one when somebody books, one when somebody cancels. Calendly treats a reschedule as a cancellation of the old booking and a new booking, so the two Zaps handle reschedules with no extra step.

Ask for a phone number

In Calendly, open the event type, go to its booking questions, add a question whose answer type is Phone Number, and make it required. Put the permission line in the question: “We’ll text this number to confirm and remind you.”

Zap 1: confirm the booking

Make a Zap with Calendly and Invitee Created, connect Calendly and press Test trigger to load a booking. Then add BlueBot, Send Text, connected with an API key from Developers, then API keys:

BlueBot fieldMap it to
ToThe answer to your phone number question. Zapier lists it among the invitee’s questions and answers.
TextHi, the invitee’s first name, then , you’re booked with Pine Street Physio for, the event’s start time, . Reply here if anything changes.
Metadatasource set to calendly, and booking set to the invitee’s URI.

Zapier shows times in a long format. To write “Thursday, Nov 13 at 9:00 AM”, add Formatter by Zapier, Date / Time, Format before this step and map its output instead.

Zap 1, continued: schedule the reminder

Add Formatter by Zapier, Date / Time, Add/Subtract Time, with the event’s start time as the input and -1 day as the expression. Then add BlueBot, Schedule Text:

BlueBot fieldMap it to
ToThe phone number answer again.
TextHi, the first name, then , a reminder that you’re booked with Pine Street Physio tomorrow at, the formatted start time, . Text me here if you need to move it.
Send AtThe Formatter step’s output.
Expires AtThe event’s start time.
Metadatasource set to calendly, and booking set to the invitee’s URI.

Zap 1, continued: remember the reminder

Add Storage by Zapier, Set Value. Set Key to the invitee’s URI and Value to the Schedule Text step’s ID (a msg_ id). This is how the second Zap finds the reminder later. Turn the Zap on.

Zap 2: cancel the reminder

Make a second Zap with Calendly and Invitee Canceled. Add Storage by Zapier, Get Value, with Key set to the invitee’s URI. Then add BlueBot, Cancel Scheduled Text, with Message ID set to the value Storage found. Turn it on.

A reminder that already went can’t be canceled, and BlueBot says so with 409 conflict; Zapier shows that step as stopped, which is what you want. When a client texts back to cancel, the text reaches you, not Calendly: cancel the booking in Calendly yourself, and Zap 2 cancels the reminder.

Cal.com with your own handler

Cal.com posts bookings, cancellations and reschedules to a webhook of yours. The handler confirms each new booking, schedules the reminder with the booking’s uid in its metadata, and on a cancellation or a reschedule finds that reminder and cancels it.

Ask for a phone number

In Cal.com, open the event type, go to Advanced, and in the booking questions turn on the attendee phone number question and make it required.

Add the webhook

In Cal.com, open Settings, then Developer, then Webhooks, and add one with your handler’s address (https://pinestreetphysio.example/hooks/cal). Tick the Booking Created, Booking Cancelled and Booking Rescheduled triggers and set a secret. Cal.com signs each post with it in X-Cal-Signature-256, the hex HMAC-SHA256 of the raw body.

Terminal
export BLUEBOT_API_KEY="bb_live_…"
export CAL_WEBHOOK_SECRET="the secret you set in Cal.com"

Write the handler

// app/hooks/cal/route.ts (Next.js). Any framework works: read the raw body first.
import { createHmac, timingSafeEqual } from 'node:crypto';
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });
const DAY = 24 * 60 * 60 * 1000;

function signedByCal(payload: string, header: string | null): boolean {
  if (!header) return false;
  const expected = createHmac('sha256', process.env.CAL_WEBHOOK_SECRET!).update(payload).digest('hex');
  return header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

const when = (iso: string, timeZone: string) =>
  new Date(iso).toLocaleString('en-US', { timeZone, weekday: 'long', hour: 'numeric', minute: '2-digit' });

// Cancel the reminder that belongs to one booking, found by the booking's uid in its metadata.
async function cancelReminder(phone: string, bookingUid: string) {
  const page = await client.messages.list({ person: phone, state: 'scheduled', limit: 100 });
  for (const message of page.data) {
    if (message.metadata.booking_uid !== bookingUid) continue;
    try {
      await client.messages.cancel(message.id);
    } catch (error) {
      // 409 conflict: it became due a moment ago and the Mac took it. Nothing more to do.
      if ((error as { code?: string }).code !== 'conflict') throw error;
    }
  }
}

async function confirmAndRemind(booking: any, phone: string) {
  const firstName = booking.attendees[0].name.split(' ')[0];
  const zone = booking.attendees[0].timeZone;
  const start = new Date(booking.startTime);

  await client.messages.send(
    {
      to: phone,
      text: `Hi ${firstName}, you’re booked with Pine Street Physio for ${when(booking.startTime, zone)}. Reply here if anything changes.`,
      metadata: { source: 'cal.com', booking_uid: booking.uid, kind: 'confirmation' },
    },
    { idempotencyKey: `cal-confirm-${booking.uid}` },
  );

  const remindAt = new Date(start.getTime() - DAY);
  if (remindAt.getTime() < Date.now()) return; // booked for tomorrow or sooner: the confirmation is the reminder

  await client.messages.send(
    {
      to: phone,
      text: `Hi ${firstName}, a reminder that you’re booked with Pine Street Physio tomorrow, ${when(booking.startTime, zone)}. Text me if you need to move it.`,
      sendAt: remindAt.toISOString(),
      expiresAt: start.toISOString(),
      metadata: { source: 'cal.com', booking_uid: booking.uid, kind: 'reminder' },
    },
    { idempotencyKey: `cal-reminder-${booking.uid}` },
  );
}

export async function POST(request: Request) {
  const payload = await request.text();
  if (!signedByCal(payload, request.headers.get('x-cal-signature-256'))) {
    return new Response('Bad signature', { status: 401 });
  }

  const { triggerEvent, payload: booking } = JSON.parse(payload);
  const phone: string | undefined = booking.responses?.attendeePhoneNumber?.value ?? booking.attendees?.[0]?.phoneNumber;
  if (!phone) return new Response(null, { status: 204 });

  if (triggerEvent === 'BOOKING_CREATED') await confirmAndRemind(booking, phone);
  if (triggerEvent === 'BOOKING_CANCELLED') await cancelReminder(phone, booking.uid);
  if (triggerEvent === 'BOOKING_RESCHEDULED') {
    await cancelReminder(phone, booking.rescheduleUid); // the booking this one replaced
    await confirmAndRemind(booking, phone);
  }
  return new Response(null, { status: 204 });
}

Each piece is one API call. Scheduling the reminder:

POST/v1/messagesSend a text →
curl -X POST "https://usebluebot.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cal-reminder-3kQm9xYbTn4Vw2LrPz8sHd" \
  -d '{
    "to": "+15555550123",
    "text": "Hi Maria, a reminder that you’re booked with Pine Street Physio tomorrow, Thursday at 9:00 AM. Text me if you need to move it.",
    "send_at": "2026-11-19T14:00:00.000Z",
    "expires_at": "2026-11-20T14:00:00.000Z",
    "metadata": {
      "source": "cal.com",
      "booking_uid": "3kQm9xYbTn4Vw2LrPz8sHd",
      "kind": "reminder"
    }
  }'

Finding the reminders still scheduled for that person:

GET/v1/messagesList texts →
curl "https://usebluebot.com/api/v1/messages?person=%2B15555550123&state=scheduled&limit=100" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY"

And canceling the one whose metadata.booking_uid matches:

POST/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a/cancelCancel a text →
curl -X POST "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a/cancel" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY"

Test it

Book an appointment yourself with your own mobile number, one more than a day ahead, then cancel it.

You gotIt meansDo next
The confirmation on your phone, and the reminder in Messages with state: "scheduled" and tomorrow’s send_atBooking works.Cancel the booking next.
After canceling, the reminder shows state: "canceled" and cancel_reason: "requested"Canceling works.Try a reschedule: the old reminder is canceled and a new one scheduled.
The reminder is still scheduled after you canceledThe cancel step didn’t find it.In Zapier, check that both Zaps use the invitee’s URI as the key. In your handler, check the booking_uid you stored matches the one Cal.com sent.
No reminder, only the confirmationThe booking was less than a day away, so there was no day before to remind on.Book further ahead.
The confirmation waits with hold.reason: "quiet_hours"It’s quiet hours where you are.It goes at the end of quiet hours. Book during the day to test.

To see a reminder go without waiting a day, change the reminder’s time to two minutes from now with PATCH /v1/messages/{id} and {"send_at": "…"}, then watch it move from scheduled to queued to sent.

When it goes wrong

  • A reminder falls in quiet hours. A reminder for a 7:30 AM appointment is due at 7:30 AM the day before, inside the default quiet hours (9 PM to 8 AM). It waits and goes at 8 AM, still the day before. If your clients are in other zones, turn on Smart quiet hours in Settings, then Sending rules, to read quiet hours in theirs.
  • Your Mac is off when a reminder is due. It waits for the Mac. Because expires_at is the appointment’s start, it never goes after the appointment has begun.
  • A client books ten appointments. Each booking has its own reminder with its own booking_uid, and canceling one leaves the others alone.
  • The person texted STOP. They’re on your stop list, and every text to them is canceled, reminders included. Call them about their appointment instead.
  • Zapier’s Storage ran out. Storage by Zapier keeps values for a long time but not forever, and a booking made months ahead might outlive its value. For bookings made far ahead, use your own handler and BlueBot’s metadata, as the Cal.com way does.

Next