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 yet
const 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 cron
idempotencyKey: `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 such
await 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.