> ## 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.

# Build, simulate, and launch a campaign journey (canvas, nodes, goals)

> Build an event-driven campaign journey in the visual canvas, validate the graph (cycles, channels, exit criteria), dry-run it in the simulator, attach a conversion goal, and launch — with per-node analytics after it goes live.

# Build, simulate, and launch a campaign journey

Journeys are the event-driven campaign type: instead of blasting one audience on a schedule, event-driven enrollment starts a contact whenever something happens — a signup event, a segment change, a webhook call, another journey's hand-off — and walks that contact through a graph of messages, waits, branches, and exit rules. This guide builds a welcome journey end-to-end: draw the graph in the journey canvas, pass the validation gates, dry-run it in the simulator, attach a conversion goal, launch, and read per-node analytics.

For the campaign lifecycle and blast/drip launch mechanics, see [Send a campaign end-to-end](/guides/campaign-end-to-end). This page covers the journey graph itself. The [Build your first automation flow](/guides/build-first-flow) guide covers Flows — the sibling automation canvas for inbound conversations; journeys are the outbound-marketing counterpart, built inside a campaign.

## 1. When to pick `type: "journey"`

A campaign's `type` decides how contacts enter, and that choice is made once at create time:

| Type      | Enrollment                                                | Pick it when                                                                                                                     |
| --------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `blast`   | Whole audience at launch                                  | One scheduled send to a full list or segment.                                                                                    |
| `drip`    | Audience resolved once, stepped through together          | A fixed multi-step sequence where everyone runs the same clock (onboarding series, nurture cadences).                            |
| `journey` | A trigger event or rollout enrolls contacts one at a time | Behavior-triggered messaging — welcome series on signup, cart reminders on an event, win-back when a segment is entered or left. |
| `ad`      | Ad-platform sync                                          | Advertising audience export, not messaging.                                                                                      |

The trade: a blast or drip audience is resolved at launch; a journey keeps accepting enrollments for as long as it is `running`. Drips and journeys both engage the per-contact step executor and support the single-contact enrollment endpoints (`POST /campaigns/:id/enroll-contact`, `/enroll-segment`); a `blast` campaign refuses those endpoints with `CAMPAIGN_TYPE_NOT_ENROLLABLE`. If the right mental model is "whenever a contact does X, start them here," use `type: "journey"`.

Create the campaign with `type: "journey"` and the smallest possible audience — trigger-not-audience journeys still need a defined audience when the trigger type is `segment`, `list`, or `all`:

```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": "Welcome series — new signups",
    "type": "journey",
    "audience_type": "all",
    "message_template": "Welcome journey for new signups"
  }'
```

For event triggers (`event`, `webhook`, `api`, `inbound_reply`), no audience is evaluated — the trigger decides who enters. For `list`/`segment`/`all` triggers, the audience is resolved once at entry like a blast. Either way, the graph lives on the campaign under `variables.journeyDefinition` (see the graph contract below), and the campaign's computed `channel` analytics label is derived from the most-used send channel in the graph.

## 2. The journey canvas

The visual builder (dashboard → Campaigns → Journey) is a flow canvas: a left palette of node types, a canvas you wire them together on, a config panel for the selected node, and save/activate actions along the toolbar. The canvas renders one node renderer per type, so every step shows its own summary (channel, delay, branch handles) inline.

Node vocabulary on the palette, with the edges they expect:

