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/messageswithscheduled_atand store the returnedjob_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.receivedevents, matches the sender's number to an upcoming appointment, and records "confirmed" or "wants to reschedule". - Bad-number cleanup.
message.failedevents 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
- Buy a number (
GET /v1/numbers/available, thenPOST /v1/numbers) and submit a registration that describes reminders, with 2 to 5 real example texts. - On booking, compute the send time in UTC from the appointment's local time.
scheduled_attakes an ISO timestamp or epoch milliseconds, up to 30 days ahead. The response is ascheduled_messageobject with ajob_id andrun_at. - 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.
- If the appointment changes, call
DELETE /v1/scheduled_messages/{id}. It answers404when the job has already sent.GET /v1/scheduled_messageslists everything still pending, soonest first. - When the customer replies, your endpoint receives
message.receivedwithfrom,to,body,message_idand aconversationthread key. - Delivery outcomes arrive as
message.deliveredormessage.failed. A failure carries afailureobject with a stablecodesuch asinvalid_numberorlandline.
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:
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:
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:
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:
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,QUITandSTOPas opt-outs. A reminder that says "reply CANCEL to cancel your appointment" will unsubscribe the customer from all your texts.YESis 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}. Theproof.sourcefield acceptsweb_form,paperandverbal, 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_registeredat 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_atreaches 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
sentwithreceipt_status: "missing". - Live sending needs live-access review. The sandbox accepts
scheduled_atand simulates delivery.
Related docs
- Scheduled messages: scheduling, listing and canceling
- Messages: the message object, statuses and idempotency
- Webhooks: event types, signatures and retries
- Auto-replies and office hours
- Opt-out, consent and TCPA: the full keyword list
- Compliance and registration: registering your reminder program
- Numbers: buying a number and sender status