Skip to content
SSalesShiftDocs

Guide

Sequences

A sequence is an ordered set of steps a contact walks through over days, with the whole thing stopping the moment they reply. It is the difference between outreach and a mail merge.

This guide builds one from nothing: two steps, an A/B variant on the first, a send window, a daily cap. Then it enrols a contact, runs a dispatch tick, and reads the result back. Every command was executed live and the responses are pasted unedited — including the sequence that actually sent a real email.

Executed end to endSequence created, enrolled, dispatchedOne real email sent

Build the sequence

Steps can be created inline with the sequence or added afterwards. A new sequence starts in draft and sends nothing until it is activated.

POST/sequences201 Created
sequence.json
{
  "name": "Docs walkthrough - two touches",
  "description": "Created while writing the sequences guide.",
  "stop_on_reply": true,
  "stop_on_meeting": true,
  "send_window_start": "08:00:00",
  "send_window_end": "20:00:00",
  "send_timezone": "UTC",
  "send_days": ["mon", "tue", "wed", "thu", "fri"],
  "daily_cap": 25,
  "steps": [
    { "step_order": 1, "step_type": "email", "delay_days": 0,
      "subject": "Quick question, {{first_name}}",
      "body_html": "<p>Hi {{first_name}},</p><p>Saw you are running outbound at {{company_name}}. Worth a look?</p>" },
    { "step_order": 2, "step_type": "email", "delay_days": 3,
      "subject": "Re: Quick question",
      "body_html": "<p>Bumping this up, {{first_name}}.</p>" }
  ]
}
Shell
curl -s -X POST "$API/sequences" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  --data-binary @sequence.json
Real response (HTTP 201, trimmed)
{
  "id": "81feb53b-17c3-4d21-970a-63976f5cfb57",
  "name": "Docs walkthrough - two touches",
  "status": "draft",
  "send_window_start": "08:00:00",
  "send_window_end": "20:00:00",
  "send_days": ["mon", "tue", "wed", "thu", "fri"],
  "daily_cap": 25,
  "steps_count": 2,
  "total_enrolled": 0
}

What each setting actually does

FieldDefaultEffect
stop_on_replytrueA detected reply ends the enrolment. The single most important setting on this object.
stop_on_meetingtrueA booked meeting ends the enrolment — no “just following up” after someone already said yes.
stop_on_clickfalseOff by default. A click is interest, not a conversation, and stopping on it usually loses the follow-up that would have converted.
send_window_startnullOutside the window, steps wait. With no window set, a tick can send at any hour.
send_daysall daysWeekday names. Saturday sends are the easiest own-goal in outbound.
send_timezoneUTCThe window and days are evaluated in this zone, not the recipient’s.
daily_capnullCeiling for this sequence per day, on top of per-mailbox caps and the organisation ceiling.
send_as_replyfalseThreads later steps under the first message rather than sending fresh subjects.
sender_account_idnullPins the sequence to one mailbox. Leaving it null lets the sending pool rotate.

A/B variants

A variant is a sibling of a step, not a separate step. Post it against the step you want to split and it is linked back through variant_of with a label and a weight.

POST/steps/{step_id}/variants
Shell
curl -s -X POST "$API/steps/f05bf6c8-9b72-458c-8051-ddf1b1052190/variants" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{
    "name": "B - shorter",
    "subject": "{{first_name}} - 30 seconds?",
    "body_html": "<p>Hi {{first_name}}, is outbound at {{company_name}} worth 30 seconds?</p>",
    "weight": 50
  }'
Real response (trimmed)
{
  "id": "25248cf0-8a6f-40a7-8b62-9b829773c968",
  "sequence_id": "81feb53b-17c3-4d21-970a-63976f5cfb57",
  "step_number": 1,
  "name": "B - shorter",
  "variant_of": "f05bf6c8-9b72-458c-8051-ddf1b1052190",
  "variant_label": "B",
  "variant_weight": 50,
  "sent_count": 0, "opened_count": 0, "replied_count": 0, "clicked_count": 0,
  "open_rate": 0.0, "reply_rate": 0.0, "click_rate": 0.0
}

