Manage your friends with Motia and Twilio

Manage your friends with Motia and Twilio | Learn how to build a wake surf booking system with SMS notifications, automated scheduling, waitlist management, and calendar invites using Motia and Twilio.

By Motia Team 14 min read
Manage your friends with Motia and Twilio

Learn how to build a wake surf booking system with SMS notifications, automated scheduling, waitlist management, and calendar invites using Motia and Twilio

0:00
/1:20

Every Tuesday at 7am, I go wake surfing with friends. The problem? I had to text everyone manually: "You coming Tuesday?" Then wait for replies. Half wouldn't respond. Someone would cancel Sunday night. I'd be texting backups Monday trying to fill the spot.

I needed something automated:

  • Text everyone on Monday asking who's in
  • Take the first 3 YES replies
  • Put extras on a waitlist
  • Send reminders Tuesday morning so nobody oversleeps
  • Let me manage everything without editing code

So I built it with Twilio for SMS and Motia for the backend workflows. Motia handles the event-driven logic—when someone books, it triggers SMS confirmations, calendar generation, and waitlist checks automatically.

Now every Monday at 3pm, the system texts everyone automatically. When they reply, it books them or adds them to the waitlist. Tuesday morning at 5:30am, confirmed people get a reminder with the roster and location. I don't touch anything.

There's also a web calendar where people can book directly, see who's coming, and cancel their spot (up to 12 hours before). If someone cancels, the first person on the waitlist automatically gets promoted and receives a text.

This tutorial shows you how to build the same thing: automated SMS invites with Twilio, event-driven booking logic that handles confirmations and waitlist promotion, cron jobs for scheduled invites and reminders, an admin panel to manage everything, a public calendar interface, and state management for sessions/friends/bookings.

What You'll Build

A booking system for weekly wake surf sessions that handles everything automatically:

For Users:

  • Calendar interface showing available sessions, capacity, and current roster
  • Direct booking via web (no SMS required)
  • SMS confirmations with calendar invite (ICS file) and cancellation link
  • Automatic waitlist if session is full
  • Cancel bookings up to 12 hours before session starts

For Admins:

  • Workbench panel to manage sessions, friends, and bookings
  • Bulk import friends from CSV
  • Manual invite triggers
  • Session creation with capacity limits
  • Real-time booking status and roster view

Automated Workflows:

  • Monday 3pm: SMS invite blast to all active friends for next Tuesday
  • Tuesday 5:30am: Morning reminders to confirmed bookings with roster and location
  • Friday 12pm: Creates next Tuesday's session automatically
  • On booking: Sends SMS confirmation with calendar invite
  • On cancellation: Promotes first waitlisted person and sends notification

Prerequisites

Before starting, you'll need:

Required:

  • Node.js 18 or higher
  • Twilio account (sign up for free trial)
    • Account SID
    • Auth Token
    • Phone number (Twilio provides one for free)
  • OpenSSL or similar tool to generate JWT secret

Twilio Setup:

  1. Create account at twilio.com/try-twilio
  2. Go to Console → Account Info
  3. Copy your Account SID and Auth Token
  4. Go to Phone Numbers → Get a number (free with trial)
  5. Copy your Twilio phone number

Generate JWT Secret:

openssl rand -base64 32

Save these values, you'll need them in the next step.


Getting Started

Clone and Install

# Clone the repository
git clone https://github.com/MotiaDev/motia-examples
cd motia-examples/examples/advanced-use-cases/wake-surf-club

# Install dependencies
npm install

Configure Environment Variables

Create a .env file in the project root:

# Twilio SMS Configuration
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_here
TWILIO_FROM_NUMBER=+15551234567

# App Configuration
PUBLIC_APP_URL=http://localhost:3000
HOST_SIGNING_SECRET=your_generated_secret_here

Replace the placeholder values:

  • TWILIO_ACCOUNT_SID: From Twilio Console → Account Info
  • TWILIO_AUTH_TOKEN: From Twilio Console → Account Info
  • TWILIO_FROM_NUMBER: Your Twilio phone number (include +1)
  • HOST_SIGNING_SECRET: Output from openssl rand -base64 32

Start the Development Server

npm run dev

