code examples
code examples
RedwoodJS SMS Appointment Reminders with Twilio: 2025 Tutorial
Learn how to build automated SMS appointment reminders with RedwoodJS 8.8.1 and Twilio Message Scheduling. Reduce no-shows with this complete, production-ready tutorial.
Build Appointment Reminders with RedwoodJS and Twilio SMS
Learn how to build an automated SMS appointment reminder system using RedwoodJS and Twilio that reduces no-shows and improves customer engagement. This comprehensive tutorial shows you how to send SMS reminders via Twilio's Programmable Messaging API and Message Scheduling feature—eliminating the need for custom scheduling infrastructure or cron jobs.
By the end of this RedwoodJS SMS tutorial, you'll have a production-ready application that can:
- Create, read, update, and delete appointments with a GraphQL API
- Automatically schedule SMS reminders using Twilio Message Scheduling
- Handle time zones correctly for international appointment scheduling
- Securely manage Twilio API credentials and environment variables
This guide assumes you understand JavaScript, Node.js, React, GraphQL, and database concepts.
Why Use RedwoodJS and Twilio for SMS Appointment Reminders?
Goal: Build a full-stack appointment reminder application where you manage appointments through a modern GraphQL API, and your system automatically schedules and sends SMS reminders at a configurable time before each appointment.
Problem Solved: Automate appointment reminders to reduce no-shows by up to 38% (industry average). Eliminate manual reminder processes and complex self-hosted scheduling infrastructure by leveraging Twilio's native message scheduling—no background workers or cron jobs required.
Technologies:
- RedwoodJS: A full-stack, serverless-first web application framework based on React, GraphQL, and Prisma. Latest version: 8.8.1 (as of October 2025). Chosen for its integrated structure, developer experience, and conventions that accelerate development of Node.js SMS applications.
- Twilio Programmable Messaging: Industry-leading SMS API for sending appointment reminders. We'll use the Message Scheduling feature via the Twilio Node.js helper library (version 5.9.0 as of October 2025). Chosen for its reliability, 99.95% uptime SLA, and developer-friendly API.
- Node.js: The runtime environment for RedwoodJS's API side. Node.js v22 LTS (v22.20.0) recommended (Active LTS from October 29, 2024 until October 2025; Maintenance LTS until April 2027).
- Prisma: The database toolkit used by RedwoodJS for database access and migrations. Latest version: 6.16.3 (as of October 2025), featuring the Rust-free architecture for improved deployment simplicity and performance.
- PostgreSQL (or SQLite/MySQL): The relational database to store appointment data and track SMS delivery status.
System Architecture:
User Browser interacts with Redwood Frontend, which communicates via GraphQL with the Redwood API. The API uses Prisma to talk to the Database and makes calls to the Twilio API.
Prerequisites:
- Node.js v20.17.0 or greater (required for RedwoodJS v8). Node.js v22 LTS (v22.20.0) recommended for long-term support (Active LTS until October 2025; Maintenance LTS until April 2027). Install from nodejs.org.
- Yarn v4.1.1 or greater (required for RedwoodJS v8). Install via npm:
npm install -g yarnor see yarnpkg.com. - A Twilio account (Sign up for free).
- An SMS-capable Twilio phone number purchased from your account.
- A Twilio Messaging Service configured (see Step 4).
- Access to a terminal or command prompt.
- A code editor (e.g., VS Code).
Step 1: Set Up Your RedwoodJS Project with Twilio
Let's start by creating a new RedwoodJS application and installing the Twilio SDK for Node.js.
-
Create the RedwoodJS App: Open your terminal and run the following command, replacing
appointment-reminder-appwith your desired project name:bashyarn create redwood-app ./appointment-reminder-app -
Navigate into the Project Directory:
bashcd appointment-reminder-app -
Install Twilio Helper Library: Install the official Twilio Node.js helper library specifically in the API workspace:
bashyarn workspace api add twilio -
Environment Variables: RedwoodJS uses a
.envfile for environment variables. Create this file in the project root:bashtouch .envAdd the following variables to your
.envfile. We will obtain these values in Step 4 (Integrating with Twilio). Do not commit this file to version control if it contains sensitive credentials. Redwood's.gitignoreincludes.envby default.dotenv# .env TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_AUTH_TOKEN=your_auth_token_xxxxxxxxxxxxxx TWILIO_MESSAGING_SERVICE_SID=MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Database URL (Example for PostgreSQL - adjust if using SQLite/MySQL) # Replace user, password, host, port, dbname with your actual DB credentials DATABASE_URL="postgresql://user:password@host:port/dbname?schema=public"TWILIO_ACCOUNT_SID: Your primary Twilio account identifier.TWILIO_AUTH_TOKEN: Your secret token for authenticating API requests. Treat this like a password.TWILIO_MESSAGING_SERVICE_SID: The unique ID for the Twilio Messaging Service you'll configure. Scheduling requires a Messaging Service.DATABASE_URL: The connection string for your database. Redwood defaults to SQLite for quick setup, but PostgreSQL is recommended for production. Update this connection string according to your database provider (e.g., Railway, Supabase, Heroku Postgres, local installation). For SQLite (default), the line would beDATABASE_URL="file:./dev.db".
Step 2: Create the Appointment Database Schema with Prisma
Define the database structure for storing appointment data, SMS tracking information, and delivery status using Prisma ORM.
Note: Prisma 6.16.x Rust-Free Architecture As of Prisma 6.16.0 (October 2025), the Rust-free version of Prisma ORM is generally available. This architectural change provides:
- Lower CPU footprint by eliminating the extra CPU usage from running the query engine as a binary
- Less deployment complexity, as the query engine binary no longer needs to be compiled for specific operating systems
- Improved performance, especially when working with large datasets
The TypeScript-based query engine makes Prisma more accessible and easier to deploy across different environments.
-
Define the Prisma Schema: Open
api/db/schema.prismaand replace its contents with the followingAppointmentmodel:prisma// api/db/schema.prisma datasource db { provider = "postgresql" // Or "sqlite", "mysql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" binaryTargets = ["native"] } model Appointment { id Int @id @default(autoincrement()) name String phoneNumber String // Store in E.164 format (e.g., +15551234567) appointmentTime DateTime // Store in UTC timeZone String // Store the user's IANA time zone (e.g., 'America/New_York') reminderSent Boolean @default(false) // Flag if reminder is successfully scheduled scheduledMessageSid String? // Store Twilio's SID for cancellation/updates createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([appointmentTime]) // Add index for sorting/filtering by time }appointmentTime: We store the appointment time in UTC. This is crucial for consistent scheduling across time zones.timeZone: Storing the original time zone allows us to format the time correctly for the user and potentially adjust reminder messages.scheduledMessageSid: This optional field will hold the unique identifier (Sid) of the scheduled message returned by Twilio. This is essential if you need to cancel or modify the reminder later.@@index([appointmentTime]): Added an index to potentially improve query performance when sorting or filtering by appointment time.
-
Apply Migrations: Run the following command to create and apply the database migration. When prompted, give your migration a descriptive name (e.g.,
create_appointment_model).bashyarn rw prisma migrate devThis command does three things:
- Creates a new SQL migration file reflecting the schema changes.
- Applies the migration to your development database.
- Generates/updates the Prisma Client based on your schema.
Step 3: Build the GraphQL API for SMS Appointment Management
RedwoodJS uses GraphQL for its API layer, providing type-safe queries and mutations. We'll generate the necessary SDL (Schema Definition Language) and service files to handle CRUD operations for appointments and integrate with the Twilio SMS API.
-
Generate SDL and Service: Use Redwood's generator to scaffold the GraphQL types, queries, mutations, and the service file for the
Appointmentmodel:bashyarn rw g sdl AppointmentThis creates:
api/src/graphql/appointments.sdl.ts: Defines the GraphQL schema (types, queries, mutations).api/src/services/appointments/appointments.ts: Contains the business logic (resolvers) for the SDL definitions.api/src/services/appointments/appointments.scenarios.ts: For defining seed data for testing.api/src/services/appointments/appointments.test.ts: For writing unit tests.
-
Refine the GraphQL Schema: Open
api/src/graphql/appointments.sdl.ts. Redwood generates basic CRUD operations. We need to adjust the input types slightly, particularly forappointmentTime.typescript// api/src/graphql/appointments.sdl.ts import gql from 'graphql-tag' // Ensure gql is imported if not auto-imported export const schema = gql` type Appointment { id: Int! name: String! phoneNumber: String! appointmentTime: DateTime! timeZone: String! reminderSent: Boolean! scheduledMessageSid: String createdAt: DateTime! updatedAt: DateTime! } type Query { appointments: [Appointment!]! @requireAuth appointment(id: Int!): Appointment @requireAuth } input CreateAppointmentInput { name: String! phoneNumber: String! # Expect E.164 format appointmentTime: DateTime! # Expect ISO 8601 String (UTC) timeZone: String! # Expect IANA Time Zone Name } input UpdateAppointmentInput { name: String phoneNumber: String appointmentTime: DateTime timeZone: String } type Mutation { createAppointment(input: CreateAppointmentInput!): Appointment! @requireAuth updateAppointment(id: Int!, input: UpdateAppointmentInput!): Appointment! @requireAuth deleteAppointment(id: Int!): Appointment! @requireAuth } `- We explicitly expect
appointmentTimeas aDateTime(which Apollo Server handles as an ISO 8601 string) and assume it's provided in UTC from the client. @requireAuthis added for basic authorization (we'll touch on setup later). Remove it if you don't need authentication initially.
- We explicitly expect
-
Implement Service Logic (Including Twilio Integration): This is where the core logic resides. Open
api/src/services/appointments/appointments.tsand implement the resolvers, including the Twilio scheduling logic within thecreateAppointmentandupdateAppointmentmutations.typescript// api/src/services/appointments/appointments.ts import { Twilio } from 'twilio' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' // Redwood's built-in logger import type { QueryResolvers, MutationResolvers, AppointmentResolvers, Appointment as TAppointment, // Prisma model type CreateAppointmentInput, UpdateAppointmentInput, } from 'types/graphql' // Initialize Twilio Client (Best Practice: Do this once) // Ensure env vars are loaded (Redwood does this automatically) let twilioClient: Twilio | null = null; try { if (process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN) { twilioClient = new Twilio( process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN ); } else { logger.warn('Twilio credentials not found in environment variables. SMS scheduling will be disabled.'); } } catch (error) { logger.error({ error }, 'Failed to initialize Twilio client.'); twilioClient = null; // Ensure client is null if init fails } // --- Helper Function for Scheduling --- const scheduleReminder = async (appointment: { id: number // Can be 0 for new appointments before saving name: string phoneNumber: string appointmentTime: Date timeZone: string // For reminder message content, not scheduling time scheduledMessageSid?: string | null // Existing SID for updates/cancellations }) => { if (!twilioClient || !process.env.TWILIO_MESSAGING_SERVICE_SID) { logger.warn(`Twilio client not initialized or Messaging Service SID missing. Skipping reminder scheduling for appointment ${appointment.id}.`); return null; // Cannot schedule } // Reminder time: e.g., 1 hour before the appointment const reminderOffsetMinutes = 60 const sendAt = new Date( appointment.appointmentTime.getTime() - reminderOffsetMinutes * 60 * 1000 ) // Twilio Message Scheduling Constraints (as of 2025): // - Messages must be scheduled at least 15 minutes (900 seconds) in advance // - Messages cannot be scheduled more than 35 days (3,024,000 seconds) in advance // - Account limit: 1,000,000 scheduled messages at any given time // Source: https://support.twilio.com/hc/en-us/articles/4412165297947 const now = new Date() const fifteenMinutesFromNow = new Date(now.getTime() + 15 * 60 * 1000) const thirtyFiveDaysFromNow = new Date(now.getTime() + 35 * 24 * 60 * 60 * 1000) if (sendAt <= fifteenMinutesFromNow) { logger.warn( `Appointment ${appointment.id} at ${appointment.appointmentTime.toISOString()} is too soon to schedule a reminder (calculated sendAt: ${sendAt.toISOString()}). Minimum scheduling window is 15 minutes. Skipping.` ) // Optionally: Send immediately if desired and appropriate // Or handle as an error / provide user feedback return null // Indicate no message was scheduled } if (sendAt > thirtyFiveDaysFromNow) { logger.warn( `Appointment ${appointment.id} at ${appointment.appointmentTime.toISOString()} is too far in the future to schedule (calculated sendAt: ${sendAt.toISOString()}). Maximum scheduling window is 35 days. Skipping.` ) // Consider background job for very distant appointments if needed return null // Indicate no message was scheduled } // --- Cancel existing reminder if updating --- if (appointment.scheduledMessageSid) { try { logger.info(`Cancelling existing reminder ${appointment.scheduledMessageSid} for appointment ${appointment.id}`) // Ensure client is available before attempting cancellation if (twilioClient) { await twilioClient.messages(appointment.scheduledMessageSid).update({ status: 'canceled' }); logger.info(`Successfully cancelled reminder ${appointment.scheduledMessageSid}`); } else { logger.warn(`Twilio client not available, cannot cancel reminder ${appointment.scheduledMessageSid}`); } } catch (error) { // Log error, but proceed to schedule new message. Possible errors: message already sent/canceled, SID invalid. // Avoid using `error.status === 404` check as it might indicate other issues. // Check Twilio docs for specific error codes if needed (e.g., 20404 usually means Not Found/already processed). logger.error({ error, appointmentId: appointment.id, sid: appointment.scheduledMessageSid }, `Failed to cancel previous reminder. It might have already been sent or canceled.`); } } // --- --- // Format appointment time for the SMS body using the user's time zone let localizedAppointmentTime = 'at the scheduled time'; // Fallback message try { localizedAppointmentTime = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', // e.g., Apr 20, 2025 timeStyle: 'short', // e.g., 10:30 AM timeZone: appointment.timeZone, // Use the stored time zone }).format(appointment.appointmentTime); } catch (e) { logger.error({ error: e, timeZone: appointment.timeZone }, 'Failed to format date with provided timezone for SMS body'); // Keep the fallback message } const messageBody = `Hi ${appointment.name}. Reminder: Your appointment is scheduled for ${localizedAppointmentTime} (${appointment.timeZone}).` try { logger.info( `Scheduling reminder for appointment ${appointment.id} to ${appointment.phoneNumber} at ${sendAt.toISOString()}` ) // Ensure client and SID are available before creating message if (!twilioClient || !process.env.TWILIO_MESSAGING_SERVICE_SID) { throw new Error('Twilio client or Messaging Service SID not available.'); } const message = await twilioClient.messages.create({ messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID, to: appointment.phoneNumber, // Ensure E.164 format from validation body: messageBody, scheduleType: 'fixed', sendAt: sendAt.toISOString(), // Send UTC ISO 8601 string }) logger.info( `Successfully scheduled message SID: ${message.sid} for appointment ${appointment.id}` ) return message.sid // Return the new message SID } catch (error) { logger.error( { error, appointmentId: appointment.id }, 'Failed to schedule Twilio message' ) // Re-throw or handle appropriately - maybe prevent appointment creation // or mark it as needing manual attention. throw new Error(`Failed to schedule SMS reminder: ${error.message || 'Unknown Twilio error'}`) } } // --- --- // Helper function for input validation const validateAppointmentInput = (input: CreateAppointmentInput | UpdateAppointmentInput, isUpdate = false) => { // Phone Number Validation (E.164 Required) if (input.phoneNumber && !/^\+[1-9]\d{1,14}$/.test(input.phoneNumber)) { throw new Error('Invalid phone number format. Use E.164 (e.g., +15551234567)'); } if (!isUpdate && !input.phoneNumber) { // Required for create throw new Error('Phone number is required.'); } // Time Zone Validation if (input.timeZone) { try { // Attempt to use the timezone; this will throw for fundamentally invalid strings Intl.DateTimeFormat(undefined, { timeZone: input.timeZone }); } catch (e) { logger.error({ error: e, timeZone: input.timeZone }, 'Invalid time zone format detected'); throw new Error(`Invalid or unrecognized time zone: ${input.timeZone}. Use IANA format (e.g., America/New_York)`); } // Note: This checks basic validity but might not catch all edge cases of non-standard identifiers. // Consider a library like 'luxon' or 'date-fns-tz' for production validation if needed. } if (!isUpdate && !input.timeZone) { // Required for create throw new Error('Time zone is required.'); } // Appointment Time Validation if (input.appointmentTime) { const time = new Date(input.appointmentTime); if (isNaN(time.getTime())) { throw new Error('Invalid appointment time format. Please use ISO 8601 format.'); } // Optional: Add check if time is in the past? Depends on requirements. // if (time < new Date()) { // throw new Error('Appointment time cannot be in the past.'); // } } if (!isUpdate && !input.appointmentTime) { // Required for create throw new Error('Appointment time is required.'); } // Name Validation if (input.name !== undefined && input.name.trim() === '') { throw new Error('Name cannot be empty.'); } if (!isUpdate && !input.name) { // Required for create throw new Error('Name is required.'); } }; export const appointments: QueryResolvers['appointments'] = () => { // Assumes @requireAuth handles authorization return db.appointment.findMany({ orderBy: { appointmentTime: 'asc' } }) } export const appointment: QueryResolvers['appointment'] = ({ id }) => { // Assumes @requireAuth handles authorization return db.appointment.findUnique({ where: { id }, }) } export const createAppointment: MutationResolvers['createAppointment'] = async ({ input, }) => { // Assumes @requireAuth handles authorization validateAppointmentInput(input); // Centralized validation const appointmentTimeUTC = new Date(input.appointmentTime); // Already validated format let scheduledMessageSid: string | null = null; try { // Schedule the reminder *before* creating the DB record // So if Twilio fails, we don't save the appointment (or handle differently) scheduledMessageSid = await scheduleReminder({ // Pass necessary fields, ID is not available yet id: 0, // Temporary placeholder, not used in scheduling call itself name: input.name, phoneNumber: input.phoneNumber, appointmentTime: appointmentTimeUTC, timeZone: input.timeZone, scheduledMessageSid: null // No existing SID for creation }) } catch (error) { // Error is logged within scheduleReminder // Decide how to proceed: fail creation, or create without reminder? // For this example, we fail the creation if scheduling fails. logger.error({ error, input }, 'Failed scheduling reminder during createAppointment, aborting creation.'); throw error; // Re-throw the error from scheduleReminder } // If scheduling was skipped (e.g., too soon/far), scheduledMessageSid will be null const reminderSentStatus = !!scheduledMessageSid; logger.info(`Creating appointment record for ${input.name} with reminderSent=${reminderSentStatus}`); const createdAppointment = await db.appointment.create({ data: { ...input, appointmentTime: appointmentTimeUTC, // Store UTC Date object reminderSent: reminderSentStatus, scheduledMessageSid: scheduledMessageSid, }, }) return createdAppointment } export const updateAppointment: MutationResolvers['updateAppointment'] = async ({ id, input, }) => { // Assumes @requireAuth handles authorization validateAppointmentInput(input, true); // Centralized validation for updates // Fetch existing appointment to get details needed for cancellation/rescheduling const existingAppointment = await db.appointment.findUnique({ where: { id } }); if (!existingAppointment) { throw new Error(`Appointment with ID ${id} not found.`); } // Prepare data for update, using existing values as fallback const updatedData: Partial<TAppointment> = {}; // Use Prisma type for partial data if (input.name) updatedData.name = input.name; if (input.phoneNumber) updatedData.phoneNumber = input.phoneNumber; if (input.timeZone) updatedData.timeZone = input.timeZone; let appointmentTimeUTC = existingAppointment.appointmentTime; if (input.appointmentTime) { appointmentTimeUTC = new Date(input.appointmentTime); // Already validated updatedData.appointmentTime = appointmentTimeUTC; // Ensure Date object is used } // Determine details for rescheduling, combining existing and new data const detailsForScheduling = { id: existingAppointment.id, name: input.name ?? existingAppointment.name, phoneNumber: input.phoneNumber ?? existingAppointment.phoneNumber, appointmentTime: appointmentTimeUTC, // Use potentially updated time timeZone: input.timeZone ?? existingAppointment.timeZone, scheduledMessageSid: existingAppointment.scheduledMessageSid // Pass existing SID for potential cancellation }; let newScheduledMessageSid: string | null = null; let reminderSentStatus = existingAppointment.reminderSent; // Default to existing status // Only reschedule if relevant fields changed or if it wasn't scheduled before const needsReschedule = input.appointmentTime || input.phoneNumber || input.name || input.timeZone || !existingAppointment.reminderSent; if (needsReschedule) { logger.info(`Rescheduling reminder for updated appointment ${id}`); try { newScheduledMessageSid = await scheduleReminder(detailsForScheduling); reminderSentStatus = !!newScheduledMessageSid; // Update status based on new attempt } catch (error) { // Decide how to proceed. Maybe update DB but flag reminder issue? // Here, we let the update fail if rescheduling fails. logger.error({ error, input, id }, 'Failed rescheduling reminder during updateAppointment, aborting update.'); throw error; // Re-throw the error from scheduleReminder } updatedData.scheduledMessageSid = newScheduledMessageSid; // Store the new SID (or null if scheduling failed/skipped) updatedData.reminderSent = reminderSentStatus; } else { logger.info(`No relevant changes detected for appointment ${id}, skipping reschedule.`); // Keep existing SID and status if no reschedule occurred updatedData.scheduledMessageSid = existingAppointment.scheduledMessageSid; updatedData.reminderSent = existingAppointment.reminderSent; } // Update the database record logger.info(`Updating appointment record ${id} with reminderSent=${updatedData.reminderSent}`); const updatedAppointment = await db.appointment.update({ where: { id }, data: updatedData, }) return updatedAppointment; } export const deleteAppointment: MutationResolvers['deleteAppointment'] = async ({ id, }) => { // Assumes @requireAuth handles authorization logger.info(`Attempting to delete appointment ${id}`); // Find the appointment first to get the scheduledMessageSid const appointmentToDelete = await db.appointment.findUnique({ where: { id } }); if (!appointmentToDelete) { // Already deleted or never existed logger.warn(`Appointment ${id} not found for deletion.`); // Depending on requirements, you might throw an error or return null/undefined // Let's throw for clarity that the requested resource wasn't found. throw new Error(`Appointment with ID ${id} not found.`); } // Attempt to cancel any scheduled reminder *before* deleting if (appointmentToDelete.scheduledMessageSid) { try { logger.info(`Cancelling reminder ${appointmentToDelete.scheduledMessageSid} before deleting appointment ${id}`); // Ensure client is available before attempting cancellation if (twilioClient) { await twilioClient.messages(appointmentToDelete.scheduledMessageSid).update({ status: 'canceled' }); logger.info(`Successfully cancelled reminder for appointment ${id}`); } else { logger.warn(`Twilio client not available, cannot cancel reminder ${appointmentToDelete.scheduledMessageSid} during deletion.`); } } catch (error) { // Log error but proceed with deletion. // Avoid using `error.status === 404` check as it might indicate other issues. // Check Twilio docs for specific error codes if needed (e.g., 20404 usually means Not Found/already processed). logger.error({ error, appointmentId: id, sid: appointmentToDelete.scheduledMessageSid }, `Failed to cancel reminder during deletion. It might have already been sent or canceled.`); } } const deletedAppointment = await db.appointment.delete({ where: { id }, }) logger.info(`Successfully deleted appointment ${id}`); return deletedAppointment } // Resolver for related fields if needed (Redwood handles basic ones) export const Appointment: AppointmentResolvers = { // Example if you needed to resolve a computed field: // formattedAppointmentTime: (_obj, { root }) => { // // Be mindful of server vs client locale when formatting // return new Intl.DateTimeFormat('en-US').format(root.appointmentTime); // }, }- Twilio Client: Initialized once with error handling and check for credentials.
scheduleReminderHelper: Encapsulates the logic for calculatingsendAt, checking Twilio's constraints (15 min - 7 days), canceling previous messages (ifscheduledMessageSidexists and client is available), formatting the message body with the correct time zone (with fallback), and calling the Twilio API. Now checks for client availability.- UTC Handling: All scheduling logic uses the
appointmentTime(assumed UTC) directly. ThetimeZoneis used only for formatting the message content for the user. - E.164 Format: Emphasizes storing and sending phone numbers in E.164 format (
+15551234567) for international compatibility. Validation moved to a helper. - Error Handling: Uses
try...catcharound Twilio calls and logs errors using Redwood'slogger. Crucially, it handles potential failures during scheduling and cancellation. Added checks for Twilio client initialization. - Validation: Centralized input validation in
validateAppointmentInputhelper for consistency increateandupdate. Added timezone validation robustness withtry/catch. - Cancellation: Implements cancellation logic in
updateAppointmentanddeleteAppointmentusing the storedscheduledMessageSid. It logs errors if cancellation fails but generally proceeds with the primary operation (update/delete). Now checks for client availability before attempting cancellation. - Update Logic: Refined update logic to reschedule only when necessary and handle status updates correctly.
Step 4: Configure Twilio SMS and Message Scheduling
Now, let's obtain your Twilio API credentials and configure a Messaging Service for SMS appointment reminders. Message Scheduling requires a Messaging Service—you cannot schedule messages using only a phone number.
-
Get Twilio Account SID and Auth Token:
- Log in to the Twilio Console.
- On the main dashboard ("Account Info"), you'll find your Account SID and Auth Token.
- Copy these values and paste them into the corresponding
TWILIO_ACCOUNT_SIDandTWILIO_AUTH_TOKENvariables in your.envfile.
-
Purchase a Twilio Phone Number:
- Navigate to Phone Numbers > Manage > Buy a number.
- Select your country and ensure the SMS capability is checked.
- Search for available numbers and buy one that suits your needs. Note the number you purchased.
-
Create and Configure a Twilio Messaging Service:
- Navigate to Messaging > Services in the Twilio Console.
- Click Create Messaging Service.
- Enter a Messaging Service friendly name (e.g., "Redwood Reminders").
- For Select what you want to use Messaging for, choose "Notify my users" or a relevant option. Click Create Messaging Service.
- Step 2: Add Senders: Click Add Senders.
- Select Sender Type:
Phone Number. Click Continue. - Select the Twilio phone number you purchased earlier. Click Add Phone Numbers.
- Select Sender Type:
- Step 3: Set up integration: You can usually leave these as defaults unless you have specific needs (like custom webhook URLs, which we don't need for scheduling). Click Step 4: Add compliance info.
- Step 4: Add compliance info (Optional but Recommended): Add any required compliance information if applicable. Click Complete Messaging Service Setup.
- Copy the Messaging Service SID: On the Messaging Service's dashboard page, find the Service SID (it starts with
MG). Copy this value. - Paste the SID into the
TWILIO_MESSAGING_SERVICE_SIDvariable in your.envfile.
Why a Messaging Service? Twilio's Message Scheduling feature requires sending via a Messaging Service. It provides features like sender ID pools, scalability, content intelligence, and advanced opt-out handling, making it superior to sending from a single number directly for applications.
Important: Twilio Message Scheduling Requirements and Limitations (as of 2025)
Required:
- Messaging Service SID is mandatory for scheduling. Passing only a
Fromphone number will cause scheduling parameters to be dropped and the message to send immediately.
Scheduling Constraints:
- Minimum advance time: 15 minutes (messages must be scheduled at least 15 minutes in the future)
- Maximum advance time: 35 days (messages cannot be scheduled more than 35 days in advance)
Capacity Limits:
- Account limit: 1,000,000 scheduled messages at any given time (including subaccounts)
- Subaccount limits are separate from parent account allocation
Pricing:
- Message scheduling is free; you only pay for messages sent
-
Restart Redwood Dev Server: If your development server is running, stop it (
Ctrl+C) and restart it to load the new environment variables:bashyarn rw dev
Step 5: Build the Frontend Interface for Appointment Reminders
Now, let's create the user interface for managing appointments using Redwood's generators and React components.
-
Generate Pages and Cells: We need pages to list appointments and a form to create/edit them. Redwood Cells handle data fetching gracefully.
bash# Generate a page to display appointments yarn rw g page Appointments /appointments # Generate a Cell to fetch and display the list of appointments yarn rw g cell Appointments # Generate a component for the Appointment form yarn rw g component AppointmentForm # Generate pages for creating and editing appointments yarn rw g page NewAppointment /appointments/new yarn rw g page EditAppointment /appointments/{id:Int}/edit yarn rw g cell EditAppointment -
Implement
AppointmentsCell: Openweb/src/components/AppointmentsCell/AppointmentsCell.tsx. This component fetches and displays the list of appointments.(Implementation details for React components like
AppointmentsCell,AppointmentForm,AppointmentsPage,NewAppointmentPage,EditAppointmentPage, andEditAppointmentCellwould follow here, including form handling, date/time input (handling time zones and converting to UTC for the API), displaying appointments, and linking between pages. This typically involves standard React/GraphQL client patterns using Redwood's helpers like<Form>,<Submit>,<FieldError>,useMutation, etc. A full implementation is beyond the scope of this Markdown rewrite task but would be the next logical step in the tutorial.)
Frequently Asked Questions
How to set up RedwoodJS for appointment reminders?
Start by creating a new RedwoodJS application using the command `yarn create redwood-app ./your-project-name`. Then navigate to the project directory. Install the necessary Twilio helper library in the API workspace with `yarn workspace api add twilio`.
What is the role of Twilio in appointment reminders?
Twilio's Programmable Messaging API is used to send SMS reminders. Its Message Scheduling feature enables reliable delivery without building a custom scheduling system. This simplifies development and ensures robust functionality.
How to schedule SMS reminders with Twilio?
Scheduling is handled by the Twilio Node.js helper library and RedwoodJS services. The `scheduleReminder` function calculates the appropriate `sendAt` time, considering the appointment time and a predefined offset. It uses the user's time zone for display but schedules based on UTC for consistency.
How to handle time zones with RedwoodJS and Twilio?
Store appointment times in UTC in your database. The user's time zone is stored separately and is used only for displaying the appointment time in the user's local time and formatting the reminder message. Twilio scheduling always uses UTC.
What are the prerequisites for building appointment reminders?
You need Node.js v18 or later, Yarn, a Twilio account, a Twilio phone number with SMS capability, and a configured Twilio Messaging Service. Familiarity with JavaScript, Node.js, React, GraphQL, and database concepts is helpful.
Why use a Twilio Messaging Service for scheduling?
Twilio's Message Scheduling requires a Messaging Service. It offers benefits like sender ID pools, scalability, and advanced opt-out handling, making it more suitable than sending messages from a single number directly.
What database is recommended for RedwoodJS appointment reminders?
While RedwoodJS defaults to SQLite for simplicity, PostgreSQL is recommended for production environments due to its robustness and scalability. You can configure the database connection string in the `.env` file.
How to integrate Twilio credentials into RedwoodJS?
Your Twilio Account SID, Auth Token, and Messaging Service SID should be stored as environment variables in a `.env` file in your project's root directory. RedwoodJS automatically loads these variables, making them available to your services.
What is the purpose of scheduledMessageSid?
The `scheduledMessageSid` field in the database stores the unique identifier of the scheduled message returned by Twilio. This allows you to cancel or modify the scheduled reminder later if needed.
How to cancel a scheduled appointment reminder?
The provided code includes functionality to cancel existing reminders using the stored `scheduledMessageSid`. This cancellation logic is implemented within the `updateAppointment` and `deleteAppointment` mutations.
What phone number format should be used for Twilio?
Use the E.164 format (e.g., +15551234567) for phone numbers. This ensures international compatibility. The provided code includes validation to enforce this format.
How does RedwoodJS handle GraphQL for appointment reminders?
RedwoodJS uses GraphQL for its API. The `appointments.sdl.ts` file defines the schema for appointment data, and `appointments.ts` contains resolver functions for interacting with the database and Twilio.
How to manage appointment data in RedwoodJS?
RedwoodJS uses Prisma as its database toolkit. You define the data model in the `schema.prisma` file and manage migrations using the Redwood CLI.
Where can I find my Twilio Account SID and Auth Token?
You can find your Account SID and Auth Token on the main dashboard ("Account Info") of the Twilio Console after logging in.
When should I schedule the Twilio SMS reminder?
The example code schedules the reminder 60 minutes before the appointment. The `scheduleReminder` function handles scheduling logic, ensuring the reminder is scheduled within Twilio's constraints (15 minutes to 7 days in the future).
Can I customize the reminder message content?
Yes, the SMS reminder content can be customized. The `scheduleReminder` function demonstrates formatting the message body and incorporating details like the recipient's name and the appointment time and timezone.