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 → running via start
  • running → paused via pause
  • paused → running via resume
  • running → completed automatically 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.