Private beta

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.

Private beta. Request an invite and we will send access as slots open.

Request an invite