Recipe

Drip campaign

A multi-step nurture sequence that auto-stops if the recipient replies, opts out, or reads-but-doesn't-act. Two ways to build it: roll your own with cron + state, or use BlueReplies' built-in Workflows engine.

Option A: Built-in Workflows (recommended)

The dashboard has a visual workflow builder. You drag nodes (send-message, wait-delay, wait-read-or-reply, condition) and connect them — same engine that powers the AI agent flows.

Programmatically enroll contacts via API:

await client.workflows.enroll({
workflowId: 'wfl_…',
phoneNumber: '+15551234567',
metadata: {
firstName: 'Sarah',
leadSource: 'organic-search',
},
});
Workflows track every send, reply, and branch decision automatically. Auto-stops on opt-out, on cap hits, and when the contact replies for branching nodes.

Option B: Roll your own

If you'd rather control the state machine in your own code, here's a minimal drip with three touches over 7 days, gated on inbound replies.

drip.js
import { BlueReplies } from 'blueapi';
import { db } from './db.js';
const client = new BlueReplies({ apiKey: process.env.BLUEREPLIES_KEY });
const STEPS = [
{ delayDays: 0, body: (c) => `Hey ${c.firstName}, thanks for signing up! What brings you here?` },
{ delayDays: 2, body: (c) => `Quick check-in ${c.firstName} — anything I can help with?` },
{ delayDays: 7, body: (c) => `Last note from me, ${c.firstName}. Let me know if you want to chat.` },
];
// Cron: every 15 minutes, send any due step for any contact not yet completed.
async function tick() {
const due = await db.dripEnrollments.findMany({
where: { completedAt: null, replied: false, optedOut: false },
});
for (const e of due) {
const step = STEPS[e.stepIndex];
const ageDays = (Date.now() - e.enrolledAt.getTime()) / 86_400_000;
if (ageDays < step.delayDays) continue; // not yet due
try {
await client.messages.send({
to: e.phone,
body: step.body(e),
idempotencyKey: `drip-${e.id}-step-${e.stepIndex}`,
});
} catch (err) {
if (err.code === 'CONFLICT' && err.details?.reason === 'opted_out') {
await db.dripEnrollments.update({ where: { id: e.id }, data: { optedOut: true }});
continue;
}
console.error(err); continue;
}
const nextIdx = e.stepIndex + 1;
await db.dripEnrollments.update({
where: { id: e.id },
data: {
stepIndex: nextIdx,
completedAt: nextIdx >= STEPS.length ? new Date() : null,
},
});
}
}
// Subscribe to message.inbound — when ANY reply comes in, halt the drip.
export async function onInbound(evt) {
if (evt.event !== 'message.inbound') return;
await db.dripEnrollments.updateMany({
where: { phone: evt.data.from, completedAt: null },
data: { replied: true, completedAt: new Date() },
});
}
tick().catch(console.error);

Gotchas

  • The 6-unanswered cutoff will block you after 6 sends to a recipient who never replies. Honor it — don't try to evade.
  • Stickiness: every message in the same conversation routes through the same line. Don't shuffle lines mid-drip; you'll trigger Apple's spam heuristics.
  • Quiet hours: default is 9pm-8am recipient-local. Configure per-customer in dashboard settings if needed.
  • Warmup caps: if you're enrolling lots of new conversations daily, watch warmup. Pre-warm lines before launching.