# 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 field | Map it to                                                                                                                                       |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| To            | The answer to your phone number question. Zapier lists it among the invitee’s questions and answers.                                            |
| Text          | `Hi`, the invitee’s first name, then `, you’re booked with Pine Street Physio for`, the event’s start time, `. Reply here if anything changes.` |
| Metadata      | `source` 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 field | Map it to                                                                                                                                                            |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| To            | The phone number answer again.                                                                                                                                       |
| Text          | `Hi`, 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 At       | The Formatter step’s output.                                                                                                                                         |
| Expires At    | The event’s start time.                                                                                                                                              |
| Metadata      | `source` 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.

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

### Write the handler

```ts title="TypeScript"
// 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 });
}
```

```python title="Python"
# Flask. Any framework works: read the raw body first.
import hashlib
import hmac
import json
import os
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

from flask import Flask, abort, request
from bluebot import BlueBot

app = Flask(__name__)
client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

def signed_by_cal(payload: bytes, header: str | None) -> bool:
    if not header:
        return False
    expected = hmac.new(os.environ["CAL_WEBHOOK_SECRET"].encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(header, expected)

def when(iso: str, zone: str) -> str:
    start = datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone(ZoneInfo(zone))
    return start.strftime("%A at %-I:%M %p")

def cancel_reminder(phone: str, booking_uid: str) -> None:
    page = client.messages.list(person=phone, state="scheduled", limit=100)
    for message in page.data:
        if message.metadata.get("booking_uid") != booking_uid:
            continue
        try:
            client.messages.cancel(message.id)
        except Exception as error:
            # 409 conflict: it became due a moment ago and the Mac took it. Nothing more to do.
            if getattr(error, "code", None) != "conflict":
                raise

def confirm_and_remind(booking: dict, phone: str) -> None:
    attendee = booking["attendees"][0]
    first_name = attendee["name"].split(" ")[0]
    start = datetime.fromisoformat(booking["startTime"].replace("Z", "+00:00"))

    client.messages.send(
        to=phone,
        text=f"Hi {first_name}, you’re booked with Pine Street Physio for "
        f"{when(booking['startTime'], attendee['timeZone'])}. Reply here if anything changes.",
        metadata={"source": "cal.com", "booking_uid": booking["uid"], "kind": "confirmation"},
        idempotency_key=f"cal-confirm-{booking['uid']}",
    )

    remind_at = start - timedelta(days=1)
    if remind_at < datetime.now(timezone.utc):
        return  # booked for tomorrow or sooner: the confirmation is the reminder

    client.messages.send(
        to=phone,
        text=f"Hi {first_name}, a reminder that you’re booked with Pine Street Physio tomorrow, "
        f"{when(booking['startTime'], attendee['timeZone'])}. Text me if you need to move it.",
        send_at=remind_at.isoformat(),
        expires_at=start.isoformat(),
        metadata={"source": "cal.com", "booking_uid": booking["uid"], "kind": "reminder"},
        idempotency_key=f"cal-reminder-{booking['uid']}",
    )

@app.post("/hooks/cal")
def cal_booking():
    payload = request.get_data()
    if not signed_by_cal(payload, request.headers.get("X-Cal-Signature-256")):
        abort(401)

    body = json.loads(payload)
    booking = body["payload"]
    phone = (booking.get("responses", {}).get("attendeePhoneNumber") or {}).get("value") or (
        booking.get("attendees") or [{}]
    )[0].get("phoneNumber")
    if not phone:
        return "", 204

    event = body["triggerEvent"]
    if event == "BOOKING_CREATED":
        confirm_and_remind(booking, phone)
    elif event == "BOOKING_CANCELLED":
        cancel_reminder(phone, booking["uid"])
    elif event == "BOOKING_RESCHEDULED":
        cancel_reminder(phone, booking["rescheduleUid"])  # the booking this one replaced
        confirm_and_remind(booking, phone)
    return "", 204
```

Each piece is one API call. Scheduling the reminder:

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

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const message = await client.messages.send({
  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.',
  sendAt: '2026-11-19T14:00:00.000Z',
  expiresAt: '2026-11-20T14:00:00.000Z',
  metadata: {
    source: 'cal.com',
    booking_uid: '3kQm9xYbTn4Vw2LrPz8sHd',
    kind: 'reminder',
  },
}, { idempotencyKey: 'cal-reminder-3kQm9xYbTn4Vw2LrPz8sHd' });
console.log(message);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

message = client.messages.send(
    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",
    },
    idempotency_key="cal-reminder-3kQm9xYbTn4Vw2LrPz8sHd",
)
print(message)
```

Finding the reminders still scheduled for that person:

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

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const page = await client.messages.list({
  person: '+15555550123',
  state: 'scheduled',
  limit: 100,
});
console.log(page.data);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

page = client.messages.list(person="+15555550123", state="scheduled", limit=100)
print(page.data)
```

And canceling the one whose `metadata.booking_uid` matches:

```bash
curl -X POST "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a/cancel" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY"
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const message = await client.messages.cancel('msg_1Hq2LxW9cVbN3mKpRtYs0a');
console.log(message);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

message = client.messages.cancel("msg_1Hq2LxW9cVbN3mKpRtYs0a")
print(message)
```

## Test it

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

| You got                                                                                                             | It means                                                                       | Do next                                                                                                                                             |
| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| The confirmation on your phone, and the reminder in **Messages** with `state: "scheduled"` and tomorrow’s `send_at` | Booking 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 canceled                                                                | The 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 confirmation                                                                                  | The 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

- [Appointment reminders from your own app](https://usebluebot.com/docs/guides/appointment-reminders): The same pattern when your app owns the bookings.
- [Scheduled sends](https://usebluebot.com/docs/sending/scheduled): `send_at`, `expires_at`, changing and canceling.
- [Calendly recipes](https://usebluebot.com/docs/recipes/calendly): Confirmations, reminders and follow-ups through Zapier or Make.
- [Cal.com recipes](https://usebluebot.com/docs/recipes/cal-com): Every Cal.com recipe, field by field.
- [Change or cancel a text](https://usebluebot.com/docs/sending/change-or-cancel): What can be changed, and until when.
- [Quiet hours](https://usebluebot.com/docs/concepts/quiet-hours): When texts wait, and whose zone counts.