| Node                   | What it does                                                                                                                                                                                                                                           |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Send Message**       | Sends on one channel (`sms`, `whatsapp`, `email`, `rcs`, `viber`, …). Leaving the channel unset defaults to `sms` in the builder, the validator, and the executor — deliberate, so draft nodes don't block saves.                                      |
| **Voice Call**         | Outbound call routed via the Devotel wholesale softswitch, with optional answering-machine detection.                                                                                                                                                  |
| **USSD Menu**          | Network-initiated USSD session (CON = keep session for a reply; END = terminal screen).                                                                                                                                                                |
| **Smart Channel**      | Picks the contact's best engagement channel; carries a `fallbackChannel` used when no profile exists.                                                                                                                                                  |
| **Channel Preference** | Ordered `primaryChannel` + `fallbackChannels` branch, trying channels in sequence.                                                                                                                                                                     |
| **Wait / Delay**       | Parks the contact for a relative duration (`delayMinutes`, `delayUnit`).                                                                                                                                                                               |
| **Wait Until Date**    | Pauses until a fixed date/time (`waitTargetDate`) or a per-contact date attribute (`waitDateField`), with an optional offset (e.g. `-4320` = three days before renewal).                                                                               |
| **Wait for Event**     | Pauses until a named event fires (`waitEventName`) or a timeout elapses; the outgoing edges are the `event` and `timeout` handles. Both must be wired.                                                                                                 |
| **Condition**          | Evaluates the contact (`conditionField`, `conditionOperator`, `conditionValue`); outgoing edges are the `yes` and `no` handles. Both must be wired. Regex operators (`matches`/`not_matches`) are rejected at save on invalid or ReDoS-risky patterns. |
| **Score Check**        | Gates on a predictive score (`scoreField`, `operator`, `threshold` — 0..1 for risk/intent/propensity, cents for LTV). Same yes/no handles as Condition.                                                                                                |
| **A/B Split**          | Diverts contacts into weighted variants (`branches` or the newer `variants` shape, with percentages summing to 100).                                                                                                                                   |
| **AI Agent**           | Hands the interaction to an agent id.                                                                                                                                                                                                                  |
| **Ambient Agent**      | Agent-initiated outreach guarded by per-day/per-hour proactive budgets.                                                                                                                                                                                |
| **Update Contact**     | Writes a contact field (`updateField`, `updateValue`).                                                                                                                                                                                                 |
| **Webhook**            | Calls an external endpoint (`webhookUrl`).                                                                                                                                                                                                             |
| **Human Handoff**      | Routes the conversation into a queue or to an explicit user. Terminal.                                                                                                                                                                                 |
| **End Journey**        | Explicit terminal node.                                                                                                                                                                                                                                |

Every type on the palette is one the server's save/launch validator accepts — a step the executor can't run never appears in the sidebar, which is why the list is a curated subset of the full node-type vocabulary (attribute/tag/list steps are authored from the AI Composer or the API, not the palette).

## 3. Build a welcome journey, step by step

A compact welcome series that greets by SMS, waits, and falls back to WhatsApp when there is no reply — sketch first, canvas second:

1. **Journey Trigger** (`journeyTrigger`) — pick `event` and point it at `contact.created`, or `segment` for a staged rollout.
2. **Send Message** — SMS welcome (`{{first_name}}` personalization resolves per contact).
3. **Wait / Delay** — 1 day.
4. **Wait for Event** — wait up to 24h for a `reply.received` event.
5. **Condition** — if the event branch fired, the contact is engaged; on the `timeout` branch, fall to a **Channel Preference** node (`whatsapp` primary) or a plain **Send Message** on WhatsApp.
6. **End Journey** — terminal on both branches.

Wired as a journey-definition graph, the same shape is:

```json theme={null}
{
  "nodes": [
    {
      "id": "n_trigger",
      "type": "journeyTrigger",
      "data": { "label": "Signup trigger", "triggerType": "event" }
    },
    {
      "id": "n_sms",
      "type": "sendMessage",
      "data": {
        "label": "Welcome SMS",
        "channel": "sms",
        "body": "Hi {{first_name}} — welcome aboard."
      }
    },
    {
      "id": "n_wait",
      "type": "waitDelay",
      "data": { "label": "Wait 1 day", "delayMinutes": 1, "delayUnit": "days" }
    },
    {
      "id": "n_wfe",
      "type": "waitForEvent",
      "data": {
        "label": "Wait for reply",
        "waitEventName": "reply.received",
        "waitEventTimeoutAmount": 24,
        "waitEventTimeoutUnit": "hours"
      }
    },
    {
      "id": "n_whatsapp",
      "type": "sendMessage",
      "data": {
        "label": "WhatsApp fallback",
        "channel": "whatsapp",
        "body": "Quick hello from us — anything we can help with?"
      }
    },
    {
      "id": "n_engage",
      "type": "updateContact",
      "data": {
        "label": "Mark engaged",
        "updateField": "journey_engaged",
        "updateValue": "true"
      }
    },
    {
      "id": "n_end",
      "type": "journeyEnd",
      "data": { "label": "End" }
    }
  ],
  "edges": [
    { "id": "e1", "source": "n_trigger", "target": "n_sms" },
    { "id": "e2", "source": "n_sms", "target": "n_wait" },
    { "id": "e3", "source": "n_wait", "target": "n_wfe" },
    { "id": "e4", "source": "n_wfe", "target": "n_end", "sourceHandle": "event" },
    { "id": "e5", "source": "n_wfe", "target": "n_whatsapp", "sourceHandle": "timeout" },
    { "id": "e6", "source": "n_whatsapp", "target": "n_end" }
  ]
}
```

Save it on the campaign under `variables.journeyDefinition`:

