# Appointment reminders from your own app

> Schedule a reminder text with send_at when your app books an appointment, move it with one request when the appointment moves, cancel it when it’s canceled, and hear about any reminder that didn’t go.

Bayview Dental has its own booking system, written by Theo, the one developer on staff. The front desk spends every afternoon phoning tomorrow’s patients. Theo wants the system to text each patient a reminder from the clinic’s own number the day before, to keep that reminder right when an appointment moves, to drop it when an appointment is canceled, and to flag the few that didn’t go so the front desk can call only those.

This guide is the API way to do it, where your app owns the appointments. If your bookings live in Calendly or Cal.com, [Confirm and remind Calendly and Cal.com bookings](https://usebluebot.com/docs/guides/booking-reminders) does the same with those apps.

## What you’ll have

- A reminder scheduled in BlueBot as soon as an appointment is booked, sent from your number the day before.
- The reminder moved with the appointment, words and time together, with one request.
- The reminder canceled when the appointment is canceled.
- A flag on each appointment whose reminder failed or expired, so somebody calls.

## What you need

- A BlueBot account with your Mac connected, and an API key with the `messages:send` and `messages:read` scopes from **Developers**, then **API keys**.
- Your app’s appointments, each with a patient’s mobile number, a start time and the zone it’s in.
- Somewhere to keep one more value on each appointment: the reminder’s `msg_` id.

## How it works

A text with `send_at` in the future is `scheduled`. It sits in BlueBot until that time, when it becomes `queued` and your Mac sends it under your sending rules. While it’s `scheduled` you can change its words, its time, its expiry and its metadata with `PATCH /v1/messages/{id}`, or cancel it. Once it’s due it can only be canceled, and once your Mac has taken it, not even that.

Your app keeps the reminder’s id on the appointment, so it never has to search for it:

| When                             | Your app calls                                        | And keeps         |
| -------------------------------- | ----------------------------------------------------- | ----------------- |
| An appointment is booked         | `POST /v1/messages` with `send_at` a day before       | The reminder’s id |
| It moves                         | `PATCH /v1/messages/{id}` with the new words and time | The same id       |
| It moves after its reminder went | `POST /v1/messages` for a new reminder                | The new id        |
| It’s canceled                    | `POST /v1/messages/{id}/cancel`                       | Nothing           |

### Add a column for the reminder

Add a nullable `reminder_message_id` to your appointments table. It holds a `msg_` id, 26 characters.

```sql title="migration.sql"
ALTER TABLE appointments ADD COLUMN reminder_message_id text;
```

### Schedule the reminder when an appointment is booked

The reminder goes 24 hours before the appointment, and `expires_at` is the appointment’s start: if your Mac is off all day, the reminder gives up rather than arriving during or after the visit. The Idempotency-Key is the appointment’s id and its start time, so a booking request your app retries never schedules two reminders, while a new time gets a new one.

```ts title="TypeScript"
// reminders.ts
import BlueBot from '@usebluebot/sdk';
import { db } from './db'; // your app's database

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

export type Appointment = {
  id: string;
  patientFirstName: string;
  patientMobile: string;
  startsAt: Date;
  timeZone: string; // the clinic's, such as 'America/Los_Angeles'
  reminderMessageId: string | null;
};

function reminderText(a: Appointment): string {
  const when = a.startsAt.toLocaleString('en-US', { timeZone: a.timeZone, weekday: 'long', hour: 'numeric', minute: '2-digit' });
  return `Hi ${a.patientFirstName}, this is Bayview Dental. A reminder that your appointment is ${when}. Reply here if you need to change it.`;
}

const remindAt = (a: Appointment) => new Date(a.startsAt.getTime() - DAY);

async function scheduleReminder(a: Appointment): Promise<string | null> {
  if (remindAt(a).getTime() <= Date.now()) return null; // less than a day away: nothing to remind on
  const message = await client.messages.send(
    {
      to: a.patientMobile,
      text: reminderText(a),
      sendAt: remindAt(a).toISOString(),
      expiresAt: a.startsAt.toISOString(),
      metadata: { appointment_id: a.id, kind: 'reminder' },
    },
    { idempotencyKey: `appointment-${a.id}-${a.startsAt.toISOString()}` },
  );
  return message.id;
}

export async function onBooked(a: Appointment) {
  const id = await scheduleReminder(a);
  await db.appointments.update(a.id, { reminderMessageId: id });
}
```

```python title="Python"
# reminders.py
import os
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

from bluebot import BlueBot

from .db import db  # your app's database

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

@dataclass
class Appointment:
    id: str
    patient_first_name: str
    patient_mobile: str
    starts_at: datetime  # aware, in UTC
    time_zone: str  # the clinic's, such as "America/Los_Angeles"
    reminder_message_id: str | None = None

def reminder_text(a: Appointment) -> str:
    when = a.starts_at.astimezone(ZoneInfo(a.time_zone)).strftime("%A at %-I:%M %p")
    return (
        f"Hi {a.patient_first_name}, this is Bayview Dental. "
        f"A reminder that your appointment is {when}. Reply here if you need to change it."
    )

def remind_at(a: Appointment) -> datetime:
    return a.starts_at - timedelta(days=1)

def schedule_reminder(a: Appointment) -> str | None:
    if remind_at(a) <= datetime.now(timezone.utc):
        return None  # less than a day away: nothing to remind on
    message = client.messages.send(
        to=a.patient_mobile,
        text=reminder_text(a),
        send_at=remind_at(a).isoformat(),
        expires_at=a.starts_at.isoformat(),
        metadata={"appointment_id": a.id, "kind": "reminder"},
        idempotency_key=f"appointment-{a.id}-{a.starts_at.isoformat()}",
    )
    return message.id

def on_booked(a: Appointment) -> None:
    db.appointments.update(a.id, reminder_message_id=schedule_reminder(a))
```

The request it makes:

```bash
curl -X POST "https://usebluebot.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: appointment-8812-2026-11-20T17:30:00.000Z" \
  -d '{
    "to": "+15555550123",
    "text": "Hi Maria, this is Bayview Dental. A reminder that your appointment is Thursday at 9:30 AM. Reply here if you need to change it.",
    "send_at": "2026-11-19T17:30:00.000Z",
    "expires_at": "2026-11-20T17:30:00.000Z",
    "metadata": {
      "appointment_id": "8812",
      "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, this is Bayview Dental. A reminder that your appointment is Thursday at 9:30 AM. Reply here if you need to change it.',
  sendAt: '2026-11-19T17:30:00.000Z',
  expiresAt: '2026-11-20T17:30:00.000Z',
  metadata: {
    appointment_id: '8812',
    kind: 'reminder',
  },
}, { idempotencyKey: 'appointment-8812-2026-11-20T17:30:00.000Z' });
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, this is Bayview Dental. A reminder that your appointment is Thursday at 9:30 AM. Reply here if you need to change it.",
    send_at="2026-11-19T17:30:00.000Z",
    expires_at="2026-11-20T17:30:00.000Z",
    metadata={
        "appointment_id": "8812",
        "kind": "reminder",
    },
    idempotency_key="appointment-8812-2026-11-20T17:30:00.000Z",
)
print(message)
```

The answer, `201`:

```json
{
  "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a",
  "object": "message",
  "direction": "out",
  "state": "scheduled",
  "to": "+15555550123",
  "send_at": "2026-11-19T17:30:00.000Z",
  "expires_at": "2026-11-20T17:30:00.000Z",
  "metadata": { "appointment_id": "8812", "kind": "reminder" },
  "hold": null
}
```

### Move the reminder when the appointment moves

Change the words and the time together, since the time is in the words. If the reminder isn’t `scheduled` any more (it went, or it was canceled), BlueBot answers `409 conflict`, and a new one is scheduled for the new time. If the new time is less than a day away, the old reminder is canceled and there’s nothing new to schedule.

```ts title="TypeScript"
function isConflict(error: unknown) {
  return (error as { code?: string }).code === 'conflict';
}

async function cancelReminder(id: string) {
  try {
    await client.messages.cancel(id);
  } catch (error) {
    if (!isConflict(error)) throw error; // the Mac already took it, or it was canceled
  }
}

export async function onMoved(a: Appointment) {
  if (a.reminderMessageId) {
    if (remindAt(a).getTime() > Date.now()) {
      try {
        await client.messages.update(a.reminderMessageId, {
          text: reminderText(a),
          sendAt: remindAt(a).toISOString(),
          expiresAt: a.startsAt.toISOString(),
        });
        return; // same reminder, new time
      } catch (error) {
        if (!isConflict(error)) throw error;
      }
    } else {
      await cancelReminder(a.reminderMessageId);
    }
  }
  const id = await scheduleReminder(a);
  await db.appointments.update(a.id, { reminderMessageId: id });
}

export async function onCanceled(a: Appointment) {
  if (a.reminderMessageId) await cancelReminder(a.reminderMessageId);
  await db.appointments.update(a.id, { reminderMessageId: null });
}
```

```python title="Python"
def is_conflict(error: Exception) -> bool:
    return getattr(error, "code", None) == "conflict"

def cancel_reminder(message_id: str) -> None:
    try:
        client.messages.cancel(message_id)
    except Exception as error:
        if not is_conflict(error):  # the Mac already took it, or it was canceled
            raise

def on_moved(a: Appointment) -> None:
    if a.reminder_message_id:
        if remind_at(a) > datetime.now(timezone.utc):
            try:
                client.messages.update(
                    a.reminder_message_id,
                    text=reminder_text(a),
                    send_at=remind_at(a).isoformat(),
                    expires_at=a.starts_at.isoformat(),
                )
                return  # same reminder, new time
            except Exception as error:
                if not is_conflict(error):
                    raise
        else:
            cancel_reminder(a.reminder_message_id)
    db.appointments.update(a.id, reminder_message_id=schedule_reminder(a))

def on_canceled(a: Appointment) -> None:
    if a.reminder_message_id:
        cancel_reminder(a.reminder_message_id)
    db.appointments.update(a.id, reminder_message_id=None)
```

Moving a reminder is one request:

```bash
curl -X PATCH "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Hi Maria, this is Bayview Dental. A reminder that your appointment is Friday at 2:00 PM. Reply here if you need to change it.",
    "send_at": "2026-11-20T22:00:00.000Z",
    "expires_at": "2026-11-21T22:00:00.000Z"
  }'
```

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

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

const message = await client.messages.update('msg_1Hq2LxW9cVbN3mKpRtYs0a', {
  text: 'Hi Maria, this is Bayview Dental. A reminder that your appointment is Friday at 2:00 PM. Reply here if you need to change it.',
  sendAt: '2026-11-20T22:00:00.000Z',
  expiresAt: '2026-11-21T22:00:00.000Z',
});
console.log(message);
```

```python
import os
from bluebot import BlueBot

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

message = client.messages.update(
    "msg_1Hq2LxW9cVbN3mKpRtYs0a",
    text="Hi Maria, this is Bayview Dental. A reminder that your appointment is Friday at 2:00 PM. Reply here if you need to change it.",
    send_at="2026-11-20T22:00:00.000Z",
    expires_at="2026-11-21T22:00:00.000Z",
)
print(message)
```

And canceling one:

```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)
```

### Hear about reminders that didn’t go

Most reminders go. A few don’t: the number can’t get texts, your Mac was off until the appointment began, or the patient is on your stop list. Make a webhook for `message.failed`, `message.expired` and `message.canceled` in **Developers**, then **Webhooks**, and flag the appointment it names in `metadata`, so the front desk calls only those patients.

```ts title="TypeScript"
// app/hooks/bluebot/route.ts (Next.js)
import BlueBot, { WebhookVerificationError } from '@usebluebot/sdk';
import { db } from '@/db';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });
const DIDNT_GO = new Set(['message.failed', 'message.expired', 'message.canceled']);

