# Appointment reminder SMS: template and code

Source: https://www.courier.com/recipes/appointment-reminder-sms (manifest: https://www.courier.com/recipes/appointment-reminder-sms/manifest.json)

Cut no-shows with reminders timed to each appointment, and restart them when an appointment moves.

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.

Tested 2026-10-19: passing.

## When to send appointment reminders

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | 24 hours before the appointment | Email, SMS | Your appointment is tomorrow at {{datetime-format appointment_at "h:mm a" (default profile.timezone "America/New_York")}} |
| 2 | 1 hour before | SMS |  |

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.

## Copy

### Appointment tomorrow (email)

Subject: "Your appointment is tomorrow at {{datetime-format appointment_at "h:mm a" (default profile.timezone "America/New_York")}}", "Reminder: {{datetime-format appointment_at "MMMM d" (default profile.timezone "America/New_York")}} at {{datetime-format appointment_at "h:mm a" (default profile.timezone "America/New_York")}}"

Hi {{default profile.first_name "there"}},

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

Need a different time? You can reschedule below.

Button: Reschedule (https://app.acme.com/appointments/{{appointment_id}})

### Appointment tomorrow (SMS)

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.

### Appointment in 1 hour (SMS)

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.

## The 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"
    }
  ]
}
```

## Create it in your workspace

```bash
COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
```

setup.ts

```typescript
// 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 }));
```

## Start it when an appointment is booked

```typescript
// 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
```

## Cancel it when an appointment moves

```typescript
// 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}")
```

## Templates

setup.ts reads these from a templates/ folder next to it.

templates/reminder-24h-email.json

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

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

```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."
            }
          ]
        }
      ]
    }
  }
}
```

## Hand it to your agent

```text
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 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

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

## Related docs

- [Delay node](https://www.courier.com/docs/journeys/nodes/delay)
- [Send node conditions](https://www.courier.com/docs/journeys/nodes/send)
- [Cancel a run](https://www.courier.com/docs/journeys/nodes/cancel)
- [Invoke a journey](https://www.courier.com/docs/journeys/invoke)
- [Send your first SMS](https://www.courier.com/docs/guides/send-your-first-sms)

## Related recipes

- [One-time passcode](https://www.courier.com/recipes/otp-sms-email-fallback): Send a login code by SMS, fall back to email, and stop retrying once the code is stale.
- [App download link](https://www.courier.com/recipes/app-download-link-sms): Move web signups onto your mobile app with a text, then an email, and skip anyone who already installed.
- [Payment receipt](https://www.courier.com/recipes/payment-receipt-email): Send a clean receipt after every payment, by email and in your app's Inbox.