Activate and enrol

  1. 1

    Preview against a real contact first

    Preview renders the step with a specific contact’s data, so you see exactly what will go out — including any merge tag that will not resolve.

    POST/sequences/{sequence_id}/preview
    Shell
    curl -s -X POST "$API/sequences/81feb53b-…/preview" \
      -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
      -d '{"step_id":"f05bf6c8-…","contact_id":"613f2aec-…"}'
    Both step_id and contact_id are required; omitting step_id returns 422.
    Real response
    {
      "step_id": "f05bf6c8-9b72-458c-8051-ddf1b1052190",
      "step_number": 1,
      "step_type": "email",
      "variant_label": "A",
      "subject": "Quick question, Joel",
      "body_html": "<p>Hi Joel,</p><p>Saw you are running outbound at {{company}}. Worth a look?</p>",
      "is_sample": false,
      "contact": {
        "id": "613f2aec-14b8-4281-9ced-19fc2c8d38a8",
        "name": "Sam Rivera",
        "email": "[email protected]",
        "company_name": null
      }
    }
    This preview is from an earlier draft that used {{company}}. Note it came back unrendered: there is no such tag, only {{company_name}}, and unknown tags pass through to the recipient verbatim. This is exactly what preview is for.
  2. 2

    Activate

    Shell
    curl -s -X POST "$API/sequences/81feb53b-…/activate" \
      -H "Authorization: Bearer $TOKEN"

    Status moves draftactive. Nothing sends until someone is enrolled and a dispatch tick runs.

  3. 3

    Enrol contacts

    Enrol by contact id, by list, or by a saved contact filter. The response is a full accounting of what happened to every candidate — not just a count.

    POST/sequences/{sequence_id}/enroll
    Shell
    curl -s -X POST "$API/sequences/81feb53b-…/enroll" \
      -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
      -d '{"contact_ids":["613f2aec-14b8-4281-9ced-19fc2c8d38a8"]}'
    Real response
    {
      "success": true,
      "enrolled": 1,
      "candidates": 1,
      "skipped": [],
      "capped": false,
      "cap": 5000,
      "skipped_existing": 0,
      "skipped_suppressed": 0,
      "skipped_no_email": 0,
      "skipped_unsubscribed": 0
    }
    Read the skip counters before assuming an enrolment worked. Suppressed and unsubscribed contacts are filtered here rather than failing later.

Dispatch

A background loop runs a dispatch tick roughly every minute. You can run one immediately for your own organisation — the same engine, just triggered by hand:

POST/sequences/dispatch-now
Executed
curl -s -X POST "$API/sequences/dispatch-now" -H "Authorization: Bearer $TOKEN"

{"success":true,
 "summary":{"processed":1,"sent":1,"failed":0,"completed":0,
            "skipped":0,"tasks":0,"deferred":0,"stopped":0}}
That 'sent: 1' was a real email leaving a real mailbox.
CounterWhat it means
processedEnrolments the tick looked at.
sentEmails the mail server accepted.
deferredThe step will be retried. Nothing is wrong with the contact — see below.
skippedThe enrolment was gated: a cap, the organisation ceiling, or a suppression.
failedThe send was attempted and rejected.
completedThe enrolment reached the end of the sequence.
stoppedA stop condition fired — a reply, a meeting, a click.
tasksNon-email steps that created a task rather than sending.

A mailbox failure defers a step, it does not burn it

This is the distinction that keeps a broken mailbox from looking like a list of bad addresses. When a send fails because the mailbox could not sign in, nothing is wrong with the contact or the step:

  • The enrolment is not marked failed and the step is not consumed.
  • next_action_at is pushed out by 30 minutes.
  • It is counted as deferred, not failed, so a dead OAuth grant does not read on the dashboard as a wall of bounces.
  • By the next tick the sending pool has already dropped that mailbox — the failure was written to its row — and rotates to one that works.

Cap and ceiling exhaustion behave the same way: deferred, enrolment stays active. Where the sending pool knows exactly when it will next have capacity, that time is honoured instead of a blanket one-day deferral — otherwise a 60-second cooldown would cost the enrolment a full day.

Only genuine contact problems terminate an enrolment: unsubscribed and suppressed set the enrolment status accordingly, and anything else marks it failed.

Enrolment progression

After the tick above, the enrolment had advanced. This is the real row:

GET/sequences/{sequence_id}/enrollments
Real response
{
  "id": "9a1ffb16-ff73-4042-8878-4fb07350f9ab",
  "contact_name": "Sam Rivera",
  "contact_email": "[email protected]",
  "status": "active",
  "current_step": 2,
  "next_action_at": "2026-08-09T18:30:58.230927+00:00",
  "last_step_at":   "2026-08-06T18:30:58.230927+00:00",
  "paused_at": null,
  "completed_at": null,
  "last_error": null,
  "variant_picks": {
    "f05bf6c8-9b72-458c-8051-ddf1b1052190": {
      "at": "2026-08-06T18:30:58.230927+00:00",
      "label": "B",
      "step_id": "25248cf0-8a6f-40a7-8b62-9b829773c968"
    }
  },
  "enrolled_at": "2026-08-06T18:30:46.679672+00:00"
}
  • current_step moved to 2 and next_action_atis three days out — exactly the delay_days: 3 configured on step 2.
  • variant_picks records that this contact was assigned arm B, keyed by the parent step id. That is the sticky assignment.
  • last_error is cleared on a successful send, so a stale error from a deferred attempt does not linger.

Individual enrolments can be paused, resumed or removed:

Shell
curl -s -X POST   "$API/sequences/<seq>/enrollments/<enr>/pause"  -H "Authorization: Bearer $TOKEN"
curl -s -X POST   "$API/sequences/<seq>/enrollments/<enr>/resume" -H "Authorization: Bearer $TOKEN"
curl -s -X DELETE "$API/sequences/<seq>/enrollments/<enr>"        -H "Authorization: Bearer $TOKEN"