export async function POST(request: Request) {
  const payload = await request.text();
  let event;
  try {
    event = client.webhooks.verify(payload, request.headers, process.env.BLUEBOT_WEBHOOK_SECRET);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response('Bad signature', { status: 400 });
    throw error;
  }

  const appointmentId = DIDNT_GO.has(event.type) ? event.data.metadata?.appointment_id : undefined;
  // A reminder your app canceled itself is canceled as "requested"; only flag the others.
  if (appointmentId && event.data.cancelReason !== 'requested') {
    await db.appointments.update(appointmentId, { needsCall: true, reminderProblem: event.type });
  }
  return new Response(null, { status: 204 });
}
```

```python title="Python"
# Flask
import os

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

from .db import db

app = Flask(__name__)
client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])
DIDNT_GO = {"message.failed", "message.expired", "message.canceled"}

@app.post("/hooks/bluebot")
def bluebot_event():
    try:
        event = client.webhooks.verify(request.get_data(), request.headers, secret=os.environ["BLUEBOT_WEBHOOK_SECRET"])
    except WebhookVerificationError:
        abort(400)

    appointment_id = (event.data.metadata or {}).get("appointment_id") if event.type in DIDNT_GO else None
    # A reminder your app canceled itself is canceled as "requested"; only flag the others.
    if appointment_id and event.data.cancel_reason != "requested":
        db.appointments.update(appointment_id, needs_call=True, reminder_problem=event.type)
    return "", 204
