Engagement
Appointment reminder SMS: template and code
Cut no-shows with reminders timed to each appointment, and restart them when an appointment moves.
Passing, tested 2026-10-19 · Email, SMS
How it works
When to send appointment reminders
An appointment reminder SMS cuts no-shows by texting customers before their appointment. A day before, it sends a text and an email with the time, place, and a reschedule link, and an hour before it sends one more text. Customers without a phone number get the email only, and rescheduling cancels the old reminders.
- Appointment bookedYour app starts the journey
- 24 hours beforeEmail and SMSThe SMS goes only to customers with a phone number
- 1 hour beforeSMSOnly for customers with a phone number
Stops early: Canceling or rescheduling an appointment cancels its reminders. For a reschedule, start a new run with the new time.
Messages
What each step sends
Step 1: 24 hours before the appointment
Email and SMS. The SMS goes only to customers with a phone number.
Hi Dana,
This is a reminder that you're booked with Acme Studio tomorrow, Thursday, October 22, at 11:30 AM. You'll find us at Acme Studio, 123 Main St.
Need a different time? You can reschedule below.
SMS
Acme Studio: your appointment is tomorrow at 11:30 AM, Acme Studio, 123 Main St. Need a new time? https://app.acme.com/appointments/appt_4Fq9 Reply STOP to opt out.
Other subject lines: "Reminder: October 22 at 11:30 AM"
Step 2: 1 hour before
SMS. Only for customers with a phone number.
SMS
Acme Studio: your appointment is in 1 hour, at 11:30 AM, Acme Studio, 123 Main St. Can't make it? https://app.acme.com/appointments/appt_4Fq9 Reply STOP to opt out.
Both times come from the appointment, so pass remind_24h_at and remind_1h_at for each one. If you move them, change "tomorrow" in the 24-hour templates and "in 1 hour" in the 1-hour text to match.
Build
Add it to your app
1. Create the journey
Run setup.ts once with your API key. It creates the templates and the journey, publishes it, and prints the journey ID you pass to step 2. Copy setup.ts, journey.json, and the templates folder from the full definition at the end of this section.
Terminal
COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
2. Start it when an appointment is booked
Node.js
// Start the reminders when an appointment is booked, with the journey ID setup.ts printed.// For a reschedule, call stopAppointmentReminders first, then this with the new time.import Courier from "@trycourier/courier";const client = new Courier();const HOUR = 60 * 60 * 1000;export async function startAppointmentReminders(journeyId: string,customerId: string,appointment: { id: string; startsAt: Date; location: string }) {const startsAt = appointment.startsAt.getTime();const { runId } = await client.journeys.invoke(journeyId, {user_id: customerId,data: {appointment_id: appointment.id,appointment_at: appointment.startsAt.toISOString(),location: appointment.location,remind_24h_at: new Date(startsAt - 24 * HOUR).toISOString(),remind_1h_at: new Date(startsAt - HOUR).toISOString(),},});return runId;}
Python
# Start the reminders when an appointment is booked, with the journey ID setup.ts printed.# For a reschedule, call stop_appointment_reminders first, then this with the new time.from datetime import timedeltafrom courier import Courierclient = Courier()def start_appointment_reminders(journey_id: str, customer_id: str, appointment: dict) -> str:starts_at = appointment["starts_at"] # a timezone-aware datetimeresponse = client.journeys.invoke(template_id=journey_id,user_id=customer_id,data={"appointment_id": appointment["id"],"appointment_at": starts_at.isoformat(),"location": appointment["location"],"remind_24h_at": (starts_at - timedelta(hours=24)).isoformat(),"remind_1h_at": (starts_at - timedelta(hours=1)).isoformat(),},)return response.run_id
3. Cancel it when an appointment moves
Node.js
// Cancel the reminders when an appointment is canceled or moved. This stops// every run for the appointment, and does nothing if they already went out.// To reschedule, call this and then start a new run with the new time.import Courier from "@trycourier/courier";const client = new Courier();export async function stopAppointmentReminders(appointmentId: string) {await client.journeys.cancel({ cancelation_token: `appt-${appointmentId}` });}
Python
# Cancel the reminders when an appointment is canceled or moved. This stops# every run for the appointment, and does nothing if they already went out.# To reschedule, call this and then start a new run with the new time.from courier import Courierclient = Courier()def stop_appointment_reminders(appointment_id: str) -> None:client.journeys.cancel(cancelation_token=f"appt-{appointment_id}")
Full definition: journey.json, setup.ts, and the 3 templates
An email and an SMS go 24 hours before the appointment, and one more SMS goes an hour before. Both SMS steps check the profile for a phone number, so customers without one get the email only.
Dates and times show in each customer's timezone from their profile, and in New York time when it isn't set. Replace Acme Studio and the reschedule link in the templates with your own.
journey.json
{"name": "Appointment reminders","cancelation_token": "appt-{{data.appointment_id}}","nodes": [{"type": "trigger","trigger_type": "api-invoke"},{"type": "delay","mode": "until","until": "{{data.remind_24h_at}}"},{"type": "send","channel": "email","message": {"template": "@reminder-24h-email"}},{"type": "send","channel": "sms","message": {"template": "@reminder-24h-sms"},"conditions": ["profile.phone_number", "exists"]},{"type": "delay","mode": "until","until": "{{data.remind_1h_at}}"},{"type": "send","channel": "sms","message": {"template": "@reminder-1h-sms"},"conditions": ["profile.phone_number", "exists"]},{"type": "exit"}]}
setup.ts
// Creates this recipe's journey from journey.json and the templates/ folder,// then publishes it. Run it once per workspace://// COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts//// It prints the journey ID and the template IDs it created.import Courier from "@trycourier/courier";import { readFileSync, readdirSync } from "node:fs";const client = new Courier();const here = new URL(".", import.meta.url);const readJson = (path: string) =>JSON.parse(readFileSync(new URL(path, here), "utf8"));const spec = readJson("journey.json");// 1. Create the journey, then the templates its send nodes use.const journey = await client.journeys.create({name: spec.name,nodes: [spec.nodes[0], spec.nodes.at(-1)],});const templateIds: Record<string, string> = {};for (const file of readdirSync(new URL("templates/", here))) {const key = file.replace(/\.json$/, "");const template = await client.journeys.templates.create(journey.id,readJson(`templates/${file}`));await client.journeys.templates.publish(template.id, {templateId: journey.id,});templateIds[key] = template.id;}// 2. Save the nodes. In journey.json, "@<template>" stands for the ID of the// template with that file name. Node IDs must be unique across a workspace,// so any node ID journey.json names (used by "send_status.<id>" conditions)// gets this journey's ID added.const nodeIds = [...JSON.stringify(spec.nodes).matchAll(/"id":"([\w-]+)"/g),].map((m) => m[1]);let nodes = JSON.stringify(spec.nodes).replace(/"@([\w-]+)"/g, (_, key) =>JSON.stringify(templateIds[key]));for (const id of nodeIds)nodes = nodes.replace(new RegExp(`(?<=[".])${id}(?=")`, "g"),`${id}-${journey.id}`);await client.journeys.replace(journey.id, {name: spec.name,cancelation_token: spec.cancelation_token,nodes: JSON.parse(nodes),});// 3. Publish, so new runs use this version.await client.journeys.publish(journey.id);console.log(JSON.stringify({ journeyId: journey.id, templateIds }));
templates/reminder-24h-email.json
{"channel": "email","notification": {"name": "Appointment tomorrow (email)","tags": ["recipe", "appointment-reminder-sms"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "Your appointment is tomorrow at {{datetime-format appointment_at \"h:mm a\" (default profile.timezone \"America/New_York\")}}"},{"type": "text","content": "Hi {{default profile.first_name \"there\"}},"},{"type": "text","content": "This is a reminder that you're booked with Acme Studio tomorrow, {{datetime-format appointment_at \"EEEE, MMMM d\" (default profile.timezone \"America/New_York\")}}, at {{datetime-format appointment_at \"h:mm a\" (default profile.timezone \"America/New_York\")}}. You'll find us at {{location}}."},{"type": "text","content": "Need a different time? You can reschedule below."},{"type": "action","content": "Reschedule","href": "https://app.acme.com/appointments/{{appointment_id}}"}]}]}}}
templates/reminder-24h-sms.json
{"channel": "sms","notification": {"name": "Appointment tomorrow (SMS)","tags": ["recipe", "appointment-reminder-sms"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "sms","elements": [{"type": "text","content": "Acme Studio: your appointment is tomorrow at {{datetime-format appointment_at \"h:mm a\" (default profile.timezone \"America/New_York\")}}, {{location}}. Need a new time? https://app.acme.com/appointments/{{appointment_id}} Reply STOP to opt out."}]}]}}}
templates/reminder-1h-sms.json
{"channel": "sms","notification": {"name": "Appointment in 1 hour (SMS)","tags": ["recipe", "appointment-reminder-sms"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "sms","elements": [{"type": "text","content": "Acme Studio: your appointment is in 1 hour, at {{datetime-format appointment_at \"h:mm a\" (default profile.timezone \"America/New_York\")}}, {{location}}. Can't make it? https://app.acme.com/appointments/{{appointment_id}} Reply STOP to opt out."}]}]}}}
Agents
Hand it to your agent
Give this prompt to Claude Code, Cursor, or Codex with the Courier skill installed. Agents can also read the markdown version and the manifest.
Data
Data fields
| Field | Type | Example | Note |
|---|---|---|---|
| appointment_id | string | appt_4Fq9 | Builds the cancellation token and the reschedule link |
| appointment_at | ISO 8601 datetime | 2026-10-22T18:30:00Z | When the appointment starts. The templates show it as a date and a time in the customer's timezone |
| location | string | Acme Studio, 123 Main St | |
| remind_24h_at | ISO 8601 datetime | 2026-10-21T18:30:00Z | When the email and SMS go, 24 hours before. The journey waits until then |
| remind_1h_at | ISO 8601 datetime | 2026-10-22T17:30:00Z | When the second SMS goes, 1 hour before. The journey waits until then |
| profile.first_name | string | Dana | From the profile. Write it when the user signs up. The emails say "there" when it's missing |
| profile.timezone | IANA timezone | America/Los_Angeles | From the profile. Dates and times show in this timezone, and in New York time when it's missing |
FAQ
Frequently asked questions
How far ahead should I send appointment reminders?
A day before and an hour before covers most appointments. The first leaves time to reschedule, and the second catches anyone who forgot. Both times come from the data you pass in, so you can move them. If you do, change "tomorrow" and "in 1 hour" in the templates to match.
Which time zone do appointment reminders use?
Each customer's own. Dates and times show in the timezone on their Courier profile, and in New York time when it isn't set. Write a timezone like America/Los_Angeles to the profile when the customer is created.
How do I update the reminders when an appointment is rescheduled?
Cancel the old run with the token appt-<appointment_id>, then start a new run with the new time. The old reminders never go out.
What if the customer has no phone number?
They get the 24-hour email and no texts. Both SMS steps check the profile for a phone number and are skipped without one.
Recipes
Related recipes
Send a login code by SMS, fall back to email, and stop retrying once the code is stale.
Move web signups onto your mobile app with a text, then an email, and skip anyone who already installed.
Send a clean receipt after every payment, by email and in your app's Inbox.
Docs
How each part works
Build this in your workspace
Run setup.ts against a free account and the journey is live in a minute. Prefer no code? Rebuild it in the Journeys editor.