Reading results

GET/sequences/{sequence_id}/analytics
Real response, after the one send
{
  "sequence_id": "81feb53b-17c3-4d21-970a-63976f5cfb57",
  "status": "active",
  "steps": [
    { "step_number": 1, "sent": 1, "opened": 0, "replied": 0, "clicked": 0,
      "bounced": 0, "failed": 0, "drop_off": 0, "drop_off_rate": 0.0,
      "variants": [
        { "step_id": "25248cf0-…", "label": "B", "weight": 50,
          "sent": 1, "opened": 0, "replied": 0, "open_rate": 0.0, "reply_rate": 0.0 }
      ] },
    { "step_number": 2, "sent": 0, "drop_off": 1, "drop_off_rate": 100.0,
      "variants": [] }
  ],
  "totals": { "sent": 1, "opened": 0, "replied": 0, "clicked": 0,
              "bounced": 0, "failed": 0, "tasks_created": 0 },
  "enrollment_counts": { "active": 1 },
  "enrolled": 1,
  "best_step": { "step_number": 1, "reply_rate": 0.0, "open_rate": 0.0, "sent": 1 },
  "worst_step": null
}
Variant counts roll up into their parent step, so step-level numbers stay comparable whether or not a step is split.

Suppression, unsubscribe and reply detection

Three independent things end an enrolment early.

Suppression and unsubscribe

Every step passes through the same gate as a one-off send. Two flags are checked in order, and the error names which fired:

ReasonSourceEnrolment becomes
unsubscribedthe contact’s own email_subscribed flagunsubscribed
suppressedan organisation-level suppression rowunsubscribed
Releasing a suppression alone does not resume sending — the contact flag is separate. See Common problems.

Recipients unsubscribe through the hosted page on every tracked message, including the RFC 8058 one-click target that Gmail and Outlook call directly. That path is covered in Sending email.

Reply detection

Replies are found by IMAP polling, not by webhook. The poller reads unseen mail, matches the sender against recent tracked sends, marks the row replied, and — when stop_on_replyis set — stops that contact’s active enrolments.

It also categorises intent from the sender’s own words: the quoted original is cut away first, because your own unsubscribe footer coming back inside a quote would otherwise file a warm reply as unsubscribed and suppress a live prospect. A reply categorised as unsubscribed does suppress the address automatically.

Pausing and retiring a sequence

Shell
# stop the whole sequence; enrolments hold where they are
curl -s -X POST "$API/sequences/<id>/pause"     -H "Authorization: Bearer $TOKEN"

# copy it, including steps and variants, as a new draft
curl -s -X POST "$API/sequences/<id>/duplicate" -H "Authorization: Bearer $TOKEN"

# retire it without deleting the history
curl -s -X POST "$API/sequences/<id>/archive"   -H "Authorization: Bearer $TOKEN"

Duplicating is the right move for iterating on copy: editing a live sequence changes what in-flight enrolments will receive at their next step, which makes the analytics on it uninterpretable.

Common problems

Dispatch reports processed: 0 and nothing sends

Work down this list:

  • Is the sequence active? A draft sequence is skipped entirely.
  • Is it inside send_window_startsend_window_end in send_timezone, and is today in send_days? A window in UTC does not follow your local clock.
  • Is next_action_at in the future? After a step fires the next one is scheduled delay_days ahead.
deferred climbing on every tick

Something reusable is blocking, not something per-contact. Check last_error on an affected enrolment. Common causes: a mailbox whose OAuth grant expired (needs_reconnect: true on GET /webmail/accounts), or the organisation daily ceiling — compare org_sent_today with org_daily_ceiling on GET /deliverability/inboxes. Deferred means it will retry; it is not data loss.

Enrol returns enrolled: 0 with no error

Read the skip counters in the same response. skipped_existingmeans those contacts are already enrolled — a contact cannot be in the same sequence twice. skipped_no_email catches records that were never mailable, which is the usual outcome of enrolling saved leads that were never converted to contacts.

A contact stayed in the sequence after replying

Confirm stop_on_reply is set on the sequence, then confirm reply detection can see the reply at all: it needs a mailbox connected as an IMAP integration, and it only reads unseen messages from the last couple of days. A reply that another client already marked as read is invisible to it. A reply from a different address than the one you mailed will not match either.

Both A and B went to the same person

That should not happen — the arm is chosen once and written to variant_picks on the enrolment. If you see it, check whether the contact exists twice under the same address and was enrolled as two separate contacts. Duplicate contacts are the usual explanation, and GET /contacts filtered by email will show them.

A step went out with {{something}} visible in it

Unknown merge tags are passed through verbatim rather than blanked. The valid ones are first_name, last_name, full_name, email, company_name, job_title, city and country — note company_name, not company. Preview every step against a real contact before activating.

Next