This starts:

  • Motia backend at http://localhost:3000
  • Workbench UI at http://localhost:3000
  • API endpoints at http://localhost:3000/api/*

Open http://localhost:3000 in your browser. You'll see the Motia Workbench with a "Surf Club Admin" tab in the navigation.

Start the Frontend (Optional)

The frontend is a separate React app for the public calendar interface:

cd frontend
npm install
npm run dev

This starts the calendar UI at http://localhost:5173. It proxies API requests to the backend on port 3000.

Note: The backend works without the frontend. You can manage everything through the admin panel and API endpoints.

Your development environment is ready. Next, we'll explore how the system works.

Project Structure

The codebase is organized by workflow type:

wake-surf-club/
├── steps/                    # Motia workflow steps
│   ├── api/                 # HTTP endpoints
│   │   ├── public/         # User-facing (booking, calendar)
│   │   └── admin/          # Admin-only (session creation, friends)
│   ├── events/             # Event-driven handlers
│   │   ├── confirm-booking.step.ts
│   │   ├── generate-calendar.step.ts
│   │   ├── handle-cancellation.step.ts
│   │   └── send-sms.step.ts
│   └── cron/               # Scheduled jobs
│       ├── seed-next-session.step.ts
│       ├── send-invite-blast.step.ts
│       └── send-morning-reminder.step.ts
├── src/types/              # Shared data models
│   ├── models.ts           # Zod schemas (Friend, Session, Booking)
│   └── utils.ts            # Helper functions (JWT, phone formatting)
├── plugins/                # Custom Workbench extensions
│   └── surf-admin/         # Admin panel UI
└── frontend/               # React calendar app
    ├── src/components/
    └── src/utils/

Step Types:

  • api - HTTP endpoints that respond to requests
  • event - Background handlers triggered by events
  • cron - Scheduled tasks that run at specific times

Key Directories:

  • steps/api/public/ - Endpoints anyone can call (booking, sessions list)
  • steps/api/admin/ - Endpoints for managing the system
  • steps/events/ - Handlers for booking confirmations, SMS, cancellations
  • steps/cron/ - Automated weekly tasks
  • src/types/ - Data models and utilities shared across steps
  • plugins/surf-admin/ - Custom admin UI that appears in Workbench
  • frontend/ - Optional React calendar interface

Data Models

The system uses three core models with Zod validation:

Friend

{
  id: string              // UUID
  name: string            // "Alice Smith"
  phoneE164: string       // "+15551234567"
  active: boolean         // Receives invites if true
  createdAt: string       // ISO timestamp
}

Friends are people who can book sessions. The active flag controls who receives Monday invite blasts.

Session

{
  id: string              // UUID
  date: string            // "2025-12-24" (YYYY-MM-DD)
  startTime: string       // "07:00" (HH:MM)
  endTime: string         // "09:00" (HH:MM)
  capacity: number        // 3 (max participants)
  status: string          // "draft" | "published" | "closed"
  location: string | null // "Main Lake Dock"
  createdAt: string       // ISO timestamp
}

Sessions represent weekly surf sessions. Only published sessions accept bookings.

Booking

{
  id: string              // UUID
  sessionId: string       // References Session.id
  friendId: string        // References Friend.id
  phoneE164: string       // Denormalized for fast lookup
  status: string          // "confirmed" | "waitlisted" | "canceled"
  createdAt: string       // ISO timestamp
  canceledAt?: string     // ISO timestamp (if canceled)
}

Bookings link friends to sessions. Status determines if they're confirmed, waitlisted, or canceled.

State Storage Pattern:

Each model is stored three ways for fast lookups:

// Sessions
state.set("sessions", date, session)        // By date: "2025-12-24"
state.set("sessions", sessionId, session)   // By ID
state.set("sessions", "list", [...])        // All sessions

// Friends
state.set("friends", phoneE164, friend)     // By phone: "+15551234567"
state.set("friends", friendId, friend)      // By ID
state.set("friends", "list", [...])         // All friends

// Bookings
state.set("bookings", `${sessionId}:${phoneE164}`, booking)
state.set("bookings", bookingId, booking)
state.set("bookings", "list", [...])

This triple-indexing avoids slow queries. Any lookup is instant regardless of database size.


How the Booking Flow Works

