Playbook
The outbound sequence orchestrator.
Multi-step outbound with reply detection, bounce handling, and pause-on-reply. Smartlead and Lemlist do the sending. Rotor handles the brain.
11 min read
The pain
Why outbound sequences leak
You build a 5-touch sequence in Smartlead. The prospect replies on touch 2. The sequence keeps sending. Three days later they get touch 3 and unsubscribe out of frustration. Your domain reputation drops.
The four ways naive sequences leak:
- No real reply detection. Smartlead detects "stop" and "unsubscribe" but misses "no thanks" or "wrong person." Sequence keeps firing.
- Bounces aren't propagated. Touch 1 hard bounces. Touches 2-5 still go to the same dead address.
- No cross-channel awareness. Prospect replies on LinkedIn. Sequence still sends touches 2-5 over email.
- No reactivation logic. Prospect replies on touch 4. Sales takes the meeting. Six months later, sales drops them. Sequence doesn't know to restart.
The architecture
What we're building
A workflow per prospect, where Rotor manages timing and state. Smartlead does the actual sending and tracking. The two systems talk via webhooks.
- Sequence as a workflow. Each touch is a step, with a
step.sleepbetween them. Sleeping costs zero compute. - Reply waits as events. Between touches, the workflow waits on a
step.waitForEventfor "reply received" with a timeout equal to the gap to next touch. - Cancel-on-reply. If a reply event matches the prospect, cancel the rest of the sequence. The workflow exits cleanly.
- Bounce handling as a workflow event. Hard bounces fire an event that cancels the sequence and marks the email invalid in HubSpot.
The implementation
A 5-touch sequence with reply waits
workflow({
id: "outbound-sequence",
trigger: { event: "outbound.start" },
steps: async ({ event, step }) => {
const prospect = event.data;
const sequenceId = `seq-${prospect.id}`;
// Touch 1
await step.run("send-touch-1", () =>
smartlead.send({ prospect, template: "touch-1" })
);
// Wait 3 days for a reply or proceed
const reply1 = await step.waitForEvent("wait-reply-1", {
event: "outbound.reply",
match: "data.prospectId",
timeout: "3d",
});
if (reply1) return { stoppedAt: 1, reason: "reply" };
// Touch 2
await step.run("send-touch-2", () =>
smartlead.send({ prospect, template: "touch-2" })
);
const reply2 = await step.waitForEvent("wait-reply-2", {
event: "outbound.reply",
match: "data.prospectId",
timeout: "4d",
});
if (reply2) return { stoppedAt: 2, reason: "reply" };
// Touch 3 — break-up email if no reply
await step.run("send-touch-3", () =>
smartlead.send({ prospect, template: "touch-3-breakup" })
);
const reply3 = await step.waitForEvent("wait-reply-3", {
event: "outbound.reply",
match: "data.prospectId",
timeout: "7d",
});
if (reply3) return { stoppedAt: 3, reason: "reply" };
return { stoppedAt: 3, reason: "completed-no-reply" };
},
});Each step.waitForEvent is durable. The workflow is suspended for up to 7 days with zero compute cost. When a reply event fires matching the prospect ID, the workflow resumes immediately.
The reply listener
Bridging Smartlead webhooks to Rotor events
Smartlead sends a webhook when a prospect replies. The handler turns that into a Rotor event that the waiting workflow can resume on.
// app/api/webhooks/smartlead/route.ts
export async function POST(req: Request) {
const body = await req.json();
const sig = req.headers.get("x-smartlead-signature");
if (!verifySmartleadSignature(body, sig)) {
return Response.json({ error: "invalid sig" }, { status: 400 });
}
if (body.event === "reply") {
await rotor.events.send({
name: "outbound.reply",
data: {
prospectId: body.prospect_id,
repliedAt: body.replied_at,
replyBody: body.reply_body,
sentiment: body.sentiment, // "positive" | "negative" | "neutral"
},
});
}
if (body.event === "bounce" && body.bounce_type === "hard") {
await rotor.events.send({
name: "outbound.bounce",
data: {
prospectId: body.prospect_id,
email: body.email,
},
});
// Cancel the sequence using cancel-on-event in the workflow def
}
return Response.json({ received: true });
}Cross-channel and reactivation
The harder bits
Cross-channel reply detection. Add a separate listener for LinkedIn replies. Same event name outbound.reply, just from a different source. The workflow doesn't care which channel detected the reply.
Reactivation after stalled deal. Trigger a new workflow run when HubSpot moves a deal to closed-lost or back to cold. The sequence picks up where it would have, with new copy.
Domain reputation guard. Add a daily cron that checks the bounce rate and sequence completion rate. If bounces are above 3%, pause all new sequence triggers and alert. Better to delay outbound than burn the domain.
Edge cases
What goes wrong, and how to handle it
Smartlead sends touch 3 to someone who replied. Race condition between webhook delivery and the next touch. Add a check at the top of every send step: re-query Smartlead for the prospect's status before sending.
Prospect changes email mid-sequence. Fire an event and resume the workflow on the new address.
Compliance request mid-sequence. Hard cancel via cancel-on-event. Add the prospect to a suppression list. Audit log the cancellation.
Long sleeps that span DST changes. Use step.sleep("3d") not step.sleepUntil(specificDate). Duration is timezone-safe, specific dates are not.
The math
What this costs on Rotor
A team running 1,000 prospects/month through a 3-touch sequence. Each prospect = ~7 step-runs (3 sends + 3 waits + initial trigger). ~7,000 step-runs/month.
That fits Rotor Hobby ($9/mo). On alternatives:
- Smartlead's native sequencing: included in the Smartlead bill, but lacks Rotor's reply-event semantics. You either accept the leakage or run a parallel workflow on top.
- Zapier with delay actions: each touch is 2-3 tasks. 7,000 prospects × 7 = 49k tasks. That's $200+/mo on top of Smartlead.
The hidden saving is domain reputation. One avoided bounce storm can be worth months of Rotor at any tier.
Fork this playbook on Rotor.
Private beta. Request an invite and we will send access as slots open.
Request an invite