```

A canceled reminder whose `cancel_reason` is `opted_out` means the patient texted STOP: they won’t get texts from you again, so the front desk should call.

## Test it

Book an appointment in your app for yourself, with your own mobile number, starting 24 hours and 5 minutes from now. The reminder is due in five minutes.

| You got                                                             | It means                                               | Do next                                                     |
| ------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------- |
| `201` with `state: "scheduled"` and the id saved on the appointment | Booking works.                                         | Move the appointment by an hour.                            |
| After moving, the same id with the new `send_at` and words          | Moving works.                                          | Wait for the reminder, or move it again.                    |
| The reminder on your phone five minutes later                       | It went.                                               | Book another and cancel it: its state becomes `canceled`.   |
| `409 conflict` when you moved it                                    | It wasn’t scheduled any more: it went or was canceled. | Your `onMoved` schedules a new one, which is right.         |
| `400 invalid_request` naming `send_at`                              | The time had no zone, or wasn’t a date.                | Send it in ISO 8601 with `Z` or an offset.                  |
| The reminder `queued` with `hold.reason: "quiet_hours"`             | It came due in your quiet hours.                       | It goes when quiet hours end, still before the appointment. |

## When it goes wrong

- **A reminder falls in quiet hours.** A 7:00 AM appointment’s reminder is due at 7:00 AM the day before, inside the default quiet hours, and goes at 8:00 AM instead. If that matters, schedule reminders for a set time the day before, such as 10:00 AM in the clinic’s zone, instead of exactly 24 hours ahead.
- **Patients in other zones.** `toLocaleString` above writes the time in the clinic’s zone, which is where the appointment is. Turn on **Smart quiet hours** in **Settings**, then **Sending rules**, if patients may be in other zones when the reminder arrives.
- **Your app crashes between sending and saving the id.** The next try sends with the same Idempotency-Key and gets the same reminder back, within 24 hours. After that, list the person’s scheduled texts (`GET /v1/messages?person=…&state=scheduled`) and match `metadata.appointment_id`.
- **A busy Monday.** Two hundred reminders due at the same minute go out 20 seconds apart, by your pace setting, so the last goes about an hour later. Spread `send_at` across the morning if that’s too late.

## Next

- [Scheduled sends](https://usebluebot.com/docs/sending/scheduled): Everything about `send_at` and `expires_at`.
- [Change or cancel a text](https://usebluebot.com/docs/sending/change-or-cancel): What PATCH can change, and until when.
- [Message states](https://usebluebot.com/docs/concepts/message-states): Every state a text can be in, and the webhook for each change.
- [Webhooks](https://usebluebot.com/docs/webhooks): Make the webhook and verify its signature.
- [Calendly and Cal.com reminders](https://usebluebot.com/docs/guides/booking-reminders): The same reminders for bookings made in those apps.
- [Idempotency](https://usebluebot.com/docs/concepts/idempotency): How a retried booking never schedules two reminders.