When someone books a session, multiple steps execute automatically through events:

1. API Entry Point

File: steps/api/public/book-session.step.ts

export const config: ApiRouteConfig = {
  type: "api",
  name: "BookSession",
  path: "/api/book/direct",
  method: "POST",
  emits: ["booking.created"],
};

The handler validates the session exists and has capacity:

const session = await state.get("sessions", sessionId);

if (!session || session.status !== "published") {
  return { status: 400, body: { error: "Session not available" } };
}

// Get current bookings
const allBookings = await state.get("bookings", "list") || [];
const sessionBookings = allBookings.filter(b => b.sessionId === sessionId);
const confirmedCount = sessionBookings.filter(b => b.status === "confirmed").length;

if (confirmedCount >= session.capacity) {
  return { status: 200, body: { status: "full" } };
}

If capacity allows, it creates the booking:

const booking = {
  id: crypto.randomUUID(),
  sessionId,
  friendId: friend.id,
  phoneE164,
  status: "confirmed",
  createdAt: new Date().toISOString(),
};

// Store with triple indexing
await state.set("bookings", `${sessionId}:${phoneE164}`, booking);
await state.set("bookings", booking.id, booking);

const allBookings = await state.get("bookings", "list") || [];
await state.set("bookings", "list", [...allBookings, booking]);

Then emits an event to trigger confirmation:

await emit({
  topic: "booking.created",
  data: {
    bookingId: booking.id,
    sessionId,
    friendName: friend.name,
    phoneE164,
    status: "confirmed",
  },
});

The API returns immediately. Background processing happens via events.

2. Booking Confirmation

File: steps/events/confirm-booking.step.ts

This step listens for booking.created events:

export const config: EventConfig = {
  type: "event",
  name: "ConfirmBooking",
  subscribes: ["booking.created"],
  emits: ["sms.send", "calendar.generate"],
};

It creates a signed cancellation link:

const secret = process.env.HOST_SIGNING_SECRET;
const cancelToken = createSignedToken(sessionId, phoneE164, secret);
const cancelUrl = `${appUrl}/cancel?token=${cancelToken}`;

Why JWT? Prevents users from guessing URLs to cancel other people's bookings. The token encodes sessionId and phoneE164 and expires after 24 hours.

Then emits two events:

// Send SMS confirmation
await emit({
  topic: "sms.send",
  data: {
    to: phoneE164,
    body: `You're in for Tue ${session.startTime}–${session.endTime}. Cancel: ${cancelUrl}`,
    type: "confirmation",
    dedupeKey: `confirmation_${bookingId}`,
  },
});

// Generate calendar invite
await emit({
  topic: "calendar.generate",
  data: {
    sessionId,
    friendId,
    sessionDate: session.date,
    startTime: session.startTime,
    endTime: session.endTime,
  },
});

3. SMS Sending

File: steps/events/send-sms.step.ts

This step handles all SMS sending with retry logic:

export const config: EventConfig = {
  type: "event",
  name: "SendSms",
  subscribes: ["sms.send"],
};

Deduplication Check:

const dedupeKey = input.dedupeKey;
if (dedupeKey) {
  const existing = await state.get(traceId, `sms_sent_${dedupeKey}`);
  if (existing) {
    logger.info("SMS already sent, skipping duplicate");
    return;
  }
}

This prevents duplicate SMSs if the event replays or the step retries.

Retry Logic with Exponential Backoff:

const client = twilio(accountSid, authToken);

let attempts = 0;
const maxAttempts = 3;

while (attempts < maxAttempts) {
  try {
    const message = await client.messages.create({
      body: input.body,
      from: fromNumber,
      to: input.to,
    });
    
    // Mark as sent
    await state.set(traceId, `sms_sent_${dedupeKey}`, {
      messageSid: message.sid,
      sentAt: new Date().toISOString(),
    });
    
    return; // Success
  } catch (error) {
    attempts++;
    if (attempts >= maxAttempts) throw error;
    
    const backoffMs = Math.pow(2, attempts - 1) * 1000; // 1s, 2s, 4s
    await new Promise(resolve => setTimeout(resolve, backoffMs));
  }
}

