Skip to main content

Send a campaign end-to-end

This guide walks the full campaign lifecycle over the API: create a draft, attach an audience, preview personalization, run the pre-flight dry-run, launch, and measure the result. Follow it once and the same sequence works for every channel — SMS, WhatsApp, email, RCS, voice, push. For request and response schemas, see the campaigns API reference. This page covers the workflow; the A/B testing guide covers variants, and best practices and opt-out lists cover deliverability and compliance specifics — linked rather than repeated below.

1. Prerequisites

  • An API key with the campaigns:write scope. Every read below (audience preview, dry-run, stats) also accepts campaigns:read, but launch actions require write. Create or edit keys in the dashboard under Settings → API keys.
  • A sending identity for your channel. For SMS/MMS that means a number or sender pool with the right registrations (US A2P requires 10DLC); for WhatsApp, a connected WABA; for email, a verified sender domain.
  • A sanctioned audience source. A contact list, a live segment, a CSV of recipient addresses, an external CRM audience (HubSpot list / Salesforce report), or a hand-picked set of contact ids. Audiences must come from opted-in recipients — suppression and unsubscribe handling is enforced at send time, not left to the sender.

2. Create the draft campaign

Create the campaign with a channel, an audience, and a message_template. The template is optional at creation — a draft without one cannot launch — but providing it up front keeps the draft complete.
Key fields on create: The response returns the campaign in draft status. Nothing sends until step 6 explicitly launches it.

3. Attach and verify the audience

Three audience shapes are common; all preview the same way. List or segment. Pass audience_type: "list" | "segment" with an audience_id, or a CRM audience_source descriptor. Segments evaluate live at send time, so a growing segment keeps working. CSV recipients. For a one-off roster, POST addresses directly on the campaign’s variables.csv_recipients array (E.164 for phone channels). The campaign stores them, and the resolver marks undeliverable entries rather than inventing recipients. Manual picks. audience_type: "manual" with variables.manual_contact_ids targets specific contact ids — same eligibility math as a list. Before launch, always count what the audience resolves to. POST /api/v1/campaigns/audience/preview accepts the same audience shape the campaign carries and returns matching_count plus a 10-contact sample:
Pass channel and the response also returns a net projection: gross matches minus suppression-list rows, minus channel-level opt-outs, minus contacts with no deliverable address for that channel (e.g. no email on file for an email campaign). An advisory frequency_capped estimate shows roughly how many sends a frequency cap will hold back. The preview is cached for five minutes per criteria, so a re-render of the same drawer is cheap. If matching_count comes back 0, fix the audience before going further — the dry-run (step 5) reports empty audiences loudly, but the preview is where you iterate.

4. Personalization and locale

Templates use {{token}} placeholders resolved per-recipient from contact fields and campaign variables{{first_name}} is the common case. Unresolved tokens fall back to empty strings at send time, and the preview endpoint tells you which tokens resolve per contact so a misspelled {{frist_name}} is caught before launch. To translate a saved template into another locale, use POST /api/v1/campaigns/templates/translate, which rewrites a template body for a target language while preserving tokens and opt-out instructions. Assertions to keep in mind:
  • Tokens are preserved verbatim across the translation.
  • The single opt-out instruction stays exactly once (carrier requirement).
Store the localized body as variant templates on the campaign, or segment the audience by language and run one campaign per locale. Either way, the dry-run (next step) validates the final template.

5. Pre-flight: run the dry-run

Before launch, POST the dry-run. It is read-only — it never mutates state, never touches the wallet, never enqueues a job — so call it on every wizard keystroke if you like.
The report bundles eight buckets:
  • audience.total_in_audience — gross matching count.
  • audience.suppressed / opted_out / unreachable — net-projection cohorts (suppression list, per-channel opt-outs, missing channel address).
  • audience.held_out — recipients carved into the incrementality control cohort if variables.campaign_holdout_percent is set; excluded from delivery.
  • audience.deliverable — the exact net count the launch will attempt.
  • cost.unit_price_cents / cost.total_cents — priced through the same resolver the send path uses, so the number matches the wallet deduction within rounding.
  • cost.balance_cents / cost.sufficient — wallet check; a green dry-run implies a green launch balance gate.
  • quiet_hours.skipped_estimate and quiet_hours.window — projected skip ratio from recipient-local quiet hours, plus the resolved window being evaluated.
  • channel_waterfall — the primary channel and its cross-channel fallback shape, so you can verify RCS → SMS (or whatever chain you set) before pressing send.
  • warnings[] — non-blocking issues (no registered provider for the channel, quiet hours fully closing the window, …) rendered separately from hard failures.
