# Send appointment reminders by SMS

Source: https://joinsimplesms.com/use-cases/appointment-reminders
Index: https://joinsimplesms.com/llms.txt

You build reminders that go out ahead of each appointment and let the customer confirm or ask to reschedule by replying. Your booking system schedules a text when the appointment is created, cancels that text if the appointment moves, and reads replies from a webhook.

## What you build

- **One scheduled message per reminder.** When an appointment is booked you call `POST /v1/messages` with `scheduled_at` and store the returned `job_` id on the appointment row.
- **A reschedule path.** When the appointment moves or is canceled, you delete the pending job and, if needed, schedule a new one.
- **Reply handling.** A webhook receives `message.received` events, matches the sender's number to an upcoming appointment, and records "confirmed" or "wants to reschedule".
- **Bad-number cleanup.** `message.failed` events tell you which phone numbers on file are landlines or out of service, so the front desk can fix them before the next visit.
- **An after-hours reply**, configured per number in the console, so a reply at 9pm gets an acknowledgement instead of silence.

## How it works

1. Buy a number (`GET /v1/numbers/available`, then `POST /v1/numbers`) and submit a [registration](/docs/compliance) that describes reminders, with 2 to 5 real example texts.
2. On booking, compute the send time in UTC from the appointment's local time. `scheduled_at` takes an ISO timestamp or epoch milliseconds, up to 30 days ahead. The response is a `scheduled_message` object with a `job_` id and `run_at`.
3. The queue sends the message on its next flush after the timestamp, within a minute. Opt-out status and quota are checked at that moment, not when you scheduled, so a customer who opted out in between is skipped.
4. If the appointment changes, call `DELETE /v1/scheduled_messages/{id}`. It answers `404` when the job has already sent. `GET /v1/scheduled_messages` lists everything still pending, soonest first.
5. When the customer replies, your endpoint receives `message.received` with `from`, `to`, `body`, `message_id` and a `conversation` thread key.
6. Delivery outcomes arrive as `message.delivered` or `message.failed`. A failure carries a `failure` object with a stable `code` such as `invalid_number` or `landline`.

For an appointment more than 30 days away, keep the reminder in your own job queue and call the API once it is inside the 30-day window.

## Code

Schedule a reminder, list pending ones, and cancel one:

```bash
curl -X POST https://api.joinsimplesms.com/v1/messages \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: appt-58213-reminder-24h" \
  -d '{
    "from": "+15005550100",
    "to": "+15005550006",
    "body": "Acme Dental: Sam, you are booked for Tue Nov 3 at 2:30 PM. Reply C to confirm or R to reschedule. Reply STOP to opt out.",
    "scheduled_at": "2026-11-02T19:30:00Z"
  }'

curl https://api.joinsimplesms.com/v1/scheduled_messages \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY"

curl -X DELETE https://api.joinsimplesms.com/v1/scheduled_messages/job_a1B2c3D4e5F6 \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY"
```

Node.js. The SDK schedules; canceling is a plain `DELETE`:

```js
import { SimpleSMS } from 'joinsimplesms';

const sms = new SimpleSMS(process.env.SIMPLESMS_API_KEY);

export async function scheduleReminder(appointment) {
  const sendAt = new Date(appointment.startsAt.getTime() - 24 * 60 * 60 * 1000);
  const job = await sms.messages.schedule({
    from: '+15005550100',
    to: appointment.phone,
    body: 'Acme Dental: ' + appointment.firstName + ', you are booked for ' +
      appointment.displayTime + '. Reply C to confirm or R to reschedule. Reply STOP to opt out.',
    sendAt,
  });
  return job.id; // "job_...", store it on the appointment
}

export async function cancelReminder(jobId) {
  const res = await fetch('https://api.joinsimplesms.com/v1/scheduled_messages/' + jobId, {
    method: 'DELETE',
    headers: { Authorization: 'Bearer ' + process.env.SIMPLESMS_API_KEY },
  });
  return res.status === 200; // 404: already sent, or no such job
}
```

Python:

```python
from datetime import timedelta
from joinsimplesms import SimpleSMS

client = SimpleSMS()  # reads SIMPLESMS_API_KEY

def schedule_reminder(appointment) -> str:
    job = client.messages.schedule(
        appointment.phone,
        f"Acme Dental: {appointment.first_name}, you are booked for {appointment.display_time}. "
        "Reply C to confirm or R to reschedule. Reply STOP to opt out.",
        appointment.starts_at - timedelta(hours=24),  # a datetime; naive values are read as UTC
        from_="+15005550100",
    )
    return job["id"]
```