If Twilio's API fails, it waits 1 second and retries. Second failure waits 2 seconds. Third failure waits 4 seconds. After 3 attempts, it logs the failure for manual review.

4. Calendar Generation

File: steps/events/generate-calendar.step.ts

Creates an ICS calendar file:

import { createEvent } from "ics";

const icsEvent = {
  start: [year, month, day, hour, minute],
  end: [year, month, day, hour, minute],
  title: "Tuesday WakeSurf Club",
  description: `WakeSurf session with the Tuesday Surf Club!\n\nLocation: ${location}`,
  location: location || "TBD",
  status: "CONFIRMED",
};

const { value } = createEvent(icsEvent);

// Store for download
await state.set(traceId, `ics_${sessionId}_${friendId}`, {
  content: value,
  sessionId,
  friendId,
});

Users can download the ICS file from /api/calendar/download/:sessionId/:friendId and add it to their calendar app.


Cancellation and Waitlist Promotion

When someone cancels, the system automatically promotes the next person on the waitlist.

1. Cancellation Request

File: steps/api/public/cancel-booking-direct.step.ts

Validates the 12-hour deadline:

const session = await state.get("sessions", booking.sessionId);
const sessionDateTime = new Date(`${session.date}T${session.startTime}:00`);
const now = new Date();
const hoursUntilSession = (sessionDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);

if (hoursUntilSession < 12) {
  return {
    status: 400,
    body: { error: "Cancellation deadline passed (12 hours before session)" },
  };
}

Updates the booking status:

const updatedBooking = {
  ...booking,
  status: "canceled",
  canceledAt: new Date().toISOString(),
};

// Update all indexes
await state.set("bookings", `${sessionId}:${phoneE164}`, updatedBooking);
await state.set("bookings", bookingId, updatedBooking);

const allBookings = await state.get("bookings", "list") || [];
const updatedList = allBookings.map(b => b.id === bookingId ? updatedBooking : b);
await state.set("bookings", "list", updatedList);

Then emits a cancellation event:

await emit({
  topic: "booking.canceled",
  data: {
    bookingId,
    sessionId,
    friendId: booking.friendId,
    phoneE164,
    canceledAt: updatedBooking.canceledAt,
  },
});

2. Waitlist Promotion

File: steps/events/handle-cancellation.step.ts

Checks if anyone is waitlisted:

const allBookings = await state.get("bookings", "list") || [];
const sessionBookings = allBookings.filter(b => b.sessionId === sessionId);

const confirmedBookings = sessionBookings.filter(b => b.status === "confirmed");
const waitlistedBookings = sessionBookings.filter(b => b.status === "waitlisted");

if (confirmedBookings.length < session.capacity && waitlistedBookings.length > 0) {
  const nextInLine = waitlistedBookings[0];
  
  // Promote to confirmed
  const updatedBooking = {
    ...nextInLine,
    status: "confirmed",
    confirmedAt: new Date().toISOString(),
  };
  
  // Update all indexes
  await state.set("bookings", nextInLine.id, updatedBooking);
  await state.set("bookings", `${sessionId}:${nextInLine.phoneE164}`, updatedBooking);
  
  const allBookings = await state.get("bookings", "list") || [];
  const updatedList = allBookings.map(b => b.id === nextInLine.id ? updatedBooking : b);
  await state.set("bookings", "list", updatedList);
}

Sends promotion notification:

const friend = allFriends.find(f => f.id === nextInLine.friendId);

await emit({
  topic: "sms.send",
  data: {
    to: friend.phoneE164,
    body: `Great news! You're now confirmed for Tuesday Surf Club (${session.startTime}–${session.endTime}).`,
    type: "promotion",
    dedupeKey: `promotion_${sessionId}_${friend.id}`,
  },
});

Also generates a new calendar invite for the promoted person.


Automated Cron Jobs

Three cron jobs run automatically each week.

1. Create Next Tuesday's Session

File: steps/cron/seed-next-session.step.ts

Runs every Friday at 12:00 PM:

export const config: CronConfig = {
  type: "cron",
  name: "SeedNextSession",
  cron: "0 12 * * FRI",
};

Calculates next Tuesday's date:

function getNextTuesday(): string {
  const now = new Date();
  const nextTue = nextTuesday(now);
  return format(nextTue, "yyyy-MM-dd");
}

