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 duetry {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.