Send appointment reminders by SMS

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 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 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.

More in use cases