```bash theme={null}
curl -X PATCH "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "variables": {
      "journeyDefinition": {
        "nodes": [ ... ],
        "edges": [ ... ],
        "exit_criteria": [],
        "re_entry_policy": "never"
      }
    }
  }'
```

The graph-level knobs live beside `nodes`/`edges`: `exit_criteria` (conversion gates — section 6), `re_entry_policy` (`never` | `after_cooldown_days` | `always`, with `re_entry_cooldown_days` for the cooldown variant), `holdout_pct` (0–50% control-group carve-out for lift measurement), and `respect_smart_send_time` (per-graph optout of send-time optimization).

The branch handles matter for ordering: a `condition`/`scoreCheck` node must carry both `yes` and `no` outgoing edges, a `waitForEvent` node both `event` and `timeout`, an A/B split its declared branch handles — and the executor fails *closed* on an unwired branch: half your contacts have nowhere to go.

## 4. Validation — what the graph gets checked for

Four gate layers run before a journey goes live; the browser builder and the API enforce the same rules on different axis (the builder blocks in-canvas; the server re-checks at save and launch).

**Cycles.** A directed cycle anywhere in the graph blocks save — pre-dating this gate, looping contacts were silently dropped at a depth guard with no operator signal. One exception is intentional: an edge *into* a Wait / Delay node is excluded from the cycle check, because waiting then re-evaluating (daily digest loops) is a legitimate pattern and the runtime allows it. The builder surfaces the offending path (`Wait 1 day → Condition → Welcome SMS → …`).

**Completeness.** Launch requires one entry trigger, a reachable graph, and at least one terminal (End / Human Handoff / Exit). The builder's pre-launch check blocks activation on a lone un-wired trigger, an unsupported trigger type (the allowlist is `event`, `segment`, `date`, `anniversary`, `recurring`, `api`, `webhook`, `inbound_reply`, `all`, `list`), a step type the workspace can't run yet, an un-wired node, a missing yes/no or event/timeout branch, or an unresolvable wait-until-date target.

**Channels.** The persisted campaign `channel` label is derived, per save, from the most-used channel across every channel-bearing node — `sendMessage` (its `data.channel`), `voiceCall` (counts as `voice`), `smartChannel` (`fallbackChannel`), and `channelPreferenceBranch` (`primaryChannel` + `fallbackChannels`); USSD is deliberately excluded from the persisted label because it isn't a member of the campaign channel enum. An absent `channel` on a Send Message node means SMS — the canvas, the config panel, the saved label, the server validator, and the runtime all apply the same default.

