Guide
Run a campaign
Audience × sequence × inbox set × schedule. Start, pause, resume.
A campaign is an orchestration over four ingredients:
- An audience — a named collection of contacts.
- A sequence — a reusable list of email steps with subject, body, and inter-step delay.
- An inbox set — one or more inboxes the campaign rotates across.
- A schedule — timezone, business hours, days of week.
The engine walks each contact through the sequence at the schedule, producing one Send per step per contact. Campaigns are one shape that produces sends — single POST /v1/sends calls produce sends through the same pipeline.
End-to-end
1. Create a sequence
const sequence = await c.sequences.create({
name: "Founder outbound, Q2",
steps: [
{
step_order: 0,
subject: "Quick question about your stack",
body_text: "Hi {{first_name}}, would love to chat — does Tuesday work?",
delay_minutes: 0,
},
{
step_order: 1,
// Blank subject on a follow-up: sent as "Re: <the subject this lead got>"
// in the same thread.
subject: "",
body_text: "Bumping this. Open to a 15-minute call?",
delay_minutes: 60 * 24 * 3, // 3 days
},
],
});
Delays are in minutes (delay_minutes); the older whole-day delay_days still works, and delay_minutes wins when both are set. Follow-ups still go out inside the campaign’s schedule window. Only the first step needs a subject. Personalise with {{first_name}} (any contact attribute), a fallback for blank values like {{first_name|there}}, the built-ins {{email}}, {{sender_name}} and {{sender_email}}, and spintax like {Hi|Hello|Hey}, which picks one option per lead (only in your template text, never inside a contact’s values).
Steps are added, edited, moved (step_order on PATCH, or POST /v1/sequences/{id}/steps/reorder with every step id in the new order) and deleted on the sequence. Step HTML is sanitized on save.
2. Build an audience
Either upload contacts via POST /v1/contact_imports and target the audience there, or attach existing contacts:
await c.audiences.attachContacts("aud_01HZ...", contactIds);
3. Create the campaign
const campaign = await c.campaigns.create({
name: "Founder outbound — May",
sequence_id: sequence.id,
sending_inbox_ids: ["inb_A", "inb_B"],
schedule: {
timezone: "America/New_York",
days_of_week: ["mon", "tue", "wed", "thu"],
business_hours: { start: "09:00", end: "17:00" },
},
track_opens: false,
track_clicks: true,
inject_unsubscribe_footer: true,
});
await c.campaigns.attachContacts(campaign.id, { audience_id: "aud_01HZ..." });
Instead of (or as well as) listing inboxes, pass sending_inbox_tags: ["outbound"]: every active inbox tagged outbound joins the rotation, including inboxes you tag later. Rotation skips any inbox that has reached today’s cap — its daily_limit, or the lower ramp-up value while ramp.enabled is on (see effective_daily_limit on the inbox). Campaign emails carry the sending inbox’s signature.
4. Dry-run before committing
?dry_run=true returns the projected schedule — which inbox, when, to whom — without committing any sends. Show it to the human, get a thumbs-up, then start for real.
const projection = await c.campaigns.start(campaign.id, { dryRun: true });
console.log(projection.steps.length, "sends would be scheduled");
The dry-run response is a CampaignDryRun, distinct from the Campaign you get without dry_run. Branch on result.object.
5. Start
const live = await c.campaigns.start(campaign.id);
console.log(live.status); // "running"
Lifecycle
draft → runningviastartrunning → pausedviapausepaused → runningviaresumerunning → completedautomatically once every contact has finished the sequence
Pausing freezes new sends in their tracks but lets in-flight sending jobs finish. Resuming re-arms the scheduler from where it left off.
Editing a running campaign
A campaign works on its own copy of the sequence’s steps, so editing the sequence later doesn’t change it. Edit the campaign’s steps directly:
await c.campaigns.updateStep(campaign.id, stepId, { subject: "", delay_minutes: 120 });
await c.campaigns.addStep(campaign.id, { body_text: "Last note from me.", delay_days: 4 });
await c.campaigns.reorderSteps(campaign.id, [step1, step3, step2]);
await c.campaigns.deleteStep(campaign.id, step3);
The rules while leads are mid-sequence:
- Content and delays can change at any time. Changes apply to emails rendered from then on. An email already queued keeps its content, and a lead already waiting for its next step keeps the send time it was given.
- Reorder, delete and insert only after the steps leads have reached. Once an active lead has been sent a step (or has it queued), that step and every step before it keep their position. Changing them returns
409 sequence_step_locked. Pause the campaign and edit the content instead, or rearrange later steps. This way no lead skips or repeats a step. - Appending always works. Leads that already finished don’t get the new step, and a completed campaign stays completed. Enrolling new contacts reopens it.
A/B variants
A step’s variants are sent in an even split, with each lead pinned to one variant. Set winning_variant_id to send only that variant from then on. To edit a variant, keep its id and change its content. To delete one, leave it out of the array. Emails it already sent keep their variant_id and stats, and a deleted winner falls back to the even split. disabled: true keeps a variant and its stats but stops sending it. At least one variant must stay enabled.
Per-contact progress
const progress = await c.campaigns.listContacts(campaign.id, { limit: 100 });
for (const p of progress.data) {
console.log(p.contact_id, p.current_step, p.status, p.next_send_at);
}
status here is per-contact: pending, in_progress, completed, unsubscribed, bounced, replied. Reply detection automatically advances the contact out of the sequence — no follow-ups go out after a real reply.
Stats
const stats = await c.campaigns.stats(campaign.id);
// stats.totals: { sent, delivered, open, click, reply, bounce, unsubscribe }
// stats.daily: per-day breakdown
What about one-off sends?
Campaigns aren’t required. If you just need to fire a single email or a small fan-out without the orchestration, hit POST /v1/sends directly — see the sends guide.