<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>OpenSequence blog</title>
    <link>https://www.opensequence.dev/blog</link>
    <description>Notes on owning your sales sequencing: cost breakdowns, tool comparisons, deliverability and how to run outreach sequences on infrastructure you control.</description>
    <language>en</language>
    <atom:link href="https://www.opensequence.dev/blog/rss.xml" rel="self" type="application/rss+xml" />
    <image>
      <url>https://www.opensequence.dev/icon-192.png</url>
      <title>OpenSequence blog</title>
      <link>https://www.opensequence.dev/blog</link>
    </image>
    <lastBuildDate>Thu, 15 Oct 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>How to add sales sequences to your own CRM or internal tool</title>
      <link>https://www.opensequence.dev/blog/add-sales-sequences-to-your-crm</link>
      <guid isPermaLink="true">https://www.opensequence.dev/blog/add-sales-sequences-to-your-crm</guid>
      <pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>Leo Guerberg</dc:creator>
      <description>Add Outreach-style email, call and SMS sequences to a custom CRM: sync contacts, build an Enroll button, write replies back, and run a task inbox. With code.</description>
      <category>crm</category>
      <category>tutorial</category>
      <category>api</category>
      <category>self-hosting</category>
      <content:encoded><![CDATA[<p>To add sales sequences to a CRM you built yourself, run a sequencing engine next to it and connect the two through an API and webhooks. Your CRM keeps the people, the deals and the screens. The engine keeps the sequences, sends the emails from each rep's Gmail, creates call tasks, and tells your CRM when someone replies, books a meeting or bounces. This tutorial does it with OpenSequence and TypeScript.</p>
<p>We build OpenSequence, an open source (Apache-2.0) engine that runs as one container and a Postgres. The steps below match how any API-first sequencer works, so most of the design carries over if you pick another one. The SDK calls and event names are the real ones from our <a href="https://docs.opensequence.dev/sdks/typescript" rel="noopener">TypeScript SDK</a>.</p>
<h2 id="who-this-is-for">Who this is for<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#who-this-is-for">#</a></h2>
<p>You have a CRM or an internal tool that reps work in every day. It might be a Next.js app on your own database, or Attio or HubSpot with your own app around them. Your reps want sequences like the ones in Outreach or Apollo. You do not want a second tool where reps re-type contacts, and you do not want to pay for a sales engagement seat per rep to get one feature.</p>
<p>If you have no engineer, stop here. Buy a hosted tool. Our <a href="https://www.opensequence.dev/blog/apollo-alternatives">Apollo alternatives comparison</a> lists the hosted options with prices.</p>
<h2 id="what-lives-where">What lives where<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-lives-where">#</a></h2>
<p>Your CRM owns the records reps care about: people, companies, deals, owners, and the activity timeline. OpenSequence owns five things. A contact is a copy of your person, keyed by your id. A sender is a rep, with their connected Gmail and phone. A sequence is a versioned JSON definition. An enrollment is one contact going through one sequence as one sender. A task is a call or LinkedIn step waiting for a human.</p>
<p>The link between the two systems is your id. You call the contact <code>p_7</code> in OpenSequence because it is <code>p_7</code> in your database, and every API route accepts your id wherever it accepts ours (<a href="https://docs.opensequence.dev/api/conventions" rel="noopener">ids and external ids</a>). You never have to store an OpenSequence contact id.</p>
<p>Data flows in two directions. Your CRM pushes contacts, enrollments and task outcomes in. OpenSequence pushes events back through signed webhooks. Nothing else syncs.</p>
<h2 id="step-1-run-the-engine-and-connect-each-reps-gmail">Step 1. Run the engine and connect each rep's Gmail<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#step-1-run-the-engine-and-connect-each-reps-gmail">#</a></h2>
<p>The <a href="https://docs.opensequence.dev/docs/self-hosting/install" rel="noopener">install guide</a> covers the container, the database and the project key. Two parts matter for the CRM integration.</p>
<p>First, register a Google OAuth client and give it to the project. If every rep is in your own Google Workspace, make the client Internal and Google skips its app review. Then create a sender per rep and send the rep through Google's consent screen from your settings page:</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="// lib/oseq.ts
import { OpenSequence } from &#x27;@opensequence/sdk&#x27;;

export const oseq = new OpenSequence({ baseUrl: process.env.OSEQ_URL!, apiKey: process.env.OSEQ_KEY! });" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#51597D;font-style:italic">// lib/oseq.ts</span></span>
<span class="line"><span style="color:#7DCFFF">import</span><span style="color:#9ABDF5"> { </span><span style="color:#0DB9D7">OpenSequence</span><span style="color:#9ABDF5"> }</span><span style="color:#7DCFFF"> from</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">@opensequence/sdk</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#7DCFFF">export</span><span style="color:#9D7CD8;font-style:italic"> const</span><span style="color:#BB9AF7"> oseq</span><span style="color:#89DDFF"> =</span><span style="color:#89DDFF"> new</span><span style="color:#7AA2F7"> OpenSequence</span><span style="color:#9ABDF5">({</span><span style="color:#73DACA"> baseUrl</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> process</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">env</span><span style="color:#89DDFF">.</span><span style="color:#FF9E64">OSEQ_URL</span><span style="color:#BB9AF7">!</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> apiKey</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> process</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">env</span><span style="color:#89DDFF">.</span><span style="color:#FF9E64">OSEQ_KEY</span><span style="color:#BB9AF7">!</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span></code></pre></figure>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="// app/api/settings/connect-gmail/route.ts
import { oseq } from &#x27;@/lib/oseq&#x27;;
import { requireUser } from &#x27;@/lib/auth&#x27;;

export async function POST(req: Request) {
  const rep = await requireUser(req);
  await oseq.senders.upsert(rep.id, { name: rep.name, email: rep.email, timezone: rep.timezone });
  const { auth_url } = await oseq.senders.connectGoogle(rep.id, {
    return_url: &#x27;https://crm.acme.com/settings/email&#x27;,
  });
  return Response.json({ redirect: auth_url });
}" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#51597D;font-style:italic">// app/api/settings/connect-gmail/route.ts</span></span>
<span class="line"><span style="color:#7DCFFF">import</span><span style="color:#9ABDF5"> { </span><span style="color:#0DB9D7">oseq</span><span style="color:#9ABDF5"> }</span><span style="color:#7DCFFF"> from</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">@/lib/oseq</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#7DCFFF">import</span><span style="color:#9ABDF5"> { </span><span style="color:#0DB9D7">requireUser</span><span style="color:#9ABDF5"> }</span><span style="color:#7DCFFF"> from</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">@/lib/auth</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#7DCFFF">export</span><span style="color:#9D7CD8;font-style:italic"> async</span><span style="color:#BB9AF7"> function</span><span style="color:#7AA2F7"> POST</span><span style="color:#9ABDF5">(</span><span style="color:#E0AF68">req</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> Request</span><span style="color:#9ABDF5">)</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">  const</span><span style="color:#BB9AF7"> rep</span><span style="color:#89DDFF"> =</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#7AA2F7"> requireUser</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">req</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">  await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">senders</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">upsert</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">rep</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">name</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> rep</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">name</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> email</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> rep</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">email</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> timezone</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> rep</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">timezone</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">  const</span><span style="color:#89DDFF"> {</span><span style="color:#BB9AF7"> auth_url</span><span style="color:#89DDFF"> }</span><span style="color:#89DDFF"> =</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">senders</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">connectGoogle</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">rep</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#73DACA">    return_url</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">https://crm.acme.com/settings/email</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">  })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">  return</span><span style="color:#C0CAF5"> Response</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">json</span><span style="color:#9ABDF5">({ </span><span style="color:#73DACA">redirect</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> auth_url</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">}</span></span></code></pre></figure>
<p>The rep's user id becomes the sender id, so later calls pass <code>rep.id</code> and nothing else. <code>oseq.senders.readiness(rep.id)</code> tells your settings page whether email is ready to send and, if not, why. The <a href="https://docs.opensequence.dev/docs/senders/connect-google" rel="noopener">Google connection guide</a> lists the four OAuth scopes and the redirect URI to register.</p>
<p>The SDK and the MCP package are not on npm yet. Install them from the repository until they are.</p>
<h2 id="step-2-sync-contacts-from-your-crm">Step 2. Sync contacts from your CRM<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#step-2-sync-contacts-from-your-crm">#</a></h2>
<p>Push a person into OpenSequence when they become someone a rep might sequence. You do not need to copy your whole database. <code>contacts.batch</code> takes up to 500 people per call and upserts by your id:</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="await oseq.contacts.batch(
  people.map((p) => ({
    external_id: p.id,
    first_name: p.firstName,
    last_name: p.lastName,
    email: p.email,
    phone: p.phone,
    timezone: p.timezone,
    attrs: { company: { name: p.company.name }, deal: { stage: p.deal?.stage ?? null } },
  }))
);" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#BB9AF7;font-style:italic">await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">contacts</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">batch</span><span style="color:#9ABDF5">(</span></span>
<span class="line"><span style="color:#C0CAF5">  people</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">map</span><span style="color:#9ABDF5">((</span><span style="color:#E0AF68">p</span><span style="color:#9ABDF5">)</span><span style="color:#BB9AF7"> =></span><span style="color:#9ABDF5"> ({</span></span>
<span class="line"><span style="color:#73DACA">    external_id</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> p</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    first_name</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> p</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">firstName</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    last_name</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> p</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">lastName</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    email</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> p</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">email</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    phone</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> p</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">phone</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    timezone</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> p</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">timezone</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    attrs</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#73DACA"> company</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> name</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> p</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">company</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">name</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> deal</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> stage</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> p</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">deal</span><span style="color:#89DDFF">?.</span><span style="color:#7DCFFF">stage</span><span style="color:#BB9AF7"> ??</span><span style="color:#FF9E64"> null</span><span style="color:#9ABDF5"> }</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">  }))</span></span>
<span class="line"><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span></code></pre></figure>
<p>Two fields deserve care. <code>timezone</code> decides when emails land, because the send window runs in the contact's zone and falls back to the sender's when it is empty. <code>attrs</code> is free JSON that templates and conditions read, so put there the fields a sequence will branch on or mention: the company name, the deal stage, the plan. When a deal moves, patch only that field with <code>oseq.contacts.patch(p.id, { attrs: { deal: { stage: 'proposal' } } })</code>. The <a href="https://docs.opensequence.dev/docs/contacts/attrs" rel="noopener">attributes page</a> shows how conditions read them.</p>
<p>You can also skip the sync and pass contacts inline when you enroll. The engine upserts them first. That is simpler, but a nightly batch keeps <code>attrs</code> fresh for people already mid-sequence.</p>
<h2 id="step-3-define-a-sequence-in-code">Step 3. Define a sequence in code<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#step-3-define-a-sequence-in-code">#</a></h2>
<p>A sequence is one JSON document of nodes and named transitions. This one sends an intro email, waits up to three business days for a reply, sends a follow-up in the same Gmail thread, then creates a call task:</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="const definition = {
  schema_version: 1,
  entry: &#x27;intro&#x27;,
  settings: {
    send_window: { days: [&#x27;mon&#x27;, &#x27;tue&#x27;, &#x27;wed&#x27;, &#x27;thu&#x27;, &#x27;fri&#x27;], from: &#x27;08:00&#x27;, to: &#x27;18:00&#x27;, tz: &#x27;contact&#x27; },
    jitter: &#x27;15m&#x27;,
  },
  exit_on: [&#x27;reply&#x27;, &#x27;meeting_booked&#x27;],
  nodes: {
    intro: {
      type: &#x27;outreach&#x27;, channel: &#x27;email&#x27;, action: &#x27;send&#x27;,
      params: {
        subject: &#x27;Quick question, {{ contact.first_name | &#x22;there&#x22; }}&#x27;,
        body: &#x27;Hi {{ contact.first_name | &#x22;there&#x22; }}, I saw {{ attrs.company.name | &#x22;your team&#x22; }} is hiring. Worth a call?\n\n{{ sender.first_name }}\n\n{{ unsubscribe_url }}&#x27;,
      },
      next: { sent: &#x27;wait_reply&#x27;, skipped: &#x27;wait_reply&#x27; },
    },
    wait_reply: {
      type: &#x27;wait_for&#x27;,
      params: { events: [&#x27;reply:email&#x27;], timeout: &#x27;3bd&#x27; },
      next: { &#x27;reply:email&#x27;: &#x27;done&#x27;, timeout: &#x27;follow_up&#x27; },
    },
    follow_up: {
      type: &#x27;outreach&#x27;, channel: &#x27;email&#x27;, action: &#x27;send&#x27;,
      params: { subject: &#x27;Re: Quick question&#x27;, body: &#x27;Bumping this up, {{ contact.first_name | &#x22;there&#x22; }}.\n\n{{ unsubscribe_url }}&#x27;, reply_in_thread: true },
      next: { sent: &#x27;call&#x27; },
    },
    call: {
      type: &#x27;task&#x27;,
      params: { kind: &#x27;call&#x27;, title: &#x27;Call {{ contact.first_name | &#x22;them&#x22; }}&#x27;, due: &#x27;1bd&#x27;, expire: &#x27;3bd&#x27; },
      next: { completed: &#x27;done&#x27;, no_answer: &#x27;done&#x27; },
    },
    done: { type: &#x27;end&#x27;, params: { label: &#x27;Done&#x27; } },
  },
};

const { ok, problems } = await oseq.sequences.validate(definition);
const { sequence, version } = await oseq.sequences.create({ name: &#x27;Inbound demo follow-up&#x27;, definition });
await oseq.sequences.versions.publish(sequence.id, version.id);" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#BB9AF7"> definition</span><span style="color:#89DDFF"> =</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#73DACA">  schema_version</span><span style="color:#89DDFF">:</span><span style="color:#FF9E64"> 1</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">  entry</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">intro</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">  settings</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#73DACA">    send_window</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> days</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> [</span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">mon</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">tue</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">wed</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">thu</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">fri</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5">]</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> from</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">08:00</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> to</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">18:00</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> tz</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">contact</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    jitter</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">15m</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">  }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">  exit_on</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> [</span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">reply</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">meeting_booked</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5">]</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">  nodes</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#73DACA">    intro</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#41A6B5">      type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">outreach</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> channel</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">email</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> action</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">send</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#41A6B5">        subject</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Quick question, {{ contact.first_name | "there" }}</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">        body</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Hi {{ contact.first_name | "there" }}, I saw {{ attrs.company.name | "your team" }} is hiring. Worth a call?</span><span style="color:#89DDFF">\n\n</span><span style="color:#9ECE6A">{{ sender.first_name }}</span><span style="color:#89DDFF">\n\n</span><span style="color:#9ECE6A">{{ unsubscribe_url }}</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">      }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      next</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> sent</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">wait_reply</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> skipped</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">wait_reply</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    wait_reply</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#41A6B5">      type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">wait_for</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> events</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> [</span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">reply:email</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5">]</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> timeout</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">3bd</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      next</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">reply:email</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">done</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> timeout</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">follow_up</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    follow_up</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#41A6B5">      type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">outreach</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> channel</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">email</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> action</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">send</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> subject</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Re: Quick question</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> body</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Bumping this up, {{ contact.first_name | "there" }}.</span><span style="color:#89DDFF">\n\n</span><span style="color:#9ECE6A">{{ unsubscribe_url }}</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> reply_in_thread</span><span style="color:#89DDFF">:</span><span style="color:#FF9E64"> true</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      next</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> sent</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">call</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    call</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#41A6B5">      type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">task</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> kind</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">call</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> title</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Call {{ contact.first_name | "them" }}</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> due</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">1bd</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> expire</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">3bd</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      next</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> completed</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">done</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> no_answer</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">done</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    done</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">end</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> label</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Done</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">  }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">}</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#89DDFF"> {</span><span style="color:#BB9AF7"> ok</span><span style="color:#89DDFF">,</span><span style="color:#BB9AF7"> problems</span><span style="color:#89DDFF"> }</span><span style="color:#89DDFF"> =</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">sequences</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">validate</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">definition</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#89DDFF"> {</span><span style="color:#BB9AF7"> sequence</span><span style="color:#89DDFF">,</span><span style="color:#BB9AF7"> version</span><span style="color:#89DDFF"> }</span><span style="color:#89DDFF"> =</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">sequences</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">create</span><span style="color:#9ABDF5">({</span><span style="color:#73DACA"> name</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Inbound demo follow-up</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#C0CAF5"> definition</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">sequences</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">versions</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">publish</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">sequence</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span><span style="color:#C0CAF5"> version</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span></code></pre></figure>
<p>A template that reads <code>contact.first_name</code> without a fallback fails to publish, because some contacts have no first name. A body without <code>{{ unsubscribe_url }}</code> publishes with a warning. A published version cannot change, so contacts already in it finish the version they started; edits go into a new version (<a href="https://docs.opensequence.dev/docs/sequences/versions" rel="noopener">versions</a>).</p>
<p>Keep the definitions in your repository next to the code that enrolls into them. A pull request then shows what a sequence change does, and a deploy script can create the new version and publish it.</p>
<h2 id="step-4-put-an-enroll-button-in-your-crm">Step 4. Put an Enroll button in your CRM<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#step-4-put-an-enroll-button-in-your-crm">#</a></h2>
<p>The button calls one route on your server. The route checks the user, then enrolls the selected people as that rep:</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="// app/api/sequences/[id]/enroll/route.ts
import { OpenSequenceError } from &#x27;@opensequence/sdk&#x27;;
import { oseq } from &#x27;@/lib/oseq&#x27;;
import { requireUser } from &#x27;@/lib/auth&#x27;;

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const rep = await requireUser(req);
  const { id } = await params;
  const { personIds, rehearse } = (await req.json()) as { personIds: string[]; rehearse?: boolean };

  try {
    const result = await oseq.sequences.enroll(id, {
      contacts: personIds,
      sender: rep.id,
      enrolled_by: rep.id,
      enrolled_by_kind: &#x27;user&#x27;,
      mode: rehearse ? &#x27;dry_run&#x27; : &#x27;live&#x27;,
    });
    return Response.json({ accepted: result.accepted, skipped: result.skipped, capacity: result.capacity });
  } catch (err) {
    if (err instanceof OpenSequenceError &#x26;&#x26; err.is(&#x27;ALREADY_IN_SEQUENCE&#x27;)) {
      return Response.json({ conflicts: (err.details as { conflicts: unknown[] }).conflicts }, { status: 409 });
    }
    throw err;
  }
}" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#51597D;font-style:italic">// app/api/sequences/[id]/enroll/route.ts</span></span>
<span class="line"><span style="color:#7DCFFF">import</span><span style="color:#9ABDF5"> { </span><span style="color:#0DB9D7">OpenSequenceError</span><span style="color:#9ABDF5"> }</span><span style="color:#7DCFFF"> from</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">@opensequence/sdk</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#7DCFFF">import</span><span style="color:#9ABDF5"> { </span><span style="color:#0DB9D7">oseq</span><span style="color:#9ABDF5"> }</span><span style="color:#7DCFFF"> from</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">@/lib/oseq</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#7DCFFF">import</span><span style="color:#9ABDF5"> { </span><span style="color:#0DB9D7">requireUser</span><span style="color:#9ABDF5"> }</span><span style="color:#7DCFFF"> from</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">@/lib/auth</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#7DCFFF">export</span><span style="color:#9D7CD8;font-style:italic"> async</span><span style="color:#BB9AF7"> function</span><span style="color:#7AA2F7"> POST</span><span style="color:#9ABDF5">(</span><span style="color:#E0AF68">req</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> Request</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> {</span><span style="color:#E0AF68"> params</span><span style="color:#89DDFF"> }:</span><span style="color:#9ABDF5"> {</span><span style="color:#73DACA"> params</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> Promise</span><span style="color:#89DDFF">&#x3C;</span><span style="color:#9ABDF5">{</span><span style="color:#73DACA"> id</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> string</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">></span><span style="color:#9ABDF5"> })</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">  const</span><span style="color:#BB9AF7"> rep</span><span style="color:#89DDFF"> =</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#7AA2F7"> requireUser</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">req</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">  const</span><span style="color:#89DDFF"> {</span><span style="color:#BB9AF7"> id</span><span style="color:#89DDFF"> }</span><span style="color:#89DDFF"> =</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> params</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">  const</span><span style="color:#89DDFF"> {</span><span style="color:#BB9AF7"> personIds</span><span style="color:#89DDFF">,</span><span style="color:#BB9AF7"> rehearse</span><span style="color:#89DDFF"> }</span><span style="color:#89DDFF"> =</span><span style="color:#9ABDF5"> (</span><span style="color:#BB9AF7;font-style:italic">await</span><span style="color:#C0CAF5"> req</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">json</span><span style="color:#9ABDF5">()) </span><span style="color:#89DDFF">as</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">personIds</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> string</span><span style="color:#9ABDF5">[]</span><span style="color:#89DDFF">;</span><span style="color:#73DACA"> rehearse</span><span style="color:#89DDFF">?:</span><span style="color:#0DB9D7"> boolean</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#BB9AF7">  try</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">    const</span><span style="color:#BB9AF7"> result</span><span style="color:#89DDFF"> =</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">sequences</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">enroll</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">id</span><span style="color:#89DDFF">,</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#73DACA">      contacts</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> personIds</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">      sender</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> rep</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">      enrolled_by</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> rep</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">      enrolled_by_kind</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">user</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">      mode</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> rehearse</span><span style="color:#BB9AF7"> ?</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">dry_run</span><span style="color:#89DDFF">'</span><span style="color:#BB9AF7"> :</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">live</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">    })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">    return</span><span style="color:#C0CAF5"> Response</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">json</span><span style="color:#9ABDF5">({ </span><span style="color:#73DACA">accepted</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> result</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">accepted</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> skipped</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> result</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">skipped</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> capacity</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> result</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">capacity</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">  } </span><span style="color:#BB9AF7">catch</span><span style="color:#9ABDF5"> (</span><span style="color:#C0CAF5">err</span><span style="color:#9ABDF5">) {</span></span>
<span class="line"><span style="color:#BB9AF7">    if</span><span style="color:#9ABDF5"> (</span><span style="color:#C0CAF5">err</span><span style="color:#89DDFF"> instanceof</span><span style="color:#C0CAF5"> OpenSequenceError</span><span style="color:#BB9AF7"> &#x26;&#x26;</span><span style="color:#C0CAF5"> err</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">is</span><span style="color:#9ABDF5">(</span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">ALREADY_IN_SEQUENCE</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5">)) {</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">      return</span><span style="color:#C0CAF5"> Response</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">json</span><span style="color:#9ABDF5">({ </span><span style="color:#73DACA">conflicts</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> (</span><span style="color:#C0CAF5">err</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">details</span><span style="color:#89DDFF"> as</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">conflicts</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> unknown</span><span style="color:#9ABDF5">[] })</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">conflicts</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">status</span><span style="color:#89DDFF">:</span><span style="color:#FF9E64"> 409</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span></span>
<span class="line"><span style="color:#BB9AF7">    throw</span><span style="color:#C0CAF5"> err</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">  }</span></span>
<span class="line"><span style="color:#9ABDF5">}</span></span></code></pre></figure>
<p>Three behaviours of the enroll call shape the UI. A contact can be in one active sequence at a time. If any selected person already is, the call enrolls nobody and returns a 409 that names each conflict, so the dialog can say "Dana Whitfield is already in UK facilities managers" and let the rep remove her. Suppressed people come back in <code>skipped</code> with a reason, and the rest still enroll. <code>capacity</code> tells you how many days the first emails take at the rep's daily cap, which the dialog can show as "first emails finish in about 3 days".</p>
<p>Call <code>oseq.sequences.previewEnrollment</code> with the same arguments before the rep confirms. It returns the same result and errors and writes nothing. <code>mode: 'dry_run'</code> goes one step further: it creates real enrollments whose emails are rendered and recorded but not sent, which is how you rehearse a new sequence on one contact (<a href="https://docs.opensequence.dev/docs/enrollments/enrolling" rel="noopener">enrolling</a>).</p>
<h2 id="step-5-write-activity-back-to-your-crm">Step 5. Write activity back to your CRM<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#step-5-write-activity-back-to-your-crm">#</a></h2>
<p>Register one webhook endpoint and handle the events your timeline needs. The handler verifies the signature on the raw body, then writes rows keyed by the event id so a retried delivery does nothing twice:</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="// app/api/hooks/opensequence/route.ts
import { verifyWebhook } from &#x27;@opensequence/sdk&#x27;;
import { db } from &#x27;@/lib/db&#x27;;

type Signal = { kind: string; channel: string | null; occurred_at: string };
type Contact = { external_id: string };

export async function POST(req: Request) {
  const event = verifyWebhook({ secret: process.env.OSEQ_WEBHOOK_SECRET!, headers: req.headers, body: await req.text() });
  const data = event.data as Record<string, unknown>;

  switch (event.type) {
    case &#x27;signal.received&#x27;: {
      const { signal, contact } = data as { signal: Signal; contact: Contact };
      await db.activity.upsert({
        id: event.id,
        personId: contact.external_id,
        kind: signal.kind,
        channel: signal.channel,
        at: signal.occurred_at,
      });
      break;
    }
    case &#x27;enrollment.exited&#x27;: {
      const { enrollment, exit_reason } = data as { enrollment: { contact: string }; exit_reason: string };
      const person = await oseq.contacts.get(enrollment.contact);
      await db.person.update({ id: person.externalId, sequenceStatus: &#x60;exited: ${exit_reason}&#x60; });
      break;
    }
  }
  return new Response(null, { status: 204 });
}" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#51597D;font-style:italic">// app/api/hooks/opensequence/route.ts</span></span>
<span class="line"><span style="color:#7DCFFF">import</span><span style="color:#9ABDF5"> { </span><span style="color:#0DB9D7">verifyWebhook</span><span style="color:#9ABDF5"> }</span><span style="color:#7DCFFF"> from</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">@opensequence/sdk</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#7DCFFF">import</span><span style="color:#9ABDF5"> { </span><span style="color:#0DB9D7">db</span><span style="color:#9ABDF5"> }</span><span style="color:#7DCFFF"> from</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">@/lib/db</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#BB9AF7">type</span><span style="color:#C0CAF5"> Signal</span><span style="color:#89DDFF"> =</span><span style="color:#9ABDF5"> {</span><span style="color:#73DACA"> kind</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> string</span><span style="color:#89DDFF">;</span><span style="color:#73DACA"> channel</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> string</span><span style="color:#89DDFF"> |</span><span style="color:#0DB9D7"> null</span><span style="color:#89DDFF">;</span><span style="color:#73DACA"> occurred_at</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> string</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7">type</span><span style="color:#C0CAF5"> Contact</span><span style="color:#89DDFF"> =</span><span style="color:#9ABDF5"> {</span><span style="color:#73DACA"> external_id</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> string</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#7DCFFF">export</span><span style="color:#9D7CD8;font-style:italic"> async</span><span style="color:#BB9AF7"> function</span><span style="color:#7AA2F7"> POST</span><span style="color:#9ABDF5">(</span><span style="color:#E0AF68">req</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> Request</span><span style="color:#9ABDF5">)</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">  const</span><span style="color:#BB9AF7"> event</span><span style="color:#89DDFF"> =</span><span style="color:#7AA2F7"> verifyWebhook</span><span style="color:#9ABDF5">({ </span><span style="color:#73DACA">secret</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> process</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">env</span><span style="color:#89DDFF">.</span><span style="color:#FF9E64">OSEQ_WEBHOOK_SECRET</span><span style="color:#BB9AF7">!</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> headers</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> req</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">headers</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> body</span><span style="color:#89DDFF">:</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> req</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">text</span><span style="color:#9ABDF5">() })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">  const</span><span style="color:#BB9AF7"> data</span><span style="color:#89DDFF"> =</span><span style="color:#C0CAF5"> event</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">data</span><span style="color:#89DDFF"> as</span><span style="color:#C0CAF5"> Record</span><span style="color:#89DDFF">&#x3C;</span><span style="color:#0DB9D7">string</span><span style="color:#89DDFF">,</span><span style="color:#0DB9D7"> unknown</span><span style="color:#89DDFF">>;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#BB9AF7">  switch</span><span style="color:#9ABDF5"> (</span><span style="color:#C0CAF5">event</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">type</span><span style="color:#9ABDF5">) {</span></span>
<span class="line"><span style="color:#BB9AF7">    case</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">signal.received</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">      const</span><span style="color:#89DDFF"> {</span><span style="color:#BB9AF7"> signal</span><span style="color:#89DDFF">,</span><span style="color:#BB9AF7"> contact</span><span style="color:#89DDFF"> }</span><span style="color:#89DDFF"> =</span><span style="color:#C0CAF5"> data</span><span style="color:#89DDFF"> as</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">signal</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> Signal</span><span style="color:#89DDFF">;</span><span style="color:#73DACA"> contact</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> Contact</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">      await</span><span style="color:#C0CAF5"> db</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">activity</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">upsert</span><span style="color:#9ABDF5">({</span></span>
<span class="line"><span style="color:#73DACA">        id</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> event</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">        personId</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> contact</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">external_id</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">        kind</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> signal</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">kind</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">        channel</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> signal</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">channel</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">        at</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> signal</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">occurred_at</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">      })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7">      break</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span></span>
<span class="line"><span style="color:#BB9AF7">    case</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">enrollment.exited</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">      const</span><span style="color:#89DDFF"> {</span><span style="color:#BB9AF7"> enrollment</span><span style="color:#89DDFF">,</span><span style="color:#BB9AF7"> exit_reason</span><span style="color:#89DDFF"> }</span><span style="color:#89DDFF"> =</span><span style="color:#C0CAF5"> data</span><span style="color:#89DDFF"> as</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">enrollment</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">contact</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> string</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">;</span><span style="color:#73DACA"> exit_reason</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> string</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">      const</span><span style="color:#BB9AF7"> person</span><span style="color:#89DDFF"> =</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">contacts</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">get</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">enrollment</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">contact</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">      await</span><span style="color:#C0CAF5"> db</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">person</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">update</span><span style="color:#9ABDF5">({ </span><span style="color:#73DACA">id</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> person</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">externalId</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> sequenceStatus</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> `</span><span style="color:#9ECE6A">exited: </span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">exit_reason</span><span style="color:#7DCFFF">}</span><span style="color:#89DDFF">`</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7">      break</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span></span>
<span class="line"><span style="color:#9ABDF5">  }</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">  return</span><span style="color:#89DDFF"> new</span><span style="color:#7AA2F7"> Response</span><span style="color:#9ABDF5">(</span><span style="color:#FF9E64">null</span><span style="color:#89DDFF">,</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">status</span><span style="color:#89DDFF">:</span><span style="color:#FF9E64"> 204</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">}</span></span></code></pre></figure>
<p><code>signal.received</code> covers replies, bounces, opt-outs, meetings, connected calls and completed tasks, and it carries your id for the contact. <code>run.done</code> fires for every sent step if you want each email on the timeline too. <code>enrollment.exited</code> and <code>enrollment.completed</code> tell you a person left a sequence and why. Deliveries retry for three days and arrive in order per enrollment, but they can arrive twice, which is why the handler upserts by <code>event.id</code> (<a href="https://docs.opensequence.dev/docs/webhooks/overview" rel="noopener">receiving webhooks</a>).</p>
<p>Create the endpoint once with <code>oseq.webhooks.create({ url, event_types: ['signal.received', 'enrollment.exited', 'task.created', 'task.expired'] })</code> and store the secret it returns. The call needs an admin-scoped key.</p>
<h2 id="step-6-give-each-rep-a-task-inbox">Step 6. Give each rep a task inbox<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#step-6-give-each-rep-a-task-inbox">#</a></h2>
<p>Call and LinkedIn steps become tasks for the rep. OpenSequence does not send LinkedIn messages itself, because automating LinkedIn breaks its user agreement, so a person does those steps. Your CRM shows the open tasks and posts the outcome back:</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="const { items } = await oseq.tasks.list({ sender: rep.id, status: &#x27;open&#x27; });

// when the rep logs the call
await oseq.tasks.complete(taskId, { outcome: &#x27;connected&#x27;, note: &#x27;Booked a demo for Thursday.&#x27; });" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#89DDFF"> {</span><span style="color:#BB9AF7"> items</span><span style="color:#89DDFF"> }</span><span style="color:#89DDFF"> =</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">tasks</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">list</span><span style="color:#9ABDF5">({</span><span style="color:#73DACA"> sender</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> rep</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> status</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">open</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#51597D;font-style:italic">// when the rep logs the call</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">tasks</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">complete</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">taskId</span><span style="color:#89DDFF">,</span><span style="color:#9ABDF5"> {</span><span style="color:#73DACA"> outcome</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">connected</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> note</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Booked a demo for Thursday.</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span></code></pre></figure>
<p>The outcome is free text up to 32 characters. The sequence can branch on it, and an outcome listed in the node's <code>connected_outcomes</code> counts as a conversation, which exits the enrollment when <code>exit_on</code> includes <code>reply</code>. A task nobody completes expires and the sequence takes its <code>no_answer</code> path. To keep the inbox live without polling, mirror <code>task.created</code> and <code>task.expired</code> into your own table. The <a href="https://docs.opensequence.dev/docs/enrollments/tasks" rel="noopener">tasks page</a> lists the fields to store.</p>
<h2 id="step-7-stop-sequences-from-crm-events">Step 7. Stop sequences from CRM events<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#step-7-stop-sequences-from-crm-events">#</a></h2>
<p>OpenSequence sees replies in threads it started, bounces, unsubscribe clicks, STOP texts and meetings in a connected Google Calendar. It does not see your CRM. When something there should end the outreach, report it:</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="// a rep marked the person do-not-contact
await oseq.contacts.reportEvent(person.id, { id: &#x60;dnc-${person.id}&#x60;, type: &#x27;suppressed&#x27;, reason: &#x27;do_not_contact&#x27; });

// a meeting was booked through a tool OpenSequence does not watch
await oseq.contacts.reportEvent(person.id, { id: calendarEventId, type: &#x27;meeting_booked&#x27;, occurred_at: new Date(start) });

// the deal closed, so take the person out of whatever sequence they are in
await oseq.enrollments.exitMany({ contact: person.id, label: &#x27;Deal won&#x27; });" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#51597D;font-style:italic">// a rep marked the person do-not-contact</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">contacts</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">reportEvent</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">person</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span><span style="color:#9ABDF5"> {</span><span style="color:#73DACA"> id</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> `</span><span style="color:#9ECE6A">dnc-</span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">person</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#7DCFFF">}</span><span style="color:#89DDFF">`</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">suppressed</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> reason</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">do_not_contact</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#51597D;font-style:italic">// a meeting was booked through a tool OpenSequence does not watch</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">contacts</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">reportEvent</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">person</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span><span style="color:#9ABDF5"> {</span><span style="color:#73DACA"> id</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> calendarEventId</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">meeting_booked</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> occurred_at</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> new</span><span style="color:#7AA2F7"> Date</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">start</span><span style="color:#9ABDF5">)</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#51597D;font-style:italic">// the deal closed, so take the person out of whatever sequence they are in</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">enrollments</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">exitMany</span><span style="color:#9ABDF5">({</span><span style="color:#73DACA"> contact</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> person</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> label</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Deal won</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span></code></pre></figure>
<p>Reported events are idempotent by the <code>id</code> you give them, so a sync job that runs every night can resend the whole do-not-contact list safely with <code>oseq.contacts.reportEvents</code>. Treat the suppression report as mandatory. If your CRM knows someone asked not to be contacted and the engine does not, it will keep emailing them (<a href="https://docs.opensequence.dev/docs/contacts/events" rel="noopener">contact events</a>). <code>exitMany</code> needs an admin key, so call it from a background job, not from the browser path.</p>
<h2 id="step-8-let-an-agent-use-the-same-setup">Step 8. Let an agent use the same setup<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#step-8-let-an-agent-use-the-same-setup">#</a></h2>
<p>The MCP server in the repository exposes the same API to Claude, Cursor or any MCP client, scoped by the key you give it. An agent can list sequences, validate a definition, preview and enroll contacts, read a contact's journey, work tasks and read analytics. It cannot create or publish a sequence, and the one-active-sequence rule applies to agents too, so a duplicate enrollment fails the same way it does for a rep. The server marks every enrollment it makes with <code>enrolled_by_kind: 'agent'</code>, so your audit trail shows which ones an agent made.</p>
<h2 id="what-you-still-build-yourself">What you still build yourself<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-you-still-build-yourself">#</a></h2>
<p>OpenSequence has no user interface. The engine and the calls above are the easy part. The screens are yours:</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Screen</th>
<th>What it needs</th>
<th>Our estimate</th>
</tr>
</thead>
<tbody>
<tr>
<td>Gmail connection in settings</td>
<td>Step 1 route, readiness badge</td>
<td>4 hours</td>
</tr>
<tr>
<td>Enroll dialog</td>
<td>sequence picker, preview, conflicts, dry run</td>
<td>12 hours</td>
</tr>
<tr>
<td>Activity on the timeline</td>
<td>Step 5 handler, rendering per kind</td>
<td>12 hours</td>
</tr>
<tr>
<td>Task inbox</td>
<td>list, outcome buttons, skip</td>
<td>16 hours</td>
</tr>
<tr>
<td>"In a sequence" badge on lists</td>
<td><code>contacts.statuses</code> for up to 500 ids</td>
<td>4 hours</td>
</tr>
<tr>
<td>Sequence report</td>
<td><code>analytics.summary</code> per sequence</td>
<td>8 hours</td>
</tr>
</tbody>
</table></div>
<p>That is about 56 hours for an engineer who knows your CRM, plus the <a href="https://docs.opensequence.dev/docs/self-hosting/install" rel="noopener">install</a>. The numbers are our estimate, not a measurement, and the enroll dialog and the task inbox are the two most likely to run over. Our <a href="https://www.opensequence.dev/blog/outreach-vs-salesloft-cost">Outreach vs Salesloft cost breakdown</a> puts those hours next to per-seat prices for a 15-rep team.</p>
<p>Buying is the better choice when reps need a sequence editor and dashboards on day one, when your team sends from Microsoft 365, since OpenSequence sends through Gmail only today, or when nobody owns the CRM codebase. Building the engine itself is a different and much larger job. Our guide to <a href="https://www.opensequence.dev/blog/build-your-own-sales-sequencer">what breaks when you build your own sequencer</a> covers it. If you are moving off a tool you already use, the <a href="https://www.opensequence.dev/blog/replace-your-sales-sequencing-software">migration guide</a> covers running both while contacts finish their old sequences.</p>
<h2 id="faq">FAQ<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#faq">#</a></h2>
<h3 id="can-i-add-email-sequences-to-a-custom-crm-without-building-the-engine">Can I add email sequences to a custom CRM without building the engine?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#can-i-add-email-sequences-to-a-custom-crm-without-building-the-engine">#</a></h3>
<p>Yes. Run a sequencing engine with an API next to your CRM, push contacts and enrollments to it, and receive webhooks back. OpenSequence is one open source option. It sends from each rep's Gmail and handles timing, caps, threading and reply detection, so your code only needs the screens and the sync.</p>
<h3 id="does-the-crm-need-to-store-sequence-data">Does the CRM need to store sequence data?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#does-the-crm-need-to-store-sequence-data">#</a></h3>
<p>Only what you show. Contacts are keyed by your own id, so you never store an OpenSequence contact id. Most teams keep a sequence status on the person and an activity row per webhook event, and read anything else, such as a full journey, from the API when a rep opens it.</p>
<h3 id="how-do-i-stop-a-sequence-when-a-deal-changes-stage">How do I stop a sequence when a deal changes stage?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-do-i-stop-a-sequence-when-a-deal-changes-stage">#</a></h3>
<p>Either call <code>exitMany</code> with the contact from the code that changes the stage, or patch the stage into the contact's <code>attrs</code> and let a condition node in the sequence branch on it. The first stops the sequence at once. The second lets the sequence decide, for example by skipping the call task for deals already in proposal.</p>
<h3 id="can-i-use-this-with-attio-or-hubspot-instead-of-my-own-crm">Can I use this with Attio or HubSpot instead of my own CRM?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#can-i-use-this-with-attio-or-hubspot-instead-of-my-own-crm">#</a></h3>
<p>Yes, as long as you have an app or a job that calls the API. The steps are the same: sync people, enroll from your app, write events back. OpenSequence also reads Attio directly. Conditions can branch on the person, company and open deal in Attio at the moment they run (<a href="https://docs.opensequence.dev/docs/senders/connect-attio" rel="noopener">connect Attio</a>).</p>
<h3 id="will-emails-come-from-the-reps-own-address">Will emails come from the rep's own address?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#will-emails-come-from-the-reps-own-address">#</a></h3>
<p>Yes. Each email goes out through the rep's connected Gmail account with the Gmail API, so it sits in their Sent folder and replies land in their inbox. Follow-ups with <code>reply_in_thread</code> stay in the same Gmail thread.</p>]]></content:encoded>
    </item>
    <item>
      <title>Apollo.io alternatives for teams that mostly use it to send sequences</title>
      <link>https://www.opensequence.dev/blog/apollo-alternatives</link>
      <guid isPermaLink="true">https://www.opensequence.dev/blog/apollo-alternatives</guid>
      <pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>Leo Guerberg</dc:creator>
      <description>Apollo.io alternatives for sequencing: OpenSequence, Outreach, Salesloft, HubSpot, lemlist, Instantly and Reply.io, with prices checked in October 2026.</description>
      <category>apollo</category>
      <category>alternatives</category>
      <category>sales-engagement</category>
      <category>open-source</category>
      <content:encoded><![CDATA[<p>If you use Apollo mainly to send sequences, you have three kinds of alternative: a sales engagement suite (Outreach, Salesloft, HubSpot Sequences), a cold email tool (lemlist, Instantly, Reply.io), or a self-hosted engine such as OpenSequence. Most teams keep a contact data provider, which can be Apollo itself on fewer seats, and replace only the sequencer.</p>
<p>We build OpenSequence, so read this page with that in mind. We list it first, cover every other tool with the same sections, and end with the cases where you should stay on Apollo. All prices come from public pricing pages as of October 2026, except Outreach and Salesloft, which do not publish prices.</p>
<h2 id="why-teams-look-past-apollo-for-sequencing">Why teams look past Apollo for sequencing<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#why-teams-look-past-apollo-for-sequencing">#</a></h2>
<p>Plenty of teams like Apollo. Its own <a href="https://www.apollo.io/pricing" rel="noopener">pricing page</a> cites 4.7 out of 5 across 9,690 reviews. Problems start when a team runs all of its outbound through a tool that charges for data.</p>
<p><strong>Credits pay for everything.</strong> One credit buys an email address, eight buy a phone number, and the US dialer costs one credit per 30 seconds. Credits also pay for warmup, purchased domains and mailboxes, and AI research. Apollo pools them across the team, grants them at the start of the billing cycle, and resets them with no rollover. A rep who dials or warms up a mailbox draws from the same pool as the person building lists.</p>
<p><strong>The cheaper plans limit sending.</strong> Basic costs $49 per seat per month, billed annually, and allows 1 mailbox per user and 250 emails per day. Multiple mailboxes and unlimited daily sends start at Professional, $79 per seat per month. The free plan caps you at 2 sequences per team.</p>
<p><strong>Many deliverability problems come from defaults.</strong> Apollo sends through the mailbox you link, so the sending reputation belongs to your domain. The shared part is the default tracking domain for opens and clicks. Apollo's help center says a <a href="https://knowledge.apollo.io/hc/en-us/articles/4415240542733" rel="noopener">custom tracking subdomain</a> protects your primary domain's reputation. If your Apollo emails land in spam, check that setting and your SPF, DKIM and DMARC records before you blame the tool. Changing sequencers will not fix a bad list. Our <a href="https://www.opensequence.dev/blog/cold-email-deliverability-self-hosted">guide to cold email deliverability on your own infrastructure</a> covers the checks.</p>
<p><strong>Data and contract complaints are common.</strong> On <a href="https://www.trustpilot.com/review/apollo.io" rel="noopener">Trustpilot</a>, Apollo scores 3.1 out of 5 from 1,365 reviews, and 36% are one star. Recent reviews repeat the same complaints. Credits expire each cycle, annual plans cannot shrink mid-term, and reviewers say they cross-check the contact data against other sources.</p>
<h2 id="apollo-does-two-jobs">Apollo does two jobs<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#apollo-does-two-jobs">#</a></h2>
<p>Apollo sells a contact database and a sequencer in one seat. The two jobs have different users inside your team. Someone in RevOps or an SDR lead builds lists and spends credits. Every rep sends sequences.</p>
<p>That split matters for cost. Ten reps on Professional, at $79 per seat per month, cost $9,480 a year. If only two people build lists, two Basic seats cost $1,176 a year and still come with 60,000 pooled credits. The other eight reps then need a sequencer, and that is the decision this page is about.</p>
<p>Whatever you pick, the handoff is a contact record. Your data tool exports the contact or returns it from an API, and your sequencer accepts the contact and enrolls it. In OpenSequence that is two requests:</p>
<figure class="code-block"><figcaption><span class="code-tag">bash</span><button type="button" class="code-copy" data-copy="curl -X PUT $SEQ/v1/contacts/p_7 -H &#x22;Authorization: Bearer $KEY&#x22; -H &#x22;Content-Type: application/json&#x22; \
  -d &#x27;{&#x22;first_name&#x22;:&#x22;Dana&#x22;,&#x22;email&#x22;:&#x22;dana@example.com&#x22;,&#x22;timezone&#x22;:&#x22;Europe/London&#x22;,&#x22;attrs&#x22;:{&#x22;source&#x22;:&#x22;apollo&#x22;}}&#x27;
curl -X POST $SEQ/v1/sequences/$SEQ_ID/enrollments -H &#x22;Authorization: Bearer $KEY&#x22; -H &#x22;Content-Type: application/json&#x22; \
  -d &#x27;{&#x22;contacts&#x22;:[&#x22;p_7&#x22;],&#x22;sender&#x22;:&#x22;u_42&#x22;}&#x27;" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#C0CAF5">curl</span><span style="color:#E0AF68"> -X</span><span style="color:#9ECE6A"> PUT</span><span style="color:#C0CAF5"> $SEQ</span><span style="color:#9ECE6A">/v1/contacts/p_7</span><span style="color:#E0AF68"> -H</span><span style="color:#89DDFF"> "</span><span style="color:#9ECE6A">Authorization: Bearer </span><span style="color:#C0CAF5">$KEY</span><span style="color:#89DDFF">"</span><span style="color:#E0AF68"> -H</span><span style="color:#89DDFF"> "</span><span style="color:#9ECE6A">Content-Type: application/json</span><span style="color:#89DDFF">"</span><span style="color:#89DDFF"> \</span></span>
<span class="line"><span style="color:#E0AF68">  -d</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">{"first_name":"Dana","email":"dana@example.com","timezone":"Europe/London","attrs":{"source":"apollo"}}</span><span style="color:#89DDFF">'</span></span>
<span class="line"><span style="color:#C0CAF5">curl</span><span style="color:#E0AF68"> -X</span><span style="color:#9ECE6A"> POST</span><span style="color:#C0CAF5"> $SEQ</span><span style="color:#9ECE6A">/v1/sequences/</span><span style="color:#C0CAF5">$SEQ_ID</span><span style="color:#9ECE6A">/enrollments</span><span style="color:#E0AF68"> -H</span><span style="color:#89DDFF"> "</span><span style="color:#9ECE6A">Authorization: Bearer </span><span style="color:#C0CAF5">$KEY</span><span style="color:#89DDFF">"</span><span style="color:#E0AF68"> -H</span><span style="color:#89DDFF"> "</span><span style="color:#9ECE6A">Content-Type: application/json</span><span style="color:#89DDFF">"</span><span style="color:#89DDFF"> \</span></span>
<span class="line"><span style="color:#E0AF68">  -d</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">{"contacts":["p_7"],"sender":"u_42"}</span><span style="color:#89DDFF">'</span></span></code></pre></figure>
<h2 id="the-alternatives">The alternatives<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#the-alternatives">#</a></h2>
<h3 id="opensequence-ours">OpenSequence (ours)<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#opensequence-ours">#</a></h3>
<p>OpenSequence is an open source sequencing engine, licensed Apache-2.0, that you run yourself. It ships as one Docker image and runs on a Postgres database you own. You define sequences as versioned JSON through a REST API, a TypeScript SDK or an MCP server, and you enroll contacts as a given rep. OpenSequence sends you a signed webhook when someone replies, books a meeting, bounces or opts out. Email goes out from each rep's own Gmail account, and follow-ups can reply in the same thread. Texts and calls go through Allo. LinkedIn steps become tasks for a person. The <a href="https://docs.opensequence.dev/docs/get-started/how-it-works" rel="noopener">how it works page</a> follows one contact from enrollment to exit.</p>
<p>It fits a team with an engineer and a CRM or internal tool where reps already work. There is no per-seat price. You pay for the container and the database.</p>
<p>It differs from Apollo in what it leaves out. It has no contact database, no warmup and no user interface, so your CRM or an internal tool is the UI. Email works only through Google Workspace today. SMTP and IMAP adapters are on the <a href="https://docs.opensequence.dev/docs/community/roadmap" rel="noopener">roadmap</a> under "later", so a Microsoft 365 team cannot use it yet. It is pre-1.0, and the SDK and MCP packages are not on npm yet. We are still verifying the Allo and Attio adapters against real accounts. Each sender gets a default cap of 30 emails per day and 60 seconds between sends, and you can change both per channel (<a href="https://docs.opensequence.dev/docs/senders/senders" rel="noopener">senders and limits</a>).</p>
<h3 id="outreach-or-salesloft">Outreach or Salesloft<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#outreach-or-salesloft">#</a></h3>
<p>These are the enterprise sales engagement suites. They bundle sequences, a dialer, call recording, forecasting and deal management, with a two-way Salesforce sync. They fit teams of 30 reps and up with a RevOps owner and a Salesforce instance.</p>
<p>Neither vendor publishes prices. <a href="https://www.vendr.com/marketplace/outreach" rel="noopener">Vendr</a> puts the median Outreach contract at $45,600 a year, based on 919 purchases. For <a href="https://www.vendr.com/marketplace/salesloft" rel="noopener">Salesloft</a>, Vendr's median is $30,740 a year and its range for teams of 10 to 25 users is $60 to $100 per user per month. Both figures are third-party estimates from February 2026.</p>
<p>Compared with Apollo, both add a dialer, call recording, forecasting and deal management, and do nothing for list building. Expect a separate data provider, an annual contract and a sales process to buy them. Our <a href="https://www.opensequence.dev/blog/outreach-vs-salesloft-cost">Outreach vs Salesloft cost breakdown</a> goes into the line items.</p>
<h3 id="hubspot-sequences">HubSpot Sequences<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#hubspot-sequences">#</a></h3>
<p>Sequences are a feature of HubSpot Sales Hub, available from the Professional plan. They fit teams whose CRM is already HubSpot and who want enrollments, tasks and replies on the same contact record.</p>
<p>Sales Hub Professional is $90 per seat per month billed annually, plus a required one-time onboarding fee of $1,500 (<a href="https://www.hubspot.com/pricing/sales" rel="noopener">HubSpot pricing</a>). It allows 500 sequence emails per user per day. Enterprise is $150 per seat, with a $3,500 onboarding fee and 1,000 emails per user per day.</p>
<p>Compared with Apollo, there is no sync to set up, because the sequences run inside the CRM. HubSpot does not sell a contact database comparable to Apollo's, and LinkedIn steps need a Sales Navigator subscription.</p>
<h3 id="lemlist">lemlist<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#lemlist">#</a></h3>
<p>lemlist is a cold outreach tool that sends email. Its Multichannel plan adds LinkedIn, SMS, calls and a WhatsApp add-on. It fits small teams and founders who run cold campaigns from a few mailboxes.</p>
<p>The Email plan is $55 a month billed yearly for unlimited users and up to 50,000 emails a month. Multichannel starts at $87 a month billed yearly and includes 5 senders per user (<a href="https://www.lemlist.com/pricing" rel="noopener">lemlist pricing</a>). Contact data is an add-on. On yearly billing, $16 a month buys 2,000 credits, which is 400 emails or 100 phone numbers.</p>
<p>Compared with Apollo, lemlist's pricing does not tie sending to data credits. You can buy data from lemlist, from Apollo, or from neither. We wrote a longer piece on a <a href="https://www.opensequence.dev/blog/lemlist-alternative-self-hosted">self-hosted lemlist alternative</a>.</p>
<h3 id="instantly">Instantly<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#instantly">#</a></h3>
<p>Instantly is a high-volume cold email platform that sends from many mailboxes, rotates across them and warms them up. It fits agencies and outbound teams that send from dozens of secondary domains.</p>
<p>Growth is $47 a month, or $37.60 a month on yearly billing, for 5,000 emails and 1,000 uploaded contacts a month. Hypergrowth is $97 a month, or $77.60 yearly, for 125,000 emails and 25,000 contacts (<a href="https://instantly.ai/pricing" rel="noopener">Instantly pricing</a>). Both list unlimited email accounts, and the price is per plan, not per seat.</p>
<p>Compared with Apollo, Instantly focuses on sending volume across many inboxes, and its sequences are email only.</p>
<h3 id="replyio">Reply.io<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#replyio">#</a></h3>
<p>Reply.io is a multichannel sequencer with email, LinkedIn, calls, SMS and semi-automated WhatsApp. It fits teams that want one tool for everything, as with Apollo, but with more channels and no credit pool tied to sending.</p>
<p>Email Volume starts at $49 per user per month billed annually, with 1,000 active contacts a month and unlimited mailboxes under a fair use policy. Multichannel starts at $89 per user per month billed annually with 10 mailboxes and unlimited active contacts (<a href="https://reply.io/pricing/" rel="noopener">Reply.io pricing</a>). Both include 50 data credits a month.</p>
<p>Compared with Apollo, Reply.io charges for active contacts and channels instead of data credits. LinkedIn automation on the Email Volume plan costs $69 per account per month extra.</p>
<h2 id="comparison-matrix">Comparison matrix<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#comparison-matrix">#</a></h2>
<p>Prices are for the lowest plan that includes sequences, billed annually, as of October 2026.</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Tool</th>
<th>Entry price</th>
<th>Pricing model</th>
<th>Contact data</th>
<th>Channels</th>
<th>Hosting</th>
</tr>
</thead>
<tbody>
<tr>
<td>Apollo</td>
<td>$49 per seat per month</td>
<td>Per seat plus credits</td>
<td>Yes, on credits</td>
<td>Email, dialer, tasks</td>
<td>Vendor cloud</td>
</tr>
<tr>
<td>OpenSequence</td>
<td>$0 license fee</td>
<td>Your infrastructure</td>
<td>No</td>
<td>Email (Gmail), SMS, calls, LinkedIn tasks</td>
<td>Self-hosted</td>
</tr>
<tr>
<td>Outreach</td>
<td>Not published</td>
<td>Per seat, annual</td>
<td>No</td>
<td>Email, dialer, tasks</td>
<td>Vendor cloud</td>
</tr>
<tr>
<td>Salesloft</td>
<td>Not published</td>
<td>Per seat, annual</td>
<td>No</td>
<td>Email, dialer, tasks</td>
<td>Vendor cloud</td>
</tr>
<tr>
<td>HubSpot</td>
<td>$90 per seat per month plus $1,500 onboarding</td>
<td>Per seat</td>
<td>Limited</td>
<td>Email, calls, tasks</td>
<td>Vendor cloud</td>
</tr>
<tr>
<td>lemlist</td>
<td>$55 a month</td>
<td>Per plan or user</td>
<td>Add-on</td>
<td>Email, more on Multichannel</td>
<td>Vendor cloud</td>
</tr>
<tr>
<td>Instantly</td>
<td>$37.60 a month</td>
<td>Per plan</td>
<td>Separate</td>
<td>Email</td>
<td>Vendor cloud</td>
</tr>
<tr>
<td>Reply.io</td>
<td>$49 per user per month</td>
<td>Per user plus active contacts</td>
<td>50 credits a month</td>
<td>Email, more on Multichannel</td>
<td>Vendor cloud</td>
</tr>
</tbody>
</table></div>
<h2 id="how-to-choose">How to choose<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-to-choose">#</a></h2>
<p>Three questions decide most cases: how many reps send, what you can spend per rep, and whether someone on the team can run a service.</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Your situation</th>
<th>Pick</th>
</tr>
</thead>
<tbody>
<tr>
<td>1 to 5 senders, no engineer, cold email</td>
<td>lemlist or Instantly</td>
</tr>
<tr>
<td>1 to 10 reps, want data and sending in one bill</td>
<td>Stay on Apollo</td>
</tr>
<tr>
<td>Already on HubSpot, 5 to 30 reps</td>
<td>HubSpot Sequences</td>
</tr>
<tr>
<td>Want several channels, no engineer</td>
<td>Reply.io or lemlist Multichannel</td>
</tr>
<tr>
<td>30 or more reps, Salesforce, RevOps team</td>
<td>Outreach or Salesloft</td>
</tr>
<tr>
<td>5 to 50 reps, an engineer, your own CRM or tool</td>
<td>OpenSequence</td>
</tr>
</tbody>
</table></div>
<p>The engineer question decides whether OpenSequence is on the list at all. Someone has to run Postgres and a container, register a Google OAuth client, and build or connect the screens reps use. The <a href="https://docs.opensequence.dev/docs/self-hosting/install" rel="noopener">install guide</a> lists every step. If nobody on the team wants that job, pick a hosted tool. If you are considering building your own, our <a href="https://www.opensequence.dev/blog/build-your-own-sales-sequencer">guide to building your own sales sequencer</a> lists the parts that take longer than expected. The <a href="https://www.opensequence.dev/blog/replace-your-sales-sequencing-software">migration guide</a> covers moving live sequences without double-sending.</p>
<h2 id="when-to-stay-on-apollo">When to stay on Apollo<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#when-to-stay-on-apollo">#</a></h2>
<p>Stay if your reps prospect and send in the same session. Searching, enriching and enrolling from one screen is Apollo's strongest feature. Split across two tools, that flow needs an export or a sync between search and send.</p>
<p>Stay if you have fewer than about ten reps and use most of your credits. At $79 per seat you get data, a dialer, warmup and sequencing on one bill. A separate data tool plus a separate sequencer often costs more.</p>
<p>Stay if your team is on Microsoft 365 and you were considering OpenSequence, since it sends only through Gmail today.</p>
<p>Stay if your problem is deliverability and you have not set a custom tracking subdomain, authenticated your domains and cleaned your lists. Those fixes are free and apply to every tool on this page.</p>
<h2 id="faq">FAQ<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#faq">#</a></h2>
<h3 id="what-is-the-best-free-alternative-to-apolloio-for-sequences">What is the best free alternative to Apollo.io for sequences?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-is-the-best-free-alternative-to-apolloio-for-sequences">#</a></h3>
<p>HubSpot's free tier does not include sequences, and lemlist, Instantly and Reply.io charge after a trial. OpenSequence has no license fee because it is open source and self-hosted, so you pay only for the server and database. It needs an engineer to run and has no contact data.</p>
<h3 id="is-there-an-open-source-apollo-alternative">Is there an open source Apollo alternative?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#is-there-an-open-source-apollo-alternative">#</a></h3>
<p>For the sequencing half, yes. OpenSequence is an Apache-2.0 engine that runs email, SMS, call and LinkedIn task sequences on your own infrastructure. We do not know of an open source tool that replaces Apollo's contact database, since that data is the product.</p>
<h3 id="can-i-keep-apollo-for-data-and-use-another-tool-for-sequences">Can I keep Apollo for data and use another tool for sequences?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#can-i-keep-apollo-for-data-and-use-another-tool-for-sequences">#</a></h3>
<p>Yes, and it is often the cheapest setup. Keep Basic seats for whoever builds lists, since Apollo pools credits across the team. Export contacts, or call Apollo's API from your CRM. The sequencer then receives the contact and enrolls it.</p>
<h3 id="why-do-my-apollo-emails-go-to-spam">Why do my Apollo emails go to spam?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#why-do-my-apollo-emails-go-to-spam">#</a></h3>
<p>The usual causes are the default shared tracking domain, missing SPF, DKIM or DMARC records, new domains sending too much too soon, and stale lists that bounce. Apollo recommends a custom tracking subdomain for every sending domain. Fix those before you switch tools, because the same mailbox and list will behave the same in any sequencer.</p>
<h3 id="how-much-does-apollo-cost-per-user-in-2026">How much does Apollo cost per user in 2026?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-much-does-apollo-cost-per-user-in-2026">#</a></h3>
<p>As of October 2026, Apollo charges $49 (Basic), $79 (Professional) and $119 (Organization, minimum 3 seats) per seat per month on annual billing. Monthly billing costs more, since annual billing saves 24%. Each plan includes a yearly credit allowance per seat: 30,000, 48,000 and 72,000.</p>]]></content:encoded>
    </item>
    <item>
      <title>What breaks after the cron job when you build your own sales sequencer</title>
      <link>https://www.opensequence.dev/blog/build-your-own-sales-sequencer</link>
      <guid isPermaLink="true">https://www.opensequence.dev/blog/build-your-own-sales-sequencer</guid>
      <pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>Leo Guerberg</dc:creator>
      <description>A sales sequencer takes 16 to 24 engineer-weeks, not a cron job. This post covers time zones, double sends, caps, replies, bounces and RFC 8058, with our code.</description>
      <category>engineering</category>
      <category>build-vs-buy</category>
      <category>email</category>
      <category>architecture</category>
      <content:encoded><![CDATA[<p>You can build your own sales sequencer, but the cron job and the templates are the small part. Take a first production version that sends from reps' own Gmail inboxes, stops on replies and never double-sends. It takes one senior engineer about 16 to 24 weeks, plus upkeep. We know the parts list because we built one. We make <a href="https://github.com/lguerberg/open-sequence" rel="noopener">OpenSequence</a>, an Apache-2.0 sequencing service. Its service code is about 13,900 lines of TypeScript plus 3,600 lines of tests.</p>
<p>Each section below names a failure mode, then shows how OpenSequence handles it, with code from the repo.</p>
<h2 id="the-naive-version">The naive version<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#the-naive-version">#</a></h2>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="// every 5 minutes
for (const e of await db.enrollments.due()) {
  await gmail.send(render(e.step, e.contact));
  await db.enrollments.advance(e.id);
}" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#51597D;font-style:italic">// every 5 minutes</span></span>
<span class="line"><span style="color:#BB9AF7">for</span><span style="color:#9ABDF5"> (</span><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#BB9AF7"> e</span><span style="color:#89DDFF"> of</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> db</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">enrollments</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">due</span><span style="color:#9ABDF5">())</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">  await</span><span style="color:#C0CAF5"> gmail</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">send</span><span style="color:#9ABDF5">(</span><span style="color:#7AA2F7">render</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">e</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">step</span><span style="color:#89DDFF">,</span><span style="color:#C0CAF5"> e</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">contact</span><span style="color:#9ABDF5">))</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">  await</span><span style="color:#C0CAF5"> db</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">enrollments</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">advance</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">e</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">}</span></span></code></pre></figure>
<p>What goes wrong with it in the first month:</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Failure</th>
<th>What the prospect sees</th>
</tr>
</thead>
<tbody>
<tr>
<td>The process dies between <code>send</code> and <code>advance</code></td>
<td>The same email twice</td>
</tr>
<tr>
<td>Two replicas run the loop</td>
<td>The same email twice</td>
</tr>
<tr>
<td>A reply arrives at 9:01 for a step due at 9:00</td>
<td>A follow-up to someone who answered</td>
</tr>
<tr>
<td>The contact's time zone is unknown</td>
<td>An email at 3am</td>
</tr>
<tr>
<td>400 enrollments come due at once</td>
<td>A rep's mailbox flagged for bulk sending</td>
</tr>
<tr>
<td>The loop counts an out-of-office reply as a reply</td>
<td>The lead drops out of the sequence</td>
</tr>
<tr>
<td>The loop ignores a bounce</td>
<td>More sends to a dead address and a worse sender reputation</td>
</tr>
<tr>
<td>Someone edits a sequence while people are in it</td>
<td>Contacts stuck on a step that no longer exists</td>
</tr>
</tbody>
</table></div>
<h2 id="scheduling-in-the-recipients-time-zone">Scheduling in the recipient's time zone<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#scheduling-in-the-recipients-time-zone">#</a></h2>
<p>First you need the zone. OpenSequence takes the contact's IANA zone if your app sent one. Failing that, it derives a zone from the phone number, using a NANP area code or a country with one zone. After that it tries a single-zone country and, for email only, the sender's zone. For SMS it does not guess. It skips the step with <code>no_timezone</code>, because the 8:00 to 21:00 calling window in <a href="https://www.ecfr.gov/current/title-47/chapter-I/subchapter-B/part-64/subpart-L/section-64.1200" rel="noopener">47 CFR 64.1200(c)(1)</a> applies in the recipient's local time.</p>
<p>The second problem is daylight saving time. In 2026 Europe falls back on October 25 and the US on November 1, so for one week London is four hours ahead of New York instead of five. A send time stored as "14:00 UTC" goes out at the wrong local hour that week. OpenSequence computes send windows in the contact's zone with <code>@date-fns/tz</code>. In its duration grammar <code>2d</code> is 48 hours, while <code>2bd</code> moves forward two weekdays in the contact's zone and keeps the wall-clock time. One test covers a step due Saturday at 10:00 BST with a weekday window of 08:00 to 18:00. The step sends on Monday at 08:00 GMT, after the clock change.</p>
<p>OpenSequence computes the timing when an enrollment enters a step and checks it again when the step runs, because the window and the caps can change in between. A configurable jitter keeps 300 sends from going out at exactly 09:00.</p>
<h2 id="never-double-send-under-at-least-once-delivery">Never double-send under at-least-once delivery<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#never-double-send-under-at-least-once-delivery">#</a></h2>
<p>Queues deliver at least once. Say a worker crashes after Gmail accepted the email but before your database recorded it. The worker retries, and the prospect gets two copies. OpenSequence prevents this with two layers.</p>
<p>The first layer is ownership. While an executor works on a step, the enrollment's <code>owner_run_id</code> points at that run and <code>next_run_at</code> is null, so the planner cannot see it. Only the executor that finishes the run releases the enrollment. There is no lease that could expire in the middle of a send.</p>
<p>The second layer is an idempotency key on the email itself. Each run has a unique key (<code>seq:&#x3C;enrollment>:&#x3C;node>:a&#x3C;attempt></code>). OpenSequence derives the <code>Message-ID</code> header from that key, and a retry asks Gmail whether the message already went out:</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="export function messageIdFor(env: StepEnv, domain: string): string {
  return &#x60;<${env.run.idempotencyKey.replace(/[^a-z0-9]/gi, &#x27;.&#x27;)}@${domain}>&#x60;;
}

const already = attempt > 1 ? await deps.providers.email.findSent({ channel: channelRef, messageId }) : null;
const sent = already ?? (await deps.providers.email.send({ /* ... */ messageId, headers }));" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#7DCFFF">export</span><span style="color:#BB9AF7"> function</span><span style="color:#7AA2F7"> messageIdFor</span><span style="color:#9ABDF5">(</span><span style="color:#E0AF68">env</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> StepEnv</span><span style="color:#89DDFF">,</span><span style="color:#E0AF68"> domain</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> string</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> string</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">  return</span><span style="color:#89DDFF"> `</span><span style="color:#9ECE6A">&#x3C;</span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">env</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">run</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">idempotencyKey</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">replace</span><span style="color:#9ABDF5">(</span><span style="color:#89DDFF">/</span><span style="color:#E0AF68">[</span><span style="color:#89DDFF">^</span><span style="color:#E0AF68">a-z0-9]</span><span style="color:#89DDFF">/gi</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">.</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5">)</span><span style="color:#7DCFFF">}</span><span style="color:#9ECE6A">@</span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">domain</span><span style="color:#7DCFFF">}</span><span style="color:#9ECE6A">></span><span style="color:#89DDFF">`</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#BB9AF7"> already</span><span style="color:#89DDFF"> =</span><span style="color:#C0CAF5"> attempt</span><span style="color:#BB9AF7"> ></span><span style="color:#FF9E64"> 1</span><span style="color:#BB9AF7"> ?</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> deps</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">providers</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">email</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">findSent</span><span style="color:#9ABDF5">({</span><span style="color:#73DACA"> channel</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> channelRef</span><span style="color:#89DDFF">,</span><span style="color:#C0CAF5"> messageId</span><span style="color:#9ABDF5"> })</span><span style="color:#BB9AF7"> :</span><span style="color:#FF9E64"> null</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#BB9AF7"> sent</span><span style="color:#89DDFF"> =</span><span style="color:#C0CAF5"> already</span><span style="color:#BB9AF7"> ??</span><span style="color:#9ABDF5"> (</span><span style="color:#BB9AF7;font-style:italic">await</span><span style="color:#C0CAF5"> deps</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">providers</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">email</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">send</span><span style="color:#9ABDF5">({</span><span style="color:#51597D;font-style:italic"> /* ... */</span><span style="color:#C0CAF5"> messageId</span><span style="color:#89DDFF">,</span><span style="color:#C0CAF5"> headers</span><span style="color:#9ABDF5"> }))</span><span style="color:#89DDFF">;</span></span></code></pre></figure>
<p><code>findSent</code> is a Gmail search for <code>rfc822msgid:&#x3C;id></code>. Texts have no equivalent key. For an SMS with an unknown outcome, OpenSequence searches the last ten minutes of outbound texts to that number. If that search is inconclusive, the run moves to <code>needs_review</code> instead of retrying. A double text is worse than a late one.</p>
<p>The database also rejects a second active enrollment for the same contact:</p>
<figure class="code-block"><figcaption><span class="code-tag">sql</span><button type="button" class="code-copy" data-copy="CREATE UNIQUE INDEX enrollments_one_active_idx ON seq.enrollments (project_id, contact_id)
  WHERE status IN (&#x27;active&#x27;, &#x27;paused&#x27;, &#x27;waiting_limit&#x27;);" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#89DDFF">CREATE</span><span style="color:#89DDFF"> UNIQUE INDEX</span><span style="color:#7AA2F7"> enrollments_one_active_idx</span><span style="color:#89DDFF"> ON</span><span style="color:#E0AF68"> seq</span><span style="color:#A9B1D6">.</span><span style="color:#E0AF68">enrollments</span><span style="color:#A9B1D6"> (project_id, contact_id)</span></span>
<span class="line"><span style="color:#7DCFFF">  WHERE</span><span style="color:#89DDFF"> status</span><span style="color:#89DDFF"> IN</span><span style="color:#A9B1D6"> (</span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">active</span><span style="color:#89DDFF">'</span><span style="color:#A9B1D6">, </span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">paused</span><span style="color:#89DDFF">'</span><span style="color:#A9B1D6">, </span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">waiting_limit</span><span style="color:#89DDFF">'</span><span style="color:#A9B1D6">);</span></span></code></pre></figure>
<p>With the default Postgres queue, the enqueue is an insert in the same transaction as the run record, so "run recorded" and "message queued" commit together. The <a href="https://docs.opensequence.dev/docs/self-hosting/queues" rel="noopener">queues page</a> covers the Redis case, where a reaper re-enqueues any run that was recorded but never queued.</p>
<h2 id="per-mailbox-caps">Per-mailbox caps<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#per-mailbox-caps">#</a></h2>
<p>Providers publish ceilings. <a href="https://knowledge.workspace.google.com/admin/gmail/gmail-sending-limits-in-google-workspace" rel="noopener">Google Workspace</a> allows 2,000 messages a day per user (500 on trial accounts). <a href="https://learn.microsoft.com/en-us/office365/servicedescriptions/exchange-online-service-description/exchange-online-limits" rel="noopener">Exchange Online</a> allows 10,000 recipients a day and 30 messages a minute per mailbox. The <a href="https://developers.google.com/workspace/gmail/api/reference/quota" rel="noopener">Gmail API</a> charges 100 quota units per <code>messages.send</code> against a budget of 6,000 units per user per minute. Your reply polling draws on the same budget.</p>
<p>Those numbers are ceilings, not targets. A provider can flag a rep's inbox that sends cold email anywhere near them. OpenSequence defaults each email channel to 30 sends per rolling 24 hours with a 60-second minimum gap. You can configure both per sender. When a sender is over the cap, OpenSequence defers the step instead of skipping it, and the enrollment shows <code>waiting_limit</code>. On each tick the planner claims at most 50 due enrollments per sender with <code>FOR UPDATE SKIP LOCKED</code>, and it takes follow-ups before first touches. One rep's 5,000-contact import therefore cannot starve everyone else.</p>
<p>OpenSequence sends through Gmail only today. It does not support Outlook or Microsoft 365 mailboxes. An SMTP and IMAP adapter is on the roadmap.</p>
<h2 id="reply-detection-and-threading">Reply detection and threading<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#reply-detection-and-threading">#</a></h2>
<p>Gmail's <a href="https://developers.google.com/workspace/gmail/api/guides/threads" rel="noopener">threading rules</a> need three things: the <code>threadId</code> on the send request, <code>In-Reply-To</code> and <code>References</code> headers as RFC 5322 defines them, and a matching subject. A <code>reply_in_thread</code> follow-up in OpenSequence sets the first two, and keeping the subject consistent is up to you. <code>In-Reply-To</code> names the enrollment's last email, and <code>References</code> lists every email of the thread so far. OpenSequence takes the Gmail <code>threadId</code> from the thread where it recorded the earlier send.</p>
<p>For inbound mail, the service reads the Gmail history feed from a stored cursor every minute. It keeps only messages in threads it started in the last 30 days. It matches them by thread id, or by finding our <code>Message-ID</code> in the <code>In-Reply-To</code> and <code>References</code> headers (<a href="https://www.rfc-editor.org/rfc/rfc5322#section-3.6.4" rel="noopener">RFC 5322 section 3.6.4</a>). The service stores nothing else from the rep's mailbox, and it stores inbound bodies only if the project turns that on.</p>
<p>An out-of-office reply arrives in the thread and looks like a real reply. OpenSequence treats a message as automatic when it has <code>Auto-Submitted</code> set to anything but <code>no</code> (<a href="https://www.rfc-editor.org/rfc/rfc3834" rel="noopener">RFC 3834</a>), an <code>X-Autoreply</code> or <code>X-Autorespond</code> header, <code>Precedence: auto_reply</code>, or a subject like "Automatic reply" or "Out of office" (with German, French and Spanish variants). An auto-reply never removes the contact from the sequence. Instead, OpenSequence pauses the enrollment until the return date it parses from the subject or snippet. If it finds no date, the pause lasts 7 days.</p>
<h2 id="bounces-and-suppressions">Bounces and suppressions<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#bounces-and-suppressions">#</a></h2>
<p>A bounce is an email from <code>mailer-daemon@</code> or <code>postmaster@</code> with a <code>message/delivery-status</code> part (<a href="https://www.rfc-editor.org/rfc/rfc3464" rel="noopener">RFC 3464</a>). The adapter reads the status code and finds the original by our <code>Message-ID</code> in the attached headers. A <code>5.x.x</code> status is permanent and a <code>4.x.x</code> is transient (<a href="https://www.rfc-editor.org/rfc/rfc3463" rel="noopener">RFC 3463</a>). On a permanent bounce, OpenSequence writes a <code>hard_bounce</code> suppression and exits the contact. A transient bounce writes no suppression.</p>
<p>OpenSequence checks suppressions at enrollment and again at send time, because a contact can opt out in between. The <a href="https://docs.opensequence.dev/docs/contacts/consent-and-suppressions" rel="noopener">consent and suppressions page</a> lists every source.</p>
<h2 id="one-click-unsubscribe-rfc-8058">One-click unsubscribe (RFC 8058)<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#one-click-unsubscribe-rfc-8058">#</a></h2>
<p><a href="https://support.google.com/a/answer/81126" rel="noopener">Google</a> requires one-click unsubscribe from senders of more than 5,000 messages a day to Gmail accounts. It <a href="https://support.google.com/a/answer/14229414" rel="noopener">asks senders to honor requests within 48 hours</a> and says to never reach a 0.3% spam rate. <a href="https://senders.yahooinc.com/best-practices/" rel="noopener">Yahoo</a> asks for the same header and asks senders to honor opt-outs within 2 days. Most reps sending 1:1 stay under the threshold. The header still costs little, and it gives a recipient a way out other than a spam complaint.</p>
<p><a href="https://www.rfc-editor.org/rfc/rfc8058.html" rel="noopener">RFC 8058</a> sets four requirements. The <code>List-Unsubscribe</code> header must carry an HTTPS URI. <code>List-Unsubscribe-Post</code> must be exactly <code>List-Unsubscribe=One-Click</code>. The POST must work with no cookies and no redirect. The DKIM signature must cover both headers. OpenSequence adds both headers to every email:</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="const headers = { &#x27;List-Unsubscribe&#x27;: &#x60;<${unsubscribe}>&#x60;, &#x27;List-Unsubscribe-Post&#x27;: &#x27;List-Unsubscribe=One-Click&#x27; };" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#BB9AF7"> headers</span><span style="color:#89DDFF"> =</span><span style="color:#9ABDF5"> {</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">List-Unsubscribe</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> `</span><span style="color:#9ECE6A">&#x3C;</span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">unsubscribe</span><span style="color:#7DCFFF">}</span><span style="color:#9ECE6A">></span><span style="color:#89DDFF">`</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">List-Unsubscribe-Post</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">List-Unsubscribe=One-Click</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">;</span></span></code></pre></figure>
<p>The problem is link scanners. Many corporate mail filters fetch the URLs in a message, so a GET that unsubscribes removes people who never clicked. OpenSequence's <code>GET /u/{token}</code> only renders a page with a button. Only a POST writes the suppression and exits the enrollment, whether it comes from the button or from the mail client's one-click action. OpenSequence signs the token with HMAC, and the token is valid for 90 days. Once you set up a DKIM key for your Workspace domain, Gmail signs with it. Check that the <code>h=</code> tag in a sent message's <code>DKIM-Signature</code> lists both headers.</p>
<h2 id="stopping-on-a-meeting-or-a-crm-change">Stopping on a meeting or a CRM change<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#stopping-on-a-meeting-or-a-crm-change">#</a></h2>
<p>A sequencer only sees its own channels. OpenSequence detects a meeting when a new event on the sender's connected Google Calendar has the contact as an attendee. It cannot see a booking in another tool, a deal moved to "closed won" or a do-not-contact flag in your CRM. Your app reports those through the <a href="https://docs.opensequence.dev/docs/contacts/events" rel="noopener">contact events API</a>, which is idempotent on your event id. A homegrown sequencer has the same problem, so budget the CRM webhooks either way. The <a href="https://www.opensequence.dev/blog/add-sales-sequences-to-your-crm">CRM integration tutorial</a> shows that code for OpenSequence. OpenSequence does not watch the CRM. A <code>condition</code> step can still read a live value from Attio, such as <code>crm.deal.stage</code>, when it decides a branch.</p>
<p>A reply can also arrive in the second between the last check and the provider call. The enrollment row lock and a last-moment status check catch almost all of these. No design can close the remaining race, so OpenSequence logs those cases instead of hiding them.</p>
<h2 id="editing-a-live-sequence">Editing a live sequence<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#editing-a-live-sequence">#</a></h2>
<p>Someone deletes step 3 while 200 people wait on it. OpenSequence makes published versions immutable, with a trigger that raises an error on any update to a non-draft definition. Each enrollment stays on the version id it started with, and new enrollments use the new default. To move people, you exit and re-enroll them. Drafts use an optimistic lock, so two editors cannot overwrite each other. The <a href="https://docs.opensequence.dev/docs/sequences/versions" rel="noopener">versions page</a> has the details.</p>
<h2 id="call-tasks">Call tasks<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#call-tasks">#</a></h2>
<p>A <code>task</code> node creates a task for the rep and waits. It branches on the outcome your app sends back (<code>connected</code>, <code>voicemail</code>). If the task expires, the node takes the <code>no_answer</code> branch, and by default a task expires 7 days after it is due. With <code>dialer: true</code>, the number goes into the rep's Allo dialer and the call result completes the task. Otherwise your app needs a task inbox.</p>
<h2 id="observability">Observability<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#observability">#</a></h2>
<p>Six weeks from now, someone will ask why Dana got that email, and you need an answer. OpenSequence writes every node execution as a row in an append-only run ledger. The row records the facts a condition read and the branch it took. Logs are Pino JSON with the enrollment and run ids on every line. The health endpoints return queue depths and the last run of each scheduler job. The <a href="https://docs.opensequence.dev/docs/self-hosting/observability" rel="noopener">observability page</a> lists what is missing today: a Prometheus endpoint and a per-channel circuit breaker.</p>
<h2 id="how-long-a-build-takes">How long a build takes<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-long-a-build-takes">#</a></h2>
<p>This is our estimate for one senior engineer who has built queue-backed systems before, with Gmail as the only mailbox provider.</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Piece</th>
<th>Engineer-weeks</th>
</tr>
</thead>
<tbody>
<tr>
<td>Data model, versions, enrollment rules</td>
<td>2</td>
</tr>
<tr>
<td>Scheduler, time zones, send windows</td>
<td>2</td>
</tr>
<tr>
<td>Executors, idempotency, retries</td>
<td>3</td>
</tr>
<tr>
<td>Google OAuth and token refresh</td>
<td>2</td>
</tr>
<tr>
<td>Replies, auto-replies, bounces</td>
<td>3</td>
</tr>
<tr>
<td>Unsubscribe, suppressions, SMS gates</td>
<td>1</td>
</tr>
<tr>
<td>Tasks and a dialer</td>
<td>1.5</td>
</tr>
<tr>
<td>Webhooks to your own app</td>
<td>2</td>
</tr>
<tr>
<td>Run ledger, health, logs</td>
<td>1.5</td>
</tr>
<tr>
<td>Mock providers, concurrency tests</td>
<td>2</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>20 (16 to 24)</strong></td>
</tr>
</tbody>
</table></div>
<p>Most of the range comes from the inbound side, where you keep finding bounce and auto-reply formats your parser does not handle. Each extra mailbox provider adds weeks. If your Google OAuth client is public rather than internal to your Workspace, Google reviews apps that request restricted Gmail scopes. You do not control how long that review takes. After launch, someone has to handle token failures, provider API changes and the occasional stuck enrollment.</p>
<h2 id="when-building-your-own-is-the-right-call">When building your own is the right call<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#when-building-your-own-is-the-right-call">#</a></h2>
<p>Build it when sequencing is your product, or when your volume is tiny. A founder sending 20 emails a day from one inbox can get by with a script and a spreadsheet. Also build it when your rules fall far outside what any tool models and an engineer will own the code for years.</p>
<p>Buy a SaaS tool when nobody on the team can own a service, when reps need a finished UI tomorrow, or when you need Outlook mailboxes, open tracking or A/B tests on content. OpenSequence has none of those last three today. The <a href="https://www.opensequence.dev/blog/replace-your-sales-sequencing-software">migration guide</a> covers moving off a SaaS tool, and the <a href="https://www.opensequence.dev/blog/outreach-vs-salesloft-cost">Outreach vs Salesloft cost breakdown</a> prices the buy side.</p>
<p>Self-host an open source engine when you want to own the data and drop per-seat pricing without spending the 20 weeks. OpenSequence is headless, so you bring the UI. It runs as one container on a Postgres database you own. <a href="https://docs.opensequence.dev/docs/get-started/how-it-works" rel="noopener">How it works</a> is the 10-minute version of this post, and the <a href="https://www.opensequence.dev/blog/cold-email-deliverability-self-hosted">self-hosted deliverability guide</a> covers DNS and warm-up.</p>
<h2 id="faq">FAQ<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#faq">#</a></h2>
<h3 id="how-long-does-it-take-to-build-a-sales-sequencer">How long does it take to build a sales sequencer?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-long-does-it-take-to-build-a-sales-sequencer">#</a></h3>
<p>A script that sends a follow-up after N days takes a few days. A production sequencer with time zones, idempotent sends, reply and bounce detection, one-click unsubscribe and versioning is a bigger job. We estimate about 16 to 24 engineer-weeks for one mailbox provider.</p>
<h3 id="can-i-use-a-cron-job-to-send-follow-up-emails">Can I use a cron job to send follow-up emails?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#can-i-use-a-cron-job-to-send-follow-up-emails">#</a></h3>
<p>For one inbox and a few dozen emails a day, yes. It breaks once you run two workers, retry after a crash, or need to stop the moment someone replies. At that point you need row-level ownership of each step and an idempotency key on each send.</p>
<h3 id="do-cold-emails-need-rfc-8058-one-click-unsubscribe">Do cold emails need RFC 8058 one-click unsubscribe?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#do-cold-emails-need-rfc-8058-one-click-unsubscribe">#</a></h3>
<p>Google's and Yahoo's rules require it for bulk senders, which Google defines as senders of more than 5,000 messages a day to Gmail accounts. Most 1:1 sales email falls under that line. Adding the <code>List-Unsubscribe</code> and <code>List-Unsubscribe-Post</code> headers costs little and turns a likely spam complaint into an unsubscribe.</p>
<h3 id="how-do-i-tell-an-out-of-office-reply-from-a-real-reply">How do I tell an out-of-office reply from a real reply?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-do-i-tell-an-out-of-office-reply-from-a-real-reply">#</a></h3>
<p>Check the <code>Auto-Submitted</code> header from RFC 3834 first, then <code>X-Autoreply</code>, <code>X-Autorespond</code> and <code>Precedence: auto_reply</code>, then the subject line. Some auto-replies set none of these headers, so the subject check is not optional. Pause the sequence on an out-of-office instead of exiting it.</p>
<h3 id="how-many-emails-can-a-google-workspace-user-send-per-day">How many emails can a Google Workspace user send per day?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-many-emails-can-a-google-workspace-user-send-per-day">#</a></h3>
<p>Google's published limit is 2,000 messages a day per user, or 500 on trial accounts. For cold outreach from a rep's own inbox, stay far below that. OpenSequence defaults to 30 a day per sender.</p>
<h3 id="does-opensequence-work-with-outlook-or-microsoft-365">Does OpenSequence work with Outlook or Microsoft 365?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#does-opensequence-work-with-outlook-or-microsoft-365">#</a></h3>
<p>Not today. OpenSequence sends email through Gmail with each rep's Google account, and an SMTP and IMAP adapter is on the roadmap.</p>]]></content:encoded>
    </item>
    <item>
      <title>Cold email deliverability when sequences send from your own stack</title>
      <link>https://www.opensequence.dev/blog/cold-email-deliverability-self-hosted</link>
      <guid isPermaLink="true">https://www.opensequence.dev/blog/cold-email-deliverability-self-hosted</guid>
      <pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>Leo Guerberg</dc:creator>
      <description>Cold email deliverability for self-hosted sequences: Gmail, Yahoo and Outlook sender rules, SPF, DKIM and DMARC records, mailbox caps, bounces and opt-outs.</description>
      <category>deliverability</category>
      <category>cold-email</category>
      <category>dns</category>
      <category>self-hosting</category>
      <content:encoded><![CDATA[<p>Cold email deliverability on your own stack depends on five things. You need a sending domain with SPF, DKIM and DMARC that align with the From address, and a user-reported spam rate under 0.1% that never reaches 0.3%. You also need low per-mailbox volume that grows slowly, a working one-click unsubscribe, and hard bounces that stop all further mail to that address. US law requires only the opt-out, a postal address and honest headers. The rest are mailbox provider rules or plain practice, and each section below cites which.</p>
<p>We build OpenSequence, an open source sequencing engine you run yourself, so read this with that in mind. Near the end, one table splits each item into what the engine does and what you configure. The last table lists the checks to run before the first send.</p>
<h2 id="what-you-take-on-when-you-leave-the-vendor">What you take on when you leave the vendor<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-you-take-on-when-you-leave-the-vendor">#</a></h2>
<p>OpenSequence sends each email through the rep's own Google Workspace mailbox with the Gmail API. Google's servers relay the message, so reverse DNS, TLS and IP reputation are Google's problem, not yours. You own the domain, its DNS records, the list, the volume, the content and the monitoring.</p>
<p>Today the only email provider is Google. Reps on Microsoft 365 cannot send through OpenSequence yet. SMTP and IMAP are on the <a href="https://docs.opensequence.dev/docs/community/roadmap" rel="noopener">roadmap</a> as a later phase, with no date. The engine also has no warm-up, no email verification and no inbox placement testing. If you want those bundled, and nobody on the team will manage DNS and check bounce numbers every week, a hosted cold email tool is the better choice. Our <a href="https://www.opensequence.dev/blog/lemlist-alternative-self-hosted">lemlist alternative post</a> covers that tradeoff.</p>
<h2 id="which-rules-are-law-and-which-are-provider-policy">Which rules are law and which are provider policy<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#which-rules-are-law-and-which-are-provider-policy">#</a></h2>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Rule</th>
<th>Source</th>
<th>Type</th>
<th>Applies to</th>
</tr>
</thead>
<tbody>
<tr>
<td>Opt-out in every commercial email, honored within 10 business days, postal address, true headers</td>
<td><a href="https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business">FTC CAN-SPAM guide</a></td>
<td>US law</td>
<td>All commercial email, B2B included</td>
</tr>
<tr>
<td>No consent needed for company addresses, but identify yourself and offer an opt-out</td>
<td><a href="https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guide-to-pecr/electronic-and-telephone-marketing/electronic-mail-marketing/">ICO guide to PECR</a></td>
<td>UK law</td>
<td>UK corporate subscribers</td>
</tr>
<tr>
<td>SPF or DKIM, PTR, TLS, spam rate under 0.3%</td>
<td><a href="https://support.google.com/a/answer/81126">Google sender guidelines</a></td>
<td>Provider policy</td>
<td>All mail to personal Gmail</td>
</tr>
<tr>
<td>SPF and DKIM, DMARC, alignment, one-click unsubscribe</td>
<td>Google, <a href="https://senders.yahooinc.com/best-practices/">Yahoo</a></td>
<td>Provider policy</td>
<td>Bulk senders</td>
</tr>
<tr>
<td>SPF and DKIM pass, DMARC at least <code>p=none</code>, aligned</td>
<td><a href="https://techcommunity.microsoft.com/blog/outlook/strengthening-email-ecosystem-outlook%E2%80%99s-new-requirements-for-high%E2%80%90volume-senders/4399730">Microsoft</a></td>
<td>Provider policy</td>
<td>5,000+ a day to Outlook.com</td>
</tr>
<tr>
<td>Start low, increase volume slowly</td>
<td>Google sender guidelines</td>
<td>Best practice</td>
<td>Everyone</td>
</tr>
</tbody>
</table></div>
<p>The FTC states that CAN-SPAM "makes no exception for business-to-business email", and its guide puts the penalty at up to $53,088 per violating email. PECR lets you email companies without consent, but sole traders and some partnerships count as individuals and need consent. EU rules differ by country. Get local advice before you email into the EU.</p>
<h2 id="gmail-yahoo-and-outlook-sender-requirements">Gmail, Yahoo and Outlook sender requirements<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#gmail-yahoo-and-outlook-sender-requirements">#</a></h2>
<p>Google's rules took effect on February 1, 2024. Every sender to personal Gmail needs SPF or DKIM, valid forward and reverse DNS, TLS, RFC 5322 formatting, and a spam rate below 0.3%. Google defines a bulk sender as one that sends close to 5,000 messages a day to personal Gmail accounts. Bulk senders also need SPF and DKIM together, a DMARC record, From alignment with SPF or DKIM, and one-click unsubscribe on marketing mail. Google's <a href="https://support.google.com/a/answer/14229414" rel="noopener">FAQ</a> adds three details that matter for cold outreach. Messages from the same primary domain, subdomains included, count toward the 5,000. Once a domain reaches bulk status, it never loses it. And starting November 2025, "Gmail is ramping up its enforcement on non-compliant traffic", with temporary and permanent rejections and a faster timeline for new domains.</p>
<p>Yahoo's list matches Google's and asks bulk senders to honor unsubscribes within 2 days. Yahoo gives no volume number for "bulk". Microsoft's rules for Outlook.com, Hotmail and Live addresses started May 5, 2025, for domains sending over 5,000 a day. Microsoft first planned to route failing mail to Junk. An April 29 update to its announcement switched to rejection, with the error <code>550; 5.7.515 Access denied, sending domain [SendingDomain] does not meet the required authentication level.</code></p>
<p>Two exceptions matter for B2B. Google's FAQ says the guidelines "don't apply to messages sent to Google Workspace accounts". Microsoft's rules cover the consumer service only, not Microsoft 365. Most B2B prospects use exactly those business mailboxes. Meet the published rules anyway. Your lists contain personal Gmail and Outlook addresses, and business inboxes still filter spam. These are also the only numbers any provider publishes.</p>
<h2 id="the-03-spam-rate-and-postmaster-tools">The 0.3% spam rate and Postmaster Tools<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#the-03-spam-rate-and-postmaster-tools">#</a></h2>
<p>Google asks senders to keep the spam rate in Postmaster Tools below 0.1% and to never reach 0.3%. A bulk sender above 0.3% loses access to Google's mitigation until the rate stays under 0.3% for 7 consecutive days.</p>
<p>Postmaster Tools has two limits for a sales team. Its data "only applies to messages sent to personal Gmail accounts", and "data might be missing if the total number of messages for a given day is too low" (<a href="https://support.google.com/mail/answer/9981691" rel="noopener">Postmaster Tools help</a>). Five reps at 30 emails a day, mostly to Workspace addresses, will often see empty charts. Register the domain anyway. Day to day, watch the numbers you do control: bounces per step, opt-outs, and replies that say "stop emailing me".</p>
<h2 id="spf-dkim-and-dmarc-for-a-secondary-sending-domain">SPF, DKIM and DMARC for a secondary sending domain<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#spf-dkim-and-dmarc-for-a-secondary-sending-domain">#</a></h2>
<p>Send cold email from a separate registered domain, not your main one and not a subdomain of it. Google counts subdomains toward the primary domain, so <code>mail.acme.com</code> counts as <code>acme.com</code> for bulk sender status. If <code>getacme.com</code> gets blocked, invoices and support replies from <code>acme.com</code> still arrive. The cost is one extra Workspace user per sending mailbox, plus a domain that recipients do not recognize. A Workspace user costs $7 per month on Business Starter with an annual plan as of October 2026 (<a href="https://workspace.google.com/pricing" rel="noopener">pricing</a>). Redirect the new domain's website to your main site, so a prospect who types it in reaches your real site.</p>
<p>Add <code>getacme.com</code> to Workspace, create the reps' sending mailboxes on it, and publish these records:</p>
<figure class="code-block"><figcaption><span class="code-tag">dns</span><button type="button" class="code-copy" data-copy="; SPF: only Google may send for getacme.com
getacme.com.                      TXT  &#x22;v=spf1 include:_spf.google.com ~all&#x22;

; DKIM: generate a 2048-bit key in Admin console > Apps > Google Workspace > Gmail > Authenticate email
google._domainkey.getacme.com.    TXT  &#x22;v=DKIM1; k=rsa; p=MIIBIjANBgkqh...IDAQAB&#x22;

; DMARC: monitor first, aggregate reports go to the main domain
_dmarc.getacme.com.               TXT  &#x22;v=DMARC1; p=none; rua=mailto:dmarc@acme.com; adkim=r; aspf=r&#x22;

; on acme.com: lets getacme.com send its DMARC reports there (RFC 7489, section 7.1)
getacme.com._report._dmarc.acme.com.  TXT  &#x22;v=DMARC1&#x22;" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span>; SPF: only Google may send for getacme.com</span></span>
<span class="line"><span>getacme.com.                      TXT  "v=spf1 include:_spf.google.com ~all"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>; DKIM: generate a 2048-bit key in Admin console > Apps > Google Workspace > Gmail > Authenticate email</span></span>
<span class="line"><span>google._domainkey.getacme.com.    TXT  "v=DKIM1; k=rsa; p=MIIBIjANBgkqh...IDAQAB"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>; DMARC: monitor first, aggregate reports go to the main domain</span></span>
<span class="line"><span>_dmarc.getacme.com.               TXT  "v=DMARC1; p=none; rua=mailto:dmarc@acme.com; adkim=r; aspf=r"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>; on acme.com: lets getacme.com send its DMARC reports there (RFC 7489, section 7.1)</span></span>
<span class="line"><span>getacme.com._report._dmarc.acme.com.  TXT  "v=DMARC1"</span></span></code></pre></figure>
<p>The SPF record follows <a href="https://knowledge.workspace.google.com/admin/security/set-up-spf" rel="noopener">Google's SPF guide</a>. Publish exactly one SPF record per domain and stay under the 10 DNS lookup limit in <a href="https://www.rfc-editor.org/rfc/rfc7208#section-4.6.4" rel="noopener">RFC 7208</a>. The DKIM selector <code>google</code> and the 2048-bit key come from <a href="https://knowledge.workspace.google.com/admin/security/set-up-dkim" rel="noopener">Google's DKIM guide</a>. After DNS propagates, click "Start authentication" in the Admin console. The external report record on <code>acme.com</code> is the easy one to miss. Without it, receivers do not send the aggregate reports to the <code>acme.com</code> address.</p>
<p><code>p=none</code> meets the Google, Yahoo and Microsoft minimum. Microsoft recommends <code>p=reject</code>. Once a few weeks of reports show only Google as a source, move to <code>p=quarantine</code>.</p>
<h3 id="alignment">Alignment<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#alignment">#</a></h3>
<p>DMARC passes when the From domain matches the domain SPF checked or the <code>d=</code> domain of a valid DKIM signature. Relaxed mode (<code>adkim=r</code>, <code>aspf=r</code>) accepts a match on the organizational domain. Send a test from a rep's mailbox to a personal Gmail account and open "Show original". SPF, DKIM and DMARC should all say PASS, and the DKIM line should name <code>getacme.com</code>.</p>
<h2 id="one-click-unsubscribe-and-11-sales-email">One-click unsubscribe and 1:1 sales email<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#one-click-unsubscribe-and-11-sales-email">#</a></h2>
<p>Google requires one-click unsubscribe "only for marketing and promotional messages", and adds that "message recipients, not Google, determine the nature of the messages they receive." A cold sales email is promotional to the person receiving it. The requirement binds only bulk senders. But a recipient with no unsubscribe link has only the spam button left, and every spam report counts against you.</p>
<p><a href="https://www.rfc-editor.org/rfc/rfc8058.html" rel="noopener">RFC 8058</a> defines the mechanism. <code>List-Unsubscribe</code> must contain an HTTPS URI, <code>List-Unsubscribe-Post</code> must read <code>List-Unsubscribe=One-Click</code>, and the DKIM signature must cover both headers. The POST carries no cookies, and the server must not answer it with a redirect.</p>
<p>OpenSequence adds both headers to every email it sends, and <code>{{ unsubscribe_url }}</code> renders the same signed link in the body. A click or a mail client's POST writes an email suppression and exits the enrollment at once, well inside Google's 48 hours and CAN-SPAM's 10 business days. If a body lacks the link, the sequence still publishes, with a <code>NO_UNSUBSCRIBE</code> warning instead of an error. Some teams drop the visible link to keep a 1:1 tone and rely on the headers plus a "reply 'no' and I'll stop" line. CAN-SPAM accepts a reply-based opt-out, so that choice is defensible. Google's bulk rules ask for a visible link. The details are in <a href="https://docs.opensequence.dev/docs/contacts/consent-and-suppressions" rel="noopener">consent and suppressions</a>.</p>
<p>The links point at <code>SEQ_PUBLIC_URL</code>, which must be public HTTPS (<a href="https://docs.opensequence.dev/docs/self-hosting/install" rel="noopener">install guide</a>). The engine signs each link with the first key in <code>SEQ_ENCRYPTION_KEYS</code>, and the link stays valid for 90 days. Rotating that key breaks the links in every email already sent. CAN-SPAM requires a working opt-out for 30 days after sending.</p>
<h2 id="volume-per-mailbox-and-ramp-up">Volume per mailbox and ramp-up<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#volume-per-mailbox-and-ramp-up">#</a></h2>
<p>Workspace caps a user at 2,000 messages and 2,000 unique external recipients a day, over a rolling 24 hours, and 500 on a trial account (<a href="https://knowledge.workspace.google.com/admin/gmail/gmail-sending-limits-in-google-workspace" rel="noopener">sending limits</a>). Those caps exist to stop abuse. Plan well below them. Google's only ramp guidance is to "start with a low sending volume to engaged users, and slowly increase the volume over time."</p>
<p>OpenSequence defaults each sender's email channel to 30 sends in a rolling 24 hours, with at least 60 seconds between sends. Follow-ups count against the same cap. The ramp below is our practice, not a provider rule:</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Week</th>
<th><code>daily_limit</code> per mailbox</th>
<th>Move on when</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>10</td>
<td>Bounces under 2%, no angry replies</td>
</tr>
<tr>
<td>2</td>
<td>20</td>
<td>Same, plus some replies</td>
</tr>
<tr>
<td>3</td>
<td>30</td>
<td>Same</td>
</tr>
<tr>
<td>4+</td>
<td>40 to 50</td>
<td>Replies hold steady</td>
</tr>
</tbody>
</table></div>
<p>To change the cap, send <code>PATCH /v1/senders/{id}/channels/email</code> with <code>{"daily_limit": 20}</code> (<a href="https://docs.opensequence.dev/docs/senders/senders" rel="noopener">senders</a>). The enrollment preview staggers first touches by that cap, so at 30 a day, contact 31 starts the next day (<a href="https://docs.opensequence.dev/docs/enrollments/enrolling" rel="noopener">enrolling</a>). The API stores <code>weekly_limit</code>, but the executors do not enforce it yet.</p>
<h2 id="tracking-pixels-and-link-tracking">Tracking pixels and link tracking<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#tracking-pixels-and-link-tracking">#</a></h2>
<p>OpenSequence v1 has no open or click tracking. Emails go out as plain text by default, with no pixel and no rewritten links. Click tracking rewrites every link to a redirect on another domain, so filters see a second domain in every message. Opens stopped being a reliable signal in 2021, when Apple's <a href="https://www.apple.com/newsroom/2021/06/apple-advances-its-privacy-leadership-with-ios-15-ipados-15-macos-monterey-and-watchos-8/" rel="noopener">Mail Privacy Protection</a> began helping users "prevent senders from knowing when they open an email."</p>
<p>You lose open-based branching and open alerts. Measure replies and meetings per 100 delivered emails instead. The analytics endpoints return both per sequence and per step. If you need click data, link to a page on your own domain with UTM parameters and read it in your web analytics.</p>
<h2 id="bounces-complaints-and-list-hygiene">Bounces, complaints and list hygiene<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#bounces-complaints-and-list-hygiene">#</a></h2>
<p>The engine polls each sender's Gmail every minute. A message from <code>mailer-daemon</code> or <code>postmaster</code> with a delivery-status part counts as a bounce, and the engine matches it to the original by <code>Message-ID</code>. A 5.x.x status is a hard bounce. It suppresses the address for email and exits the enrollment with <code>bounced</code>. A 4.x.x soft bounce suppresses nothing.</p>
<p>The engine does not detect complaints. It handles a <code>complaint</code> signal, but the Gmail adapter never produces one, because Gmail gives individual senders no per-message complaint feed. Postmaster Tools is the only complaint view you get.</p>
<p>The engine does not verify addresses. Run your list through a verification service before you enroll. A new domain has no sending history to offset the bounces from a bad list. Report do-not-contact flags from your CRM as <code>suppressed</code> events (<a href="https://docs.opensequence.dev/docs/contacts/events" rel="noopener">contact events</a>). The engine only sees its own channels, so a contact marked "do not email" in your CRM keeps getting mail until you report it. Our rule of thumb is to stop email for the project when a batch bounces above 2%, then clean the list before turning email back on. To stop it, send <code>PUT /v1/engine/flags/channel:email</code> with <code>{"value": false}</code>.</p>
<h2 id="what-opensequence-does-and-what-you-configure">What OpenSequence does and what you configure<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-opensequence-does-and-what-you-configure">#</a></h2>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Item</th>
<th>OpenSequence</th>
<th>You</th>
</tr>
</thead>
<tbody>
<tr>
<td>PTR, TLS, IP reputation</td>
<td>Sends via Gmail API on Google's servers</td>
<td>Nothing</td>
</tr>
<tr>
<td>SPF, DKIM, DMARC</td>
<td>Nothing</td>
<td>DNS records, DKIM in the Admin console</td>
</tr>
<tr>
<td>One-click unsubscribe</td>
<td>Headers on every email, hosted page, publish warning</td>
<td>Public HTTPS <code>SEQ_PUBLIC_URL</code>, stable keys</td>
</tr>
<tr>
<td>Honoring opt-outs</td>
<td>Suppress and exit at once, checked at every send</td>
<td>Report CRM opt-outs</td>
</tr>
<tr>
<td>Volume</td>
<td><code>daily_limit</code>, <code>min_gap_seconds</code>, staggered first touches</td>
<td>The ramp</td>
</tr>
<tr>
<td>Send times</td>
<td><code>settings.send_window</code> in the contact's zone</td>
<td>Set the window</td>
</tr>
<tr>
<td>Hard bounces</td>
<td>Detects them in Gmail, suppresses and exits</td>
<td>Verify lists first</td>
</tr>
<tr>
<td>Spam complaints</td>
<td>Not detected</td>
<td>Postmaster Tools</td>
</tr>
<tr>
<td>Tracking</td>
<td>None</td>
<td>UTM links if needed</td>
</tr>
<tr>
<td>Postal address</td>
<td>Nothing</td>
<td>Put it in the template</td>
</tr>
</tbody>
</table></div>
<h2 id="pre-launch-checklist">Pre-launch checklist<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#pre-launch-checklist">#</a></h2>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Check</th>
<th>How to confirm</th>
</tr>
</thead>
<tbody>
<tr>
<td>Secondary domain, website redirects to main site</td>
<td>Open it in a browser</td>
</tr>
<tr>
<td>One SPF record with <code>include:_spf.google.com</code></td>
<td><code>dig TXT getacme.com</code></td>
</tr>
<tr>
<td>DKIM 2048-bit, authentication started</td>
<td><code>dig TXT google._domainkey.getacme.com</code></td>
</tr>
<tr>
<td>DMARC <code>p=none</code> with <code>rua</code>, report record on main domain</td>
<td><code>dig TXT _dmarc.getacme.com</code></td>
</tr>
<tr>
<td>Test email shows SPF, DKIM, DMARC PASS</td>
<td>"Show original" in Gmail</td>
</tr>
<tr>
<td>Domain added to Postmaster Tools</td>
<td>Verified in the console</td>
</tr>
<tr>
<td><code>SEQ_PUBLIC_URL</code> serves the opt-out page over HTTPS</td>
<td>Click a test link, then POST to it</td>
</tr>
<tr>
<td>Every email body has <code>{{ unsubscribe_url }}</code> or a reply opt-out</td>
<td>No <code>NO_UNSUBSCRIBE</code> at publish</td>
</tr>
<tr>
<td>Postal address in the signature</td>
<td>Read a rendered test</td>
</tr>
<tr>
<td><code>daily_limit</code> at 10 for week one</td>
<td><code>GET /v1/senders/{id}</code></td>
</tr>
<tr>
<td>Send window set</td>
<td>Read the sequence settings</td>
</tr>
<tr>
<td>List verified, CRM do-not-contacts reported</td>
<td>Suppressions on <code>GET /v1/contacts/{id}</code></td>
</tr>
</tbody>
</table></div>
<p>For the rest of the move, see the <a href="https://www.opensequence.dev/blog/replace-your-sales-sequencing-software">guide to replacing your sales sequencing software</a> and <a href="https://www.opensequence.dev/blog/build-your-own-sales-sequencer">what building your own sequencer involves</a>.</p>
<h2 id="faq">FAQ<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#faq">#</a></h2>
<h3 id="do-gmails-bulk-sender-rules-apply-to-cold-email-sent-to-business-addresses">Do Gmail's bulk sender rules apply to cold email sent to business addresses?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#do-gmails-bulk-sender-rules-apply-to-cold-email-sent-to-business-addresses">#</a></h3>
<p>Not formally. Google says its sender guidelines do not apply to messages sent to Google Workspace accounts, and Microsoft's rules cover Outlook.com consumer addresses only. Most lists still include personal addresses, and business inboxes still filter spam, so meet the bulk rules anyway.</p>
<h3 id="is-one-click-unsubscribe-required-for-cold-sales-emails">Is one-click unsubscribe required for cold sales emails?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#is-one-click-unsubscribe-required-for-cold-sales-emails">#</a></h3>
<p>Google requires it from bulk senders for marketing and promotional mail, and says the recipient decides what counts as promotional. CAN-SPAM requires some opt-out in every commercial email, which can be a reply-based one. The List-Unsubscribe headers cost nothing, so send them on every message.</p>
<h3 id="how-many-cold-emails-can-one-mailbox-send-per-day">How many cold emails can one mailbox send per day?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-many-cold-emails-can-one-mailbox-send-per-day">#</a></h3>
<p>Workspace allows up to 2,000 messages a day per user, but that is an abuse limit. A new mailbox should start near 10 a day and grow to 30 to 50 over a month, counting follow-ups. Add mailboxes rather than raising the cap past that.</p>
<h3 id="should-i-send-cold-email-from-my-main-domain">Should I send cold email from my main domain?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#should-i-send-cold-email-from-my-main-domain">#</a></h3>
<p>No. Use a separate registered domain, because Google counts a subdomain toward your main domain's bulk sender status. If the cold domain gets blocked, your main domain's invoices and support mail keep arriving.</p>
<h3 id="does-dmarc-pnone-satisfy-the-sender-requirements">Does DMARC p=none satisfy the sender requirements?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#does-dmarc-pnone-satisfy-the-sender-requirements">#</a></h3>
<p>Yes. Google, Yahoo and Microsoft all accept <code>p=none</code> as the minimum, as long as SPF or DKIM aligns with the From domain. Microsoft recommends <code>p=reject</code>, so move to <code>p=quarantine</code> once your reports show only Google sending for the domain.</p>]]></content:encoded>
    </item>
    <item>
      <title>When Customer.io works for sales outreach, when it breaks, and how to split the job</title>
      <link>https://www.opensequence.dev/blog/customer-io-for-sales-sequences</link>
      <guid isPermaLink="true">https://www.opensequence.dev/blog/customer-io-for-sales-sequences</guid>
      <pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>Leo Guerberg</dc:creator>
      <description>Customer.io handles lifecycle email to people who signed up. Its policy bans cold lists, and it cannot send from a rep's Gmail or stop on a reply.</description>
      <category>customer-io</category>
      <category>sales-engagement</category>
      <category>product-led-growth</category>
      <content:encoded><![CDATA[<p>Customer.io works for sales outreach only when the recipient signed up for your product and the email can come from your brand. It breaks when a rep needs to send from their own Gmail, when the sequence has to stop on a reply, or when the rep needs a call or LinkedIn task. Cold outreach is not a judgment call. Customer.io's acceptable use policy forbids purchased, scraped and third-party lists.</p>
<p>We build OpenSequence, an open source sales sequencing engine, so read this with that in mind. We also think most product-led teams should keep Customer.io and split the work between two tools. The rest of this post shows which jobs go where and how to connect the two.</p>
<h2 id="which-tool-for-which-job">Which tool for which job<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#which-tool-for-which-job">#</a></h2>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Job</th>
<th>Customer.io</th>
<th>A sales sequencer</th>
</tr>
</thead>
<tbody>
<tr>
<td>Onboarding and activation email</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Product-triggered nurture to users</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Push, in-app, newsletters</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>1:1 email from a rep's Gmail</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Stop when the person replies</td>
<td>No native email replies</td>
<td>Yes</td>
</tr>
<tr>
<td>Call and LinkedIn tasks for a rep</td>
<td>No task object</td>
<td>Yes</td>
</tr>
<tr>
<td>Per-rep ownership and send caps</td>
<td>No sender object</td>
<td>Yes</td>
</tr>
<tr>
<td>Cold outreach to non-users</td>
<td>Forbidden by its acceptable use policy</td>
<td>Yes, with care</td>
</tr>
</tbody>
</table></div>
<h2 id="what-customerio-does-well">What Customer.io does well<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-customerio-does-well">#</a></h2>
<p>Customer.io is a messaging automation platform. You send it people and events through its SDKs, the Track API or Data Pipelines. An automation starts on an event, a segment or a schedule. That model fits lifecycle messaging. A user who created a workspace and never invited a teammate gets the "invite your team" email on day 3. A trial ending in 48 hours triggers an email and a push.</p>
<p>Its exit conditions cover the common cases too. An automation can <a href="https://docs.customer.io/messaging/send/automations/exit-conditions/" rel="noopener">exit people</a> when a profile attribute changes, when they perform an event, or when they join a segment. So "stop the onboarding drip once they invite a teammate" is a checkbox, not code.</p>
<p>The pricing also fits lifecycle volume. As of October 2026, the <a href="https://customer.io/pricing" rel="noopener">Essentials plan</a> starts at $100 a month for 5,000 profiles and includes 1 million emails. Push, in-app and webhooks are unlimited on every tier. If the job is "talk to the people using our product," keep it in Customer.io. We would not move that work.</p>
<h2 id="where-it-breaks-for-sales">Where it breaks for sales<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#where-it-breaks-for-sales">#</a></h2>
<h3 id="the-email-does-not-come-from-the-reps-mailbox">The email does not come from the rep's mailbox<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#the-email-does-not-come-from-the-reps-mailbox">#</a></h3>
<p>Customer.io can put the account owner's address in the From field. Its docs give that example. <a href="https://docs.customer.io/messaging/channels/email/headers/multiple-from-addresses/" rel="noopener">A Liquid statement as the from address</a> resolves to each person's account manager and has a fallback for people who have none. The address has to resolve to a domain you verified in Customer.io.</p>
<p>That gets the right name on the email. It does not send from the rep's mailbox. The message leaves from Customer.io's servers or from a <a href="https://docs.customer.io/messaging/channels/email/deliverability/custom-smtp/use-your-smtp-server/" rel="noopener">custom SMTP server</a> you configure for the workspace. You can add up to six SMTP servers, but you assign each one to an automation type, either segment triggered, event triggered or transactional, and not to a person. The email never shows up in the rep's Sent folder. The rep's follow-up is a new message rather than a reply in a thread they own.</p>
<p>A sales email from <code>max@acme.com</code> sent through a marketing pipeline reads like marketing to the prospect. Their reply goes to an inbox Customer.io never reads.</p>
<h3 id="nothing-stops-on-a-reply">Nothing stops on a reply<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#nothing-stops-on-a-reply">#</a></h3>
<p>Customer.io handles inbound messages for SMS and WhatsApp. As of October 2026 its messaging docs have no inbound pages for email. A reply goes to whatever address is in From or Reply-To, and Customer.io does not see it. The workaround on its community forum has four parts: a per-person Reply-To alias, a forwarding service like Zapier, a lookup that matches the sender to a profile, and a custom event that an exit condition listens for.</p>
<p>You can build that. But "stop following up when they answer" is the most important rule in sales email, and here it depends on four parts you maintain. When the Zapier step fails without an error, the prospect who said "yes, Thursday works" gets your day 5 bump anyway.</p>
<h3 id="calls-and-linkedin-are-tasks-for-a-person">Calls and LinkedIn are tasks for a person<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#calls-and-linkedin-are-tasks-for-a-person">#</a></h3>
<p>A real sales sequence has steps a machine does not perform: call this person tomorrow, send a LinkedIn invitation, call again if they did not pick up. Customer.io has no task object for a rep. Its <a href="https://docs.customer.io/messaging/send/workflows/webhooks/action/" rel="noopener">webhook action</a> can push a task into your CRM. Your CRM then has to send the outcome, such as "voicemail", "connected" or "wrong number", back to Customer.io as an event before the automation can branch on it. You end up building a task queue next to Customer.io and keeping two state machines in sync.</p>
<h3 id="there-is-no-rep-in-the-model">There is no rep in the model<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#there-is-no-rep-in-the-model">#</a></h3>
<p>Customer.io models people and objects. Sales sequencing also needs a sender: the rep a contact belongs to, the rep's mailbox and its daily cap, the rep's tasks. When a rep leaves, you need to know which running sequences were theirs. When an account executive takes over an account, the next email should come from them. Customer.io can store <code>account_owner</code> as an attribute and template it into From. It has no record that holds the rep's mailbox, caps it at 30 cold emails a day, or lists the rep's open calls.</p>
<h3 id="cold-outreach-violates-the-acceptable-use-policy">Cold outreach violates the acceptable use policy<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#cold-outreach-violates-the-acceptable-use-policy">#</a></h3>
<p>This one is not about features. Customer.io's <a href="https://customer.io/legal/acceptable-use-policy" rel="noopener">Acceptable Use Policy</a>, revised November 2024, says emails "must be sent only to recipients who have explicitly opted in to receive content related to your brand." It adds that opt-ins "must be express and specific to your brand." It then lists prohibited recipient sources. You may not contact people whose information:</p>
<ul>
<li>was harvested or scraped from websites</li>
<li>originates from purchased lists, "regardless of opt-in status"</li>
<li>has been inactive or unengaged for two years or longer</li>
<li>was obtained from partners or third-party organizations</li>
</ul>
<p>An Apollo export, a Clay table and a conference attendee list all fall into those categories. The policy also states that an account "will be terminated if the user sends unsolicited email messages." A user-generated spam complaint rate at or above 0.1% over 30 days can lead to suspension. Sending to addresses obtained any other way than an opt-in "may incur a $100 (US) charge per substantiated incident (i.e. per email)."</p>
<p>Braze, Iterable and Loops are the same kind of product, a platform for messaging your own users. Read their terms before you upload a prospect list to any of them. The risk is losing the channel your onboarding email depends on.</p>
<h3 id="prospects-count-as-billable-profiles">Prospects count as billable profiles<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#prospects-count-as-billable-profiles">#</a></h3>
<p>Pricing is the weakest argument here, but it is real. Customer.io bills by profiles, and <a href="https://docs.customer.io/accounts/billing/how-we-bill/" rel="noopener">it counts every profile that existed at any point in the billing period</a>, including ones you deleted. As of October 2026 Essentials costs $100 for 5,000 profiles, $145 for 10,000, $235 for 20,000 and $325 for 30,000, with overage at $0.009 per profile. Load 15,000 sales prospects into a 5,000-user workspace and you move from the $100 tier to the $235 tier, even if you delete them a week later. The difference is small, and you pay it for people you should not email from Customer.io anyway.</p>
<h2 id="keep-customerio-for-lifecycle-run-sales-next-to-it">Keep Customer.io for lifecycle, run sales next to it<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#keep-customerio-for-lifecycle-run-sales-next-to-it">#</a></h2>
<p>In the setup we recommend, Customer.io keeps sending every message to users, and a separate sales sequencer handles the cases where a rep should get involved. Both tools get the product events your app already emits.</p>
<p>This is how it works with OpenSequence. Each rep is a sender who connects their Google account once (<a href="https://docs.opensequence.dev/docs/senders/connect-google" rel="noopener">Connect Google</a>). Emails leave from their Gmail, and follow-ups can reply in the same thread. The engine polls that mailbox for replies in the threads it started. A sequence can mix email, a wait, a call task and a LinkedIn task. An opt-out or a hard bounce always exits the enrollment. A reply or a booking on the rep's calendar exits it when the sequence lists that event in <code>exit_on</code>.</p>
<h3 id="1-decide-in-your-code-who-gets-a-human">1. Decide in your code who gets a human<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#1-decide-in-your-code-who-gets-a-human">#</a></h3>
<p>Your backend already sends <code>track</code> calls to Customer.io. The same code can apply a sales rule, for example "a workspace on a trial invited 3 or more teammates." OpenSequence has no auto-enrollment rules today. They are on the roadmap. Until then the rule lives in your code, which is where the definition of a product-qualified lead belongs anyway.</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="import { OpenSequence, OpenSequenceError } from &#x27;@opensequence/sdk&#x27;;

const oseq = new OpenSequence({ baseUrl: process.env.SEQ_URL!, apiKey: process.env.SEQ_KEY! });

export async function onTeammatesInvited(user: User, workspace: Workspace) {
  if (workspace.plan !== &#x27;trial&#x27; || workspace.invitedCount < 3) return;

  try {
    await oseq.sequences.enroll(process.env.PQL_SEQUENCE_ID!, {
      contacts: [{
        external_id: user.id,
        first_name: user.firstName,
        email: user.email,
        timezone: user.timezone,
        attrs: { workspace: { name: workspace.name, seats: workspace.invitedCount } },
      }],
      sender: workspace.accountOwnerId, // the rep&#x27;s id in your app
      enrolled_by_kind: &#x27;system&#x27;,
    });
  } catch (err) {
    // One active sequence per contact: a rep already has this person.
    if (err instanceof OpenSequenceError &#x26;&#x26; err.is(&#x27;ALREADY_IN_SEQUENCE&#x27;)) return;
    throw err;
  }
}" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#7DCFFF">import</span><span style="color:#9ABDF5"> { </span><span style="color:#0DB9D7">OpenSequence</span><span style="color:#89DDFF">,</span><span style="color:#0DB9D7"> OpenSequenceError</span><span style="color:#9ABDF5"> }</span><span style="color:#7DCFFF"> from</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">@opensequence/sdk</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#BB9AF7"> oseq</span><span style="color:#89DDFF"> =</span><span style="color:#89DDFF"> new</span><span style="color:#7AA2F7"> OpenSequence</span><span style="color:#9ABDF5">({</span><span style="color:#73DACA"> baseUrl</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> process</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">env</span><span style="color:#89DDFF">.</span><span style="color:#FF9E64">SEQ_URL</span><span style="color:#BB9AF7">!</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> apiKey</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> process</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">env</span><span style="color:#89DDFF">.</span><span style="color:#FF9E64">SEQ_KEY</span><span style="color:#BB9AF7">!</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#7DCFFF">export</span><span style="color:#9D7CD8;font-style:italic"> async</span><span style="color:#BB9AF7"> function</span><span style="color:#7AA2F7"> onTeammatesInvited</span><span style="color:#9ABDF5">(</span><span style="color:#E0AF68">user</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> User</span><span style="color:#89DDFF">,</span><span style="color:#E0AF68"> workspace</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> Workspace</span><span style="color:#9ABDF5">)</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#BB9AF7">  if</span><span style="color:#9ABDF5"> (</span><span style="color:#C0CAF5">workspace</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">plan</span><span style="color:#BB9AF7"> !==</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">trial</span><span style="color:#89DDFF">'</span><span style="color:#BB9AF7"> ||</span><span style="color:#C0CAF5"> workspace</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">invitedCount</span><span style="color:#BB9AF7"> &#x3C;</span><span style="color:#FF9E64"> 3</span><span style="color:#9ABDF5">) </span><span style="color:#BB9AF7;font-style:italic">return</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#BB9AF7">  try</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">    await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">sequences</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">enroll</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">process</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">env</span><span style="color:#89DDFF">.</span><span style="color:#FF9E64">PQL_SEQUENCE_ID</span><span style="color:#BB9AF7">!</span><span style="color:#89DDFF">,</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#73DACA">      contacts</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> [{</span></span>
<span class="line"><span style="color:#73DACA">        external_id</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> user</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">        first_name</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> user</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">firstName</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">        email</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> user</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">email</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">        timezone</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> user</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">timezone</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">        attrs</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> { </span><span style="color:#41A6B5">workspace</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> { </span><span style="color:#41A6B5">name</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> workspace</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">name</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> seats</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> workspace</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">invitedCount</span><span style="color:#9ABDF5"> } }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">      }]</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">      sender</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> workspace</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">accountOwnerId</span><span style="color:#89DDFF">,</span><span style="color:#51597D;font-style:italic"> // the rep's id in your app</span></span>
<span class="line"><span style="color:#73DACA">      enrolled_by_kind</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">system</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">    })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">  } </span><span style="color:#BB9AF7">catch</span><span style="color:#9ABDF5"> (</span><span style="color:#C0CAF5">err</span><span style="color:#9ABDF5">) {</span></span>
<span class="line"><span style="color:#51597D;font-style:italic">    // One active sequence per contact: a rep already has this person.</span></span>
<span class="line"><span style="color:#BB9AF7">    if</span><span style="color:#9ABDF5"> (</span><span style="color:#C0CAF5">err</span><span style="color:#89DDFF"> instanceof</span><span style="color:#C0CAF5"> OpenSequenceError</span><span style="color:#BB9AF7"> &#x26;&#x26;</span><span style="color:#C0CAF5"> err</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">is</span><span style="color:#9ABDF5">(</span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">ALREADY_IN_SEQUENCE</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5">)) </span><span style="color:#BB9AF7;font-style:italic">return</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7">    throw</span><span style="color:#C0CAF5"> err</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">  }</span></span>
<span class="line"><span style="color:#9ABDF5">}</span></span></code></pre></figure>
<p>The enroll call upserts the inline contact before it creates the enrollment, so you need no separate sync job. It also refuses a contact who is already in any active sequence. That check keeps two reps from emailing the same trial user (<a href="https://docs.opensequence.dev/docs/enrollments/enrolling" rel="noopener">Enrolling</a>).</p>
<p>You could call the enroll endpoint from a Customer.io webhook action instead. We would not. Customer.io retries a webhook that returns 409 up to 11 times over about an hour, and here a 409 is a correct answer, not a failure. A ten-line handler in your backend can catch the 409 and return, as the code above does.</p>
<h3 id="2-pause-customerios-drips-while-a-rep-works-the-account">2. Pause Customer.io's drips while a rep works the account<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#2-pause-customerios-drips-while-a-rep-works-the-account">#</a></h3>
<p>A trial user in a sales sequence should not also get the generic "upgrade now" drip. Register an OpenSequence webhook for <code>enrollment.created</code> and <code>enrollment.exited</code> (<a href="https://docs.opensequence.dev/docs/webhooks/overview" rel="noopener">Receiving webhooks</a>) and copy the state onto the Customer.io profile:</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="import express from &#x27;express&#x27;;
import { verifyWebhook } from &#x27;@opensequence/sdk&#x27;;

const CIO_TRACK_AUTH = Buffer.from(&#x60;${process.env.CIO_SITE_ID}:${process.env.CIO_API_KEY}&#x60;).toString(&#x27;base64&#x27;);

app.post(&#x27;/hooks/seq&#x27;, express.raw({ type: &#x27;*/*&#x27; }), async (req, res) => {
  const event = verifyWebhook({ secret: process.env.SEQ_WEBHOOK_SECRET!, headers: req.headers, body: req.body });
  const { enrollment } = event.data as { enrollment: { contact: string } };
  const contact = await oseq.contacts.get(enrollment.contact);

  await fetch(&#x60;https://track.customer.io/api/v1/customers/${contact.externalId}&#x60;, {
    method: &#x27;PUT&#x27;,
    headers: { Authorization: &#x60;Basic ${CIO_TRACK_AUTH}&#x60;, &#x27;Content-Type&#x27;: &#x27;application/json&#x27; },
    body: JSON.stringify({ in_sales_sequence: event.type === &#x27;enrollment.created&#x27; }),
  });
  res.sendStatus(204);
});" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#7DCFFF">import</span><span style="color:#0DB9D7"> express</span><span style="color:#7DCFFF"> from</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">express</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#7DCFFF">import</span><span style="color:#9ABDF5"> { </span><span style="color:#0DB9D7">verifyWebhook</span><span style="color:#9ABDF5"> }</span><span style="color:#7DCFFF"> from</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">@opensequence/sdk</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#BB9AF7"> CIO_TRACK_AUTH</span><span style="color:#89DDFF"> =</span><span style="color:#C0CAF5"> Buffer</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">from</span><span style="color:#9ABDF5">(</span><span style="color:#89DDFF">`</span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">process</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">env</span><span style="color:#89DDFF">.</span><span style="color:#FF9E64">CIO_SITE_ID</span><span style="color:#7DCFFF">}</span><span style="color:#9ECE6A">:</span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">process</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">env</span><span style="color:#89DDFF">.</span><span style="color:#FF9E64">CIO_API_KEY</span><span style="color:#7DCFFF">}</span><span style="color:#89DDFF">`</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">toString</span><span style="color:#9ABDF5">(</span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">base64</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C0CAF5">app</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">post</span><span style="color:#9ABDF5">(</span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">/hooks/seq</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#C0CAF5"> express</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">raw</span><span style="color:#9ABDF5">({</span><span style="color:#73DACA"> type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">*/*</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">,</span><span style="color:#9D7CD8;font-style:italic"> async</span><span style="color:#9ABDF5"> (</span><span style="color:#E0AF68">req</span><span style="color:#89DDFF">,</span><span style="color:#E0AF68"> res</span><span style="color:#9ABDF5">)</span><span style="color:#BB9AF7"> =></span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">  const</span><span style="color:#BB9AF7"> event</span><span style="color:#89DDFF"> =</span><span style="color:#7AA2F7"> verifyWebhook</span><span style="color:#9ABDF5">({ </span><span style="color:#73DACA">secret</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> process</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">env</span><span style="color:#89DDFF">.</span><span style="color:#FF9E64">SEQ_WEBHOOK_SECRET</span><span style="color:#BB9AF7">!</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> headers</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> req</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">headers</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> body</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> req</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">body</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">  const</span><span style="color:#89DDFF"> {</span><span style="color:#BB9AF7"> enrollment</span><span style="color:#89DDFF"> }</span><span style="color:#89DDFF"> =</span><span style="color:#C0CAF5"> event</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">data</span><span style="color:#89DDFF"> as</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">enrollment</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">contact</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> string</span><span style="color:#9ABDF5"> } }</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">  const</span><span style="color:#BB9AF7"> contact</span><span style="color:#89DDFF"> =</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">contacts</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">get</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">enrollment</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">contact</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">  await</span><span style="color:#7AA2F7"> fetch</span><span style="color:#9ABDF5">(</span><span style="color:#89DDFF">`</span><span style="color:#9ECE6A">https://track.customer.io/api/v1/customers/</span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">contact</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">externalId</span><span style="color:#7DCFFF">}</span><span style="color:#89DDFF">`</span><span style="color:#89DDFF">,</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#73DACA">    method</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">PUT</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    headers</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">Authorization</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> `</span><span style="color:#9ECE6A">Basic </span><span style="color:#7DCFFF">${</span><span style="color:#FF9E64">CIO_TRACK_AUTH</span><span style="color:#7DCFFF">}</span><span style="color:#89DDFF">`</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Content-Type</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">application/json</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    body</span><span style="color:#89DDFF">:</span><span style="color:#0DB9D7"> JSON</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">stringify</span><span style="color:#9ABDF5">({ </span><span style="color:#73DACA">in_sales_sequence</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> event</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">type</span><span style="color:#BB9AF7"> ===</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">enrollment.created</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">  })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#C0CAF5">  res</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">sendStatus</span><span style="color:#9ABDF5">(</span><span style="color:#FF9E64">204</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">})</span><span style="color:#89DDFF">;</span></span></code></pre></figure>
<p>In Customer.io, add <code>in_sales_sequence is true</code> as an exit condition on the upgrade and nurture automations, and as a filter on their triggers. Nobody gets the generic drip while a rep is working the account. OpenSequence delivers each webhook at least once, in order per enrollment, so a handler that sets a boolean is safe to run twice.</p>
<h3 id="3-send-events-back-to-opensequence">3. Send events back to OpenSequence<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#3-send-events-back-to-opensequence">#</a></h3>
<p>Some product events should end the sales sequence. If the user upgrades on their own, exit their enrollment with <code>oseq.enrollments.exitMany({ contact: user.id, label: 'self-serve upgrade' })</code>. If they unsubscribe in Customer.io, or your CRM marks them do-not-contact, report it as a contact event so OpenSequence suppresses them (<a href="https://docs.opensequence.dev/docs/contacts/events" rel="noopener">Contact events</a>). The engine only sees its own channels, so you have to report anything that happens elsewhere.</p>
<p>When someone opts out from a sales email, OpenSequence sends a <code>signal.received</code> webhook. Whether that also unsubscribes the person from product email is a policy decision for you and your counsel. Copying it to Customer.io's <code>unsubscribed</code> attribute is the conservative default.</p>
<h3 id="what-this-costs-you">What this costs you<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-this-costs-you">#</a></h3>
<p>OpenSequence is headless. It has no inbox UI for reps, so you build the task list in your app or CRM (<a href="https://docs.opensequence.dev/docs/enrollments/tasks" rel="noopener">Tasks</a>). You also run one container plus Postgres (<a href="https://docs.opensequence.dev/docs/self-hosting/install" rel="noopener">Install</a>). Today it sends email through Gmail only. SMTP and IMAP are on the roadmap. The project has not reached 1.0. If those trade-offs are wrong for you, the same split works with a hosted sequencer. Our <a href="https://www.opensequence.dev/blog/replace-your-sales-sequencing-software">guide to replacing your sales sequencing software</a> compares the options, and <a href="https://www.opensequence.dev/blog/build-your-own-sales-sequencer">building your own sequencer</a> covers what you take on if you write the engine yourself.</p>
<h2 id="when-customerio-alone-is-enough">When Customer.io alone is enough<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#when-customerio-alone-is-enough">#</a></h2>
<p>Stay on Customer.io alone if all of these are true:</p>
<ul>
<li>Every person you email signed up for your product, and none of the outreach is cold.</li>
<li>A brand address, or an account manager's name on a verified domain, is acceptable to the reader.</li>
<li>You do not need the email to stop the instant someone replies, because a reply goes to a human who handles it.</li>
<li>There are no call or LinkedIn steps, or reps handle those outside any sequence.</li>
<li>One person, usually a founder, does all the selling, so per-rep ownership and caps do not matter yet.</li>
</ul>
<p>Plenty of early product-led companies fit that description. A "talk to the founder" email from Customer.io with the founder's name in From and a working Reply-To is fine. Add a sales sequencer when you hire the first rep whose job is to follow up with specific accounts, or the first time you want to email someone who has not signed up. That second case is also when deliverability becomes your problem rather than Customer.io's, so read up on <a href="https://www.opensequence.dev/blog/cold-email-deliverability-self-hosted">cold email deliverability on your own infrastructure</a> first.</p>
<h2 id="faq">FAQ<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#faq">#</a></h2>
<h3 id="can-i-use-customerio-for-cold-email">Can I use Customer.io for cold email?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#can-i-use-customerio-for-cold-email">#</a></h3>
<p>No. Its Acceptable Use Policy requires an express, brand-specific opt-in and prohibits purchased, scraped and third-party lists, "regardless of opt-in status." It says Customer.io will terminate accounts that send unsolicited email, and it reserves a $100 charge per substantiated incident.</p>
<h3 id="can-customerio-send-from-each-sales-reps-email-address">Can Customer.io send from each sales rep's email address?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#can-customerio-send-from-each-sales-reps-email-address">#</a></h3>
<p>It can put the rep's address in the From header with Liquid, as long as you verified the domain in Customer.io. The email still leaves through Customer.io or a workspace SMTP server, not the rep's own mailbox. It does not appear in their Sent folder, and replies do not come back to Customer.io.</p>
<h3 id="does-customerio-detect-email-replies">Does Customer.io detect email replies?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#does-customerio-detect-email-replies">#</a></h3>
<p>Not for email, as of October 2026. It handles inbound SMS and WhatsApp. The common workaround for email routes a Reply-To alias through a service like Zapier. Zapier sends an event back to Customer.io, and an exit condition acts on it.</p>
<h3 id="what-is-the-difference-between-marketing-automation-and-sales-engagement">What is the difference between marketing automation and sales engagement?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-is-the-difference-between-marketing-automation-and-sales-engagement">#</a></h3>
<p>Marketing automation sends one brand's messages to many opted-in people, triggered by their behavior. Sales engagement runs a rep's personal outreach to specific people from the rep's mailbox. It adds tasks for calls and LinkedIn, and it stops when a human answers. The data model differs too. Marketing tools model the recipient. Sales tools model the rep and the recipient together.</p>
<h3 id="what-is-a-good-customerio-alternative-for-sales-sequences">What is a good Customer.io alternative for sales sequences?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-is-a-good-customerio-alternative-for-sales-sequences">#</a></h3>
<p>You probably do not need to replace Customer.io. Run sales sequences in a separate tool, either a hosted sequencer or a self-hosted engine like OpenSequence. Send the same product events to both, and sync a flag so the two never email the same person at once.</p>
<h3 id="how-much-does-customerio-cost-in-october-2026">How much does Customer.io cost in October 2026?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-much-does-customerio-cost-in-october-2026">#</a></h3>
<p>Essentials starts at $100 a month for 5,000 profiles and 1 million emails, billed monthly, with overage at $0.009 per profile and $0.12 per 1,000 emails. Premium starts at $1,000 a month billed yearly, and Enterprise is custom. Companies that have raised less than $10M can get the first year free through its startup program.</p>]]></content:encoded>
    </item>
    <item>
      <title>A self-hosted lemlist alternative, and the teams that should stay on lemlist</title>
      <link>https://www.opensequence.dev/blog/lemlist-alternative-self-hosted</link>
      <guid isPermaLink="true">https://www.opensequence.dev/blog/lemlist-alternative-self-hosted</guid>
      <pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>Leo Guerberg</dc:creator>
      <description>lemlist's Email plan is $55 a month flat and Multichannel is $87 per user. When your own Gmail and Postgres sequences beat it, and when they do not.</description>
      <category>lemlist</category>
      <category>cold-email</category>
      <category>self-hosting</category>
      <category>pricing</category>
      <content:encoded><![CDATA[<p>A self-hosted lemlist alternative runs the sequencing engine on your own servers. Emails go out from each rep's own Google Workspace mailbox, LinkedIn steps become tasks a rep does by hand, and every send, reply and bounce becomes a row in a Postgres database you own. We make one, OpenSequence, which is open source under Apache-2.0, so read this page with that in mind.</p>
<p>As of October 2026, lemlist's Email plan costs $55 a month, billed yearly, for unlimited users and 50,000 emails. That is about what hosting your own engine costs. Self-hosting saves money only against lemlist's Multichannel plan, which costs $87 per user per month billed yearly. So the reason to leave lemlist is rarely the email bill. It is where the data lives, who sets the limits, and whether sequencing should run inside your own CRM or product.</p>
<h2 id="what-lemlist-does-well">What lemlist does well<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-lemlist-does-well">#</a></h2>
<p>lemlist's strongest feature is personalization. Its editor makes a custom image for each lead from a template. Text and lead-photo layers fill in <code>{{firstName}}</code> or <code>{{companyName}}</code>, or the editor pulls a per-lead image URL from a CSV column (<a href="https://help.lemlist.com/en/articles/4494113-create-an-image-template" rel="noopener">lemlist help: create an image template</a>). OpenSequence does not do that.</p>
<p>Deliverability tooling is the second strength. lemlist includes lemwarm, its warm-up network, in every plan, according to the FAQ on the <a href="https://www.lemlist.com/pricing" rel="noopener">pricing page</a>. The plans also list a deliverability hub, inbox rotation, a custom tracking domain, DNS setup tests and deliverability alerts. lemlist connects Google, Microsoft and SMTP mailboxes.</p>
<p>The Multichannel plan adds SMS, an in-app dialer with a power dialer, and LinkedIn automation that covers profile visits, follows, invites, text and voice messages, and InMail. Every plan includes a database of more than 650 million leads and an email and phone finder. The comparison table also lists a unified inbox and AI detection of reply intent and out-of-office replies. A team that wants to find leads, email them and handle the replies in one tab gets all of that in one subscription.</p>
<h2 id="lemlist-pricing-as-of-october-2026">lemlist pricing as of October 2026<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#lemlist-pricing-as-of-october-2026">#</a></h2>
<p>We took these prices from <a href="https://www.lemlist.com/pricing" rel="noopener">lemlist.com/pricing</a> in October 2026. All prices are in USD.</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Plan</th>
<th>Monthly billing</th>
<th>Yearly billing</th>
<th>Billed per</th>
<th>Senders</th>
</tr>
</thead>
<tbody>
<tr>
<td>Email, 50,000 emails a month</td>
<td>$69</td>
<td>$55</td>
<td>account</td>
<td>unlimited</td>
</tr>
<tr>
<td>Email, 100,000 emails a month</td>
<td>$89</td>
<td>$71</td>
<td>account</td>
<td>unlimited</td>
</tr>
<tr>
<td>Email, 200,000 emails a month</td>
<td>$159</td>
<td>$127</td>
<td>account</td>
<td>unlimited</td>
</tr>
<tr>
<td>Multichannel</td>
<td>$109</td>
<td>$87</td>
<td>user</td>
<td>5 per user</td>
</tr>
<tr>
<td>Enterprise</td>
<td>custom</td>
<td>custom</td>
<td>custom</td>
<td>5 or more per user</td>
</tr>
</tbody>
</table></div>
<p>lemlist prices the Email plan by volume, with unlimited users and senders. The usual per-seat argument for self-hosting does not apply to it. Multichannel is per user, so its price grows with headcount. Data costs extra. Lead enrichment starts at $20 a month for 2,000 credits, and buying-intent signals start at $29 a month for 100 signals. Both prices are for monthly billing.</p>
<h2 id="what-changes-when-you-self-host">What changes when you self-host<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-changes-when-you-self-host">#</a></h2>
<p>OpenSequence is one Docker image and a Postgres database, version 14 or later. You register senders and contacts, publish sequences as versioned JSON, and enroll people over a REST API, the TypeScript SDK or the MCP server. Signed webhooks tell your app when someone replies, books a meeting or bounces. There is no UI. Your CRM or internal tool is the UI. The <a href="https://docs.opensequence.dev/docs/self-hosting/install" rel="noopener">install guide</a> covers a production setup.</p>
<h3 id="you-bring-your-own-mailboxes-and-today-they-must-be-google">You bring your own mailboxes, and today they must be Google<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#you-bring-your-own-mailboxes-and-today-they-must-be-google">#</a></h3>
<p>lemlist also sends through your mailboxes, so the mailbox bill stays the same. What changes is who holds the connection. Each OpenSequence project registers its own Google OAuth client, and each rep consents once to Gmail and Calendar access (<a href="https://docs.opensequence.dev/docs/senders/connect-google" rel="noopener">Connect Google</a>). The Gmail API sends each email as the rep, and follow-ups appear in the same thread as the first email.</p>
<p>OpenSequence sends through Gmail only. It does not support Microsoft 365, and SMTP and IMAP adapters are in the "later" phase of the <a href="https://docs.opensequence.dev/docs/community/roadmap" rel="noopener">roadmap</a>. If your reps work in Outlook, stop here and stay on lemlist.</p>
<p>Google classifies Gmail scopes as "sensitive". If every sender is in your own Workspace, an internal OAuth app needs no review. If any sender is outside your domain, Google has to verify the app before the consent screen stops showing them a warning.</p>
<h3 id="you-own-the-sending-limits">You own the sending limits<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#you-own-the-sending-limits">#</a></h3>
<p>Every sender's email channel has a <code>daily_limit</code>, which defaults to 30 sends in a rolling 24 hours, and a <code>min_gap_seconds</code>, which defaults to 60. When a sender reaches the cap, the step waits an hour and the enrollment shows <code>waiting_limit</code>. Each sequence also has a send window in the contact's time zone. You change a rep's limits with one call (<a href="https://docs.opensequence.dev/docs/senders/senders" rel="noopener">Senders</a>):</p>
<figure class="code-block"><figcaption><span class="code-tag">bash</span><button type="button" class="code-copy" data-copy="curl -X PATCH $SEQ/v1/senders/u_42/channels/email \
  -H &#x22;Authorization: Bearer $KEY&#x22; -H &#x22;Content-Type: application/json&#x22; \
  -d &#x27;{&#x22;daily_limit&#x22;:50,&#x22;min_gap_seconds&#x22;:90}&#x27;" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#C0CAF5">curl</span><span style="color:#E0AF68"> -X</span><span style="color:#9ECE6A"> PATCH</span><span style="color:#C0CAF5"> $SEQ</span><span style="color:#9ECE6A">/v1/senders/u_42/channels/email</span><span style="color:#89DDFF"> \</span></span>
<span class="line"><span style="color:#E0AF68">  -H</span><span style="color:#89DDFF"> "</span><span style="color:#9ECE6A">Authorization: Bearer </span><span style="color:#C0CAF5">$KEY</span><span style="color:#89DDFF">"</span><span style="color:#E0AF68"> -H</span><span style="color:#89DDFF"> "</span><span style="color:#9ECE6A">Content-Type: application/json</span><span style="color:#89DDFF">"</span><span style="color:#89DDFF"> \</span></span>
<span class="line"><span style="color:#E0AF68">  -d</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">{"daily_limit":50,"min_gap_seconds":90}</span><span style="color:#89DDFF">'</span></span></code></pre></figure>
<p>Enrollment spreads the first emails according to that cap. If you enroll 300 contacts for a rep with a cap of 30 a day, the first emails go out over ten days (<a href="https://docs.opensequence.dev/docs/enrollments/enrolling" rel="noopener">Enrolling</a>). Google's own limit is higher, at 2,000 messages a day per Workspace user and 500 on a trial account (<a href="https://knowledge.workspace.google.com/admin/gmail/gmail-sending-limits-in-google-workspace" rel="noopener">Gmail sending limits</a>). The cap that protects your domain is the number you set. No vendor algorithm changes it. The channel also stores a <code>weekly_limit</code>, but the executors do not enforce it yet.</p>
<h3 id="warm-up-is-your-job">Warm-up is your job<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#warm-up-is-your-job">#</a></h3>
<p>OpenSequence is not a warm-up service and runs no warm-up network. A new mailbox on a new domain has no reputation, and a sequencer cannot give it one. You can buy warm-up separately. lemwarm sells standalone at $29 per mailbox per month, or $24 billed yearly, according to <a href="https://www.lemwarm.com/pricing" rel="noopener">lemwarm.com/pricing</a>. Or you can raise the volume by hand. We would start a new mailbox at a <code>daily_limit</code> of 10 and raise it each week while bounces stay low and Postmaster Tools shows no complaints. Our guide to <a href="https://www.opensequence.dev/blog/cold-email-deliverability-self-hosted">cold email deliverability when you self-host</a> covers the DNS records and the ramp.</p>
<p>Google sets the rules you must meet. Every sender to Gmail needs SPF or DKIM and a spam rate below 0.3% in Postmaster Tools. Senders of more than 5,000 messages a day to Gmail also need SPF, DKIM and DMARC, and one-click unsubscribe (<a href="https://support.google.com/a/answer/81126" rel="noopener">Google's email sender guidelines</a>). Every OpenSequence email includes <code>List-Unsubscribe</code> and <code>List-Unsubscribe-Post</code> headers at any volume. An opt-out, a hard bounce or a complaint suppresses the contact and exits the enrollment (<a href="https://docs.opensequence.dev/docs/contacts/consent-and-suppressions" rel="noopener">Consent and suppressions</a>).</p>
<h3 id="linkedin-becomes-a-task-again">LinkedIn becomes a task again<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#linkedin-becomes-a-task-again">#</a></h3>
<p>This is the largest functional gap against lemlist Multichannel. OpenSequence does not automate LinkedIn. A LinkedIn step is a task. The rep gets "Connect with Dana on LinkedIn" with the profile link and a due date, does it by hand, and completes the task with an outcome. A <code>linkedin_connect</code> task completed as <code>accepted</code> resumes any step waiting on the connection. A <code>linkedin_message</code> task completed as <code>replied</code> counts as a reply in exits and analytics (<a href="https://docs.opensequence.dev/docs/senders/connect-linkedin" rel="noopener">LinkedIn steps</a>).</p>
<p>We chose tasks because section 8.2 of LinkedIn's <a href="https://www.linkedin.com/legal/user-agreement" rel="noopener">User Agreement</a> forbids using "bots or other unauthorized automated methods to access the Services, add or download contacts, send or redirect messages". The account at risk is the rep's own profile. If we ship an automated adapter, it will be off by default behind a <code>channel:linkedin_auto</code> flag. It will need a written opt-in per sender, cap invitations per day and per week, and pause on any restriction signal. Anyone who turns it on still accepts that LinkedIn can restrict the rep's account. None of it ships today.</p>
<h3 id="the-data-is-in-your-postgres">The data is in your Postgres<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#the-data-is-in-your-postgres">#</a></h3>
<p>Every enrollment, run, message, signal and task is a row in the <code>seq</code> schema of your database (<a href="https://docs.opensequence.dev/docs/self-hosting/database" rel="noopener">Database</a>). The API and the webhooks are the supported contract. The tables can change between minor versions before 1.0. For an ad hoc question, run a read-only query. This one counts replies and bounces per rep over the last 30 days:</p>
<figure class="code-block"><figcaption><span class="code-tag">sql</span><button type="button" class="code-copy" data-copy="select s.name,
       count(*) as enrolled,
       count(*) filter (where e.replied_at is not null) as replied,
       count(*) filter (where e.exit_reason = &#x27;bounced&#x27;) as bounced
from seq.enrollments e
join seq.senders s on s.id = e.sender_id
where e.mode = &#x27;live&#x27;
  and e.enrolled_at > now() - interval &#x27;30 days&#x27;
group by s.name
order by bounced desc;" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#7DCFFF">select</span><span style="color:#E0AF68"> s</span><span style="color:#A9B1D6">.</span><span style="color:#E0AF68">name</span><span style="color:#A9B1D6">,</span></span>
<span class="line"><span style="color:#0DB9D7">       count</span><span style="color:#A9B1D6">(</span><span style="color:#89DDFF">*</span><span style="color:#A9B1D6">) </span><span style="color:#89DDFF">as</span><span style="color:#A9B1D6"> enrolled,</span></span>
<span class="line"><span style="color:#0DB9D7">       count</span><span style="color:#A9B1D6">(</span><span style="color:#89DDFF">*</span><span style="color:#A9B1D6">) </span><span style="color:#89DDFF">filter</span><span style="color:#A9B1D6"> (</span><span style="color:#7DCFFF">where</span><span style="color:#E0AF68"> e</span><span style="color:#A9B1D6">.</span><span style="color:#E0AF68">replied_at</span><span style="color:#89DDFF"> is not null</span><span style="color:#A9B1D6">) </span><span style="color:#89DDFF">as</span><span style="color:#A9B1D6"> replied,</span></span>
<span class="line"><span style="color:#0DB9D7">       count</span><span style="color:#A9B1D6">(</span><span style="color:#89DDFF">*</span><span style="color:#A9B1D6">) </span><span style="color:#89DDFF">filter</span><span style="color:#A9B1D6"> (</span><span style="color:#7DCFFF">where</span><span style="color:#E0AF68"> e</span><span style="color:#A9B1D6">.</span><span style="color:#E0AF68">exit_reason</span><span style="color:#BB9AF7"> =</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">bounced</span><span style="color:#89DDFF">'</span><span style="color:#A9B1D6">) </span><span style="color:#89DDFF">as</span><span style="color:#A9B1D6"> bounced</span></span>
<span class="line"><span style="color:#7DCFFF">from</span><span style="color:#E0AF68"> seq</span><span style="color:#A9B1D6">.</span><span style="color:#E0AF68">enrollments</span><span style="color:#A9B1D6"> e</span></span>
<span class="line"><span style="color:#7DCFFF">join</span><span style="color:#E0AF68"> seq</span><span style="color:#A9B1D6">.</span><span style="color:#E0AF68">senders</span><span style="color:#A9B1D6"> s </span><span style="color:#89DDFF">on</span><span style="color:#E0AF68"> s</span><span style="color:#A9B1D6">.</span><span style="color:#E0AF68">id</span><span style="color:#BB9AF7"> =</span><span style="color:#E0AF68"> e</span><span style="color:#A9B1D6">.</span><span style="color:#E0AF68">sender_id</span></span>
<span class="line"><span style="color:#7DCFFF">where</span><span style="color:#E0AF68"> e</span><span style="color:#A9B1D6">.</span><span style="color:#E0AF68">mode</span><span style="color:#BB9AF7"> =</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">live</span><span style="color:#89DDFF">'</span></span>
<span class="line"><span style="color:#7DCFFF">  and</span><span style="color:#E0AF68"> e</span><span style="color:#A9B1D6">.</span><span style="color:#E0AF68">enrolled_at</span><span style="color:#BB9AF7"> ></span><span style="color:#89DDFF"> now</span><span style="color:#9ABDF5">()</span><span style="color:#89DDFF"> -</span><span style="color:#A9B1D6"> interval </span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">30 days</span><span style="color:#89DDFF">'</span></span>
<span class="line"><span style="color:#7DCFFF">group by</span><span style="color:#E0AF68"> s</span><span style="color:#A9B1D6">.</span><span style="color:#E0AF68">name</span></span>
<span class="line"><span style="color:#7DCFFF">order by</span><span style="color:#A9B1D6"> bounced </span><span style="color:#89DDFF">desc</span><span style="color:#A9B1D6">;</span></span></code></pre></figure>
<p>The service reads only the Gmail threads it started, and it stores reply bodies only when the project sets <code>store_inbound_bodies</code>. No third party holds a copy of your prospect list or your reps' replies.</p>
<h2 id="cost-for-3-10-and-25-senders">Cost for 3, 10 and 25 senders<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#cost-for-3-10-and-25-senders">#</a></h2>
<p>A sender here is one rep with one Google Workspace mailbox. We assume each rep sends at most 60 emails per working day, so even 25 reps stay under 33,000 emails a month. That fits inside lemlist's smallest Email tier. Prices are per month, billed yearly where the vendor offers it, as of October 2026.</p>
<p>We sized the hosting ourselves on <a href="https://render.com/pricing" rel="noopener">Render</a>. A 2 GB web service at $25 runs the API and the workers in one container, and a 1 GB Postgres costs $19. At 25 senders we move to a 2 GB Postgres at $40. These are estimates, not benchmarks. Mailboxes are Google Workspace Business Starter at its list price of $7 per user per month on an annual commitment (<a href="https://workspace.google.com/pricing" rel="noopener">Google Workspace pricing</a>). You pay for mailboxes with lemlist too.</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Per month</th>
<th>3 senders</th>
<th>10 senders</th>
<th>25 senders</th>
</tr>
</thead>
<tbody>
<tr>
<td>lemlist Email</td>
<td>$55</td>
<td>$55</td>
<td>$55</td>
</tr>
<tr>
<td>lemlist Multichannel</td>
<td>$261</td>
<td>$870</td>
<td>$2,175</td>
</tr>
<tr>
<td>OpenSequence hosting</td>
<td>$44</td>
<td>$44</td>
<td>$65</td>
</tr>
<tr>
<td>Workspace mailboxes, any option</td>
<td>$21</td>
<td>$70</td>
<td>$175</td>
</tr>
</tbody>
</table></div>
<p>For email-only outbound, lemlist's Email plan costs about the same as hosting your own engine: $55 a month against $44 to $65. Once you add engineer time, self-hosting does not pay for itself on price there.</p>
<p>Against Multichannel, the gap at 10 reps is $826 a month, or about $9,900 a year. At 25 reps it is $2,110 a month. If you count mailboxes instead of reps, the gap shrinks. If 5 reps hold 5 mailboxes each, those 25 mailboxes need only 5 Multichannel users, or $435 a month.</p>
<p>The table leaves out engineering time. Someone has to deploy the container, register the Google OAuth client, set up DNS, connect each rep, and build the screens where reps see their tasks and enroll people. At $100 an hour, the $826 gap at 10 reps pays for about 8 hours a month. If your reps already work in a CRM or internal tool you control, that may be enough. If not, budget for building one. SMS and dialer calls go through <a href="https://withallo.com" rel="noopener">Allo</a>, which bills separately and is not in the table.</p>
<h2 id="what-you-lose-by-leaving-lemlist">What you lose by leaving lemlist<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-you-lose-by-leaving-lemlist">#</a></h2>
<ul>
<li>A user interface. OpenSequence is headless, so you build the screens.</li>
<li>Personalized images and lemlist's template library.</li>
<li>Open and click tracking. OpenSequence counts replies and meetings only.</li>
<li>A/B tests on content. A <code>split</code> node is on the roadmap, not in the code.</li>
<li>Warm-up, inbox rotation and the deliverability hub.</li>
<li>Microsoft 365 and SMTP mailboxes.</li>
<li>LinkedIn automation, LinkedIn voice notes and WhatsApp.</li>
<li>The lead database, the email finder and enrichment credits.</li>
<li>A unified inbox. Replies stay in each rep's Gmail, and OpenSequence only detects them and exits the sequence.</li>
</ul>
<p>In return, OpenSequence gives you things lemlist does not. Sequences are versioned JSON behind an API, so your product or an agent can create sequences and enroll contacts from code. Conditions can read a contact's open deal from Attio at decision time. A contact can be in only one active sequence per project. An enroll call that would break that rule fails with a <code>409</code> that names the conflict, so two reps never sequence the same person. OpenSequence stores the facts behind every branch decision.</p>
<h2 id="who-should-stay-on-lemlist">Who should stay on lemlist<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#who-should-stay-on-lemlist">#</a></h2>
<p>lemlist is the better choice in any of these cases:</p>
<ul>
<li>Your reps send from Outlook or an SMTP server. OpenSequence cannot send through those mailboxes today.</li>
<li>Email is your only channel. $55 a month for unlimited users is hard to beat with any server bill.</li>
<li>LinkedIn automation brings in a large share of your pipeline, and your reps accept the account risk.</li>
<li>Nobody on the team will own a container, a database and an OAuth client.</li>
<li>You have no CRM or internal tool to act as the UI.</li>
<li>Image personalization or the lead database is the reason you bought lemlist.</li>
</ul>
<p>Self-hosting fits a narrower team. That team has engineers on staff and Google Workspace mailboxes, triggers sequences from its own CRM or product, and pays for several reps on Multichannel whose LinkedIn steps could be manual. If that is you, the <a href="https://www.opensequence.dev/blog/replace-your-sales-sequencing-software">migration guide for replacing your sequencing software</a> covers the move. If you are weighing writing the engine yourself, read <a href="https://www.opensequence.dev/blog/build-your-own-sales-sequencer">what it takes to build your own sales sequencer</a> first. If you also pay Apollo for data, our <a href="https://www.opensequence.dev/blog/apollo-alternatives">Apollo alternatives</a> post covers that part.</p>
<h2 id="faq">FAQ<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#faq">#</a></h2>
<h3 id="is-there-an-open-source-lemlist-alternative">Is there an open source lemlist alternative?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#is-there-an-open-source-lemlist-alternative">#</a></h3>
<p>Yes. OpenSequence is open source under Apache-2.0, and you host it yourself. It covers the sequencing part: multi-step email from each rep's Gmail, SMS and call tasks through Allo, LinkedIn steps as manual tasks, and reply, bounce and opt-out detection. It does not include lead data, warm-up, image personalization or a user interface.</p>
<h3 id="how-much-does-lemlist-cost-in-2026">How much does lemlist cost in 2026?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-much-does-lemlist-cost-in-2026">#</a></h3>
<p>As of October 2026, the Email plan starts at $69 a month, or $55 billed yearly, for 50,000 emails with unlimited users and senders. Multichannel is $109 per user per month, or $87 billed yearly, with 5 senders per user. Enterprise pricing is custom. Data add-ons start at $20 a month for 2,000 enrichment credits.</p>
<h3 id="can-i-use-lemwarm-without-lemlist">Can I use lemwarm without lemlist?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#can-i-use-lemwarm-without-lemlist">#</a></h3>
<p>Yes. lemwarm sells standalone on <a href="https://www.lemwarm.com/pricing" rel="noopener">lemwarm.com/pricing</a>. As of October 2026, Essential is $29 per mailbox per month, or $24 billed yearly, and Smart is $49, or $40 billed yearly. lemlist also includes lemwarm in every lemlist plan.</p>
<h3 id="is-linkedin-automation-against-linkedins-terms">Is LinkedIn automation against LinkedIn's terms?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#is-linkedin-automation-against-linkedins-terms">#</a></h3>
<p>LinkedIn's User Agreement, section 8.2, forbids bots and other unauthorized automated methods that add contacts or send messages. LinkedIn can restrict the account that does it, which is the rep's own profile. That is why OpenSequence turns LinkedIn steps into tasks a person performs.</p>
<h3 id="does-self-hosted-cold-email-software-improve-deliverability">Does self-hosted cold email software improve deliverability?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#does-self-hosted-cold-email-software-improve-deliverability">#</a></h3>
<p>Not by itself. Inbox placement depends on the domain's and the mailbox's reputation, SPF, DKIM and DMARC, volume and complaint rates. Self-hosting gives you exact control over daily caps and gaps per mailbox, but it adds no warm-up. You still have to ramp up new mailboxes and watch Postmaster Tools.</p>
<h3 id="do-i-need-google-workspace-to-self-host-opensequence">Do I need Google Workspace to self-host OpenSequence?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#do-i-need-google-workspace-to-self-host-opensequence">#</a></h3>
<p>For email, yes. The email channel sends through the Gmail API with each rep's consent, and meeting detection reads Google Calendar. SMTP and IMAP support is on the roadmap for a later phase, with no date.</p>]]></content:encoded>
    </item>
    <item>
      <title>Outreach vs Salesloft pricing in 2026, and what a self-hosted sequencer costs instead</title>
      <link>https://www.opensequence.dev/blog/outreach-vs-salesloft-cost</link>
      <guid isPermaLink="true">https://www.opensequence.dev/blog/outreach-vs-salesloft-cost</guid>
      <pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>Leo Guerberg</dc:creator>
      <description>Outreach vs Salesloft pricing for 15 reps in 2026. Year one is $36,000 to $40,000 on Outreach, $18,000 to $35,000 on Salesloft and about $16,200 self-hosted.</description>
      <category>outreach</category>
      <category>salesloft</category>
      <category>pricing</category>
      <category>self-hosting</category>
      <content:encoded><![CDATA[<p>A 15-rep team should expect to pay $36,000 to $40,000 in the first year for Outreach and $18,000 to $35,000 for Salesloft, depending on the tier. Neither company publishes prices, so these figures come from Vendr buyer data and Outreach's one public AWS Marketplace offer, plus onboarding and Google Workspace mailboxes. A self-hosted sequencer such as OpenSequence costs about $16,200 in year one and $8,200 in year two, and most of that is engineer time.</p>
<p>We build OpenSequence, so weigh this page with that in mind. We show every number's source and the assumptions behind our own column. We also list what the per-seat price buys that OpenSequence does not do, and the cases where Outreach or Salesloft is the better buy. We checked all prices in October 2026.</p>
<h2 id="neither-vendor-publishes-a-price">Neither vendor publishes a price<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#neither-vendor-publishes-a-price">#</a></h2>
<p>The <a href="https://www.outreach.ai/pricing" rel="noopener">Outreach pricing page</a> lists four Amplify packages and a "Request pricing" button on each. The <a href="https://www.salesloft.com/pricing" rel="noopener">Salesloft pricing page</a> lists products and a "Talk to sales" button, with no tiers and no numbers.</p>
<p>That leaves three kinds of evidence. Vendr publishes aggregated contract data from the purchases it handles. Outreach sells one fixed package on AWS Marketplace with a visible price. Pricing roundups fill the gaps. Several of the ones we read cite each other or show medians computed from zero transactions, so we left them out.</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th></th>
<th>Outreach</th>
<th>Salesloft</th>
</tr>
</thead>
<tbody>
<tr>
<td>Published price</td>
<td>None</td>
<td>None</td>
</tr>
<tr>
<td>Public reference</td>
<td>$180 per seat a month (AWS)</td>
<td>None</td>
</tr>
<tr>
<td>Vendr median contract</td>
<td>$45,600 a year</td>
<td>$30,740 a year</td>
</tr>
<tr>
<td>Vendr sample</td>
<td>919 purchases</td>
<td>710 purchases</td>
</tr>
<tr>
<td>Per seat, per Vendr</td>
<td>Not given</td>
<td>$60 to $200+ by tier</td>
</tr>
<tr>
<td>Minimum seats</td>
<td>5 to 10 typical</td>
<td>10 to 15 typical</td>
</tr>
<tr>
<td>Contract</td>
<td>12 months typical</td>
<td>Annual or multi-year</td>
</tr>
<tr>
<td>Onboarding</td>
<td>$5,000 to $25,000+</td>
<td>$5,000 to $25,000+</td>
</tr>
<tr>
<td>Renewal uplift</td>
<td>3% to 7% a year</td>
<td>5% to 8% a year</td>
</tr>
<tr>
<td>Average negotiated saving</td>
<td>12%</td>
<td>18%</td>
</tr>
</tbody>
</table></div>
<p>Sources: <a href="https://www.vendr.com/marketplace/outreach" rel="noopener">Vendr on Outreach</a>, <a href="https://www.vendr.com/marketplace/salesloft" rel="noopener">Vendr on Salesloft</a> (both updated February 2026) and the <a href="https://aws.amazon.com/marketplace/pp/prodview-hm7n4y6z3x36i" rel="noopener">Outreach listing on AWS Marketplace</a>.</p>
<h3 id="outreach">Outreach<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#outreach">#</a></h3>
<p>The AWS Marketplace listing sells "Orchestrate (50 seats)" for $108,000 over 12 months. That is $2,160 per seat per year, or $180 a month. Orchestrate is an older package name, and 50 seats is more than our example team. Treat $180 as a reference point, not as your quote.</p>
<p>Vendr does not break Outreach down by seat. It reports the median contract and a range from $8,660 to $215,400. Volume discounts begin around 20 to 25 users. Add-ons such as Kaia, conversation intelligence and Commit can raise the contract value by 20% to 40%. Premium support costs 10% to 15% of the annual contract value.</p>
<h3 id="salesloft">Salesloft<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#salesloft">#</a></h3>
<p>Vendr gives Salesloft a per-seat table, per user per month:</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Tier</th>
<th>List</th>
<th>What buyers paid</th>
<th>Typical buyer size</th>
</tr>
</thead>
<tbody>
<tr>
<td>Essentials</td>
<td>$75 to $100</td>
<td>$60 to $85</td>
<td>10 to 25 users</td>
</tr>
<tr>
<td>Advanced</td>
<td>$125 to $150</td>
<td>$100 to $130</td>
<td>25 to 75 users</td>
</tr>
<tr>
<td>Premier</td>
<td>$165 to $200+</td>
<td>$130 to $170</td>
<td>75+ users</td>
</tr>
</tbody>
</table></div>
<p>A 15-rep team is at Vendr's 10 to 15 seat minimum. That puts it in the Essentials buyer band, and it should expect to pay close to list on Advanced. Salesloft prices add-ons as a share of the base. Conversations, for call recording and transcription, adds 20% to 40%. Rhythm, for next-best-action, adds 15% to 30%, and Deals adds 10% to 20%.</p>
<h2 id="what-changed-in-2025-and-2026">What changed in 2025 and 2026<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-changed-in-2025-and-2026">#</a></h2>
<p>Clari and Salesloft <a href="https://www.salesloft.com/company/newsroom/clari-and-salesloft-announce-merger-agreement" rel="noopener">announced their merger</a> on August 7, 2025, and <a href="https://salesloft.com/company/newsroom/clari-salesloft-merger" rel="noopener">closed it on December 3, 2025</a> with Steve Cox as CEO. The clari.com domain now redirects to salesloft.com, and the Salesloft pricing page lists Clari Forecast next to Cadence, Conversations, Deals and Rhythm. In practice, forecasting is now a line item on the same Salesloft quote. Ask for each product priced separately before you accept a bundle.</p>
<p>Outreach moved its site to outreach.ai and repackaged around AI credits. Its pricing page says the model "combines two models: seat-based access, where you pay for each user, and consumption-based AI credits, where you pay for what you use." Essentials includes 10,000 credits, Core 25,000, Plus 50,000 and Pro 100,000. Extra credit packs are for sale, and the page does not price them. Budget for credit purchases on top of seats, and ask what one credit buys before you sign.</p>
<h2 id="outreach-and-salesloft-for-15-reps">Outreach and Salesloft for 15 reps<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#outreach-and-salesloft-for-15-reps">#</a></h2>
<p>Outreach at the AWS reference price is 15 × $180 × 12 = $32,400. With Vendr's 12% average negotiated saving it is $28,512. Vendr says volume discounts start at 20 to 25 users, so a 15-seat team is unlikely to get a price below that.</p>
<p>Salesloft Essentials, at what Vendr's 10 to 25 user buyers paid, is 15 × $60 × 12 = $10,800 to 15 × $85 × 12 = $15,300. Salesloft Advanced at list is 15 × $125 × 12 = $22,500 to 15 × $150 × 12 = $27,000.</p>
<p>Both vendors charge for onboarding. We use $5,000, the bottom of Vendr's range for both, because a 15-rep team rarely needs the enterprise package. We leave out add-ons and premium support. Those would raise the vendor totals by 20% or more.</p>
<h2 id="self-hosted-costs-line-by-line">Self-hosted costs, line by line<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#self-hosted-costs-line-by-line">#</a></h2>
<p>OpenSequence is an Apache-2.0 sequencing engine that runs as one Docker image next to a Postgres you own. Our <a href="https://docs.opensequence.dev/docs/self-hosting/install" rel="noopener">install guide</a> covers the production setup. For our example team, the bill has four parts.</p>
<h3 id="hosting">Hosting<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#hosting">#</a></h3>
<p>The <a href="https://docs.opensequence.dev/docs/self-hosting/scaling" rel="noopener">scaling guide</a> suggests 500 mCPU and 512 MB to start. It also estimates that 30 sequences with about 500 active contacts produce 5,000 to 15,000 step executions a day. For that load, one container with <code>SEQ_ROLES=all</code> and a small Postgres are enough. On <a href="https://render.com/pricing" rel="noopener">Render's list prices</a>:</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Item</th>
<th>Monthly</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pro workspace</td>
<td>$25</td>
</tr>
<tr>
<td>Standard web service (1 CPU, 2 GB)</td>
<td>$25</td>
</tr>
<tr>
<td>Postgres Basic-1gb</td>
<td>$19</td>
</tr>
<tr>
<td>10 GB database storage at $0.30</td>
<td>$3</td>
</tr>
<tr>
<td>Total</td>
<td>$72</td>
</tr>
</tbody>
</table></div>
<p>That is $864 a year. On <a href="https://docs.fly.io/about/pricing" rel="noopener">Fly.io</a>, a shared-cpu-1x machine with 1 GB costs $6.70 a month and Managed Postgres starts at $38. The same setup there is about $45 a month.</p>
<h3 id="mailboxes">Mailboxes<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#mailboxes">#</a></h3>
<p>OpenSequence sends each email through the rep's own Gmail account with the Gmail API. Outreach and Salesloft also send from the rep's mailbox, so this cost is the same in every column. <a href="https://workspace.google.com/pricing" rel="noopener">Google Workspace</a> Business Standard is $14 per user per month on an annual plan, so 15 × $14 × 12 = $2,520.</p>
<p>If every rep is in your own Workspace, you can make the OAuth client an Internal app. Google says <a href="https://support.google.com/cloud/answer/10311615" rel="noopener">Internal apps do not need verification</a>, so you skip the review that Gmail's sensitive scopes trigger for outside users. The <a href="https://docs.opensequence.dev/docs/senders/connect-google" rel="noopener">Google connection guide</a> lists the four scopes.</p>
<h3 id="sms-and-calls">SMS and calls<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#sms-and-calls">#</a></h3>
<p>OpenSequence sends texts and pushes call tasks to a power dialer through <a href="https://docs.opensequence.dev/docs/senders/connect-allo" rel="noopener">Allo</a>. It has no Twilio integration today. A Twilio adapter is on the <a href="https://docs.opensequence.dev/docs/community/roadmap" rel="noopener">roadmap</a> under "Later". On the <a href="https://www.withallo.com/pricing" rel="noopener">Allo pricing page</a>, Business is $32 per seat per month billed yearly and includes outbound SMS to US numbers, call recording and AI call summaries. The Power Dialer is $50 a month for the whole team, and 10DLC registration is $24 once.</p>
<p>For 15 reps that is 15 × $32 × 12 = $5,760, plus $600 for the dialer and $24 for 10DLC, so $6,384 in year one. Email-only teams skip this line.</p>
<h3 id="engineer-time">Engineer time<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#engineer-time">#</a></h3>
<p>This is the largest cost, and you should replace our assumptions with yours. We assume a loaded rate of $100 an hour and these hours for year one:</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Task</th>
<th>Hours</th>
</tr>
</thead>
<tbody>
<tr>
<td>Deploy, secrets, TLS, backups</td>
<td>8</td>
</tr>
<tr>
<td>Google OAuth client and rep connections</td>
<td>8</td>
</tr>
<tr>
<td>Webhooks into your CRM</td>
<td>16</td>
</tr>
<tr>
<td>Enroll button and task list in your CRM</td>
<td>40</td>
</tr>
<tr>
<td>First sequences written as code</td>
<td>8</td>
</tr>
<tr>
<td>Maintenance, 4 hours a month</td>
<td>48</td>
</tr>
<tr>
<td>Total</td>
<td>128</td>
</tr>
</tbody>
</table></div>
<p>OpenSequence has no user interface, so the 40-hour line is the easiest to underestimate. Your CRM or an internal tool has to show sequences, enrollments and tasks to reps. Year one comes to 128 × $100 = $12,800. Year two is maintenance only, 48 × $100 = $4,800.</p>
<h2 id="12-month-totals-side-by-side">12-month totals, side by side<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#12-month-totals-side-by-side">#</a></h2>
<p>Every row is 15 reps and includes $2,520 for Google Workspace. Vendor rows include $5,000 onboarding in year one and Vendr's renewal uplift in year two, which is 3% to 7% for Outreach and 5% to 8% for Salesloft. Self-hosted rows include hosting and engineer time.</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Option</th>
<th>Year one</th>
<th>Year two</th>
</tr>
</thead>
<tbody>
<tr>
<td>Outreach</td>
<td>$36,032 to $39,920</td>
<td>$31,887 to $37,188</td>
</tr>
<tr>
<td>Salesloft Essentials</td>
<td>$18,320 to $22,820</td>
<td>$13,860 to $19,044</td>
</tr>
<tr>
<td>Salesloft Advanced</td>
<td>$30,020 to $34,520</td>
<td>$26,145 to $31,680</td>
</tr>
<tr>
<td>OpenSequence, email only</td>
<td>$16,184</td>
<td>$8,184</td>
</tr>
<tr>
<td>OpenSequence with Allo</td>
<td>$22,568</td>
<td>$14,544</td>
</tr>
</tbody>
</table></div>
<p>We put zero engineer hours on the vendor rows. Someone administers Outreach too, so this choice favors the vendors.</p>
<p>Against Salesloft Essentials, self-hosting saves $2,136 to $6,636 in year one. The savings grow in year two, when engineer time drops to maintenance, and they grow again with headcount. Each rep you add costs $720 to $2,160 a year in vendor seats, $0 in OpenSequence, and $384 in Allo if that rep texts.</p>
<h2 id="what-the-per-seat-price-buys-that-opensequence-does-not">What the per-seat price buys that OpenSequence does not<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-the-per-seat-price-buys-that-opensequence-does-not">#</a></h2>
<p>The vendor price covers a finished application. Every Outreach package includes conversation intelligence. Core adds the Outreach Voice integrated dialer and call insights. Plus adds deal management, win and loss analysis, and meeting integrations with Zoom, Teams, Meet and Webex. Pro adds forecasting. Salesloft sells a <a href="https://www.salesloft.com/platform/dialer-messenger" rel="noopener">dialer with local presence</a>, Conversations for recording and coaching, Rhythm for next-best-action, Deals, and Clari Forecast.</p>
<p>OpenSequence does none of the following:</p>
<ul>
<li>It has no user interface for reps or managers.</li>
<li>It does not record, transcribe or score calls. Allo records the calls it dials.</li>
<li>It has no coaching, forecasting or deal management.</li>
<li>It has no contact database and no mailbox warm-up.</li>
<li>It sends email through Gmail only. SMTP and IMAP are on the roadmap, so Microsoft 365 teams cannot use it today.</li>
<li>LinkedIn steps are tasks a rep performs by hand.</li>
</ul>
<p>OpenSequence does the sequencing. It sends from each rep's Gmail and threads follow-ups. It applies daily caps per sender, send windows in the contact's time zone, and an 08:00 to 21:00 window for texts. It detects replies, bounces, opt-outs and meetings booked in Google Calendar, then exits the enrollment and sends a signed webhook to your app. <a href="https://docs.opensequence.dev/docs/get-started/how-it-works" rel="noopener">How it works</a> follows one contact through every step. The project is pre-1.0, and the SDK and MCP packages are not on npm yet.</p>
<h2 id="when-outreach-or-salesloft-is-the-better-buy">When Outreach or Salesloft is the better buy<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#when-outreach-or-salesloft-is-the-better-buy">#</a></h2>
<p>Self-hosting is the wrong choice for many teams. Pay for Outreach or Salesloft if any of these apply:</p>
<ul>
<li>You have no engineer who can spend two weeks on setup and a few hours a month after.</li>
<li>Your reps send from Microsoft 365.</li>
<li>You want recording, coaching and forecasting on one contract with one vendor to call.</li>
<li>Managers need dashboards and a sequence editor on day one.</li>
<li>You have five reps or fewer. At that size the engineering hours cost more than the seats you would save.</li>
</ul>
<p>Self-hosting is worth it when you already run your own CRM or product, your headcount grows, or you want sequences inside software you sell to customers. Our <a href="https://www.opensequence.dev/blog/replace-your-sales-sequencing-software">migration guide for replacing a sequencing tool</a> covers the move. If you are weighing writing the engine yourself, read <a href="https://www.opensequence.dev/blog/build-your-own-sales-sequencer">what it takes to build your own sales sequencer</a> first.</p>
<h2 id="how-to-bring-a-vendor-quote-down">How to bring a vendor quote down<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-to-bring-a-vendor-quote-down">#</a></h2>
<p>If you stay, Vendr's data points to five tactics. Multi-year terms of 24 to 36 months cut the effective annual price by 15% to 30% for both vendors. Ask for the renewal escalator in writing, since 3% to 8% a year compounds. Vendr suggests a 60 to 90 day notice window for auto-renewal, so put the date in a calendar 90 days ahead. On Outreach, ask how many AI credits your team will use and what the next pack costs. On Salesloft, get Conversations, Rhythm and Clari Forecast priced as separate lines before you accept a bundle.</p>
<p>For the cheaper end of the market, our <a href="https://www.opensequence.dev/blog/apollo-alternatives">Apollo alternatives comparison</a> covers tools billed at $49 to $119 a seat.</p>
<h2 id="faq">FAQ<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#faq">#</a></h2>
<h3 id="how-much-does-outreach-cost-per-user-in-2026">How much does Outreach cost per user in 2026?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-much-does-outreach-cost-per-user-in-2026">#</a></h3>
<p>Outreach does not publish prices. Its only public price is an AWS Marketplace package at $108,000 for 50 seats over 12 months, which is $180 per seat per month. Vendr's median Outreach contract is $45,600 a year across 919 purchases.</p>
<h3 id="how-much-does-salesloft-cost-per-user">How much does Salesloft cost per user?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-much-does-salesloft-cost-per-user">#</a></h3>
<p>Salesloft does not publish prices either. Vendr's data puts Essentials at $75 to $100 per user per month at list, Advanced at $125 to $150, and Premier at $165 to $200 or more. Buyers with 10 to 25 users paid $60 to $85 on Essentials.</p>
<h3 id="is-salesloft-cheaper-than-outreach">Is Salesloft cheaper than Outreach?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#is-salesloft-cheaper-than-outreach">#</a></h3>
<p>On Vendr's data, yes. The median Salesloft contract is $30,740 a year against $45,600 for Outreach, and Salesloft Essentials costs less than any Outreach reference price we found. For a 15-rep team, Salesloft Advanced at list costs close to Outreach.</p>
<h3 id="did-clari-and-salesloft-merge">Did Clari and Salesloft merge?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#did-clari-and-salesloft-merge">#</a></h3>
<p>Yes. They announced the merger on August 7, 2025, and closed it on December 3, 2025, with Steve Cox as CEO. Clari's site now redirects to salesloft.com, and Salesloft sells Clari Forecast as one of its products.</p>
<h3 id="do-outreach-and-salesloft-require-annual-contracts">Do Outreach and Salesloft require annual contracts?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#do-outreach-and-salesloft-require-annual-contracts">#</a></h3>
<p>Both sell annual or multi-year contracts, according to Vendr. Most Outreach contracts last 12 months, and 24 to 36 months is common for larger teams. Salesloft bills per user per month, paid annually or multi-year.</p>
<h3 id="what-does-it-cost-to-self-host-a-sales-sequencer">What does it cost to self-host a sales sequencer?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-does-it-cost-to-self-host-a-sales-sequencer">#</a></h3>
<p>For 15 reps on OpenSequence, expect about $864 a year in hosting on Render and $2,520 in Google Workspace seats you likely already pay. Engineer time is the largest cost, which we estimate at $12,800 in year one and $4,800 a year after. SMS and a dialer through Allo add $6,384 in year one.</p>]]></content:encoded>
    </item>
    <item>
      <title>How to move off Outreach, Salesloft, Apollo or lemlist without double-sending anyone</title>
      <link>https://www.opensequence.dev/blog/replace-your-sales-sequencing-software</link>
      <guid isPermaLink="true">https://www.opensequence.dev/blog/replace-your-sales-sequencing-software</guid>
      <pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>Leo Guerberg</dc:creator>
      <description>Replace a sales engagement platform in about six weeks. See what Outreach, Salesloft, Apollo and lemlist export, how to map steps, and how to cut over.</description>
      <category>migration</category>
      <category>sales-engagement</category>
      <category>outreach</category>
      <category>apollo</category>
      <content:encoded><![CDATA[<p>To replace a sales engagement platform, export your contacts and suppression lists and rebuild each live sequence in the new system. Then start new contacts there while existing ones finish in the old tool, and cancel only after the longest sequence has drained. For a team of about 10 reps, plan on four to six weeks. The sequences are the easy part. The risk is in the jobs the old tool did by default, such as honoring unsubscribes, stopping on replies, and logging activity to the CRM.</p>
<p>We build OpenSequence, an Apache-2.0 open source sequencing engine you run yourself, so this guide uses it as the destination. Most steps apply to any target. For many teams, the better choice is to stay.</p>
<h2 id="who-should-not-move">Who should not move<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#who-should-not-move">#</a></h2>
<p>OpenSequence is a headless engine that runs as one container with a Postgres database and an API. It is pre-1.0, and the SDK is not on npm yet. Stay where you are if any of these is true:</p>
<ul>
<li>Your reps send from Microsoft 365 or Outlook. OpenSequence sends through the Gmail API only. SMTP and IMAP are on the <a href="https://docs.opensequence.dev/docs/community/roadmap" rel="noopener">roadmap</a>, not in the code.</li>
<li>Nobody on the team writes code. OpenSequence has no UI. Someone has to build the enroll button, the task inbox and the reporting screen on the API.</li>
<li>Your managers coach on open rates, click rates or A/B tests of copy. The engine tracks none of these. It counts only replies and meetings booked.</li>
<li>You use Apollo mostly for its contact database, or lemlist mostly for lemwarm. OpenSequence is neither a prospect database nor a warm-up service.</li>
<li>You need Salesforce or HubSpot sync on day one. The only CRM adapter is for Attio, and it reads from Attio but does not write to it. HubSpot is on the roadmap.</li>
</ul>
<p>The teams who should move run Google Workspace, have an engineer, and want sequences inside their own CRM or product. If cost is the reason, read <a href="https://www.opensequence.dev/blog/outreach-vs-salesloft-cost">what Outreach and Salesloft cost</a> or <a href="https://www.opensequence.dev/blog/apollo-alternatives">the Apollo alternatives compared</a> first.</p>
<h2 id="inventory-the-features-you-use">Inventory the features you use<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#inventory-the-features-you-use">#</a></h2>
<p>List the features your team used in the last 90 days. A sequence with no enrollments in a quarter does not need porting.</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>In the old tool</th>
<th>In OpenSequence</th>
<th>Gap</th>
</tr>
</thead>
<tbody>
<tr>
<td>Sequence, cadence, campaign</td>
<td>A versioned JSON definition</td>
<td>None</td>
</tr>
<tr>
<td>Automatic email step</td>
<td><code>outreach</code> email node</td>
<td>None</td>
</tr>
<tr>
<td>Threaded follow-up</td>
<td><code>reply_in_thread: true</code></td>
<td>Threads within one enrollment only</td>
</tr>
<tr>
<td>Template library</td>
<td>Text inside the node</td>
<td>No shared library</td>
</tr>
<tr>
<td>Snippets</td>
<td>Constants in your code</td>
<td>No snippet object</td>
</tr>
<tr>
<td>Call step</td>
<td><code>task</code> with <code>kind: "call"</code></td>
<td>Dialer push works with Allo only</td>
</tr>
<tr>
<td>LinkedIn step</td>
<td><code>linkedin_connect</code> task</td>
<td>The rep does it by hand</td>
</tr>
<tr>
<td>Task queue</td>
<td>Tasks API</td>
<td>You build the inbox</td>
</tr>
<tr>
<td>Connected mailboxes</td>
<td>Senders with Google</td>
<td>Gmail only</td>
</tr>
<tr>
<td>Open and click tracking</td>
<td>None</td>
<td>Not in v1</td>
</tr>
<tr>
<td>A/B test</td>
<td>None</td>
<td>A <code>split</code> node is planned</td>
</tr>
<tr>
<td>CRM activity sync</td>
<td>Webhooks to your code</td>
<td>You write the sync</td>
</tr>
</tbody>
</table></div>
<p>Any gap your team depends on is either work for your engineer or a reason to stay.</p>
<h2 id="what-each-tool-lets-you-export">What each tool lets you export<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-each-tool-lets-you-export">#</a></h2>
<p>Every vendor makes contacts easy to export and sequence content harder.</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Tool</th>
<th>Contacts</th>
<th>Sequence content</th>
</tr>
</thead>
<tbody>
<tr>
<td>Outreach</td>
<td>CSV, 100K per filtered list</td>
<td>API only</td>
</tr>
<tr>
<td>Salesloft</td>
<td>API</td>
<td>Cadence export by API</td>
</tr>
<tr>
<td>Apollo</td>
<td>CSV, credits for net-new</td>
<td>Copy by hand</td>
</tr>
<tr>
<td>lemlist</td>
<td>CSV or JSON by API</td>
<td>Steps by API</td>
</tr>
</tbody>
</table></div>
<p><strong>Outreach.</strong> The <a href="https://support.outreach.io/hc/en-us/articles/360038242394" rel="noopener">export overview</a> lists prospects, accounts, opportunities, calls, tasks, sequence states, users and emails as exportable. Instant downloads stop at 200 records, Outreach emails anything larger, and a filtered list caps at 100K. The same page says "Templates and Snippets cannot be exported." The REST API does expose <code>sequences</code>, <code>sequenceSteps</code>, <code>sequenceTemplates</code>, <code>templates</code> and <code>snippets</code> (<a href="https://developers.outreach.io/api/common-patterns" rel="noopener">common patterns</a>), so a script can pull the content the UI cannot export. Export <code>sequenceStates</code> too. It tells you who is mid-sequence on cutover day.</p>
<p><strong>Salesloft.</strong> Export people through <a href="https://developers.salesloft.com/docs/api/people-index/" rel="noopener"><code>GET /v2/people</code></a>. <code>POST /v2/cadence_export_downloads</code> (<a href="https://developer.salesloft.com/docs/api/cadence-export-downloads-create/" rel="noopener">docs</a>) requests an export of the cadences the user can see. The export runs in the background, and Salesloft emails the file instead of returning it in the response. The <a href="https://developer.salesloft.com/docs/platform/cadence-imports/introduction/" rel="noopener">cadence import docs</a> show the JSON shape, with steps nested under <code>cadence_content.step_groups[].steps[]</code>.</p>
<p><strong>Apollo.</strong> Exporting saved contacts to CSV costs no credits. Exporting net-new contacts does (<a href="https://knowledge.apollo.io/hc/en-us/articles/4409237712141-Export-Contacts-to-a-CSV" rel="noopener">Apollo help center</a>). For sequences, the <a href="https://docs.apollo.io/reference/search-for-sequences" rel="noopener">search endpoint</a> needs a master API key and a paid plan. It returns a step count, not the steps. Plan to copy step text by hand.</p>
<p><strong>lemlist.</strong> The API exports a campaign's leads as CSV or JSON, filtered by state (<a href="https://developer.lemlist.com/api-reference/endpoints/campaigns/export-campaign-leads-v2" rel="noopener">export leads</a>). The states include <code>emailsUnsubscribed</code> and <code>emailsBounced</code>, which together give you your suppression list. <code>GET /campaigns/{campaignId}/sequences</code> (<a href="https://developer.lemlist.com/api-reference/endpoints/sequences/get-campaign-sequences" rel="noopener">docs</a>) returns every step with its <code>type</code>, <code>delay</code> in days, <code>subject</code> and HTML <code>message</code>. lemlist is the easiest of the four to convert with a script.</p>
<h2 id="map-sequences-to-definitions">Map sequences to definitions<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#map-sequences-to-definitions">#</a></h2>
<p>The mapping is mechanical. Each email becomes an <code>outreach</code> node, each delay a <code>wait</code>, each call or LinkedIn step a <code>task</code>, and "stop on reply" becomes <code>exit_on</code>. The sending schedule becomes <code>settings.send_window</code>. Merge fields become template paths such as <code>{{ contact.first_name | "there" }}</code>, and custom fields move into the contact's <code>attrs</code>.</p>
<p>Watch for three rules on your first publish. <code>contact.first_name</code>, <code>contact.last_name</code> and <code>contact.email</code> must carry a fallback or publishing fails with <code>BAD_TEMPLATE</code>. An email body without <code>{{ unsubscribe_url }}</code> publishes with the warning <code>NO_UNSUBSCRIBE</code>. Templates support only paths and fallbacks, with no conditionals or loops. Put any branching in a <code>condition</code> node.</p>
<p>Here is a typical five-touch Outreach sequence after translation. The snippet became a constant.</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="import { OpenSequence } from &#x27;@opensequence/sdk&#x27;;

const oseq = new OpenSequence({ baseUrl: process.env.SEQ_URL!, apiKey: process.env.SEQ_KEY! });

// What used to be an Outreach snippet
const signoff = &#x27;{{ sender.first_name }}\n\nNot for you? {{ unsubscribe_url }}&#x27;;

const definition = {
  schema_version: 1,
  entry: &#x27;email_1&#x27;,
  settings: {
    send_window: { days: [&#x27;mon&#x27;, &#x27;tue&#x27;, &#x27;wed&#x27;, &#x27;thu&#x27;, &#x27;fri&#x27;], from: &#x27;08:00&#x27;, to: &#x27;17:00&#x27;, tz: &#x27;contact&#x27; },
    jitter: &#x27;15m&#x27;,
    reenroll: &#x27;never&#x27;,
  },
  exit_on: [&#x27;reply&#x27;, &#x27;meeting_booked&#x27;],
  nodes: {
    email_1: {
      type: &#x27;outreach&#x27;, channel: &#x27;email&#x27;, action: &#x27;send&#x27;,
      params: {
        subject: &#x27;Outbound at {{ attrs.company.name | &#x22;your team&#x22; }}&#x27;,
        body: &#x60;Hi {{ contact.first_name | &#x22;there&#x22; }},\n\n…\n\n${signoff}&#x60;,
      },
      next: { sent: &#x27;wait_1&#x27;, skipped: &#x27;wait_1&#x27; },
    },
    wait_1: { type: &#x27;wait&#x27;, params: { duration: &#x27;2bd&#x27; }, next: { then: &#x27;email_2&#x27; } },
    email_2: {
      type: &#x27;outreach&#x27;, channel: &#x27;email&#x27;, action: &#x27;send&#x27;,
      params: { subject: &#x27;Re: outbound&#x27;, body: &#x60;…\n\n${signoff}&#x60;, reply_in_thread: true },
      next: { sent: &#x27;call_1&#x27;, skipped: &#x27;call_1&#x27; },
    },
    call_1: {
      type: &#x27;task&#x27;,
      params: { kind: &#x27;call&#x27;, title: &#x27;Call {{ contact.first_name | &#x22;them&#x22; }}&#x27;, due: &#x27;2bd&#x27;, expire: &#x27;4bd&#x27; },
      next: { completed: &#x27;has_li&#x27;, no_answer: &#x27;has_li&#x27; },
    },
    has_li: {
      type: &#x27;condition&#x27;,
      params: { mode: &#x27;all&#x27;, rules: [{ field: &#x27;contact.has_linkedin&#x27;, op: &#x27;is_true&#x27; }] },
      next: { yes: &#x27;li_1&#x27;, no: &#x27;wait_2&#x27; },
    },
    li_1: {
      type: &#x27;task&#x27;,
      params: { kind: &#x27;linkedin_connect&#x27;, title: &#x27;Connect with {{ contact.first_name | &#x22;them&#x22; }}&#x27;, link: &#x27;{{ contact.linkedin_url }}&#x27;, due: &#x27;1bd&#x27; },
      next: { completed: &#x27;wait_2&#x27;, no_answer: &#x27;wait_2&#x27; },
    },
    wait_2: { type: &#x27;wait&#x27;, params: { duration: &#x27;3bd&#x27; }, next: { then: &#x27;email_3&#x27; } },
    email_3: {
      type: &#x27;outreach&#x27;, channel: &#x27;email&#x27;, action: &#x27;send&#x27;,
      params: { subject: &#x27;Closing the loop&#x27;, body: &#x60;Hi {{ contact.first_name | &#x22;there&#x22; }}, last note from me.\n\n${signoff}&#x60; },
      next: { sent: &#x27;end&#x27;, skipped: &#x27;end&#x27; },
    },
    end: { type: &#x27;end&#x27; },
  },
};

const { ok, problems } = await oseq.sequences.validate(definition);
if (!ok) throw new Error(JSON.stringify(problems));

const { sequence, version } = await oseq.sequences.create({ name: &#x27;VP Sales outbound&#x27;, definition });
await oseq.sequences.versions.publish(sequence.id, version.id);" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#7DCFFF">import</span><span style="color:#9ABDF5"> { </span><span style="color:#0DB9D7">OpenSequence</span><span style="color:#9ABDF5"> }</span><span style="color:#7DCFFF"> from</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">@opensequence/sdk</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#BB9AF7"> oseq</span><span style="color:#89DDFF"> =</span><span style="color:#89DDFF"> new</span><span style="color:#7AA2F7"> OpenSequence</span><span style="color:#9ABDF5">({</span><span style="color:#73DACA"> baseUrl</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> process</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">env</span><span style="color:#89DDFF">.</span><span style="color:#FF9E64">SEQ_URL</span><span style="color:#BB9AF7">!</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> apiKey</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> process</span><span style="color:#89DDFF">.</span><span style="color:#C0CAF5">env</span><span style="color:#89DDFF">.</span><span style="color:#FF9E64">SEQ_KEY</span><span style="color:#BB9AF7">!</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#51597D;font-style:italic">// What used to be an Outreach snippet</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#BB9AF7"> signoff</span><span style="color:#89DDFF"> =</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">{{ sender.first_name }}</span><span style="color:#89DDFF">\n\n</span><span style="color:#9ECE6A">Not for you? {{ unsubscribe_url }}</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#BB9AF7"> definition</span><span style="color:#89DDFF"> =</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#73DACA">  schema_version</span><span style="color:#89DDFF">:</span><span style="color:#FF9E64"> 1</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">  entry</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">email_1</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">  settings</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#73DACA">    send_window</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> days</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> [</span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">mon</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">tue</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">wed</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">thu</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">fri</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5">]</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> from</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">08:00</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> to</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">17:00</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> tz</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">contact</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    jitter</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">15m</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    reenroll</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">never</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">  }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">  exit_on</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> [</span><span style="color:#89DDFF">'</span><span style="color:#9ECE6A">reply</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">meeting_booked</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5">]</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">  nodes</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#73DACA">    email_1</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#41A6B5">      type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">outreach</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> channel</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">email</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> action</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">send</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#41A6B5">        subject</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Outbound at {{ attrs.company.name | "your team" }}</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">        body</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> `</span><span style="color:#9ECE6A">Hi {{ contact.first_name | "there" }},</span><span style="color:#89DDFF">\n\n</span><span style="color:#9ECE6A">…</span><span style="color:#89DDFF">\n\n</span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">signoff</span><span style="color:#7DCFFF">}</span><span style="color:#89DDFF">`</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">      }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      next</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> sent</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">wait_1</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> skipped</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">wait_1</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    wait_1</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">wait</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> duration</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">2bd</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> next</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> then</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">email_2</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    email_2</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#41A6B5">      type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">outreach</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> channel</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">email</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> action</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">send</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> subject</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Re: outbound</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> body</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> `</span><span style="color:#9ECE6A">…</span><span style="color:#89DDFF">\n\n</span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">signoff</span><span style="color:#7DCFFF">}</span><span style="color:#89DDFF">`</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> reply_in_thread</span><span style="color:#89DDFF">:</span><span style="color:#FF9E64"> true</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      next</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> sent</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">call_1</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> skipped</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">call_1</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    call_1</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#41A6B5">      type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">task</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> kind</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">call</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> title</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Call {{ contact.first_name | "them" }}</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> due</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">2bd</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> expire</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">4bd</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      next</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> completed</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">has_li</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> no_answer</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">has_li</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    has_li</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#41A6B5">      type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">condition</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> mode</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">all</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> rules</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> [{</span><span style="color:#41A6B5"> field</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">contact.has_linkedin</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> op</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">is_true</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }]</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      next</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> yes</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">li_1</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> no</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">wait_2</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    li_1</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#41A6B5">      type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">task</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> kind</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">linkedin_connect</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> title</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Connect with {{ contact.first_name | "them" }}</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> link</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">{{ contact.linkedin_url }}</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> due</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">1bd</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      next</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> completed</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">wait_2</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> no_answer</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">wait_2</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    wait_2</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">wait</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> duration</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">3bd</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> next</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> then</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">email_3</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    email_3</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#41A6B5">      type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">outreach</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> channel</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">email</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> action</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">send</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      params</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> subject</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">Closing the loop</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> body</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> `</span><span style="color:#9ECE6A">Hi {{ contact.first_name | "there" }}, last note from me.</span><span style="color:#89DDFF">\n\n</span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">signoff</span><span style="color:#7DCFFF">}</span><span style="color:#89DDFF">`</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#41A6B5">      next</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> sent</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">end</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#41A6B5"> skipped</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">end</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">    }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#73DACA">    end</span><span style="color:#89DDFF">:</span><span style="color:#9ABDF5"> {</span><span style="color:#41A6B5"> type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">end</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">  }</span><span style="color:#89DDFF">,</span></span>
<span class="line"><span style="color:#9ABDF5">}</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#89DDFF"> {</span><span style="color:#BB9AF7"> ok</span><span style="color:#89DDFF">,</span><span style="color:#BB9AF7"> problems</span><span style="color:#89DDFF"> }</span><span style="color:#89DDFF"> =</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">sequences</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">validate</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">definition</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7">if</span><span style="color:#9ABDF5"> (</span><span style="color:#BB9AF7">!</span><span style="color:#C0CAF5">ok</span><span style="color:#9ABDF5">)</span><span style="color:#BB9AF7"> throw</span><span style="color:#89DDFF"> new</span><span style="color:#7AA2F7"> Error</span><span style="color:#9ABDF5">(</span><span style="color:#0DB9D7">JSON</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">stringify</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">problems</span><span style="color:#9ABDF5">))</span><span style="color:#89DDFF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">const</span><span style="color:#89DDFF"> {</span><span style="color:#BB9AF7"> sequence</span><span style="color:#89DDFF">,</span><span style="color:#BB9AF7"> version</span><span style="color:#89DDFF"> }</span><span style="color:#89DDFF"> =</span><span style="color:#BB9AF7;font-style:italic"> await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">sequences</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">create</span><span style="color:#9ABDF5">({</span><span style="color:#73DACA"> name</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">VP Sales outbound</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#C0CAF5"> definition</span><span style="color:#9ABDF5"> })</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">sequences</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">versions</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">publish</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">sequence</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#89DDFF">,</span><span style="color:#C0CAF5"> version</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">id</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span></code></pre></figure>
<p>A connected call counts as a reply, so <code>exit_on: ['reply']</code> ends the flow when a rep reaches the contact. <code>2bd</code> means two business days in the contact's time zone, and it does not account for holidays. The full grammar is in <a href="https://docs.opensequence.dev/docs/sequences/definition" rel="noopener">the definition reference</a>. Commit the file. From here on, every sequence change is a pull request. Published versions are immutable, so enrolled contacts finish on the version they started.</p>
<h2 id="mailboxes-and-warm-up-continuity">Mailboxes and warm-up continuity<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#mailboxes-and-warm-up-continuity">#</a></h2>
<p>If your reps connected Google Workspace mailboxes to Outreach or Salesloft, you do not need to re-warm anything. OpenSequence sends through the <a href="https://docs.opensequence.dev/docs/senders/connect-google" rel="noopener">same Gmail accounts by OAuth</a>, from the same domain, with the same reputation. You do need your own Google OAuth client. Google classes Gmail scopes as sensitive. If accounts outside your Workspace domain use the app, Google must verify it before the consent screen stops warning users.</p>
<p>The trap is combined volume during the parallel run. OpenSequence's <code>daily_limit</code> defaults to 30 emails with a 60 second minimum gap, and it counts only OpenSequence's own sends. It cannot see what Outreach sent from the same mailbox an hour earlier. If a rep sent 50 a day before, split that budget. Put 35 in the old tool and 15 in OpenSequence in week one, then shift the split as the old sequences drain. Google's hard ceiling is <a href="https://knowledge.workspace.google.com/admin/gmail/gmail-sending-limits-in-google-workspace" rel="noopener">2,000 messages a day per Workspace user</a>, and a cold outbound mailbox should stay far below it.</p>
<p>Secondary domains that lemwarm or another warm-up service warms are a different case. OpenSequence does not warm anything, so keep the warm-up service on those mailboxes or retire them. Confirm they are Google Workspace mailboxes before you plan around them.</p>
<p>Google's <a href="https://support.google.com/a/answer/81126" rel="noopener">sender guidelines</a> require SPF, DKIM, DMARC and one-click unsubscribe for senders above 5,000 messages a day to Gmail. They also require a spam rate below 0.30%, with 0.10% as the target. OpenSequence adds <code>List-Unsubscribe</code> and <code>List-Unsubscribe-Post</code> headers to every email. The rest is DNS work, which <a href="https://www.opensequence.dev/blog/cold-email-deliverability-self-hosted">cold email deliverability when you self-host</a> covers.</p>
<h2 id="run-both-systems-without-double-sending">Run both systems without double-sending<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#run-both-systems-without-double-sending">#</a></h2>
<p>On any given day, a contact should be in exactly one system. OpenSequence returns <code>409 ALREADY_IN_SEQUENCE</code> when you enroll a contact who is already in one of its sequences, but it cannot see Outreach. Your enroll code has to check the old tool too.</p>
<p>Drain the old tool instead of moving people. On cutover day, send new enrollments only to OpenSequence, and let contacts already in an old sequence finish in the old tool. Remove the old tool's "add to sequence" permission so nobody enrolls by habit.</p>
<p>Load suppressions before the first send. The API cannot remove a suppression, so get the list right.</p>
<figure class="code-block"><figcaption><span class="code-tag">ts</span><button type="button" class="code-copy" data-copy="// rows: unsubscribes and bounces exported from the old tool, keyed by your CRM id
for (let i = 0; i < rows.length; i += 500) {
  const chunk = rows.slice(i, i + 500);
  await oseq.contacts.batch(chunk.map((r) => ({ external_id: r.crmId, email: r.email })));
  await oseq.contacts.reportEvents(chunk.map((r) => (
    r.unsubscribed
      ? { contact: r.crmId, id: &#x60;import-optout-${r.crmId}&#x60;, type: &#x27;opt_out&#x27;, channel: &#x27;email&#x27; }
      : { contact: r.crmId, id: &#x60;import-bounce-${r.crmId}&#x60;, type: &#x27;suppressed&#x27;, reason: &#x27;invalid_email&#x27; }
  )));
}" aria-label="Copy the code"><span class="copy-idle">Copy</span><span class="copy-done">Copied</span></button></figcaption><pre class="shiki tokyo-night" style="background-color:#1a1b26;color:#a9b1d6" tabindex="0"><code><span class="line"><span style="color:#51597D;font-style:italic">// rows: unsubscribes and bounces exported from the old tool, keyed by your CRM id</span></span>
<span class="line"><span style="color:#BB9AF7">for</span><span style="color:#9ABDF5"> (</span><span style="color:#9D7CD8;font-style:italic">let</span><span style="color:#BB9AF7"> i</span><span style="color:#89DDFF"> =</span><span style="color:#FF9E64"> 0</span><span style="color:#89DDFF">;</span><span style="color:#C0CAF5"> i</span><span style="color:#BB9AF7"> &#x3C;</span><span style="color:#C0CAF5"> rows</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">length</span><span style="color:#89DDFF">;</span><span style="color:#C0CAF5"> i</span><span style="color:#89DDFF"> +=</span><span style="color:#FF9E64"> 500</span><span style="color:#9ABDF5">)</span><span style="color:#9ABDF5"> {</span></span>
<span class="line"><span style="color:#9D7CD8;font-style:italic">  const</span><span style="color:#BB9AF7"> chunk</span><span style="color:#89DDFF"> =</span><span style="color:#C0CAF5"> rows</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">slice</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">i</span><span style="color:#89DDFF">,</span><span style="color:#C0CAF5"> i</span><span style="color:#89DDFF"> +</span><span style="color:#FF9E64"> 500</span><span style="color:#9ABDF5">)</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">  await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">contacts</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">batch</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">chunk</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">map</span><span style="color:#9ABDF5">((</span><span style="color:#E0AF68">r</span><span style="color:#9ABDF5">) </span><span style="color:#BB9AF7">=></span><span style="color:#9ABDF5"> ({ </span><span style="color:#73DACA">external_id</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> r</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">crmId</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> email</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> r</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">email</span><span style="color:#9ABDF5"> })))</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#BB9AF7;font-style:italic">  await</span><span style="color:#C0CAF5"> oseq</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">contacts</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">reportEvents</span><span style="color:#9ABDF5">(</span><span style="color:#C0CAF5">chunk</span><span style="color:#89DDFF">.</span><span style="color:#7AA2F7">map</span><span style="color:#9ABDF5">((</span><span style="color:#E0AF68">r</span><span style="color:#9ABDF5">) </span><span style="color:#BB9AF7">=></span><span style="color:#9ABDF5"> (</span></span>
<span class="line"><span style="color:#C0CAF5">    r</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">unsubscribed</span></span>
<span class="line"><span style="color:#BB9AF7">      ?</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">contact</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> r</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">crmId</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> id</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> `</span><span style="color:#9ECE6A">import-optout-</span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">r</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">crmId</span><span style="color:#7DCFFF">}</span><span style="color:#89DDFF">`</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">opt_out</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> channel</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">email</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span></span>
<span class="line"><span style="color:#BB9AF7">      :</span><span style="color:#9ABDF5"> { </span><span style="color:#73DACA">contact</span><span style="color:#89DDFF">:</span><span style="color:#C0CAF5"> r</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">crmId</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> id</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> `</span><span style="color:#9ECE6A">import-bounce-</span><span style="color:#7DCFFF">${</span><span style="color:#C0CAF5">r</span><span style="color:#89DDFF">.</span><span style="color:#7DCFFF">crmId</span><span style="color:#7DCFFF">}</span><span style="color:#89DDFF">`</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> type</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">suppressed</span><span style="color:#89DDFF">'</span><span style="color:#89DDFF">,</span><span style="color:#73DACA"> reason</span><span style="color:#89DDFF">:</span><span style="color:#89DDFF"> '</span><span style="color:#9ECE6A">invalid_email</span><span style="color:#89DDFF">'</span><span style="color:#9ABDF5"> }</span></span>
<span class="line"><span style="color:#9ABDF5">  )))</span><span style="color:#89DDFF">;</span></span>
<span class="line"><span style="color:#9ABDF5">}</span></span></code></pre></figure>
<p>Event ids make the import idempotent, so a rerun after a crash changes nothing. The <a href="https://docs.opensequence.dev/docs/contacts/events" rel="noopener">contact events page</a> lists every type.</p>
<p>Watch for replies on old threads. OpenSequence detects replies only in threads it started, so it does not see a prospect who answers an Outreach email from three weeks ago. While both systems run, forward any such reply as a <code>reply</code> event, or that contact keeps getting follow-ups.</p>
<p>Sometimes contacts must move mid-sequence, because the contract ends before the drain does. In that case, export their sequence state, remove them from the sequence in the old tool, and enroll them in a second published sequence whose <code>entry</code> is the step they had reached. Its first email cannot thread onto the old one, because <code>reply_in_thread</code> only threads within the same enrollment. Write that email as a new message that makes sense without the earlier thread.</p>
<p>Rehearse with <code>mode: "dry_run"</code> first. In dry-run mode, the engine records email and text steps with their rendered text instead of sending them. Waits, conditions and tasks run for real (<a href="https://docs.opensequence.dev/docs/enrollments/enrolling" rel="noopener">enrolling</a>).</p>
<h2 id="crm-sync">CRM sync<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#crm-sync">#</a></h2>
<p>Outreach and Salesloft write activities into your CRM. OpenSequence writes nothing to it. You subscribe to <a href="https://docs.opensequence.dev/docs/webhooks/overview" rel="noopener">signed webhooks</a> and write what you want: <code>run.done</code> for a sent step, <code>signal.received</code> for replies, meetings and bounces, <code>enrollment.exited</code> with the reason, and <code>task.created</code> for your task inbox. OpenSequence retries each delivery for three days, and the same event can arrive twice. Make the handler idempotent on the event <code>id</code>.</p>
<p>The other direction matters more. Your CRM has to push do-not-contact flags, meetings booked in other tools, and deleted contacts back to OpenSequence. The engine never reads your database. If you skip this, it keeps emailing people your reps already excluded. Our tutorial on <a href="https://www.opensequence.dev/blog/add-sales-sequences-to-your-crm">adding sales sequences to your own CRM</a> has the code for both directions.</p>
<h2 id="reporting-parity">Reporting parity<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#reporting-parity">#</a></h2>
<p>Open and click rates do not carry over. OpenSequence counts <a href="https://docs.opensequence.dev/docs/analytics/outcomes" rel="noopener">replies and meetings</a>. It credits each one once per enrollment to the touch that earned it, within a 14 day window. It reports them per 100 enrolled and per 100 delivered, by sequence, step and channel.</p>
<p>Each vendor defines "reply rate" its own way, so do not compare dashboards. Before cancelling, export the old sequence stats and recompute replies per 100 enrolled for your top five sequences. Compare OpenSequence's numbers against that baseline after a month of sends.</p>
<h2 id="cutover-checklist">Cutover checklist<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#cutover-checklist">#</a></h2>
<ol>
<li>Confirm every sending mailbox is Google Workspace.</li>
<li>Export contacts, sequence states, step content and stats from the old tool.</li>
<li>Export unsubscribes, bounces and complaints, and load them as suppressions.</li>
<li>Deploy OpenSequence, register the Google OAuth client, and connect each sender.</li>
<li>Translate the live sequences, validate them, and run one dry-run enrollment each.</li>
<li>Build the webhook handler that writes activity to the CRM.</li>
<li>Build the task inbox for call and LinkedIn steps.</li>
<li>Add the old-tool check to your enroll code.</li>
<li>Split each mailbox's daily volume between the two systems.</li>
<li>Pilot with two reps on new contacts only.</li>
<li>Switch all new enrollments, and remove enroll rights in the old tool.</li>
<li>Forward replies on old threads as <code>reply</code> events until the drain ends.</li>
<li>Record baseline reply and meeting rates.</li>
<li>Cancel after the last old sequence finishes, and keep the exports.</li>
</ol>
<h2 id="a-realistic-timeline">A realistic timeline<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#a-realistic-timeline">#</a></h2>
<p>This is our estimate for a team of about 10 reps with one engineer working half time and sequences no longer than three weeks. It is not a measured benchmark.</p>
<div class="table-wrap" role="region" aria-label="Table, scrolls sideways" tabindex="0"><table>
<thead>
<tr>
<th>Week</th>
<th>Work</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>Inventory, exports, deploy, OAuth client, two pilot senders</td>
</tr>
<tr>
<td>2</td>
<td>Translate sequences, dry runs, suppressions, CRM webhook handler, task inbox</td>
</tr>
<tr>
<td>3</td>
<td>Pilot with two reps enrolling new contacts in OpenSequence</td>
</tr>
<tr>
<td>4</td>
<td>All new enrollments move to OpenSequence, and the old tool drains</td>
</tr>
<tr>
<td>5 and 6</td>
<td>Drain ends, baseline check, cancel</td>
</tr>
</tbody>
</table></div>
<p>The UI work in week 2 grows with every screen your reps expect. The drain lasts as long as your longest active sequence. Check your contract's notice period in week 1, because a missed auto-renewal date can lock you in for another term. If you are considering building your own engine instead, <a href="https://www.opensequence.dev/blog/build-your-own-sales-sequencer">what it takes to build your own sales sequencer</a> covers that option.</p>
<h2 id="faq">FAQ<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#faq">#</a></h2>
<h3 id="can-i-import-outreach-sequences-into-opensequence-automatically">Can I import Outreach sequences into OpenSequence automatically?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#can-i-import-outreach-sequences-into-opensequence-automatically">#</a></h3>
<p>There is no built-in importer. Outreach's API exposes sequences, steps and templates. The mapping is mechanical, so a short script can turn them into definitions. Validate each definition with <code>POST /v1/sequences:validate</code> before publishing.</p>
<h3 id="will-my-deliverability-drop-when-i-switch-tools">Will my deliverability drop when I switch tools?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#will-my-deliverability-drop-when-i-switch-tools">#</a></h3>
<p>Not if reps keep the same Google Workspace mailboxes, because the sending account and domain do not change. The risk is the parallel period, when both tools send from the same mailbox. Split the daily budget between them.</p>
<h3 id="what-happens-to-prospects-in-the-middle-of-a-sequence">What happens to prospects in the middle of a sequence?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#what-happens-to-prospects-in-the-middle-of-a-sequence">#</a></h3>
<p>Let them finish in the old tool and enroll only new contacts in the new one. If you must move them, enroll them in a copy of the sequence that starts at the step they reached. Their next email starts a new thread.</p>
<h3 id="does-opensequence-work-with-outlook-or-microsoft-365">Does OpenSequence work with Outlook or Microsoft 365?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#does-opensequence-work-with-outlook-or-microsoft-365">#</a></h3>
<p>No. Email goes through the Gmail API only. SMTP and IMAP adapters are on the roadmap but not in the code.</p>
<h3 id="do-i-lose-open-and-click-tracking">Do I lose open and click tracking?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#do-i-lose-open-and-click-tracking">#</a></h3>
<p>Yes. OpenSequence has no open or click tracking. It measures replies and meetings booked, and attributes each one to a sequence, a step and a channel.</p>
<h3 id="how-long-does-a-migration-from-apollo-or-lemlist-take">How long does a migration from Apollo or lemlist take?<a class="heading-anchor" aria-hidden="true" tabindex="-1" href="#how-long-does-a-migration-from-apollo-or-lemlist-take">#</a></h3>
<p>About four to six weeks for a small team with an engineer. lemlist is faster to convert because its API returns step content. You have to copy Apollo's step text by hand.</p>]]></content:encoded>
    </item>
  </channel>
</rss>