The reply webhook, with the signature checked against the raw body before anything is parsed:

```js
import express from 'express';
import { SimpleSMS } from 'joinsimplesms';

const sms = new SimpleSMS(process.env.SIMPLESMS_API_KEY);
const app = express();

app.post('/webhooks/simplesms', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = await sms.webhooks.verify({
      payload: req.body, // raw Buffer, not parsed JSON
      headers: req.headers,
      secret: process.env.SIMPLESMS_WEBHOOK_SECRET,
    });
  } catch {
    return res.status(400).end(); // bad or stale signature
  }

  if (event.type === 'message.received') {
    const reply = String(event.data.body).trim().toUpperCase();
    if (reply === 'C') await markConfirmed(event.data.from);
    else if (reply === 'R') await flagForReschedule(event.data.from);
  }
  if (event.type === 'message.failed') {
    await flagBadNumber(event.data.to, event.data.failure.code);
  }
  res.status(200).end(); // answer within 5 seconds
});
```

Rehearse the reply path in the sandbox with `POST /v1/test/inbound` (`to` your sandbox number, `from` any number, `body` "C"); it emits a real `message.received`.

## Compliance notes

- **Pick reply words that are not keywords.** The platform treats `CANCEL`, `END`, `QUIT` and `STOP` as opt-outs. A reminder that says "reply CANCEL to cancel your appointment" will unsubscribe the customer from all your texts. `YES` is an opt-in keyword; it still reaches your webhook, but single letters such as C and R avoid the overlap entirely.
- **Consent comes from the booking.** Ask for permission to text when you take the number, and store the evidence with `POST /v1/consent/{phone}`. The `proof.source` field accepts `web_form`, `paper` and `verbal`, which covers an online booking form, a clipboard at the front desk and a phone booking.
- **Quiet hours are yours to respect.** SimpleSMS does not know a recipient's time zone and does not hold messages. Because you choose `scheduled_at`, compute it from the appointment's local time so a "24 hours before" reminder for an 8am visit is not also a text at an hour you would not call someone.
- **Registration gates who you can reach.** Until a US local number is linked to an approved registration it can text only your verified numbers. A scheduled reminder to anyone else fails with `sender_not_registered` at send time; it is not silently dropped and not charged.
- **Your examples must match.** Carriers compare the sample texts on the registration with real traffic, so file reminders that look like the ones above.

## What it costs

- **$0.009 per reminder**, whatever its length, carrier fees included. A reminder that fails before the carrier accepts it is not billed, and a canceled job is never sent.
- **$0.95 per number, per month.**
- **Replies:** inbound SMS is listed at $0.004 per message and is not metered yet, so replies are free for now. Automatic replies and STOP or HELP confirmations sent for you are not billed.
- **Worked example:** 2,000 appointments a month with one reminder each, from one number: $18.95. Add a second reminder two hours before and it is $36.95.

See [pricing](/pricing) for the estimator.

## Limits and caveats

- `scheduled_at` reaches 30 days ahead, no further.
- Sending happens within a minute after the timestamp, not on the exact second.
- One call schedules one message. A series (a week before, a day before, two hours before) is three calls and three job ids to cancel.
- A job that is mid-send cannot be canceled.
- A schedule reserves no quota. On the free tier (100 texts a month, 10 a day, verified recipients only) a reminder that comes due after the day's allowance is used is refused at send time.
- US and Canadian recipients only, SMS only. Outbound MMS returns `mms_not_enabled`, so no map images or calendar attachments.
- Carriers report delivery for most messages within minutes, and some never report. A message with no report after 72 hours keeps status `sent` with `receipt_status: "missing"`.
- Live sending needs live-access review. The sandbox accepts `scheduled_at` and simulates delivery.

## Related docs

- [Scheduled messages](/docs/scheduled): scheduling, listing and canceling
- [Messages](/docs/messages): the message object, statuses and idempotency
- [Webhooks](/docs/webhooks): event types, signatures and retries
- [Auto-replies and office hours](/docs/auto-reply)
- [Opt-out, consent and TCPA](/docs/opt-out): the full keyword list
- [Compliance and registration](/docs/compliance): registering your reminder program
- [Numbers](/docs/numbers): buying a number and sender status
