Clever Tagline
Clever Tagline2mo ago

Simple SMS drip campaign driven by Convex

I work for a landscape lighting company, and each person who fills out the request form on our website is sent an immediate SMS response with some questions about what ideas they might have for their project. However, not everyone responds to that request right away. The Christmas season is really busy for us, so in order to save our lone sales exec from following up with each request directly—on top of his other duties, which are plentiful— I was asked to create a SMS drip campaign. Here's how I put it together.
3 Replies
Clever Tagline
Clever TaglineOP2mo ago
The goal of the campaign is simply to prompt the contact to reply via SMS. The drip system that I was asked to create would send pre-written follow-up messages twice a day—at 8 am and 4 pm—over the four days following each contact's initial request, with no messages sent on Sundays. The drip stops after four days if there’s no reply. If the contact replies via SMS, or a meeting is scheduled with them, the drip is terminated early. Our databases and business logic for the message delivery and early drip termination still reside outside of Convex for now (on Airtable and DigitalOcean, respectively), but I felt that using Convex to trigger the delivery system at the appropriate times would be the most efficient approach out of all the options at my disposal. This was my first time using a self-scheduled action, and it came together very easily. The action I created accepts a single timeOfDay string argument, with the value being either “AM” or “PM”. Each action: - Calls a serverless function on DigitalOcean, passing the received timeOfDay value. This other logic handles retrieving contact data and sending appropriate drip sequence text messages. - Schedules the next action to run at the appropriate time, passing the alternate timeOfDay value from the one received. Here’s the code for the action:
import { v } from "convex/values";
import { internalAction } from "../_generated/server";
import { internal } from "../_generated/api";

// I'm using the Luxon library for date calculations
import { DateTime } from "luxon"

export const sendIntakeSequenceMessages = internalAction({
args: {
timeOfDay: v.union(
v.literal("AM"),
v.literal("PM")
)
},
handler: async (ctx, args) => {
/*
* OMITTED: Call to DigitalOcean, passing args.timeOfDay
*/

// Schedule the next action
const currentTime = DateTime.now().setZone("America/New_York")
let commonValues = {minute: 0, second: 0, millisecond: 0}
let newTime = args.timeOfDay === "AM"
// If timeOfDay is "AM", schedule the next run for 4 PM the same day
? currentTime.set({ hour: 16, ...commonValues })
// Otherwise, schedule for 8 AM the next day, skipping Sunday if the current day is Saturday
: currentTime.plus({ day: currentTime.weekday === 6 ? 2 : 1 }).set({ hour: 8, ...commonValues })
console.log("sendIntakeSequenceMessages: Next action scheduled for", newTime.toISO())

await ctx.scheduler.runAt(
newTime.toJSDate(),
internal.sales.contacts.sendIntakeSequenceMessages, {
timeOfDay: args.timeOfDay === "AM" ? "PM" : "AM"
}
)
}
})
import { v } from "convex/values";
import { internalAction } from "../_generated/server";
import { internal } from "../_generated/api";

// I'm using the Luxon library for date calculations
import { DateTime } from "luxon"

export const sendIntakeSequenceMessages = internalAction({
args: {
timeOfDay: v.union(
v.literal("AM"),
v.literal("PM")
)
},
handler: async (ctx, args) => {
/*
* OMITTED: Call to DigitalOcean, passing args.timeOfDay
*/

// Schedule the next action
const currentTime = DateTime.now().setZone("America/New_York")
let commonValues = {minute: 0, second: 0, millisecond: 0}
let newTime = args.timeOfDay === "AM"
// If timeOfDay is "AM", schedule the next run for 4 PM the same day
? currentTime.set({ hour: 16, ...commonValues })
// Otherwise, schedule for 8 AM the next day, skipping Sunday if the current day is Saturday
: currentTime.plus({ day: currentTime.weekday === 6 ? 2 : 1 }).set({ hour: 8, ...commonValues })
console.log("sendIntakeSequenceMessages: Next action scheduled for", newTime.toISO())

await ctx.scheduler.runAt(
newTime.toJSDate(),
internal.sales.contacts.sendIntakeSequenceMessages, {
timeOfDay: args.timeOfDay === "AM" ? "PM" : "AM"
}
)
}
})
To launch the first of these actions, I created another internal action that I would only run directly from the Convex dashboard:
export const launchIntakeSequenceScheduler = internalAction({
args: {
// ISO string, date only; e.g. "2024-01-01"
date: v.string(),
timeOfDay: v.union(
v.literal("AM"),
v.literal("PM")
)
},
handler: async (ctx, args) => {
// Calculate the first run time
let startTime = DateTime
.fromISO(`${args.date}T10:00:00.000Z`)
.setZone("America/New_York")
.set({ hour: args.timeOfDay === "AM" ? 8 : 16, minute: 0, second: 0, millisecond: 0})

// Schedule the first action
await ctx.scheduler.runAt(
startTime.toJSDate(),
internal.sales.contacts.sendIntakeSequenceMessages, {
timeOfDay: args.timeOfDay,
}
)
}
})
export const launchIntakeSequenceScheduler = internalAction({
args: {
// ISO string, date only; e.g. "2024-01-01"
date: v.string(),
timeOfDay: v.union(
v.literal("AM"),
v.literal("PM")
)
},
handler: async (ctx, args) => {
// Calculate the first run time
let startTime = DateTime
.fromISO(`${args.date}T10:00:00.000Z`)
.setZone("America/New_York")
.set({ hour: args.timeOfDay === "AM" ? 8 : 16, minute: 0, second: 0, millisecond: 0})

// Schedule the first action
await ctx.scheduler.runAt(
startTime.toJSDate(),
internal.sales.contacts.sendIntakeSequenceMessages, {
timeOfDay: args.timeOfDay,
}
)
}
})
I know this isn’t a terribly complex setup, but maybe something here will be useful to someone else.
sam
sam2mo ago
Thanks for sharing! Sometimes, simple and effective is best, like almost all the time! 🙏🏽
jamwt
jamwt2mo ago
nice project!

Did you find this page helpful?