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. This page covers the journey graph itself. The Build your first automation 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:
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:
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:
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:- Journey Trigger (
journeyTrigger) — pickeventand point it atcontact.created, orsegmentfor a staged rollout. - Send Message — SMS welcome (
{{first_name}}personalization resolves per contact). - Wait / Delay — 1 day.
- Wait for Event — wait up to 24h for a
reply.receivedevent. - Condition — if the event branch fired, the contact is engaged; on the
timeoutbranch, fall to a Channel Preference node (whatsappprimary) or a plain Send Message on WhatsApp. - End Journey — terminal on both branches.
variables.journeyDefinition:
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 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.
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:
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.
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:
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
conditionorscoreCheckmissing itsnoedge, or awaitForEventmissingtimeout, 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.channelonly 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 scanvariablessee 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/conversioncall 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 — blast/drip lifecycle, dry-run, and launch mechanics.
- Build your first automation flow — the sibling visual canvas for inbound conversational flows.
- Troubleshooting: campaign and journey enrollment errors — the refusal codes a bad graph returns (
JOURNEY_DEFINITION_INVALID,JOURNEY_TRIGGER_MISSING,JOURNEY_TRIGGER_DISCONNECTED,CAMPAIGN_TYPE_NOT_ENROLLABLE). - Campaigns API reference — request/response shapes for every endpoint cited here.