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.sleep between them. Sleeping costs zero compute.
  • Reply waits as events. Between touches, the workflow waits on a step.waitForEvent for "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.

$9 to start. 30-day money back. Hard caps protect you from runaway bills.

Start shipping