Checks if session already exists:

const nextTuesdayDate = getNextTuesday();
const existingSession = await state.get("sessions", nextTuesdayDate);

if (existingSession) {
  logger.info("Session already exists for next Tuesday");
  return;
}

Creates the session:

const newSession = {
  id: crypto.randomUUID(),
  date: nextTuesdayDate,
  startTime: "07:00",
  endTime: "09:00",
  capacity: 3,
  status: "published",
  location: null,
  createdAt: new Date().toISOString(),
};

// Store with triple indexing
await state.set("sessions", nextTuesdayDate, newSession);
await state.set("sessions", newSession.id, newSession);

const allSessions = await state.get("sessions", "list") || [];
await state.set("sessions", "list", [...allSessions, newSession]);

2. Send Monday Invite Blast

File: steps/cron/send-invite-blast.step.ts

Runs every Monday at 3:00 PM:

export const config: CronConfig = {
  type: "cron",
  name: "SendInviteBlast",
  cron: "0 15 * * MON",
  emits: ["sms.send"],
};

Gets next Tuesday's session:

const dateStr = calculateNextTuesdayDate();
const session = await state.get("sessions", dateStr);

if (!session || session.status !== "published") {
  logger.warn("No published session for next Tuesday");
  return;
}

Gets all active friends:

const allFriends = await state.get("friends", "list") || [];
const activeFriends = allFriends.filter(f => f.active === true);

Sends invite to each friend:

for (const friend of activeFriends) {
  const bookingToken = createSignedToken(session.id, friend.phoneE164, secret);
  const bookingUrl = `${appUrl}/book?token=${bookingToken}`;
  
  await emit({
    topic: "sms.send",
    data: {
      to: friend.phoneE164,
      body: `Tuesday Surf Club is on! 7–9am. Book your spot: ${bookingUrl}`,
      type: "invite",
      dedupeKey: `invite_${session.id}_${friend.id}`,
    },
  });
}

3. Send Tuesday Morning Reminders

File: steps/cron/send-morning-reminder.step.ts

Runs every Tuesday at 5:30 AM:

export const config: CronConfig = {
  type: "cron",
  name: "SendMorningReminder",
  cron: "30 5 * * TUE",
  emits: ["sms.send"],
};

Gets today's session and confirmed bookings:

const today = new Date();
const todayStr = today.toISOString().split("T")[0];
const session = await state.get("sessions", todayStr);

const allBookings = await state.get("bookings", "list") || [];
const confirmedBookings = allBookings.filter(
  b => b.sessionId === session.id && b.status === "confirmed"
);

Builds roster list:

const allFriends = await state.get("friends", "list") || [];
const rosterNames = confirmedBookings.map(booking => {
  const friend = allFriends.find(f => f.id === booking.friendId);
  return friend?.name || "Friend";
});

const rosterText = rosterNames.join(", ");

Sends reminder to each confirmed booking:

for (const booking of confirmedBookings) {
  const friend = allFriends.find(f => f.id === booking.friendId);
  
  await emit({
    topic: "sms.send",
    data: {
      to: friend.phoneE164,
      body: `See you at ${session.startTime}! Roster: ${rosterText}. Meet at ${session.location || "TBD"}.`,
      type: "reminder",
      dedupeKey: `reminder_${session.id}_${friend.id}`,
    },
  });
}

Admin Panel

The custom Workbench plugin provides a web interface for managing the system.

0:00
/0:12

File: plugins/surf-admin/index.ts

Registers the plugin:

export default function surfAdminPlugin(motia: MotiaPluginContext): MotiaPlugin {
  return {
    dirname: path.join(__dirname),
    workbench: [{
      componentName: "SurfAdminPanel",
      packageName: "~/plugins/surf-admin/components/surf-admin-panel",
      label: "Surf Club Admin",
      labelIcon: "waves",
      position: "top",
    }],
  };
}

The panel appears as a tab in the Workbench navigation.

Friends Management

Import friends from CSV format:

const friendsToImport = importText.split("\n").map(line => {
  const [name, phone] = line.split(",").map(s => s.trim());
  return { name, phone };
});

