> ## Documentation Index
> Fetch the complete documentation index at: https://docs.12m.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Building a sequencer

> Multi-step cold outbound on top of /send and the events stream. Your DB stays small; we own threading, rotation, warmup, and the no-reply timer.

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.

```sql theme={null}
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

```js theme={null}
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:

```js theme={null}
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:

```js theme={null}
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 `429`s, 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:

```js theme={null}
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.