Two optional pre-flight gates:
  • Approvals. If your org requires a supervisor sign-off, the launch (POST /campaigns/:id/send) returns a pending-approval state instead of sending; the approver queue lives at GET /campaigns/approvals/pending, and org defaults at GET/PUT /campaigns/approvals/settings.
  • Provider readiness. The dry-run checks list hygiene and provider registration implicitly through its warnings — a channel with no configured provider shows up there instead of failing at send.
A healthy dry-run returning warnings: [] and cost.sufficient: true clears launch.

6. Launch and choose the send shape

Launch with POST /api/v1/campaigns/cmp_abc123/send (or scheduled_at for a future batch). Four send shapes to pick from: Blast. One batch at one time. type: "blast". The simplest shape — the whole deliverable audience goes in one pass at throttle_rate pacing. Drip sequence. type: "drip" with a steps array (up to 50 steps). Each step has its own channel, delay, and optional read-condition guard. message_template on the campaign is the step-0 welcome message. Drip progress shows under GET /campaigns/:id/drip-analytics. Journey. type: "journey" — event-driven enrollment with per-node analytics at GET /campaigns/:id/journey-analytics and node breakdowns at GET /campaigns/:id/journey/node-analytics. A journey simulation endpoint (POST /campaigns/journeys/simulate) dry-runs the graph before launch just like the blast dry-run does for audiences. Channel fallback ladder. For omnichannel campaigns, the channels array is an ordered chain (max depth enforced, no duplicate channels). Each non-final entry carries a fallback_on trigger — failed, no_delivery, or no_engagement — plus a fallback_after_seconds window. The last entry is terminal and carries no fallback fields. When the chain exists, the top-level channel must equal its first entry. Whatever the shape, these pacing controls apply:
  • Throttle. throttle_rate (msgs/sec) caps the send pace — the platform default is 50 msg/s; an explicit 0 also means default.
  • Smart send. send_time_optimization: "recipient-optimal" schedules each recipient at their next best-time window from engagement profiles, and spreads delivery across the next 24h. Preview it first with POST /campaigns/:id/preview-smart-send, which returns per-contact recommended times plus a next_best_channel hint for the sample. To measure whether the timing itself is paying off, opt into a per-recipient hold-out cohort with variables.smart_send_holdout_pct — see step 7.
  • Channel preview. If the campaign’s channel is still open, validate it against a sample of recipients with POST /messages/route-preview — the smart router’s recommendation, fallback chain, per-message cost, and engagement score, with nothing sent. The smart route preview guide walks the endpoint and the send-pipeline pattern.
  • Send window. send_window: { start_local_hour, end_local_hour } defers recipients whose local time falls outside the window — a positive per-recipient window, distinct from quiet hours (a negative filter).

7. Observe and measure

