Skip to main content
A multi-step cold-outbound sequencer — Outreach, Smartlead, Instantly, the kind of tool you might be replacing — is mostly bookkeeping over your own domain. A list of prospects, a definition of steps, a state machine per prospect. Wire that to our send endpoint and our events stream and you’re done. This guide walks through the full shape: what your DB looks like, how the loop runs, where idempotency goes, and how you recover from downtime.

Your storage

We don’t replicate any of this. You own it. We own the conversation, the threading, and the no-reply timer.
sequence (
  id          text primary key,
  name        text,
  steps       jsonb        -- array of { subject, template, waitNext }
)

lead (
  id            text primary key,
  email         text not null,
  sequence_id   text not null references sequence(id),
  current_step  int  not null default 0,
  status        text not null default 'queued',
                            -- queued | in_flight | replied | bounced | exhausted
  conv_id       text,       -- 12m convId, populated after the first send
  last_step_at  timestamptz
)
That’s the whole schema. In real systems you’d add audit columns, campaign metadata, segment labels, etc., but the loop only needs these.

Firing a step

async function fireStep(lead) {
  const seq  = await db.sequence.byId(lead.sequence_id);
  const step = seq.steps[lead.current_step];

  const body = lead.conv_id
    ? {
        // Follow-up — stays in the existing thread, same mailbox, same
        // subject line with `Re:` prefix.
        convId: lead.conv_id,
        text:   render(step.template, lead),
        noReplyEventAfter: step.waitNext,
      }
    : {
        // First touch — creates the conversation.
        to:      lead.email,
        subject: render(step.subject, lead),
        text:    render(step.template, lead),
        noReplyEventAfter: step.waitNext,
      };

  const res = await fetch12m(`/v1/identities/${HANDLE}/send`, {
    method: "POST",
    headers: {
      "Content-Type":    "application/json",
      "Authorization":  `Bearer ${process.env.TWELVEM_KEY}`,
      "Idempotency-Key": `${lead.id}:${lead.current_step}`,
    },
    body: JSON.stringify(body),
  }).then((r) => r.json());

  await db.lead.update(lead.id, {
    conv_id:      lead.conv_id ?? res.messages[0].convId,
    status:       "in_flight",
    last_step_at: new Date(),
  });
}
Two details worth calling out:
  • Idempotency-Key: ${lead.id}:${lead.current_step} — if your worker crashes after we accepted the send but before your DB recorded it, the retry calls fireStep again and we return the same response without sending twice.
  • noReplyEventAfter: step.waitNext — each step defines its own follow-up window. This is the timer that wakes us up to advance to the next step.

Reacting to events

The handler for the four event types is small:
app.post("/12m", verify, async (req, res) => {
  const ev   = req.body;
  const lead = await db.lead.byConvId(ev.data.convId);
  if (!lead) return res.sendStatus(200);   // not ours; ignore

  switch (ev.type) {
    case "email.replied":
      // They responded. Halt the sequence.
      await db.lead.update(lead.id, { status: "replied" });
      break;

    case "email.no_reply": {
      // No response in the window. Advance, or mark exhausted.
      const seq = await db.sequence.byId(lead.sequence_id);
      const next = lead.current_step + 1;
      if (next < seq.steps.length) {
        await db.lead.update(lead.id, { current_step: next });
        await fireStep({ ...lead, current_step: next });
      } else {
        await db.lead.update(lead.id, { status: "exhausted" });
      }
      break;
    }

    case "email.received":
      // Inbound from a recipient who never got our outbound first.
      // Cold inbounds don't usually map to a sequence — route to a
      // human or your triage queue.
      break;
  }
  res.sendStatus(200);
});
That’s the loop. email.sent confirmations are useful for monitoring (“how many sends are in flight”), but they don’t change lead state in this model — the send already happened in fireStep and the DB row already moved to in_flight.

Bootstrapping the campaign

To start a campaign, you fire the first step for each lead:
async function startCampaign(sequenceId, leadIds) {
  await db.lead.update(leadIds, {
    sequence_id:   sequenceId,
    current_step:  0,
    status:        "queued",
  });
  for (const lead of await db.lead.byIds(leadIds)) {
    await fireStep(lead);
  }
}
For bigger campaigns you’ll want to space the initial sends — kicking off ten thousand sends in a tight loop will hit the identity’s daily cap, trigger 429s, and look bursty. A simple pacer (one send per N milliseconds, randomized within a window) is enough; we don’t need a queue.

Recovery

Your webhook receiver was down for an hour. You missed events. Recover by walking the events stream from the last seq you persisted:
let cursor = await db.getLastWebhookSeq(HANDLE);
for (;;) {
  const r = await fetch12m(
    `/v1/identities/${HANDLE}/events?since=${cursor}&limit=200`,
  ).then((r) => r.json());

  for (const ev of r.events) await handleEvent(ev);   // same handler as webhook
  cursor = r.cursor;
  if (!r.hasMore) break;
}
await db.setLastWebhookSeq(HANDLE, cursor);
Webhook delivery and pull events share the same seq, so this is trivially deduppable: track (identity, lastSeq) per identity and refuse anything <= it.

What we don’t emit yet

Bounces and engagement events aren’t in the stream today.
  • Bounces. email.bounced is on the roadmap. Today, a bounce arrives as an inbound email.received (or email.replied) from mailer-daemon@…. If your sequencer needs to suppress dead addresses immediately, pattern-match the sender domain in your email.received handler.
  • Opens / clicks. email.opened and email.clicked aren’t in the stream. Most sequencers run their own tracking pixel and link rewriter — inject them into the html you pass to /send and you get the same telemetry every other tool offers, plus full control over what’s tracked. (Whether you should track opens at all is a product question, not a 12m one.)
Add bounce tracking to your suppression list once email.bounced ships; until then, the mailer-daemon heuristic is what every sequencer uses.

What you don’t have to build

Worth being explicit about, since teams often start a sequencer build without realizing how much is already done:
  • A scheduler. The no-reply timer is the scheduler.
  • A mailbox warmup system. We run it.
  • A mailbox rotation algorithm. We run it.
  • A reply-detection system. The email.replied event is it.
  • Threading headers, subject prefixes, sender continuity. We do them.
  • Per-mailbox daily caps and budget tracking. We do that too.
All you build is the campaign UI, the lead list, the templates, the state machine in this guide, and the dashboards your reps want to see. That’s a much smaller product than “build a sequencer from scratch on top of Gmail,” and it’s why teams ship 12m-based sequencers in days instead of months.