**Exit criteria.** Exit-criteria rows are validated at save — a row must declare a `field`, one of the supported operators (`equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `greater_than`, `less_than`, `is_set`, `is_not_set`), and, except for `is_set`/`is_not_set`, a `value`. Blank abandoned rows are dropped silently rather than blocking the save; a half-started row blocks until it's complete. The same enforcement sits on the server save path, so a non-browser caller (SDK, mobile, direct POST) gets the same 400 a browser would have shown inline.

**Sanitize on load.** A graph arriving from an un-trusted source (the AI-composer hand-off or a previously-hand-edited definition) is repaired before render: nodes get guaranteed ids, finite positions, and a label; edges that point at nodes no longer in the graph get dropped. This is why an odd graph can't take the canvas down mid-edit.

If a launch-time `400` names `JOURNEY_DEFINITION_INVALID`, `JOURNEY_TRIGGER_MISSING`, or `JOURNEY_TRIGGER_DISCONNECTED`, the graph failed one of the structural gates — see [Troubleshooting: campaign and journey enrollment errors](/troubleshooting/campaign-journey-errors) for the exact fix per code.

## 5. Simulate before you launch

The journey simulator is a pure, read-only projection of a graph: POST it the same `{ nodes, edges }` the builder holds plus an estimated entry-cohort size, and it returns per-node projected arrivals, per-channel send counts, terminal outcomes (completed / exited / unresolved), structural advisories (dead steps, missing branches, weight drift), and, optionally, what-if scenario comparisons. It never enrolls a contact, never enqueues a job, never initiates a send, never touches the wallet — call it on every save if you like.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns/journeys/simulate" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "nodes": [ ... ],
    "edges": [ ... ],
    "entryVolume": 10000,
    "assumptions": {
      "conditionPassRate": 0.6,
      "costPerSendUsd": { "sms": 0.0075, "whatsapp": 0.005 }
    },
    "scenarios": [
      { "name": "2x signups", "entryVolume": 20000 },
      { "name": "half engaged", "conditionPassRate": 0.3 }
    ]
  }'
```

`assumptions.conditionPassRate` (0–1) sets the default gate pass-rate; `assumptions.passRateByNodeId` and `assumptions.costPerSendUsd` override per node/channel. Up to 20 named `scenarios` re-run the projection on different volumes, pass-rates, or costs and report the delta — use them to answer "what if signups double?" or "what if engagement halves?" before your first live enrollment.

In the builder, the simulator panel runs the same math client-side against the draft state, so the projection it shows matches what this endpoint would return. Run it once with realistic cohort size; a healthy result shows arrivals tapering through branches, no dead steps, and projected spend inside your campaign's `credit_cap_usd_cents`.

## 6. Goals — attach a conversion goal, launch, and read analytics

A conversion goal is a named event the journey aims at — `purchase.completed`, `signup.activated`, `trial.started` — plus an optional event-property filter and an attribution window. Upsert it once per campaign:

```bash theme={null}
curl -X PUT "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123/journey/goal" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "goal_event_type": "purchase.completed",
    "goal_event_filter": { "product_tier": "pro" },
    "conversion_window_hours": 168,
    "description": "Pro upgrade within 7 days of welcome journey"
  }'
```

`conversion_window_hours` defaults to 168 (7 days); set `0` explicitly for unbounded attribution. Conversions are recorded against a contact (first conversion wins; replays are swallowed so the rate stays bounded), with attribution to the last journey node the contact saw — either passed explicitly as `last_node_id` or resolved automatically from the contact's most recent journey message.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123/journey/goal/conversion" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "contact_id": "ct_456", "last_node_id": "n_whatsapp", "value_cents": 4900 }'
```

Launch the journey the same way any campaign launches: `POST /campaigns/:id/send` flips it to `running`, and from then on the trigger's configured sources enroll contacts one at a time. Trigger sources — the event router, segment-entry/exit routers, the inbound-reply router, and direct enrollment endpoints — all read the same saved graph.

Once it is running, read the goal rollup and the per-node breakdown:

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

Returns `total_enrolled`, `converted`, `conversion_rate`, `total_value_cents`, and a `per_step` breakdown sorted by contribution — the step that closed the most conversions, with the same window predicate as the rollup so out-of-window latecomers don't mislead the ranking. Journeys also expose the aggregate funnel at `GET /campaigns/:id/journey-analytics` and the per-node counters (arrivals, sends, exits) at `GET /campaigns/:id/journey/node-analytics`; on a holdout you measure lift with `GET /campaigns/:id/journey/holdout-lift`. Use the node numbers to find the step that's bleeding contacts that would otherwise have converted, and the goal rollup to see whether the journey is paying off in actual conversions.

## Ordering pitfalls worth checking twice

* **Un-wired branch handles.** A `condition` or `scoreCheck` missing its `no` edge, or a `waitForEvent` missing `timeout`, blocks launch — but a malformed draft from the API can technically save while unwired. Wire both every time.
* **Trigger type left blank.** A blank-journey seed or AI-recovery scaffold starts the entry node with no `triggerType`; the pre-launch check rejects it and so does the server's launch validator. Pick an audience or event first.
* **Blank channel is SMS, on purpose.** Don't fight the default — fill `data.channel` only when you want a non-SMS channel. An explicitly-empty string, though, is a genuine misconfiguration and blocks launch.
* **Edge into a Wait node is not a cycle.** A legitimate "wait, then re-evaluate" loop is exempt from the cycle check; a loop through any other node type blocks save.
* **`(variables.journeyDefinition)`.** Legacy readers (older SDKs or workers) that scan `variables` see this key; the launch path also persists the graph to a dedicated column. Either way, the API shape you write is the same.
* **Conversions need a goal first.** A `POST .../goal/conversion` call on a campaign with no goal defined 404s; upsert the goal before wiring the event router to record conversions.

## See also

* [Send a campaign end-to-end](/guides/campaign-end-to-end) — blast/drip lifecycle, dry-run, and launch mechanics.
* [Build your first automation flow](/guides/build-first-flow) — the sibling visual canvas for inbound conversational flows.
* [Troubleshooting: campaign and journey enrollment errors](/troubleshooting/campaign-journey-errors) — the refusal codes a bad graph returns (`JOURNEY_DEFINITION_INVALID`, `JOURNEY_TRIGGER_MISSING`, `JOURNEY_TRIGGER_DISCONNECTED`, `CAMPAIGN_TYPE_NOT_ENROLLABLE`).
* [Campaigns API reference](/api-reference/endpoints/campaigns) — request/response shapes for every endpoint cited here.