Live stats. GET /campaigns/:id/stats returns totals for sent, delivered, failed, opened, clicked, replied. For finer breakdown, drip and journey campaigns expose /drip-analytics, /journey-analytics, and /journey/node-analytics respectively. Retries follow the messaging retry policy — failures with retryable codes re-enqueue until the policy exhausts. Exit criteria. Declare conversion-goals as exit criteria (or exit_goals on drips): when a contact reaches the goal (e.g. purchase_completed), they leave the sequence instead of receiving the rest. Validate exit criteria at save time; the evaluator enforces them at runtime. Holdout lift. If the campaign set a holdout (campaign_holdout_percent in variables, or ab_holdout_percent on A/B tests), GET /campaigns/:id/holdout-lift compares the treatment cohort against the control and returns a conversion-rate delta with a Wald confidence interval — so you can see whether the message beat doing nothing, not just which variant won. Journeys expose the same under GET /campaigns/:id/journey/holdout-lift; smart-send under GET /campaigns/:id/smart-send-holdout-lift. Smart-send hold-out (per-recipient timing). The campaign-wide holdout above answers “did sending at all help?” — the control cohort gets no message. To isolate the send-time optimization itself, set variables.smart_send_holdout_pct (0–50, values above 50 are clamped) on a recipient-optimal campaign. That slice of recipients keeps the natural pre-optimization schedule while everyone else gets their predicted best time; both arms still receive the campaign, so the comparison isolates the timing. Assignment is deterministic per contact (stable across pause/resume and re-launches) and uses its own seed, uncorrelated with the campaign-wide holdout. Unset or 0 means no hold-out. Read the result with GET /campaigns/:id/smart-send-holdout-lift, which returns configured, holdout_pct, the primary lift on the clicked conversion signal, and a metrics breakdown scored on delivered/read/replied so link-less channels (plain SMS, most WhatsApp) still get a real number. A campaign with no hold-out configured returns configured: false with a 200 rather than an error. ROI / ROAS. GET /campaigns/:id/roas returns attributed revenue (last-touch and multi-touch breakdowns), the click funnel, and holdout-incremental revenue. GET /campaigns/:id/roas/touchpoints shows the per-touch contribution. Use GET /campaigns/:id/cost and /campaigns/:id/export to reconcile spend and pull raw rows. Recurring measurement. For scheduled recurring campaigns the same stats endpoints apply per occurrence; GET /campaigns/stats aggregates across campaigns.

8. Subscribe to campaign webhooks

Register an endpoint (see webhooks overview) and subscribe to campaign.* events to drive your own CRM or alerting:
  • campaign.created / campaign.updated / campaign.deleted
  • campaign.started / campaign.paused / campaign.resumed / campaign.cancelled / campaign.completed
  • campaign.drip_step.sent / campaign.drip_step.completed / campaign.drip.completed
For signature verification, retries, and dead letters, the durable webhook consumer guide walks the full pattern.

9. Troubleshooting

  • Audience resolves to zero. Re-run the audience preview with a channel passed; the net projection tells you which cohort emptied it (all suppressed, all opted out, no reachable address). Fix the list or the channel address coverage, not the campaign.
  • Dry-run shows red warnings. warnings[] entries are explicit — “no provider registered” means the channel has no configured sending identity; “quiet hours fully closed” means the resolved window rejects every local hour; “insufficient balance” means cost.sufficient: false. Clear the warning and re-run the dry-run.
  • Launch returns 402 or a pending-approval shape. Balance check failed, or the approvals workflow is on — either top up the wallet or wait for POST /campaigns/approvals/:id/approve.
  • Journey campaign stuck in paused. A campaign auto-pauses when it hits credit_cap_usd_cents or a broken drip step condition. Inspect GET /campaigns/:id/stats, clear the blocker, then POST /campaigns/:id/resume. Paused journeys keep their enrollment state; resuming continues where they stopped.
  • Smart-send spreads deliveries too thin. recipient-optimal intentionally fans sends across 24h. If you need one batch, set send_time_optimization: "fixed" and use send_window if recipients span timezones.

Worked example: retail promo, draft to analytics

A summer-sale SMS promo to a VIP list, with a holdout so the lift number is real.
  1. Create the draft.
  1. Preview the audience.
Expect a healthy matching_count, a small suppressed + opted_out cohort, and an unreachable of zero.
  1. Dry-run it.
Confirm audience.deliverable (net of the 10% holdout), cost.sufficient: true, and no warnings.
  1. Preview smart-send times (optional).
  1. Launch.
  1. Subscribe to lifecycle events (campaign.started, campaign.completed) and read stats as deliveries accumulate.
  2. Measure. After the send completes:
The holdout-lift response tells you the promo’s message outperformed the do-nothing control (or didn’t), and the ROAS report attributes revenue per touch. Either fold the winner into the next campaign or revise the template and rerun this sequence.