> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Send a campaign end-to-end: draft, audience, dry-run, launch, analytics

> Take a campaign from draft to launch to measurement — pick an audience, preview recipients, run the pre-flight dry-run, launch as a blast or drip, and read stats, holdout lift, and ROI.

# 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](/api-reference/endpoints/campaigns). This page covers the workflow; the [A/B testing guide](/guides/campaign-ab-testing) covers variants, and [best practices](/guides/best-practices) and [opt-out lists](/guides/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](/guides/10dlc-registration)); 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.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Retail promo — summer sale",
    "type": "blast",
    "channel": "sms",
    "audience_type": "list",
    "audience_id": "list_summerVIPs",
    "message_template": "Hi {{first_name}}, early access to our summer sale starts now: 25% off through Sunday. Reply STOP to opt out.",
    "send_time_optimization": "fixed"
  }'
```

Key fields on create:

| Field                    | Notes                                                                                                               |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `type`                   | `blast` (one scheduled send), `drip` (multi-step sequence), `journey` (event-driven), or `ad`.                      |
| `audience_type`          | `all`, `list`, `segment`, `csv`, or `manual` (hand-picked contact ids).                                             |
| `audience_id`            | Required when `audience_type` is `list` or `segment`.                                                               |
| `audience_source`        | Optional CRM descriptor (`hubspot_list`, `salesforce_report`, …) that overrides the list/segment pair at send time. |
| `message_template`       | Up to 100,000 chars; email bodies fit comfortably. Personalization tokens use `{{token}}` form.                     |
| `subject`                | Email-only subject line. Ignored for non-email channels.                                                            |
| `send_time_optimization` | `fixed` (one batch time) or `recipient-optimal` (per-contact best-time, see step 6).                                |
| `throttle_rate`          | Send ceiling in messages/second, 0–1000. Absent/0 = platform default pacing.                                        |
| `scheduled_at`           | ISO timestamp for the launch, or edit later. Must be in the future.                                                 |
| `recurrence`             | Optional schedule for recurring campaigns.                                                                          |
| `credit_cap_usd_cents`   | Hard spending cap; the campaign auto-pauses when reached.                                                           |

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:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns/audience/preview" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "audience_type": "list",
    "audience_id": "list_summerVIPs",
    "channel": "sms"
  }'
```

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.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123/dry-run" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

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](/guides/smart-route-preview) 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](/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](/guides/webhook-consumer) 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.**

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Retail promo — summer sale",
    "type": "blast",
    "channel": "sms",
    "audience_type": "list",
    "audience_id": "list_summerVIPs",
    "message_template": "Hi {{first_name}}, early access to our summer sale: 25% off through Sunday. Reply STOP to opt out.",
    "send_time_optimization": "recipient-optimal",
    "throttle_rate": 100,
    "variables": { "campaign_holdout_percent": 10 },
    "credit_cap_usd_cents": 50000
  }'
```

2. **Preview the audience.**

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns/audience/preview" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "audience_type": "list", "audience_id": "list_summerVIPs", "channel": "sms" }'
```

Expect a healthy `matching_count`, a small `suppressed` + `opted_out` cohort, and an `unreachable` of zero.

3. **Dry-run it.**

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123/dry-run" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

Confirm `audience.deliverable` (net of the 10% holdout), `cost.sufficient: true`, and no warnings.

4. **Preview smart-send times** (optional).

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123/preview-smart-send" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

5. **Launch.**

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123/send" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

6. **Subscribe to lifecycle events** (`campaign.started`, `campaign.completed`) and read stats as deliveries accumulate.

7. **Measure.** After the send completes:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123/holdout-lift" \
  -H "X-API-Key: $ORBIT_API_KEY"
curl "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123/roas" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

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.
