Recipe
Appointment reminders
Send a single iMessage reminder ~24h before each scheduled appointment. Respects opt-outs. Idempotent so cron retries don't double-send.
The cron job
send-reminders.js
import { BlueReplies } from 'blueapi';import { db } from './db.js';const client = new BlueReplies({ apiKey: process.env.BLUEREPLIES_KEY });// Run this every 15 minutes via cron.async function sendReminders() {// Get appointments scheduled in the next 23-25 hours we haven't reminded yetconst upcoming = await db.appointments.findMany({where: {scheduledFor: {gte: new Date(Date.now() + 23 * 3600_000),lt: new Date(Date.now() + 25 * 3600_000),},reminderSentAt: null,},});for (const appt of upcoming) {try {const msg = await client.messages.send({to: appt.customerPhone,body: `Hi ${appt.customerName}, reminder: your ${appt.serviceName} appointment is tomorrow at ${formatTime(appt.scheduledFor)}. Reply CANCEL to reschedule.`,// Idempotency = appointment id → safe to re-run cronidempotencyKey: `reminder-${appt.id}`,});await db.appointments.update({where: { id: appt.id },data: { reminderSentAt: new Date(), reminderMessageId: msg.id },});} catch (err) {if (err.code === 'CONFLICT' && err.details?.reason === 'opted_out') {// Recipient opted out — don't retry, mark as suchawait db.appointments.update({where: { id: appt.id },data: { reminderSentAt: new Date(), reminderSkipped: 'opted_out' },});continue;}console.error('Reminder failed', appt.id, err);}}}function formatTime(d) {return d.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });}sendReminders().catch(console.error);
Handling replies (CANCEL, RESCHEDULE)
Subscribe to the message.inbound webhook event so your back-end can react to opt-out keywords and customer responses in real time.
Always include an opt-out path
Put "Reply STOP to opt out" in at least the first message of any sequence. iMessage doesn't have carrier-enforced STOP like SMS does, but recipient sentiment matters — spam reports are the #1 reason lines get banned.