await fetch("/admin/friends/import", {
  method: "POST",
  body: JSON.stringify({ friends: friendsToImport }),
});

CSV format:

Alice Smith, 5551234567
Bob Johnson, 5559876543

The API normalizes phone numbers to E.164 format (+15551234567) and checks for duplicates.

Session Management

Create sessions manually:

await fetch("/admin/session/create", {
  method: "POST",
  body: JSON.stringify({
    date: "2025-12-24",
    startTime: "07:00",
    endTime: "09:00",
    capacity: 3,
    location: "Main Lake Dock",
    status: "published",
  }),
});

View current roster and capacity:

const response = await fetch("/api/sessions");
const data = await response.json();

data.sessions.forEach(sessionInfo => {
  console.log(`${sessionInfo.session.date}: ${sessionInfo.stats.confirmed}/${sessionInfo.session.capacity} spots`);
  console.log(`Roster: ${sessionInfo.roster.map(r => r.name).join(", ")}`);
});

Manual Actions

Trigger invite blast manually (outside Monday 3pm schedule):

await fetch("/admin/invite/send", {
  method: "POST",
});

This sends SMS invites to all active friends immediately.


Frontend Calendar

The React frontend provides a public booking interface.

File: frontend/src/components/EnhancedCalendar.tsx

Uses date-fns for date calculations:

const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const startDate = startOfWeek(monthStart);
const endDate = endOfWeek(monthEnd);

const calendarDays = eachDayOfInterval({ start: startDate, end: endDate });

Maps sessions to calendar dates:

const getSessionForDate = (date: Date) => {
  return sessions.find(s => isSameDay(parseISO(s.session.date), date));
};

Shows capacity status:

const getSessionStatus = (session: SessionInfo) => {
  const bookings = session.bookings || [];
  if (bookings.length >= session.session.capacity) return "full";
  if (bookings.length > 0) return "partial";
  return "empty";
};

Color codes by status:

const getStatusColor = (status: string) => {
  switch (status) {
    case "full": return "bg-red-500";
    case "partial": return "bg-yellow-500";
    case "empty": return "bg-green-500";
  }
};

Handles booking:

const handleBook = async (sessionId: string) => {
  const response = await fetch("/api/book/direct", {
    method: "POST",
    body: JSON.stringify({
      sessionId,
      friendName: "Demo Friend",
      phoneE164: "+15551234567",
    }),
  });
  
  const data = await response.json();
  
  if (data.status === "confirmed") {
    showToast("Successfully booked!", "success");
    loadSessions(); // Refresh
  }
};

Testing the System

1. Create a Session

Open the admin panel at http://localhost:3000 → "Surf Club Admin" tab → "Sessions" → "Create Session":

{
  "date": "2025-12-24",
  "startTime": "07:00",
  "endTime": "09:00",
  "capacity": 3,
  "location": "Main Lake Dock",
  "status": "published"
}

2. Import Friends

Go to "Friends" tab → "Import Friends":

Alice Smith, 5551234567
Bob Johnson, 5559876543
Carol Davis, 5555551234

The system normalizes these to E.164 format (+15551234567).

3. Book a Session

Via API:

curl -X POST http://localhost:3000/api/book/direct \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "session_id_here",
    "friendName": "Alice Smith",
    "phoneE164": "+15551234567"
  }'

Via Frontend:

Open http://localhost:5173, click on December 24, click "Book This Session".

Check SMS:

Go to Twilio Console → Messaging → Logs. You'll see the confirmation SMS sent to +15551234567.

4. Test Cancellation

Get the cancellation link from the SMS or create one manually:

curl -X POST http://localhost:3000/api/book/cancel-direct \
  -H "Content-Type: application/json" \
  -d '{
    "bookingId": "booking_id_here",
    "phoneE164": "+15551234567"
  }'

The booking status changes to "canceled". If someone was waitlisted, they automatically get promoted.

5. Test Waitlist

Book 3 sessions (capacity limit), then book a 4th:

curl -X POST http://localhost:3000/api/book/direct \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "session_id_here",
    "friendName": "Dave Wilson",
    "phoneE164": "+15559998888"
  }'

Response:

{
  "status": "full",
  "message": "Sorry, this session is full (3/3 spots taken)."
}

6. Manual Invite Blast

