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.
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.
/sequences201 Created{
"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>" }
]
}curl -s -X POST "$API/sequences" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
--data-binary @sequence.json{
"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
| Field | Default | Effect |
|---|---|---|
stop_on_reply | true | A detected reply ends the enrolment. The single most important setting on this object. |
stop_on_meeting | true | A booked meeting ends the enrolment — no “just following up” after someone already said yes. |
stop_on_click | false | Off by default. A click is interest, not a conversation, and stopping on it usually loses the follow-up that would have converted. |
send_window_start | null | Outside the window, steps wait. With no window set, a tick can send at any hour. |
send_days | all days | Weekday names. Saturday sends are the easiest own-goal in outbound. |
send_timezone | UTC | The window and days are evaluated in this zone, not the recipient’s. |
daily_cap | null | Ceiling for this sequence per day, on top of per-mailbox caps and the organisation ceiling. |
send_as_reply | false | Threads later steps under the first message rather than sending fresh subjects. |
sender_account_id | null | Pins 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.
/steps/{step_id}/variantscurl -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
}'{
"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
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}/previewcurl -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. { "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
Activate
curl -s -X POST "$API/sequences/81feb53b-…/activate" \ -H "Authorization: Bearer $TOKEN"Status moves
draft→active. Nothing sends until someone is enrolled and a dispatch tick runs. - 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}/enrollcurl -s -X POST "$API/sequences/81feb53b-…/enroll" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"contact_ids":["613f2aec-14b8-4281-9ced-19fc2c8d38a8"]}'{ "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:
/sequences/dispatch-nowcurl -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}}| Counter | What it means |
|---|---|
| processed | Enrolments the tick looked at. |
| sent | Emails the mail server accepted. |
| deferred | The step will be retried. Nothing is wrong with the contact — see below. |
| skipped | The enrolment was gated: a cap, the organisation ceiling, or a suppression. |
| failed | The send was attempted and rejected. |
| completed | The enrolment reached the end of the sequence. |
| stopped | A stop condition fired — a reply, a meeting, a click. |
| tasks | Non-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_atis pushed out by 30 minutes.- It is counted as
deferred, notfailed, 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:
/sequences/{sequence_id}/enrollments{
"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_stepmoved to 2 andnext_action_atis three days out — exactly thedelay_days: 3configured on step 2.variant_picksrecords that this contact was assigned arm B, keyed by the parent step id. That is the sticky assignment.last_erroris cleared on a successful send, so a stale error from a deferred attempt does not linger.
Individual enrolments can be paused, resumed or removed:
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
/sequences/{sequence_id}/analytics{
"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
}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:
| Reason | Source | Enrolment becomes |
|---|---|---|
unsubscribed | the contact’s own email_subscribed flag | unsubscribed |
suppressed | an organisation-level suppression row | unsubscribed |
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
# 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? Adraftsequence is skipped entirely. - Is it inside
send_window_start–send_window_endinsend_timezone, and is today insend_days? A window in UTC does not follow your local clock. - Is
next_action_atin the future? After a step fires the next one is scheduleddelay_daysahead.
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
Deliverability
The caps, rotation and health rules that decide whether a step can send at all.
Leads and enrichment
Find and convert the contacts you are about to enrol.
Sending email
Tracking, merge fields and the suppression gate every step passes through.
REST API reference
All 23 sequence routes with parameters and error cases.