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:writescope. Every read below (audience preview, dry-run, stats) also acceptscampaigns: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 amessage_template. The template is optional at creation — a draft without one cannot launch — but providing it up front keeps the draft complete.
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. Passaudience_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:
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).
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.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 ifvariables.campaign_holdout_percentis 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_estimateandquiet_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.
- 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 atGET /campaigns/approvals/pending, and org defaults atGET/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.
warnings: [] and cost.sufficient: true clears launch.
6. Launch and choose the send shape
Launch withPOST /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 withPOST /campaigns/:id/preview-smart-send, which returns per-contact recommended times plus anext_best_channelhint for the sample. To measure whether the timing itself is paying off, opt into a per-recipient hold-out cohort withvariables.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 tocampaign.* events to drive your own CRM or alerting:
campaign.created/campaign.updated/campaign.deletedcampaign.started/campaign.paused/campaign.resumed/campaign.cancelled/campaign.completedcampaign.drip_step.sent/campaign.drip_step.completed/campaign.drip.completed
9. Troubleshooting
- Audience resolves to zero. Re-run the audience preview with a
channelpassed; 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” meanscost.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_centsor a broken drip step condition. InspectGET /campaigns/:id/stats, clear the blocker, thenPOST /campaigns/:id/resume. Paused journeys keep their enrollment state; resuming continues where they stopped. - Smart-send spreads deliveries too thin.
recipient-optimalintentionally fans sends across 24h. If you need one batch, setsend_time_optimization: "fixed"and usesend_windowif 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.- Create the draft.
- Preview the audience.
matching_count, a small suppressed + opted_out cohort, and an unreachable of zero.
- Dry-run it.
audience.deliverable (net of the 10% holdout), cost.sufficient: true, and no warnings.
- Preview smart-send times (optional).
- Launch.
-
Subscribe to lifecycle events (
campaign.started,campaign.completed) and read stats as deliveries accumulate. - Measure. After the send completes: