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.

  1. Appointment booked
    Your app starts the journey
  2. 24 hours before
    Email and SMS
    The SMS goes only to customers with a phone number
  3. 1 hour before
    SMS
    Only 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.

Email

From: Acme
Your appointment is tomorrow at 11:30 AM

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.

Reschedule

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 timedelta
from courier import Courier
client = Courier()
def start_appointment_reminders(journey_id: str, customer_id: str, appointment: dict) -> str:
starts_at = appointment["starts_at"] # a timezone-aware datetime
response = 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 Courier
client = 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.

Add appointment reminders to this app, sent through Courier: an email and SMS 24 hours before an appointment and a second SMS an hour before, with the SMS only for customers who have a phone number. Follow the steps at https://www.courier.com/recipes/appointment-reminder-sms.md and use the Courier skill for SDK details. 1. Create the journey and its templates in my Courier workspace by replacing Acme Studio and the reschedule link in the template files with our own, then running setup.ts from that page with COURIER_API_KEY from our environment. Save the journey ID it prints as COURIER_APPOINTMENT_JOURNEY_ID. 2. Find where this codebase books an appointment and start the journey there. Pass the fields in the Data fields table from our own data, with all times as ISO 8601 timestamps. Make sure first_name, phone_number, and timezone are written to the customer's Courier profile when the customer is created. 3. Find where an appointment is rescheduled or canceled and cancel the journey with the token appt-<appointment_id>. For a reschedule, start a new run with the new time right after. 4. Match this codebase's language, file layout, and error handling. Never hardcode the API key. 5. When you're done, start one run for a test customer with remind_24h_at and remind_1h_at a minute apart, and show me the run's steps.

Data

Data fields

FieldTypeExampleNote
appointment_idstringappt_4Fq9Builds the cancellation token and the reschedule link
appointment_atISO 8601 datetime2026-10-22T18:30:00ZWhen the appointment starts. The templates show it as a date and a time in the customer's timezone
locationstringAcme Studio, 123 Main St
remind_24h_atISO 8601 datetime2026-10-21T18:30:00ZWhen the email and SMS go, 24 hours before. The journey waits until then
remind_1h_atISO 8601 datetime2026-10-22T17:30:00ZWhen the second SMS goes, 1 hour before. The journey waits until then
profile.first_namestringDanaFrom the profile. Write it when the user signs up. The emails say "there" when it's missing
profile.timezoneIANA timezoneAmerica/Los_AngelesFrom 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.

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.