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. SDK users can run the whole lifecycle below without cURL: each step pairs its curl block with a Node SDK tab — the typedorbit.campaigns.* helper where one exists, otherwise the SDK’s generic orbit.request(method, path, body) escape hatch, which covers every endpoint on the API surface.
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. - Node SDK (optional).
npm install @devotel-orbit/nodeif you prefer the SDK tabs over cURL. - 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.
name / channel / message_template trio. To pass the full body shown in the curl tab (type, audience_type, send_time_optimization, …), the SDK’s orbit.request("POST", "/campaigns", body) escape hatch accepts any fields. Either form requires the campaigns:write scope on your API key.
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. 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. The campaign journey builder guide walks the visual canvas, graph validation gates, simulator, and conversion-goal wiring end to end.
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. The unified fallback chains guide compares this surface with per-message cascade_policy and Verify-profile chains, and walks worked examples for both.
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. To arm that chain for the send (so a terminal failure cascades instead of stopping at the first channel), passcascade_policyon the same request — the smart-send fallback chains guide covers the per-message policy shape. 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. From the SDK, that is orbit.request("GET", \/campaigns/$/stats`)— the escape hatch again, since the typed resource stops atsend. 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:
orbit.request() escape hatch for the rest: