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.
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

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:
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:
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:
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.