Schedule SMS Reminders with Sinch in RedwoodJS: Complete Implementation Guide
Build a production-ready appointment reminder system in RedwoodJS using Sinch SMS API. Complete guide covering Prisma schema, GraphQL mutations, Luxon date/time handling, send_at scheduling, and React form implementation.
Build a RedwoodJS application that schedules and sends SMS reminders using the Sinch SMS API. Create an appointment reminder system where users enter appointment details and the application automatically schedules an SMS reminder via Sinch at a specified time before the appointment.
This guide solves the common need for automated, time-sensitive notifications without requiring complex background job infrastructure. Instead, leverage Sinch's built-in send_at scheduling capabilities to handle the job scheduling for you.
Real-world applications:
Medical practices reducing no-shows by 30-40% with timely appointment reminders
Service businesses (salons, auto repair, consulting) improving customer experience with automated confirmations
SaaS platforms sending trial expiration warnings and subscription renewal notices
Educational institutions managing class schedules and exam notifications
Key Technologies:
RedwoodJS: A full-stack, serverless-friendly JavaScript/TypeScript framework for the web. Chosen for its integrated frontend (React) and backend (GraphQL API, Prisma), developer experience, and conventions.
Sinch SMS API: A service for sending and receiving SMS messages globally. Use its Node.js SDK and specifically its send_at feature for scheduling.
Prisma: A next-generation ORM for Node.js and TypeScript, used by RedwoodJS for database access.
Node.js: The underlying JavaScript runtime environment.
PostgreSQL (or SQLite): The database for storing appointment information.
A RedwoodJS application with a simple UI to schedule appointment reminders. The backend validates input, stores appointment details, and uses the Sinch Node SDK to schedule an SMS to be sent 2 hours before the scheduled appointment time.
How Do You Set Up a RedwoodJS Project for SMS Scheduling?
Initialize a new RedwoodJS project and configure the necessary environment.
Create Redwood App:
Open your terminal and navigate to the directory where you want to create your project. Run the following command, replacing <your-app-name> with your desired project name (e.g., redwood-sinch-reminders):
Time zone support: First-class IANA time zone handling (e.g., America/New_York)
ISO-8601 formatting: Built-in .toISO() method matches Sinch API requirements exactly
Readable API: appointmentDateTime.minus({ hours: 2 }) vs. complex Date arithmetic
Alternative libraries like Moment.js are deprecated, and Day.js lacks robust time zone support without plugins.
Environment Variable Setup:
Redwood uses .env files for environment variables. The Sinch SDK requires credentials. Create a .env file in the root of your project:
Bash
touch .env
Add the following variables to your .env file, replacing the placeholder values with your actual Sinch credentials and configuration:
.env
# .env
# Find these in your Sinch Dashboard. Navigate to API Credentials
# under your Project Settings (often via Access Keys section).
SINCH_KEY_ID='YOUR_SINCH_KEY_ID'
SINCH_KEY_SECRET='YOUR_SINCH_KEY_SECRET'
SINCH_PROJECT_ID='YOUR_SINCH_PROJECT_ID'
# Obtain from your Sinch Customer Dashboard -> Numbers -> Your Numbers
# Ensure the number is SMS enabled and assigned to the correct Project ID
SINCH_FROM_NUMBER='+1xxxxxxxxxx' # Use E.164 format
# Specify the Sinch API region (e.g., 'us' or 'eu')
# Regional endpoints: us.sms.api.sinch.com (US), eu.sms.api.sinch.com (EU),
# au.sms.api.sinch.com (AU), br.sms.api.sinch.com (BR), ca.sms.api.sinch.com (CA)
# Choose the region closest to your user base for optimal performance.
SINCH_SMS_REGION='us'
# Default country code prefix for numbers if not provided in E.164 format
# Adjust based on your primary target region if needed, but prefer E.164 input
DEFAULT_COUNTRY_CODE='+1'
SINCH_KEY_ID, SINCH_KEY_SECRET, SINCH_PROJECT_ID: Find these in your Sinch Dashboard under your project's API credentials/Access Keys section. Treat SINCH_KEY_SECRET like a password – never commit it to Git.
SINCH_FROM_NUMBER: A virtual number you've acquired through Sinch, enabled for SMS, and associated with your Project ID. Must be in E.164 format (e.g., +12025550187).
SINCH_SMS_REGION: The regional endpoint for the Sinch API. Available regions: us (United States), eu (European Union), au (Australia), br (Brazil), ca (Canada). Use the region closest to your user base or where your account is homed.
DEFAULT_COUNTRY_CODE: Used as a fallback if the user enters a local number format. Best practice requires E.164 format input.
Add .env to .gitignore:
Ensure your .env file (containing secrets) is not committed to version control. Open your project's root .gitignore file and add .env if it's not already present.
Text
# .gitignore
# ... other entries
.env
.env.defaults # Often safe to commit, but double-check
.env.development
.env.production
# ...
Initial Commit (if not done during creation):
If you didn't initialize Git during create redwood-app:
Bash
git init
git add .
git commit -m "Initial project setup with dependencies and env structure"
How Do You Create the Database Schema for Reminder Scheduling?
Define a database table to store the details of scheduled reminders.
Define Prisma Schema:
Open api/db/schema.prisma and define a model for Reminder:
Prisma
// api/db/schema.prisma
datasource db {
provider = "postgresql" // Or "sqlite" for local dev/simplicity
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = "native"
}
model Reminder {
id Int @id @default(autoincrement())
patientName String
doctorName String
phoneNumber String // Store in E.164 format (e.g., +1xxxxxxxxxx)
appointmentTime DateTime // Store in UTC
reminderTime DateTime // Store in UTC (when the SMS should be sent)
status String @default("PENDING") // PENDING, SENT, FAILED
sinchBatchId String? // Store the ID returned by Sinch API
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, reminderTime]) // Index for potential future querying/cleanup
}
Store phone numbers in E.164 format for consistency.
Store all DateTime fields in UTC to avoid time zone issues.
status tracks the state of the reminder (PENDING means scheduled but not yet sent).
sinchBatchId can be useful for tracking the message status within Sinch later.
An index on status and reminderTime could be useful for querying pending jobs or cleanup tasks.
FAILED: Scheduling or delivery failed (check sinchBatchId in Sinch dashboard for details)
Enhanced schema for production:
Prisma
model Reminder {
id Int @id @default(autoincrement())
patientName String
doctorName String
phoneNumber String
appointmentTime DateTime
reminderTime DateTime
status String @default("PENDING")
sinchBatchId String?
retryCount Int @default(0) // Track retry attempts
errorMessage String? // Store last error for debugging
deliveryStatus String? // DELIVERED, UNDELIVERED, EXPIRED from webhook
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, reminderTime])
}
Set up Database Connection:
Ensure your DATABASE_URL in the .env file points to your database (e.g., postgresql://user:password@host:port/database). For local development, Redwood defaults to SQLite, which requires no extra setup if you stick with the default provider = "sqlite".
Run Database Migration:
Apply the schema changes to your database:
Bash
yarn rw prisma migrate dev
This command creates a new SQL migration file based on your schema.prisma changes and applies it to your development database. Provide a name for the migration when prompted (e.g., create reminder model).
How Do You Implement the Sinch SMS Scheduling Service in RedwoodJS?
Create the RedwoodJS service that handles the logic for scheduling reminders.
Generate Service Files:
Use the Redwood generator to create the necessary service and GraphQL files:
Implement the scheduleReminder Service Function:
Open api/src/services/reminders/reminders.ts and add the logic to create a reminder record and schedule the SMS via Sinch.
TypeScript
// api/src/services/reminders/reminders.tsimport { validate } from'@redwoodjs/api'importtype { MutationResolvers } from'types/graphql'import { db } from'src/lib/db'import { logger } from'src/lib/logger'import { sinchClient } from'src/lib/sinch'// We'll create this client lib nextimport { DateTime } from'luxon'interfaceScheduleReminderInput {
patientName: stringdoctorName: stringphoneNumber: string// Expecting E.164 formatappointmentDate: string// e.g., '2025-07-15'appointmentTime: string// e.g., '14:30'timeZone: string// e.g., 'America/New_York'
}
// --- Helper Function for Phone Number Validation/Normalization ---const normalizePhoneNumber = (inputPhone: string): string => {
// Basic check for E.164 format (starts with +, followed by digits)if (/^\+[1-9]\d{1,14}$/.test(inputPhone)) {
return inputPhone
}
// Attempt to prefix with default country code if it looks like a local number// WARNING: This is a significant simplification. Real-world validation is complex.// Using a library like libphonenumber-js is highly recommended for production// as it handles varying international and local formats, plus provides// robust validation, which this basic example lacks.const digits = inputPhone.replace(/\D/g, '')
if (digits.length >= 10) { // Basic check for common lengthsreturn (process.env.DEFAULT_COUNTRY_CODE || '+1') + digits
}
thrownewError('Invalid phone number format. Use E.164 format (e.g., +1xxxxxxxxxx).')
}
// --- Main Service Function ---exportconstscheduleReminder: MutationResolvers['scheduleReminder'] = async ({
input,
}: {
input: ScheduleReminderInput
}) => {
logger.info({ input }, 'Received request to schedule reminder')
// 1. Validate Inputvalidate(input.patientName, 'Patient Name', { presence: true, length: { min: 1 } })
validate(input.doctorName, 'Doctor Name', { presence: true, length: { min: 1 } })
validate(input.phoneNumber, 'Phone Number', { presence: true })
validate(input.appointmentDate, 'Appointment Date', { presence: true })
validate(input.appointmentTime, 'Appointment Time', { presence: true })
validate(input.timeZone, 'Time Zone', { presence: true }) // Ensure timezone is providedletnormalizedPhone: string;
try {
normalizedPhone = normalizePhoneNumber(input.phoneNumber)
} catch (error) {
logger.error({ error }, 'Phone number validation failed')
thrownewError(error.message)
}
// 2. Calculate Appointment and Reminder Times using LuxonletappointmentDateTime: DateTime;
letreminderDateTime: DateTime;
try {
const dateTimeString = `${input.appointmentDate}T${input.appointmentTime}`
appointmentDateTime = DateTime.fromISO(dateTimeString, { zone: input.timeZone })
if (!appointmentDateTime.isValid) {
thrownewError(`Invalid date/time format or timezone: ${appointmentDateTime.invalidReason}`)
}
// Calculate reminder time (e.g., 2 hours before appointment)
reminderDateTime = appointmentDateTime.minus({ hours: 2 })
// Validation: Ensure reminder time is in the futureif (reminderDateTime <= DateTime.now()) {
thrownewError('Calculated reminder time is in the past. Appointment must be sufficiently in the future.')
}
} catch (error) {
logger.error({ error, input }, 'Error processing date/time')
thrownewError(`Failed to process date/time: ${error.message}`)
}
// Convert times to UTC for storage and Sinch APIconst appointmentTimeUtc = appointmentDateTime.toUTC()
const reminderTimeUtc = reminderDateTime.toUTC()
// Format for Sinch API (ISO 8601 with Z for UTC)const sendAtIso = reminderTimeUtc.toISO() // Luxon defaults to ISO 8601// 3. Construct SMS Message Bodyconst messageBody = `Hi ${input.patientName}, this is a reminder for your appointment with Dr. ${input.doctorName} on ${appointmentDateTime.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY)} at ${appointmentDateTime.toLocaleString(DateTime.TIME_SIMPLE)}. Reply STOP to unsubscribe.`// Added opt-out language
logger.info({
normalizedPhone,
messageBody,
sendAtIso,
appointmentTime: appointmentDateTime.toISO(),
timeZone: input.timeZone
}, 'Prepared reminder details')
// 4. Schedule SMS with Sinch APIlet sinchResponse;
try {
sinchResponse = await sinchClient.sms.batches.send({
sendSMSRequestBody: {
to: [normalizedPhone],
from: process.env.SINCH_FROM_NUMBER,
body: messageBody,
send_at: sendAtIso, // Use the calculated UTC time// Optional: Add delivery_report: 'full' or 'summary' if needed
},
})
logger.info({ sinchResponse }, 'Successfully scheduled SMS via Sinch')
} catch (error) {
logger.error({ error }, 'Failed to schedule SMS via Sinch API')
// Consider creating the DB record with status 'FAILED' or throwingthrownewError(`Failed to schedule SMS: ${error.response?.data?.error?.message || error.message}`)
}
// 5. Store Reminder in Databasetry {
const createdReminder = await db.reminder.create({
data: {
patientName: input.patientName,
doctorName: input.doctorName,
phoneNumber: normalizedPhone,
appointmentTime: appointmentTimeUtc.toJSDate(), // Convert Luxon DateTime to JS Date for PrismareminderTime: reminderTimeUtc.toJSDate(),
status: 'PENDING', // Indicates scheduled but not yet sentsinchBatchId: sinchResponse?.id, // Store the batch ID if available
},
})
logger.info({ reminderId: createdReminder.id }, 'Reminder record created in database')
return createdReminder
} catch (dbError) {
logger.error({ dbError, sinchBatchId: sinchResponse?.id }, 'Failed to save reminder to database after scheduling SMS')
// Critical failure: SMS scheduled, but DB save failed.// Production systems need robust compensation logic: e.g., save reminder// with 'SCHEDULING_FAILED_DB' status *before* API call, update to// 'PENDING' after success, or have a cleanup job identify orphaned scheduled// messages via the Sinch Batch ID. Log details for manual intervention.// Consider attempting to cancel the Sinch message if the API supports it.thrownewError('Failed to save reminder details after scheduling. Manual check required.')
}
}
Input Validation: Uses Redwood's validate and custom logic for the phone number and date/time. The phone number normalization explicitly notes its limitations and recommends libphonenumber-js for production.
Date/Time Handling: Leverages luxon to parse the input date/time with the provided time zone, calculate the reminder time, and convert both to UTC for storage and the API call. Validates that the reminder time is in the future.
Sinch Client: Calls the sinchClient.sms.batches.send method (you'll define sinchClient next).
send_at Parameter: Passes the calculated reminderTimeUtc in ISO-8601 format to Sinch's send_at parameter. Maximum scheduling window: 3 days (Sinch default expire_at).
Database Storage: Saves the reminder details with status 'PENDING' and the sinchBatchId.
Error Handling: Includes try...catch blocks. The database error handling after a successful Sinch call includes warnings and suggestions for compensation logic.
Transaction handling and idempotency:
The current implementation has a race condition: if the SMS schedules successfully but the database write fails, you have an orphaned scheduled message. Production systems should:
Save first, update later: Create DB record with status PENDING_SCHEDULE before calling Sinch, then update to PENDING after success
Use transactions: Wrap both operations in a database transaction (note: external API calls can't be rolled back)
Implement idempotency: Store a unique request ID to prevent duplicate scheduling on retry
Canceling scheduled messages:
Add this function to handle cancellations:
TypeScript
exportconstcancelReminder: MutationResolvers['cancelReminder'] = async ({ id }) => {
const reminder = await db.reminder.findUnique({ where: { id } })
if (!reminder) thrownewError('Reminder not found')
if (reminder.status !== 'PENDING') thrownewError('Can only cancel pending reminders')
try {
// Cancel via Sinch APIawait sinchClient.sms.batches.cancel(reminder.sinchBatchId)
// Update databasereturnawait db.reminder.update({
where: { id },
data: { status: 'CANCELLED' }
})
} catch (error) {
logger.error({ error, reminderId: id }, 'Failed to cancel reminder')
thrownewError(`Cancellation failed: ${error.message}`)
}
}
Create Sinch Client Library:
Centralize the Sinch SDK client initialization.
Create a new file: api/src/lib/sinch.ts
TypeScript
// api/src/lib/sinch.tsimport { SinchClient } from'@sinch/sdk-core'import { logger } from'./logger'// Ensure required environment variables are presentconstrequiredEnvVars: string[] = [
'SINCH_PROJECT_ID',
'SINCH_KEY_ID',
'SINCH_KEY_SECRET',
'SINCH_SMS_REGION', // Crucial for API endpoint routing'SINCH_FROM_NUMBER', // Needed for sending
]
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
const errorMessage = `Configuration error: Missing required Sinch environment variable ${envVar}`
logger.fatal(errorMessage)
// Throwing here will prevent the app from starting without essential configthrownewError(errorMessage)
}
}
// Initialize the Sinch Client// The SDK should ideally use the provided credentials and potentially region// information to route requests correctly.exportconst sinchClient = newSinchClient({
projectId: process.env.SINCH_PROJECT_ID,
keyId: process.env.SINCH_KEY_ID,
keySecret: process.env.SINCH_KEY_SECRET,
// The @sinch/sdk-core might use the region in different ways depending on the specific API (SMS, Voice etc.)// Ensure SINCH_SMS_REGION is set correctly in your environment.// Consult the latest Sinch Node.js SDK documentation for the precise method// of ensuring the SMS API calls target the correct region if issues arise.// It might involve setting a base URL or a specific regional property.
})
// Log confirmation that the client is initialized (without exposing secrets)
logger.info('Sinch Client Initialized with Project ID and Key ID.')
// Although the region variable is checked above, re-emphasize its importance.
logger.info(`Sinch client configured to target region: ${process.env.SINCH_SMS_REGION}`)
This initializes the SinchClient using the environment variables.
Includes strict checks to ensure necessary variables are set, preventing runtime errors due to missing configuration.
Exports the initialized client for use in services.
Clarifies the importance of the SINCH_SMS_REGION environment variable for correct API endpoint targeting and advises checking Sinch SDK docs for the specific mechanism if needed.
Configuring regional endpoints:
If the SDK doesn't automatically route based on SINCH_SMS_REGION, configure the base URL explicitly:
How Do You Build the GraphQL API for SMS Reminders?
Define the GraphQL mutation to expose the scheduleReminder service function.
Define GraphQL Schema:
Open api/src/graphql/reminders.sdl.ts. Redwood generators created a basic structure. Modify it to define the ScheduleReminderInput and the scheduleReminder mutation.
GraphQL
# api/src/graphql/reminders.sdl.tsinput ScheduleReminderInput {patientName: String!doctorName: String!phoneNumber: String!# E.164 format preferred (e.g., +1xxxxxxxxxx)appointmentDate: String!# Format: YYYY-MM-DDappointmentTime: String!# Format: HH:MM (24-hour)timeZone: String!# IANA Time Zone Name (e.g., America/New_York)}type Reminder {id: Int!patientName: String!doctorName: String!phoneNumber: String!appointmentTime: DateTime!reminderTime: DateTime!status: String!sinchBatchId: String
createdAt: DateTime!updatedAt: DateTime!}typeMutation{"""Schedules a new SMS reminder via Sinch."""
scheduleReminder(input: ScheduleReminderInput!): Reminder # @requireAuth removed for initial dev"""Cancels a pending SMS reminder."""
cancelReminder(id: Int!): Reminder
}typeQuery{"""Retrieves all reminders for the authenticated user."""reminders:[Reminder!]!@requireAuth"""Retrieves a single reminder by ID."""
reminder(id: Int!): Reminder @requireAuth}
Defines the input structure expected by the mutation. Using ! marks fields as required.
Defines the Reminder type that mirrors our Prisma model and will be returned by the mutation on success.
Defines the scheduleReminder mutation, taking the input and returning a Reminder.
@requireAuth: Initially added by the generator. It has been removed here for easier initial testing. Important: Removing authentication is only for initial development convenience. You MUST re-enable or implement proper authentication before deploying to any non-local environment.
Testing the API Endpoint:
Once the development server is running (yarn rw dev), test the mutation using the Redwood GraphQL Playground (usually at http://localhost:8911/graphql) or a tool like curl or Postman.
GraphQL Playground Mutation:
GraphQL
mutation ScheduleNewReminder {
scheduleReminder(input:{patientName:"Alice Wonderland"doctorName:"Cheshire"phoneNumber:"+15551234567"# Use a real test number if possibleappointmentDate:"2025-08-22"# Ensure this date/time is far enough in the futureappointmentTime:"15:00"timeZone:"America/Los_Angeles"# Use a valid IANA timezone}){
id
patientName
phoneNumber
appointmentTime
reminderTime
status
sinchBatchId
}}
Replace placeholders with valid data. Ensure the appointmentDate and appointmentTime result in a reminderTime (2 hours prior) that is in the future from when you run the test.
Check the response in the GraphQL playground or terminal. You should see the details of the created Reminder record.
Check the API server logs (yarn rw dev output) for logs from the service function.
Check your Sinch Dashboard (Logs or specific API logs) to see if the message scheduling request was received.
How Do You Create the React Frontend for Scheduling Reminders?
Create a React page with a form to schedule reminders.
Generate Page:
Create a new page component for the reminder form.
Bash
yarn rw g page ReminderScheduler /schedule
This creates web/src/pages/ReminderSchedulerPage/ReminderSchedulerPage.tsx.
Build the Form:
Open web/src/pages/ReminderSchedulerPage/ReminderSchedulerPage.tsx and implement the form using Redwood Form components.
TypeScript
// web/src/pages/ReminderSchedulerPage/ReminderSchedulerPage.tsximport { MetaTags, useMutation } from'@redwoodjs/web'import { toast, Toaster } from'@redwoodjs/web/toast'import {
Form,
Label,
TextField,
DateField,
TimeField,
SelectField,
Submit,
FieldError,
FormError,
useForm // Import useForm for reset
} from'@redwoodjs/forms'import { useEffect, useState } from'react'// GraphQL Mutation Definition (should match api/src/graphql/reminders.sdl.ts)constSCHEDULE_REMINDER_MUTATION = gql`mutation ScheduleReminder($input: ScheduleReminderInput!){
scheduleReminder(input:$input) {
id # Request necessary fields back}}
`// Basic list of IANA time zones (add more as needed or use a library)const timeZones = [
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
'America/Anchorage', 'America/Honolulu', 'Europe/London', 'Europe/Paris',
'Asia/Tokyo', 'Australia/Sydney', 'UTC'// Add more relevant time zones here
];
constReminderSchedulerPage = () => {
const formMethods = useForm() // Get form methods for resetconst [createReminder, { loading, error }] = useMutation(
SCHEDULE_REMINDER_MUTATION,
{
onCompleted: () => {
toast.success('Reminder scheduled successfully!')
formMethods.reset() // Reset form after successful submission
},
onError: (error) => {
toast.error(`Error scheduling reminder: ${error.message}`)
console.error(error)
},
}
)
// Get user's local timezone guess (optional, provide a default)const [defaultTimeZone, setDefaultTimeZone] = useState('UTC');
useEffect(() => {
try {
setDefaultTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
} catch (e) {
console.warn("Could not detect browser timezone.")
}
}, [])
constonSubmit = (data) => {
console.log('Submitting data:', data)
// The service expects separate date/time strings, so direct submission is okaycreateReminder({ variables: { input: data } })
}
return (
<><MetaTagstitle="Schedule Reminder"description="Schedule an SMS reminder" /><ToastertoastOptions={{className: 'rw-toast', duration:6000 }} /><h1>Schedule New Reminder</h1><FormonSubmit={onSubmit}error={error}className="rw-form-wrapper"formMethods={formMethods}
>
{/* Display top-level form errors (like network issues) */}
<FormErrorerror={error}wrapperClassName="rw-form-error-wrapper"titleClassName="rw-form-error-title"listClassName="rw-form-error-list" /><Labelname="patientName"errorClassName="rw-label rw-label-error">Patient Name</Label><TextFieldname="patientName"validation={{required:true }}
errorClassName="rw-input rw-input-error"className="rw-input"aria-required="true"aria-label="Patient Name"
/><FieldErrorname="patientName"className="rw-field-error" /><Labelname="doctorName"errorClassName="rw-label rw-label-error">Doctor Name</Label><TextFieldname="doctorName"validation={{required:true }}
errorClassName="rw-input rw-input-error"className="rw-input"aria-required="true"aria-label="Doctor Name"
/><FieldErrorname="doctorName"className="rw-field-error" /><Labelname="phoneNumber"errorClassName="rw-label rw-label-error">Phone Number (E.164 Format: +1xxxxxxxxxx)</Label><TextFieldname="phoneNumber"placeholder="+15551234567"validation={{required:true,
pattern: {
value: /^\+[1-9]\d{1,14}$/, // BasicE.164regexmessage: 'PleaseenterinE.164format (e.g., +15551234567)',
},
}}
errorClassName="rw-input rw-input-error"className="rw-input"aria-required="true"aria-label="Phone Number"aria-describedby="phone-help"
/><spanid="phone-help"className="rw-help-text">Include country code (e.g., +1 for US)</span><FieldErrorname="phoneNumber"className="rw-field-error" /><Labelname="appointmentDate"errorClassName="rw-label rw-label-error">Appointment Date</Label><DateFieldname="appointmentDate"validation={{required:true }}
errorClassName="rw-input rw-input-error"className="rw-input"aria-required="true"aria-label="Appointment Date"
/><FieldErrorname="appointmentDate"className="rw-field-error" /><Labelname="appointmentTime"errorClassName="rw-label rw-label-error">Appointment Time (24-hour)</Label><TimeFieldname="appointmentTime"validation={{required:true }}
errorClassName="rw-input rw-input-error"className="rw-input"aria-required="true"aria-label="Appointment Time"
/><FieldErrorname="appointmentTime"className="rw-field-error" /><Labelname="timeZone"errorClassName="rw-label rw-label-error">Appointment Time Zone</Label><SelectFieldname="timeZone"validation={{required:true }}
errorClassName="rw-input rw-input-error"className="rw-input"defaultValue={defaultTimeZone} // Setdefaultbasedonbrowserguessaria-required="true"aria-label="Time Zone"
>
{timeZones.map(tz => (
<optionkey={tz}value={tz}>{tz}</option>
))}
</SelectField><FieldErrorname="timeZone"className="rw-field-error" /><divclassName="rw-button-group"><Submitdisabled={loading}className="rw-button rw-button-blue"aria-label={loading ? "Schedulingreminder..." : "ScheduleReminder"}
>
{loading ? (
<><spanclassName="spinner"aria-hidden="true"></span>
Scheduling...
</>
) : (
'Schedule Reminder'
)}
</Submit></div></Form></>
)
}
exportdefaultReminderSchedulerPage
Uses Redwood's useMutation hook to call the scheduleReminder GraphQL mutation.
Uses Redwood Form components (<Form>, <TextField>, <DateField>, <TimeField>, <SelectField>, <Submit>, <FieldError>, <FormError>) for structure, validation, and error handling.
Includes basic client-side validation (required, pattern). More complex validation happens in the service.
Uses Toaster for displaying success/error messages.
Includes a <SelectField> for selecting the appointment's time zone, crucial for correct calculation. It attempts to default to the user's browser time zone.
Form resets after successful submission using formMethods.reset()
Loading state indicator shows "Scheduling..." with spinner during submission
ARIA attributes for screen reader accessibility
Add Route:
Ensure the route is defined in web/src/Routes.tsx:
TypeScript
// web/src/Routes.tsximport { Router, Route, Set } from'@redwoodjs/router'importGeneralLayoutfrom'src/layouts/GeneralLayout/GeneralLayout'// Example layoutconstRoutes = () => {
return (
<Router><Setwrap={GeneralLayout}> // Use your desired layout
<Routepath="/schedule"page={ReminderSchedulerPage}name="scheduleReminder" />
{/* Add other routes here */}
<Routenotfoundpage={NotFoundPage} /></Set></Router>
)
}
exportdefaultRoutes
Run and Test:
Start the development server:
Bash
yarn rw dev
Navigate to http://localhost:8910/schedule (or your configured port). Fill out the form with valid data (ensure the appointment is far enough in the future) and submit. Check the browser console, API server logs, and Sinch dashboard for confirmation. You should receive the SMS 2 hours before the specified appointment time.
How Do You Handle Errors and Implement Retry Logic?
Error Handling:
Frontend: Uses Redwood Forms FieldError and FormError, useMutation's onError callback, and toast notifications.
Backend (Service): Uses try...catch blocks around critical operations (validation, date/time parsing, API calls, database writes). Includes specific error messages and logs errors using Redwood's logger. Highlights the critical failure case where the SMS is scheduled but the database write fails, suggesting robust compensation logic for production.
Logging:
Uses Redwood's built-in logger on the API side (src/lib/logger.ts). Logs key events like receiving requests, preparing data, successful API calls, database writes, and errors. Avoid logging sensitive data like SINCH_KEY_SECRET.
Implementing Retry Logic with async-retry:
Install the library:
Bash
yarn workspace api add async-retry
yarn workspace api add -D @types/async-retry
Update the service to include retry logic:
TypeScript
// api/src/services/reminders/reminders.tsimport retry from'async-retry'// Inside scheduleReminder function, replace the Sinch API call section:// 4. Schedule SMS with Sinch API (with retry logic)let sinchResponse;
try {
sinchResponse = awaitretry(
async (bail) => {
try {
returnawait sinchClient.sms.batches.send({
sendSMSRequestBody: {
to: [normalizedPhone],
from: process.env.SINCH_FROM_NUMBER,
body: messageBody,
send_at: sendAtIso,
},
})
} catch (error) {
// Don't retry 4xx client errors (bad request, auth failures)if (error.response?.status >= 400 && error.response?.status < 500) {
logger.error({ error, status: error.response.status }, 'Client error - not retrying')
bail(newError(`Client error: ${error.response?.data?.error?.message || error.message}`))
return
}
// Retry 5xx server errors and network failures
logger.warn({ error, attempt: error.attemptNumber }, 'Sinch API call failed, retrying...')
throw error
}
},
{
retries: 3, // Maximum 3 retry attemptsfactor: 2, // Exponential backoff factorminTimeout: 1000, // Start with 1 second delaymaxTimeout: 5000, // Max 5 seconds between retriesonRetry: (error, attempt) => {
logger.info({ attempt, maxRetries: 3 }, 'Retrying Sinch API call')
},
}
)
logger.info({ sinchResponse }, 'Successfully scheduled SMS via Sinch')
} catch (error) {
logger.error({ error }, 'Failed to schedule SMS after all retries')
thrownewError(`Failed to schedule SMS: ${error.message}`)
}
Error Code Handling with Recovery Strategies:
Error Type
HTTP Status
Recovery Strategy
Implementation
Invalid phone number
40001
Prompt user to fix
Return validation error immediately
Past timestamp
40003
Reject submission
Check reminderTime > DateTime.now() before API call
Beyond 3-day window
40004
Queue for later
Store in DB, schedule via cron job when within window
Auth failure
40101
Alert admin
Log fatal error, send notification to ops team
Rate limit
42901
Exponential backoff
Implement retry with longer delays (10s, 30s, 60s)
Track metrics: Monitor reminder scheduling success rate, average latency, and error rates
Set up alerts: Configure notifications for:
Failed reminders exceeding 5% of total
Database connection failures
Sinch API authentication errors
Reminders stuck in PENDING status for >24 hours
Implement dead letter queue: Store failed reminders in a separate table for manual review and retry
Frequently Asked Questions
What is the maximum scheduling window for Sinch SMS messages?
Sinch SMS API allows scheduling messages up to 3 days in the future using the send_at parameter. The expire_at parameter defaults to 3 days after send_at, which is also the maximum allowed value. If you need to schedule messages further in advance, implement your own queueing system or use a job scheduler like Redwood's job system to trigger the Sinch API call closer to the desired send time.
Implementing longer-term scheduling:
TypeScript
// api/src/services/reminders/reminders.tsexportconstscheduleReminder: MutationResolvers['scheduleReminder'] = async ({ input }) => {
// Calculate reminder timeconst reminderDateTime = appointmentDateTime.minus({ hours: 2 })
const now = DateTime.now()
const threeDaysFromNow = now.plus({ days: 3 })
if (reminderDateTime > threeDaysFromNow) {
// Store in DB without calling Sinch API yetconst reminder = await db.reminder.create({
data: {
...input,
status: 'QUEUED', // New status for future schedulingreminderTime: reminderDateTime.toJSDate(),
},
})
logger.info({ reminderId: reminder.id }, 'Reminder queued for future scheduling')
return reminder
} else {
// Schedule immediately via Sinch API// ... existing scheduling logic
}
}
// Create a cron job to process queued reminders// api/src/functions/scheduleQueuedReminders.tsexportconsthandler = async () => {
const threeDaysFromNow = DateTime.now().plus({ days: 3 })
const queuedReminders = await db.reminder.findMany({
where: {
status: 'QUEUED',
reminderTime: {
lte: threeDaysFromNow.toJSDate(),
},
},
})
for (const reminder of queuedReminders) {
// Schedule via Sinch API and update status to PENDING// ... scheduling logic
}
}
How do you format the send_at timestamp for Sinch API?
Use ISO-8601 format with timezone information: YYYY-MM-DDThh:mm:ss.SSSZ. For example, 2025-08-22T14:30:00.000Z represents August 22, 2025 at 2:30 PM UTC. Luxon's toISO() method automatically formats DateTime objects in this format. Always convert your local appointment times to UTC before passing to the Sinch API to ensure accurate scheduling across time zones.
Which Sinch regional endpoints are available for SMS?
Sinch provides five regional SMS API endpoints:
Region
Endpoint
Use Case
Typical Latency
US
us.sms.api.sinch.com
North American users
~50-100ms from US locations
EU
eu.sms.api.sinch.com
European users (Ireland, Sweden)
~30-80ms from Europe
AU
au.sms.api.sinch.com
Asia-Pacific users
~40-90ms from APAC
BR
br.sms.api.sinch.com
South American users
~60-120ms from South America
CA
ca.sms.api.sinch.com
Canadian users
~40-80ms from Canada
Region selection criteria:
Data residency requirements: EU companies often must use EU region for GDPR compliance
User base location: Choose closest region to majority of recipients
Latency requirements: Critical for time-sensitive reminders
Account provisioning: Check which regions your Sinch account supports
How do you handle time zones correctly in RedwoodJS SMS scheduling?
Use Luxon's DateTime object with explicit time zone information. Accept the user's time zone as input (using IANA time zone names like America/New_York), parse the appointment date/time in that zone using DateTime.fromISO(dateTimeString, { zone: timeZone }), then convert to UTC with .toUTC() before storing in the database and sending to Sinch. This ensures reminders send at the correct local time regardless of server location.
What database fields do you need for SMS reminder tracking?
Store these essential fields in your Prisma schema:
Field
Type
Purpose
Example
phoneNumber
String
E.164 format recipient
+12025550187
appointmentTime
DateTime
UTC appointment time
2025-08-22T14:00:00.000Z
reminderTime
DateTime
UTC send time
2025-08-22T12:00:00.000Z
status
String
Tracking state
PENDING, SENT, FAILED
sinchBatchId
String?
Sinch tracking ID
01HXYZ123ABC...
createdAt
DateTime
Record creation
2025-08-20T10:30:00.000Z
updatedAt
DateTime
Last modification
2025-08-22T12:00:15.000Z
Index status and reminderTime together for efficient queries when building admin dashboards or cleanup jobs:
Prisma
@@index([status, reminderTime])
How do you validate phone numbers for Sinch SMS in RedwoodJS?
Use E.164 format validation (/^\+[1-9]\d{1,14}$/ regex) for basic checking, but implement the libphonenumber-js library for production applications.
Supported input formats (all convert to +12025550187):
+1 202 555 0187 (international with spaces)
(202) 555-0187 (US local format)
202-555-0187 (US dashed format)
2025550187 (digits only, with country hint)
What happens if the SMS scheduling fails but the database save succeeds?
This is a critical consistency issue. The example code includes a try...catch around the database save after successful Sinch scheduling. If the database write fails, the SMS is already scheduled but you have no record. Production systems need compensation logic: save the reminder with status PENDINGbefore calling Sinch, then update to SCHEDULED after success. Alternatively, log the sinchBatchId to a failure tracking system and implement a reconciliation job that queries Sinch API to identify orphaned scheduled messages.
How do you implement retry logic for failed Sinch API calls in RedwoodJS?
Use the async-retry npm package in your service function. Wrap the sinchClient.sms.batches.send() call with retry logic that handles transient network errors (connection timeouts, 5xx responses) but not permanent failures (4xx authentication or validation errors). Implement exponential backoff (e.g., 1s, 2s, 4s delays) with a maximum of 3-5 retry attempts. Log each retry attempt using RedwoodJS logger and update the reminder status to FAILED if all retries exhaust.
See the complete implementation in the "How Do You Handle Errors and Implement Retry Logic?" section above.
Frequently asked questions
Use RedwoodJS's GraphQL API, integrated with the Sinch SMS API, to schedule and send SMS reminders. Create a Redwood service that interacts with both the Sinch API and your database to manage reminder scheduling and data storage. Build a frontend form in RedwoodJS to collect user input and trigger the reminder scheduling process via the GraphQL API.
The Sinch SMS API enables your RedwoodJS application to send SMS messages. It handles the complexities of SMS delivery, allowing you to focus on application logic. Specifically, the 'send_at' feature allows scheduling SMS messages for future delivery without managing background jobs within RedwoodJS itself.
RedwoodJS offers a full-stack, serverless-friendly framework that simplifies development by providing integrated frontend (React), backend (GraphQL API, Prisma), and database access. This structure makes it easier to handle user interactions, data management, and external API integrations like Sinch.
Luxon is highly beneficial when working with dates and times, especially when dealing with different time zones. It's used to parse, manipulate, and format dates/times accurately in your RedwoodJS application, avoiding common time zone issues. In this SMS reminder app, Luxon ensures accurate calculation of the reminder time based on the user's specified time zone and converts it to UTC for consistent storage and Sinch API interaction.
Yes, you can use SQLite for local development and simpler projects. RedwoodJS defaults to SQLite and handles the basic setup automatically if you use provider = "sqlite" in your schema.prisma file. For production environments, PostgreSQL is generally recommended for its scalability and robustness, requiring you to configure the DATABASE_URL environment variable appropriately.
RedwoodJS uses .env files for environment variables. Create a .env file in your project's root directory and add your sensitive information, such as API keys and database URLs. Ensure .env is added to .gitignore to prevent committing secrets to version control.
Prisma acts as an Object-Relational Mapper (ORM) for your database. It simplifies database interactions by allowing you to work with data using JavaScript objects and methods. In this application, Prisma facilitates storing and retrieving reminder details like patient name, appointment time, and phone number.
The user interacts with a React frontend, which communicates with a GraphQL API. The API interacts with a Redwood service that handles logic, using Prisma to manage data in a database (PostgreSQL/SQLite) and the Sinch API to schedule SMS messages. Sinch sends the SMS at the designated time.
You need Node.js (v20 or higher recommended), Yarn v1, a Sinch account (with API credentials and a provisioned number), access to a terminal, and basic understanding of JavaScript, React, GraphQL, and databases.
The Sinch SMS API expects times in UTC for the send_at parameter. Use a library like Luxon to convert user-provided times (along with their time zone) into UTC before passing to the Sinch API. Ensure your database also stores all DateTime fields in UTC for consistency.
Define a 'Reminder' model in your api/db/schema.prisma file specifying fields like patientName, phoneNumber, appointmentTime, reminderTime, and status. Use appropriate data types and consider an index on status and reminderTime for efficient querying.
The 'PENDING' status indicates that an SMS reminder has been scheduled but has not yet been sent by Sinch. Other status options could be SENT or FAILED for enhanced tracking and reporting.
E.164 is an international standard format for phone numbers (e.g., +15551234567) that ensures consistency and compatibility with global communication systems. Using E.164 simplifies validation and reduces ambiguity when sending SMS messages internationally.
Implement try...catch blocks around API calls and database operations to handle potential errors gracefully. Log errors using Redwood's logger and provide user feedback through toasts or other notification mechanisms. Consider retry mechanisms for API calls and compensation logic for database errors to enhance robustness.
Build a RedwoodJS application that schedules and sends SMS reminders using the Sinch SMS API. Create an appointment reminder system where users enter appointment details and the application automatically schedules an SMS reminder via Sinch at a specified time before the appointment.
This guide solves the common need for automated, time-sensitive notifications without requiring complex background job infrastructure. Instead, leverage Sinch's built-in
send_atscheduling capabilities to handle the job scheduling for you.Real-world applications:
Key Technologies:
send_atfeature for scheduling.System Architecture:
Data flow:
Prerequisites:
Obtaining Sinch credentials:
Sinch API Scheduling Requirements:
send_atYYYY-MM-DDThh:mm:ss.SSSZ(e.g.,2025-08-22T14:30:00.000Z)expire_atsend_at(maximum value)+[country_code][number](e.g.,+12025550187)Regional Endpoints:
us.sms.api.sinch.comeu.sms.api.sinch.comau.sms.api.sinch.combr.sms.api.sinch.comca.sms.api.sinch.comSinch API Error Codes:
4000140003send_attimestamp in the past40004send_atbeyond 3-day window4010150000Source: Sinch SMS Batches API Documentation
Final Outcome:
A RedwoodJS application with a simple UI to schedule appointment reminders. The backend validates input, stores appointment details, and uses the Sinch Node SDK to schedule an SMS to be sent 2 hours before the scheduled appointment time.
How Do You Set Up a RedwoodJS Project for SMS Scheduling?
Initialize a new RedwoodJS project and configure the necessary environment.
Create Redwood App: Open your terminal and navigate to the directory where you want to create your project. Run the following command, replacing
<your-app-name>with your desired project name (e.g.,redwood-sinch-reminders):--typescriptfor enhanced type safety, recommended for production applications.yes(recommended)Initial commit(or your preferred message)yesNavigate to Project Directory:
cd <your-app-name>Install Additional Dependencies: Install the Sinch SDK and
luxonfor robust date/time manipulation.yarn workspace api add @sinch/sdk-core luxon yarn workspace api add -D @types/luxon # Dev dependency for types@sinch/sdk-core: The official Sinch Node.js SDK.luxon: A powerful library for handling dates, times, and time zones.Why Luxon over native Date or other libraries?
Luxon provides:
America/New_York).toISO()method matches Sinch API requirements exactlyappointmentDateTime.minus({ hours: 2 })vs. complex Date arithmeticAlternative libraries like Moment.js are deprecated, and Day.js lacks robust time zone support without plugins.
Environment Variable Setup: Redwood uses
.envfiles for environment variables. The Sinch SDK requires credentials. Create a.envfile in the root of your project:touch .envAdd the following variables to your
.envfile, replacing the placeholder values with your actual Sinch credentials and configuration:SINCH_KEY_ID,SINCH_KEY_SECRET,SINCH_PROJECT_ID: Find these in your Sinch Dashboard under your project's API credentials/Access Keys section. TreatSINCH_KEY_SECRETlike a password – never commit it to Git.SINCH_FROM_NUMBER: A virtual number you've acquired through Sinch, enabled for SMS, and associated with your Project ID. Must be in E.164 format (e.g.,+12025550187).SINCH_SMS_REGION: The regional endpoint for the Sinch API. Available regions:us(United States),eu(European Union),au(Australia),br(Brazil),ca(Canada). Use the region closest to your user base or where your account is homed.DEFAULT_COUNTRY_CODE: Used as a fallback if the user enters a local number format. Best practice requires E.164 format input.Source: Sinch SMS API Reference – Regional Endpoints
Add
.envto.gitignore: Ensure your.envfile (containing secrets) is not committed to version control. Open your project's root.gitignorefile and add.envif it's not already present.Initial Commit (if not done during creation): If you didn't initialize Git during
create redwood-app:git init git add . git commit -m "Initial project setup with dependencies and env structure"How Do You Create the Database Schema for Reminder Scheduling?
Define a database table to store the details of scheduled reminders.
Define Prisma Schema: Open
api/db/schema.prismaand define a model forReminder:DateTimefields in UTC to avoid time zone issues.statustracks the state of the reminder (PENDINGmeans scheduled but not yet sent).sinchBatchIdcan be useful for tracking the message status within Sinch later.statusandreminderTimecould be useful for querying pending jobs or cleanup tasks.Status lifecycle and state transitions:
sinchBatchIdin Sinch dashboard for details)Enhanced schema for production:
DATABASE_URLin the.envfile points to your database (e.g.,postgresql://user:password@host:port/database). For local development, Redwood defaults to SQLite, which requires no extra setup if you stick with the defaultprovider = "sqlite".Troubleshooting database connection issues:
Can't reach database serverbrew services start postgresql(macOS)Authentication failedDATABASE_URLDatabase does not existcreatedb your_database_nameConnection timeoutRun Database Migration: Apply the schema changes to your database:
schema.prismachanges and applies it to your development database. Provide a name for the migration when prompted (e.g.,create reminder model).How Do You Implement the Sinch SMS Scheduling Service in RedwoodJS?
Create the RedwoodJS service that handles the logic for scheduling reminders.
Generate Service Files: Use the Redwood generator to create the necessary service and GraphQL files:
This creates:
api/src/services/reminders/reminders.ts(Service logic)api/src/services/reminders/reminders.scenarios.ts(Seed data for testing)api/src/services/reminders/reminders.test.ts(Unit tests)api/src/graphql/reminders.sdl.ts(GraphQL schema definition)Implement the
scheduleReminderService Function: Openapi/src/services/reminders/reminders.tsand add the logic to create a reminder record and schedule the SMS via Sinch.// api/src/services/reminders/reminders.ts import { validate } from '@redwoodjs/api' import type { MutationResolvers } from 'types/graphql' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' import { sinchClient } from 'src/lib/sinch' // We'll create this client lib next import { DateTime } from 'luxon' interface ScheduleReminderInput { patientName: string doctorName: string phoneNumber: string // Expecting E.164 format appointmentDate: string // e.g., '2025-07-15' appointmentTime: string // e.g., '14:30' timeZone: string // e.g., 'America/New_York' } // --- Helper Function for Phone Number Validation/Normalization --- const normalizePhoneNumber = (inputPhone: string): string => { // Basic check for E.164 format (starts with +, followed by digits) if (/^\+[1-9]\d{1,14}$/.test(inputPhone)) { return inputPhone } // Attempt to prefix with default country code if it looks like a local number // WARNING: This is a significant simplification. Real-world validation is complex. // Using a library like libphonenumber-js is highly recommended for production // as it handles varying international and local formats, plus provides // robust validation, which this basic example lacks. const digits = inputPhone.replace(/\D/g, '') if (digits.length >= 10) { // Basic check for common lengths return (process.env.DEFAULT_COUNTRY_CODE || '+1') + digits } throw new Error('Invalid phone number format. Use E.164 format (e.g., +1xxxxxxxxxx).') } // --- Main Service Function --- export const scheduleReminder: MutationResolvers['scheduleReminder'] = async ({ input, }: { input: ScheduleReminderInput }) => { logger.info({ input }, 'Received request to schedule reminder') // 1. Validate Input validate(input.patientName, 'Patient Name', { presence: true, length: { min: 1 } }) validate(input.doctorName, 'Doctor Name', { presence: true, length: { min: 1 } }) validate(input.phoneNumber, 'Phone Number', { presence: true }) validate(input.appointmentDate, 'Appointment Date', { presence: true }) validate(input.appointmentTime, 'Appointment Time', { presence: true }) validate(input.timeZone, 'Time Zone', { presence: true }) // Ensure timezone is provided let normalizedPhone: string; try { normalizedPhone = normalizePhoneNumber(input.phoneNumber) } catch (error) { logger.error({ error }, 'Phone number validation failed') throw new Error(error.message) } // 2. Calculate Appointment and Reminder Times using Luxon let appointmentDateTime: DateTime; let reminderDateTime: DateTime; try { const dateTimeString = `${input.appointmentDate}T${input.appointmentTime}` appointmentDateTime = DateTime.fromISO(dateTimeString, { zone: input.timeZone }) if (!appointmentDateTime.isValid) { throw new Error(`Invalid date/time format or timezone: ${appointmentDateTime.invalidReason}`) } // Calculate reminder time (e.g., 2 hours before appointment) reminderDateTime = appointmentDateTime.minus({ hours: 2 }) // Validation: Ensure reminder time is in the future if (reminderDateTime <= DateTime.now()) { throw new Error('Calculated reminder time is in the past. Appointment must be sufficiently in the future.') } } catch (error) { logger.error({ error, input }, 'Error processing date/time') throw new Error(`Failed to process date/time: ${error.message}`) } // Convert times to UTC for storage and Sinch API const appointmentTimeUtc = appointmentDateTime.toUTC() const reminderTimeUtc = reminderDateTime.toUTC() // Format for Sinch API (ISO 8601 with Z for UTC) const sendAtIso = reminderTimeUtc.toISO() // Luxon defaults to ISO 8601 // 3. Construct SMS Message Body const messageBody = `Hi ${input.patientName}, this is a reminder for your appointment with Dr. ${input.doctorName} on ${appointmentDateTime.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY)} at ${appointmentDateTime.toLocaleString(DateTime.TIME_SIMPLE)}. Reply STOP to unsubscribe.` // Added opt-out language logger.info({ normalizedPhone, messageBody, sendAtIso, appointmentTime: appointmentDateTime.toISO(), timeZone: input.timeZone }, 'Prepared reminder details') // 4. Schedule SMS with Sinch API let sinchResponse; try { sinchResponse = await sinchClient.sms.batches.send({ sendSMSRequestBody: { to: [normalizedPhone], from: process.env.SINCH_FROM_NUMBER, body: messageBody, send_at: sendAtIso, // Use the calculated UTC time // Optional: Add delivery_report: 'full' or 'summary' if needed }, }) logger.info({ sinchResponse }, 'Successfully scheduled SMS via Sinch') } catch (error) { logger.error({ error }, 'Failed to schedule SMS via Sinch API') // Consider creating the DB record with status 'FAILED' or throwing throw new Error(`Failed to schedule SMS: ${error.response?.data?.error?.message || error.message}`) } // 5. Store Reminder in Database try { const createdReminder = await db.reminder.create({ data: { patientName: input.patientName, doctorName: input.doctorName, phoneNumber: normalizedPhone, appointmentTime: appointmentTimeUtc.toJSDate(), // Convert Luxon DateTime to JS Date for Prisma reminderTime: reminderTimeUtc.toJSDate(), status: 'PENDING', // Indicates scheduled but not yet sent sinchBatchId: sinchResponse?.id, // Store the batch ID if available }, }) logger.info({ reminderId: createdReminder.id }, 'Reminder record created in database') return createdReminder } catch (dbError) { logger.error({ dbError, sinchBatchId: sinchResponse?.id }, 'Failed to save reminder to database after scheduling SMS') // Critical failure: SMS scheduled, but DB save failed. // Production systems need robust compensation logic: e.g., save reminder // with 'SCHEDULING_FAILED_DB' status *before* API call, update to // 'PENDING' after success, or have a cleanup job identify orphaned scheduled // messages via the Sinch Batch ID. Log details for manual intervention. // Consider attempting to cancel the Sinch message if the API supports it. throw new Error('Failed to save reminder details after scheduling. Manual check required.') } }validateand custom logic for the phone number and date/time. The phone number normalization explicitly notes its limitations and recommendslibphonenumber-jsfor production.luxonto parse the input date/time with the provided time zone, calculate the reminder time, and convert both to UTC for storage and the API call. Validates that the reminder time is in the future.sinchClient.sms.batches.sendmethod (you'll definesinchClientnext).send_atParameter: Passes the calculatedreminderTimeUtcin ISO-8601 format to Sinch'ssend_atparameter. Maximum scheduling window: 3 days (Sinch defaultexpire_at).'PENDING'and thesinchBatchId.try...catchblocks. The database error handling after a successful Sinch call includes warnings and suggestions for compensation logic.Transaction handling and idempotency:
The current implementation has a race condition: if the SMS schedules successfully but the database write fails, you have an orphaned scheduled message. Production systems should:
PENDING_SCHEDULEbefore calling Sinch, then update toPENDINGafter successCanceling scheduled messages:
Add this function to handle cancellations:
export const cancelReminder: MutationResolvers['cancelReminder'] = async ({ id }) => { const reminder = await db.reminder.findUnique({ where: { id } }) if (!reminder) throw new Error('Reminder not found') if (reminder.status !== 'PENDING') throw new Error('Can only cancel pending reminders') try { // Cancel via Sinch API await sinchClient.sms.batches.cancel(reminder.sinchBatchId) // Update database return await db.reminder.update({ where: { id }, data: { status: 'CANCELLED' } }) } catch (error) { logger.error({ error, reminderId: id }, 'Failed to cancel reminder') throw new Error(`Cancellation failed: ${error.message}`) } }Source: Sinch SMS Batches API – send_at Parameter
Create Sinch Client Library: Centralize the Sinch SDK client initialization.
Create a new file:
api/src/lib/sinch.ts// api/src/lib/sinch.ts import { SinchClient } from '@sinch/sdk-core' import { logger } from './logger' // Ensure required environment variables are present const requiredEnvVars: string[] = [ 'SINCH_PROJECT_ID', 'SINCH_KEY_ID', 'SINCH_KEY_SECRET', 'SINCH_SMS_REGION', // Crucial for API endpoint routing 'SINCH_FROM_NUMBER', // Needed for sending ] for (const envVar of requiredEnvVars) { if (!process.env[envVar]) { const errorMessage = `Configuration error: Missing required Sinch environment variable ${envVar}` logger.fatal(errorMessage) // Throwing here will prevent the app from starting without essential config throw new Error(errorMessage) } } // Initialize the Sinch Client // The SDK should ideally use the provided credentials and potentially region // information to route requests correctly. export const sinchClient = new SinchClient({ projectId: process.env.SINCH_PROJECT_ID, keyId: process.env.SINCH_KEY_ID, keySecret: process.env.SINCH_KEY_SECRET, // The @sinch/sdk-core might use the region in different ways depending on the specific API (SMS, Voice etc.) // Ensure SINCH_SMS_REGION is set correctly in your environment. // Consult the latest Sinch Node.js SDK documentation for the precise method // of ensuring the SMS API calls target the correct region if issues arise. // It might involve setting a base URL or a specific regional property. }) // Log confirmation that the client is initialized (without exposing secrets) logger.info('Sinch Client Initialized with Project ID and Key ID.') // Although the region variable is checked above, re-emphasize its importance. logger.info(`Sinch client configured to target region: ${process.env.SINCH_SMS_REGION}`)SinchClientusing the environment variables.SINCH_SMS_REGIONenvironment variable for correct API endpoint targeting and advises checking Sinch SDK docs for the specific mechanism if needed.Configuring regional endpoints:
If the SDK doesn't automatically route based on
SINCH_SMS_REGION, configure the base URL explicitly:const regionEndpoints = { us: 'https://us.sms.api.sinch.com', eu: 'https://eu.sms.api.sinch.com', au: 'https://au.sms.api.sinch.com', br: 'https://br.sms.api.sinch.com', ca: 'https://ca.sms.api.sinch.com', } export const sinchClient = new SinchClient({ projectId: process.env.SINCH_PROJECT_ID, keyId: process.env.SINCH_KEY_ID, keySecret: process.env.SINCH_KEY_SECRET, smsServicePlanId: process.env.SINCH_PROJECT_ID, smsRegion: process.env.SINCH_SMS_REGION, // If SDK supports custom base URL: // baseUrl: regionEndpoints[process.env.SINCH_SMS_REGION] })Check the @sinch/sdk-core documentation for the exact initialization parameters.
How Do You Build the GraphQL API for SMS Reminders?
Define the GraphQL mutation to expose the
scheduleReminderservice function.Define GraphQL Schema: Open
api/src/graphql/reminders.sdl.ts. Redwood generators created a basic structure. Modify it to define theScheduleReminderInputand thescheduleRemindermutation.# api/src/graphql/reminders.sdl.ts input ScheduleReminderInput { patientName: String! doctorName: String! phoneNumber: String! # E.164 format preferred (e.g., +1xxxxxxxxxx) appointmentDate: String! # Format: YYYY-MM-DD appointmentTime: String! # Format: HH:MM (24-hour) timeZone: String! # IANA Time Zone Name (e.g., America/New_York) } type Reminder { id: Int! patientName: String! doctorName: String! phoneNumber: String! appointmentTime: DateTime! reminderTime: DateTime! status: String! sinchBatchId: String createdAt: DateTime! updatedAt: DateTime! } type Mutation { """Schedules a new SMS reminder via Sinch.""" scheduleReminder(input: ScheduleReminderInput!): Reminder # @requireAuth removed for initial dev """Cancels a pending SMS reminder.""" cancelReminder(id: Int!): Reminder } type Query { """Retrieves all reminders for the authenticated user.""" reminders: [Reminder!]! @requireAuth """Retrieves a single reminder by ID.""" reminder(id: Int!): Reminder @requireAuth }!marks fields as required.Remindertype that mirrors our Prisma model and will be returned by the mutation on success.scheduleRemindermutation, taking the input and returning aReminder.@requireAuth: Initially added by the generator. It has been removed here for easier initial testing. Important: Removing authentication is only for initial development convenience. You MUST re-enable or implement proper authentication before deploying to any non-local environment.Testing the API Endpoint: Once the development server is running (
yarn rw dev), test the mutation using the Redwood GraphQL Playground (usually athttp://localhost:8911/graphql) or a tool likecurlor Postman.GraphQL Playground Mutation:
mutation ScheduleNewReminder { scheduleReminder( input: { patientName: "Alice Wonderland" doctorName: "Cheshire" phoneNumber: "+15551234567" # Use a real test number if possible appointmentDate: "2025-08-22" # Ensure this date/time is far enough in the future appointmentTime: "15:00" timeZone: "America/Los_Angeles" # Use a valid IANA timezone } ) { id patientName phoneNumber appointmentTime reminderTime status sinchBatchId } }Expected Response:
{ "data": { "scheduleReminder": { "id": 1, "patientName": "Alice Wonderland", "phoneNumber": "+15551234567", "appointmentTime": "2025-08-22T22:00:00.000Z", "reminderTime": "2025-08-22T20:00:00.000Z", "status": "PENDING", "sinchBatchId": "01HXYZ123ABC..." } } }Common Errors:
Invalid phone number format+and country codeCalculated reminder time is in the pastFailed to schedule SMS: 40101.envDatabase error: unique constraintDebugging Tips:
yarn rw devlogger.debug({ sinchResponse }, 'Full response')in serviceCurl Example:
curl 'http://localhost:8911/graphql' \ -H 'Content-Type: application/json' \ --data-binary '{"query":"mutation ScheduleNewReminder($input: ScheduleReminderInput!) {\n scheduleReminder(input: $input) {\n id\n patientName\n phoneNumber\n appointmentTime\n reminderTime\n status\n sinchBatchId\n }\n}","variables":{"input":{"patientName":"Bob The Builder","doctorName":"Wendy","phoneNumber":"+15559876543","appointmentDate":"2025-09-10","appointmentTime":"10:00","timeZone":"Europe/London"}}}' \ --compressedappointmentDateandappointmentTimeresult in areminderTime(2 hours prior) that is in the future from when you run the test.Reminderrecord.yarn rw devoutput) for logs from the service function.How Do You Create the React Frontend for Scheduling Reminders?
Create a React page with a form to schedule reminders.
Generate Page: Create a new page component for the reminder form.
This creates
web/src/pages/ReminderSchedulerPage/ReminderSchedulerPage.tsx.Build the Form: Open
web/src/pages/ReminderSchedulerPage/ReminderSchedulerPage.tsxand implement the form using Redwood Form components.// web/src/pages/ReminderSchedulerPage/ReminderSchedulerPage.tsx import { MetaTags, useMutation } from '@redwoodjs/web' import { toast, Toaster } from '@redwoodjs/web/toast' import { Form, Label, TextField, DateField, TimeField, SelectField, Submit, FieldError, FormError, useForm // Import useForm for reset } from '@redwoodjs/forms' import { useEffect, useState } from 'react' // GraphQL Mutation Definition (should match api/src/graphql/reminders.sdl.ts) const SCHEDULE_REMINDER_MUTATION = gql` mutation ScheduleReminder($input: ScheduleReminderInput!) { scheduleReminder(input: $input) { id # Request necessary fields back } } ` // Basic list of IANA time zones (add more as needed or use a library) const timeZones = [ 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Anchorage', 'America/Honolulu', 'Europe/London', 'Europe/Paris', 'Asia/Tokyo', 'Australia/Sydney', 'UTC' // Add more relevant time zones here ]; const ReminderSchedulerPage = () => { const formMethods = useForm() // Get form methods for reset const [createReminder, { loading, error }] = useMutation( SCHEDULE_REMINDER_MUTATION, { onCompleted: () => { toast.success('Reminder scheduled successfully!') formMethods.reset() // Reset form after successful submission }, onError: (error) => { toast.error(`Error scheduling reminder: ${error.message}`) console.error(error) }, } ) // Get user's local timezone guess (optional, provide a default) const [defaultTimeZone, setDefaultTimeZone] = useState('UTC'); useEffect(() => { try { setDefaultTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone); } catch (e) { console.warn("Could not detect browser timezone.") } }, []) const onSubmit = (data) => { console.log('Submitting data:', data) // The service expects separate date/time strings, so direct submission is okay createReminder({ variables: { input: data } }) } return ( <> <MetaTags title="Schedule Reminder" description="Schedule an SMS reminder" /> <Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} /> <h1>Schedule New Reminder</h1> <Form onSubmit={onSubmit} error={error} className="rw-form-wrapper" formMethods={formMethods} > {/* Display top-level form errors (like network issues) */} <FormError error={error} wrapperClassName="rw-form-error-wrapper" titleClassName="rw-form-error-title" listClassName="rw-form-error-list" /> <Label name="patientName" errorClassName="rw-label rw-label-error">Patient Name</Label> <TextField name="patientName" validation={{ required: true }} errorClassName="rw-input rw-input-error" className="rw-input" aria-required="true" aria-label="Patient Name" /> <FieldError name="patientName" className="rw-field-error" /> <Label name="doctorName" errorClassName="rw-label rw-label-error">Doctor Name</Label> <TextField name="doctorName" validation={{ required: true }} errorClassName="rw-input rw-input-error" className="rw-input" aria-required="true" aria-label="Doctor Name" /> <FieldError name="doctorName" className="rw-field-error" /> <Label name="phoneNumber" errorClassName="rw-label rw-label-error">Phone Number (E.164 Format: +1xxxxxxxxxx)</Label> <TextField name="phoneNumber" placeholder="+15551234567" validation={{ required: true, pattern: { value: /^\+[1-9]\d{1,14}$/, // Basic E.164 regex message: 'Please enter in E.164 format (e.g., +15551234567)', }, }} errorClassName="rw-input rw-input-error" className="rw-input" aria-required="true" aria-label="Phone Number" aria-describedby="phone-help" /> <span id="phone-help" className="rw-help-text">Include country code (e.g., +1 for US)</span> <FieldError name="phoneNumber" className="rw-field-error" /> <Label name="appointmentDate" errorClassName="rw-label rw-label-error">Appointment Date</Label> <DateField name="appointmentDate" validation={{ required: true }} errorClassName="rw-input rw-input-error" className="rw-input" aria-required="true" aria-label="Appointment Date" /> <FieldError name="appointmentDate" className="rw-field-error" /> <Label name="appointmentTime" errorClassName="rw-label rw-label-error">Appointment Time (24-hour)</Label> <TimeField name="appointmentTime" validation={{ required: true }} errorClassName="rw-input rw-input-error" className="rw-input" aria-required="true" aria-label="Appointment Time" /> <FieldError name="appointmentTime" className="rw-field-error" /> <Label name="timeZone" errorClassName="rw-label rw-label-error">Appointment Time Zone</Label> <SelectField name="timeZone" validation={{ required: true }} errorClassName="rw-input rw-input-error" className="rw-input" defaultValue={defaultTimeZone} // Set default based on browser guess aria-required="true" aria-label="Time Zone" > {timeZones.map(tz => ( <option key={tz} value={tz}>{tz}</option> ))} </SelectField> <FieldError name="timeZone" className="rw-field-error" /> <div className="rw-button-group"> <Submit disabled={loading} className="rw-button rw-button-blue" aria-label={loading ? "Scheduling reminder..." : "Schedule Reminder"} > {loading ? ( <> <span className="spinner" aria-hidden="true"></span> Scheduling... </> ) : ( 'Schedule Reminder' )} </Submit> </div> </Form> </> ) } export default ReminderSchedulerPageuseMutationhook to call thescheduleReminderGraphQL mutation.<Form>,<TextField>,<DateField>,<TimeField>,<SelectField>,<Submit>,<FieldError>,<FormError>) for structure, validation, and error handling.required,pattern). More complex validation happens in the service.Toasterfor displaying success/error messages.<SelectField>for selecting the appointment's time zone, crucial for correct calculation. It attempts to default to the user's browser time zone.formMethods.reset()Add Route: Ensure the route is defined in
web/src/Routes.tsx:// web/src/Routes.tsx import { Router, Route, Set } from '@redwoodjs/router' import GeneralLayout from 'src/layouts/GeneralLayout/GeneralLayout' // Example layout const Routes = () => { return ( <Router> <Set wrap={GeneralLayout}> // Use your desired layout <Route path="/schedule" page={ReminderSchedulerPage} name="scheduleReminder" /> {/* Add other routes here */} <Route notfound page={NotFoundPage} /> </Set> </Router> ) } export default RoutesRun and Test: Start the development server:
Navigate to
http://localhost:8910/schedule(or your configured port). Fill out the form with valid data (ensure the appointment is far enough in the future) and submit. Check the browser console, API server logs, and Sinch dashboard for confirmation. You should receive the SMS 2 hours before the specified appointment time.How Do You Handle Errors and Implement Retry Logic?
Error Handling:
FieldErrorandFormError,useMutation'sonErrorcallback, andtoastnotifications.try...catchblocks around critical operations (validation, date/time parsing, API calls, database writes). Includes specific error messages and logs errors using Redwood's logger. Highlights the critical failure case where the SMS is scheduled but the database write fails, suggesting robust compensation logic for production.Logging:
loggeron the API side (src/lib/logger.ts). Logs key events like receiving requests, preparing data, successful API calls, database writes, and errors. Avoid logging sensitive data likeSINCH_KEY_SECRET.Implementing Retry Logic with async-retry:
Install the library:
Update the service to include retry logic:
// api/src/services/reminders/reminders.ts import retry from 'async-retry' // Inside scheduleReminder function, replace the Sinch API call section: // 4. Schedule SMS with Sinch API (with retry logic) let sinchResponse; try { sinchResponse = await retry( async (bail) => { try { return await sinchClient.sms.batches.send({ sendSMSRequestBody: { to: [normalizedPhone], from: process.env.SINCH_FROM_NUMBER, body: messageBody, send_at: sendAtIso, }, }) } catch (error) { // Don't retry 4xx client errors (bad request, auth failures) if (error.response?.status >= 400 && error.response?.status < 500) { logger.error({ error, status: error.response.status }, 'Client error - not retrying') bail(new Error(`Client error: ${error.response?.data?.error?.message || error.message}`)) return } // Retry 5xx server errors and network failures logger.warn({ error, attempt: error.attemptNumber }, 'Sinch API call failed, retrying...') throw error } }, { retries: 3, // Maximum 3 retry attempts factor: 2, // Exponential backoff factor minTimeout: 1000, // Start with 1 second delay maxTimeout: 5000, // Max 5 seconds between retries onRetry: (error, attempt) => { logger.info({ attempt, maxRetries: 3 }, 'Retrying Sinch API call') }, } ) logger.info({ sinchResponse }, 'Successfully scheduled SMS via Sinch') } catch (error) { logger.error({ error }, 'Failed to schedule SMS after all retries') throw new Error(`Failed to schedule SMS: ${error.message}`) }Error Code Handling with Recovery Strategies:
reminderTime > DateTime.now()before API callMonitoring and Alerting for Production:
// api/src/functions/health.ts export const handler = async () => { try { await db.$queryRaw`SELECT 1` return { statusCode: 200, body: JSON.stringify({ status: 'healthy' }) } } catch (error) { return { statusCode: 503, body: JSON.stringify({ status: 'unhealthy', error: error.message }) } } }Track metrics: Monitor reminder scheduling success rate, average latency, and error rates
Set up alerts: Configure notifications for:
PENDINGstatus for >24 hoursImplement dead letter queue: Store failed reminders in a separate table for manual review and retry
Frequently Asked Questions
What is the maximum scheduling window for Sinch SMS messages?
Sinch SMS API allows scheduling messages up to 3 days in the future using the
send_atparameter. Theexpire_atparameter defaults to 3 days aftersend_at, which is also the maximum allowed value. If you need to schedule messages further in advance, implement your own queueing system or use a job scheduler like Redwood's job system to trigger the Sinch API call closer to the desired send time.Implementing longer-term scheduling:
// api/src/services/reminders/reminders.ts export const scheduleReminder: MutationResolvers['scheduleReminder'] = async ({ input }) => { // Calculate reminder time const reminderDateTime = appointmentDateTime.minus({ hours: 2 }) const now = DateTime.now() const threeDaysFromNow = now.plus({ days: 3 }) if (reminderDateTime > threeDaysFromNow) { // Store in DB without calling Sinch API yet const reminder = await db.reminder.create({ data: { ...input, status: 'QUEUED', // New status for future scheduling reminderTime: reminderDateTime.toJSDate(), }, }) logger.info({ reminderId: reminder.id }, 'Reminder queued for future scheduling') return reminder } else { // Schedule immediately via Sinch API // ... existing scheduling logic } } // Create a cron job to process queued reminders // api/src/functions/scheduleQueuedReminders.ts export const handler = async () => { const threeDaysFromNow = DateTime.now().plus({ days: 3 }) const queuedReminders = await db.reminder.findMany({ where: { status: 'QUEUED', reminderTime: { lte: threeDaysFromNow.toJSDate(), }, }, }) for (const reminder of queuedReminders) { // Schedule via Sinch API and update status to PENDING // ... scheduling logic } }How do you format the send_at timestamp for Sinch API?
Use ISO-8601 format with timezone information:
YYYY-MM-DDThh:mm:ss.SSSZ. For example,2025-08-22T14:30:00.000Zrepresents August 22, 2025 at 2:30 PM UTC. Luxon'stoISO()method automatically formats DateTime objects in this format. Always convert your local appointment times to UTC before passing to the Sinch API to ensure accurate scheduling across time zones.Which Sinch regional endpoints are available for SMS?
Sinch provides five regional SMS API endpoints:
us.sms.api.sinch.comeu.sms.api.sinch.comau.sms.api.sinch.combr.sms.api.sinch.comca.sms.api.sinch.comRegion selection criteria:
How do you handle time zones correctly in RedwoodJS SMS scheduling?
Use Luxon's
DateTimeobject with explicit time zone information. Accept the user's time zone as input (using IANA time zone names likeAmerica/New_York), parse the appointment date/time in that zone usingDateTime.fromISO(dateTimeString, { zone: timeZone }), then convert to UTC with.toUTC()before storing in the database and sending to Sinch. This ensures reminders send at the correct local time regardless of server location.What database fields do you need for SMS reminder tracking?
Store these essential fields in your Prisma schema:
phoneNumber+12025550187appointmentTime2025-08-22T14:00:00.000ZreminderTime2025-08-22T12:00:00.000ZstatusPENDING,SENT,FAILEDsinchBatchId01HXYZ123ABC...createdAt2025-08-20T10:30:00.000ZupdatedAt2025-08-22T12:00:15.000ZIndex
statusandreminderTimetogether for efficient queries when building admin dashboards or cleanup jobs:How do you validate phone numbers for Sinch SMS in RedwoodJS?
Use E.164 format validation (
/^\+[1-9]\d{1,14}$/regex) for basic checking, but implement thelibphonenumber-jslibrary for production applications.Installing and using libphonenumber-js:
// api/src/services/reminders/reminders.ts import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js' const normalizePhoneNumber = (inputPhone: string, defaultCountry = 'US'): string => { try { // Validate format if (!isValidPhoneNumber(inputPhone, defaultCountry)) { throw new Error(`Invalid phone number: ${inputPhone}`) } // Parse and format to E.164 const phoneNumber = parsePhoneNumber(inputPhone, defaultCountry) return phoneNumber.format('E.164') // Returns +12025550187 } catch (error) { logger.error({ error, inputPhone }, 'Phone validation failed') throw new Error('Enter a valid phone number with country code (e.g., +1 555 123 4567)') } }Supported input formats (all convert to
+12025550187):+1 202 555 0187(international with spaces)(202) 555-0187(US local format)202-555-0187(US dashed format)2025550187(digits only, with country hint)What happens if the SMS scheduling fails but the database save succeeds?
This is a critical consistency issue. The example code includes a
try...catcharound the database save after successful Sinch scheduling. If the database write fails, the SMS is already scheduled but you have no record. Production systems need compensation logic: save the reminder with statusPENDINGbefore calling Sinch, then update toSCHEDULEDafter success. Alternatively, log thesinchBatchIdto a failure tracking system and implement a reconciliation job that queries Sinch API to identify orphaned scheduled messages.How do you implement retry logic for failed Sinch API calls in RedwoodJS?
Use the
async-retrynpm package in your service function. Wrap thesinchClient.sms.batches.send()call with retry logic that handles transient network errors (connection timeouts, 5xx responses) but not permanent failures (4xx authentication or validation errors). Implement exponential backoff (e.g., 1s, 2s, 4s delays) with a maximum of 3-5 retry attempts. Log each retry attempt using RedwoodJS logger and update the reminder status toFAILEDif all retries exhaust.See the complete implementation in the "How Do You Handle Errors and Implement Retry Logic?" section above.