Go to "Actions" tab → "Trigger Invite Blast". All active friends receive an SMS immediately.

Check Twilio Console to see all outbound messages.

7. Test Cron Jobs

Cron jobs in development are set to * * * * * (every minute) for testing. In the logs, you'll see:

[SeedNextSession] Next Tuesday session created
[SendInviteBlast] Sending invites to 3 active friends
[SendMorningReminder] Reminders sent to 2 confirmed bookings

Change the cron schedules to production values before deploying:

// Development (every minute)
cron: "* * * * *"

// Production
cron: "0 12 * * FRI"  // Friday 12pm
cron: "0 15 * * MON"  // Monday 3pm
cron: "30 5 * * TUE"  // Tuesday 5:30am

Customization

Change Session Time

Edit steps/cron/seed-next-session.step.ts:

const newSession = {
  // ...
  startTime: "08:00",  // Change from 07:00
  endTime: "10:00",    // Change from 09:00
};

Change Capacity

Edit the session creation form or API call:

capacity: 5,  // Change from 3

Change SMS Message Content

Edit steps/events/confirm-booking.step.ts:

const message = `You're confirmed for ${session.startTime}! See you at ${session.location}.`;

Or steps/cron/send-invite-blast.step.ts:

const message = `Wake surf this Tuesday? Reply YES to ${bookingUrl}`;

Add Email Notifications

Install Resend or similar:

npm install resend

Create a new event step:

// steps/events/send-email.step.ts

export const config: EventConfig = {
  type: "event",
  name: "SendEmail",
  subscribes: ["booking.created"],
};

export const handler: Handlers["SendEmail"] = async (input, { logger }) => {
  const resend = new Resend(process.env.RESEND_API_KEY);
  
  await resend.emails.send({
    from: "surf@yourdomain.com",
    to: input.email,
    subject: "Booking Confirmed",
    html: `<p>You're confirmed for ${input.sessionDate}!</p>`,
  });
};

Emit the event from confirm-booking.step.ts:

await emit({
  topic: "booking.created",
  data: { ...bookingData, email: friend.email },
});

Add Payment Processing

Install Stripe:

npm install stripe

Create a payment step:

// steps/events/process-payment.step.ts

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export const config: EventConfig = {
  type: "event",
  name: "ProcessPayment",
  subscribes: ["booking.created"],
  emits: ["payment.completed"],
};

export const handler: Handlers["ProcessPayment"] = async (input, { emit, logger }) => {
  const paymentIntent = await stripe.paymentIntents.create({
    amount: 2000, // $20.00
    currency: "usd",
    metadata: { bookingId: input.bookingId },
  });
  
  await emit({
    topic: "payment.completed",
    data: { bookingId: input.bookingId, paymentIntentId: paymentIntent.id },
  });
};

Only confirm bookings after payment:

// confirm-booking.step.ts
subscribes: ["payment.completed"],  // Changed from "booking.created"

Change Day of Week

Edit all cron jobs and session creation logic:

// Change from Tuesday to Thursday

// seed-next-session.step.ts
function getNextThursday(): string {
  const now = new Date();
  const nextThu = nextThursday(now);  // Use date-fns nextThursday
  return format(nextThu, "yyyy-MM-dd");
}

// send-invite-blast.step.ts
cron: "0 15 * * WED",  // Wednesday 3pm

// send-morning-reminder.step.ts
cron: "30 5 * * THU",  // Thursday 5:30am

What You Learned

You now have a working booking system with:

  • Event-driven architecture - Steps communicate through events, not direct calls
  • SMS integration - Twilio sends invites, confirmations, and reminders
  • Retry logic - Failed SMS sends retry with exponential backoff
  • Deduplication - Prevents duplicate messages if events replay
  • Cron jobs - Automated weekly session creation and reminders
  • Waitlist management - Automatic promotion when someone cancels
  • JWT security - Signed links prevent URL manipulation
  • Triple-indexed state - Fast lookups without slow queries
  • Admin panel - Custom Workbench plugin for management
  • Calendar UI - React frontend for public booking

The patterns here apply to any booking system, including gyms, studios, coworking spaces, equipment rentals, appointment scheduling, and event registrations. Change the domain logic, keep the infrastructure.